import Foundation

public enum MeasurementUnavailable: String, Codable, Sendable { case unreviewedJoint, invalidGeometry }
public struct PairMeasurement: Codable, Equatable, Sendable {
    public let frameID: String
    public let angle: Double?
    public let missingJoints: [String]
    public let unavailable: MeasurementUnavailable?
}
public struct PairObservation: Codable, Equatable, Sendable, Identifiable {
    public let feature: PairFeature
    public let subject: PairMeasurement
    public let reference: PairMeasurement
    public let delta: Double?
    public let comparisonUnavailable: String?
    public var id: String { feature.rawValue }
}
public struct PairReport: Equatable, Sendable {
    public let evidenceHash: String
    public let pair: ReviewPair
    public let revision: Int
    public let context: PairContext
    public let analyzerVersion: String
    public let catalogVersion: String
    public let observations: [PairObservation]
    public let focus: [FocusPoint]
    public let activeRuleCount: Int
    public var unobservableKnees: Set<PairFeature> = []
    public static let limitations = "Independent photo views: perspective, bike and rider proportions can affect 2D angles. No hidden depth, foot pressure, peg or seat contact, grip force, or causal riding fault is established. Coach review and utility validation remain pending."
}
public enum PairAnalysis {
    public static let version = "1"
    public static func includedAngle(_ a: Point2, _ vertex: Point2, _ b: Point2) -> Double? {
        guard [a.x, a.y, vertex.x, vertex.y, b.x, b.y].allSatisfy(\.isFinite) else { return nil }
        let ax = a.x - vertex.x, ay = a.y - vertex.y, bx = b.x - vertex.x, by = b.y - vertex.y
        let al = hypot(ax, ay), bl = hypot(bx, by)
        guard al.isFinite, bl.isFinite, al > 1e-9, bl > 1e-9 else { return nil }
        let dot = (ax / al) * (bx / bl) + (ay / al) * (by / bl)
        return acos(min(1, max(-1, dot))) * 180 / .pi
    }
    public static func measurement(_ frame: ReviewFrame, feature: PairFeature) -> PairMeasurement {
        let missing = feature.joints.filter { frame.reviewedPoint($0) == nil }
        guard missing.isEmpty else { return PairMeasurement(frameID: frame.id, angle: nil, missingJoints: missing, unavailable: .unreviewedJoint) }
        let points = feature.joints.compactMap { frame.reviewedPoint($0) }
        let angle = includedAngle(points[0], points[1], points[2])
        return PairMeasurement(frameID: frame.id, angle: angle, missingJoints: [], unavailable: angle == nil ? .invalidGeometry : nil)
    }
    public static func analyze(_ document: ReviewDocument, pair: ReviewPair, catalog: RuleCatalog = .production) throws -> PairReport {
        try PairReview.validate(document)
        guard try PairReview.resolve(document.frames).contains(pair), let referenceID = pair.referenceID,
              let subject = document.frames.first(where: { $0.id == pair.subjectID }),
              let reference = document.frames.first(where: { $0.id == referenceID }) else { throw PoseError.invalidInput("No complete pair to analyze.") }
        let context = try document.comparisonContexts.first { $0.pairID == pair.id } ?? PairContext(document: document, pair: pair)
        let observations = PairFeature.allCases.map { feature in
            let s = measurement(subject, feature: feature), r = measurement(reference, feature: feature)
            let reason: String?
            if context.comparability[feature, default: .unreviewed] != .comparable { reason = "Review whether these feature views are comparable." }
            else if s.angle == nil || r.angle == nil { reason = "Required visible joints are missing, excluded, or geometrically invalid." }
            else { reason = nil }
            return PairObservation(feature: feature, subject: s, reference: r,
                                   delta: reason == nil ? s.angle! - r.angle! : nil, comparisonUnavailable: reason)
        }
        return PairReport(evidenceHash: context.evidenceHash, pair: pair, revision: document.revision, context: context,
                          analyzerVersion: version, catalogVersion: catalog.version, observations: observations,
                          focus: catalog.evaluate(observations, context: context), activeRuleCount: catalog.rules.filter(\.isSupported).count,
                          unobservableKnees: Set([PairFeature.leftKnee, .rightKnee].filter { feature in
                              [subject, reference].contains { $0.edits[feature.joints[1]]?.state == .occluded }
                          }))
    }
}
