import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// Validates the real authority wiring: a live `AppStore` standing in as `SyncHostBridge`,
/// projected to a `SyncClient` over an in-memory pipe. Complements `SyncHostTests` (which
/// uses a fake bridge) by exercising the actual session→wire mapping and snapshot path.
@MainActor
@Suite("AppStore sync bridge", .serialized, .isolatedContainerSettings)
struct AppStoreSyncBridgeTests {
    private func makeStore(repo: GitTestRepo) -> AppStore {
        let database = try! GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        return AppStore(
            database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { session in
            let wtPath = session.worktreePath ?? ""
            return ScriptedBackend { e, _ in
                e.emit(.sessionStarted(SessionStarted(
                    backendSessionID: "be", model: "m", cwd: wtPath, toolNames: ["Write"])))
                e.emit(.assistantText(TextChunk(messageID: "m1", text: "on it", isPartial: false)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    @Test func phoneSeesRealSessionsAndSnapshot() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.syncHostName = "Andrew's Mac"

        let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")
        let projectID = try #require(project?.id)
        let sessionID = try await store.createSession(
            in: store.project(projectID)!, title: "make a file", prompt: "go")
        store.openSessionID = sessionID
        await store.awaitOpenSessionSettled()

        // Stand the sync server up with the live store as its authority.
        let hostIdentity = DeviceIdentity()
        let host = SyncHost(identity: hostIdentity, bridge: store, store: InMemoryPairedDeviceStore())
        let listener = ManualListener()
        try await host.start(listener: listener)
        let (clientCh, serverCh) = MemoryChannel.pair()
        listener.push(serverCh)
        let payload = await host.beginPairing()

        let client = SyncClient(
            channel: clientCh, identity: DeviceIdentity(), hostStaticKey: payload.hostStaticKey,
            mode: .pair(secret: payload.pairingSecret), deviceID: "iphone-1", deviceLabel: "iPhone")
        let recorder = EventRecorder()
        await recorder.consume(client.start())

        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.host.hostName == "Andrew's Mac")

        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.count == 1)
        let summary = try #require(summaries.first)
        #expect(summary.sessionID == sessionID)
        #expect(summary.projectName == "demo")
        #expect(summary.title == "make a file")
        #expect(summary.status == .awaitingInput)

        await client.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
        let snap = await recorder.waitFor { if case .snapshot = $0 { return true } else { return false } }
        guard case .snapshot(let snapshot) = snap else { Issue.record("no snapshot"); return }
        #expect(snapshot.summary.sessionID == sessionID)
        // The transcript tail came across: the agent's assistant text is in there.
        #expect(snapshot.recentEvents.contains {
            if case .assistantText(let c) = $0.kind { return c.text == "on it" } else { return false }
        })

        await host.stop()
    }

    // MARK: Full-transcript mesh sync — serving side

    @Test func bridgeAdvertisesTranscriptSyncCapability() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        #expect(await store.capabilities.canSyncTranscripts == true)
    }

    @Test func transcriptEventsServesFullHistoryAndHeader() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let sessionID = try await store.createSession(in: project, title: "make a file", prompt: "go")
        store.openSessionID = sessionID
        await store.awaitOpenSessionSettled()

        // afterSeq: 0 ⇒ the WHOLE transcript (not a 200 tail) + the header, so a requester can
        // build a faithful mirror.
        let full = try #require(await store.transcriptEvents(sessionID, afterSeq: 0))
        #expect(full.header != nil)
        #expect(full.header?.sessionID == sessionID)
        #expect(full.events.count >= 2)
        #expect(full.events.contains {
            if case .assistantText(let c) = $0.kind { return c.text == "on it" } else { return false }
        })
        // Events are canonically seq-ordered and contiguous from 1.
        #expect(full.events.map(\.seq) == Array(1...UInt64(full.events.count)))

        // A warm cursor returns only the tail after it, and no header.
        let tipSeq = try #require(full.events.last?.seq)
        let afterFirst = try #require(await store.transcriptEvents(sessionID, afterSeq: 1))
        #expect(afterFirst.header == nil)
        #expect(afterFirst.events.allSatisfy { $0.seq > 1 })
        #expect(afterFirst.events.last?.seq == tipSeq)

        // Unknown session ⇒ nil ⇒ the handler replies `notHeld`.
        #expect(await store.transcriptEvents(SessionID(rawValue: "ghost"), afterSeq: 0) == nil)
    }

    // MARK: Session transfer — destination side (mesh P5)

    @Test func bridgeAdvertisesTransferCapability() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        #expect(await store.capabilities.canReceiveSessionTransfer == true)
    }

    @Test func offerForUnknownProjectIsRejected() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)  // no projects added → nothing resolves

        let offer = TransferOffer(
            transferID: "x",
            record: SessionTransferRecord(
                sessionID: SessionID(rawValue: "s1"), backend: .claudeCode, backendSessionID: nil,
                title: "t", branch: "nucleic/feature", baseSHA: "abc",
                model: nil, effort: nil, lastSeq: 0, createdAt: Date(timeIntervalSince1970: 1)),
            project: ProjectDescriptor(
                projectID: ProjectID(rawValue: "unknown"), name: "ghost",
                rootCommitSHA: "nope", normalizedRemote: nil, defaultBranch: "main"),
            transcriptHeaderVersion: SessionHeader.currentVersion,
            availableBaseSHAs: ["abc"], knownItems: [])
        let replies = await store.receiveTransferOffer(offer, from: "host-A")
        guard case .transferReject(let reject) = replies.first else {
            Issue.record("expected a reject, got \(replies)"); return
        }
        #expect(reject.reason == .projectNotFound)
    }

    @Test func offerForAKnownProjectIsAcceptedWithHaveSHAs() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let baseSHA = try await repo.revParse("HEAD")

        let offer = TransferOffer(
            transferID: "x",
            record: SessionTransferRecord(
                sessionID: SessionID(rawValue: "s1"), backend: .claudeCode, backendSessionID: nil,
                title: "t", branch: "nucleic/never-seen", baseSHA: baseSHA,
                model: nil, effort: nil, lastSeq: 0, createdAt: Date(timeIntervalSince1970: 1)),
            project: ProjectDescriptor(
                projectID: project.id, name: "demo", rootCommitSHA: nil,
                normalizedRemote: nil, defaultBranch: "main"),
            transcriptHeaderVersion: SessionHeader.currentVersion,
            availableBaseSHAs: [baseSHA], knownItems: [
                TransferItemDescriptor(kind: .transcript, byteCount: 10, sha256: "z", chunkCount: 1)])
        let replies = await store.receiveTransferOffer(offer, from: "host-A")
        guard case .transferAccept(let accept) = replies.first else {
            Issue.record("expected accept, got \(replies)"); return
        }
        #expect(accept.resolvedProjectID == project.id)
        // The destination shares the base commit (its own HEAD) → it's offered as a have.
        #expect(accept.haveSHAs.contains(baseSHA))
        // Clean up the staging the offer opened.
        await store.receiveTransferCancel("x")
    }

    // MARK: Relaunch recovery (mesh P5)

    /// `recoverInterruptedTransfers` reconciles locks a crash left behind: pre-tombstone outbound
    /// locks, orphaned inbound staging, and an unrecoverable inbound `ready` (no manifest on disk)
    /// are all cleared, while a tombstoned source with no reachable peer is preserved for a later
    /// attempt.
    @Test func recoverInterruptedTransfersCleansUpAbandonedLocks() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let db = try GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let store = AppStore(
            database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { _ in ScriptedBackend { e, _ in e.emit(.runFinished(RunFinished(outcome: .completed))) } }

        let t = Date(timeIntervalSince1970: 1_700_000_000)
        func seed(_ id: String, _ session: String, _ dir: TransferDirection, _ state: SessionTransferState) async throws {
            try await db.beginTransfer(SessionTransferLock(
                transferID: id, sessionID: SessionID(rawValue: session), direction: dir,
                peerDeviceID: "peer-B", state: state, createdAt: t, updatedAt: t))
        }
        try await seed("o-offer", "s1", .outbound, .offering)
        try await seed("o-stream", "s2", .outbound, .streaming)
        try await seed("o-await", "s3", .outbound, .awaitingReady)
        try await seed("o-tomb", "s4", .outbound, .tombstoned)
        try await seed("i-stage", "s5", .inbound, .staging)
        try await seed("i-ready", "s6", .inbound, .ready)

        await store.recoverInterruptedTransfers()

        // Abandoned pre-tombstone outbound locks: cleared (the sessions live here again).
        #expect(try await db.transfer(transferID: "o-offer") == nil)
        #expect(try await db.transfer(transferID: "o-stream") == nil)
        #expect(try await db.transfer(transferID: "o-await") == nil)
        // Orphaned inbound partial receive: cleared.
        #expect(try await db.transfer(transferID: "i-stage") == nil)
        // Tombstoned with no reachable peer (no PeerClient here): preserved for a later attempt.
        #expect(try await db.transfer(transferID: "o-tomb")?.state == .tombstoned)
        // Inbound `ready` with no manifest on disk is unrecoverable → its orphan lock is cleared.
        #expect(try await db.transfer(transferID: "i-ready") == nil)
        #expect(store.pendingArrivedTransfers.isEmpty)
    }

    /// A stranded inbound `ready` transfer *with* its on-disk manifest is surfaced as a pending
    /// arrival the user can activate or discard (mesh P5 relaunch recovery).
    @Test func recoverInterruptedTransfersSurfacesStrandedArrival() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let db = try GRDBMetadataStore(path: nil)
        let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
        let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
            .appendingPathComponent("transcripts"))
        let store = AppStore(
            database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
            now: { Date(timeIntervalSince1970: 1_700_000_000) }
        ) { _ in ScriptedBackend { e, _ in e.emit(.runFinished(RunFinished(outcome: .completed))) } }

        let t = Date(timeIntervalSince1970: 1_700_000_000)
        let sessionID = SessionID(rawValue: "arr-session")
        try await db.beginTransfer(SessionTransferLock(
            transferID: "arr", sessionID: sessionID, direction: .inbound,
            peerDeviceID: "laptop-A", state: .ready, createdAt: t, updatedAt: t))
        // Write the staged-session manifest where the importer looks (…/transfers/<id>/).
        let stagingRoot = transcriptsDir.deletingLastPathComponent()
            .appendingPathComponent("transfers", isDirectory: true)
        let dir = stagingRoot.appendingPathComponent("arr", isDirectory: true)
        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        let staged = Session(
            id: sessionID, projectID: ProjectID(rawValue: "p1"), backend: .claudeCode,
            title: "ported chat", status: .awaitingInput, worktreePath: "/tmp/wt",
            branch: "nucleic/ported", baseSHA: "abc", lastSeq: 1,
            transcriptPath: dir.appendingPathComponent("t.jsonl").path,
            arrivedFromDeviceID: "laptop-A", createdAt: t, updatedAt: t)
        try JSONEncoder().encode(staged).write(
            to: dir.appendingPathComponent("staged-session.json"), options: .atomic)

        await store.recoverInterruptedTransfers()

        #expect(store.pendingArrivedTransfers.count == 1)
        let prompt = try #require(store.pendingArrivedTransfers.first)
        #expect(prompt.transferID == "arr")
        #expect(prompt.title == "ported chat")
        #expect(prompt.sourceDeviceID == "laptop-A")
        // Still recoverable (the lock stays until the user resolves it).
        #expect(try await db.transfer(transferID: "arr")?.state == .ready)
    }

    // MARK: Bulk hand-off (mesh P5)

    /// `transferableSessions` lists the eligible chats (excluding archived ones), and a bulk
    /// `moveSessionsToPeer` with no reachable peer fails every move, sets a rollup `lastError`, and
    /// restores the sessions (nothing is lost when a hand-off can't reach the destination).
    @Test func bulkHandoffListsEligibleAndRollsUpFailures() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
        let s1 = try await store.createSession(in: store.project(project.id)!, title: "one", prompt: "go")
        let s2 = try await store.createSession(in: store.project(project.id)!, title: "two", prompt: "go")

        // Wait for both turns to quiesce (a mid-turn session isn't transferable).
        var eligible = await store.transferableSessions()
        var tries = 0
        while eligible.count < 2, tries < 200 {
            try await Task.sleep(for: .milliseconds(20))
            eligible = await store.transferableSessions()
            tries += 1
        }
        #expect(Set(eligible.map(\.id)) == [s1, s2])

        // Archiving one drops it from the eligible list.
        await store.setSessionArchived(s1, true)
        #expect(await store.transferableSessions().map(\.id) == [s2])

        // Hand-off to a peer that isn't connected: the move fails, but the session is restored.
        let result = await store.moveSessionsToPeer([s2], to: "ghost-device", label: "Studio")
        #expect(result.moved == 0)
        #expect(result.failed == 1)
        #expect(store.lastError != nil)
        #expect(store.summaries(for: project.id).map(\.id).contains(s2))
    }
}
