import Foundation
import WireProtocol

/// One "open session" connection engine (frozen contract, plan §3.2 —
/// T-iOS-10). The foreground session holds exactly one live engine; switching
/// sessions = `close()` + a fresh engine (the server replays the ring buffer).
///
/// Responsibilities:
/// - Owns the single WS connection via the injected `TermTransport` (the ONLY
///   WS I/O boundary — `FakeTransport` and `URLSessionTermTransport` are
///   indistinguishable here, plan §3.2).
/// - Enforces the server's first-frame rule: `attach` is ALWAYS the first
///   frame on every (re)connection (src/server.ts:707-711); frames submitted
///   earlier queue behind it in order, never reordered.
/// - Survives disconnects: `ReconnectMachine` backoff on the injected clock;
///   `notifyForegrounded` short-circuits the retry timer (the user is looking
///   — connect NOW) and re-sends dims (latest-writer-wins sizing, v0.4).
/// - Treats the server as an UNTRUSTED input source (standards §4): every
///   frame goes through `MessageCodec.decodeServer`; nil → dropped + counted,
///   never a crash.
/// - Terminal states never re-enter backoff: `exit` (session over, including
///   spawn failure -1, M4) and `.replayTooLarge` (deterministic failure).
///
/// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY
/// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no
/// dims, so the INITIAL resize goes out on the first `notifyForegrounded` /
/// `send(.resize)` (SwiftTerm fires `sizeChanged` on first layout, so this is
/// immediate in practice). Every LATER attach replays `lastKnownDims` directly
/// after the attach frame so a reconnect reclaims full-screen.
///
/// Gate decisions (documented decision): the wire `approve` frame carries no
/// epoch (the server re-parses only `mode`, src/server.ts:91-102), so the
/// engine guards decisions with `GateTracker.canDecide` against the epoch of
/// the most recently EMITTED gate — a decision arriving after the gate lifted
/// (or before any gate rose) is dropped, and decisions are never queued while
/// disconnected. The tap-vs-newest-gate epoch comparison lives in the UI layer
/// (T-iOS-14), mirroring the web client (public/terminal-session.ts:98-102).
public actor SessionEngine {
    // MARK: - Dependencies (immutable)

    private let transport: any TermTransport
    private let clock: any Clock<Duration>
    private let endpoint: HostEndpoint
    /// Away-digest injection point (plan §3.2): production wraps
    /// `APIClient.events`; tests inject a fake. Keeps `TermTransport` the only
    /// WS boundary — the engine never holds an HTTP client.
    private let eventsSource: @Sendable (UUID) async throws -> [TimelineEvent]

    // MARK: - Event outlet (single consumer: the ViewModel)

    public nonisolated let events: AsyncStream<SessionEvent>
    private let eventsContinuation: AsyncStream<SessionEvent>.Continuation

    // MARK: - Connection runtime state (actor-confined)

    private var reconnect: ReconnectMachine
    private var connection: TransportConnection?
    private var connectionTask: Task<Void, Never>?
    private var pingTask: Task<Void, Never>?
    private var digestTask: Task<Void, Never>?
    /// Bumped by `close()` and every lifecycle restart: stale lifecycle tasks
    /// compare against it after each await and bow out silently.
    private var generation = 0
    /// True once attach (+dims+queue) went out on the CURRENT connection.
    private var isAttachReady = false
    private var reconnectAttempt = 0
    private var pendingOutbound: [ClientMessage] = []

    // MARK: - Session state

    private var currentSessionId: UUID?
    private var requestedCwd: String?
    private var lastKnownDims: (cols: Int, rows: Int)?
    private var gateTracker = GateTracker.initial
    /// Epoch of the most recently emitted (non-nil) gate — see type doc.
    private var latestEmittedGateEpoch = 0
    /// Moment the live connection was lost; consumed by the next adoption to
    /// reduce the away digest. `nil` = no away window pending.
    private var disconnectedAt: Date?

    // MARK: - Lifecycle flags

    private var hasOpened = false
    private var hasExited = false
    private var hasFailedTerminally = false
    private var isClosedByClient = false

    private var isTerminal: Bool { hasExited || hasFailedTerminally }

    // MARK: - Diagnostics (internal, test-visible; server input is untrusted)

    private(set) var droppedServerFrameCount = 0
    private(set) var droppedDecisionCount = 0
    private(set) var droppedOutboundFrameCount = 0
    private(set) var digestFetchFailureCount = 0

    /// `AwayDigest.reduce` truncation is a UI concern (T-iOS-14 renders the
    /// list); no Tunables constant exists for an engine-side cap, so the
    /// engine passes the no-truncation identity and keeps every post-`since`
    /// event.
    private static let digestRecentLimit = Int.max

    // MARK: - Init (frozen signature, plan §3.2)

    public init(
        transport: any TermTransport,
        clock: any Clock<Duration>,
        endpoint: HostEndpoint,
        reconnect: ReconnectMachine = .initial,
        eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
    ) {
        self.transport = transport
        self.clock = clock
        self.endpoint = endpoint
        self.reconnect = reconnect
        self.eventsSource = eventsSource
        (events, eventsContinuation) = AsyncStream<SessionEvent>.makeStream()
    }

    // MARK: - Public API (frozen signatures, plan §3.2)

    /// Connect → first-frame attach → replay → live. One-shot: later calls are
    /// ignored (reconnection is the engine's own job). A `sessionId` that the
    /// server's SESSION_ID_RE would reject (non-v4) is treated as nil — the
    /// server would silently drop the attach frame and hang the connection.
    /// An invalid (relative) `cwd` is stripped for the same reason
    /// (src/protocol.ts:142-149).
    public func open(sessionId: UUID?, cwd: String?) async {
        guard !hasOpened, !isClosedByClient, !isTerminal else { return }
        hasOpened = true
        currentSessionId = sessionId.flatMap { id in
            Validation.isValidSessionId(id.uuidString) ? id : nil
        }
        requestedCwd = cwd.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
        emit(.connection(.connecting))
        startLifecycle()
    }

    /// Submit one client frame. Before the current connection's attach went
    /// out (or while disconnected) frames QUEUE and flush after the next
    /// attach, order preserved — except gate decisions, which are only valid
    /// against the gate held right now and are dropped instead (type doc).
    public func send(_ message: ClientMessage) async {
        guard !isClosedByClient, !isTerminal else {
            droppedOutboundFrameCount += 1
            return
        }
        if case let .resize(cols, rows) = message {
            guard Validation.isValidResize(cols: cols, rows: rows) else {
                droppedOutboundFrameCount += 1 // server would silently discard it
                return
            }
            lastKnownDims = (cols, rows)
        }
        if isGateDecision(message) {
            guard isAttachReady, gateTracker.canDecide(epoch: latestEmittedGateEpoch) else {
                droppedDecisionCount += 1
                return
            }
        }
        guard isAttachReady, let connection else {
            pendingOutbound = pendingOutbound + [message]
            return
        }
        await transmit(message, over: connection)
    }

    /// The scene became active: reclaim full-screen (latest-writer-wins).
    /// Connected → just resize; disconnected → connect NOW (skip the backoff
    /// timer; the ladder itself only resets on a successful connect) and the
    /// re-attach replays these dims right after the attach frame.
    public func notifyForegrounded(dims: (cols: Int, rows: Int)) async {
        guard Validation.isValidResize(cols: dims.cols, rows: dims.rows) else {
            droppedOutboundFrameCount += 1
            return
        }
        lastKnownDims = dims
        guard hasOpened, !isClosedByClient, !isTerminal else { return }
        if isAttachReady, connection != nil {
            await send(.resize(cols: dims.cols, rows: dims.rows))
            return
        }
        let (machine, effect) = reconnect.reduce(.foregrounded)
        reconnect = machine
        guard case .connectNow = effect else { return }
        startLifecycle()
    }

    /// Explicit detach: the server-side PTY keeps running (invariant #2).
    /// Cancels every engine task, closes the transport, emits `.closed`, and
    /// finishes the events stream. Idempotent.
    public func close() async {
        guard !isClosedByClient else { return }
        isClosedByClient = true
        generation += 1 // invalidate in-flight lifecycle resumptions
        connectionTask?.cancel()
        digestTask?.cancel()
        stopPing()
        if let connection {
            self.connection = nil
            await connection.close()
        }
        isAttachReady = false
        emit(.connection(.closed))
        eventsContinuation.finish()
    }

    // MARK: - Lifecycle loop

    /// Starts (or restarts) the connect→attach→pump→backoff loop under a fresh
    /// generation; the previous task is cancelled and neutered by the bump.
    private func startLifecycle() {
        generation += 1
        let gen = generation
        connectionTask?.cancel()
        connectionTask = Task { await self.runLifecycle(generation: gen) }
    }

    private func runLifecycle(generation gen: Int) async {
        while true {
            let established: (connection: TransportConnection, pinger: (any ConnectionPinger)?)
            do {
                established = try await establishConnection()
            } catch {
                guard isCurrent(gen) else { return }
                guard resolveDisconnect(error: error) else { return }
                guard await backoffThenRetry(generation: gen) else { return }
                continue
            }
            guard isCurrent(gen) else {
                await established.connection.close()
                return
            }
            connection = established.connection
            do {
                try await performAttachHandshake(on: established.connection)
            } catch {
                guard isCurrent(gen) else { return }
                teardownConnection()
                guard resolveDisconnect(error: error) else { return }
                guard await backoffThenRetry(generation: gen) else { return }
                continue
            }
            guard isCurrent(gen) else { return }
            becomeConnected(established, generation: gen)
            let streamError = await pumpFrames(from: established.connection, generation: gen)
            guard isCurrent(gen) else { return }
            teardownConnection()
            guard resolveDisconnect(error: streamError) else { return }
            guard await backoffThenRetry(generation: gen) else { return }
        }
    }

    /// Connects through the internal ping hook when the transport supports it
    /// (URLSessionTermTransport does; FakeTransport does not — T-iOS-9).
    private func establishConnection() async throws
        -> (connection: TransportConnection, pinger: (any ConnectionPinger)?) {
        if let pingable = transport as? any PingableTermTransport {
            let (connection, pinger) = try await pingable.connectPingable(to: endpoint)
            return (connection, pinger)
        }
        return (try await transport.connect(to: endpoint), nil)
    }

    /// First-frame rule: attach, then last-known dims (a shared PTY is sized
    /// by the latest writer), then the queued frames in submission order.
    /// A frame whose send throws stays queued for the next attach.
    private func performAttachHandshake(on connection: TransportConnection) async throws {
        let cwd = currentSessionId == nil ? requestedCwd : nil
        try await connection.send(MessageCodec.encode(.attach(sessionId: currentSessionId, cwd: cwd)))
        if let dims = lastKnownDims {
            try await connection.send(MessageCodec.encode(.resize(cols: dims.cols, rows: dims.rows)))
        }
        while let queued = pendingOutbound.first {
            try await connection.send(MessageCodec.encode(queued))
            pendingOutbound = Array(pendingOutbound.dropFirst())
        }
        isAttachReady = true
    }

    private func becomeConnected(
        _ established: (connection: TransportConnection, pinger: (any ConnectionPinger)?),
        generation gen: Int
    ) {
        (reconnect, _) = reconnect.reduce(.connected) // back-off ladder resets
        reconnectAttempt = 0
        if let pinger = established.pinger {
            startPing(pinger, connection: established.connection, generation: gen)
        }
        emit(.connection(.connected))
    }

    /// Delivers server frames in arrival order until the stream ends.
    /// Returns the stream error (nil = clean finish).
    private func pumpFrames(
        from connection: TransportConnection, generation gen: Int
    ) async -> (any Error)? {
        do {
            for try await frame in connection.frames {
                guard isCurrent(gen) else { return nil }
                handleFrame(frame)
            }
            return nil
        } catch {
            return error
        }
    }

    private func teardownConnection() {
        connection = nil
        isAttachReady = false
        stopPing()
        // The away window opens at the FIRST loss; later failed retries keep it.
        disconnectedAt = disconnectedAt ?? Date()
    }

    /// Classifies how a connection attempt/stream ended. Returns true when the
    /// lifecycle should enter backoff; terminal classifications emit their
    /// final event and finish the events stream.
    private func resolveDisconnect(error: (any Error)?) -> Bool {
        if isClosedByClient { return false }
        if hasExited {
            emit(.connection(.closed)) // session over — deliberate conclusion
            eventsContinuation.finish()
            return false
        }
        if let error, isReplayTooLarge(error) {
            hasFailedTerminally = true
            emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1)
            eventsContinuation.finish()
            return false
        }
        return true
    }

    /// One backoff rung: schedule per the reducer, announce, sleep on the
    /// injected clock, then ask for connectNow. False = stop the lifecycle
    /// (cancelled by close()/foregrounded restart, or superseded).
    private func backoffThenRetry(generation gen: Int) async -> Bool {
        let (afterDisconnect, effect) = reconnect.reduce(.disconnected)
        reconnect = afterDisconnect
        guard case .scheduleRetry(let delay) = effect else { return false }
        reconnectAttempt += 1
        emit(.connection(.reconnecting(attempt: reconnectAttempt, next: delay)))
        do {
            try await clock.sleep(for: delay, tolerance: nil)
        } catch {
            return false // CancellationError: close() or a lifecycle restart
        }
        guard isCurrent(gen) else { return false }
        let (afterTimer, fireEffect) = reconnect.reduce(.retryTimerFired)
        reconnect = afterTimer
        guard case .connectNow = fireEffect else { return false }
        return true
    }

    // MARK: - Inbound frames (untrusted)

    /// Every frame goes through `MessageCodec.decodeServer`; nil → dropped and
    /// counted, never a crash (plan §7 T-iOS-10 安全注).
    private func handleFrame(_ frame: String) {
        guard let message = MessageCodec.decodeServer(frame) else {
            droppedServerFrameCount += 1
            return
        }
        switch message {
        case .attached(let sessionId):
            currentSessionId = sessionId // ALWAYS adopt the server-issued id
            emit(.adopted(sessionId: sessionId))
            scheduleAwayDigestIfNeeded(for: sessionId)
        case .output(let data):
            emit(.output(data))
        case .exit(let code, let reason):
            hasExited = true // terminal: no reconnect once the stream ends
            emit(.exited(code: code, reason: reason))
        case .status(_, let detail, let pending, let gate):
            applyGateFrame(pending: pending, gate: gate, detail: detail)
        case .telemetry(let telemetry):
            emit(.telemetry(telemetry))
        }
    }

    /// Folds a status frame into `GateTracker`; emits `.gate` only on change
    /// (rising edge mints a new epoch, falling edge emits nil — T-iOS-6).
    private func applyGateFrame(pending: Bool, gate: GateKind?, detail: String?) {
        let next = gateTracker.reduce(pending: pending, gate: gate, detail: detail)
        defer { gateTracker = next }
        guard next.current != gateTracker.current else { return }
        if let held = next.current { latestEmittedGateEpoch = held.epoch }
        emit(.gate(next.current))
    }

    // MARK: - Away digest

    /// Consumes the pending away window (set at disconnect) exactly once per
    /// reconnect: fetch the timeline off the frame loop and emit ONE digest.
    /// The initial attach has no away window, so it never digests.
    private func scheduleAwayDigestIfNeeded(for sessionId: UUID) {
        guard let since = disconnectedAt else { return }
        disconnectedAt = nil
        let source = eventsSource
        digestTask = Task { [weak self] in
            do {
                let timeline = try await source(sessionId)
                let digest = AwayDigest.reduce(
                    events: timeline, since: since, limit: Self.digestRecentLimit
                )
                await self?.emitDigest(digest)
            } catch {
                await self?.recordDigestFetchFailure()
            }
        }
    }

    private func emitDigest(_ digest: AwayDigest) {
        emit(.digest(digest))
    }

    private func recordDigestFetchFailure() {
        digestFetchFailureCount += 1 // fetch failure = no digest, never a crash
    }

    // MARK: - Keep-alive (T-iOS-9 internal hook + T-iOS-5 scheduler)

    /// `URLSessionWebSocketTask` has no automatic ping (plan §1): run the
    /// PingScheduler against this connection's pinger; on `connectionLost`,
    /// close the half-dead connection so the pump ends and the normal backoff
    /// reconnect takes over — never "looks connected but dead".
    private func startPing(
        _ pinger: any ConnectionPinger, connection: TransportConnection, generation gen: Int
    ) {
        let scheduler = PingScheduler()
        let clock = self.clock
        pingTask = Task { [weak self] in
            let outcome = await scheduler.run(clock: clock) { await pinger.ping() }
            guard outcome == .connectionLost else { return }
            await self?.handlePingLoss(connection: connection, generation: gen)
        }
    }

    private func handlePingLoss(connection: TransportConnection, generation gen: Int) async {
        guard isCurrent(gen), !isClosedByClient else { return }
        await connection.close() // finishes frames → pump ends → backoff path
    }

    private func stopPing() {
        pingTask?.cancel()
        pingTask = nil
    }

    // MARK: - Helpers

    private func transmit(_ message: ClientMessage, over connection: TransportConnection) async {
        do {
            try await connection.send(MessageCodec.encode(message))
        } catch {
            // The connection is dying; the stream-end path owns the reconnect.
            droppedOutboundFrameCount += 1
        }
    }

    private func isGateDecision(_ message: ClientMessage) -> Bool {
        switch message {
        case .approve, .reject: return true
        case .attach, .input, .resize: return false
        }
    }

    private func isCurrent(_ gen: Int) -> Bool {
        generation == gen
    }

    private func isReplayTooLarge(_ error: any Error) -> Bool {
        (error as? TermTransportError) == .replayTooLarge
    }

    private func emit(_ event: SessionEvent) {
        eventsContinuation.yield(event)
    }
}
