import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm

/// T-iOS-14 · GateViewModel + gate/digest components (plan §7). All tests run
/// against a REAL `SessionEngine` over `TestSupport.FakeTransport` +
/// `FakeClock` (same testability choice as TerminalViewModelTests): the VM
/// consumes `engine.events` exactly as the T-iOS-15 wiring will — zero real
/// waits, zero network, and the engine's `GateTracker.canDecide` second-line
/// guard stays live underneath the VM's first-line tap-epoch guard.
@MainActor
@Suite("GateViewModel")
struct GateViewModelTests {
    // MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)

    private enum ServerFrames {
        static func attached(_ id: UUID) -> String {
            #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
        }

        static func status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String {
            var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"]
            if let gate { fields.append("\"gate\":\"\(gate)\"") }
            if let detail { fields.append("\"detail\":\"\(detail)\"") }
            return "{\(fields.joined(separator: ","))}"
        }
    }

    private struct TestStreamError: Error {}

    // MARK: - Test doubles

    /// Records haptic firings — the "exactly once per gate epoch" probe.
    @MainActor
    private final class HapticRecorder: HapticSignaling {
        private(set) var gateArrivalCount = 0
        func gateDidArrive() { gateArrivalCount += 1 }
    }

    // MARK: - Harness

    /// Engine over fakes + the VM under test, wired exactly like production.
    @MainActor
    private final class Harness {
        let transport = FakeTransport()
        let clock = FakeClock()
        let haptics = HapticRecorder()
        let engine: SessionEngine
        let viewModel: GateViewModel

        init(
            eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] }
        ) throws {
            let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
            let endpoint = try #require(HostEndpoint(baseURL: baseURL))
            engine = SessionEngine(
                transport: transport, clock: clock, endpoint: endpoint,
                eventsSource: eventsSource
            )
            viewModel = GateViewModel(
                engine: engine, events: engine.events, haptics: haptics, clock: clock
            )
            viewModel.start()
        }

        /// Standard preamble: open → .connecting → .connected → server confirms
        /// the attach. 3 events reach the VM.
        func openAndAdopt(_ id: UUID = UUID()) async {
            await engine.open(sessionId: nil, cwd: nil)
            await transport.emit(frame: ServerFrames.attached(id))
            await viewModel.waitUntilProcessed(eventCount: 3)
        }

        /// Frames the client sent on connection 0 (attach first, then decisions).
        func wireFrames() async -> [String] {
            let byConnection = await transport.sentFramesByConnection
            return byConnection.first ?? []
        }

        /// Drop the wire, ride the 1s backoff rung, reconnect, re-adopt `id` —
        /// the away window that makes the engine emit exactly one digest.
        /// Event count after: 7 (3 preamble + reconnecting + connected +
        /// adopted + digest).
        func reconnectForDigest(_ id: UUID) async {
            await transport.emitError(TestStreamError())
            await viewModel.waitUntilProcessed(eventCount: 4) // .reconnecting
            await clock.waitForSleepers(count: 1) // backoff rung parked
            clock.advance(by: .seconds(1))
            await transport.emit(frame: ServerFrames.attached(id))
            await viewModel.waitUntilProcessed(eventCount: 7)
        }
    }

    // MARK: - Gate routing (tool → banner, plan → sheet)

    @Test("tool gate → two-button banner state; plan gate → three-way sheet state; lifted → neither")
    func gateRoutingToolVsPlan() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt()

        // Act: tool gate rises (epoch 1).
        await harness.transport.emit(
            frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)

        // Assert: banner state only.
        #expect(harness.viewModel.toolGate == GateState(kind: .tool, detail: "Bash", epoch: 1))
        #expect(harness.viewModel.planGate == nil)

        // Act: gate lifted.
        await harness.transport.emit(frame: ServerFrames.status(pending: false))
        await harness.viewModel.waitUntilProcessed(eventCount: 5)

        // Assert: neither surface renders.
        #expect(harness.viewModel.toolGate == nil)
        #expect(harness.viewModel.planGate == nil)

        // Act: plan gate rises (epoch 2).
        await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
        await harness.viewModel.waitUntilProcessed(eventCount: 6)

        // Assert: sheet state only.
        #expect(harness.viewModel.planGate == GateState(kind: .plan, detail: nil, epoch: 2))
        #expect(harness.viewModel.toolGate == nil)
    }

    // MARK: - Wire mapping (mirror of public/tabs.ts:345-347 / src/types.ts:84-86)

    @Test("plan three-way maps EXACTLY: Approve+Auto→acceptEdits · Approve+Review→default · Keep Planning→reject — no allowAutoMode gate anywhere")
    func planThreeWayMappingMirrorsWebClient() async throws {
        // Arrange: a held plan gate (epoch 1). Note the VM has NO HTTP client
        // and never consults /config/ui — uiConfig.allowAutoMode is reserved
        // for a future permission-mode picker (plan §3.1 note).
        let harness = try Harness()
        await harness.openAndAdopt()
        await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)
        let epoch = try #require(harness.viewModel.planGate?.epoch)

        // Act: all three choices against the held gate (web sends per click).
        harness.viewModel.decide(.approveAuto, epoch: epoch)
        harness.viewModel.decide(.approveReview, epoch: epoch)
        harness.viewModel.decide(.keepPlanning, epoch: epoch)
        await harness.viewModel.waitUntilForwarded(decisionCount: 3)

        // Assert: exact wire frames, in order — never raw `auto`.
        #expect(await harness.wireFrames() == [
            MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
            MessageCodec.encode(.approve(mode: .acceptEdits)),
            MessageCodec.encode(.approve(mode: .default)),
            MessageCodec.encode(.reject),
        ])
    }

    @Test("tool two-way maps: Approve→approve(mode:nil) · Reject→reject")
    func toolTwoWayMapping() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt()
        await harness.transport.emit(
            frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)
        let epoch = try #require(harness.viewModel.toolGate?.epoch)

        // Act
        harness.viewModel.decide(.approve, epoch: epoch)
        harness.viewModel.decide(.reject, epoch: epoch)
        await harness.viewModel.waitUntilForwarded(decisionCount: 2)

        // Assert: bare approve (no mode key) then reject.
        #expect(await harness.wireFrames() == [
            MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
            MessageCodec.encode(.approve(mode: nil)),
            MessageCodec.encode(.reject),
        ])
    }

    // MARK: - Tap-epoch guard (first line; engine canDecide is the second)

    @Test("a tap carrying a stale epoch is dropped and never sent — a slow tap must not approve the NEXT gate")
    func staleEpochTapIsDroppedNotSent() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt()

        // Act: tap before any gate ever rose → dropped.
        harness.viewModel.decide(.approve, epoch: 0)
        #expect(harness.viewModel.droppedStaleDecisionCount == 1)

        // Arrange: gate 1 rises, resolves, gate 2 rises (the dangerous window).
        await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)
        await harness.transport.emit(frame: ServerFrames.status(pending: false))
        await harness.viewModel.waitUntilProcessed(eventCount: 5)
        await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
        await harness.viewModel.waitUntilProcessed(eventCount: 6)

        // Act: a tap rendered against gate 1 lands now → dropped, not sent.
        harness.viewModel.decide(.approve, epoch: 1)
        #expect(harness.viewModel.droppedStaleDecisionCount == 2)
        #expect(harness.viewModel.forwardedDecisionCount == 0)

        // Act: a fresh tap against the CURRENT gate (epoch 2) goes through.
        harness.viewModel.decide(.approve, epoch: 2)
        await harness.viewModel.waitUntilForwarded(decisionCount: 1)

        // Assert: exactly one approve ever reached the wire.
        #expect(await harness.wireFrames() == [
            MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
            MessageCodec.encode(.approve(mode: nil)),
        ])
    }

    @Test("same-epoch kind morph (tool→plan sustained refresh): the tool tap is dropped, the plan choice goes through")
    func kindMorphSameEpochDropsForeignAffordance() async throws {
        // Arrange: tool gate epoch 1, then a sustained-pending refresh flips
        // the kind to plan WITHOUT minting a new epoch (GateTracker semantics).
        let harness = try Harness()
        await harness.openAndAdopt()
        await harness.transport.emit(
            frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)
        await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
        await harness.viewModel.waitUntilProcessed(eventCount: 5)
        #expect(harness.viewModel.planGate?.epoch == 1)

        // Act: the tap rendered on the old TOOL banner is no longer an offered
        // affordance — dropped even though the epoch still matches.
        harness.viewModel.decide(.approve, epoch: 1)
        #expect(harness.viewModel.droppedStaleDecisionCount == 1)

        // Act: a choice from the CURRENT plan sheet goes through.
        harness.viewModel.decide(.approveAuto, epoch: 1)
        await harness.viewModel.waitUntilForwarded(decisionCount: 1)

        // Assert
        #expect(await harness.wireFrames() == [
            MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
            MessageCodec.encode(.approve(mode: .acceptEdits)),
        ])
    }

    // MARK: - Haptics (exactly once per gate epoch)

    @Test("haptic fires exactly once per gate epoch — sustained refreshes and lifts never re-buzz")
    func hapticFiresExactlyOncePerGateEpoch() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt()
        #expect(harness.haptics.gateArrivalCount == 0)

        // Act: rising edge mints epoch 1 → one buzz.
        await harness.transport.emit(
            frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)
        #expect(harness.haptics.gateArrivalCount == 1)

        // Act: sustained-pending refresh (same epoch, new detail) → NO re-buzz.
        await harness.transport.emit(
            frame: ServerFrames.status(pending: true, gate: "tool", detail: "Read"))
        await harness.viewModel.waitUntilProcessed(eventCount: 5)
        #expect(harness.haptics.gateArrivalCount == 1)

        // Act: gate lifted → no buzz for a nil gate.
        await harness.transport.emit(frame: ServerFrames.status(pending: false))
        await harness.viewModel.waitUntilProcessed(eventCount: 6)
        #expect(harness.haptics.gateArrivalCount == 1)

        // Act: a NEW gate (epoch 2) → second buzz.
        await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
        await harness.viewModel.waitUntilProcessed(eventCount: 7)
        #expect(harness.haptics.gateArrivalCount == 2)
    }

    // MARK: - Away digest (render / fade / expand)

    @Test("non-zero digest renders the summary state; the all-zero digest renders nothing and parks no fade timer")
    func digestNonZeroRendersAndAllZeroDoesNot() async throws {
        // Arrange: one away-window event (far-future `at` passes the `since`
        // filter; the engine stamps the disconnect moment with the real Date).
        let sessionId = UUID()
        let awayEvent = TimelineEvent(
            at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash")
        let harness = try Harness(eventsSource: { _ in [awayEvent] })
        await harness.openAndAdopt(sessionId)

        // Act
        await harness.reconnectForDigest(sessionId)

        // Assert: summary state set, collapsed by default.
        #expect(harness.viewModel.digest == AwayDigest(
            toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false,
            recent: [awayEvent]))
        #expect(!harness.viewModel.isDigestExpanded)

        // Arrange/Act: an empty away timeline → `.digest(.empty)`.
        let empty = try Harness(eventsSource: { _ in [] })
        await empty.openAndAdopt(sessionId)
        await empty.reconnectForDigest(sessionId)

        // Assert: nothing rendered, and no fade timer was ever scheduled.
        #expect(empty.viewModel.digest == nil)
        #expect(empty.clock.pendingSleeperCount == 0)
    }

    @Test("digest auto-fades after Tunables.digestFadeDelay on the injected clock")
    func digestAutoFadesAfterDelay() async throws {
        // Arrange: a visible digest.
        let sessionId = UUID()
        let awayEvent = TimelineEvent(
            at: Int.max / 2, class: "waiting", toolName: nil, label: "requested approval")
        let harness = try Harness(eventsSource: { _ in [awayEvent] })
        await harness.openAndAdopt(sessionId)
        await harness.reconnectForDigest(sessionId)
        #expect(harness.viewModel.digest != nil)

        // Act: the fade timer parks on the fake clock; fire it.
        await harness.clock.waitForSleepers(count: 1)
        harness.clock.advance(by: Tunables.digestFadeDelay)
        await harness.viewModel.waitUntilFadeCompleted(count: 1)

        // Assert
        #expect(harness.viewModel.digest == nil)
    }

    @Test("manual expand shows recent entries and cancels the fade — an expanded digest never vanishes under the reader")
    func expandedDigestNeverFadesAndShowsRecent() async throws {
        // Arrange: a visible digest with its fade timer parked.
        let sessionId = UUID()
        let awayEvent = TimelineEvent(
            at: Int.max / 2, class: "tool", toolName: "Edit", label: "edited 3 files")
        let harness = try Harness(eventsSource: { _ in [awayEvent] })
        await harness.openAndAdopt(sessionId)
        await harness.reconnectForDigest(sessionId)
        await harness.clock.waitForSleepers(count: 1)

        // Act: the user expands the summary row.
        harness.viewModel.expandDigest()

        // Assert: expanded, recent entries available, fade timer CANCELLED.
        #expect(harness.viewModel.isDigestExpanded)
        #expect(harness.viewModel.digest?.recent == [awayEvent])
        #expect(harness.clock.pendingSleeperCount == 0)

        // Act: even way past the fade delay the digest stays.
        harness.clock.advance(by: Tunables.digestFadeDelay * 10)
        #expect(harness.viewModel.digest != nil)

        // Act: explicit dismiss clears everything.
        harness.viewModel.dismissDigest()
        #expect(harness.viewModel.digest == nil)
        #expect(!harness.viewModel.isDigestExpanded)
    }

    // MARK: - Component copy (mirror of public/tabs.ts:326-350)

    @Test("banner/sheet copy and choice labels mirror the web client exactly")
    func componentCopyMirrorsWebClient() {
        // Tool banner: "Claude wants to use ${pendingTool ?? 'a tool'}".
        let toolGate = GateState(kind: .tool, detail: "Bash", epoch: 1)
        #expect(GateBanner.message(for: toolGate) == "Claude wants to use Bash")
        #expect(GateBanner.message(for: GateState(kind: .tool, detail: nil, epoch: 1))
            == "Claude wants to use a tool")
        #expect(GateChoiceSpec.specs(for: toolGate).map(\.label)
            == ["✓ Approve", "✗ Reject"])
        #expect(GateChoiceSpec.specs(for: toolGate).map(\.affordance)
            == [.approve, .reject])

        // Plan sheet: three-way, exact labels and title.
        let planGate = GateState(kind: .plan, detail: nil, epoch: 2)
        #expect(GateChoiceSpec.specs(for: planGate).map(\.label)
            == ["✓ Approve + Auto", "✓ Approve + Review", "✎ Keep Planning"])
        #expect(GateChoiceSpec.specs(for: planGate).map(\.affordance)
            == [.approveAuto, .approveReview, .keepPlanning])
        #expect(PlanGateSheet.title == "Claude finished planning — how should it proceed?")
    }

    @Test("digest summary composes non-zero parts only; user-only activity falls back to a count")
    func digestSummaryComposition() {
        // Arrange / Act / Assert: all four signal parts.
        let full = AwayDigest(
            toolRuns: 3, waitingCount: 1, sawDone: true, sawStuck: false, recent: [])
        #expect(AwayDigestView.summary(for: full)
            == "离开期间：工具调用 3 次 · 等待审批 1 次 · 已完成")

        // Stuck-only digest.
        let stuck = AwayDigest(
            toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: true, recent: [])
        #expect(AwayDigestView.summary(for: stuck) == "离开期间：曾卡住")

        // Non-empty digest whose counted signals are all zero (e.g. only
        // `user` events) still gets a rendered fallback line.
        let userOnly = AwayDigest(
            toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false,
            recent: [TimelineEvent(at: 1, class: "user", toolName: nil, label: "typed a prompt")])
        #expect(AwayDigestView.summary(for: userOnly) == "离开期间：活动 1 条")
    }
}
