import AppKit
import SwiftUI
import PoseCore

// Presentation only: source names, roles, and serialized evidence remain immutable.
enum RiderLabels {
    static let rider = "Rider"
    static let reference = "Reference rider"
}

extension ReviewFrame {
    var riderDisplayName: String { role == "reference" ? RiderLabels.reference : RiderLabels.rider }
}

struct LeanEditTarget: Equatable {
    let session: UUID
    let canvas: UUID
    let frameID: String
}
enum LeanHandle: String, CaseIterable {
    case wheelA, wheelB, referenceA, referenceB
    var isReference: Bool { self == .referenceA || self == .referenceB }
    var isStart: Bool { self == .wheelA || self == .referenceA }
    var label: String {
        switch self {
        case .wheelA: "Wheel endpoint 1"
        case .wheelB: "Wheel endpoint 2"
        case .referenceA: "Reference endpoint 1"
        case .referenceB: "Reference endpoint 2"
        }
    }
}
extension LeanAnnotation {
    func point(_ handle: LeanHandle) -> Point2? {
        let line = handle.isReference ? reference : wheel
        return handle.isStart ? line?.a : line?.b
    }
    mutating func move(_ handle: LeanHandle, to point: Point2) {
        if handle.isReference {
            if handle.isStart { reference?.a = point } else { reference?.b = point }
            referenceReviewed = false
        } else {
            if handle.isStart { wheel?.a = point } else { wheel?.b = point }
        }
        state = .draft
    }
}
enum ReviewUndo {
    case joint(String, String, JointEdit?)
    case lean(String, LeanAnnotation?, LeanPairContext?)
    case leanContext(String, LeanPairContext?)
    var frameID: String {
        switch self { case .joint(let id, _, _), .lean(let id, _, _), .leanContext(let id, _): id }
    }
}

struct ReviewPreset: Decodable, Identifiable, Sendable {
    let title: String
    let manifest: String
    let results: String
    var id: String { manifest }
}

@MainActor
final class ReviewerModel: ObservableObject {
    @Published var isDetecting = false
    @Published var presets: [ReviewPreset] = []
    @Published private(set) var isDiscovering = false
    @Published private(set) var discoveryStatus = ""
    @Published private(set) var discoveryFailures: [String] = []
    private var discoveryStarted = false

    @discardableResult
    func discoverSessions(dataDirectory: URL? = nil,
                          analyze: (@Sendable (URL, URL) throws -> Void)? = nil) -> Task<Void, Never>? {
        guard !isDiscovering else { return nil }
        let directory: URL
        if let dataDirectory { directory = dataDirectory }
        else if let config = Bundle.main.url(forResource: "data-directory", withExtension: "txt"),
                let path = try? String(contentsOf: config, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
                !path.isEmpty { directory = URL(fileURLWithPath: path) }
        else { discoveryStatus = "Automatic discovery needs a configured data folder."; return nil }
        discoveryStarted = true; isDiscovering = true; discoveryFailures = []
        discoveryStatus = "Checking photo folders…"
        let known = Dictionary(presets.map { (URL(fileURLWithPath: $0.manifest), URL(fileURLWithPath: $0.results)) }, uniquingKeysWith: { first, _ in first })
        let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/pose-batch")
        let analysis: @Sendable (URL, URL) throws -> Void = analyze ?? { manifest, output in
            try SessionAnalysisWorker.run(manifest: manifest, output: output, helper: helper)
        }
        return Task { [weak self] in
            guard let model = self else { return }
            let result = await Task.detached(priority: .utility) {
                SessionDiscovery.run(dataDirectory: directory, knownResults: known, progress: { name in
                    Task { @MainActor [weak model] in
                        guard model?.isDiscovering == true else { return }
                        model?.discoveryStatus = "Checking / analyzing \(name)…"
                    }
                }, analyze: analysis)
            }.value
            guard let self else { return }
            for session in result.sessions {
                let preset = ReviewPreset(title: session.title, manifest: session.manifestURL.path, results: session.resultsURL.path)
                if let index = self.presets.firstIndex(where: { $0.id == preset.id }) {
                    // Preserve familiar menu titles for the bundled legacy sessions.
                    let old = self.presets[index]
                    self.presets[index] = ReviewPreset(title: old.title, manifest: preset.manifest, results: preset.results)
                } else { self.presets.append(preset) }
            }
            self.discoveryFailures = result.failures.map { "\($0.folderURL.lastPathComponent): \($0.message)" }
            let count = result.sessions.filter(\.wasAnalyzed).count
            self.discoveryStatus = "\(result.sessions.count) sessions ready · \(count) newly analyzed" + (result.failures.isEmpty ? "" : " · Some folders need attention")
            self.isDiscovering = false
            if self.document == nil, !self.isDetecting, let preset = self.presets.first { self.openPreset(preset) }
        }
    }

    func discoverSessionsOnLaunch() {
        guard !discoveryStarted else { return }
        discoverSessions()
    }

    struct Snapshot { let document: ReviewDocument; let report: PairReport? }
    @Published private(set) var snapshot: Snapshot?
    var document: ReviewDocument? { snapshot?.document }
    var report: PairReport? { snapshot?.report }
    @Published var compare = false
    @Published var showMarkers = true
    @Published var inspectorVisible = true
    @Published var images: [String: CGImage] = [:]
    @Published var canvasGeneration = UUID()
    @Published var previewTarget: ReviewEditTarget?
    @Published var highlightedJoints: Set<String> = []
    @Published private(set) var savedRevision: Int?
    private(set) var sessionID = UUID()
    struct Presentation {
        var kind = "3D"; var zoom = 1.0; var showRaw = false
        var autoFrame = true
        var framingRequest = 0
    }
    private(set) var framingRegions: [String: PixelRect] = [:]
    var viewportCenters: [String: Point2] = [:]
    private(set) var singlePhotoCoaching: [String: SinglePhotoCoaching] = [:]
    private(set) var singlePhotoCoachingError: String?
    var currentSinglePhotoCoaching: SinglePhotoCoaching? {
        guard let frame, frame.role == "subject", pair?.referenceID == nil else { return nil }
        return singlePhotoCoaching[frame.id]
    }
    private(set) var coachingChecklists: [String: CoachingChecklistReview] = [:]
    private(set) var coachingChecklistError: String?
    private var checklistSubjectID: String? { pair?.subjectID }
    var checklistDirectionConflict: Bool {
        guard let id = checklistSubjectID, let review = coachingChecklists[id],
              let direction = report?.effectiveCornerDirection, direction != .unknown,
              review.cornerDirection != .unknown else { return false }
        return direction != review.cornerDirection
    }
    var currentCoachingChecklist: CoachingChecklistReview? {
        guard !checklistDirectionConflict, let id = checklistSubjectID else { return nil }
        return coachingChecklists[id]
    }
    @Published var presentation: [String: Presentation] = [:]
    var pairs: [ReviewPair] { (try? PairReview.resolve(document?.frames ?? [])) ?? [] }
    var pair: ReviewPair? { pairs.first { $0.id == frame?.pairID } }
    var visibleIDs: [String] { compare ? (pair?.frameIDs ?? []) : (frame.map { [$0.id] } ?? []) }
    var isUnsaved: Bool { document.map { savedRevision != $0.revision } ?? false }
    func frame(_ id: String) -> ReviewFrame? { document?.frames.first { $0.id == id } }
    func settings(_ id: String) -> Presentation { presentation[id] ?? Presentation() }
    func setSettings(_ id: String, _ value: Presentation) {
        if leanPreviewTarget != nil { cancelInteraction() }
        presentation[id] = value
    }
    var kind: String {
        get { frame.map { settings($0.id).kind } ?? "3D" }
        set { if let frame { var value = settings(frame.id); value.kind = newValue; setSettings(frame.id, value) } }
    }
    @Published private(set) var activeFrameID: String?
    var index: Int { document?.frames.firstIndex { $0.id == activeFrameID } ?? 0 }
    @Published var selected = "left_elbow"
    @Published var placing = false
    @Published var message = "Open a session to review its joints."
    @Published var error: String?
    @Published var undoStack: [ReviewUndo] = []
    @Published private(set) var leanMode = false
    @Published var showLean = true
    @Published var selectedLeanHandle: LeanHandle = .wheelA
    @Published var leanPreviewTarget: LeanEditTarget?
    @Published var leanPlacement: LeanHandle?
    @Published var leanPlacementStart: Point2?
    @Published private(set) var leanProposals: [String: LeanAnnotation] = [:]
    var hasPreview: Bool { previewTarget != nil || leanPreviewTarget != nil || leanPlacementStart != nil }
    private var manifestURL: URL?
    private var reviewURL: URL?
    var frame: ReviewFrame? { activeFrameID.flatMap { frame($0) } }

    func loadPresets() {
        if let url = Bundle.main.url(forResource: "launch", withExtension: "json"),
           let data = try? Data(contentsOf: url), let values = try? JSONDecoder().decode([ReviewPreset].self, from: data) {
            presets = values
        }
    }
    func openPreset(_ preset: ReviewPreset) {
        guard !isDetecting, save() else { return }
        load(manifest: URL(fileURLWithPath: preset.manifest), results: URL(fileURLWithPath: preset.results))
    }
    func openSession() {
        guard !isDetecting, save() else { return }
        let manifestPanel = NSOpenPanel()
        manifestPanel.message = "Choose the session.json used for analysis"
        manifestPanel.allowedContentTypes = [.json]
        guard manifestPanel.runModal() == .OK, let manifest = manifestPanel.url else { return }
        let resultsPanel = NSOpenPanel()
        resultsPanel.message = "Choose that run’s results.json"
        resultsPanel.allowedContentTypes = [.json]
        guard resultsPanel.runModal() == .OK, let results = resultsPanel.url else { return }
        load(manifest: manifest, results: results)
    }
    func load(manifest: URL, results: URL) {
        guard !isDetecting, save() else { return }
        do {
            let baseline = try ReviewStore.make(manifestURL: manifest, reportURL: results)
            let destination = manifest.deletingLastPathComponent().appendingPathComponent("joint-review-\(baseline.reportHash.prefix(12)).json")
            let exists = FileManager.default.fileExists(atPath: destination.path)
            let loaded = exists ? try ReviewStore.load(from: destination) : baseline
            try ReviewStore.validate(loaded, against: baseline)
            let proposalsURL = manifest.deletingLastPathComponent().appendingPathComponent("lean-proposals.json")
            var proposals: [String: LeanAnnotation] = [:]
            if FileManager.default.fileExists(atPath: proposalsURL.path) {
                for var proposal in try JSONDecoder().decode([LeanAnnotation].self, from: Data(contentsOf: proposalsURL)) {
                    guard let f = loaded.frames.first(where: { $0.id == proposal.frameID }), proposals[f.id] == nil else {
                        throw PoseError.invalidInput("Invalid or duplicate lean proposal frame.")
                    }
                    proposal.state = .draft; proposal.referenceReviewed = false
                    try proposal.validate(frame: f); proposals[f.id] = proposal
                }
            }
            guard let first = loaded.frames.first(where: { loaded.leanAnnotations[$0.id] != nil || proposals[$0.id] != nil }) ?? loaded.frames.first else { throw PoseError.invalidInput("No frames to review.") }
            let loadedImages = try loadImages([first.id], document: loaded, manifest: manifest)
            let next = try makeSnapshot(loaded, pairID: first.pairID)
            manifestURL = manifest; reviewURL = destination; savedRevision = exists ? loaded.revision : nil
            cancelInteraction(); sessionID = UUID(); compare = false; images = loadedImages
            let framingURL = manifest.deletingLastPathComponent().appendingPathComponent("photo-framing.json")
            let hasFraming = FileManager.default.fileExists(atPath: framingURL.path)
            let regions = try? PhotoFramingRegion.load(from: framingURL, frames: loaded.frames)
            framingRegions = regions ?? [:]; viewportCenters = [:]
            let coachingURL = manifest.deletingLastPathComponent().appendingPathComponent("single-photo-coaching.json")
            singlePhotoCoaching = [:]; singlePhotoCoachingError = nil
            if FileManager.default.fileExists(atPath: coachingURL.path) {
                do { singlePhotoCoaching = try SinglePhotoCoaching.load(from: coachingURL, frames: loaded.frames) }
                catch { singlePhotoCoachingError = "Saved photo notes could not be loaded or did not match this session. Coaching is withheld until the notes are corrected." }
            }
            coachingChecklists = [:]; coachingChecklistError = nil
            let checklistURL = manifest.deletingLastPathComponent().appendingPathComponent("coaching-checklist.json")
            if FileManager.default.fileExists(atPath: checklistURL.path) {
                do { coachingChecklists = try CoachingChecklist.load(from: checklistURL, frames: loaded.frames) }
                catch { coachingChecklistError = "Saved checklist findings do not match this session or catalog. Topics need review again." }
            }
            activeFrameID = first.id; snapshot = next; undoStack = []; presentation = [:]
            leanProposals = proposals; leanMode = !proposals.isEmpty || !loaded.leanAnnotations.isEmpty; showLean = true
            if leanMode { showMarkers = false }
            message = exists ? "Saved review reopened · revision \(loaded.revision)" : "Raw detections loaded · nothing reviewed yet"
            if hasFraming && regions == nil { message += " · Framing unavailable; showing full photos" }
        } catch { self.error = error.localizedDescription }
    }
    private func loadImage(_ frame: ReviewFrame, manifest: URL) throws -> CGImage {
        do {
            let input = try ImageInput.load(url: ReviewStore.assetURL(file: frame.file, manifestURL: manifest))
            guard input.sha256 == frame.sourceHash else { throw PoseError.invalidInput("Source hash changed; reanalyze before applying corrections.") }
            return try input.cropped(to: frame.panel)
        } catch {
            throw PoseError.invalidInput("Cannot load \(frame.pairID) \(frame.role) photo (\(frame.file)): \(error.localizedDescription) Previous selection preserved.")
        }
    }

    private func loadImages(_ ids: [String], document: ReviewDocument, manifest: URL) throws -> [String: CGImage] {
        var result: [String: CGImage] = [:]
        for id in ids {
            guard let frame = document.frames.first(where: { $0.id == id }) else { throw PoseError.invalidInput("Missing frame.") }
            result[id] = try loadImage(frame, manifest: manifest)
        }
        return result
    }
    private func makeSnapshot(_ doc: ReviewDocument, pairID: String?) throws -> Snapshot {
        let pair = try PairReview.resolve(doc.frames).first { $0.id == pairID && $0.referenceID != nil }
        return Snapshot(document: doc, report: try pair.map { try PairAnalysis.analyze(doc, pair: $0) })
    }
    private func publish(_ doc: ReviewDocument) throws {
        snapshot = try makeSnapshot(doc, pairID: pair?.id)
    }
    func cancelInteraction() {
        previewTarget = nil; placing = false; canvasGeneration = UUID(); highlightedJoints = []
        leanPreviewTarget = nil; leanPlacement = nil; leanPlacementStart = nil
    }
    func activate(_ id: String) {
        guard visibleIDs.contains(id), frame(id) != nil, id != activeFrameID else { return }
        cancelInteraction(); activeFrameID = id
    }
    func selectFrame(_ newIndex: Int) {
        guard !isDetecting, let doc = document, doc.frames.indices.contains(newIndex), let manifestURL, save() else { return }
        let target = doc.frames[newIndex]
        let selectedPair = pairs.first { $0.id == target.pairID }
        let paired = compare && selectedPair?.referenceID != nil
        do {
            let loadedImages = try loadImages(paired ? selectedPair!.frameIDs : [target.id], document: doc, manifest: manifestURL)
            let next = try makeSnapshot(doc, pairID: target.pairID)
            cancelInteraction(); compare = paired; images = loadedImages; activeFrameID = target.id; snapshot = next
        } catch { self.error = error.localizedDescription }
    }
    func selectPair(_ id: String) {
        guard let target = pairs.first(where: { $0.id == id }), let i = document?.frames.firstIndex(where: { $0.id == target.subjectID }) else { return }
        selectFrame(i)
    }
    func setCompare(_ enabled: Bool) {
        guard enabled != compare, !isDetecting, let doc = document, let frame, let manifestURL, save() else { return }
        guard !enabled || pair?.referenceID != nil else { message = "No reference rider exists for this photograph. Single mode remains available."; return }
        do {
            let loadedImages = try loadImages(enabled ? pair!.frameIDs : [frame.id], document: doc, manifest: manifestURL)
            let next = try makeSnapshot(doc, pairID: frame.pairID)
            cancelInteraction(); images = loadedImages; compare = enabled; snapshot = next
        } catch { self.error = error.localizedDescription }
    }
    func target(frameID: String, joint: String) -> ReviewEditTarget {
        ReviewEditTarget(session: sessionID, canvas: canvasGeneration, frameID: frameID, joint: joint)
    }
    func valid(_ target: ReviewEditTarget) -> Bool {
        document.flatMap { target.index(in: $0, session: sessionID, canvas: canvasGeneration, visibleIDs: visibleIDs) } != nil
    }
    func edit(point: Point2?, state: JointReviewState, target: ReviewEditTarget? = nil) {
        guard var doc = document, let frame else { return }
        let target = target ?? self.target(frameID: frame.id, joint: selected)
        guard let i = target.index(in: doc, session: sessionID, canvas: canvasGeneration, visibleIDs: visibleIDs) else { return }
        do {
            let before = doc.frames[i].edits[target.joint]
            try doc.frames[i].setJoint(target.joint, point: point, state: state)
            guard before != doc.frames[i].edits[target.joint] else { return }
            doc.schemaVersion = 3; doc.revision += 1
            try publish(doc)
            undoStack.append(.joint(target.frameID, target.joint, before)); placing = false; previewTarget = nil
            _ = save()
        } catch { self.error = error.localizedDescription }
    }
    func setState(_ state: JointReviewState) {
        edit(point: state == .occluded ? nil : frame?.displayPoint(selected, kind: kind), state: state)
    }
    func nudge(dx: Double, dy: Double, frameID: String) {
        guard frame?.id == frameID, let p = frame(frameID)?.displayPoint(selected, kind: settings(frameID).kind) else { return }
        edit(point: Point2(x: p.x + dx, y: p.y + dy), state: .corrected, target: target(frameID: frameID, joint: selected))
    }
    func undo() {
        guard !isDetecting, let last = undoStack.last, var doc = document,
              let i = doc.frames.firstIndex(where: { $0.id == last.frameID }), let manifestURL else { return }
        do {
            let nextPair = pairs.first { $0.id == doc.frames[i].pairID }
            let paired = compare && nextPair?.referenceID != nil
            let loadedImages = try loadImages(paired ? nextPair!.frameIDs : [last.frameID], document: doc, manifest: manifestURL)
            switch last {
            case .joint(_, let joint, let before): doc.frames[i].restore(joint, to: before); selected = joint
            case .lean(let id, let before, let context):
                doc.leanAnnotations[id] = before; doc.leanContexts[doc.frames[i].pairID] = context
            case .leanContext(_, let before): doc.leanContexts[doc.frames[i].pairID] = before
            }
            doc.schemaVersion = 3; doc.revision += 1
            let next = try makeSnapshot(doc, pairID: nextPair?.id)
            cancelInteraction(); images = loadedImages; compare = paired; activeFrameID = last.frameID; snapshot = next
            undoStack.removeLast(); _ = save()
        } catch { self.error = error.localizedDescription }
    }
    func updateContext(_ context: PairContext) {
        guard var doc = document, context.pairID == pair?.id else { return }
        var context = context
        if report?.context.cornerDirection != context.cornerDirection { context.kneePlacement = nil }
        if doc.comparisonContexts.first(where: { $0.pairID == context.pairID }) == context { return }
        doc.comparisonContexts.removeAll { $0.pairID == context.pairID }; doc.comparisonContexts.append(context)
        doc.schemaVersion = 3; doc.revision += 1
        do { try publish(doc); _ = save() } catch { self.error = error.localizedDescription }
    }
    func highlight(_ feature: PairFeature) {
        cancelInteraction(); selected = feature.joints[1]; highlightedJoints = Set(feature.joints); showMarkers = true
    }
    func retryDetection(fullPhoto: Bool) {
        guard !isDetecting, let frame, let manifestURL, let reviewURL, save() else { return }
        do {
            let manifest = try SessionManifest.load(url: manifestURL)
            guard let rider = manifest.pairs.first(where: { $0.id == frame.pairID })?.riders.first(where: { $0.role == frame.role }) else { return }
            let request = RetryRequest(source: try ReviewStore.assetURL(file: frame.file, manifestURL: manifestURL),
                                       sourceHash: frame.sourceHash, crop: fullPhoto ? frame.panel : rider.crop)
            let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/pose-batch")
            let directory = reviewURL.deletingLastPathComponent().appendingPathComponent("detection-retries/" + UUID().uuidString)
            let frameID = frame.id
            let retrySession = sessionID
            isDetecting = true; message = "Detecting again… Your reviewed joints will be preserved."
            Task {
                defer { isDetecting = false }
                do {
                    let retry = try await Task.detached {
                        try RetryWorker.run(request: request, helper: helper, directory: directory)
                    }.value
                    try acceptRetry(retry, frameID: frameID, session: retrySession)
                } catch { self.error = error.localizedDescription; message = "Retry failed · existing joints preserved" }
            }
        } catch { self.error = error.localizedDescription }
    }
    func acceptRetry(_ retry: DetectionRetry, frameID: String, session: UUID) throws {
        guard session == sessionID, var doc = document,
              let target = doc.frames.firstIndex(where: { $0.id == frameID }) else { return }
        doc.frames[target].addRetry(retry)
        doc.schemaVersion = 3; doc.revision += 1; try publish(doc)
        let usable = [retry.twoD, retry.threeD].compactMap { $0 }.filter {
            $0.status == "needsReview" && $0.candidateCount == 1 && !$0.joints.isEmpty
        }
        if save() {
            message = usable.isEmpty ? "No usable pose found. Existing points preserved · automatically saved." : "Retry automatically saved · new points need review; your corrections are preserved."
            var value = settings(frameID)
            if !usable.contains(where: { $0.kind == value.kind }), let first = usable.first {
                value.kind = first.kind; setSettings(frameID, value)
            }
        }
    }
    @discardableResult func save() -> Bool {
        guard let document, let reviewURL else { return true }
        if savedRevision == document.revision { return true }
        do {
            try ReviewStore.save(document, to: reviewURL, expectedRevision: savedRevision)
            savedRevision = document.revision
            message = "Automatically saved locally · revision \(document.revision) · corrections are 2D only"
            return true
        } catch { self.error = error.localizedDescription; message = "Unsaved changes — save failed"; return false }
    }

    func setLeanMode(_ value: Bool) { cancelInteraction(); leanMode = value; if value { showLean = true } }
    func lean(_ id: String) -> LeanAnnotation? { document?.leanAnnotations[id] ?? leanProposals[id] }
    func leanTarget(frameID: String) -> LeanEditTarget { LeanEditTarget(session: sessionID, canvas: canvasGeneration, frameID: frameID) }
    func validLean(_ target: LeanEditTarget) -> Bool {
        leanMode && target.session == sessionID && target.canvas == canvasGeneration && visibleIDs.contains(target.frameID)
    }
    func commitLean(_ annotation: LeanAnnotation, target: LeanEditTarget) {
        guard validLean(target), var doc = document, let f = frame(target.frameID), annotation.frameID == f.id else { return }
        do {
            try annotation.validate(frame: f)
            let before = doc.leanAnnotations[f.id]
            guard before != annotation else { return }
            let context = doc.leanContexts[f.pairID]
            doc.leanAnnotations[f.id] = annotation
            if context != nil {
                var reset = LeanPairContext(); reset.similarSpeedReported = context?.similarSpeedReported ?? false
                doc.leanContexts[f.pairID] = reset
            }
            doc.schemaVersion = 3; doc.revision += 1
            try publish(doc)
            undoStack.append(.lean(f.id, before, context)); leanPreviewTarget = nil
            _ = save()
        } catch { self.error = error.localizedDescription }
    }
    func changeLean(_ change: (inout LeanAnnotation) -> Void) {
        guard let frame else { return }
        var a = lean(frame.id) ?? LeanAnnotation(frame: frame)
        change(&a); commitLean(a, target: leanTarget(frameID: frame.id))
    }
    func beginLeanPlacement(reference: Bool) {
        cancelInteraction(); showLean = true
        leanPlacement = reference ? .referenceA : .wheelA
    }
    func placeLean(_ point: Point2, frameID: String) {
        guard let handle = leanPlacement, frame?.id == frameID else { return }
        guard let first = leanPlacementStart else { leanPlacementStart = point; return }
        changeLean { a in
            let line = LeanLine(a: first, b: point, uncertainty: 3)
            if handle.isReference { a.reference = line; a.referenceReviewed = false } else { a.wheel = line }
            a.state = .draft
        }
        leanPlacement = nil; leanPlacementStart = nil
    }
    func nudgeLean(dx: Double, dy: Double) {
        guard let frame, let point = lean(frame.id)?.point(selectedLeanHandle) else { return }
        let handle = selectedLeanHandle
        changeLean { $0.move(handle, to: Point2(x: point.x + dx, y: point.y + dy)) }
    }
    func updateLeanContext(_ value: LeanPairContext, approve: Bool = false) {
        guard var doc = document, let pair, pair.referenceID != nil, let frame else { return }
        do {
            var value = value
            value.approvalDigest = approve ? try LeanAnalysis.evidenceDigest(doc, pair: pair) : nil
            let before = doc.leanContexts[pair.id]
            guard before != value else { return }
            doc.leanContexts[pair.id] = value
            doc.schemaVersion = 3
            try LeanReview.validate(doc)
            doc.revision += 1; try publish(doc)
            undoStack.append(.leanContext(frame.id, before)); _ = save()
        } catch { self.error = error.localizedDescription }
    }
}
