import Foundation
import CryptoKit
import Darwin

public enum ReviewCoordinates {
    public static func display(_ point: Point2, panel: PixelRect, scale: Double) -> Point2 {
        Point2(x: (point.x - panel.x) * scale, y: (point.y - panel.y) * scale)
    }
    public static func source(_ point: Point2, panel: PixelRect, scale: Double) -> Point2 {
        Point2(x: panel.x + point.x / scale, y: panel.y + point.y / scale)
    }
}

public enum JointReviewState: String, Codable, CaseIterable, Sendable {
    case corrected, confirmed, occluded, uncertain
    public var label: String {
        switch self {
        case .corrected: "Corrected · 2D"
        case .confirmed: "Confirmed · visible"
        case .occluded: "Obscured · unobservable"
        case .uncertain: "Uncertain · excluded"
        }
    }
}
public struct JointEdit: Codable, Equatable, Sendable {
    public let point: Point2?
    public let state: JointReviewState
}
public struct JointChange: Codable, Sendable {
    public let joint: String
    public let before: JointEdit?
    public let after: JointEdit?
    public let timestamp: Date
    public let action: String
}

public struct ReviewFrame: Codable, Sendable, Identifiable {
    public let id: String
    public let pairID: String
    public let role: String
    public let rider: String
    public let file: String
    public let sourceHash: String
    public let panel: PixelRect
    public let raw3D: PoseResult?
    public let raw2D: PoseResult?
    public private(set) var edits: [String: JointEdit] = [:]
    public private(set) var history: [JointChange] = []
    public private(set) var retries: [DetectionRetry]?
    public var hasValidated3D: Bool { false }

    public init(id: String, pairID: String, role: String, rider: String, file: String, sourceHash: String,
                panel: PixelRect, raw3D: PoseResult?, raw2D: PoseResult?) {
        self.id = id; self.pairID = pairID; self.role = role; self.rider = rider; self.file = file
        self.sourceHash = sourceHash; self.panel = panel; self.raw3D = raw3D; self.raw2D = raw2D
    }
    public func rawPoint(_ joint: String, kind: String) -> Point2? {
        let pose = kind == "2D" ? raw2D : raw3D
        guard pose?.status == "needsReview", let result = pose?.joints.first(where: { $0.name == joint }),
              (result.confidence2D ?? 1) >= 0.2, contains(result.point) else { return nil }
        return result.point
    }
    public func reviewedPoint(_ joint: String) -> Point2? {
        guard let edit = edits[joint], edit.state == .corrected || edit.state == .confirmed else { return nil }
        return edit.point
    }
    public func displayPoint(_ joint: String, kind: String) -> Point2? {
        if let edit = edits[joint] { return edit.state == .occluded ? nil : edit.point }
        return seedPoint(joint, kind: kind)
    }
    public mutating func addRetry(_ retry: DetectionRetry) {
        retries = (retries ?? []) + [retry]
    }
    public func seedPose(kind: String) -> PoseResult? {
        for retry in (retries ?? []).reversed() {
            let pose = kind == "2D" ? retry.twoD : retry.threeD
            if pose?.status == "needsReview", pose?.candidateCount == 1, pose?.joints.isEmpty == false { return pose }
        }
        return kind == "2D" ? raw2D : raw3D
    }
    public func seedPoint(_ joint: String, kind: String) -> Point2? {
        let pose = seedPose(kind: kind)
        guard pose?.status == "needsReview", let result = pose?.joints.first(where: { $0.name == joint }),
              (result.confidence2D ?? 1) >= 0.2, contains(result.point) else { return nil }
        return result.point
    }
    public func contains(_ point: Point2) -> Bool {
        point.x.isFinite && point.y.isFinite && point.x >= panel.x && point.y >= panel.y &&
        point.x < panel.x + panel.width && point.y < panel.y + panel.height
    }
    public mutating func setJoint(_ name: String, point: Point2?, state: JointReviewState) throws {
        guard InspectionRenderer.jointID(name) != nil,
              point.map(contains) ?? (state == .occluded || state == .uncertain),
              state != .occluded || point == nil else { throw PoseError.invalidInput("Joint position must be inside the source photo; obscured joints have no reviewed position.") }
        let edit = JointEdit(point: point, state: state)
        guard edits[name] != edit else { return }
        history.append(JointChange(joint: name, before: edits[name], after: edit, timestamp: Date(), action: "review"))
        edits[name] = edit
    }
    public mutating func restore(_ name: String, to edit: JointEdit?) {
        history.append(JointChange(joint: name, before: edits[name], after: edit, timestamp: Date(), action: "undo"))
        edits[name] = edit
    }
}

public struct ReviewDocument: Codable, Sendable {
    public var schemaVersion = 3
    public let manifestHash: String
    public let reportHash: String
    public var revision = 0
    public var frames: [ReviewFrame]
    public var comparisonContexts: [PairContext] = []
    public var leanAnnotations: [String: LeanAnnotation] = [:]
    public var leanContexts: [String: LeanPairContext] = [:]
    public var downstreamEvidenceStale: Bool { revision > 0 }
    public init(manifestHash: String, reportHash: String, frames: [ReviewFrame]) {
        self.manifestHash = manifestHash; self.reportHash = reportHash; self.frames = frames
    }
    private enum CodingKeys: String, CodingKey { case schemaVersion, manifestHash, reportHash, revision, frames, comparisonContexts, leanAnnotations, leanContexts }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        schemaVersion = try c.decode(Int.self, forKey: .schemaVersion)
        manifestHash = try c.decode(String.self, forKey: .manifestHash)
        reportHash = try c.decode(String.self, forKey: .reportHash)
        revision = try c.decode(Int.self, forKey: .revision)
        frames = try c.decode([ReviewFrame].self, forKey: .frames)
        comparisonContexts = try c.decodeIfPresent([PairContext].self, forKey: .comparisonContexts) ?? []
        leanAnnotations = try c.decodeIfPresent([String: LeanAnnotation].self, forKey: .leanAnnotations) ?? [:]
        leanContexts = try c.decodeIfPresent([String: LeanPairContext].self, forKey: .leanContexts) ?? [:]
    }
}

public enum ReviewStore {
    public static func digest(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
    public static func assetURL(file: String, manifestURL: URL) throws -> URL {
        let root = manifestURL.deletingLastPathComponent().resolvingSymlinksInPath()
        let url = root.appendingPathComponent(file).resolvingSymlinksInPath()
        guard !file.hasPrefix("/"), url.path.hasPrefix(root.path + "/") else { throw PoseError.invalidInput("Source path escapes the session folder.") }
        return url
    }
    public static func make(manifestURL: URL, reportURL: URL) throws -> ReviewDocument {
        let manifest = try SessionManifest.load(url: manifestURL)
        let reportData = try Data(contentsOf: reportURL)
        let report = try JSONDecoder().decode(FeasibilityReport.self, from: reportData)
        let manifestHash = try digest(Data(contentsOf: manifestURL))
        guard report.schemaVersion == 1, report.manifestHash == manifestHash else { throw PoseError.invalidInput("This results file belongs to a different manifest revision.") }
        var frames: [ReviewFrame] = []
        for pair in manifest.pairs {
            for rider in pair.riders {
                let rows = report.crops.filter { $0.pairID == pair.id && $0.role == rider.role }
                guard rows.count == 1, let row = rows.first, let hash = row.sourceHash else { throw PoseError.invalidInput("Missing or duplicate source evidence for \(pair.id) \(rider.role).") }
                let image = try ImageInput.load(url: manifest.assetURL(for: pair, rider: rider, manifestURL: manifestURL))
                guard image.sha256 == hash else { throw PoseError.invalidInput("Source image changed: \(pair.id) \(rider.role).") }
                try rider.panel.validate(imageWidth: image.image.width, imageHeight: image.image.height)
                frames.append(ReviewFrame(id: "\(pair.id)-\(rider.role)", pairID: pair.id, role: rider.role, rider: rider.rider,
                                          file: rider.file ?? pair.file, sourceHash: hash, panel: rider.panel, raw3D: row.threeD, raw2D: row.twoD))
            }
        }
        return ReviewDocument(manifestHash: manifestHash, reportHash: digest(reportData), frames: frames)
    }
    public static func load(from url: URL) throws -> ReviewDocument {
        let document = try JSONDecoder().decode(ReviewDocument.self, from: Data(contentsOf: url))
        guard (1...3).contains(document.schemaVersion), document.revision >= 0,
              Set(document.frames.map(\.id)).count == document.frames.count else { throw PoseError.invalidInput("Unsupported or invalid review document; original file preserved.") }
        try PairReview.validate(document)
        try LeanReview.validate(document)
        for frame in document.frames {
            for (name, edit) in frame.edits {
                guard InspectionRenderer.jointID(name) != nil, edit.point.map(frame.contains) ?? (edit.state == .occluded || edit.state == .uncertain),
                      edit.state != .occluded || edit.point == nil else { throw PoseError.invalidInput("Invalid saved joint correction.") }
            }
        }
        return document
    }
    public static func validate(_ saved: ReviewDocument, against baseline: ReviewDocument) throws {
        try PairReview.validate(saved)
        try LeanReview.validate(saved)
        guard saved.manifestHash == baseline.manifestHash, saved.reportHash == baseline.reportHash,
              saved.frames.count == baseline.frames.count else { throw PoseError.invalidInput("Saved corrections belong to different source evidence.") }
        let encoder = JSONEncoder(); encoder.outputFormatting = [.sortedKeys]
        for frame in saved.frames {
            guard let raw = baseline.frames.first(where: { $0.id == frame.id }),
                  frame.pairID == raw.pairID, frame.role == raw.role, frame.rider == raw.rider,
                  frame.file == raw.file, frame.sourceHash == raw.sourceHash, frame.panel == raw.panel,
                  try encoder.encode(frame.raw3D) == encoder.encode(raw.raw3D),
                  try encoder.encode(frame.raw2D) == encoder.encode(raw.raw2D) else {
                throw PoseError.invalidInput("Saved frame identity or raw evidence differs from the original analysis.")
            }
        }
    }
    public static func legacyBackupURL(for url: URL, schema: Int = 1) -> URL {
        URL(fileURLWithPath: url.path + ".schema\(schema)-backup")
    }
    private static func preserveLegacy(_ bytes: Data, at url: URL, schema: Int) throws {
        let backup = legacyBackupURL(for: url, schema: schema)
        let fd = open(backup.path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, S_IRUSR | S_IWUSR)
        if fd < 0 {
            guard errno == EEXIST else { throw PoseError.invalidInput("Cannot create schema-\(schema) backup; review preserved.") }
            let existing = open(backup.path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK)
            guard existing >= 0 else { throw PoseError.invalidInput("Cannot safely read schema-\(schema) backup.") }
            let handle = FileHandle(fileDescriptor: existing, closeOnDealloc: true)
            var info = stat()
            guard fstat(existing, &info) == 0, info.st_mode & S_IFMT == S_IFREG,
                  try handle.readToEnd() == bytes else { throw PoseError.invalidInput("Schema-\(schema) backup differs or is not a regular file; both files preserved.") }
            return
        }
        let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
        do { try handle.write(contentsOf: bytes); try handle.synchronize() }
        catch { try? handle.close(); try? FileManager.default.removeItem(at: backup); throw error }
    }
    public static func save(_ document: ReviewDocument, to url: URL, expectedRevision: Int?) throws {
        guard (1...3).contains(document.schemaVersion), document.revision >= 0 else { throw PoseError.invalidInput("Unsupported review schema.") }
        var upgraded = document; upgraded.schemaVersion = 3
        try PairReview.validate(upgraded)
        try LeanReview.validate(upgraded)
        // Serialize readers of the revision with the atomic replacement, across app instances.
        let descriptor = open(url.path + ".lock", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR)
        guard descriptor >= 0 else { throw PoseError.invalidInput("Cannot lock review file for saving.") }
        defer { close(descriptor) }
        guard flock(descriptor, LOCK_EX) == 0 else { throw PoseError.invalidInput("Cannot acquire review save lock.") }
        defer { flock(descriptor, LOCK_UN) }
        if FileManager.default.fileExists(atPath: url.path) {
            let current = try load(from: url)
            guard current.revision == expectedRevision, current.manifestHash == document.manifestHash,
                  current.reportHash == document.reportHash else { throw PoseError.invalidInput("A newer or different review is already saved. Reopen it before editing; your unsaved changes remain in this window.") }
            try validate(upgraded, against: current)
            if current.schemaVersion < 3 { try preserveLegacy(Data(contentsOf: url), at: url, schema: current.schemaVersion) }
        } else if expectedRevision != nil { throw PoseError.invalidInput("The review file was moved or removed. Reopen the session before saving.") }
        let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        try encoder.encode(upgraded).write(to: url, options: .atomic)
    }
}
