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

/// T-iOS-23 · 多会话切换器：unread 点（`lastOutputAt` vs 本地水位，经
/// `UnreadLedger` + App 侧 UserDefaults 持久化）+ OSC 标题（SwiftTerm
/// `setTerminalTitle` delegate → `TitleSanitizer` → 列表行）。
///
/// 分层：
/// - 列表 VM：unread 判定/清除/持久化、标题注册表（净化在两个边界各自执行）；
/// - TerminalViewModel：delegate 原始标题在此边界净化，adopted 后携 id 上浮；
/// - 接线：controller 把 title hook 接到新旧 VM（suspend→resume 重建后不掉线）；
///   coordinator 的 closeTerminal 记 last-seen 水位、title 流入列表行。
/// 单活 WS 不变式：切会话 = close→open（deep-link 路径 `openDeepLinkedSession`
/// 与列表返回→再开共用 `closeTerminal`，一次只有一个 engine —— 见接线用例）。
@MainActor
@Suite("SessionSwitcher (T-iOS-23)")
struct SessionSwitcherTests {
    private nonisolated static let base = "http://192.168.1.5:3000"
    private nonisolated static let nowMs = 1_700_000_100_000

    // MARK: - Fixtures

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

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

    private func sessionJSON(id: UUID, lastOutputAt: Int? = nil, cwd: String = "/Users/dev/proj") -> String {
        let lastOutputJSON = lastOutputAt.map { ",\"lastOutputAt\":\($0)" } ?? ""
        return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000"
            + ",\"clientCount\":1,\"status\":\"working\",\"exited\":false"
            + ",\"cwd\":\"\(cwd)\",\"cols\":80,\"rows\":24\(lastOutputJSON)}"
    }

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

    private func makeListViewModel(
        hosts: [HostRegistry.Host],
        http: any HTTPTransport,
        unreadStore: any UnreadWatermarkStore
    ) -> SessionListViewModel {
        SessionListViewModel(
            hostStore: InMemoryHostStore(hosts: hosts),
            http: http,
            clock: FakeClock(),
            unreadStore: unreadStore,
            nowMs: { Self.nowMs }
        )
    }

    private func loadOnce(_ viewModel: SessionListViewModel) async {
        await viewModel.reloadHosts()
        await viewModel.refresh()
    }

    // MARK: - 列表 unread 点（水位判定 + 清除 + 复燃）

    @Test("lastOutputAt 高于水位 → unread 点亮；markSeen 熄灭；更新的输出再点亮")
    func unreadDotLifecycle() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let store = InMemoryUnreadWatermarkStore()
        let sessionId = UUID()
        let url = try listURL()
        await http.queueSuccess(
            url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)])
        )
        let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store)

        // Act & Assert: 从未看过 → unread。
        await loadOnce(viewModel)
        #expect(viewModel.rows.first?.isUnread == true)

        // Act & Assert: 标记已读 → 立即熄灭（不等下一次轮询）。
        viewModel.markSeen(sessionId: sessionId)
        #expect(viewModel.rows.first?.isUnread == false)

        // Act & Assert: 服务器又有新输出 → 复燃。
        await http.queueSuccess(
            url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs + 5_000)])
        )
        await viewModel.refresh()
        #expect(viewModel.rows.first?.isUnread == true)
    }

    @Test("lastOutputAt 缺席（旧服务器）→ 绝不点灯")
    func absentLastOutputAtNeverLights() 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 = makeListViewModel(
            hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore()
        )

        // Act
        await loadOnce(viewModel)

        // Assert
        #expect(viewModel.rows.first?.isUnread == false)
    }

    @Test("markSeen 持久化水位；新 VM（重启模拟）读回后照样已读")
    func watermarkPersistsAcrossViewModels() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let store = InMemoryUnreadWatermarkStore()
        let sessionId = UUID()
        let url = try listURL()
        let outputAt = Self.nowMs - 1_000
        await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)]))
        let first = makeListViewModel(hosts: [host], http: http, unreadStore: store)
        await loadOnce(first)

        // Act: 标记已读 → 存储必须收到水位。
        first.markSeen(sessionId: sessionId)
        #expect(store.snapshot[sessionId] == Self.nowMs)

        // Arrange: "重启"——新 VM 从同一 store 载入。
        await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)]))
        let second = makeListViewModel(hosts: [host], http: http, unreadStore: store)

        // Act & Assert
        await loadOnce(second)
        #expect(second.rows.first?.isUnread == false)
    }

    @Test("点开会话行（openSession）即记 last-seen —— 点即熄灭")
    func openSessionMarksSeen() async throws {
        // Arrange
        let http = FakeHTTPTransport()
        let host = try makeHost()
        let store = InMemoryUnreadWatermarkStore()
        let sessionId = UUID()
        await http.queueSuccess(
            url: try listURL(),
            body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)])
        )
        let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store)
        await loadOnce(viewModel)
        #expect(viewModel.rows.first?.isUnread == true)

        // Act
        viewModel.openSession(id: sessionId)

        // Assert: 导航信号照发，同时水位已记、点已熄。
        #expect(viewModel.openRequest?.sessionId == sessionId)
        #expect(viewModel.rows.first?.isUnread == false)
        #expect(store.snapshot[sessionId] == Self.nowMs)
    }

    // MARK: - 列表标题注册表（敌意标题在列表边界再净化一次）

    @Test("setSessionTitle 净化敌意标题后上行；净化后为空 → 回退 cwd 名（title nil）")
    func sessionTitleRegistrySanitizes() 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 = makeListViewModel(
            hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore()
        )
        await loadOnce(viewModel)
        #expect(viewModel.rows.first?.title == nil)

        // Act: RLO + ESC 注入的敌意标题直接打列表接口（不信任上游净化）。
        viewModel.setSessionTitle(sessionId: sessionId, title: "npm \u{202E}run\u{1B}[2J build")

        // Assert
        #expect(viewModel.rows.first?.title == "npm run[2J build")

        // Act & Assert: 全零宽标题 → 清空注册表条目，回退 nil。
        viewModel.setSessionTitle(sessionId: sessionId, title: "\u{200B}\u{200D}")
        #expect(viewModel.rows.first?.title == nil)
    }

    // MARK: - TerminalViewModel：delegate 边界净化 + adopted 后携 id 上浮

    /// TerminalViewModelTests 的同款 harness：真 engine over FakeTransport。
    @MainActor
    private final class TerminalHarness {
        let transport = FakeTransport()
        let engine: SessionEngine
        let viewModel: TerminalViewModel

        init() throws {
            let baseURL = try #require(URL(string: SessionSwitcherTests.base))
            let endpoint = try #require(HostEndpoint(baseURL: baseURL))
            engine = SessionEngine(
                transport: transport, clock: FakeClock(), endpoint: endpoint,
                eventsSource: { _ in [] }
            )
            viewModel = TerminalViewModel(engine: engine, events: engine.events)
            viewModel.start()
        }

        func openAndAdopt(_ id: UUID) async {
            await engine.open(sessionId: nil, cwd: nil)
            await transport.emit(
                frame: #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
            )
            await viewModel.waitUntilProcessed(eventCount: 3)
        }
    }

    @Test("setTerminalTitle：敌意原始标题在 VM 边界净化，terminalTitle 暴露净化值并携 adopted id 上浮")
    func terminalTitleSanitizedAndForwarded() async throws {
        // Arrange
        let harness = try TerminalHarness()
        let adopted = UUID()
        var forwarded: [(UUID, String)] = []
        harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
        await harness.openAndAdopt(adopted)

        // Act: SwiftTerm delegate 原样送来的敌意 OSC 标题。
        harness.viewModel.setTerminalTitle("  claude\u{202E}\u{07} · fix bug  ")

        // Assert
        #expect(harness.viewModel.terminalTitle == "claude · fix bug")
        #expect(forwarded.count == 1)
        #expect(forwarded.first?.0 == adopted)
        #expect(forwarded.first?.1 == "claude · fix bug")
    }

    @Test("adopted 前到达的标题被暂存，adopted 后补发一次（不丢、不提前）")
    func titleBeforeAdoptionIsHeldThenForwarded() async throws {
        // Arrange
        let harness = try TerminalHarness()
        let adopted = UUID()
        var forwarded: [(UUID, String)] = []
        harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }

        // Act: 标题先到（防御路径——真实线序 attached 永远在 output 前）。
        harness.viewModel.setTerminalTitle("early")
        #expect(forwarded.isEmpty)
        await harness.openAndAdopt(adopted)

        // Assert
        #expect(forwarded.count == 1)
        #expect(forwarded.first?.0 == adopted)
        #expect(forwarded.first?.1 == "early")
    }

    @Test("标题被清空（净化后空串）→ terminalTitle 归 nil，上浮空串让注册表清条目")
    func clearedTitleForwardsEmpty() async throws {
        // Arrange
        let harness = try TerminalHarness()
        let adopted = UUID()
        var forwarded: [(UUID, String)] = []
        harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
        await harness.openAndAdopt(adopted)
        harness.viewModel.setTerminalTitle("work")

        // Act
        harness.viewModel.setTerminalTitle("   ")

        // Assert
        #expect(harness.viewModel.terminalTitle == nil)
        #expect(forwarded.map(\.1) == ["work", ""])
    }

    // MARK: - 接线：controller 挂 hook（重建后不掉）+ coordinator 全链路

    private struct WiringFixture {
        let transport: FakeTransport
        let http: FakeHTTPTransport
        let host: HostRegistry.Host
        let unreadStore: InMemoryUnreadWatermarkStore
        let environment: AppEnvironment
    }

    private func makeWiringFixture(suiteName: String) throws -> WiringFixture {
        let baseURL = try #require(URL(string: Self.base))
        let endpoint = try #require(HostEndpoint(baseURL: baseURL))
        let transport = FakeTransport()
        let http = FakeHTTPTransport()
        let unreadStore = InMemoryUnreadWatermarkStore()
        let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
        let defaults = try #require(UserDefaults(suiteName: suiteName))
        return WiringFixture(
            transport: transport,
            http: http,
            host: host,
            unreadStore: unreadStore,
            environment: AppEnvironment(
                hostStore: InMemoryHostStore(hosts: [host]),
                lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
                http: http,
                termTransport: transport,
                probe: { _ in .failure(.timeout) },
                unreadStore: unreadStore
            )
        )
    }

    private func adopt(
        _ controller: TerminalSessionController,
        transport: FakeTransport,
        sessionId: UUID
    ) async {
        await controller.openTask?.value
        await transport.emit(
            frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
        )
        await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
    }

    @Test("controller 把 title hook 接到 VM；suspend→resume 重建后新 VM 依然上浮")
    func controllerRewiresTitleHookAcrossRebuild() async throws {
        // Arrange
        let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.rewire")
        let adopted = UUID()
        var forwarded: [(UUID, String)] = []
        let controller = TerminalSessionController(
            host: fixture.host, sessionId: nil, environment: fixture.environment,
            onPendingChanged: { _, _ in },
            onTitleChanged: { forwarded.append(($0, $1)) }
        )
        controller.start()
        await adopt(controller, transport: fixture.transport, sessionId: adopted)

        // Act: 第一代 VM 上浮。
        controller.terminalViewModel.setTerminalTitle("gen0")

        // Assert
        #expect(forwarded.map(\.1) == ["gen0"])

        // Act: 后台→前台重建（新 engine + 新 VM），再上浮一次。
        controller.suspend()
        controller.resumeIfNeeded()
        await adopt(controller, transport: fixture.transport, sessionId: adopted)
        controller.terminalViewModel.setTerminalTitle("gen1")

        // Assert: 重建没有弄丢 hook，id 仍是 adopted id。
        #expect(forwarded.map(\.1) == ["gen0", "gen1"])
        #expect(forwarded.allSatisfy { $0.0 == adopted })
        controller.teardown()
    }

    @Test("coordinator：closeTerminal 记 last-seen 水位（切会话/返回列表即已读）；title 流入列表行")
    func coordinatorMarksSeenOnCloseAndRoutesTitles() async throws {
        // Arrange
        let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.coordinator")
        let coordinator = AppCoordinator(environment: fixture.environment)
        let adopted = UUID()
        await coordinator.sessionList.reloadHosts()

        // Act: 打开新会话 → 服务器 adopted。
        coordinator.open(SessionListViewModel.OpenRequest(
            id: UUID(), host: fixture.host, sessionId: nil
        ))
        let controller = try #require(coordinator.terminalController)
        await adopt(controller, transport: fixture.transport, sessionId: adopted)

        // Act: OSC 标题上浮（模拟 SwiftTerm delegate）→ 列表行可见。
        controller.terminalViewModel.setTerminalTitle("vim server.ts")
        await fixture.http.queueSuccess(
            url: try listURL(), body: listBody([sessionJSON(id: adopted)])
        )
        await coordinator.sessionList.refresh()
        #expect(coordinator.sessionList.rows.first?.title == "vim server.ts")

        // Act: 返回列表（单活 WS 的切会话前半程：close）。
        coordinator.closeTerminal()

        // Assert: adopted 会话的 last-seen 已入库 —— 切换回列表即已读。
        #expect(coordinator.terminalController == nil)
        #expect(fixture.unreadStore.snapshot[adopted] != nil)
    }
}

// MARK: - 测试替身（App 层 UnreadWatermarkStore 的内存实现）

/// 线程安全的内存水位存储（SessionListViewModelTests 也复用）。
final class InMemoryUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
    private let lock = NSLock()
    private var stored: [UUID: Int]

    init(_ initial: [UUID: Int] = [:]) {
        stored = initial
    }

    func load() -> [UUID: Int] {
        lock.withLock { stored }
    }

    func save(_ watermarks: [UUID: Int]) {
        lock.withLock { stored = watermarks }
    }

    var snapshot: [UUID: Int] {
        lock.withLock { stored }
    }
}
