import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

@Suite struct SyncHostTests {
    private func summary(_ id: String, status: SessionStatus = .running) -> WireSessionSummary {
        WireSessionSummary(
            sessionID: SessionID(rawValue: id), projectID: "p1", projectName: "ProjA",
            backend: .claudeCode, status: status, title: "auth-refactor", branch: "nucleic/auth",
            lastSeq: 10, diffStat: DiffStat(filesChanged: 2, added: 30, removed: 4),
            updatedAt: Date(timeIntervalSince1970: 1_700_000_000))
    }

    private func snapshot(for id: String) -> SessionSnapshot {
        SessionSnapshot(
            summary: summary(id, status: .awaitingApproval),
            recentEvents: [AgentEvent(
                sessionID: SessionID(rawValue: id), seq: 9, at: Date(timeIntervalSince1970: 1),
                backend: .claudeCode, nativeType: nil,
                kind: .assistantText(TextChunk(messageID: "m", text: "working", isPartial: false)))],
            pendingApprovals: [], cursor: 10)
    }

    /// Spin up host + client over an in-memory pipe and pair them, returning everything the
    /// test needs to drive the conversation.
    private func makePaired(
        bridge: FakeSyncBridge, deviceID: String = "iphone-1"
    ) async throws -> (host: SyncHost, hostIdentity: DeviceIdentity, client: SyncClient, recorder: EventRecorder, phone: DeviceIdentity) {
        let hostIdentity = DeviceIdentity()
        let phoneIdentity = DeviceIdentity()
        let store = InMemoryPairedDeviceStore()
        let host = SyncHost(identity: hostIdentity, bridge: bridge, store: store)

        let (clientCh, serverCh) = MemoryChannel.pair()
        try await host.start(listener: OneShotListener(serverCh))
        let payload = await host.beginPairing()

        let client = SyncClient(
            channel: clientCh, identity: phoneIdentity, hostStaticKey: payload.hostStaticKey,
            mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
            deviceLabel: "Andrew's iPhone")
        let recorder = EventRecorder()
        await recorder.consume(client.start())
        return (host, hostIdentity, client, recorder, phoneIdentity)
    }

    @Test func pairsAndWelcomesWithControlScope() async throws {
        let bridge = FakeSyncBridge(sessions: [summary("s1")])
        let (host, _, _, recorder, phone) = try await makePaired(bridge: bridge)

        let ready = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }
        guard case .ready(let welcome) = ready else { Issue.record("no welcome"); return }
        #expect(welcome.grantedScope == .control)
        #expect(welcome.host.hostName == "Andrew's Mac")
        #expect(welcome.capabilities.canModifyToolInput)

        // The host pinned this device with the phone's real static key.
        let devices = await host.pairedDevices()
        #expect(devices.count == 1)
        #expect(devices.first?.staticPublicKey == phone.staticPublicKey)
        #expect(devices.first?.deviceID == "iphone-1")
        await host.stop()
    }

    @Test func listAndSubscribeProjectHostState() async throws {
        let bridge = FakeSyncBridge(sessions: [summary("s1")], snapshots: [SessionID(rawValue: "s1"): snapshot(for: "s1")])
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        await client.send(.listSessions)
        let list = await recorder.waitFor { if case .sessionList = $0 { return true } else { return false } }
        guard case .sessionList(let summaries) = list else { Issue.record("no list"); return }
        #expect(summaries == [summary("s1")])

        await client.send(.subscribe(Subscribe(sessionID: SessionID(rawValue: "s1"), sinceSeq: nil, verbosity: .full)))
        let snap = await recorder.waitFor { if case .snapshot = $0 { return true } else { return false } }
        guard case .snapshot(let s) = snap else { Issue.record("no snapshot"); return }
        #expect(s.summary.sessionID == SessionID(rawValue: "s1"))
        #expect(s.cursor == 10)
        await host.stop()
    }

    @Test func approvalBroadcastAndFirstResponderWins() async throws {
        let sid = SessionID(rawValue: "s1")
        let bridge = FakeSyncBridge(sessions: [summary("s1")], snapshots: [sid: snapshot(for: "s1")])
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        // Must be subscribed to receive an approval push for that session.
        await client.send(.subscribe(Subscribe(sessionID: sid, sinceSeq: nil, verbosity: .full)))
        _ = await recorder.waitFor { if case .snapshot = $0 { return true } else { return false } }

        let approval = ApprovalRequest(
            id: ApprovalID(rawValue: "a1"), sessionID: sid, toolCallID: "t", toolName: "Bash",
            input: ["command": "rm -rf build/"], title: "Run command", risk: .destructive,
            createdAt: Date(timeIntervalSince1970: 2))
        await bridge.push(.approvalRequested(approval))

        let got = await recorder.waitFor { if case .approvalRequested = $0 { return true } else { return false } }
        guard case .approvalRequested(let req) = got else { Issue.record("no approval"); return }
        #expect(req.id == approval.id)

        await client.send(.approvalRespond(approval.id, .deny(reason: "nope")))
        // Wait (bounded) for the decision to traverse seal→decrypt→dispatch→bridge.
        let recorded = await poll { await bridge.respondedApprovals }
        #expect(recorded.count == 1)
        #expect(recorded.first?.0 == approval.id)
        await host.stop()
    }

    @Test func sendInputReachesHost() async throws {
        let sid = SessionID(rawValue: "s1")
        let bridge = FakeSyncBridge(sessions: [summary("s1")])
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        await client.send(.sendInput(sid, AgentInput(text: "keep going")))
        let inputs = await poll { await bridge.sentInputs }
        #expect(inputs.first?.0 == sid)
        #expect(inputs.first?.1.plainText == "keep going")
        await host.stop()
    }

    @Test func controlActionsReachHostAndGrantControlScope() async throws {
        let bridge = FakeSyncBridge(sessions: [summary("s1")])
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        let ready = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }
        guard case .ready(let welcome) = ready else { Issue.record("no welcome"); return }
        // Default pairing now grants full control parity.
        #expect(welcome.grantedScope == .control)

        let pid = ProjectID(rawValue: "p1")
        await client.send(.startChat(StartChatRequest(projectID: pid, message: "go")))
        await client.send(.captureTodo(CaptureTodoRequest(text: "idea", projectID: pid)))
        await client.send(.dispatchTodo(TodoID(rawValue: "t1"), pid))
        await client.send(.integrate(SessionID(rawValue: "s1"), .squash))
        await client.send(.setArchived(SessionID(rawValue: "s1"), true))

        let chats = await poll { await bridge.startedChats }
        #expect(chats.first?.message == "go")
        #expect(await poll { await bridge.capturedTodos }.first?.text == "idea")
        #expect(await poll { await bridge.dispatchedTodos }.first?.1 == pid)
        #expect(await poll { await bridge.integrations }.first?.1 == .squash)
        #expect(await poll { await bridge.archives }.first?.1 == true)
        await host.stop()
    }

    @Test func welcomeCarriesModelCatalog() async throws {
        let catalog = WireModelCatalog(
            groups: [[WireModelCatalog.Model(
                sku: "claude-opus-4-8", displayName: "Opus 4.8", backend: .claudeCode,
                contextBadge: "256K", contextWindow: 256_000, efforts: ["low", "high"],
                effortNoun: "Effort")]],
            effortDisplayNames: ["orchestra": "Orchestra"], orchestraSentinel: "orchestra",
            orchestraRequiresControlNote: "Requires Nucleic Control",
            fallbackModel: "claude-opus-4-8", fallbackEffort: "high")
        let bridge = FakeSyncBridge(modelCatalog: catalog, sessions: [summary("s1")])
        let (host, _, _, recorder, _) = try await makePaired(bridge: bridge)

        let ready = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }
        guard case .ready(let welcome) = ready else { Issue.record("no welcome"); return }
        #expect(welcome.modelCatalog == catalog)
        #expect(welcome.modelCatalog.efforts(forModel: "claude-opus-4-8") == ["low", "high"])
        await host.stop()
    }

    @Test func midSessionSettersReachHost() async throws {
        let sid = SessionID(rawValue: "s1")
        let bridge = FakeSyncBridge(sessions: [summary("s1")])
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        await client.send(.setSessionModel(sid, "claude-sonnet-4-6"))
        await client.send(.setSessionEffort(sid, "xhigh"))
        await client.send(.setSessionAuto(sid, true))
        await client.send(.setSessionAutoShip(sid, true))
        await client.send(.setSessionShipBranch(sid, "main"))

        #expect(await poll { await bridge.sessionModels }.first?.1 == "claude-sonnet-4-6")
        #expect(await poll { await bridge.sessionEfforts }.first?.1 == "xhigh")
        #expect(await poll { await bridge.sessionAutos }.first?.1 == true)
        #expect(await poll { await bridge.sessionAutoShips }.first?.1 == true)
        #expect(await poll { await bridge.sessionShipBranches }.first?.1 == "main")
        await host.stop()
    }

    @Test func dashboardRequestReturnsSnapshot() async throws {
        let bridge = FakeSyncBridge(sessions: [summary("s1")])
        let snapshot = DashboardSnapshot(
            counts: DashboardCounts(projects: 1, chats: 1, activeChats: 1, messages: 3, activeDays: 1),
            activity: [], projects: [], todos: [])
        await bridge.setDashboard(snapshot)
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        await client.send(.listDashboard)
        let got = await recorder.waitFor { if case .dashboard = $0 { return true } else { return false } }
        guard case .dashboard(let d) = got else { Issue.record("no dashboard"); return }
        #expect(d.counts.messages == 3)
        await host.stop()
    }

    @Test func lostApprovalRaceReturnsError() async throws {
        let sid = SessionID(rawValue: "s1")
        let bridge = FakeSyncBridge(sessions: [summary("s1")], snapshots: [sid: snapshot(for: "s1")])
        await bridge.setFailApproval(WireError(code: .alreadyResolved, message: "already answered on Mac", sessionID: sid))
        let (host, _, client, recorder, _) = try await makePaired(bridge: bridge)
        _ = await recorder.waitFor { if case .ready = $0 { return true } else { return false } }

        await client.send(.approvalRespond(ApprovalID(rawValue: "a1"), .allow()))
        let err = await recorder.waitFor { if case .wireError = $0 { return true } else { return false } }
        guard case .wireError(let wire) = err else { Issue.record("no error"); return }
        #expect(wire.code == .alreadyResolved)
        await host.stop()
    }

    @Test func reconnectWithPinnedKeyUsesIK() async throws {
        // First pair to populate the store.
        let bridge = FakeSyncBridge(sessions: [summary("s1")])
        let hostIdentity = DeviceIdentity()
        let phoneIdentity = DeviceIdentity()
        let store = InMemoryPairedDeviceStore()
        let host = SyncHost(identity: hostIdentity, bridge: bridge, store: store)

        let listener = ManualListener()
        try await host.start(listener: listener)

        let (c1, s1) = MemoryChannel.pair()
        listener.push(s1)
        let payload = await host.beginPairing()
        let pairClient = SyncClient(
            channel: c1, identity: phoneIdentity, hostStaticKey: payload.hostStaticKey,
            mode: .pair(secret: payload.pairingSecret), deviceID: "iphone-1", deviceLabel: "iPhone")
        let rec1 = EventRecorder()
        await rec1.consume(pairClient.start())
        _ = await rec1.waitFor { if case .ready = $0 { return true } else { return false } }
        await pairClient.disconnect()

        // Now reconnect over a fresh channel using IK against the pinned host static.
        let (c2, s2) = MemoryChannel.pair()
        listener.push(s2)
        let reconnectClient = SyncClient(
            channel: c2, identity: phoneIdentity, hostStaticKey: hostIdentity.staticPublicKey,
            mode: .reconnect, deviceID: "iphone-1", deviceLabel: "iPhone")
        let rec2 = EventRecorder()
        await rec2.consume(reconnectClient.start())
        let ready = await rec2.waitFor { if case .ready = $0 { return true } else { return false } }
        guard case .ready(let welcome) = ready else { Issue.record("no reconnect welcome"); return }
        #expect(welcome.grantedScope == .control)
        await host.stop()
    }
}
