import Foundation
import Testing

@testable import NucleicCore

@Suite("ProjectRelocator — path math + git relink")
struct ProjectRelocatorTests {

    // MARK: - rewrite (pure)

    @Test func rewriteSwapsPrefixUnderRoot() {
        let out = ProjectRelocator.rewrite(
            path: "/old/repo/.nucleic/worktrees/feat",
            oldRoot: "/old/repo", newRoot: "/new/place/repo")
        #expect(out == "/new/place/repo/.nucleic/worktrees/feat")
    }

    @Test func rewriteMapsTheRootItself() {
        let out = ProjectRelocator.rewrite(
            path: "/old/repo", oldRoot: "/old/repo", newRoot: "/new/repo")
        #expect(out == "/new/repo")
    }

    @Test func rewriteLeavesPathsOutsideRootUntouched() {
        // A worktree base (or external worktree) that doesn't live under the moved tree
        // must not be rewritten.
        let out = ProjectRelocator.rewrite(
            path: "/somewhere/else/wt", oldRoot: "/old/repo", newRoot: "/new/repo")
        #expect(out == "/somewhere/else/wt")
        // A sibling that merely shares a name prefix is not "under" the root.
        let sibling = ProjectRelocator.rewrite(
            path: "/old/repo-2/x", oldRoot: "/old/repo", newRoot: "/new/repo")
        #expect(sibling == "/old/repo-2/x")
    }

    @Test func rewriteToleratesTrailingSlash() {
        let out = ProjectRelocator.rewrite(
            path: "/old/repo/a", oldRoot: "/old/repo/", newRoot: "/new/repo")
        #expect(out == "/new/repo/a")
    }

    // MARK: - placeholder derivation (pure)

    @Test func placeholderRealURLsMapsStubsBackToVisibleNames() throws {
        let dir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-icloud-\(UUID().uuidString)")
        try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(atPath: dir) }
        // iCloud stores undownloaded "foo.txt" as a hidden ".foo.txt.icloud" stub.
        let stub = (dir as NSString).appendingPathComponent(".foo.txt.icloud")
        try Data().write(to: URL(fileURLWithPath: stub))

        let urls = ProjectRelocator.placeholderRealURLs(under: dir)
        #expect(urls.map(\.lastPathComponent) == ["foo.txt"])
    }

    @Test func placeholderRealURLsSkipsStaleStubsWithDownloadedContent() throws {
        // A `.icloud` stub whose real file already exists is a stale leftover (the content is
        // downloaded) — counting it as pending made the download phase spin with nothing to do,
        // the "stuck at ~50% even though everything is Keep Downloaded" bug.
        let dir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-icloud-\(UUID().uuidString)")
        try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(atPath: dir) }

        // Genuinely undownloaded: only the hidden stub is on disk.
        try Data().write(to: URL(fileURLWithPath:
            (dir as NSString).appendingPathComponent(".pending.txt.icloud")))
        // Stale stub: the real file is already present alongside it, so it must be ignored.
        try Data().write(to: URL(fileURLWithPath:
            (dir as NSString).appendingPathComponent(".done.txt.icloud")))
        try "real".write(toFile: (dir as NSString).appendingPathComponent("done.txt"),
                         atomically: true, encoding: .utf8)

        let names = ProjectRelocator.placeholderRealURLs(under: dir).map(\.lastPathComponent)
        #expect(names == ["pending.txt"])
    }

    @Test func directoryAllocatedSizeGrowsWithContents() throws {
        let dir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-size-\(UUID().uuidString)")
        try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(atPath: dir) }

        let empty = ProjectRelocator.directoryAllocatedSize(dir)
        try Data(repeating: 0x7F, count: 256_000).write(
            to: URL(fileURLWithPath: (dir as NSString).appendingPathComponent("blob.bin")))
        let filled = ProjectRelocator.directoryAllocatedSize(dir)
        #expect(filled > empty)  // the copy-progress heartbeat relies on this growing as bytes land
    }

    // MARK: - pending-placeholder classification (pure)

    @Test func classifyPendingTreatsUnreadableItemsAsIdleNotErrored() {
        // The download loop only gives up *fast* when every pending item is errored. Items whose
        // iCloud state can't be read (here: plain non-existent / non-ubiquitous URLs) must count as
        // idle, never as errors, so a normal tree doesn't get bailed out prematurely.
        let urls = [URL(fileURLWithPath: "/tmp/nope-\(UUID().uuidString)/a.txt"),
                    URL(fileURLWithPath: "/tmp/nope-\(UUID().uuidString)/b.txt")]
        let status = ProjectRelocator.classifyPending(urls)
        #expect(status.errored == 0)
        #expect(status.downloading + status.idle == urls.count)
        #expect(status.firstErrors.isEmpty)
    }

    // MARK: - download-loop decision (pure: monotonic progress + oscillation-proof bail)

    /// Run a sequence of (pending, errored) ticks through `downloadStep`, threading state, and
    /// return the reports emitted and the elapsed time at which it bailed (nil if it never did).
    private func runDownload(_ ticks: [(pending: Int, errored: Int)], total: Int,
                             holdout: Double = 30, allErrored: Double = 6, tick: Double = 0.5)
        -> (reports: [Double], bailedAt: Double?) {
        var best = Int.max, last = -1.0, idle = 0.0, elapsed = 0.0
        var reports: [Double] = []
        for t in ticks {
            let step = ProjectRelocator.downloadStep(
                pending: t.pending, total: total, errored: t.errored,
                bestBacklog: best, lastReported: last, idleSeconds: idle,
                tick: tick, holdoutBailSeconds: holdout, allErroredBailSeconds: allErrored)
            best = step.bestBacklog; last = step.lastReported; idle = step.idleSeconds
            elapsed += tick
            if let r = step.report { reports.append(r) }
            if step.bail { return (reports, elapsed) }
        }
        return (reports, nil)
    }

    @Test func downloadStepReportsNeverRegressOrGoNegative() {
        // Backlog grows past the frozen total (iCloud re-evicting / creating stubs) and bounces:
        // every emitted fraction must stay in 0...1 and never decrease.
        let (reports, _) = runDownload(
            [(10, 0), (3, 0), (5, 0), (12, 0), (4, 0), (2, 0)].map { ($0.0, $0.1) }, total: 10)
        #expect(reports.allSatisfy { $0 >= 0 && $0 <= 1 })
        #expect(zip(reports, reports.dropFirst()).allSatisfy { $0 <= $1 })   // monotonic non-decreasing
    }

    @Test func downloadStepBailsOnOscillatingBacklog() {
        // 3,2,3,2,… never reaches a new low after the first 2, so idle must accrue and the loop must
        // bail near the 30s holdout window — not spin to the 60s ceiling (the old previous-tick bug).
        let ticks = (0..<200).map { (pending: $0 % 2 == 0 ? 3 : 2, errored: 0) }
        let (_, bailedAt) = runDownload(ticks, total: 5)
        #expect(bailedAt != nil)
        #expect((bailedAt ?? .infinity) <= 32)
    }

    @Test func downloadStepBailsFastWhenAllErrored() {
        // Every pending stub is errored → it'll never download → bail within the short window.
        let ticks = Array(repeating: (pending: 4, errored: 4), count: 200)
        let (_, bailedAt) = runDownload(ticks, total: 4)
        #expect(bailedAt != nil)
        #expect((bailedAt ?? .infinity) <= 7)
    }

    @Test func downloadStepNeverFalseBailsOnSteadyProgress() {
        // A backlog draining 10→1 reaches a new low every tick, so idle stays 0 and it never bails.
        let ticks = (1...10).reversed().map { (pending: $0, errored: 0) }
        let (reports, bailedAt) = runDownload(ticks, total: 10)
        #expect(bailedAt == nil)
        #expect((reports.last ?? 0) >= 0.9)   // approached completion
    }

    @Test func prepareICloudThreadsThroughTheMoveLog() async throws {
        // A relocator handed a (temp-dir) MoveLog traces its iCloud prep — proving the logger is
        // wired through the prep path the move pipeline relies on.
        let logDir = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-movelog-\(UUID().uuidString)")
        defer { try? FileManager.default.removeItem(atPath: logDir) }
        let log = MoveLog(label: "test", directory: URL(fileURLWithPath: logDir), enabled: true)
        let work = (NSTemporaryDirectory() as NSString)
            .appendingPathComponent("nucleic-prep-\(UUID().uuidString)")
        try FileManager.default.createDirectory(atPath: work, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(atPath: work) }

        let relocator = ProjectRelocator(log: log)
        await relocator.prepareICloud(at: work)   // no placeholders on a plain temp dir — returns fast
        log.finish("done")

        let contents = try String(contentsOf: try #require(log.fileURL), encoding: .utf8)
        #expect(contents.contains("iCloud prep: starting"))
        #expect(contents.contains("[test]"))
    }

    // MARK: - copy + repair + verify on a real worktree

    @Test func movePreservesLinkedWorktree() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let manager = GitWorktreeManager()
        let project = repo.project()
        let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "feat")
        #expect(FileManager.default.fileExists(atPath: wt.path))

        let relocator = ProjectRelocator()
        let oldRoot = GitWorktreeManager.canonical(repo.root)
        let destParent = (repo.container as NSString).appendingPathComponent("moved")
        try FileManager.default.createDirectory(atPath: destParent, withIntermediateDirectories: true)
        let newRoot = (destParent as NSString).appendingPathComponent("repo")

        let oldWorktrees = try await relocator.linkedWorktrees(in: oldRoot)
        #expect(oldWorktrees.contains(GitWorktreeManager.canonical(wt.path)))
        let newWorktrees = oldWorktrees.map {
            ProjectRelocator.rewrite(path: $0, oldRoot: oldRoot, newRoot: newRoot)
        }

        await relocator.prepareICloud(at: oldRoot)  // no-op on a non-ubiquitous temp dir
        try relocator.copyTree(from: oldRoot, to: newRoot)
        try await relocator.repairGit(newRoot: newRoot, worktreePaths: newWorktrees)
        try await relocator.verify(newRoot: newRoot, worktreePaths: newWorktrees)

        // The relocated worktree is linked and usable from the new location.
        let newWorktree = ProjectRelocator.rewrite(path: wt.path, oldRoot: oldRoot, newRoot: newRoot)
        #expect(FileManager.default.fileExists(atPath: newWorktree))
        let listing = try await GitRunner().checked(["worktree", "list", "--porcelain"], in: newRoot)
        #expect(listing.contains(GitWorktreeManager.canonical(newWorktree)))
        let status = try await GitRunner().run(["status", "--porcelain"], in: newWorktree)
        #expect(status.ok)
    }
}

@MainActor
@Suite("AppStore.moveProject — end to end", .serialized)
struct AppStoreMoveTests {
    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-move", model: "m", cwd: wtPath, toolNames: ["Write"])))
                let file = (wtPath as NSString).appendingPathComponent("made.txt")
                try? "by agent\n".write(toFile: file, atomically: true, encoding: .utf8)
                e.emit(.fileChange(FileChange(path: "made.txt", kind: .add)))
                e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
                e.emit(.runFinished(RunFinished(outcome: .completed)))
            }
        }
    }

    private func waitFor(_ condition: @escaping () -> Bool, timeoutMs: Int = 3000) async {
        var elapsed = 0
        while !condition() && elapsed < timeoutMs {
            try? await Task.sleep(for: .milliseconds(10))
            elapsed += 10
        }
    }

    @Test func moveRelocatesRepoAndRewritesPaths() async throws {
        let repo = try await GitTestRepo()
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        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()

        let oldWorktree = try #require(await store.sessions(for: projectID).first?.worktreePath)
        #expect(oldWorktree.hasPrefix(GitWorktreeManager.canonical(repo.root)))

        let destParent = (repo.container as NSString).appendingPathComponent("relocated")
        try FileManager.default.createDirectory(atPath: destParent, withIntermediateDirectories: true)
        await store.moveProject(projectID, toParent: URL(fileURLWithPath: destParent))
        await waitFor { !store.moving }

        #expect(store.lastError == nil)
        let newRoot = (destParent as NSString).appendingPathComponent("repo")
        let moved = try #require(store.project(projectID))
        #expect(moved.rootPath == newRoot)

        // Session worktree was rewritten and still works from the new location.
        let newWorktree = try #require(await store.sessions(for: projectID).first?.worktreePath)
        #expect(newWorktree.hasPrefix(newRoot))
        #expect(FileManager.default.fileExists(atPath: newWorktree))
        let status = try await GitRunner().run(["status", "--porcelain"], in: newWorktree)
        #expect(status.ok)

        // Source is gone; new location is a valid repo.
        #expect(!FileManager.default.fileExists(atPath: GitWorktreeManager.canonical(repo.root)))
        let top = try await GitRunner().run(["rev-parse", "--show-toplevel"], in: newRoot)
        #expect(top.ok)
    }

    @Test func cancelMoveLeavesTheProjectOnAValidRepo() async throws {
        // Cancelling a move must never leave a half-state: the project still points at a real,
        // valid git repo afterward (the original, if the cancel rolled the move back; or the new
        // copy, if it had already committed), and `moving` clears.
        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 destParent = (repo.container as NSString).appendingPathComponent("canceldest")
        try FileManager.default.createDirectory(atPath: destParent, withIntermediateDirectories: true)

        async let moved: Void = store.moveProject(project.id, toParent: URL(fileURLWithPath: destParent))
        await Task.yield()                 // let the move register its task before we cancel
        store.cancelMove(project.id)        // racy by design — may land mid-move or after it commits
        await moved
        await waitFor { !store.moving }

        let p = try #require(store.project(project.id))
        #expect(FileManager.default.fileExists(atPath: p.rootPath))
        let top = try await GitRunner().run(["rev-parse", "--show-toplevel"], in: p.rootPath)
        #expect(top.ok)
    }

    @Test func moveRejectsExistingDestination() 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"))

        // A destParent that already contains a "repo" folder must be refused.
        let destParent = (repo.container as NSString).appendingPathComponent("taken")
        let clash = (destParent as NSString).appendingPathComponent("repo")
        try FileManager.default.createDirectory(atPath: clash, withIntermediateDirectories: true)

        await store.moveProject(project.id, toParent: URL(fileURLWithPath: destParent))
        #expect(store.lastError != nil)
        #expect(store.project(project.id)?.rootPath == repo.root)  // unchanged
    }

    @Test func moveRejectsNucleicControlProject() async throws {
        // Once a project is under Nucleic Control it can't be moved/released.
        let repo = try await GitTestRepo(controlled: true)
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)
        let project = try #require(await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main"))
        #expect(project.isNucleicControlled)

        let destParent = (repo.container as NSString).appendingPathComponent("out")
        try FileManager.default.createDirectory(atPath: destParent, withIntermediateDirectories: true)
        await store.moveProject(project.id, toParent: URL(fileURLWithPath: destParent))

        #expect(store.lastError != nil)
        // Untouched — still controlled at its original location.
        #expect(store.project(project.id)?.rootPath == repo.root)
        #expect(store.project(project.id)?.isNucleicControlled == true)
    }

    @Test func cloneIntoControlKeepsOriginalAndRegistersControlCopy() async throws {
        let repo = try await GitTestRepo()  // a normal, non-controlled project
        defer { repo.cleanup() }
        let store = makeStore(repo: repo)

        // Nucleic Control requires the container service; enable it for the duration and
        // restore the prior value afterward (the suite is `.serialized`).
        let key = ContainerServiceSettings.serviceEnabledKey
        let prior = UserDefaults.standard.object(forKey: key)
        UserDefaults.standard.set(true, forKey: key)
        defer {
            if let prior { UserDefaults.standard.set(prior, forKey: key) }
            else { UserDefaults.standard.removeObject(forKey: key) }
        }

        let project = try #require(await store.addProject(
            name: "demo", rootPath: repo.root, defaultBranch: "main"))
        #expect(!project.isNucleicControlled)

        let control = try #require(await store.cloneProjectIntoControl(project.id))
        // The clone lands in the real ~/.nucleic/control/ base (not under repo.container, so
        // repo.cleanup() won't reach it) — remove it explicitly.
        defer { try? FileManager.default.removeItem(atPath: control.rootPath) }
        await waitFor { !store.moving }
        #expect(store.lastError == nil)

        // The new project is a separate, Nucleic-controlled copy under the control base, and a
        // valid git repo.
        #expect(control.id != project.id)
        #expect(control.isNucleicControlled)
        #expect(ProjectCloner.isControlled(control.rootPath))
        let top = try await GitRunner().run(["rev-parse", "--show-toplevel"], in: control.rootPath)
        #expect(top.ok)
        #expect(FileManager.default.fileExists(
            atPath: (control.rootPath as NSString).appendingPathComponent("README.md")))

        // Non-destructive: the original project and its on-disk working copy are untouched.
        let original = try #require(store.project(project.id))
        #expect(original.rootPath == repo.root)
        #expect(!original.isNucleicControlled)
        #expect(FileManager.default.fileExists(atPath: GitWorktreeManager.canonical(repo.root)))

        // Both projects are registered.
        #expect(store.project(project.id) != nil)
        #expect(store.project(control.id) != nil)
    }
}
