//
// ExerciseFigureView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//

import SwiftUI
#if canImport(AppKit)
import AppKit
#endif

/// The looping animated stick-figure for the run screen's bottom half, rendered with
/// `Canvas` + `TimelineView(.animation)` from the bundled anatomical rig data (see
/// `Exercise Library/SYSTEM.md` for the visual language).
///
/// The solver hands back a depth-sorted draw order per frame (far parts first, the head
/// always last so overhead arms pass behind the face) and a per-vertex nearness for each
/// limb: every joint is toned by its own camera depth, so each bone is stroked with a
/// gradient between its joints' tones — the ink flows along a limb reaching in depth
/// instead of snapping, and the far member sits lighter, thinner, and nudged by the
/// readability offset. Working parts swap the near/far inks for the accent teals; the
/// spine is always dark.

/// Everything the renderer needs for one exercise, resolved once from the bundle.
struct FigureAnimation {
    let timeline: MotionTimeline
    /// Parts drawn in the working accent color (`arm_r`, `leg_l`, `spine`, …).
    let working: Set<String>
    /// Equipment layer (see SYSTEM.md "The props layer").
    let props: [MotionProp]
    /// Presentation zoom about the ground-center anchor — standing motions author
    /// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md).
    let zoom: Double

    init?(exerciseName: String, profile: FigureProfile = .neutral) {
        guard
            let resources = ExerciseMotionLibrary.resources(for: exerciseName, profile: profile),
            let timeline = MotionTimeline(motion: resources.motion, profile: resources.profile)
        else { return nil }
        self.timeline = timeline
        self.working = Set(resources.motion.working ?? [])
        self.props = resources.motion.props ?? []
        self.zoom = resources.motion.camera?.zoom ?? 1
    }

    /// Seconds per full camera revolution.
    private static let orbitPeriod: Double = 24

    /// Drawable geometry at `time` — the figure plus the resolved equipment layers.
    /// Every motion slowly orbits the camera while animating: the equipment has
    /// world-space 3D form and rotates with the figure, so machines turn too.
    func geometry(at time: Double) -> FigureGeometry {
        let yaw = 360 * (time / Self.orbitPeriod)
        return timeline.geometry(at: time, yawOffset: yaw, props: props)
    }
}

/// Bottom-half slot for the run screen: the looping figure when a bundled motion
/// matches the exercise name, or empty space (the pre-figure layout) when none does.
struct ExerciseFigureSlot: View {
    let exerciseName: String

    @AppStorage("figureProfile") private var figureProfile: FigureProfile = .neutral
    @State private var figure: FigureAnimation?

    var body: some View {
        ZStack {
            if let figure {
                ExerciseFigureView(figure: figure)
            } else {
                Color.clear
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        // Rebuild when either the exercise or the chosen body type changes.
        .task(id: [exerciseName, figureProfile.rawValue]) {
            figure = FigureAnimation(exerciseName: exerciseName, profile: figureProfile)
        }
    }
}

/// Draws one `FigureAnimation`, looping forever. The 320×180 design canvas is scaled
/// uniformly to fit the available space (stroke widths scale with it).
struct ExerciseFigureView: View {
    let figure: FigureAnimation

    /// Design-canvas metrics, shared with the reference renderer.
    private static let designSize = CGSize(width: 320, height: 180)
    private static let groundY: CGFloat = 152

    var body: some View {
        TimelineView(.animation) { context in
            Canvas { graphics, size in
                var ctx = graphics
                draw(&ctx, size: size, time: context.date.timeIntervalSinceReferenceDate)
            }
        }
        // Purely decorative: the paged flow already conveys the exercise and its
        // progress, so the looping figure is skipped by VoiceOver.
        .accessibilityHidden(true)
    }

    private func draw(_ ctx: inout GraphicsContext, size: CGSize, time: Double) {
        let scale = min(size.width / Self.designSize.width, size.height / Self.designSize.height)
        guard scale > 0 else { return }
        ctx.translateBy(x: (size.width - Self.designSize.width * scale) / 2,
                        y: (size.height - Self.designSize.height * scale) / 2)
        ctx.scaleBy(x: scale, y: scale)

        // Presentation zoom about the ground-center anchor, mirroring the reference
        // renderer's `apply_zoom` (stroke widths scale with the geometry).
        if figure.zoom != 1 {
            ctx.translateBy(x: Self.designSize.width / 2, y: Self.groundY)
            ctx.scaleBy(x: figure.zoom, y: figure.zoom)
            ctx.translateBy(x: -Self.designSize.width / 2, y: -Self.groundY)
        }

        let geo = figure.geometry(at: time)

        // The exercise mat: a ground-plane quad sized to the motion's footprint,
        // rotating with the camera about the figure.
        if let floorQuad = geo.floor, let first = floorQuad.first {
            var path = Path()
            path.addLines(floorQuad)
            path.addLine(to: first)
            path.closeSubpath()
            ctx.stroke(path, with: .color(.figureGround),
                       style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
        }

        // Equipment behind the figure: scene shapes and cables.
        drawProps(&ctx, geo.propsBackground)

        // Parts far-to-near. The head paints last (opaque), preceded by the joint-attached
        // equipment so bars/pads sit over the limbs but behind the face.
        for part in geo.order {
            switch part {
            case "head":
                drawProps(&ctx, geo.propsForeground)
                drawHead(&ctx, geo)
            case "spine":
                drawSpine(&ctx, geo)
            default:
                guard let limb = FigureLimb(rawValue: part), let points = geo.limbs[limb] else { continue }
                let tones = geo.nearness[limb] ?? Array(repeating: 1, count: points.count)
                strokeLimb(&ctx, points, tones: tones, part: part)
            }
        }
    }

    private func drawHead(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
        let r = geo.headRadius
        let headRect = CGRect(x: geo.headCenter.x - r, y: geo.headCenter.y - r, width: 2 * r, height: 2 * r)
        let headPath = Path(ellipseIn: headRect)
        ctx.fill(headPath, with: .color(.figureHeadFill))
        ctx.stroke(headPath, with: .color(.figureNear), lineWidth: 6)
        if let noseStart = geo.noseStart, let noseEnd = geo.noseEnd {
            stroke(&ctx, [noseStart, noseEnd], color: .figureNear, width: 4)
        }
    }

    private func drawSpine(_ ctx: inout GraphicsContext, _ geo: FigureGeometry) {
        let color = ink("spine", nearness: 1)
        stroke(&ctx, geo.girdle, color: color, width: 5)
        stroke(&ctx, geo.pelvisBar, color: color, width: 5)
        var path = Path()
        path.move(to: geo.spineStart)
        path.addQuadCurve(to: geo.spineEnd, control: geo.spineControl)
        ctx.stroke(path, with: .color(color),
                   style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round))
    }

    // MARK: Props

    /// Draws one resolved equipment layer. The solver hands back ready canvas
    /// primitives (`MotionSolver.resolveProps`, kept 1:1 with the reference
    /// renderer); the view only maps inks to the adaptive palette.
    private func drawProps(_ ctx: inout GraphicsContext, _ prims: [PropPrimitive]) {
        for prim in prims {
            switch prim {
            case .line(let points, let width, let ink):
                stroke(&ctx, points, color: color(for: ink), width: width)
            case .poly(let points, let width, let ink):
                // An extruded scene slab: filled and outlined, so it degenerates
                // to the plain line whenever the sweep collapses edge-on.
                guard let first = points.first else { continue }
                var path = Path()
                path.addLines(points)
                path.addLine(to: first)
                path.closeSubpath()
                ctx.fill(path, with: .color(color(for: ink)))
                ctx.stroke(path, with: .color(color(for: ink)),
                           style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
            case .circle(let center, let radius, let width, let fill, let ink):
                let rect = CGRect(x: center.x - radius, y: center.y - radius,
                                  width: 2 * radius, height: 2 * radius)
                if fill {
                    ctx.fill(Path(ellipseIn: rect), with: .color(color(for: ink)))
                } else {
                    ctx.stroke(Path(ellipseIn: rect), with: .color(color(for: ink)), lineWidth: width)
                }
            }
        }
    }

    private func color(for ink: PropInk) -> Color {
        ink == .prop ? .figureProp : .figureEquipment
    }

    /// A limb's ink at `nearness` (1 = near/dark side, 0 = far/light side): the near
    /// and far grays, or the accent teals for a working part. The spine (passed
    /// nearness 1) is the near ink unless it's working.
    private func ink(_ part: String, nearness t: Double) -> Color {
        .figureShaded(working: figure.working.contains(part), nearness: t)
    }

    /// Strokes a limb chain with a per-vertex depth gradient: each bone segment is a
    /// round-capped line filled with a linear gradient between its two joints' depth
    /// tones, so the ink flows smoothly along a limb that reaches toward or away from
    /// the camera instead of the bone snapping to one flat tone. Adjacent segments share
    /// a joint tone, so their round caps meet seamlessly. Mirrors `render.py`.
    private func strokeLimb(_ ctx: inout GraphicsContext, _ points: [CGPoint],
                            tones: [Double], part: String) {
        guard points.count >= 2, tones.count == points.count else { return }
        for i in 0..<(points.count - 1) {
            let a = points[i], b = points[i + 1]
            let t0 = tones[i], t1 = tones[i + 1]
            let style = StrokeStyle(lineWidth: 5 + (t0 + t1) / 2, lineCap: .round)
            var path = Path()
            path.move(to: a)
            path.addLine(to: b)
            if t0 == t1 || (abs(a.x - b.x) < 0.01 && abs(a.y - b.y) < 0.01) {
                ctx.stroke(path, with: .color(ink(part, nearness: t0)), style: style)
            } else {
                let gradient = Gradient(colors: [ink(part, nearness: t0), ink(part, nearness: t1)])
                ctx.stroke(path, with: .linearGradient(gradient, startPoint: a, endPoint: b), style: style)
            }
        }
    }

    private func stroke(_ ctx: inout GraphicsContext, _ points: [CGPoint], color: Color, width: CGFloat) {
        var path = Path()
        path.addLines(points)
        ctx.stroke(path, with: .color(color),
                   style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round))
    }
}

// MARK: - Figure Palette

/// The reference palette (`render.py`), made dark-mode adaptive: the prominent near
/// side stays strong (near-black → light gray), the recessive far side stays muted,
/// and the working teals brighten/desaturate so they read on black. watchOS has no
/// dynamic-provider `UIColor` and renders on black, so it takes the dark variant
/// verbatim.
private extension Color {
    static func figure(light: (Double, Double, Double), dark: (Double, Double, Double)) -> Color {
        #if os(watchOS)
        Color(red: dark.0, green: dark.1, blue: dark.2)
        #elseif os(macOS)
        Color(nsColor: NSColor(name: nil) { appearance in
            appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
                ? NSColor(red: dark.0, green: dark.1, blue: dark.2, alpha: 1)
                : NSColor(red: light.0, green: light.1, blue: light.2, alpha: 1)
        })
        #else
        Color(UIColor { traits in
            traits.userInterfaceStyle == .dark
                ? UIColor(red: dark.0, green: dark.1, blue: dark.2, alpha: 1)
                : UIColor(red: light.0, green: light.1, blue: light.2, alpha: 1)
        })
        #endif
    }

    /// The gradient endpoints as `(lightMode, darkMode)` RGB pairs — the near (dark,
    /// prominent) and far (light, recessive) inks, plain and working. `figureShaded`
    /// blends between the far and near endpoint of a pair by a limb's nearness.
    private static let nearInk = (light: (0.23, 0.25, 0.29), dark: (0.76, 0.79, 0.83))   // #3a3f4b
    private static let farInk = (light: (0.66, 0.69, 0.73), dark: (0.36, 0.39, 0.43))    // #a9afba
    private static let nearWorkingInk = (light: (0.05, 0.58, 0.53), dark: (0.18, 0.83, 0.75)) // #0d9488
    private static let farWorkingInk = (light: (0.53, 0.81, 0.77), dark: (0.31, 0.56, 0.52))  // #86cfc5

    static func lerp3(_ a: (Double, Double, Double), _ b: (Double, Double, Double), _ t: Double) -> (Double, Double, Double) {
        (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, a.2 + (b.2 - a.2) * t)
    }

    /// A limb's ink blended by nearness (1 = near/dark side, 0 = far/light side),
    /// interpolating both the light- and dark-mode endpoints so it stays adaptive.
    static func figureShaded(working: Bool, nearness t: Double) -> Color {
        let far = working ? farWorkingInk : farInk
        let near = working ? nearWorkingInk : nearInk
        return figure(light: lerp3(far.light, near.light, t), dark: lerp3(far.dark, near.dark, t))
    }

    /// Near-side ink for the head, nose, and non-gradient strokes (`#3a3f4b`).
    static let figureNear = figure(light: nearInk.light, dark: nearInk.dark)

    /// Scene equipment — seats, benches, frames, cables (`#c5cad4`); sits behind
    /// the figure, one step lighter than the ground line.
    static let figureEquipment = figure(light: (0.77, 0.79, 0.83), dark: (0.25, 0.27, 0.31))

    /// Joint-attached items — bars, dumbbells, pads (`#6b7180`); darker than the
    /// scene so the thing being moved reads over the limbs.
    static let figureProp = figure(light: (0.42, 0.44, 0.50), dark: (0.55, 0.58, 0.63))

    /// Ground line (`#b9bec9`).
    static let figureGround = figure(light: (0.73, 0.75, 0.79), dark: (0.29, 0.31, 0.36))

    /// Opaque head fill — the screen background, so limbs pass behind the face.
    #if os(watchOS)
    static let figureHeadFill = Color.black
    #elseif os(macOS)
    static let figureHeadFill = Color(nsColor: .windowBackgroundColor)
    #else
    static let figureHeadFill = Color(.systemBackground)
    #endif
}
