import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm

/// T-iOS-13 · SessionListViewModel (merged chooser + dashboard, plan §7).
///
/// Covers the task RED list end to end, deterministically (zero real waits):
/// - poll cadence on `FakeClock` (`waitForSleepers` barrier + cancellation
///   leak-check on leave),
/// - status dot mapping for all 5 states + the `pending:true` ⚠ badge priority
///   (overlay — `/live-sessions` carries NO pending field, src/types.ts:246-256),
/// - telemetry staleness at the exact `Tunables.telemetryStaleTtlMs` boundary
///   via an injected now-provider,
/// - swipe-to-kill: optimistic removal proven BEFORE the server replies (gated
///   transport), Origin stamped on the DELETE, rollback on failure,
/// - server order kept with `exited:true` grouped at the bottom,
/// - "+ New session" navigation signal carrying `sessionId: nil`.
///
/// All HTTP goes through the REAL `APIClient` over `FakeHTTPTransport`
/// (task brief: LiveSessionInfo decode exists — exercise it, don't stub rows).
@MainActor
@Suite("SessionListViewModel")
struct SessionListViewModelTests {
    private nonisolated static let base = "http://192.168.1.5:3000"
    /// Fixed "now" injected into the VM — staleness must be deterministic.
    private nonisolated static let nowMs = 1_700_000_100_000

    // MARK: - Fixtures

    private func makeHost(
        _ urlString: String = SessionListViewModelTests.base,
        name: String = "书房 Mac"
    ) throws -> HostRegistry.Host {
        let url = try #require(URL(string: urlString))
        let endpoint = try #require(HostEndpoint(baseURL: url))
        return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
    }

    private func listURL(_ base: String = SessionListViewModelTests.base) throws -> URL {
        try #require(URL(string: "\(base)/live-sessions"))
    }

    private func deleteURL(
        id: UUID, base: String = SessionListViewModelTests.base
    ) throws -> URL {
        try #require(URL(string: "\(base)/live-sessions/\(id.uuidString.lowercased())"))
    }

    /// One `/live-sessions` entry in the server's exact JSON shape.
    private func sessionJSON(
        id: UUID,
        createdAt: Int = 1_700_000_000_000,
        clientCount: Int = 1,
        status: String = "working",
        exited: Bool = false,
        cwd: String? = "/Users/dev/proj",
        cols: Int = 80,
        rows: Int = 24,
        telemetry: String? = nil
    ) -> String {
        let cwdJSON = cwd.map { "\"\($0)\"" } ?? "null"
        let telemetryJSON = telemetry.map { ",\"telemetry\":\($0)" } ?? ""
        return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":\(createdAt)"
            + ",\"clientCount\":\(clientCount),\"status\":\"\(status)\",\"exited\":\(exited)"
            + ",\"cwd\":\(cwdJSON),\"cols\":\(cols),\"rows\":\(rows)\(telemetryJSON)}"
    }

    private func listBody(_ entries: [String]) -> Data {
        Data("[\(entries.joined(separator: ","))]".utf8)
    }

    private func makeViewModel(
        hosts: [HostRegistry.Host],
        http: any HTTPTransport,
        clock: any Clock<Duration> = FakeClock(),
        nowMs: @escaping @Sendable () -> Int = { SessionListViewModelTests.nowMs }
    ) -> SessionListViewModel {
        SessionListViewModel(
            hostStore: InMemoryHostStore(hosts: hosts),
            http: http,
            clock: clock,
            unreadStore: InMemoryUnreadWatermarkStore(), // T-iOS-23 seam
            nowMs: nowMs
        )
    }

    /// Loads hosts + performs one manual fetch — the no-polling arrangement
    /// used by every test that does not exercise the poll loop itself.
    private func loadOnce(_ viewModel: SessionListViewModel) async {
        await viewModel.reloadHosts()
        await viewModel.refresh()
    }

    private struct StoreFailure: Error {}

    private actor ThrowingHostStore: HostStore {
        func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
        func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] { [] }
        func remove(id: UUID) async throws -> [HostRegistry.Host] { [] }
    }

    /// HTTPTransport wrapper that parks requests of one method until the test
    /// opens the gate — the deterministic way to observe the OPTIMISTIC state
    /// while the DELETE is still in flight.
    private actor GatedHTTPTransport: HTTPTransport {
        private let inner: FakeHTTPTransport
        private let gatedMethod: String
        private var isOpen = false
        private var gatedArrivalCount = 0
        private var gateOpeners: [CheckedContinuation<Void, Never>] = []
        private var arrivalWaiters: [CheckedContinuation<Void, Never>] = []

        init(inner: FakeHTTPTransport, gatedMethod: String) {
            self.inner = inner
            self.gatedMethod = gatedMethod.uppercased()
        }

        func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
            if (request.httpMethod ?? "GET").uppercased() == gatedMethod {
                gatedArrivalCount += 1
                for waiter in arrivalWaiters { waiter.resume() }
                arrivalWaiters = []
                if !isOpen {
                    await withCheckedContinuation { gateOpeners.append($0) }
                }
            }
            return try await inner.send(request)
        }

        /// Suspends until at least one gated request has arrived (and parked).
        func waitForGatedArrival() async {
            guard gatedArrivalCount == 0 else { return }
            await withCheckedContinuation { arrivalWaiters.append($0) }
        }

        func openGate() {
            isOpen = true
            for opener in gateOpeners { opener.resume() }
            gateOpeners = []
        }
    }

    // MARK: - Poll cadence + leave stops cleanly (task RED item 1)

    @Test("visible: polls /live-sessions every listPollInterval; leaving cancels the parked sleeper — no leaked task, zero further traffic")
    func pollingCadenceAndLeaveStopsCleanly() async throws {
        // Arrange: 3 queued list responses — one per expected poll tick.
        let clock = FakeClock()
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let url = try listURL()
        let sessionId = UUID()
        for _ in 0..<3 {
            await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
        }
        let viewModel = makeViewModel(hosts: [host], http: http, clock: clock)

        // Act: appear (twice — idempotence: no second poll loop may spawn).
        viewModel.appeared()
        viewModel.appeared()
        await viewModel.waitUntilFetches(count: 1)

        // Assert: first fetch landed and rows are decoded LiveSessionInfo.
        #expect(viewModel.rows.map(\.id) == [sessionId])
        #expect(viewModel.emptyState == nil)

        // Act + Assert: each interval elapsing triggers exactly one more fetch.
        await clock.waitForSleepers(count: 1)
        clock.advance(by: Tunables.listPollInterval)
        await viewModel.waitUntilFetches(count: 2)

        await clock.waitForSleepers(count: 1)
        clock.advance(by: Tunables.listPollInterval)
        await viewModel.waitUntilFetches(count: 3)

        // Act: leave the screen while the poll task is parked in sleep.
        await clock.waitForSleepers(count: 1)
        viewModel.disappeared()

        // Assert: cancellation reclaimed the sleeper synchronously (no leaked
        // timer) and a long stretch of fake time produces zero new requests.
        #expect(clock.pendingSleeperCount == 0)
        clock.advance(by: Tunables.listPollInterval * 10)
        let requestCount = await http.recordedRequests.count
        #expect(requestCount == 3)
    }

    // MARK: - Empty states

    @Test("no paired hosts → .notPaired empty state and ZERO network traffic")
    func notPairedEmptyStateWithoutTraffic() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let viewModel = makeViewModel(hosts: [], http: http)

        // Act
        viewModel.appeared()
        await viewModel.waitUntilFetches(count: 1)

        // Assert
        #expect(viewModel.emptyState == .notPaired)
        #expect(await http.recordedRequests.isEmpty)
        viewModel.disappeared()
    }

    @Test("empty list from the host → .noSessions; a later non-empty fetch clears it")
    func noSessionsEmptyStateThenPopulated() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let url = try listURL()
        await http.queueSuccess(url: url, body: listBody([]))
        let viewModel = makeViewModel(hosts: [host], http: http)

        // Act & Assert: loaded + empty → .noSessions (distinct from not-paired).
        await loadOnce(viewModel)
        #expect(viewModel.emptyState == .noSessions)

        // Act & Assert: pull-to-refresh path fetches on demand and clears it.
        await http.queueSuccess(url: url, body: listBody([sessionJSON(id: UUID())]))
        await viewModel.refresh()
        #expect(viewModel.rows.count == 1)
        #expect(viewModel.emptyState == nil)
    }

    @Test("host store failure surfaces explicit copy — never a silent empty list")
    func hostStoreFailureIsExplicit() async throws {
        // Arrange
        let viewModel = SessionListViewModel(
            hostStore: ThrowingHostStore(),
            http: FakeHTTPTransport(),
            clock: FakeClock(),
            unreadStore: InMemoryUnreadWatermarkStore(),
            nowMs: { Self.nowMs }
        )

        // Act
        await viewModel.reloadHosts()

        // Assert
        #expect(viewModel.hosts.isEmpty)
        #expect(viewModel.hostsErrorMessage == SessionListCopy.hostsLoadFailed)
    }

    // MARK: - Status dots (5 states) + pending ⚠ badge priority

    @Test("status dot maps every ClaudeStatus state from the wire", arguments: [
        ("working", ClaudeStatus.working),
        ("waiting", ClaudeStatus.waiting),
        ("idle", ClaudeStatus.idle),
        ("unknown", ClaudeStatus.unknown),
        ("stuck", ClaudeStatus.stuck),
    ])
    func statusDotMapsFiveStates(raw: String, expected: ClaudeStatus) async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let sessionId = UUID()
        await http.queueSuccess(
            url: try listURL(), body: listBody([sessionJSON(id: sessionId, status: raw)])
        )
        let viewModel = makeViewModel(hosts: [host], http: http)

        // Act
        await loadOnce(viewModel)

        // Assert
        #expect(viewModel.rows.first?.indicator == .status(expected))
    }

    @Test("pending overlay wins over the status dot with a ⚠ badge, and clears back")
    func pendingBadgeTakesPriority() async throws {
        // Arrange: a WORKING session — the strongest contrast to the badge.
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let sessionId = UUID()
        await http.queueSuccess(
            url: try listURL(),
            body: listBody([sessionJSON(id: sessionId, status: "working")])
        )
        let viewModel = makeViewModel(hosts: [host], http: http)
        await loadOnce(viewModel)
        #expect(viewModel.rows.first?.indicator == .status(.working))

        // Act: gate arrives (status frame pending:true → T-iOS-15 wiring calls this).
        viewModel.setPendingApproval(sessionId: sessionId, pending: true)

        // Assert: badge PRIORITY — status stays working underneath.
        #expect(viewModel.rows.first?.indicator == .pendingApproval)
        #expect(viewModel.rows.first?.info.status == .working)

        // Act & Assert: gate resolved → dot returns.
        viewModel.setPendingApproval(sessionId: sessionId, pending: false)
        #expect(viewModel.rows.first?.indicator == .status(.working))
    }

    @Test("indicator glyphs mirror public/tabs.ts claudeIcon, ⚠ for pending")
    func indicatorGlyphsMirrorWeb() {
        #expect(SessionListViewModel.StatusIndicator.status(.working).glyph == "⚙")
        #expect(SessionListViewModel.StatusIndicator.status(.waiting).glyph == "⏳")
        #expect(SessionListViewModel.StatusIndicator.status(.idle).glyph == "✓")
        #expect(SessionListViewModel.StatusIndicator.status(.stuck).glyph == "⚠")
        #expect(SessionListViewModel.StatusIndicator.status(.unknown).glyph == "")
        #expect(SessionListViewModel.StatusIndicator.pendingApproval.glyph == "⚠")
    }

    // MARK: - Telemetry staleness (injected now-provider, exact boundary)

    @Test("telemetry chips grey strictly past telemetryStaleTtlMs; at the boundary they stay live; no telemetry → no chips")
    func telemetryStalenessBoundary() async throws {
        // Arrange: `at` exactly TTL old (NOT stale — web rule is strict `>`,
        // public/preview-grid.ts), one 1 ms older (stale), one without telemetry.
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let boundaryId = UUID()
        let staleId = UUID()
        let bareId = UUID()
        let boundaryAt = Self.nowMs - Tunables.telemetryStaleTtlMs
        let staleAt = Self.nowMs - Tunables.telemetryStaleTtlMs - 1
        await http.queueSuccess(url: try listURL(), body: listBody([
            sessionJSON(id: boundaryId, telemetry: "{\"at\":\(boundaryAt),\"costUsd\":0.5}"),
            sessionJSON(id: staleId, telemetry: "{\"at\":\(staleAt),\"costUsd\":0.5}"),
            sessionJSON(id: bareId),
        ]))
        let viewModel = makeViewModel(hosts: [host], http: http)

        // Act
        await loadOnce(viewModel)

        // Assert
        let rowsById = Dictionary(uniqueKeysWithValues: viewModel.rows.map { ($0.id, $0) })
        #expect(rowsById[boundaryId]?.telemetry?.isStale == false)
        #expect(rowsById[staleId]?.telemetry?.isStale == true)
        #expect(rowsById[bareId]?.telemetry == nil)
    }

    @Test("chip model mirrors the web gauge: $ cost to 4 places, ctx warn > 80, PR link https-only")
    func telemetryChipModelContent() throws {
        // Arrange: http:// PR URL — the SEC-L5 mirror must refuse to link it.
        let telemetry = StatusTelemetry(
            contextUsedPct: 91.4,
            costUsd: 0.1234,
            model: "opus",
            pr: PrInfo(number: 7, url: "http://github.com/x/pull/7", reviewState: "approved"),
            at: Self.nowMs
        )

        // Act
        let model = try #require(TelemetryChips.Model(telemetry: telemetry, nowMs: Self.nowMs))

        // Assert
        #expect(model.costText == "$0.1234")
        #expect(model.contextText == "ctx 91%")
        #expect(model.isContextWarning)
        #expect(model.modelName == "opus")
        #expect(model.prText == "PR #7")
        #expect(model.prReviewState == "approved")
        #expect(model.prURL == nil) // http never becomes a tappable link
        #expect(!model.isStale)

        // Act & Assert: https URL IS linkable; low ctx carries no warning.
        let httpsTelemetry = StatusTelemetry(
            contextUsedPct: 40,
            pr: PrInfo(number: 7, url: "https://github.com/x/pull/7"),
            at: Self.nowMs
        )
        let httpsModel = try #require(
            TelemetryChips.Model(telemetry: httpsTelemetry, nowMs: Self.nowMs)
        )
        #expect(httpsModel.prURL != nil)
        #expect(!httpsModel.isContextWarning)
        #expect(TelemetryChips.Model(telemetry: nil, nowMs: Self.nowMs) == nil)
    }

    // MARK: - Ordering: server order kept, exited grouped at the bottom

    @Test("rows keep the server's order with exited sessions grouped last, order preserved inside each group")
    func orderingGroupsExitedAtBottom() async throws {
        // Arrange: server (newest-first) order interleaves exited sessions.
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let liveA = UUID()
        let exitedB = UUID()
        let liveC = UUID()
        let exitedD = UUID()
        await http.queueSuccess(url: try listURL(), body: listBody([
            sessionJSON(id: liveA),
            sessionJSON(id: exitedB, exited: true),
            sessionJSON(id: liveC),
            sessionJSON(id: exitedD, exited: true),
        ]))
        let viewModel = makeViewModel(hosts: [host], http: http)

        // Act
        await loadOnce(viewModel)

        // Assert
        #expect(viewModel.rows.map(\.id) == [liveA, liveC, exitedB, exitedD])
    }

    // MARK: - Swipe-to-kill: optimistic + Origin + rollback

    @Test("kill removes the row optimistically BEFORE the server replies, stamps Origin on the DELETE, stays removed on 204")
    func killOptimisticRemovalThenConfirmed() async throws {
        // Arrange: DELETE parks at a gate so the in-flight state is observable.
        let inner = FakeHTTPTransport()
        let gated = GatedHTTPTransport(inner: inner, gatedMethod: "DELETE")
        let host = try makeHost()
        let targetId = UUID()
        let otherId = UUID()
        await inner.queueSuccess(
            url: try listURL(),
            body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)])
        )
        await inner.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 204)
        let viewModel = makeViewModel(hosts: [host], http: gated)
        await loadOnce(viewModel)
        #expect(viewModel.rows.map(\.id) == [targetId, otherId])

        // Act: swipe-to-kill; hold the DELETE at the gate.
        let killTask = Task { await viewModel.kill(sessionId: targetId) }
        await gated.waitForGatedArrival()

        // Assert: the row is ALREADY gone while the server has not answered.
        #expect(viewModel.rows.map(\.id) == [otherId])
        #expect(viewModel.killErrorMessage == nil)

        // Act: let the 204 through.
        await gated.openGate()
        await killTask.value

        // Assert: still removed; the DELETE carried the exact Origin (G 铁律).
        #expect(viewModel.rows.map(\.id) == [otherId])
        let deleteRequest = await inner.recordedRequests.first { $0.httpMethod == "DELETE" }
        #expect(deleteRequest?.url == (try deleteURL(id: targetId)))
        #expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == Self.base)
    }

    @Test("kill failure rolls the row back at its original position with explicit copy")
    func killFailureRollsBack() async throws {
        // Arrange: the Origin guard rejects the DELETE (403).
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let targetId = UUID()
        let otherId = UUID()
        await http.queueSuccess(
            url: try listURL(),
            body: listBody([sessionJSON(id: targetId), sessionJSON(id: otherId)])
        )
        await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 403)
        let viewModel = makeViewModel(hosts: [host], http: http)
        await loadOnce(viewModel)

        // Act
        await viewModel.kill(sessionId: targetId)

        // Assert: rollback restored the ORIGINAL position, copy is actionable.
        #expect(viewModel.rows.map(\.id) == [targetId, otherId])
        #expect(viewModel.killErrorMessage
            == SessionListCopy.killFailed(APIClientError.forbidden.message))
    }

    @Test("kill on an already-gone session (404) keeps it removed without an error")
    func killAlreadyGoneStaysRemoved() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let targetId = UUID()
        await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: targetId)]))
        await http.queueSuccess(method: "DELETE", url: try deleteURL(id: targetId), status: 404)
        let viewModel = makeViewModel(hosts: [host], http: http)
        await loadOnce(viewModel)

        // Act
        await viewModel.kill(sessionId: targetId)

        // Assert: 404 = session already dead — the optimistic removal was right.
        #expect(viewModel.rows.isEmpty)
        #expect(viewModel.killErrorMessage == nil)
    }

    // MARK: - Navigation signals

    @Test("+ New session emits an OpenRequest with sessionId nil; row taps carry the id; repeats re-fire")
    func navigationSignals() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let sessionId = UUID()
        await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)]))
        let viewModel = makeViewModel(hosts: [host], http: http)
        await loadOnce(viewModel)
        #expect(viewModel.openRequest == nil)

        // Act & Assert: "+ New session" → sessionId nil (attach(null) downstream).
        viewModel.requestNewSession()
        let newRequest = try #require(viewModel.openRequest)
        #expect(newRequest.sessionId == nil)
        #expect(newRequest.host == host)

        // Act & Assert: opening a listed session carries its id.
        viewModel.openSession(id: sessionId)
        let openRequest = try #require(viewModel.openRequest)
        #expect(openRequest.sessionId == sessionId)
        #expect(openRequest.host == host)

        // Act & Assert: the SAME tap again is a fresh signal (unique id).
        viewModel.openSession(id: sessionId)
        let repeated = try #require(viewModel.openRequest)
        #expect(repeated.sessionId == sessionId)
        #expect(repeated.id != openRequest.id)

        // Act & Assert: unknown ids are refused at the boundary.
        viewModel.openSession(id: UUID())
        #expect(viewModel.openRequest?.id == repeated.id)
    }

    // MARK: - Host switching (multi-host header hook)

    @Test("switching hosts refetches from the new endpoint and resets the list")
    func hostSwitchRefetches() async throws {
        // Arrange: two paired hosts with distinct session lists.
        let baseB = "http://10.0.0.7:3000"
        let hostA = try makeHost()
        let hostB = try makeHost(baseB, name: "工作室 Mac")
        let http = FakeHTTPTransport()
        let sessionA = UUID()
        let sessionB = UUID()
        await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionA)]))
        await http.queueSuccess(
            url: try listURL(baseB), body: listBody([sessionJSON(id: sessionB)])
        )
        let viewModel = makeViewModel(hosts: [hostA, hostB], http: http)
        await loadOnce(viewModel)
        #expect(viewModel.activeHost == hostA)
        #expect(viewModel.rows.map(\.id) == [sessionA])

        // Act
        await viewModel.selectHost(id: hostB.id)

        // Assert: list now comes from host B; navigation carries host B.
        #expect(viewModel.activeHost == hostB)
        #expect(viewModel.rows.map(\.id) == [sessionB])
        viewModel.requestNewSession()
        #expect(viewModel.openRequest?.host == hostB)
        let lastURL = await http.recordedRequests.last?.url
        #expect(lastURL == (try listURL(baseB)))
    }

    // MARK: - Fetch failure keeps the stale list (explicit error, no wipe)

    @Test("a failed poll keeps the last good rows and surfaces copy; the next success clears it")
    func fetchFailureKeepsStaleRows() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let url = try listURL()
        let sessionId = UUID()
        await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
        await http.queueSuccess(url: url, status: 500)
        await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId)]))
        let viewModel = makeViewModel(hosts: [host], http: http)
        await loadOnce(viewModel)
        #expect(viewModel.rows.map(\.id) == [sessionId])

        // Act: the 500 poll.
        await viewModel.refresh()

        // Assert: stale rows survive, error copy is explicit (includes status).
        #expect(viewModel.rows.map(\.id) == [sessionId])
        let message = try #require(viewModel.fetchErrorMessage)
        #expect(message.contains("500"))
        #expect(viewModel.emptyState == nil)

        // Act & Assert: recovery clears the error.
        await viewModel.refresh()
        #expect(viewModel.fetchErrorMessage == nil)
    }
}
