//
//  WheelSlider.swift
//
//  A circular "wheel" slider in SwiftUI — the control NaturalCam uses for
//  shutter speed, ISO and focal length. Drop this one file into a project or a
//  Swift Playground and it runs; there are no dependencies beyond SwiftUI.
//
//  The idea in one line: the pivot is OUTSIDE the view, down in the corner the
//  holding hand grips, so the whole arc sits inside one thumb sweep.
//
//  Everything geometric lives in `WheelGeometry` as free functions over
//  numbers. No SwiftUI, no view state, no CoreAnimation — which is what makes
//  it unit-testable and what makes the drawing code boring.
//

import SwiftUI

#if canImport(UIKit)
import UIKit
#endif

// MARK: - Geometry
//
// Pure arithmetic. Angles run from the horizontal, away from the gripping
// hand, round to past vertical. In right-handed use that is nine o'clock
// towards twelve; the left-handed mirror runs three o'clock towards twelve.

enum WheelGeometry {

    /// Where the ladder starts, a little below the horizontal.
    ///
    /// A quarter turn is what a thumb sweeps comfortably; the few degrees
    /// either side of it are what a thumb *can* reach when it wants the end of
    /// the ladder. Starting just under the horizontal and finishing just past
    /// the vertical buys about a fifth more track for free.
    static let startDegrees: Double = -10

    /// How far it sweeps from there.
    static let sweepDegrees: Double = 110

    static var endDegrees: Double { startDegrees + sweepDegrees }

    // MARK: Rungs

    /// The angle a rung sits at, spread evenly across the sweep.
    ///
    /// A single-rung ladder has no spread to speak of and would divide by
    /// zero, so it sits at the near end where the thumb rests.
    static func angle(forIndex index: Int, count: Int) -> Double {
        guard count > 1 else { return startDegrees }
        let clamped = index.clamped(to: 0...(count - 1))
        return startDegrees + Double(clamped) * sweepDegrees / Double(count - 1)
    }

    /// Which rung an angle lands on. The arc is absolute, not accumulated:
    /// where the thumb is *is* the value, the way a real dial reads.
    static func index(forAngle angle: Double, count: Int) -> Int {
        guard count > 1 else { return 0 }
        let clamped = angle.clamped(to: startDegrees...endDegrees)
        return Int(((clamped - startDegrees) / sweepDegrees * Double(count - 1)).rounded())
            .clamped(to: 0...(count - 1))
    }

    // MARK: Screen points

    /// A point on the arc, in the view's own coordinates.
    ///
    /// Only the horizontal component flips for a left hand, so labels stay
    /// upright rather than being mirrored with the geometry the way a CSS
    /// `scaleX(-1)` would.
    static func point(
        radius: CGFloat,
        angleDegrees: Double,
        pivot: CGPoint,
        mirrored: Bool
    ) -> CGPoint {
        let radians = angleDegrees * .pi / 180
        let horizontal = radius * CGFloat(cos(radians))
        return CGPoint(
            x: mirrored ? pivot.x + horizontal : pivot.x - horizontal,
            y: pivot.y - radius * CGFloat(sin(radians))   // UIKit y grows downwards
        )
    }

    /// The angle a touch is at, clamped to the sweep so a thumb that slides
    /// off the end of the arc holds the last rung instead of wrapping round.
    static func angle(at point: CGPoint, pivot: CGPoint, mirrored: Bool) -> Double {
        let horizontal = mirrored ? point.x - pivot.x : pivot.x - point.x
        let vertical = pivot.y - point.y
        let radians = atan2(Double(vertical), Double(horizontal))
        return (radians * 180 / .pi).clamped(to: startDegrees...endDegrees)
    }

    /// How far from the pivot a touch is — for telling stacked rings apart.
    static func radius(at point: CGPoint, pivot: CGPoint) -> CGFloat {
        hypot(point.x - pivot.x, point.y - pivot.y)
    }

    // MARK: Fitting and engraving

    /// How much to shrink the unit geometry by so the arc fits the space it
    /// was handed.
    ///
    /// Below the floor the ticks are closer together than a thumb can
    /// resolve, so it stops shrinking and lets the top of the arc be clipped
    /// instead — a cramped dial you can still use beats a tidy one you cannot.
    static func scale(forAvailableRadius available: CGFloat) -> CGFloat {
        guard available > 0 else { return 1 }
        return (available / Metrics.extent).clamped(to: 0.62...1.0)
    }

    /// How many labels an arc of this radius can space out before they touch.
    /// About 34 points each: a four-digit engraving is roughly 26 wide.
    static func maxLabels(atRadius radius: CGFloat) -> Int {
        let arcLength = radius * CGFloat(sweepDegrees * .pi / 180)
        return max(3, Int(arcLength / 34))
    }

    /// The rungs to engrave, given some that must not be dropped.
    ///
    /// A plain stride is not enough once anything else earns a label of its
    /// own: on a long ladder a stride label lands next to a required one and
    /// the two numbers print on top of each other.
    ///
    /// So placement is greedy rather than arithmetic — the required rungs go
    /// down first and keep their space, then the rest fill in wherever there
    /// is room. Never denser than the arc can carry, and the rungs that matter
    /// are never the ones dropped.
    static func labelledRungs(count: Int, required: Set<Int>, maxLabels: Int) -> Set<Int> {
        guard count > 0 else { return [] }
        guard maxLabels > 0 else { return required }

        let minimumGap = max(1, Int((Double(count) / Double(maxLabels)).rounded(.up)))
        var placed = required.filter { (0..<count).contains($0) }

        for rung in 0..<count where !placed.contains(rung) {
            let clashes = placed.contains { abs($0 - rung) < minimumGap }
            if !clashes { placed.insert(rung) }
        }
        return placed
    }

    // MARK: Radii
    //
    // In points, from the pivot, at scale 1. Scaled as one group so the parts
    // stay concentric and proportional whatever height they are given.
    //
    // A thumb resting on the shutter reaches about 140 points before the hand
    // has to regrip. Everything here sits inside that: past it, the control
    // silently becomes a two-handed one.

    enum Metrics {
        static let track: CGFloat = 120
        static let bandWidth: CGFloat = 42
        static let label: CGFloat = 112
        static let tickInner: CGFloat = 127
        static let tickOuter: CGFloat = 137
        static let activeTickInner: CGFloat = 124
        static let activeTickOuter: CGFloat = 141

        /// The furthest ink from the pivot, which is what has to fit.
        static let extent: CGFloat = 141

        static var bandInner: CGFloat { track - bandWidth / 2 }
        static var bandOuter: CGFloat { track + bandWidth / 2 }

        /// How far the pivot sits in from the trailing edge and up from the
        /// bottom of the view.
        static let pivotInsetFromEdge: CGFloat = 58
        static let pivotInsetFromBottom: CGFloat = 62
    }
}

private extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        min(max(self, range.lowerBound), range.upperBound)
    }
}

// MARK: - The control

/// A ladder of discrete values laid along an arc, pivoting on the corner the
/// holding hand grips.
///
/// Give it as much room as you can — it places its own pivot near the bottom
/// trailing corner of whatever space it gets and shrinks to fit.
struct WheelSlider: View {

    /// Rung labels, near end first.
    let labels: [String]

    /// Which rung is lit. Absolute: dragging sets it to whatever the thumb is
    /// pointing at, it does not accumulate.
    @Binding var index: Int

    /// Rungs worth marking out, which are always engraved whatever else has to
    /// give. Empty when no rung is better than any other.
    var emphasis: Set<Int> = []

    /// Left-handed use pivots on the other corner and swings the other way.
    var mirrored: Bool = false

    /// Leaned on top of the fit, for hands that differ by more than phones do.
    var sizeMultiplier: CGFloat = 1

    #if canImport(UIKit)
    private let haptics = UIImpactFeedbackGenerator(style: .rigid)
    #endif

    var body: some View {
        GeometryReader { geo in
            let layout = Layout(
                size: geo.size,
                mirrored: mirrored,
                multiplier: sizeMultiplier
            )

            ring(layout: layout)
                // Only the band is grabbable. The hole in the middle belongs to
                // whatever you put there (in NaturalCam, the shutter button),
                // and everything outside the band to the view behind.
                .contentShape(
                    AnnulusSector(
                        layout: layout,
                        innerRadius: WheelGeometry.Metrics.bandInner,
                        outerRadius: WheelGeometry.Metrics.bandOuter
                    )
                )
                .gesture(
                    // minimumDistance: 0 so a tap on a rung selects it, with no
                    // slop threshold to drag through first.
                    DragGesture(minimumDistance: 0)
                        .onChanged { move(to: $0.location, layout: layout) }
                )
        }
    }

    // MARK: Touch

    private func move(to point: CGPoint, layout: Layout) {
        let angle = WheelGeometry.angle(at: point, pivot: layout.pivot, mirrored: mirrored)
        let next = WheelGeometry.index(forAngle: angle, count: labels.count)
        guard next != index else { return }
        index = next

        // One tick per rung crossed. The detent is the whole point: it is what
        // makes an arc feel like a dial rather than a slider.
        #if canImport(UIKit)
        haptics.impactOccurred(intensity: 0.6)
        #endif
    }

    // MARK: Drawing

    private func ring(layout: Layout) -> some View {
        typealias Metrics = WheelGeometry.Metrics

        // The lit rung and the emphasised ones are engraved whatever else has
        // to give; everything after them fills the space that is left.
        let engraved = WheelGeometry.labelledRungs(
            count: labels.count,
            required: emphasis.union([index]),
            maxLabels: WheelGeometry.maxLabels(atRadius: Metrics.label * layout.scale)
        )
        let marked = emphasis.subtracting([index]).sorted()
        let plain = labels.indices.filter { !engraved.contains($0) }
        let labelled = engraved.subtracting(emphasis).subtracting([index]).sorted()

        return ZStack {
            AnnulusSector(
                layout: layout,
                innerRadius: Metrics.bandInner,
                outerRadius: Metrics.bandOuter
            )
            .fill(.black.opacity(0.28))

            ArcCurve(layout: layout, radius: Metrics.bandOuter)
                .stroke(.white.opacity(0.18), lineWidth: 1)
            ArcCurve(layout: layout, radius: Metrics.bandInner)
                .stroke(.white.opacity(0.18), lineWidth: 1)

            // Unlabelled rungs are still rungs — the ladder's length is
            // information, and a thumb aims at ticks rather than at text.
            ticks(plain, layout: layout, inner: Metrics.tickInner, outer: Metrics.tickOuter)
                .stroke(.white.opacity(0.45), style: StrokeStyle(lineWidth: 1.5, lineCap: .round))

            ticks(labelled, layout: layout, inner: Metrics.tickInner, outer: Metrics.tickOuter)
                .stroke(.white.opacity(0.85), style: StrokeStyle(lineWidth: 2, lineCap: .round))

            ticks(marked, layout: layout, inner: Metrics.tickInner, outer: Metrics.tickOuter)
                .stroke(.green, style: StrokeStyle(lineWidth: 2, lineCap: .round))

            ticks([index], layout: layout, inner: Metrics.activeTickInner, outer: Metrics.activeTickOuter)
                .stroke(.orange, style: StrokeStyle(lineWidth: 3, lineCap: .round))

            ForEach(Array(labels.enumerated()), id: \.offset) { rung, label in
                if engraved.contains(rung) {
                    Text(label)
                        .font(.system(size: 11, weight: rung == index ? .semibold : .regular,
                                      design: .monospaced))
                        .foregroundStyle(rung == index ? .orange : .white.opacity(0.65))
                        .fixedSize()
                        .position(
                            layout.point(
                                radius: Metrics.label,
                                angle: WheelGeometry.angle(forIndex: rung, count: labels.count)
                            )
                        )
                }
            }
        }
    }

    private func ticks(_ rungs: [Int], layout: Layout, inner: CGFloat, outer: CGFloat) -> TickMarks {
        TickMarks(layout: layout, rungs: rungs, count: labels.count, inner: inner, outer: outer)
    }

    // MARK: Layout

    /// Where the wheel sits in the space it was handed, and how far it had to
    /// shrink to get there.
    struct Layout: Equatable {
        let pivot: CGPoint
        let scale: CGFloat
        let mirrored: Bool

        init(size: CGSize, mirrored: Bool, multiplier: CGFloat = 1) {
            typealias Metrics = WheelGeometry.Metrics
            let x = mirrored
                ? Metrics.pivotInsetFromEdge
                : size.width - Metrics.pivotInsetFromEdge
            let pivot = CGPoint(x: x, y: size.height - Metrics.pivotInsetFromBottom)

            self.pivot = pivot
            self.mirrored = mirrored
            // Whichever runs out first: the room above the pivot, or the room
            // between it and the far edge.
            self.scale = WheelGeometry.scale(
                forAvailableRadius: min(pivot.y, mirrored ? size.width - pivot.x : pivot.x)
            ) * multiplier
        }

        func point(radius: CGFloat, angle: Double) -> CGPoint {
            WheelGeometry.point(
                radius: radius * scale,
                angleDegrees: angle,
                pivot: pivot,
                mirrored: mirrored
            )
        }
    }
}

// MARK: - Shapes

/// How finely the curves are walked. A polyline rather than `Path.addArc`
/// because every point in this file goes through the same mirroring function —
/// one code path for both hands beats two that have to agree.
private let arcSteps = 72

private func sweepAngle(_ step: Int) -> Double {
    WheelGeometry.startDegrees
        + Double(step) / Double(arcSteps) * WheelGeometry.sweepDegrees
}

/// The band a thumb rides: out along the outer radius, back along the inner.
private struct AnnulusSector: Shape {
    let layout: WheelSlider.Layout
    let innerRadius: CGFloat
    let outerRadius: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        for step in 0...arcSteps {
            let point = layout.point(radius: outerRadius, angle: sweepAngle(step))
            if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
        }
        for step in stride(from: arcSteps, through: 0, by: -1) {
            path.addLine(to: layout.point(radius: innerRadius, angle: sweepAngle(step)))
        }
        path.closeSubpath()
        return path
    }
}

/// The hairline at a band's edge.
private struct ArcCurve: Shape {
    let layout: WheelSlider.Layout
    let radius: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        for step in 0...arcSteps {
            let point = layout.point(radius: radius, angle: sweepAngle(step))
            if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
        }
        return path
    }
}

/// The engraved rungs. One path per group keeps the tick count off the
/// view-identity treadmill — fourteen rungs would otherwise be fourteen more
/// views for SwiftUI to diff on every frame of a drag.
private struct TickMarks: Shape {
    let layout: WheelSlider.Layout
    let rungs: [Int]
    let count: Int
    let inner: CGFloat
    let outer: CGFloat

    func path(in rect: CGRect) -> Path {
        var path = Path()
        for rung in rungs {
            let angle = WheelGeometry.angle(forIndex: rung, count: count)
            path.move(to: layout.point(radius: inner, angle: angle))
            path.addLine(to: layout.point(radius: outer, angle: angle))
        }
        return path
    }
}

// MARK: - Demo

struct WheelSliderDemo: View {

    /// What the ticks are engraved with. The denominator alone, the way a
    /// shutter dial actually is — spell these out as `1/2000` and the label
    /// collides with its neighbour at the top of the arc, where the rungs
    /// converge and `maxLabels` has only ~26 points of width to give each one.
    private let engraved = ["A", "1\"", "15", "30", "60", "125", "250",
                            "500", "1000", "2000", "4000", "8000"]

    /// What the panel beside the dial reads. Two strings per value, not one.
    private let readout = ["A", "1\"", "1/15", "1/30", "1/60", "1/125", "1/250",
                           "1/500", "1/1000", "1/2000", "1/4000", "1/8000"]

    @State private var index = 5
    @State private var mirrored = false

    var body: some View {
        ZStack(alignment: .bottom) {
            LinearGradient(colors: [.gray.opacity(0.9), .black],
                           startPoint: .top, endPoint: .bottom)
                .ignoresSafeArea()

            VStack {
                Text(readout[index])
                    .font(.system(size: 34, weight: .medium, design: .monospaced))
                    .foregroundStyle(.white)
                    .padding(.top, 80)
                Toggle("Left-handed", isOn: $mirrored)
                    .foregroundStyle(.white)
                    .padding(.horizontal, 60)
                Spacer()
            }

            WheelSlider(labels: engraved, index: $index, mirrored: mirrored)
                .frame(height: 260)

            // Whatever lives in the hole at the pivot. Here, a shutter.
            Circle()
                .fill(.white)
                .frame(width: 76, height: 76)
                .padding(mirrored ? .leading : .trailing,
                         WheelGeometry.Metrics.pivotInsetFromEdge - 38)
                .padding(.bottom, WheelGeometry.Metrics.pivotInsetFromBottom - 38)
                .frame(maxWidth: .infinity,
                       alignment: mirrored ? .bottomLeading : .bottomTrailing)
        }
    }
}

#Preview {
    WheelSliderDemo()
}
