// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and
// HID-output (0xCD) planes and replays them on the active physical controller —
//
//   rumble        → CHHapticEngine players (per-handle localities when the pad has them,
//                   one combined engine otherwise),
//   lightbar      → GCDeviceLight,
//   player LEDs   → GCController.playerIndex (the DS bit patterns map to player 1–4),
//   trigger FX    → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger.
//
// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists
// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the
// drain always polls both planes with short timeouts and never spins, so an Xbox session
// just renders rumble. GameController profile mutation
// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself
// touches neither. When GamepadManager switches the active controller mid-session, the
// old pad is reset (triggers off, player index unset) and the last known feedback state
// is replayed onto the new one.

import Combine
import CoreHaptics
import Foundation
import GameController
import os

private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad")

private final class FeedbackStopFlag: @unchecked Sendable {
    private let lock = NSLock()
    private var stopped = false
    var isStopped: Bool {
        lock.lock()
        defer { lock.unlock() }
        return stopped
    }
    func stop() {
        lock.lock()
        stopped = true
        lock.unlock()
    }
}

/// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound,
/// but it isn't a free-for-all either). Engines are created lazily on the first nonzero
/// amplitude and torn down on retarget; players run only while their motor is on, so an
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
/// downgrade to silence — rumble is best-effort by design.
///
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is
/// read and written only inside `queue` closures — the serial queue is the synchronization.
private final class RumbleRenderer: @unchecked Sendable {
    private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)

    /// One actuator's started engine plus the player currently driving it (nil = idle). The
    /// player is rebuilt per level change — `drive` bakes the target intensity into a fresh
    /// continuous event rather than scaling a long-lived one with a dynamic parameter.
    private struct Motor {
        let engine: CHHapticEngine
        var player: CHHapticAdvancedPatternPlayer?
    }

    private var controller: GCController?
    private var low: Motor?
    private var high: Motor?
    // `broken` latches OFF only for a controller that genuinely has no haptics engine (an Xbox pad
    // on an OS that doesn't expose rumble through GameController, a Siri Remote) — nothing to retry
    // until the controller changes. A transient engine failure does NOT latch it; it tears down for
    // a lazy rebuild instead, so a single hiccup can't kill rumble for the whole session.
    private var broken = false
    /// Last logged active/silent state — for a one-line transition log, not per-event spam.
    private var wasActive = false
    // Backoff after an engine failure. A broken `gamecontrollerd.haptics` XPC connection (CoreHaptics
    // -4811 "server connection broke") fails EVERY rebuild until the service relaunches — and that
    // break fires neither stoppedHandler nor resetHandler, so without a cooldown the next rumble
    // update immediately rebuilds into the same dead connection, flooding the log and never
    // recovering. Delay the next setup() — growing 0.5→1→2→4 s on repeated failure — and clear it
    // the moment a player runs cleanly (or the controller changes).
    private var retryAfter = Date.distantPast
    private var consecutiveFailures = 0

    /// CHHapticEvent sharpness = actuator frequency. A DualSense's voice-coil motors need a
    /// defined frequency to move at all — an intensity-only event (no sharpness) left them
    /// silent, while a classic Xbox rotor (which ignores sharpness) rumbled fine. 0.5 is the mid
    /// value the known-working macOS DualSense rumble implementations use. (Used only on the
    /// CoreHaptics path — a DualSense on macOS is driven over raw HID instead, see below.)
    private static let sharpness: Float = 0.5

    #if os(macOS)
    /// Set when the active pad is a DualSense: its motors are driven over raw HID (CoreHaptics
    /// does not reach them on macOS — adaptive triggers/lightbar work, rumble is silent). nil for
    /// every other controller, which keeps the CoreHaptics path.
    private var dualSenseHID: DualSenseHID?
    #endif

    /// `onBackend`, if given, is invoked (on the internal queue) with a human-readable name of the
    /// rumble backend now in use — for the debug controller-test panel.
    func retarget(_ c: GCController?, onBackend: ((String) -> Void)? = nil) {
        queue.async {
            self.teardown()
            self.closeHID()
            self.controller = c
            self.broken = false
            self.consecutiveFailures = 0
            self.retryAfter = .distantPast
            _ = self.openHIDIfDualSense(c)
            onBackend?(self.backendNote(for: c))
        }
    }

    func apply(low lowAmp: UInt16, high highAmp: UInt16) {
        queue.async {
            let active = lowAmp != 0 || highAmp != 0
            if active != self.wasActive {
                self.wasActive = active
                log.debug(
                    "rumble: \(active ? "active" : "stop", privacy: .public) low=\(lowAmp, privacy: .public) high=\(highAmp, privacy: .public)")
            }
            // A DualSense on macOS is driven over raw HID; CoreHaptics is the path for every
            // other pad (and for a DualSense whose HID device could not be opened).
            if self.hidRumble(low: lowAmp, high: highAmp) { return }
            guard !self.broken else { return }
            if active, self.low == nil, self.high == nil, Date() >= self.retryAfter {
                self.setup()
            }
            let ok: Bool
            if self.high != nil {
                // Per-handle: low = left/heavy motor, high = right/light — the XInput convention
                // the wire carries.
                let okLow = self.drive(&self.low, Float(lowAmp) / 65535)
                let okHigh = self.drive(&self.high, Float(highAmp) / 65535)
                ok = okLow && okHigh
            } else {
                // Combined engine: whichever motor is stronger wins.
                ok = self.drive(&self.low, Float(max(lowAmp, highAmp)) / 65535)
            }
            // Rebuild on the next nonzero amplitude if an engine errored — and tear down OUTSIDE
            // the `inout` accesses above, so teardown() never mutates a motor that a `drive` call
            // still holds an exclusive reference to. Back off so a broken XPC isn't re-hit every
            // update; once a player is actually running the path has recovered, so clear the backoff.
            if !ok {
                self.teardown()
                self.scheduleRetryBackoff()
            } else if self.low?.player != nil || self.high?.player != nil {
                self.consecutiveFailures = 0
                self.retryAfter = .distantPast
            }
        }
    }

    func stop() {
        queue.sync {
            self.teardown()
            self.closeHID()
        }
    }

    /// Engines per handle when the pad distinguishes them (low = left/heavy motor,
    /// high = right/light — the Xbox/XInput convention the wire carries); one combined
    /// engine otherwise, driven by whichever amplitude is stronger.
    private func setup() {
        guard let haptics = controller?.haptics else {
            // No haptics engine at all — an Xbox controller on an OS/firmware that doesn't expose
            // rumble through GameController (works on Android via the standard Vibrator path, but
            // Apple's support is controller/OS-dependent), or a Siri Remote. Nothing to retry until
            // the controller changes; latch off (retarget clears it) and say so once.
            log.info("rumble: active controller exposes no haptics engine — rumble unavailable")
            broken = true
            return
        }
        let localities = haptics.supportedLocalities
        if localities.contains(.leftHandle), localities.contains(.rightHandle) {
            low = makeMotor(haptics, .leftHandle)
            high = makeMotor(haptics, .rightHandle)
        } else {
            low = makeMotor(haptics, .default)
        }
        if low == nil, high == nil {
            // Haptics present but no engine could be built right now (server busy / XPC broken). Do
            // NOT latch broken — back off and the next nonzero amplitude past the cooldown retries.
            log.warning("rumble: haptics present but engine setup failed — backing off, will retry")
            scheduleRetryBackoff()
        }
    }

    /// Push the next engine-build attempt out after a failure (capped exponential backoff), so a
    /// broken `gamecontrollerd.haptics` connection gets time to relaunch instead of being re-hit on
    /// every rumble update.
    private func scheduleRetryBackoff() {
        consecutiveFailures += 1
        let shift = min(consecutiveFailures - 1, 4)
        retryAfter = Date().addingTimeInterval(min(0.5 * Double(1 << shift), 4))
    }

    private func makeMotor(_ haptics: GCDeviceHaptics, _ locality: GCHapticsLocality) -> Motor? {
        guard let engine = haptics.createEngine(withLocality: locality) else { return nil }
        // A controller's motors carry no audio, so keep this engine OUT of the app's audio session
        // (the default is to join it). Streaming keeps an AVAudioSession active the whole time;
        // letting a haptics-only engine join it is a needless coupling that can get its
        // gamecontrollerd XPC connection interrupted (the repeated -4811 server-connection breaks).
        engine.playsHapticsOnly = true
        // The haptic server can stop or reset the engine out from under us — app backgrounding, an
        // audio-session interruption (a call, Siri, another audio app), or a server crash. Left
        // unhandled the players go dead and every later rumble throws, latching rumble off for the
        // rest of the session (the "rumble worked, then went spotty" failure). Tear down on the
        // serial queue so the next nonzero amplitude lazily rebuilds the engine, instead.
        engine.stoppedHandler = { [weak self] reason in
            log.info("rumble: haptic engine stopped (reason \(reason.rawValue, privacy: .public)) — will rebuild")
            self?.queue.async { self?.teardown() }
        }
        engine.resetHandler = { [weak self] in
            log.info("rumble: haptic engine reset — will rebuild")
            self?.queue.async { self?.teardown() }
        }
        do {
            // Start the engine now; the player that actually moves the motor is built per level
            // change in `drive` (a fresh event baked at the target intensity).
            try engine.start()
            return Motor(engine: engine, player: nil)
        } catch {
            log.warning("haptic engine setup failed (\(locality.rawValue, privacy: .public)): \(error, privacy: .public)")
            return nil
        }
    }

    /// Drive one motor at `amplitude` (0...1) by (re)building a continuous player whose intensity
    /// is BAKED into the event. On a DualSense this is what actually moves the actuators: a
    /// fixed-intensity event scaled by a dynamic `.hapticIntensityControl` parameter (the old
    /// path) drives the iPhone Taptic Engine but is silent on a controller's haptic engine. The
    /// event carries an explicit sharpness (frequency) so the voice coils respond, and an infinite
    /// duration so a single host update — the host sends rumble only when the level changes —
    /// sustains until the next one. Returns false if the engine errored; the caller tears down for
    /// a rebuild (done outside this `inout` access to avoid an exclusivity violation).
    private func drive(_ motor: inout Motor?, _ amplitude: Float) -> Bool {
        guard var m = motor else { return true }
        // Replace any running player: stop the old, and for a zero level leave the motor idle.
        try? m.player?.stop(atTime: CHHapticTimeImmediate)
        m.player = nil
        guard amplitude > 0 else { motor = m; return true }
        do {
            let event = CHHapticEvent(
                eventType: .hapticContinuous,
                parameters: [
                    CHHapticEventParameter(parameterID: .hapticIntensity, value: amplitude),
                    CHHapticEventParameter(parameterID: .hapticSharpness, value: Self.sharpness),
                ],
                relativeTime: 0,
                duration: TimeInterval(GCHapticDurationInfinite))
            let player = try m.engine.makeAdvancedPlayer(
                with: CHHapticPattern(events: [event], parameters: []))
            try player.start(atTime: CHHapticTimeImmediate)
            m.player = player
            motor = m
            return true
        } catch {
            // A transient failure (the engine stopped/reset between its handler firing and now).
            // Signal a rebuild — do NOT latch rumble off for the session (the old "spotty" bug).
            log.warning("rumble: haptic update failed — rebuilding: \(error, privacy: .public)")
            motor = m
            return false
        }
    }

    private func teardown() {
        for m in [low, high].compactMap({ $0 }) {
            // Disarm the handlers before stopping so stop() can't re-enter teardown via them.
            // (Both properties are non-optional closures on this SDK, so assign no-ops, not nil.)
            m.engine.stoppedHandler = { _ in }
            m.engine.resetHandler = {}
            try? m.player?.stop(atTime: CHHapticTimeImmediate)
            m.engine.stop()
        }
        low = nil
        high = nil
    }

    // MARK: - DualSense raw-HID rumble (macOS)
    //
    // On macOS the DualSense's motors aren't reachable through CHHapticEngine, so for a DualSense
    // we drive them over raw HID (see `DualSenseHID`); every other pad keeps the CoreHaptics path.
    // All three run on the serial `queue`, like the rest of the renderer state.

    private func openHIDIfDualSense(_ c: GCController?) -> Bool {
        #if os(macOS)
        guard let c, c.extendedGamepad is GCDualSenseGamepad else { return false }
        let hid = DualSenseHID()
        guard hid.open() else { return false }
        dualSenseHID = hid
        return true
        #else
        return false
        #endif
    }

    /// Drive the DualSense's motors over HID if that's the active backend; false → not a HID pad,
    /// so the caller uses CoreHaptics. The wire's 0...0xFFFF amplitudes scale to the pad's 0...255.
    private func hidRumble(low: UInt16, high: UInt16) -> Bool {
        #if os(macOS)
        guard let hid = dualSenseHID else { return false }
        hid.rumble(low: UInt8(low >> 8), high: UInt8(high >> 8))
        return true
        #else
        return false
        #endif
    }

    private func closeHID() {
        #if os(macOS)
        dualSenseHID?.close()
        dualSenseHID = nil
        #endif
    }

    private func backendNote(for c: GCController?) -> String {
        #if os(macOS)
        if let hid = dualSenseHID { return "DualSense HID · \(hid.transport)" }
        #endif
        return c == nil ? "—" : "CoreHaptics"
    }
}

public final class GamepadFeedback {
    private let connection: PunktfunkConnection
    private let flag = FeedbackStopFlag()
    private let drainDone = DispatchSemaphore(value: 0)
    private var drainStarted = false
    private let rumble = RumbleRenderer()
    private var activeSub: AnyCancellable?

    // Last applied feedback (main-actor) — replayed when the active controller changes.
    @MainActor private var target: GCController?
    @MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)?
    @MainActor private var lastPlayerBits: UInt8?
    @MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil]

    public init(connection: PunktfunkConnection, manager: GamepadManager) {
        self.connection = connection
        // Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing
        // an implicit strong one — and the subscription (stored on self) never retain-cycles.
        Task { @MainActor [weak self] in
            guard let self else { return }
            self.activeSub = manager.$active.sink { [weak self] dc in
                MainActor.assumeIsolated { self?.retarget(dc?.controller) }
            }
        }
    }

    /// Safety net: the drain thread captures `connection` strongly and only `self` weakly, so if
    /// this is dropped without `stop()` (an abrupt teardown) the thread would poll forever and
    /// leak the connection — signal it to exit. (`stop()` is the normal path and also joins it.)
    deinit { flag.stop() }

    /// Map the DualSense player-LED bit patterns (5 LEDs, hid-playstation's player
    /// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit
    /// count, clamped to the four indices GC offers.
    public static func playerIndex(forBits bits: UInt8) -> GCControllerPlayerIndex {
        switch bits & 0x1F {
        case 0: return .indexUnset
        case 0b00100: return .index1
        case 0b01010: return .index2
        case 0b10101: return .index3
        case 0b11011: return .index4
        default:
            let lit = (bits & 0x1F).nonzeroBitCount
            return GCControllerPlayerIndex(rawValue: min(lit, 4) - 1) ?? .index1
        }
    }

    public func start() {
        guard !drainStarted else { return }
        drainStarted = true
        // Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad
        // session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
        // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
        let hasHidout = connection.resolvedGamepad == .dualSense
            || connection.resolvedGamepad == .dualShock4
        let hidTimeout: UInt32 = hasHidout ? 10 : 0
        let thread = Thread { [connection, flag, drainDone, weak self] in
            while !flag.isStopped {
                do {
                    if let r = try connection.nextRumble(timeoutMs: 10), r.pad == 0 {
                        self?.rumble.apply(low: r.low, high: r.high)
                    }
                    // Drain a BOUNDED burst of hidout events: only the first poll waits,
                    // and the cap + stop check keep sustained 0xCD traffic (a game writing
                    // per-frame LED/trigger reports) from starving the rumble poll above
                    // or blocking stop() past one cycle.
                    var burst = 0
                    while burst < 64, !flag.isStopped,
                          let ev = try connection.nextHidOutput(
                            timeoutMs: burst == 0 ? hidTimeout : 0) {
                        self?.render(ev)
                        burst += 1
                    }
                } catch {
                    break // .closed (or fatal) — the session is over
                }
            }
            drainDone.signal()
        }
        thread.name = "punktfunk-feedback"
        thread.qualityOfService = .userInteractive
        thread.start()
    }

    /// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one
    /// poll cycle) — call off the main actor, before `connection.close()`.
    public func stop() {
        flag.stop()
        if drainStarted {
            drainDone.wait()
            drainStarted = false
        }
        rumble.stop()
        // Drop the retarget subscription and the dead session's cached feedback — a
        // controller change after teardown must not replay this session's triggers/LEDs.
        Task { @MainActor in
            self.activeSub = nil
            self.lastLight = nil
            self.lastPlayerBits = nil
            self.lastTrigger = [nil, nil]
            self.reset(self.target)
            self.target = nil
        }
    }

    private func render(_ ev: PunktfunkConnection.HidOutputEvent) {
        DispatchQueue.main.async {
            MainActor.assumeIsolated { self.apply(ev) }
        }
    }

    @MainActor
    private func apply(_ ev: PunktfunkConnection.HidOutputEvent) {
        switch ev {
        case let .led(pad, r, g, b):
            guard pad == 0 else { return }
            lastLight = (r, g, b)
            target?.light?.color = GCColor(
                red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
        case let .playerLEDs(pad, bits):
            guard pad == 0 else { return }
            lastPlayerBits = bits
            target?.playerIndex = Self.playerIndex(forBits: bits)
        case let .triggerEffect(pad, which, effect):
            guard pad == 0, which < 2 else { return }
            let parsed = DualSenseTriggerEffect.parse(effect)
            lastTrigger[Int(which)] = parsed
            if let trigger = adaptiveTrigger(which) {
                parsed.apply(to: trigger)
            }
        }
    }

    @MainActor
    private func retarget(_ controller: GCController?) {
        guard controller !== target else { return }
        reset(target)
        target = controller
        rumble.retarget(controller)
        // Replay the session's feedback state so a swapped-in controller looks the same.
        if let (r, g, b) = lastLight {
            controller?.light?.color = GCColor(
                red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255)
        }
        if let bits = lastPlayerBits {
            controller?.playerIndex = Self.playerIndex(forBits: bits)
        }
        for which in 0..<2 {
            if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) {
                effect.apply(to: trigger)
            }
        }
    }

    @MainActor
    private func reset(_ controller: GCController?) {
        guard let c = controller else { return }
        c.playerIndex = .indexUnset
        if let ds = c.extendedGamepad as? GCDualSenseGamepad {
            ds.leftTrigger.setModeOff()
            ds.rightTrigger.setModeOff()
        }
    }

    @MainActor
    private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? {
        guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil }
        return which == 0 ? ds.leftTrigger : ds.rightTrigger
    }
}

#if DEBUG
/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds
/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a
/// live session uses — just aimed at the physically-connected controller instead of the
/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player
/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point:
/// a passing test exercises the exact code a session runs.
@MainActor
public final class ControllerTester: ObservableObject {
    private let renderer = RumbleRenderer()
    private weak var controller: GCController?

    /// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" —
    /// for the test panel to display so it's obvious which path a given pad takes.
    @Published public private(set) var rumbleBackend = "—"

    public init() {}

    /// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every
    /// active-controller change.
    public func target(_ c: GCController?) {
        guard c !== controller else { return }
        controller = c
        renderer.retarget(c) { [weak self] note in
            Task { @MainActor in self?.rumbleBackend = note }
        }
    }

    /// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to
    /// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`.
    public func rumble(low: Float, high: Float) {
        func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) }
        renderer.apply(low: u16(low), high: u16(high))
    }

    public func stopRumble() { renderer.apply(low: 0, high: 0) }

    /// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect`
    /// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad.
    public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) {
        guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
        effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger)
    }

    public func resetTriggers() {
        guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return }
        ds.leftTrigger.setModeOff()
        ds.rightTrigger.setModeOff()
    }

    /// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light.
    public func setLight(_ color: GCColor?) {
        controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0)
    }

    /// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear).
    public func setPlayerIndex(_ index: GCControllerPlayerIndex) {
        controller?.playerIndex = index
    }

    /// Silence every channel and release the controller — call on the panel's disappear.
    public func stop() {
        resetTriggers()
        setPlayerIndex(.indexUnset)
        setLight(nil)
        renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref
        controller = nil
    }
}
#endif
