import Foundation
import Observation
import SessionCore
import WireProtocol

/// Terminal screen state (T-iOS-11, plan §3.5): consumes the engine's
/// `SessionEvent` stream on the MainActor and turns it into UI state — output
/// forwarded to SwiftTerm, connection banner, non-retryable failure copy and
/// the read-only exit state. All outbound traffic (key bar, hardware key
/// commands, SwiftTerm delegate) funnels through here into ONE ordered queue.
///
/// Testability / stream-sharing decision (documented per task brief):
/// `engine.events` is a single-consumer `AsyncStream`, and GateViewModel
/// (T-iOS-14) must eventually observe `.gate`/`.digest` from the SAME stream.
/// So the events stream is injected SEPARATELY from the engine: today callers
/// pass `engine.events` verbatim (tests do exactly that, over
/// `TestSupport.FakeTransport`); the T-iOS-15 wiring may pass a fan-out branch
/// instead without touching this class.
///
/// Swift 6 strict concurrency: the class is `@MainActor`, the terminal sink is
/// `@MainActor`-typed — `feed()` off the main actor cannot compile.
@MainActor
@Observable
final class TerminalViewModel {
    // MARK: - UI state model

    /// Connection banner state (mirrors the web client's status line:
    /// public/terminal-session.ts `SessionStatus`).
    enum ConnectionBanner: Equatable {
        case none
        case connecting
        /// Retry `attempt` fires after `next` (ReconnectMachine ladder 1s→30s).
        case reconnecting(attempt: Int, next: Duration)
    }

    /// Which terminal the user is looking at: a live one, a dead-for-good one
    /// (non-retryable failure), or a finished one (read-only).
    enum TerminalPhase: Equatable {
        case live
        /// Non-retryable terminal failure — actionable copy instead of a spinner.
        case failed(message: String)
        /// The shell exited; the terminal stays readable but accepts no input.
        case exited(code: Int, reason: String?)
    }

    /// Terminal geometry snapshot (Equatable for test assertions; the frozen
    /// engine API takes a tuple, so `asTuple` bridges).
    struct TerminalDims: Equatable, Sendable {
        let cols: Int
        let rows: Int
        var asTuple: (cols: Int, rows: Int) { (cols, rows) }
    }

    /// Actionable copy for `.failed(.replayTooLarge)` (plan §3.2 / §3.2.1
    /// coupling warning): reconnecting would deterministically fail forever,
    /// so the user must change a knob, not wait.
    static let replayTooLargeMessage =
        "服务器 scrollback 超过客户端上限，请调低 SCROLLBACK_BYTES 或调高客户端上限"

    /// Last VALID dims forwarded to the engine (SwiftTerm `sizeChanged`).
    /// Read by the wiring layer for `notifyForegrounded(dims:)` — the frozen
    /// §3.2 signature needs real cols/rows and this is their single source.
    private(set) var lastSentDims: TerminalDims?

    private(set) var banner: ConnectionBanner = .none
    private(set) var phase: TerminalPhase = .live
    /// Server-adopted session id (ALWAYS the server-issued one — persisting it
    /// per host is the T-iOS-15 wiring's job via `LastSessionStore`).
    private(set) var sessionId: UUID?
    /// Sanitized OSC title (T-iOS-23). Raw `setTerminalTitle` delegate input
    /// is HOST/ATTACKER-CONTROLLED and passes `TitleSanitizer` at THIS
    /// boundary; nil = no title (empty after sanitisation).
    private(set) var terminalTitle: String?

    /// Read-only = no input reaches the PTY (exit / terminal failure). Resize
    /// is NOT gated here — the engine owns terminal-state dropping.
    var isReadOnly: Bool { phase != .live }

    /// Single render input for `ReconnectBanner`: terminal phases win over
    /// transient connection states.
    var bannerModel: ReconnectBanner.Model? {
        switch phase {
        case .failed(let message):
            return .failed(message: message)
        case .exited(let code, let reason):
            return .exited(code: code, reason: reason)
        case .live:
            break
        }
        switch banner {
        case .none:
            return nil
        case .connecting:
            return .connecting
        case .reconnecting(let attempt, let next):
            return .reconnecting(attempt: attempt, retryIn: next)
        }
    }

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

    @ObservationIgnored private let engine: SessionEngine
    @ObservationIgnored private let events: AsyncStream<SessionEvent>
    @ObservationIgnored private var consumeTask: Task<Void, Never>?
    /// Where output bytes go (SwiftTerm's `feed(text:)`). `@MainActor`-typed:
    /// feeding off the main actor is a compile error.
    @ObservationIgnored private var terminalSink: (@MainActor (String) -> Void)?
    /// Output that arrived before the SwiftTerm view existed; flushed in order
    /// the moment the sink attaches (replay must never be dropped).
    @ObservationIgnored private var pendingOutput: [String] = []
    /// Ordered outbound queue: UIKit callbacks are synchronous, `engine.send`
    /// is async — one pump task preserves submission order (two quick key taps
    /// must never race each other onto the wire).
    @ObservationIgnored private var sendQueue: [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
    /// Sends handed to the engine so far — `waitUntilForwarded` barrier counter.
    @ObservationIgnored private(set) var forwardedSendCount = 0
    /// Input dropped because the terminal is read-only (exit/failed).
    @ObservationIgnored private(set) var droppedReadOnlyInputCount = 0
    /// Test tap, called after each event is applied (state already coherent).
    @ObservationIgnored var onEventApplied: (@MainActor (SessionEvent) -> Void)?

    // MARK: - OSC title surface (T-iOS-23; wired by TerminalSessionController)

    /// List-side registry hook: fires with the ADOPTED sessionId and the
    /// SANITIZED title ("" = title cleared — the registry drops the entry).
    /// Titles arriving before adoption are held and forwarded once on
    /// `.adopted` (defensive — `attached` always precedes output on the wire).
    @ObservationIgnored var onTitleChanged: (@MainActor (UUID, String) -> Void)?
    /// Latest sanitized title not yet delivered to `onTitleChanged` because
    /// no sessionId was known at the time. nil = nothing held.
    @ObservationIgnored private var heldTitleForward: String?

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

    @ObservationIgnored private var eventWaiters: [CountWaiter] = []
    @ObservationIgnored private var sendWaiters: [CountWaiter] = []

    // MARK: - Lifecycle

    /// - Parameters:
    ///   - engine: send-side dependency (`send` only — `open`/`close` belong to
    ///     the T-iOS-15 wiring that constructs the engine).
    ///   - events: the event stream to consume; pass `engine.events` unless a
    ///     fan-out branch is needed (see type doc).
    init(engine: SessionEngine, events: AsyncStream<SessionEvent>) {
        self.engine = engine
        self.events = events
    }

    /// Begin consuming events. Idempotent — a second call is a no-op (the
    /// stream has exactly one consumer).
    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). Does NOT close the engine — detach
    /// vs. keep-running is the T-iOS-15 lifecycle owner's call.
    func stop() {
        consumeTask?.cancel()
        consumeTask = nil
        releaseAllWaiters()
    }

    // MARK: - Terminal output sink

    /// Attach the SwiftTerm feed target. Buffered output (anything that
    /// arrived before the view existed) flushes immediately, in order.
    func attachTerminalSink(_ sink: @escaping @MainActor (String) -> Void) {
        terminalSink = sink
        let buffered = pendingOutput
        pendingOutput = []
        for chunk in buffered {
            sink(chunk)
        }
    }

    // MARK: - Outbound (KeyBar / UIKeyCommand / SwiftTerm delegate)

    /// Key-bar or hardware key press: bytes resolved through `KeyByteMap`
    /// (the single source of truth — plan §7 T-iOS-11).
    func send(key: KeyByteMap.Key) {
        sendInput(KeyByteMap.bytes(for: key))
    }

    /// Raw input bytes, verbatim (invariant #9 — no content filtering).
    /// Dropped (and counted) while the terminal is read-only.
    func sendInput(_ data: String) {
        guard !isReadOnly else {
            droppedReadOnlyInputCount += 1
            return
        }
        enqueueSend(.input(data: data))
    }

    /// SwiftTerm reported an OSC 0/2 title (`setTerminalTitle` delegate).
    /// Sanitize FIRST — the raw string is untrusted terminal output — then
    /// surface locally and forward to the list registry (T-iOS-23).
    func setTerminalTitle(_ raw: String) {
        let sanitized = TitleSanitizer.sanitize(raw)
        terminalTitle = sanitized.isEmpty ? nil : sanitized
        guard let sessionId else {
            heldTitleForward = sanitized
            return
        }
        onTitleChanged?(sessionId, sanitized)
    }

    /// Terminal geometry changed (SwiftTerm `sizeChanged`). Always forwarded —
    /// the engine validates bounds and owns terminal-state dropping. Valid
    /// dims are remembered in `lastSentDims` so the T-iOS-15 wiring can feed
    /// `engine.notifyForegrounded(dims:)` on scenePhase reactivation (closes
    /// the W4 documented deviation; invalid dims never overwrite a good pair).
    func sendResize(cols: Int, rows: Int) {
        if Validation.isValidResize(cols: cols, rows: rows) {
            lastSentDims = TerminalDims(cols: cols, rows: rows)
        }
        enqueueSend(.resize(cols: cols, rows: rows))
    }

    // MARK: - Event application (single consumer, MainActor)

    private func apply(_ event: SessionEvent) {
        switch event {
        case .connection(let state):
            applyConnection(state)
        case .adopted(let id):
            sessionId = id // ALWAYS adopt the server-issued id
            if let held = heldTitleForward {
                heldTitleForward = nil
                onTitleChanged?(id, held)
            }
        case .output(let data):
            deliverOutput(data)
        case .exited(let code, let reason):
            phase = .exited(code: code, reason: reason)
        case .gate, .telemetry, .digest:
            break // GateViewModel's domain (T-iOS-14; wired in T-iOS-15)
        }
        processedEventCount += 1
        onEventApplied?(event)
        resumeEventWaiters()
    }

    private func applyConnection(_ state: ConnectionState) {
        switch state {
        case .connecting:
            banner = .connecting
        case .connected:
            banner = .none
        case .reconnecting(let attempt, let next):
            banner = .reconnecting(attempt: attempt, next: next)
        case .closed:
            banner = .none // deliberate end; exit/failure phase (if any) stays
        case .failed(.replayTooLarge):
            banner = .none
            phase = .failed(message: Self.replayTooLargeMessage)
        }
    }

    private func deliverOutput(_ data: String) {
        guard let terminalSink else {
            pendingOutput = pendingOutput + [data]
            return
        }
        terminalSink(data)
    }

    // MARK: - Ordered send pump

    private func enqueueSend(_ message: ClientMessage) {
        sendQueue = sendQueue + [message]
        guard !isPumping else { return }
        isPumping = true
        Task { await self.pumpSendQueue() }
    }

    private func pumpSendQueue() async {
        while let next = sendQueue.first {
            sendQueue = Array(sendQueue.dropFirst())
            await engine.send(next)
            forwardedSendCount += 1
            resumeSendWaiters()
        }
        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 `sendCount` messages were handed to the engine.
    func waitUntilForwarded(sendCount target: Int) async {
        await withCheckedContinuation { continuation in
            guard forwardedSendCount < target else {
                continuation.resume()
                return
            }
            sendWaiters = sendWaiters + [CountWaiter(target: target, continuation: continuation)]
        }
    }

    private func resumeEventWaiters() {
        let satisfied = eventWaiters.filter { $0.target <= processedEventCount }
        eventWaiters = eventWaiters.filter { $0.target > processedEventCount }
        for waiter in satisfied {
            waiter.continuation.resume()
        }
    }

    private func resumeSendWaiters() {
        let satisfied = sendWaiters.filter { $0.target <= forwardedSendCount }
        sendWaiters = sendWaiters.filter { $0.target > forwardedSendCount }
        for waiter in satisfied {
            waiter.continuation.resume()
        }
    }

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