import Foundation

/// Relocates a project's repository tree to a new filesystem location while keeping
/// git's linked worktrees and Nucleic's stored paths intact — the "safe move" used to
/// pull a project out of iCloud, where sync conflicts and undownloaded placeholder
/// stubs (`*.icloud`) silently corrupt the working copy.
///
/// Pure path math is `static` + unit-testable; the filesystem/iCloud/git steps are
/// instance methods over `GitRunner` + `FileManager`. Orchestration (stopping sessions,
/// rewriting the metadata DB, and the copy → verify → delete-source ordering) lives in
/// `AppStore.moveProject`, which calls these in sequence and only deletes the source
/// once the new copy verifies.
public struct ProjectRelocator: Sendable {
    private let git: GitRunner
    /// Optional Smart Move diagnostics sink. When present, the iCloud-prep / copy / repair steps
    /// trace what they find and do; nil (the default) keeps everything silent. See `MoveLog`.
    private let log: MoveLog?

    public init(git: GitRunner = GitRunner(), log: MoveLog? = nil) {
        self.git = git
        self.log = log
    }

    // MARK: - Path math (pure)

    /// Prefix-swap `path` from under `oldRoot` to `newRoot`, comparing canonical
    /// (symlink-resolved) forms. Returns `newRoot` when `path` *is* the root, and the
    /// original `path` unchanged when it isn't under `oldRoot` at all — e.g. a custom
    /// `worktreeBase` or a git worktree living outside the moved tree, which doesn't move.
    public static func rewrite(path: String, oldRoot: String, newRoot: String) -> String {
        let canonOld = GitWorktreeManager.canonical(oldRoot)
        let canonPath = GitWorktreeManager.canonical(path)
        if canonPath == canonOld { return newRoot }
        let prefix = canonOld + "/"
        guard canonPath.hasPrefix(prefix) else { return path }
        let suffix = String(canonPath.dropFirst(prefix.count))
        return (newRoot as NSString).appendingPathComponent(suffix)
    }

    // MARK: - iCloud preparation

    /// Make `root` safe to move off iCloud: force every undownloaded placeholder to
    /// materialize (a plain copy of a `*.icloud` stub would relocate an empty
    /// placeholder, not the file), then drop any unresolved iCloud conflict copies,
    /// keeping the current version. A no-op on a non-ubiquitous tree.
    ///
    /// `progress` (0...1) is driven across both sub-phases (download → 0...0.85, conflict scan →
    /// 0.85...1.0) and fired on every real step *and* a per-tick liveness heartbeat while work is
    /// genuinely happening — so the caller's stall detector only trips when nothing is actually
    /// moving, not merely because a phase is opaque. The whole thing bails promptly on cancel.
    public func prepareICloud(at root: String, progress: (@Sendable (Double) -> Void)? = nil) async {
        log?.line("iCloud prep: starting at \(root)")
        await downloadPlaceholders(at: root) { frac in progress?(min(0.85, frac * 0.85)) }
        if Task.isCancelled { log?.line("iCloud prep: cancelled after download phase"); return }
        await resolveConflicts(at: root) { progress?(0.85 + 0.15) }
        log?.line("iCloud prep: finished")
    }

    /// Force-download iCloud placeholder stubs under `root`, polling until none remain. Bounded so
    /// it can never hang the move:
    ///
    /// - Reports a rising fraction whenever the backlog shrinks (a real file materialized).
    /// - Fires a *liveness* heartbeat (re-reporting the current fraction, refreshing the caller's
    ///   "last progress" timestamp) on every tick where iCloud is still working — so a slow but
    ///   live download isn't mistaken for a stall.
    /// - Gives up on *holdouts* — stubs that aren't shrinking and that iCloud reports as errored or
    ///   idle (not actively downloading) — after `holdoutBailSeconds`, and proceeds without them.
    ///   This is the fix for the "stuck at ~50% on Downloading from iCloud then suddenly jumps"
    ///   bug: a stale/orphan stub used to spin the loop to its full ceiling doing nothing.
    /// - Still honors a hard `ceilingSeconds` cap and bails at once on cancel.
    private func downloadPlaceholders(at root: String, progress: (@Sendable (Double) -> Void)? = nil) async {
        let fm = FileManager.default
        let tick = 0.5
        let ceilingSeconds = 60.0
        let holdoutBailSeconds = 30.0   // no backlog shrink + iCloud not actively pulling ⇒ give up
        var elapsed = 0.0
        var initialCount: Int?
        var lastReported = -1.0
        var bestBacklog = Int.max   // low-water mark: the fewest placeholders seen so far
        var idleSeconds = 0.0

        while elapsed < ceilingSeconds {
            if Task.isCancelled { log?.line("iCloud download: cancelled"); return }
            let pending = Self.placeholderRealURLs(under: root)
            let total = initialCount ?? pending.count
            initialCount = total
            if total == 0 { log?.line("iCloud download: no undownloaded placeholders found"); progress?(1); return }
            if pending.isEmpty {
                log?.line("iCloud download: all \(total) placeholder(s) materialized")
                if lastReported < 1 { progress?(1) }
                return
            }

            let status = Self.classifyPending(pending)
            let step = Self.downloadStep(
                pending: pending.count, total: total, errored: status.errored,
                bestBacklog: bestBacklog, lastReported: lastReported, idleSeconds: idleSeconds,
                tick: tick, holdoutBailSeconds: holdoutBailSeconds, allErroredBailSeconds: 6)
            bestBacklog = step.bestBacklog
            lastReported = step.lastReported
            idleSeconds = step.idleSeconds
            if let report = step.report { progress?(report) }   // monotonic, never negative
            log?.line("iCloud download: \(pending.count)/\(total) still pending "
                + "(no-error \(status.downloading), errored \(status.errored), idle \(status.idle)); "
                + "idle \(String(format: "%.1f", idleSeconds))s")

            // Give up on holdouts so they can't wedge the move (decision in `downloadStep`): stubs
            // iCloud reports as *all errored* won't ever download so we bail fast, otherwise we allow
            // a generous no-new-low grace. The copy step tolerates a leftover stub far better than the
            // user staring at a wedged bar. (This is the "stuck at ~50% then suddenly jumps" fix: a
            // stale/orphan stub used to spin the loop to its full ceiling doing nothing.)
            if step.bail {
                log?.line("iCloud download: ⚠︎ \(pending.count) placeholder(s) made no net progress — "
                    + "proceeding without them. errored=\(status.errored) idle=\(status.idle). "
                    + "holdouts: \(pending.prefix(12).map { $0.lastPathComponent }.joined(separator: ", "))"
                    + (status.firstErrors.isEmpty ? "" : " | errors: \(status.firstErrors.joined(separator: "; "))"))
                return
            }

            for url in pending {
                do { try fm.startDownloadingUbiquitousItem(at: url) }
                catch { log?.line("iCloud download: startDownloading failed for \(url.lastPathComponent): \(error.localizedDescription)") }
            }
            do { try await Task.sleep(nanoseconds: UInt64(tick * 1_000_000_000)) } catch { return }  // cancelled
            elapsed += tick
        }
        let leftover = Self.placeholderRealURLs(under: root).count
        log?.line("iCloud download: hit \(Int(ceilingSeconds))s ceiling with \(leftover) still pending — proceeding")
        progress?(max(0, lastReported))
    }

    /// Why placeholders are still pending, read from iCloud's per-item download state: how many it
    /// is actively pulling vs. wedged (a download error, or simply not downloading). Drives the
    /// download loop's liveness heartbeat and its give-up-on-holdouts bail. Items whose status is
    /// unreadable (e.g. the real file doesn't exist yet, or the tree isn't ubiquitous) count as
    /// idle. Uses `ubiquitousItemDownloadingStatusKey`/`…ErrorKey` (the non-deprecated keys).
    struct PendingStatus {
        var downloading = 0
        var errored = 0
        var idle = 0
        var firstErrors: [String] = []
    }

    static func classifyPending(_ urls: [URL]) -> PendingStatus {
        var s = PendingStatus()
        let keys: Set<URLResourceKey> = [.ubiquitousItemDownloadingStatusKey, .ubiquitousItemDownloadingErrorKey]
        for url in urls {
            let values = try? url.resourceValues(forKeys: keys)
            if let error = values?.ubiquitousItemDownloadingError {
                s.errored += 1
                if s.firstErrors.count < 5 {
                    s.firstErrors.append("\(url.lastPathComponent): \(error.localizedDescription)")
                }
            } else if values?.ubiquitousItemDownloadingStatus == .notDownloaded {
                s.downloading += 1   // not present yet, no error ⇒ iCloud should still be fetching it
            } else {
                s.idle += 1
            }
        }
        return s
    }

    /// One tick of the placeholder-download decision, factored out *pure* so its two invariants are
    /// unit-testable (`downloadPlaceholders` drives the filesystem/timing around it):
    ///
    /// 1. **Monotonic, non-negative progress** — `report` never decreases and is never < 0, even if
    ///    the observed backlog grows between ticks (iCloud can re-evict a materialized file to a stub
    ///    or create new ones, which would otherwise make a naive `(total - pending)/total` regress or
    ///    go negative once `pending` exceeds the frozen `total`).
    /// 2. **Holdout give-up that survives oscillation** — idle time accrues on every tick that
    ///    doesn't reach a *new low* backlog (`bestBacklog`), not merely a tick-over-tick dip. So a
    ///    backlog that bounces (3,2,3,2,…) still bails instead of spinning to the ceiling. All-errored
    ///    holdouts (which will never download) bail fast; otherwise a generous no-new-low grace.
    struct DownloadStep: Equatable {
        var bestBacklog: Int
        var lastReported: Double
        var idleSeconds: Double
        var report: Double?     // fraction to emit (clamped ≥ 0, monotonic), or nil for "no change"
        var bail: Bool          // give up on the remaining holdouts and proceed
    }

    static func downloadStep(
        pending: Int, total: Int, errored: Int,
        bestBacklog: Int, lastReported: Double, idleSeconds: Double,
        tick: Double, holdoutBailSeconds: Double, allErroredBailSeconds: Double
    ) -> DownloadStep {
        var bestBacklog = bestBacklog
        var lastReported = lastReported
        var idleSeconds = idleSeconds
        var report: Double?
        if pending < bestBacklog {                       // genuine net progress: a new low-water backlog
            bestBacklog = pending
            idleSeconds = 0
            let frac = total > 0 ? Double(total - pending) / Double(total) : 0
            lastReported = max(lastReported, frac)       // monotonic; frac ≥ 0 here since pending ≤ total
            report = max(0, lastReported)
        } else {
            idleSeconds += tick                          // no new low ⇒ accrue idle time
            if errored < pending { report = max(0, lastReported) }   // liveness while not all errored
        }
        let allErrored = pending > 0 && errored == pending
        let bailAfter = allErrored ? allErroredBailSeconds : holdoutBailSeconds
        return DownloadStep(bestBacklog: bestBacklog, lastReported: lastReported,
                            idleSeconds: idleSeconds, report: report, bail: idleSeconds >= bailAfter)
    }

    /// The *visible* item URLs for any iCloud placeholder stubs under `root` that are genuinely
    /// undownloaded. iCloud stores an undownloaded file `foo.txt` as a hidden `.foo.txt.icloud`
    /// stub; the download API wants the real item URL (`foo.txt`), so map back to it.
    ///
    /// A `.icloud` stub whose real file *already exists on disk* is a stale leftover — a known
    /// iCloud quirk where the hidden stub lingers after the content has downloaded (e.g. on a
    /// "Keep Downloaded" tree). Treating those as pending made the download phase spin to its
    /// ceiling with nothing to do, so skip any stub whose real file is already present.
    static func placeholderRealURLs(under root: String) -> [URL] {
        let fm = FileManager.default
        guard let enumerator = fm.enumerator(
            at: URL(fileURLWithPath: root), includingPropertiesForKeys: nil) else { return [] }
        var urls: [URL] = []
        for case let url as URL in enumerator where url.pathExtension == "icloud" {
            var name = url.lastPathComponent
            name.removeLast(".icloud".count)         // strip ".icloud"
            if name.hasPrefix(".") { name.removeFirst() }  // strip the leading dot
            let real = url.deletingLastPathComponent().appendingPathComponent(name)
            if fm.fileExists(atPath: real.path) { continue }  // stale stub — content is downloaded
            urls.append(real)
        }
        return urls
    }

    /// Sum of on-disk allocated sizes under `path` (best-effort; 0 on error). Used to watch a
    /// copy/clone destination grow so the move can report real progress + liveness during an
    /// otherwise-opaque bulk copy. Cheap-ish: a metadata-only walk, no file contents read.
    public static func directoryAllocatedSize(_ path: String) -> Int64 {
        let keys: Set<URLResourceKey> = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
        guard let enumerator = FileManager.default.enumerator(
            at: URL(fileURLWithPath: path), includingPropertiesForKeys: Array(keys)) else { return 0 }
        var total: Int64 = 0
        for case let url as URL in enumerator {
            let values = try? url.resourceValues(forKeys: keys)
            total += Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0)
        }
        return total
    }

    /// Resolve any iCloud document conflicts under `root` by keeping the current version
    /// and removing the conflict copies (git is the source of truth, so iCloud's
    /// divergent copies are just noise). Best-effort per file.
    ///
    /// This walks the whole tree calling a per-file iCloud API, which on a large tree is slow and
    /// otherwise opaque — so it checks `Task.isCancelled` every file (making Cancel responsive
    /// instead of wedged) and fires `beat` periodically (refreshing the caller's liveness
    /// timestamp so the UI doesn't flag a busy scan as stalled).
    private func resolveConflicts(at root: String, beat: (@Sendable () -> Void)? = nil) async {
        let fm = FileManager.default
        guard let enumerator = fm.enumerator(
            at: URL(fileURLWithPath: root), includingPropertiesForKeys: nil) else { return }
        log?.line("iCloud conflicts: scanning under \(root)")
        var checked = 0
        var resolved = 0
        // `nextObject()` rather than `for case ... in enumerator`: the latter's `makeIterator` is
        // unavailable in an async context, and we want to walk lazily (not materialize a huge tree).
        while let item = enumerator.nextObject() {
            guard let url = item as? URL else { continue }
            if Task.isCancelled { log?.line("iCloud conflicts: cancelled after \(checked) files"); return }
            checked += 1
            if checked % 100 == 0 {
                beat?()
                log?.line("iCloud conflicts: scanned \(checked) files, \(resolved) resolved…")
            }
            guard let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: url),
                  !conflicts.isEmpty else { continue }
            for version in conflicts { version.isResolved = true }
            try? NSFileVersion.removeOtherVersionsOfItem(at: url)
            resolved += 1
        }
        log?.line("iCloud conflicts: done — \(checked) files checked, \(resolved) resolved")
    }

    // MARK: - Copy / repair / verify / delete

    /// Copy the whole tree to `newRoot`, leaving the source intact (so a failure mid-move
    /// is recoverable). `newRoot` must not already exist.
    public func copyTree(from oldRoot: String, to newRoot: String) throws {
        try FileManager.default.copyItem(
            at: URL(fileURLWithPath: oldRoot), to: URL(fileURLWithPath: newRoot))
    }

    /// Repair git's linked-worktree administrative files after the move. The whole tree
    /// (repo + `.nucleic/worktrees`) moves together, so git's two-way links
    /// (`.git/worktrees/<name>/gitdir` ⇄ each worktree's `.git` file) hold stale absolute
    /// paths. `git worktree repair <new-paths…>` rewrites both directions; passing the new
    /// worktree locations lets it match moved worktrees up. Worktrees that didn't move
    /// (outside the tree) are still repaired so their link to the relocated common dir
    /// is fixed.
    public func repairGit(newRoot: String, worktreePaths: [String]) async throws {
        try await git.checked(["worktree", "repair"] + worktreePaths, in: newRoot)
        _ = try? await git.run(["worktree", "prune"], in: newRoot)
    }

    /// Confirm the relocated repo is healthy: it's a git repo, and every expected worktree
    /// is linked and usable. Throws on the first failure so the caller can roll back.
    public func verify(newRoot: String, worktreePaths: [String]) async throws {
        _ = try await git.checked(["rev-parse", "--show-toplevel"], in: newRoot)
        guard !worktreePaths.isEmpty else { return }
        let listing = try await git.checked(["worktree", "list", "--porcelain"], in: newRoot)
        let linked = GitWorktreeManager.parseWorktreeList(
            listing, excluding: GitWorktreeManager.canonical(newRoot))
        for path in worktreePaths {
            let canon = GitWorktreeManager.canonical(path)
            guard linked.contains(canon) else {
                throw GitError.commandFailed(
                    command: "git worktree verify",
                    status: 1, stderr: "worktree not linked after move: \(path)")
            }
            _ = try await git.checked(["status", "--porcelain"], in: path)
        }
    }

    /// The repo's currently linked worktree paths (excluding the main checkout), canonical.
    /// Read from the still-valid source before the move so we know exactly which worktrees
    /// to remap — authoritative regardless of what the metadata DB believes.
    public func linkedWorktrees(in root: String) async throws -> [String] {
        let listing = try await git.checked(["worktree", "list", "--porcelain"], in: root)
        return GitWorktreeManager.parseWorktreeList(
            listing, excluding: GitWorktreeManager.canonical(root)).sorted()
    }

    /// Remove a directory tree — used to delete the iCloud source once the new copy is
    /// live, and to clean up a partial copy after a failed move.
    public func remove(_ path: String) throws {
        try FileManager.default.removeItem(at: URL(fileURLWithPath: path))
    }
}
