import Foundation
import Observation
import SessionCore
import UIKit
import WireProtocol

/// Haptic seam (T-iOS-14): gate-arrival feedback is injected so tests can
/// assert "exactly once per gate epoch" without UIKit hardware.
@MainActor
protocol HapticSignaling {
    /// A NEW gate (fresh epoch) is waiting for the user's decision.
    func gateDidArrive()
}

/// Production haptics: notification-style buzz — a gate is Claude asking for a
/// decision (THE steering primitive), not a mere UI tick.
@MainActor
final class GateHaptics: HapticSignaling {
    private let generator = UINotificationFeedbackGenerator()

    func gateDidArrive() {
        generator.notificationOccurred(.warning)
    }
}

/// T-iOS-14 · Gate + away-digest state (plan §3.5): a STANDALONE
/// `@MainActor @Observable` VM consuming the SAME `SessionEvent` stream as
/// `TerminalViewModel` — `.gate`/`.digest` are its domain, everything else is
/// ignored here. TerminalScreen wiring (who fans the stream out) is T-iOS-15.
///
/// Stale-tap guard (FIRST line): every rendered gate button carries the epoch
/// of the gate it was rendered against; `decide` compares that epoch — and the
/// affordance — against the LATEST gate event received and silently drops a
/// mismatch, so a slow tap can never resolve a NEWER gate than the one that
/// was on screen (mirrors the web's pendingEpoch nonce,
/// public/terminal-session.ts:98-102). The engine's `GateTracker.canDecide`
/// is the SECOND line (SessionEngine type doc) — both must agree to send.
///
/// Plan-gate mapping is frozen in SessionCore
/// (`GateState.Affordance.clientMessage`, mirror of public/tabs.ts:345-347):
/// Approve+Auto → acceptEdits, Approve+Review → default, Keep Planning →
/// reject. There is NO allowAutoMode gating anywhere — the web client never
/// gates the plan three-way, and `uiConfig` is reserved for a future
/// permission-mode picker (plan §3.1 note).
@MainActor
@Observable
final class GateViewModel {
    // MARK: - Observable UI state

    /// The latest gate event received (nil = lifted). This is what taps are
    /// validated against.
    private(set) var currentGate: GateState?
    /// Visible away digest (nil = nothing rendered; all-zero digests never
    /// become visible).
    private(set) var digest: AwayDigest?
    /// True once the user opened the recent-entries detail; expansion cancels
    /// the auto-fade (a digest must never vanish while being read).
    private(set) var isDigestExpanded = false

    /// Tool gate → two-button `GateBanner`.
    var toolGate: GateState? { gate(of: .tool) }
    /// Plan gate → three-way `PlanGateSheet`.
    var planGate: GateState? { gate(of: .plan) }

    // MARK: - Dependencies & plumbing (not observed)

    @ObservationIgnored private let engine: SessionEngine
    @ObservationIgnored private let events: AsyncStream<SessionEvent>
    @ObservationIgnored private let haptics: any HapticSignaling
    @ObservationIgnored private let clock: any Clock<Duration>
    @ObservationIgnored private var consumeTask: Task<Void, Never>?
    @ObservationIgnored private var fadeTask: Task<Void, Never>?
    /// Highest epoch that already buzzed — the haptic fires ONCE per epoch
    /// (sustained-pending refreshes reuse the epoch and stay silent).
    @ObservationIgnored private var lastHapticEpoch = 0
    /// Ordered decision queue: taps are synchronous, `engine.send` is async —
    /// one pump task preserves submission order (same pattern as
    /// TerminalViewModel's send pump).
    @ObservationIgnored private var decisionQueue: [ClientMessage] = []
    @ObservationIgnored private var isPumping = false

    // MARK: - Test-visible diagnostics & deterministic barriers (internal)

    /// Events applied so far — `waitUntilProcessed` barrier counter.
    @ObservationIgnored private(set) var processedEventCount = 0
    /// Decisions handed to the engine — `waitUntilForwarded` barrier counter.
    @ObservationIgnored private(set) var forwardedDecisionCount = 0
    /// Taps dropped by the first-line guard (stale epoch / lifted gate /
    /// affordance no longer offered).
    @ObservationIgnored private(set) var droppedStaleDecisionCount = 0
    /// Completed auto-fades — `waitUntilFadeCompleted` barrier counter.
    @ObservationIgnored private(set) var fadeCompletedCount = 0
    /// Test tap, called after each event is applied (state already coherent).
    @ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?

    private struct CountWaiter {
        let target: Int
        let continuation: CheckedContinuation<Void, Never>
    }

    @ObservationIgnored private var eventWaiters: [CountWaiter] = []
    @ObservationIgnored private var decisionWaiters: [CountWaiter] = []
    @ObservationIgnored private var fadeWaiters: [CountWaiter] = []

    // MARK: - Lifecycle

    /// - Parameters:
    ///   - engine: send-side dependency (decisions only — open/close belong to
    ///     the T-iOS-15 lifecycle owner).
    ///   - events: the event stream to consume; the T-iOS-15 wiring passes a
    ///     fan-out branch of `engine.events` shared with `TerminalViewModel`.
    ///   - haptics: gate-arrival feedback (production: `GateHaptics`).
    ///   - clock: drives the digest auto-fade (production: `ContinuousClock`).
    init(
        engine: SessionEngine, events: AsyncStream<SessionEvent>,
        haptics: any HapticSignaling, clock: any Clock<Duration>
    ) {
        self.engine = engine
        self.events = events
        self.haptics = haptics
        self.clock = clock
    }

    /// Begin consuming events. Idempotent — a second call is a no-op.
    func start() {
        guard consumeTask == nil else { return }
        consumeTask = Task { [weak self] in
            guard let events = self?.events else { return }
            for await event in events {
                guard let self else { return }
                self.apply(event)
            }
            self?.releaseAllWaiters() // stream over — never leave a test hanging
        }
    }

    /// Stop consuming (screen torn down); cancels the fade timer too.
    func stop() {
        consumeTask?.cancel()
        consumeTask = nil
        cancelFade()
        releaseAllWaiters()
    }

    // MARK: - Decisions (Approve / Reject / plan three-way)

    /// Resolve the held gate with `affordance`, tapped against the gate whose
    /// `epoch` the button rendered. First-line stale guard: the epoch must
    /// match the LATEST gate received AND the affordance must still be one the
    /// current gate offers (a same-epoch kind morph invalidates old buttons).
    /// Mismatch → dropped, nothing is sent.
    func decide(_ affordance: GateState.Affordance, epoch: Int) {
        guard let gate = currentGate, gate.epoch == epoch,
              gate.affordances.contains(affordance)
        else {
            droppedStaleDecisionCount += 1
            return
        }
        decisionQueue = decisionQueue + [affordance.clientMessage]
        pumpIfIdle()
    }

    // MARK: - Digest interactions

    /// Show the recent-entries detail; cancels the auto-fade so the digest
    /// never vanishes while being read (documented decision).
    func expandDigest() {
        guard digest != nil else { return }
        isDigestExpanded = true
        cancelFade()
    }

    /// Explicitly dismiss the digest (the ✕ on the summary row).
    func dismissDigest() {
        digest = nil
        isDigestExpanded = false
        cancelFade()
    }

    // MARK: - Event application (MainActor)

    private func apply(_ event: SessionEvent) {
        switch event {
        case .gate(let gate):
            applyGate(gate)
        case .digest(let digest):
            applyDigest(digest)
        case .connection, .adopted, .output, .exited, .telemetry:
            break // TerminalViewModel's domain (T-iOS-11)
        }
        processedEventCount += 1
        onEventApplied?(event)
        eventWaiters = drainWaiters(eventWaiters, reached: processedEventCount)
    }

    private func applyGate(_ gate: GateState?) {
        currentGate = gate
        guard let gate, gate.epoch > lastHapticEpoch else { return }
        lastHapticEpoch = gate.epoch // exactly one buzz per epoch
        haptics.gateDidArrive()
    }

    /// All-zero digest → render nothing (spec). A visible digest starts
    /// collapsed and auto-fades after `Tunables.digestFadeDelay` unless the
    /// user expands it first.
    private func applyDigest(_ incoming: AwayDigest) {
        guard !incoming.isEmpty else { return }
        cancelFade()
        digest = incoming
        isDigestExpanded = false
        scheduleFade()
    }

    private func gate(of kind: GateKind) -> GateState? {
        guard let currentGate, currentGate.kind == kind else { return nil }
        return currentGate
    }

    // MARK: - Digest auto-fade (injected clock; zero real waits in tests)

    private func scheduleFade() {
        let clock = self.clock
        fadeTask = Task { [weak self] in
            do {
                try await clock.sleep(for: Tunables.digestFadeDelay, tolerance: nil)
            } catch {
                return // cancelled: expanded, dismissed, replaced, or stopped
            }
            self?.completeFade()
        }
    }

    private func completeFade() {
        guard !isDigestExpanded else { return } // expand cancels; belt & braces
        digest = nil
        fadeTask = nil
        fadeCompletedCount += 1
        fadeWaiters = drainWaiters(fadeWaiters, reached: fadeCompletedCount)
    }

    private func cancelFade() {
        fadeTask?.cancel()
        fadeTask = nil
    }

    // MARK: - Ordered decision pump

    private func pumpIfIdle() {
        guard !isPumping else { return }
        isPumping = true
        Task { await self.pumpDecisions() }
    }

    private func pumpDecisions() async {
        while let next = decisionQueue.first {
            decisionQueue = Array(decisionQueue.dropFirst())
            await engine.send(next)
            forwardedDecisionCount += 1
            decisionWaiters = drainWaiters(decisionWaiters, reached: forwardedDecisionCount)
        }
        isPumping = false
    }

    // MARK: - Deterministic test barriers (no polling, no real sleeps)

    /// Suspends until at least `eventCount` events have been applied.
    func waitUntilProcessed(eventCount target: Int) async {
        await withCheckedContinuation { continuation in
            guard processedEventCount < target else {
                continuation.resume()
                return
            }
            eventWaiters = eventWaiters + [CountWaiter(target: target, continuation: continuation)]
        }
    }

    /// Suspends until at least `decisionCount` decisions reached the engine.
    func waitUntilForwarded(decisionCount target: Int) async {
        await withCheckedContinuation { continuation in
            guard forwardedDecisionCount < target else {
                continuation.resume()
                return
            }
            decisionWaiters = decisionWaiters
                + [CountWaiter(target: target, continuation: continuation)]
        }
    }

    /// Suspends until at least `count` auto-fades have completed.
    func waitUntilFadeCompleted(count target: Int) async {
        await withCheckedContinuation { continuation in
            guard fadeCompletedCount < target else {
                continuation.resume()
                return
            }
            fadeWaiters = fadeWaiters + [CountWaiter(target: target, continuation: continuation)]
        }
    }

    /// Resumes every waiter satisfied by `count`; returns the still-waiting
    /// remainder (immutable style — the caller reassigns).
    private func drainWaiters(_ waiters: [CountWaiter], reached count: Int) -> [CountWaiter] {
        let satisfied = waiters.filter { $0.target <= count }
        for waiter in satisfied {
            waiter.continuation.resume()
        }
        return waiters.filter { $0.target > count }
    }

    private func releaseAllWaiters() {
        let all = eventWaiters + decisionWaiters + fadeWaiters
        eventWaiters = []
        decisionWaiters = []
        fadeWaiters = []
        for waiter in all {
            waiter.continuation.resume()
        }
    }
}
