import Foundation
import Testing
import NucleicProtocol

@testable import NucleicCore

/// The unified local/remote sidebar model (mesh session sync): peer Macs' projects and
/// sessions map into the same `ProjectSummary` / `SessionSummary` shapes the local sidebar
/// renders, interleave through the same list/lookup APIs, and dispatch the same verbs — so
/// the interface represents both origins identically, differentiated only by origin fields.
@MainActor
@Suite("Mesh unified sidebar", .serialized, .isolatedContainerSettings)
struct MeshUnifiedSidebarTests {
    private let t0 = Date(timeIntervalSince1970: 1_700_000_000)

    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) }
        ) { _ in
            ScriptedBackend { e, _ in
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    private func wireSession(
        _ id: String, project: String = "remote-p1", projectName: String = "Remote Project",
        title: String = "remote chat", favorite: Bool = false, archived: Bool = false,
        updatedAt: Date? = nil, lastUserMessageAt: Date? = nil
    ) -> WireSessionSummary {
        WireSessionSummary(
            sessionID: SessionID(rawValue: id), projectID: project, projectName: projectName,
            backend: .claudeCode, status: .running, disposition: nil, title: title,
            branch: "nucleic/x", lastSeq: 3, diffStat: DiffStat(filesChanged: 1, added: 2, removed: 1),
            pendingApprovalCount: 1, favorite: favorite, archived: archived,
            model: "claude-x", effort: "high", auto: true,
            updatedAt: updatedAt ?? t0, lastUserMessageAt: lastUserMessageAt)
    }

    private func peer(
        _ deviceID: String = "peer-A", label: String = "Studio",
        sessions: [WireSessionSummary] = [], projects: [WireProject] = []
    ) -> PeerClient.PeerPresence {
        PeerClient.PeerPresence(
            deviceID: deviceID, label: label, connected: true,
            sessions: sessions,
            dashboard: projects.isEmpty ? nil : DashboardSnapshot(
                counts: DashboardCounts(
                    projects: projects.count, chats: sessions.count, activeChats: 0,
                    messages: 0, activeDays: 0, tokens: 0),
                activity: [], projects: projects, todos: []))
    }

    // MARK: Wire → local summary mapping

    @Test func wireSummaryMapsIntoLocalShapeWithOrigin() {
        let wire = wireSession("s-1", favorite: true, lastUserMessageAt: t0.addingTimeInterval(-30))
        let summary = SessionSummary(wire: wire, hostID: "peer-A")
        // The last-user-message stamp is carried on the wire, so the sidebar orders remote rows
        // on the last turn rather than reshuffling on every bit of agent activity.
        #expect(summary.lastUserMessageAt == wire.lastUserMessageAt)
        #expect(summary.id.rawValue == "s-1")
        #expect(summary.projectID.rawValue == "remote-p1")
        #expect(summary.title == "remote chat")
        #expect(summary.status == .running)
        #expect(summary.favorite)
        #expect(summary.auto)
        #expect(summary.effort == "high")
        #expect(summary.pendingApprovalCount == 1)
        #expect(summary.hostID == "peer-A")
        // Owner-tracked view-state the wire doesn't carry stays quiet, not guessed.
        #expect(!summary.unseenCompletion)
        #expect(!summary.autoShipFailed)
        // Recency comparators fall back to updatedAt so remote rows sort like local ones.
        #expect(summary.createdAt == wire.updatedAt)
        // Row identity is origin-qualified (tombstone/live-twin collision guard).
        #expect(summary.sidebarRowID == "peer-A-s-1")
    }

    // MARK: Unified project list + lookup

    @Test func sidebarProjectsInterleavesLocalAndRemoteByName() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        _ = await store.addProject(name: "bravo", rootPath: repo.root, defaultBranch: "main")

        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-1", project: "remote-p1", projectName: "alpha")],
            projects: [
                WireProject(id: ProjectID(rawValue: "remote-p1"), name: "alpha",
                            defaultBranch: "main", sessionCount: 1, activeCount: 1,
                            isNucleicControlled: true),
                // A peer project with no sessions still gets a header, like an empty local one.
                WireProject(id: ProjectID(rawValue: "remote-p2"), name: "zulu",
                            defaultBranch: "main", sessionCount: 0, activeCount: 0),
            ])])

        let projects = store.sidebarProjects
        #expect(projects.map(\.name) == ["alpha", "bravo", "zulu"])
        let alpha = try #require(projects.first { $0.name == "alpha" })
        #expect(alpha.isRemote)
        #expect(alpha.hostID == "peer-A")
        #expect(alpha.hostLabel == "Studio")
        #expect(alpha.isNucleicControlled)   // carried from the wire, not path-derived
        let bravo = try #require(projects.first { $0.name == "bravo" })
        #expect(!bravo.isRemote)

        // Unified lookup resolves both origins.
        #expect(store.projectSummary(alpha.id)?.hostID == "peer-A")
        #expect(store.projectSummary(bravo.id)?.hostID == nil)

        // A disconnected peer takes its projects with it.
        store.setMeshPeersForTesting([])
        #expect(store.sidebarProjects.map(\.name) == ["bravo"])
    }

    @Test func projectHeaderSynthesizedFromSessionsUntilDashboardArrives() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.setMeshPeersForTesting([peer(
            sessions: [wireSession("s-1", project: "remote-p1", projectName: "alpha")])])
        let alpha = try #require(store.sidebarProjects.first { $0.name == "alpha" })
        #expect(alpha.isRemote)
        #expect(!alpha.isNucleicControlled)   // unknown until the dashboard lands
    }

    // MARK: Unified session lists

    @Test func summariesServeRemoteProjectsWithLocalOrdering() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-old", title: "old", updatedAt: t0.addingTimeInterval(-100)),
            wireSession("s-new", title: "new", updatedAt: t0),
            wireSession("s-fav", title: "fav", favorite: true, updatedAt: t0.addingTimeInterval(-500)),
            wireSession("s-arch", title: "gone", archived: true),
        ])])

        let project = try #require(store.projectSummary(ProjectID(rawValue: "remote-p1")))
        let live = store.summaries(for: project)
        // Favorites pin first (like local), then recency; archived rows stay out.
        #expect(live.map(\.title) == ["fav", "new", "old"])
        #expect(live.allSatisfy { $0.hostID == "peer-A" })

        let archived = store.archivedSummaries(for: project)
        #expect(archived.map(\.title) == ["gone"])
        #expect(archived.first?.hostID == "peer-A")
    }

    /// Remote rows order on the last *user* message, not on `updatedAt`. `updatedAt` bumps on any
    /// agent activity (output, diff refresh, autoship notes), which reshuffled the list every few
    /// seconds; the last-turn stamp keeps a chat's position steady until the user speaks again.
    @Test func remoteSessionsOrderByLastUserMessageNotActivity() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        // "chatty" has the newest activity but the oldest user message; "quiet" is the reverse.
        // Ordering by activity would put chatty first; ordering by last turn puts quiet first.
        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-chatty", title: "chatty",
                        updatedAt: t0, lastUserMessageAt: t0.addingTimeInterval(-500)),
            wireSession("s-quiet", title: "quiet",
                        updatedAt: t0.addingTimeInterval(-400), lastUserMessageAt: t0.addingTimeInterval(-100)),
        ])])

        let project = try #require(store.projectSummary(ProjectID(rawValue: "remote-p1")))
        #expect(store.summaries(for: project).map(\.title) == ["quiet", "chatty"])
    }

    /// Two Macs holding the same ProjectID (a migrated app database) must not cross their
    /// session lists: the origin-aware overloads key by the summary's own origin.
    @Test func sameProjectIDOnTwoOriginsKeepsSessionListsApart() 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 localID = try await store.createSession(in: project, title: "local chat", prompt: "")
        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-remote", project: project.id.rawValue, projectName: "demo"),
        ])])

        let localSummary = ProjectSummary(project)
        let remoteSummary = ProjectSummary(id: project.id, name: "demo", hostID: "peer-A")
        #expect(store.summaries(for: localSummary).map(\.id) == [localID])
        #expect(store.summaries(for: remoteSummary).map(\.title) == ["remote chat"])
    }

    @Test func recentsUnionTheMeshAndPreferLocalTwins() 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 localID = try await store.createSession(in: project, title: "local chat", prompt: "")

        store.setMeshPeersForTesting([peer(sessions: [
            wireSession("s-remote", title: "remote chat"),
            // The same id as a local record (a moved session mirrored back): local row wins.
            wireSession(localID.rawValue, title: "shadowed twin"),
        ])])

        let recents = store.recentSummaries()
        #expect(recents.contains { $0.title == "remote chat" && $0.hostID == "peer-A" })
        #expect(recents.contains { $0.id == localID && $0.hostID == nil })
        #expect(!recents.contains { $0.title == "shadowed twin" })
        #expect(store.recentCount(now: t0.addingTimeInterval(60)) == 2)
    }

    // MARK: Selection routing

    @Test func openSessionRoutesRemoteAndLocalRecordsWin() 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 localID = try await store.createSession(in: project, title: "mine", prompt: "")

        store.setMeshPeersForTesting([peer(sessions: [wireSession("s-remote")])])

        // The one selection path: assigning the id routes remotely on its own.
        store.openSessionID = SessionID(rawValue: "s-remote")
        #expect(store.openHostID == "peer-A")
        #expect(store.openSession?.title == "remote chat")
        #expect(store.openSession?.worktreePath == nil)

        // The summary-shaped opener does the same.
        store.openSessionID = nil
        let remoteProject = try #require(store.projectSummary(ProjectID(rawValue: "remote-p1")))
        let summary = try #require(store.summaries(for: remoteProject).first)
        store.openSession(summary)
        #expect(store.openSessionID?.rawValue == "s-remote")
        #expect(store.openHostID == "peer-A")

        // A local record with the same id shadows the remote copy (tombstone guard).
        store.setMeshPeersForTesting([peer(sessions: [wireSession(localID.rawValue, title: "imposter")])])
        store.openSessionID = localID
        #expect(store.openHostID == nil)
        #expect(store.remoteSessionLocation(localID) == nil)
    }

    /// After a transfer between two OTHER Macs, both peers mirror the same SessionID — the
    /// old owner as a moved-away tombstone, the new owner live. Routing must bind to the
    /// live copy regardless of peer order, and an explicit open of the live row must honor
    /// the caller's host even when the roster lookup would pick the tombstone's.
    @Test func routingPrefersLiveCopyOverPeerTombstone() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        var tombstone = wireSession("s-x", title: "moved away")
        tombstone = WireSessionSummary(
            sessionID: tombstone.sessionID, projectID: tombstone.projectID,
            projectName: tombstone.projectName, backend: tombstone.backend,
            status: tombstone.status, title: tombstone.title, branch: tombstone.branch,
            lastSeq: tombstone.lastSeq, diffStat: nil, archived: true, updatedAt: t0,
            movedTo: MovedDestination(deviceID: "peer-B", deviceName: "Studio"))
        // "Air" sorts before "Studio", so a naive first-match lookup would pick the tombstone.
        store.setMeshPeersForTesting([
            peer("peer-A", label: "Air", sessions: [tombstone]),
            peer("peer-B", label: "Studio", sessions: [wireSession("s-x", title: "live twin")]),
        ])

        let location = try #require(store.remoteSessionLocation(SessionID(rawValue: "s-x")))
        #expect(location.deviceID == "peer-B")
        #expect(location.summary.movedTo == nil)

        // Explicit open of the live row binds to the clicked host.
        let studioProject = ProjectSummary(
            id: ProjectID(rawValue: "remote-p1"), name: "Remote Project", hostID: "peer-B")
        let live = try #require(store.summaries(for: studioProject).first)
        store.openSession(live)
        #expect(store.openHostID == "peer-B")
    }

    // MARK: Owner-side broadcasts that keep viewers' mirrors fresh

    @Test func favoriteAndDeleteBroadcastToViewers() 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: "watched", prompt: "")

        let collected = Collector()
        let stream = await store.broadcasts()
        let consumer = Task { for await b in stream { await collected.add(b) } }
        defer { consumer.cancel() }

        // A local favorite toggle (a mutation that emits no transcript event) reaches viewers.
        await store.setSessionFavorite(sessionID, true)
        let updated = await collected.waitFor { broadcasts in
            broadcasts.contains {
                if case .sessionUpdated(let s) = $0 { return s.sessionID == sessionID && s.favorite }
                return false
            }
        }
        #expect(updated)

        // A deletion pushes the whole refreshed list, so viewers drop the row immediately.
        await store.deleteSession(sessionID)
        let removed = await collected.waitFor { broadcasts in
            broadcasts.contains {
                if case .sessionList(let list) = $0 { return !list.contains { $0.sessionID == sessionID } }
                return false
            }
        }
        #expect(removed)
    }
}

/// Collects host broadcasts off the main actor so tests can await a matching one.
private actor Collector {
    private var broadcasts: [HostBroadcast] = []
    func add(_ b: HostBroadcast) { broadcasts.append(b) }
    func waitFor(
        _ predicate: @Sendable ([HostBroadcast]) -> Bool, tries: Int = 200
    ) async -> Bool {
        for _ in 0..<tries {
            if predicate(broadcasts) { return true }
            try? await Task.sleep(for: .milliseconds(10))
        }
        return predicate(broadcasts)
    }
}
