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

/// T-iOS-11 · TerminalViewModel (plan §7). All tests run against a REAL
/// `SessionEngine` over `TestSupport.FakeTransport` + `FakeClock` (the task's
/// documented testability choice): the VM consumes `engine.events` exactly as
/// production will, with zero real waits and zero network.
///
/// Determinism: the VM's internal `waitUntilProcessed(eventCount:)` /
/// `waitUntilForwarded(sendCount:)` barriers and the `onEventApplied` tap are
/// the "event reached the VM" signals — never polling, never sleeping.
@MainActor
@Suite("TerminalViewModel")
struct TerminalViewModelTests {
    // 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 output(_ data: String) -> String {
            #"{"type":"output","data":"\#(data)"}"#
        }

        static func exit(code: Int, reason: String) -> String {
            #"{"type":"exit","code":\#(code),"reason":"\#(reason)"}"#
        }
    }

    private struct TestStreamError: Error {}

    // MARK: - Harness

    /// Engine over fakes + the VM under test, wired exactly like production
    /// (T-iOS-15 passes `engine.events`; a fan-out branch arrives only when
    /// GateViewModel shares the stream).
    @MainActor
    private final class Harness {
        let transport = FakeTransport()
        let clock = FakeClock()
        let engine: SessionEngine
        let viewModel: TerminalViewModel

        init() 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: { _ in [] }
            )
            viewModel = TerminalViewModel(engine: engine, events: engine.events)
            viewModel.start()
        }

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

    /// Records `(banner, phase)` after every applied event — the deterministic
    /// way to observe transient states (`.connecting` is often already
    /// superseded by the time a barrier-based await resumes).
    @MainActor
    private final class StateRecorder {
        private(set) var banners: [TerminalViewModel.ConnectionBanner] = []

        func attach(to viewModel: TerminalViewModel) {
            viewModel.onEventApplied = { [weak self, weak viewModel] _ in
                guard let self, let viewModel else { return }
                self.banners = self.banners + [viewModel.banner]
            }
        }
    }

    // MARK: - Output forwarding (feed order, MainActor by construction)

    @Test("output forwards to the terminal sink in arrival order; a late sink flushes the buffer first")
    func outputForwardsInOrderAndLateSinkFlushesBuffer() async throws {
        // Arrange
        let harness = try Harness()
        let sessionId = UUID()
        await harness.openAndAdopt(sessionId)

        // Act: two chunks arrive BEFORE any sink exists (screen not built yet).
        await harness.transport.emit(frame: ServerFrames.output("one"))
        await harness.transport.emit(frame: ServerFrames.output("two"))
        await harness.viewModel.waitUntilProcessed(eventCount: 5)

        // The sink attaches late (SwiftTerm view just got created): buffered
        // chunks must flush immediately, in order. The sink closure is
        // @MainActor-typed — feed-on-main is compiler-enforced (Swift 6).
        let received = Recorder()
        harness.viewModel.attachTerminalSink { received.append($0) }

        // Assert: buffered flush preserved order.
        #expect(received.chunks == ["one", "two"])

        // Act: live output after the sink is attached appends behind it.
        await harness.transport.emit(frame: ServerFrames.output("three"))
        await harness.viewModel.waitUntilProcessed(eventCount: 6)

        // Assert
        #expect(received.chunks == ["one", "two", "three"])
        #expect(harness.viewModel.sessionId == sessionId)
    }

    @MainActor
    private final class Recorder {
        private(set) var chunks: [String] = []
        func append(_ chunk: String) { chunks = chunks + [chunk] }
    }

    @Test("start() is idempotent — a second start never double-delivers output")
    func startIsIdempotent() async throws {
        // Arrange
        let harness = try Harness()
        harness.viewModel.start() // second call must be a no-op
        await harness.openAndAdopt(UUID())
        let received = Recorder()
        harness.viewModel.attachTerminalSink { received.append($0) }

        // Act
        await harness.transport.emit(frame: ServerFrames.output("once"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)

        // Assert
        #expect(received.chunks == ["once"])
    }

    // MARK: - Reconnect banner

    @Test("banner: connecting → hidden on connect → reconnecting(attempt) on drop → hidden again after recovery")
    func bannerFollowsConnectionLifecycle() async throws {
        // Arrange
        let harness = try Harness()
        let recorder = StateRecorder()
        recorder.attach(to: harness.viewModel)
        await harness.openAndAdopt(UUID())

        // Act: the transport drops → engine enters backoff (1s first rung).
        await harness.transport.emitError(TestStreamError())
        await harness.viewModel.waitUntilProcessed(eventCount: 4)

        // Assert: the reconnect banner is up, carrying attempt + retry delay.
        #expect(harness.viewModel.banner
            == .reconnecting(attempt: 1, next: .seconds(1)))
        #expect(harness.viewModel.bannerModel
            == .reconnecting(attempt: 1, retryIn: .seconds(1)))

        // Act: fire the backoff timer — the engine reconnects and re-attaches.
        await harness.clock.waitForSleepers(count: 1)
        harness.clock.advance(by: .seconds(1))
        await harness.viewModel.waitUntilProcessed(eventCount: 5)

        // Assert: banner hidden again; full observed banner timeline is exact
        // (events: connecting, connected, adopted, reconnecting, connected).
        #expect(harness.viewModel.banner == .none)
        #expect(harness.viewModel.bannerModel == nil)
        #expect(recorder.banners == [
            .connecting,
            .none,
            .none,
            .reconnecting(attempt: 1, next: .seconds(1)),
            .none,
        ])
    }

    // MARK: - Non-retryable failure (replayTooLarge)

    @Test("replayTooLarge → non-retrying failed state with the actionable SCROLLBACK_BYTES copy; no reconnect attempt")
    func replayTooLargeBecomesNonRetryingErrorWithActionableCopy() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt(UUID())

        // Act: the transport fails the way an oversized single-frame replay
        // does (EMSGSIZE → typed error, T-iOS-9).
        await harness.transport.emitError(TermTransportError.replayTooLarge)
        await harness.viewModel.waitUntilProcessed(eventCount: 4)

        // Assert: explicit error state with the plan §3.2 actionable copy —
        // NOT a spinner, NOT a reconnecting banner.
        #expect(harness.viewModel.phase
            == .failed(message: TerminalViewModel.replayTooLargeMessage))
        #expect(TerminalViewModel.replayTooLargeMessage
            == "服务器 scrollback 超过客户端上限，请调低 SCROLLBACK_BYTES 或调高客户端上限")
        #expect(harness.viewModel.bannerModel
            == .failed(message: TerminalViewModel.replayTooLargeMessage))
        #expect(harness.viewModel.isReadOnly)

        // Assert: deterministic failure never enters the backoff loop — the
        // one original connect stays the only one, and no retry timer sleeps.
        #expect(await harness.transport.connectAttempts.count == 1)
        #expect(harness.clock.pendingSleeperCount == 0)
    }

    // MARK: - Exit → read-only

    @Test("exited → read-only terminal + exit banner; input is dropped, never sent")
    func exitedBecomesReadOnlyAndDropsInput() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt(UUID())

        // Act
        await harness.transport.emit(frame: ServerFrames.exit(code: 0, reason: "bye"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)

        // Assert: read-only exit state, surfaced as the exit banner.
        #expect(harness.viewModel.phase == .exited(code: 0, reason: "bye"))
        #expect(harness.viewModel.isReadOnly)
        #expect(harness.viewModel.bannerModel == .exited(code: 0, reason: "bye"))

        // Act: typing after exit — both key-bar and delegate paths.
        harness.viewModel.send(key: .esc)
        harness.viewModel.sendInput("x")

        // Assert: dropped at the VM boundary (counted, not forwarded).
        #expect(harness.viewModel.droppedReadOnlyInputCount == 2)
        #expect(harness.viewModel.forwardedSendCount == 0)
        let frames = await harness.transport.sentFramesByConnection[0]
        #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
    }

    // MARK: - Outbound sends (single ordered pipeline through KeyByteMap)

    @Test("key + text + resize forward to the engine in submission order; key bytes come from KeyByteMap")
    func keyAndTextSendsForwardInOrderThroughKeyByteMap() async throws {
        // Arrange
        let harness = try Harness()
        await harness.openAndAdopt(UUID())

        // Act: rapid mixed submissions (two taps around typed text + a fit).
        harness.viewModel.send(key: .esc)
        harness.viewModel.sendInput("abc")
        harness.viewModel.send(key: .enter)
        harness.viewModel.sendResize(cols: 120, rows: 40)
        await harness.viewModel.waitUntilForwarded(sendCount: 4)

        // Assert: exact wire frames, in order, bytes resolved via KeyByteMap
        // (Esc = 0x1B, Enter = \r — never \n).
        let frames = await harness.transport.sentFramesByConnection[0]
        #expect(frames == [
            MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
            MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .esc))),
            MessageCodec.encode(.input(data: "abc")),
            MessageCodec.encode(.input(data: KeyByteMap.bytes(for: .enter))),
            MessageCodec.encode(.resize(cols: 120, rows: 40)),
        ])
    }

    @Test("resize is NOT gated by read-only (engine owns terminal-state dropping)")
    func resizeStillForwardsAfterExit() async throws {
        // Arrange: exited session (read-only for INPUT only).
        let harness = try Harness()
        await harness.openAndAdopt(UUID())
        await harness.transport.emit(frame: ServerFrames.exit(code: 1, reason: "done"))
        await harness.viewModel.waitUntilProcessed(eventCount: 4)

        // Act: a layout pass after exit still reports dims; the VM forwards and
        // the ENGINE (already terminal) is the layer that drops it.
        harness.viewModel.sendResize(cols: 80, rows: 24)
        await harness.viewModel.waitUntilForwarded(sendCount: 1)

        // Assert: VM forwarded (no read-only drop); no wire frame appeared
        // because the engine discarded it post-exit.
        #expect(harness.viewModel.droppedReadOnlyInputCount == 0)
        let frames = await harness.transport.sentFramesByConnection[0]
        #expect(frames == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
    }

    @Test("lastSentDims: valid resize is remembered for notifyForegrounded; invalid dims never overwrite a good pair")
    func lastSentDimsTracksOnlyValidResizes() async throws {
        // Arrange
        let harness = try Harness()
        #expect(harness.viewModel.lastSentDims == nil)

        // Act: a valid layout pass, then a bogus one (cols=0 fails Validation).
        harness.viewModel.sendResize(cols: 120, rows: 40)
        harness.viewModel.sendResize(cols: 0, rows: 40)
        await harness.viewModel.waitUntilForwarded(sendCount: 2)

        // Assert: the good pair survives — this is what the wiring feeds to
        // engine.notifyForegrounded(dims:) on the alive-engine .active hop.
        #expect(harness.viewModel.lastSentDims == .init(cols: 120, rows: 40))
    }
}
