import Foundation
import SwiftGitX
import os

// MARK: - Failure

/// **Why a git operation didn't happen**, named and carrying libgit2's own message.
///
/// One type rather than a case per operation because everything that reaches a user goes through
/// the same two sentences — what was being attempted, and what the library said — and because the
/// operations that will join `initialize` here (commit, checkout, pull) all fail in exactly that
/// shape (06-history-undo.md ▸ Interaction with external writers: "An operation that fails
/// *cleanly* … surfaces as a one-shot banner failure naming the operation and the error").
public struct GitOperationFailure: Error, Sendable, Equatable, CustomStringConvertible {

    /// What was being attempted, in the user's words rather than libgit2's — "Adding git to this
    /// board", not `git_repository_init`.
    public let operation: String

    /// libgit2's message for the failure, verbatim. Kept rather than mapped: the messages are
    /// specific ("could not write to '…': Permission denied") in a way no re-phrasing of ours would
    /// be, and the alternative to showing it is a shrug.
    public let message: String

    public init(operation: String, message: String) {
        self.operation = operation
        self.message = message
    }

    public var description: String { "\(operation) failed: \(message)" }
}

// MARK: - GitRepository

/// **The board's repository, through the bundled libgit2** (06-history-undo.md ▸ Rules ▸ Opt-in
/// init: "Bundled libgit2 — no git install required").
///
/// SwiftGitX vendors libgit2 as an in-process library, so every call here runs inside the sandbox
/// with no `Process`, no `/usr/bin/git` and no sandbox extension — the shipped Release build behaves
/// identically on a machine that has never had the command-line tools installed.
///
/// ### Isolation
///
/// Every function is `nonisolated` and **opens its own `Repository`, confined to its own
/// synchronous scope**. `Repository` is `Sendable` (SwiftGitX marks it so to make handles
/// transferable), but the libgit2 handle underneath is not safe for concurrent use from several
/// threads at once, so no handle here is ever shared across an `await`, a `Task`, or a stored
/// property. `HistoryStore` — which is `@MainActor` — reaches these through `Task.detached`, so the
/// main actor never blocks on libgit2 and libgit2 never sees two threads at once.
///
/// This is the pathfinder's `GitSource` shape, kept because it was right, with the pathfinder's
/// *policy* deliberately left behind: nothing here auto-initializes anything and nothing commits on
/// its own schedule. It writes no seed of its own any more: the `.gitignore` outgrew git on
/// 2026-07-31 and belongs to the board now (`BoardWriter.gitignoreSeed`, seeded at creation and
/// healed in at open), so all that survives here is a last-chance check that the file exists before
/// the initial commit freezes the tree — see `seedGitignoreIfAbsent(at:)`.
enum GitRepository {

    /// **The root commit's own subject** (06-history-undo.md ▸ Rules ▸ Abnormal repo states,
    /// settled): "whenever the app creates a repo's first commit … it commits the whole tree as
    /// *Initial board state*, never a folded diff-from-empty: there is no last-committed snapshot to
    /// diff against, and forty Adds would bury the event."
    static let initialCommitSubject = "Initial board state"

    /// The branch a board's first commit lands on.
    ///
    /// **Forced rather than inherited, deliberately.** libgit2's compiled-in initial-branch name
    /// comes from `init.defaultBranch` in whatever config layer it can find at
    /// `git_repository_init` time — which is non-deterministic across machines and simply
    /// unavailable in the sandbox (redirected, empty HOME). `Repository.create(at:)` has no
    /// initial-branch parameter, so this is applied by writing `.git/HEAD` directly: on a freshly
    /// created, unborn, non-bare repository that file is nothing but the plain-text symbolic ref, so
    /// writing it is exactly `git symbolic-ref HEAD refs/heads/main` before anything else touches
    /// the repo.
    ///
    /// **The initial branch is `main`** (06 ▸ Rules ▸ Opt-in init, blessed 2026-07-31): "the host's
    /// `init.defaultBranch` lives in config layers the sandbox can't read, so add-git sets it
    /// deterministically — git's modern default, the pathfinder's choice."
    static let initialBranchName = "main"

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

    // MARK: Opt-in init

    /// **Add-git** (06-history-undo.md ▸ Rules ▸ Opt-in init): initializes a repository at
    /// `boardRoot` and immediately commits the whole tree as `Initial board state`.
    ///
    /// The commit is not deferred to any debounce — "init doesn't wait for the debounce; the board
    /// is protected from the moment git exists" — so the two halves are one operation and a failure
    /// in either is one failure.
    ///
    /// Between them sits a last-chance `.gitignore` check — the file is the board's rather than
    /// git's since 2026-07-31, so it is almost always already there; when it is not, seeding it here
    /// puts it *in* the initial commit rather than after it (`seedGitignoreIfAbsent`).
    ///
    /// **Create re-runs full detection and refuses anything but clean mode none** (06 ▸ Rules ▸
    /// Detection, ruled 2026-07-31): "as hardening, add-git's create re-runs full detection and
    /// refuses unless it reads clean none, so the forbidden nested init is impossible even on a
    /// raced or stale read."
    ///
    /// The caller (`HistoryStore.addGit`) has already established mode `none` from the mode it
    /// detected at board open, which can be minutes old — a `git init` in a terminal at the board root
    /// *or anywhere above it* between the two would otherwise slip past a root-only check and
    /// initialize a repository inside the user's, which is the one init 06 forbids outright. The whole
    /// walk runs again here, at the moment of the write, so the refusal is structural rather than
    /// probable. **`.unverifiable` refuses too** — a denied ancestor check can never be told apart
    /// from a repository actually being there, so only a genuinely clean `.none` reading proceeds; a
    /// stale `.none` that has since become unverifiable is refused exactly like one that has since
    /// become repo-nested.
    ///
    /// Returns the branch the root commit landed on, which is the popover's display line.
    nonisolated static func create(at boardRoot: URL) -> Result<String, GitOperationFailure> {
        let operation = "Adding git to this board"

        switch BoardGitMode.detect(boardRoot: boardRoot) {
        case .none:
            break
        case .git:
            return .failure(GitOperationFailure(
                operation: operation,
                message: "this board already has a git repository"
            ))
        case .repoNested:
            return .failure(GitOperationFailure(
                operation: operation,
                message: "this board lives inside a repository; Lanework leaves it to that repository"
            ))
        case .unverifiable:
            return .failure(GitOperationFailure(
                operation: operation,
                message: "this board's surroundings could not be fully checked, so Lanework will not add a repository here"
            ))
        }

        let gitDirectory: URL
        do {
            let created = try Repository.create(at: boardRoot)
            gitDirectory = created.path
            // Before anything else touches the repo — see `initialBranchName`.
            try? "ref: refs/heads/\(initialBranchName)\n".write(
                to: gitDirectory.appendingPathComponent("HEAD"),
                atomically: true,
                encoding: .utf8
            )
        } catch {
            return .failure(GitOperationFailure(operation: operation, message: reason(error)))
        }

        // **Before the stage below, so the seed is *in* the initial commit** (06 ▸ Repository
        // hygiene). Ordering is the whole of it: written first, `.gitignore` is one of the paths
        // `git status` reports and rides into "Initial board state" like any other file — and any
        // `.DS_Store` the Finder already left under the board is ignored from the repository's very
        // first commit rather than entering history and needing to be forgotten later, which nothing
        // in this app will ever do (06 ▸ Deleting never forgets).
        seedGitignoreIfAbsent(at: boardRoot)

        // **The root commit goes through the same signature-capable path every later commit does**
        // (`GitCommitOperation`), which is what retired this method's config materialization.
        //
        // Until the auto-commit card there was no way to hand libgit2 a signature through SwiftGitX
        // — `commit(message:)` leaves `author`/`committer` null and libgit2 falls back to
        // `git_signature_default`, which reads a merged config ladder the sandbox cannot see — so
        // add-git wrote `user.name`/`user.email` into the fresh repository's own config to give that
        // fallback something to find. That was an explicit interim, and it is gone: **nothing in the
        // app writes those keys any more.** The identity resolves at commit time, in one place
        // (`GitCommitOperation.userIdentity(at:)`), repo-local config winning over the derived
        // default exactly as 06 states — and a repository the app created now looks like one `git
        // init` made, with no opinion of ours baked into its config. The popover's identity fields
        // (a later card) are what will write that file, because there "the setting *is* the file".
        //
        // Every path `git status` reports is staged — full `git add -A` semantics, `.gitignore`
        // respected — which is what "commits the whole tree" means: the board's files, the agent
        // guide, strays and all (06 ▸ Commit messages: "the committer stages the whole board root").
        let identity = GitCommitOperation.userIdentity(at: boardRoot)
        let outcome = GitCommitOperation.perform(
            at: boardRoot,
            commits: [PlannedCommit(
                paths: GitCommitOperation.changedPaths(at: boardRoot).map(\.path),
                message: initialCommitSubject,
                author: identity,
                committer: identity
            )]
        )

        switch outcome {
        case .committed:
            return .success(branchName(at: boardRoot) ?? initialBranchName)
        case .nothingToCommit:
            // A board with no files at all — `git init` on an empty folder. The repository exists,
            // which is what add-git promised; the first settled change takes the root commit through
            // the ordinary engine (06 ▸ Rules ▸ Abnormal repo states: an unborn HEAD "is normal git
            // mode"), and the branch line has a name to show either way.
            return .success(branchName(at: boardRoot) ?? initialBranchName)
        case .locked:
            return .failure(GitOperationFailure(
                operation: operation,
                message: "another program is using this repository's index"
            ))
        case let .held(pause):
            return .failure(GitOperationFailure(operation: operation, message: pause.explanation))
        case let .failed(failure):
            logger.error("initial commit failed at \(boardRoot.path, privacy: .public): \(failure.message, privacy: .public)")
            return .failure(GitOperationFailure(operation: operation, message: failure.message))
        }
    }

    /// **The last-chance `.gitignore` seed, immediately before the initial commit.**
    ///
    /// The seed itself stopped being git's on 2026-07-31 (06-history-undo.md ▸ Repository hygiene,
    /// re-ruled: "`.gitignore` seeded on every board, never touched after … git or not"). Every board
    /// the app creates is born with one, and every board it opens is healed into having one
    /// (`BoardStore.seedGitignore`) — and add-git can only run on a board that is *open* and writable,
    /// so by the time this line is reached the file is essentially always already there and this call
    /// writes nothing.
    ///
    /// **It stays anyway, and stays here — before the stage below.** The one case it still answers is
    /// the one that cannot be fixed afterwards: if the board's seed heal has not landed (a transient
    /// failure that armed its memo, a picture that has not changed since), the initial commit would
    /// otherwise capture every `.DS_Store` the Finder has left under the board *into history*, where
    /// this app has no operation that could ever remove it (06 ▸ Deleting never forgets). One
    /// `lstat` on the one path that mints a repository is a cheap insurance policy against a
    /// permanent record.
    ///
    /// Seeding is `BoardWriter.seedGitignoreIfAbsent`'s — one seed text, one write-only-when-free
    /// rule, `lstat` semantics — so this cannot drift from what board creation and the heal write.
    ///
    /// A write that fails is not a failure of add-git. The repository exists, the commit that follows
    /// simply will not carry a `.gitignore`, and the board's own heal will try again at the next
    /// open — surfacing a banner about a courtesy file would be louder than the thing it reports.
    private static func seedGitignoreIfAbsent(at boardRoot: URL) {
        do {
            try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: boardRoot)
        } catch {
            logger.notice("could not seed .gitignore at \(boardRoot.path, privacy: .public): \(String(describing: error), privacy: .public)")
        }
    }

    // MARK: Reads

    /// **Whether libgit2 can open the repository at the board root at all** — the detection-time
    /// probe behind 06-history-undo.md ▸ Rules' corrupt-`.git` loud failure (ruled 2026-07-31):
    /// "a corrupt or unopenable repo never falls to mode none … the failure is **loud**".
    ///
    /// It is deliberately the *same* call every read here already makes (`Repository.open`), so
    /// "unreadable" means exactly what it means to the rest of this file rather than being a second
    /// opinion about the same repository. `git_repository_open` validates the layout — `HEAD`,
    /// `objects/`, `refs/` — resolves a `gitdir:` pointer file, and refuses a repository whose
    /// format version or extensions it does not implement, which is why a SHA-256 repository lands
    /// here "by construction" (06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot
    /// open takes the corrupt-repo loud-failure path").
    ///
    /// A board with no `.git` at all answers `false` too — there is no repository to read — but that
    /// is not a state any caller reaches: the probe runs only in mode `git`, which is exactly the
    /// mode a root `.git` defines.
    ///
    /// Read-only, like everything in this section: opening a repository writes nothing, and a
    /// repository that fails to open has not been touched at all.
    nonisolated static func canOpen(at boardRoot: URL) -> Bool {
        guard BoardGitMode.hasGitEntry(at: boardRoot) else { return false }
        return (try? Repository.open(at: boardRoot)) != nil
    }

    /// The current branch's short name, or `nil` when there is no repository at `boardRoot` or
    /// libgit2 cannot open it — the popover's read-only branch line (03-board-ui.md ▸ Board
    /// popover), and nothing more: branch switching and creation are a later card.
    ///
    /// **An unborn HEAD answers with a name, not with `nil`** (06 ▸ Rules ▸ Abnormal repo states:
    /// "an unborn HEAD is normal git mode"). Every SwiftGitX HEAD accessor goes through
    /// `git_repository_head`, which refuses to resolve an unborn HEAD to a name and throws instead,
    /// so the only way to recover the branch a first commit *would* land on is to read `.git/HEAD`'s
    /// symbolic-ref target — the same plain text this file writes at init.
    nonisolated static func branchName(at boardRoot: URL) -> String? {
        guard BoardGitMode.hasGitEntry(at: boardRoot),
              let repository = try? Repository.open(at: boardRoot) else { return nil }

        if repository.isHEADUnborn {
            return unbornBranchName(gitDirectory: repository.path)
        }
        guard let head = try? repository.HEAD else { return nil }
        if repository.isHEADDetached {
            // Detached HEAD reports its branch `name` as the literal "HEAD", which labels nothing.
            // The short hash is what plain git shows in the same state. (The *posture* a detached
            // HEAD calls for — pausing the whole git surface honestly, 06 ▸ Abnormal repo states —
            // is the auto-commit card's; this is only the label.)
            return (head.target as? Commit)?.id.abbreviated ?? "HEAD"
        }
        return head.name
    }

    /// HEAD's commit, flattened to what a caller (and a test) can assert on: subject, author, and
    /// how many parents it has — a root commit having none is how "the root commit has its own
    /// subject" is checkable.
    nonisolated static func headCommit(at boardRoot: URL) -> CommitSummary? {
        guard BoardGitMode.hasGitEntry(at: boardRoot),
              let repository = try? Repository.open(at: boardRoot),
              !repository.isHEADUnborn,
              let head = try? repository.HEAD,
              let commit = head.target as? Commit else { return nil }

        return CommitSummary(
            oid: commit.id.hex,
            subject: commit.summary,
            authorName: commit.author.name,
            authorEmail: commit.author.email,
            parentCount: (try? commit.parents)?.count ?? 0
        )
    }

    /// Every file path in HEAD's tree, board-root-relative and sorted — what the repository actually
    /// tracks right now.
    nonisolated static func trackedPaths(at boardRoot: URL) -> [String] {
        guard BoardGitMode.hasGitEntry(at: boardRoot),
              let repository = try? Repository.open(at: boardRoot),
              !repository.isHEADUnborn,
              let head = try? repository.HEAD,
              let commit = head.target as? Commit else { return [] }
        return filePaths(of: commit, in: repository).sorted()
    }

    /// One commit, as much of it as anything outside this file needs.
    struct CommitSummary: Sendable, Equatable {
        let oid: String
        let subject: String
        let authorName: String
        let authorEmail: String
        let parentCount: Int
    }

    // MARK: Path history

    /// **When each path entered history** — the git half of the loader's earlier-occurrence-wins
    /// ladder (01-storage-format.md ▸ Fractal layout ▸ Rules: "on git boards, the path history
    /// already tracks outranks the newcomer"; `BoardLoader.IdentityHistoryRanker`).
    ///
    /// The answer is `git log --diff-filter=A`-shaped, walked here rather than shelled out: HEAD's
    /// **first-parent** ancestry oldest-first, with each commit's rank being its position in that
    /// walk. Paths present in the oldest commit reached rank 0 (its whole tree, since a root commit
    /// has no parent to diff against and a capped walk's base is "everything that already existed");
    /// every later commit contributes the paths its diff *adds*. Lower is earlier, which is exactly
    /// the ranker's contract, and a path never seen is absent — the `nil` the rule reads as
    /// "outranked by anything tracked".
    ///
    /// **Ranks are recorded for folders, not only files**, because the loader asks about *items*:
    /// a card is a folder, and what git tracks is the `index.md` inside it. Every directory prefix
    /// of an added file therefore takes that file's rank unless it already has an earlier one.
    ///
    /// Two honest limits. The walk is **capped** (`limit`), so a board with a longer history than
    /// that reads everything at its base as equally early — a tie the ladder resolves on birth date,
    /// exactly as it does without git. And **renames are not followed**: libgit2 reports a rename as
    /// an add plus a delete unless rename detection is run over the diff, so a card moved between
    /// lanes ranks at its move rather than at its birth (`--follow`'s job). Both degrade toward the
    /// no-history answer rather than toward a wrong one.
    nonisolated static func pathFirstAppearanceRanks(at boardRoot: URL, limit: Int = 512) -> [String: Int] {
        guard BoardGitMode.hasGitEntry(at: boardRoot),
              let repository = try? Repository.open(at: boardRoot),
              !repository.isHEADUnborn,
              let head = try? repository.HEAD,
              let tip = head.target as? Commit else { return [:] }

        var chain: [Commit] = []
        var current: Commit? = tip
        while let commit = current, chain.count < limit {
            chain.append(commit)
            current = (try? commit.parents)?.first
        }

        var ranks: [String: Int] = [:]
        for (rank, commit) in chain.reversed().enumerated() {
            if rank == 0 {
                for path in filePaths(of: commit, in: repository) {
                    record(path: path, rank: rank, into: &ranks)
                }
                continue
            }
            guard let diff = try? repository.diff(commit: commit) else { continue }
            for delta in diff.changes where delta.type == .added || delta.type == .renamed || delta.type == .copied {
                record(path: delta.newFile.path, rank: rank, into: &ranks)
            }
        }
        return ranks
    }

    /// Records `path` and every directory prefix above it at `rank`, keeping the earliest rank any
    /// of them has already earned.
    private static func record(path: String, rank: Int, into ranks: inout [String: Int]) {
        var components = path.split(separator: "/").map(String.init)
        while !components.isEmpty {
            let key = components.joined(separator: "/")
            if let existing = ranks[key] {
                ranks[key] = min(existing, rank)
            } else {
                ranks[key] = rank
            }
            components.removeLast()
        }
    }

    // MARK: - Private helpers

    /// Every blob path under `commit`'s tree, recursively.
    private static func filePaths(of commit: Commit, in repository: Repository) -> [String] {
        guard let tree = try? commit.tree else { return [] }
        var paths: [String] = []

        func walk(_ tree: Tree, prefix: String) {
            for entry in tree.entries {
                let path = prefix.isEmpty ? entry.name : prefix + "/" + entry.name
                if entry.type == .tree {
                    guard let subtree: Tree = try? repository.show(id: entry.id) else { continue }
                    walk(subtree, prefix: path)
                } else {
                    paths.append(path)
                }
            }
        }

        walk(tree, prefix: "")
        return paths
    }

    /// The unborn HEAD's symbolic target, parsed out of `.git/HEAD`'s plain text
    /// (`ref: refs/heads/main` → `main`).
    private static func unbornBranchName(gitDirectory: URL) -> String? {
        guard let contents = try? String(
            contentsOf: gitDirectory.appendingPathComponent("HEAD"),
            encoding: .utf8
        ) else { return nil }
        let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
        let prefix = "ref: refs/heads/"
        guard trimmed.hasPrefix(prefix) else { return nil }
        let name = String(trimmed.dropFirst(prefix.count))
        return name.isEmpty ? nil : name
    }

    /// libgit2's own message for a SwiftGitX error — far more useful than the struct's synthesized
    /// description — falling back to the description for anything else.
    private static func reason(_ error: any Error) -> String {
        if let gitError = error as? SwiftGitXError { return gitError.message }
        return String(describing: error)
    }
}
