import Foundation
import libgit2
import os

// MARK: - The plan

/// **One restore, as the writes it will make** — computed before anything touches the working tree,
/// so the whole of what a ⌘Z is about to do is a value a caller can inspect, gate on, and test.
public struct GitRestorePlan: Sendable, Equatable {

    /// One file the restore will write or remove.
    public struct Change: Sendable, Equatable {
        /// Board-root-relative, in git's own spelling.
        public let path: String
        /// The bytes to write, or `nil` to remove the file.
        public let contents: Data?

        public init(path: String, contents: Data?) {
            self.path = path
            self.contents = contents
        }
    }

    public let changes: [Change]

    public init(changes: [Change]) {
        self.changes = changes
    }

    public var paths: [String] { changes.map(\.path) }

    public var isEmpty: Bool { changes.isEmpty }
}

// MARK: - GitRestoreOperation

/// **Undo and redo, as forward commits** (14-git-operations.md ▸ The forward-restore model; the
/// load-bearing extraction): "Every restorative operation moves history forward. Nothing the app does
/// ever rewrites a published commit: no reset, no force-push, no revert-by-rewrite."
///
/// ### What this file is allowed to call, and what it is not
///
/// It materializes an older state as **ordinary working-tree writes** and then commits them through
/// the same signature-capable path every auto-commit takes (`GitCommitOperation.perform`). It never
/// calls `git_reset`, never moves a reference by hand, never writes `refs/`, and never touches the
/// reflog: the only ref movement in the whole restore is `git_commit_create`'s own advance of HEAD,
/// which is what a commit *is*. That is the property "verifiable by trail inspection in any git
/// client" reduces to, and it is checkable here by reading the imports: nothing below resolves a
/// reset or a checkout symbol at all.
///
/// ### Only the diff, never the tree
///
/// "A restore materializes only the diff between the current tree and the target state, so a card
/// whose open Edit session the diff doesn't touch is simply unaffected" (06-history-undo.md ▸ Rules
/// ▸ Undo restore vs open Edit sessions). So the plan is HEAD's tree against the target's, file by
/// file — never a checkout of the whole target, which would sweep every unrelated file on the board
/// through a write it did not need.
///
/// Two deliberate narrowings ride on that:
///
/// - **`excluding`** — the heal-transparency rule's second half (06 ▸ Rules ▸ Heal commits are
///   transparent to undo): "a restore materializing an older target **excludes paths whose divergence
///   is heal work**, so a ⌘Z run never reverts a repair and never summons the scheduler."
/// - **`reconciling`** — the card sessions the user chose to **Discard** at the save-or-discard step
///   (06 ▸ Branch switching: "Discard reverts buffers and uncommitted saves to HEAD"). Those folders
///   are compared against the **working tree** rather than against HEAD, because their uncommitted
///   on-disk saves are precisely the state HEAD does not have — one pass that both drops the
///   discarded saves and applies the restore, instead of a revert followed by a restore that would
///   have to agree with it. They arrive as folder **names**, not paths; see `folderPaths(named:at:)`
///   for why that distinction is the difference between the rule working and silently not.
///
/// ### Isolation
///
/// `GitCommitOperation`'s rule restated: `nonisolated`, opens its own `git_repository`, frees it in
/// the same synchronous scope, and no handle crosses an `await`. Called from a detached task.
enum GitRestoreOperation {

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "git")

    /// The operation name a failure carries into the banner (06 ▸ Interaction with external writers:
    /// "surfaces as a one-shot banner failure naming the operation and the error").
    static let operationName = "Restoring an earlier state"

    /// libgit2's global state — `GitCommitOperation.startUp`'s twin, and for its reason.
    private static let startUp: Bool = {
        git_libgit2_init() >= 0
    }()

    // MARK: - Planning

    /// **The writes that would turn the working tree into `target`'s state**, or `nil` when the
    /// repository could not be read.
    ///
    /// `nil` is emphatically not "nothing to do": a restore that silently did nothing because a tree
    /// would not load is the one failure mode a forward-only undo could not explain afterwards.
    ///
    /// - Parameters:
    ///   - target: the oid of the commit whose state is being restored.
    ///   - excluding: board-root-relative paths whose divergence is heal work — never materialized.
    ///   - reconciling: card **folder names** — the ids `SettleableSession.cardFolderName` carries —
    ///     whose folders are compared against the working tree rather than against HEAD (the Discard
    ///     branch of the save-or-discard step). Resolved to real paths here, once, by the resolver
    ///     both callers share.
    nonisolated static func plan(
        at boardRoot: URL,
        target: String,
        excluding: Set<String> = [],
        reconciling folderNames: Set<String> = []
    ) -> GitRestorePlan? {
        _ = startUp
        guard let repository = open(boardRoot) else { return nil }
        defer { git_repository_free(repository) }

        // **Names in, paths out — the one resolution both Discard paths take** (the undo restore's,
        // and the branch switch's `revertToHead`). A card's folder name is its id; its *path* is
        // `<lane>/<id>`, and every live card has a lane above it, so treating the name as a path
        // matched nothing at all and made the whole Discard branch silently inert.
        let reconciling = folderPaths(named: folderNames, at: boardRoot)

        guard let targetTree = tree(of: target, in: repository) else { return nil }
        defer { git_tree_free(targetTree) }
        var wanted: [String: git_oid] = [:]
        fileMap(of: targetTree, in: repository, prefix: "", depth: 0, into: &wanted)

        var current: [String: git_oid] = [:]
        if let headTree = headTree(of: repository) {
            defer { git_tree_free(headTree) }
            fileMap(of: headTree, in: repository, prefix: "", depth: 0, into: &current)
        }

        // The reconciled folders answer from disk instead: their committed state is beside the point,
        // because what is being discarded is exactly what is *not* committed.
        if !reconciling.isEmpty {
            for folder in reconciling {
                current = current.filter { !isInside($0.key, folder: folder) }
            }
            for path in workingTreeFiles(under: reconciling, at: boardRoot) {
                // A sentinel oid nothing can equal: the comparison below only ever asks "same or
                // different", and a working-tree file's bytes are not addressed by the object store.
                current[path] = git_oid()
            }
        }

        var changes: [GitRestorePlan.Change] = []
        for (path, oid) in wanted.sorted(by: { $0.key < $1.key }) {
            guard !excluding.contains(path) else { continue }
            if let held = current[path], equal(held, oid), !isInside(path, folders: reconciling) { continue }
            guard let data = blob(oid, in: repository) else { continue }
            changes.append(GitRestorePlan.Change(path: path, contents: data))
        }
        for path in current.keys.sorted() where wanted[path] == nil {
            guard !excluding.contains(path) else { continue }
            changes.append(GitRestorePlan.Change(path: path, contents: nil))
        }
        return GitRestorePlan(changes: changes.sorted { $0.path < $1.path })
    }

    // MARK: - Applying

    /// **Writes the plan and commits it** — one new commit on the current branch, nothing rewound.
    ///
    /// The commit goes through `GitCommitOperation.perform` unchanged, so it takes the ordinary
    /// signature path (06 ▸ Interaction with external writers) and is authored by the user: a restore
    /// is the user acting through the app, whatever the origin of the commit it crosses.
    ///
    /// A plan that turns out to write nothing new commits nothing — `perform`'s own empty-tree skip —
    /// and answers `.nothingToCommit`, which the caller reads as "the step was crossed and needed no
    /// bytes", not as a failure.
    nonisolated static func apply(
        _ plan: GitRestorePlan,
        at boardRoot: URL,
        message: String
    ) -> GitCommitOutcome {
        _ = startUp
        guard !plan.isEmpty else { return .nothingToCommit }

        if let failure = materialize(plan, at: boardRoot) {
            return .failed(failure)
        }

        let identity = GitCommitOperation.userIdentity(at: boardRoot)
        return GitCommitOperation.perform(
            at: boardRoot,
            commits: [PlannedCommit(
                paths: plan.paths,
                message: message,
                author: identity,
                committer: identity,
                kind: .user
            )],
            allowRootCommit: false
        )
    }

    /// **The writes, without the commit** — the plan materialized onto disk. `nil` means every change
    /// landed.
    ///
    /// Split out of `apply` for the branch switch's Discard branch (`revertToHead(folders:at:)`),
    /// which needs the bytes moved and emphatically does *not* want a commit attempted over them.
    nonisolated static func materialize(_ plan: GitRestorePlan, at boardRoot: URL) -> GitOperationFailure? {
        let manager = FileManager.default
        for change in plan.changes {
            let url = boardRoot.appendingPathComponent(change.path)
            guard let contents = change.contents else {
                try? manager.removeItem(at: url)
                pruneEmptyFolders(above: url, upTo: boardRoot)
                continue
            }
            let folder = url.deletingLastPathComponent()
            do {
                try manager.createDirectory(at: folder, withIntermediateDirectories: true)
                try contents.write(to: url, options: .atomic)
            } catch {
                logger.error("restore could not write \(change.path, privacy: .public)")
                return GitOperationFailure(
                    operation: operationName,
                    message: (error as NSError).localizedDescription
                )
            }
        }
        return nil
    }

    /// **"Discard reverts buffers and uncommitted saves to HEAD"** (06-history-undo.md ▸ Branch
    /// switching) — the *uncommitted saves* half, for the operation that has no restore plan to fold
    /// it into.
    ///
    /// An undo restore reconciles a discarded card's folder inside its own plan, because it is
    /// materializing a target state anyway and one pass that does both cannot disagree with itself. A
    /// branch switch materializes nothing — libgit2's checkout does the moving — so the discard has to
    /// be its own step, and it has to run **before** the pending auto-commit is flushed: `discard`
    /// ends the Edit session, which un-stages-around the card's folder, so a flush over a folder still
    /// holding those saves would commit exactly the text the user just asked to lose.
    ///
    /// It is expressed as a restore *to HEAD* with the folders reconciled against the working tree,
    /// which is the same machinery under a different target: every path outside those folders compares
    /// HEAD against HEAD and produces nothing, and inside them the working tree's own files are what
    /// the plan replaces. Nothing is committed — by construction there is nothing new to commit, since
    /// the tree afterwards is HEAD's.
    ///
    /// Answers whether the revert ran cleanly; `false` is a repository that could not be read, which
    /// the caller reports as its operation's clean failure.
    ///
    /// - Parameter folderNames: card **folder names** — the ids `SettleableSession.cardFolderName`
    ///   carries, not paths. Resolved against the tree here for that property's own reason: "a card's
    ///   own folder component never changes, only the lane above it", so a session that began before a
    ///   lane move is still matched afterwards.
    nonisolated static func revertToHead(folderNames: Set<String>, at boardRoot: URL) -> Bool {
        _ = startUp
        guard !folderNames.isEmpty else { return true }
        guard let head = GitHistoryWalk.headOID(at: boardRoot) else { return false }
        // A card whose folder is not on disk resolves to nothing, plans nothing, and writes nothing:
        // it was deleted, or it never existed, and either way there are no uncommitted saves to
        // revert.
        guard let plan = plan(at: boardRoot, target: head, reconciling: folderNames) else { return false }
        return materialize(plan, at: boardRoot) == nil
    }

    /// **Board-root-relative paths of every folder whose last component is one of `names`** — the one
    /// place a card id becomes a place on disk.
    ///
    /// Component-exact, which is the same match `SessionSettleGate` uses to decide *which* sessions an
    /// operation reaches (`GitHistoryWalk.path(_:isInsideFolderNamed:)`) and it is chosen for that
    /// rule's own reason: "a card's own folder component never changes, only the lane above it", so a
    /// session that began before a lane move is still found afterwards. Matching a name as a path
    /// prefix instead is what made the Discard branch inert — a bug this resolver exists to make
    /// unrepeatable, since both callers now go through it.
    ///
    /// `.git` is never walked — it is not part of any board's tree, and nothing here may write into
    /// it.
    private static func folderPaths(named names: Set<String>, at boardRoot: URL) -> Set<String> {
        guard let walker = FileManager.default.enumerator(
            at: boardRoot,
            includingPropertiesForKeys: [.isDirectoryKey],
            options: [.skipsPackageDescendants]
        ) else { return [] }

        var found: Set<String> = []
        for case let url as URL in walker {
            let name = url.lastPathComponent
            if name == ".git" {
                walker.skipDescendants()
                continue
            }
            guard (try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true,
                  names.contains(name),
                  let relative = relativePath(of: url, under: boardRoot) else { continue }
            found.insert(relative)
        }
        return found
    }

    // MARK: - Private plumbing

    private static func open(_ boardRoot: URL) -> OpaquePointer? {
        guard BoardGitMode.hasGitEntry(at: boardRoot) else { return nil }
        var repository: OpaquePointer?
        guard git_repository_open(&repository, boardRoot.path) == 0 else { return nil }
        return repository
    }

    private static func tree(of oid: String, in repository: OpaquePointer) -> OpaquePointer? {
        var id = git_oid()
        guard git_oid_fromstr(&id, oid) == 0 else { return nil }
        var commit: OpaquePointer?
        guard git_commit_lookup(&commit, repository, &id) == 0, let commit else { return nil }
        defer { git_commit_free(commit) }
        var tree: OpaquePointer?
        guard git_commit_tree(&tree, commit) == 0 else { return nil }
        return tree
    }

    private static func headTree(of repository: OpaquePointer) -> OpaquePointer? {
        guard git_repository_head_unborn(repository) != 1 else { return nil }
        var reference: OpaquePointer?
        guard git_repository_head(&reference, repository) == 0, let reference else { return nil }
        defer { git_reference_free(reference) }
        var object: OpaquePointer?
        guard git_reference_peel(&object, reference, GIT_OBJECT_TREE) == 0 else { return nil }
        return object
    }

    /// Every blob under a tree, board-root-relative, with its object id.
    ///
    /// The depth cap is `GitHeadSnapshot.materialize`'s, for its reason: a guard against a
    /// pathological repository, not a statement about boards.
    private static func fileMap(
        of tree: OpaquePointer,
        in repository: OpaquePointer,
        prefix: String,
        depth: Int,
        into map: inout [String: git_oid]
    ) {
        guard depth < 8 else { return }
        for position in 0..<git_tree_entrycount(tree) {
            guard let entry = git_tree_entry_byindex(tree, position),
                  let rawName = git_tree_entry_name(entry),
                  let id = git_tree_entry_id(entry) else { continue }
            let name = String(cString: rawName)
            // A `/` in a tree entry name is impossible in a well-formed tree and would be a path
            // escape if it were not: refuse rather than interpret (`GitHeadSnapshot`'s rule).
            guard !name.isEmpty, name != ".", name != "..", !name.contains("/") else { continue }
            let path = prefix.isEmpty ? name : prefix + "/" + name

            switch git_tree_entry_type(entry) {
            case GIT_OBJECT_TREE:
                var child: OpaquePointer?
                guard git_tree_lookup(&child, repository, id) == 0, let child else { continue }
                defer { git_tree_free(child) }
                fileMap(of: child, in: repository, prefix: path, depth: depth + 1, into: &map)
            case GIT_OBJECT_BLOB:
                map[path] = id.pointee
            default:
                // Submodules and symlinks: neither is a board, and neither is followed anywhere else
                // in this app either.
                continue
            }
        }
    }

    private static func blob(_ oid: git_oid, in repository: OpaquePointer) -> Data? {
        var id = oid
        var blob: OpaquePointer?
        guard git_blob_lookup(&blob, repository, &id) == 0, let blob else { return nil }
        defer { git_blob_free(blob) }
        let size = Int(git_blob_rawsize(blob))
        guard size > 0, let bytes = git_blob_rawcontent(blob) else { return Data() }
        return Data(bytes: bytes, count: size)
    }

    /// Every file on disk under one of `folders`, board-root-relative. `.git` is never walked — it is
    /// not part of any board's tree and nothing here may write into it.
    private static func workingTreeFiles(under folders: Set<String>, at boardRoot: URL) -> [String] {
        var found: [String] = []
        for folder in folders {
            let root = boardRoot.appendingPathComponent(folder)
            guard let walker = FileManager.default.enumerator(
                at: root,
                includingPropertiesForKeys: [.isRegularFileKey],
                options: [.skipsHiddenFiles, .skipsPackageDescendants]
            ) else { continue }
            for case let url as URL in walker {
                guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
                else { continue }
                guard let relative = relativePath(of: url, under: boardRoot) else { continue }
                found.append(relative)
            }
        }
        return found
    }

    private static func relativePath(of url: URL, under boardRoot: URL) -> String? {
        let root = boardRoot.standardizedFileURL.path
        let path = url.standardizedFileURL.path
        guard path.hasPrefix(root + "/") else { return nil }
        return String(path.dropFirst(root.count + 1))
    }

    private static func isInside(_ path: String, folder: String) -> Bool {
        path == folder || path.hasPrefix(folder + "/")
    }

    private static func isInside(_ path: String, folders: Set<String>) -> Bool {
        folders.contains { isInside(path, folder: $0) }
    }

    /// Removes folders emptied by a deletion, up to (never including) the board root — the same
    /// tidiness a card's own delete leaves behind, so a restore does not litter a board with empty
    /// UUID folders that the loader would then have to ignore.
    private static func pruneEmptyFolders(above file: URL, upTo boardRoot: URL) {
        let manager = FileManager.default
        let root = boardRoot.standardizedFileURL.path
        var folder = file.deletingLastPathComponent().standardizedFileURL
        while folder.path != root, folder.path.hasPrefix(root + "/") {
            let contents = (try? manager.contentsOfDirectory(atPath: folder.path)) ?? []
            guard contents.isEmpty || contents == [".DS_Store"] else { return }
            try? manager.removeItem(at: folder)
            folder = folder.deletingLastPathComponent().standardizedFileURL
        }
    }

    private static func equal(_ lhs: git_oid, _ rhs: git_oid) -> Bool {
        var left = lhs
        var right = rhs
        return git_oid_cmp(&left, &right) == 0
    }
}
