import Foundation
import SwiftGitX

// MARK: - GitCommitRecord

/// **One commit, flattened to what a stack and a sidebar need.**
///
/// The undo stack reads `oid`, `parentOID` and `subject`; the card window's History section reads
/// `subject`, `authorName` and `date` (05-card-window.md ▸ History: "semantic subject, relative date,
/// author"). One value rather than two because they are the same walk read twice, and a second record
/// type would be a second definition of what a commit is.
public struct GitCommitRecord: Sendable, Equatable, Identifiable {

    /// The full hex oid. `id` too — a commit is its hash, and nothing in this app ever shows two
    /// records for one commit.
    public let oid: String

    /// The commit's first line, exactly as the message engine wrote it ("Move card 'Fix login' to
    /// Doing"). The undo menu's label and the History row's headline are both this string.
    public let subject: String

    /// The **author**, which is where origin lives (06-history-undo.md ▸ Interaction with external
    /// writers: "Origin lives in the author field … not in message prose"). So a foreign commit's row
    /// reads `Lanework External` and a stamped agent's reads its own name, with no rendering rule of
    /// its own.
    public let authorName: String

    /// The author's timestamp — what "2 days ago" is relative to.
    public let date: Date

    /// The **first** parent, or `nil` for a root commit. First-parent only, because the whole stack
    /// is defined as first-parent ancestry and a merge's second parent is a different history.
    public let parentOID: String?

    public var id: String { oid }

    public init(oid: String, subject: String, authorName: String, date: Date, parentOID: String?) {
        self.oid = oid
        self.subject = subject
        self.authorName = authorName
        self.date = date
        self.parentOID = parentOID
    }
}

// MARK: - GitHistoryWalk

/// **HEAD's first-parent ancestry, read** (06-history-undo.md ▸ Rules ▸ The stack is HEAD's
/// first-parent ancestry, live) — the one walk both this milestone's surfaces are built on.
///
/// ### Why the walk is the stack
///
/// "The undo stack reseeds from HEAD's first-parent ancestry on load; redo starts empty … no sidecar
/// state, nothing ever lost" (06 ▸ Rules ▸ Undo survives relaunch). There is therefore no persisted
/// stack to read and nothing to keep in step with the repository: the repository *is* the stack, and
/// this file is how it is spelled out. In-session and post-relaunch are one rule because they are one
/// function.
///
/// ### Isolation
///
/// `GitRepository`'s rule, unchanged: every function is `nonisolated`, opens its own `Repository`, and
/// confines the handle to its own synchronous scope. Callers reach these through `Task.detached`, so
/// the main actor never blocks on libgit2 and libgit2 never sees two threads on one handle.
enum GitHistoryWalk {

    /// How far back a walk goes.
    ///
    /// A cap rather than an unbounded walk for `GitRepository.pathFirstAppearanceRanks`' reason: a
    /// board with years of history must not spend a second answering "can I undo?". The cost of the
    /// cap is that the oldest steps of a very long trail are unreachable by ⌘Z, which is the same
    /// bound every undo stack has ever had, and the whole trail stays inspectable in any git client —
    /// the property 06 actually promises.
    static let defaultLimit = 512

    /// HEAD's oid, or `nil` on an unborn HEAD or a repository that will not open.
    ///
    /// **The pre-flight sync's whole question** (06: "The stack re-syncs its top to HEAD before every
    /// undo/redo — self-commits move HEAD outside the app's committer; the pre-flight sync is how the
    /// stack learns"). One reference read, which is why the sync can afford to run before every
    /// crossing.
    nonisolated static func headOID(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 nil }
        return commit.id.hex
    }

    /// HEAD's first-parent ancestry, **newest first** — index 0 is HEAD.
    ///
    /// An unborn HEAD answers `[]`, which is exactly "the undo trail simply starts empty" (06 ▸ Rules
    /// ▸ Abnormal repo states) with no case of its own.
    nonisolated static func ancestry(at boardRoot: URL, limit: Int = defaultLimit) -> [GitCommitRecord] {
        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 records: [GitCommitRecord] = []
        var current: Commit? = tip
        while let commit = current, records.count < max(0, limit) {
            let parent = (try? commit.parents)?.first
            records.append(record(commit, parent: parent))
            current = parent
        }
        return records
    }

    /// **Every commit that touched one card's folder, newest first** — the card window's History
    /// section (05-card-window.md ▸ History).
    ///
    /// ### Following the card is matching its own folder name
    ///
    /// "The listing **follows the card across lane moves** (path changes; the UUID folder is the
    /// identity to track)." A card's folder *is* its identity: `‹lane-uuid›/‹card-uuid›/index.md`, so
    /// a lane move rewrites the first component and never the second. Matching on the card's own
    /// folder component therefore follows it across every move it can make — into another lane, into
    /// `.trash/`, back out again — with no rename detection to be defeated by a large diff, and no
    /// `--follow` heuristic to disagree with git's own answer. (`GitRepository.pathFirstAppearanceRanks`
    /// records the opposite trade for its own question: it does *not* follow renames, and says so.)
    ///
    /// The walk is HEAD's first-parent ancestry, so the trail a card shows is the trail its board's
    /// current branch has — which is what makes a branch switch change it for free.
    nonisolated static func commitsTouching(
        folderNamed name: String,
        at boardRoot: URL,
        limit: Int = defaultLimit
    ) -> [GitCommitRecord] {
        guard !name.isEmpty,
              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 records: [GitCommitRecord] = []
        var current: Commit? = tip
        var walked = 0
        while let commit = current, walked < max(0, limit) {
            walked += 1
            let parent = (try? commit.parents)?.first
            if touches(commit, folderNamed: name, parent: parent, in: repository) {
                records.append(record(commit, parent: parent))
            }
            current = parent
        }
        return records
    }

    /// Whether `path` lies inside a folder named `name` — component-exact, so a card whose id is a
    /// prefix of another's cannot borrow its history.
    nonisolated static func path(_ path: String, isInsideFolderNamed name: String) -> Bool {
        path.split(separator: "/").dropLast().contains { $0 == name }
    }

    // MARK: - Private

    private static func record(_ commit: Commit, parent: Commit?) -> GitCommitRecord {
        GitCommitRecord(
            oid: commit.id.hex,
            subject: commit.summary,
            authorName: commit.author.name,
            date: commit.author.date,
            parentOID: parent?.id.hex
        )
    }

    /// Whether one commit's diff against its first parent mentions the folder.
    ///
    /// **A root commit is diffed against nothing**, so its whole tree counts as touched — the same
    /// reading `pathFirstAppearanceRanks` gives a walk's base, and the honest one: every file in a
    /// root commit arrived in it.
    private static func touches(
        _ commit: Commit,
        folderNamed name: String,
        parent: Commit?,
        in repository: Repository
    ) -> Bool {
        guard parent != nil else {
            return treePaths(of: commit, in: repository).contains { path($0, isInsideFolderNamed: name) }
        }
        guard let diff = try? repository.diff(commit: commit) else { return false }
        return diff.changes.contains { delta in
            path(delta.newFile.path, isInsideFolderNamed: name)
                || path(delta.oldFile.path, isInsideFolderNamed: name)
        }
    }

    /// Every blob path under a commit's tree — `GitRepository.filePaths`' twin, kept here rather than
    /// shared because that one is `private` to a file with a different job.
    private static func treePaths(of commit: Commit, in repository: Repository) -> [String] {
        guard let tree = try? commit.tree else { return [] }
        var paths: [String] = []

        func walk(_ tree: Tree, prefix: String, depth: Int) {
            guard depth < 8 else { return }
            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, depth: depth + 1)
                } else {
                    paths.append(path)
                }
            }
        }

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