import Foundation
import Testing

@testable import NucleicCore

@Suite("GRDBMetadataStore — in-memory SQLite")
struct GRDBMetadataStoreTests {
    let now = Date(timeIntervalSince1970: 1_700_000_000)

    func makeProject() -> Project {
        Project(
            id: .generate(), name: "demo", rootPath: "/repos/demo",
            defaultBranch: "main", defaultBackend: .claudeCode,
            worktreeBase: nil, setupScript: "npm install", setupPolicy: .warn, createdAt: now)
    }

    func makeSession(projectID: ProjectID) -> Session {
        Session(
            id: .generate(), projectID: projectID, backend: .claudeCode,
            backendSessionID: "be-1", title: "fix bug", status: .running,
            worktreePath: "/wt/x", branch: "nucleic/fix-bug", baseSHA: "abc123",
            model: "opus", effort: "high", lastSeq: 7, transcriptPath: "/t/x.jsonl",
            diffStat: DiffStat(filesChanged: 2, added: 10, removed: 3), ahead: 1, behind: 0,
            createdAt: now, updatedAt: now)
    }

    @Test func projectRoundTrips() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let project = makeProject()
        try await store.saveProject(project)
        let loaded = try await store.loadProjects()
        #expect(loaded == [project])
    }

    @Test func nvrsionConfigRoundTrips() async throws {
        let store = try GRDBMetadataStore(path: nil)
        // Default project: no nvrsion config → nil (off).
        let plain = makeProject()
        try await store.saveProject(plain)
        #expect(try await store.loadProjects().first(where: { $0.id == plain.id })?.nvrsion == nil)

        // Project with an explicit nvrsion config round-trips every field (v20).
        var project = makeProject()
        project.nvrsion = ProjectNvrsion(
            enabled: true, trunkBranch: "nucleic/trunk", prelandHook: "nvrsion-check",
            keepWarmIdleSeconds: 9)
        try await store.saveProject(project)
        let loaded = try await store.loadProjects().first(where: { $0.id == project.id })?.nvrsion
        #expect(loaded == ProjectNvrsion(
            enabled: true, trunkBranch: "nucleic/trunk", prelandHook: "nvrsion-check",
            keepWarmIdleSeconds: 9))

        // A blank pre-land hook decodes back to nil (→ land immediately), and a missing
        // trunk-branch falls back to the default — the tolerant decode (NVRSION §10).
        let blankHook = #"{"enabled":true,"prelandHook":"  ","keepWarmIdleSeconds":2}"#
        let decoded = try JSONDecoder().decode(ProjectNvrsion.self, from: Data(blankHook.utf8))
        #expect(decoded.resolvedPrelandHook == nil)
        #expect(decoded.trunkBranch == ProjectNvrsion.defaultTrunkBranch)
    }

    @Test func nvrsionActiveGate() {
        // A control-located repo with nvrsion on and the shared container → active.
        let controlPath = (ProjectCloner.controlBase as NSString).appendingPathComponent("demo")
        var control = Project(
            id: .generate(), name: "demo", rootPath: controlPath, defaultBranch: "main",
            nvrsion: ProjectNvrsion(enabled: true), createdAt: now)
        #expect(control.isNucleicControlled)
        #expect(control.usesSharedControlContainer)
        #expect(control.nvrsionActive)

        // Disabled → not active.
        control.nvrsion = ProjectNvrsion(enabled: false)
        #expect(!control.nvrsionActive)

        // Enabled but per-session containers (no shared container) → not active in v0.
        control.nvrsion = ProjectNvrsion(enabled: true)
        control.sandbox = ProjectSandbox(enabled: true, perSessionContainers: true)
        #expect(!control.usesSharedControlContainer)
        #expect(!control.nvrsionActive)

        // A non-control repo is never nvrsion-active, even with the flag on.
        let outside = Project(
            id: .generate(), name: "demo", rootPath: "/repos/demo", defaultBranch: "main",
            nvrsion: ProjectNvrsion(enabled: true), createdAt: now)
        #expect(!outside.isNucleicControlled)
        #expect(!outside.nvrsionActive)
    }

    @Test func shipDestinationResolutionPrecedence() {
        var project = makeProject()  // defaultBranch == "main", no autoship override
        var session = makeSession(projectID: project.id)

        // No overrides, no parentRef → the project root repo's default branch.
        session.parentRef = nil
        #expect(session.shipDestination(in: project) == GitRef("main"))

        // A nested child (parentRef set, no overrides) ships into its parent branch.
        session.parentRef = "nucleic/parent"
        #expect(session.shipDestination(in: project) == GitRef("nucleic/parent"))

        // A project autoship branch overrides the parentRef fallback.
        project.autoShipBranch = GitRef("staging")
        #expect(session.shipDestination(in: project) == GitRef("staging"))

        // A per-session override beats the project setting; blank/whitespace is ignored.
        session.shipBranch = "  "
        #expect(session.shipDestination(in: project) == GitRef("staging"))
        session.shipBranch = "release"
        #expect(session.shipDestination(in: project) == GitRef("release"))
    }

    @Test func autoshipDestinationOverridesRoundTrip() async throws {
        let store = try GRDBMetadataStore(path: nil)
        // Default project: no autoship-branch override → nil (ships to the root repo).
        let plain = makeProject()
        try await store.saveProject(plain)
        #expect(try await store.loadProjects().first?.autoShipBranch == nil)

        // Project with an explicit autoship branch, and a session that overrides it.
        var project = makeProject()
        project.autoShipBranch = GitRef("staging")
        try await store.saveProject(project)
        #expect(try await store.loadProjects().first(where: { $0.id == project.id })?.autoShipBranch
            == GitRef("staging"))

        var session = makeSession(projectID: project.id)
        session.shipBranch = "release"
        try await store.saveSession(session)
        let loaded = try await store.loadSession(id: session.id)
        #expect(loaded?.shipBranch == "release")  // v17 round-trip
        #expect(loaded?.shipDestination(in: project) == GitRef("release"))  // session override wins

        // Clearing the session override falls back to the project's autoship branch.
        session.shipBranch = nil
        try await store.saveSession(session)
        let reloaded = try await store.loadSession(id: session.id)
        #expect(reloaded?.shipBranch == nil)
        #expect(reloaded?.shipDestination(in: project) == GitRef("staging"))
    }

    @Test func sessionUpsertAndLoad() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let project = makeProject()
        try await store.saveProject(project)
        var session = makeSession(projectID: project.id)
        try await store.saveSession(session)

        let loaded = try await store.loadSession(id: session.id)
        #expect(loaded == session)
        #expect(loaded?.model == "opus")
        #expect(loaded?.effort == "high")  // v2 migration column

        #expect(loaded?.lastTurnDisposition == nil)  // v8 migration column, default null
        #expect(loaded?.archivedAt == nil)  // v16 migration column, default null

        // Upsert: change status + lastSeq + diffstat + disposition + archive stamp.
        session.status = .finished
        session.lastSeq = 42
        session.diffStat = DiffStat(filesChanged: 3, added: 20, removed: 5)
        session.lastTurnDisposition = .completed
        session.archived = true
        session.archivedAt = now.addingTimeInterval(3600)
        try await store.saveSession(session)

        let reloaded = try await store.loadSession(id: session.id)
        #expect(reloaded?.status == .finished)
        #expect(reloaded?.lastSeq == 42)
        #expect(reloaded?.diffStat == DiffStat(filesChanged: 3, added: 20, removed: 5))
        #expect(reloaded?.lastTurnDisposition == .completed)  // v8 round-trip
        #expect(reloaded?.archivedAt == now.addingTimeInterval(3600))  // v16 round-trip
        // Still a single row.
        #expect(try await store.loadSessions(projectID: project.id).count == 1)
    }

    @Test func sessionsScopedToProjectAndDeletable() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let p1 = makeProject()
        let p2 = Project(name: "other", rootPath: "/repos/other", defaultBranch: "main", createdAt: now)
        try await store.saveProject(p1)
        try await store.saveProject(p2)
        let s1 = makeSession(projectID: p1.id)
        let s2 = makeSession(projectID: p2.id)
        try await store.saveSession(s1)
        try await store.saveSession(s2)

        #expect(try await store.loadSessions(projectID: p1.id).map(\.id) == [s1.id])

        try await store.deleteSession(id: s1.id)
        #expect(try await store.loadSession(id: s1.id) == nil)
        #expect(try await store.loadSession(id: s2.id) != nil)
    }

    @Test func todoRoundTripsAndDeletes() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let project = makeProject()
        try await store.saveProject(project)

        let later = now.addingTimeInterval(60)
        let a = Todo(text: "unassigned idea", createdAt: now, updatedAt: now)
        let b = Todo(
            text: "tagged idea with a much longer body", projectID: project.id, status: .dispatched,
            dispatchedSessionID: .generate(), summary: "tagged idea", createdAt: later, updatedAt: later)
        try await store.saveTodo(a)
        try await store.saveTodo(b)

        // Loaded most-recently-updated first.
        let loaded = try await store.loadTodos()
        #expect(loaded == [b, a])
        #expect(loaded.first?.projectID == project.id)
        #expect(loaded.first?.status == .dispatched)
        #expect(loaded.first?.summary == "tagged idea")  // v6 migration column
        #expect(loaded.last?.summary == nil)
        #expect(loaded.last?.projectID == nil)

        // Upsert: same id, mutated fields → still one row.
        var updated = a
        updated.status = .done
        updated.text = "edited"
        try await store.saveTodo(updated)
        let afterUpsert = try await store.loadTodos()
        #expect(afterUpsert.count == 2)
        #expect(afterUpsert.first(where: { $0.id == a.id })?.text == "edited")

        try await store.deleteTodo(id: b.id)
        #expect(try await store.loadTodos().map(\.id) == [a.id])
    }

    @Test func approvalPendingThenResolved() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let session = makeSession(projectID: .generate())
        let request = ApprovalRequest(
            id: .generate(), sessionID: session.id, toolCallID: "tc1", toolName: "Write",
            input: .object(["file_path": .string("/a.txt")]), title: "Write /a.txt",
            risk: .write, createdAt: now)
        try await store.saveApproval(request)

        let pending = try await store.pendingApprovals(sessionID: session.id)
        #expect(pending.count == 1)
        #expect(pending.first?.toolName == "Write")
        #expect(pending.first?.input["file_path"]?.stringValue == "/a.txt")

        try await store.resolveApproval(ApprovalResolved(
            id: request.id, decision: .allow(), decidedBy: "mac-ui", decidedAt: now))
        #expect(try await store.pendingApprovals(sessionID: session.id).isEmpty)
    }

    @Test func persistsToDiskFileAndReopens() async throws {
        let dir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-db-\(UUID().uuidString)")
        let path = (dir as NSString).appendingPathComponent("nucleic.sqlite")
        defer { try? FileManager.default.removeItem(atPath: dir) }

        let project = makeProject()
        let session = makeSession(projectID: project.id)
        do {
            let store = try GRDBMetadataStore(path: path)
            try await store.saveProject(project)
            try await store.saveSession(session)
        }
        // Reopen a fresh handle on the same file — data survives.
        let reopened = try GRDBMetadataStore(path: path)
        #expect(try await reopened.loadSession(id: session.id) == session)
    }

    @Test func controllerPersistsThroughPipeline() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let projectID = ProjectID.generate()
        let dir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-cp-\(UUID().uuidString)")
        defer { try? FileManager.default.removeItem(atPath: dir) }
        let url = URL(fileURLWithPath: (dir as NSString).appendingPathComponent("t.jsonl"))
        let sessionID = SessionID.generate()
        let writer = try TranscriptWriter(
            url: url,
            header: SessionHeader(sessionID: sessionID, backend: .claudeCode, worktree: "/tmp", createdAt: now))
        let session = Session(
            id: sessionID, projectID: projectID, backend: .claudeCode, title: "run",
            transcriptPath: url.path, createdAt: now, updatedAt: now)
        let backend = ScriptedBackend { e, _ in
            e.emit(.sessionStarted(SessionStarted(
                backendSessionID: "be-9", model: "m", cwd: "/tmp", toolNames: [])))
            e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
            e.emit(.runFinished(RunFinished(outcome: .completed)))
        }
        let controller = SessionController(
            session: session, backend: backend, transcript: writer,
            metadataStore: store, now: { self.now })

        await controller.start(prompt: AgentInput(text: "go"))
        await controller.join()

        let persisted = try await store.loadSession(id: sessionID)
        #expect(persisted?.status == .finished)
        #expect(persisted?.backendSessionID == "be-9")
        #expect((persisted?.lastSeq ?? 0) >= 3)
    }

    @Test func loadAllSessionsSpansEveryProject() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let p1 = makeProject()
        let p2 = Project(name: "other", rootPath: "/repos/other", defaultBranch: "main", createdAt: now)
        try await store.saveProject(p1)
        try await store.saveProject(p2)
        let s1 = makeSession(projectID: p1.id)
        let s2 = makeSession(projectID: p2.id)
        try await store.saveSession(s1)
        try await store.saveSession(s2)

        // One query across both projects — the launch/dashboard fast path.
        #expect(Set(try await store.loadAllSessions().map(\.id)) == [s1.id, s2.id])
    }

    @Test func activityCacheRoundTripsAndUpserts() async throws {
        let store = try GRDBMetadataStore(path: nil)
        let project = makeProject()
        let session = makeSession(projectID: project.id)
        try await store.saveProject(project)
        try await store.saveSession(session)  // cache rows reference a real session (FK)

        let day = Calendar.current.startOfDay(for: now)
        // Carries both the message and the token (v22) series — round-trips intact.
        let entry = ActivityCacheEntry(
            lastSeq: 12, messageCount: 3, activityByDay: [day: 3],
            tokenCount: 4_200, tokensByDay: [day: 4_200])
        try await store.saveActivityCache(session.id, entry)
        #expect(try await store.loadActivityCache()[session.id] == entry)

        // Upsert by session id: the cursor + rollup are replaced, not duplicated.
        let grown = ActivityCacheEntry(
            lastSeq: 20, messageCount: 5, activityByDay: [day: 5],
            tokenCount: 9_001, tokensByDay: [day: 9_001])
        try await store.saveActivityCache(session.id, grown)
        let reloaded = try await store.loadActivityCache()
        #expect(reloaded.count == 1)
        #expect(reloaded[session.id] == grown)

        // The cache row cascades away with its session (ON DELETE CASCADE).
        try await store.deleteSession(id: session.id)
        #expect(try await store.loadActivityCache().isEmpty)
    }
}
