import Foundation

// MARK: - A harvested receipt

/// **One EchoLedger receipt, copied out for the committer** (02-architecture.md ▸ Components
/// ▸ EchoLedger; 06-history-undo.md ▸ Interaction with external writers).
///
/// ### Why a copy and not a read
///
/// The ledger's receipts are **consumed** by the landing reload that classifies them — "one write,
/// one echo", which is what buys the announcer its silence. The committer asks its question two
/// seconds later, by which time several reloads have landed and every receipt for the user's own
/// card edit is gone. Reading the live ledger at flush time would therefore attribute the user's own
/// work to `Lanework External`, which is the one misattribution this whole mechanism exists to
/// prevent.
///
/// So the committer harvests at the **close of each write bracket** — the moment a receipt describes
/// a completed write and nothing has had a chance to consume it — and keeps its own copy for the
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
///
/// The satisfaction check stays the ledger's rule, re-applied against disk at commit time, so the
/// two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app write
/// classify app-mediated, and a foreign edit that misses the hash classifies foreign.
public struct HarvestedReceipt: Sendable, Equatable {

    public let receipt: EchoLedger.Receipt

    /// **Whether the write that dropped it was a heal** — the flag 06 (ruled 2026-07-29) keys the
    /// third commit class on: "a debounce window holding a scheduled heal's changes alongside anyone
    /// else's splits the heal's paths into their own commit".
    public let isHeal: Bool

    public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
        self.receipt = receipt
        self.isHeal = isHeal
    }
}

// MARK: - The split

/// One debounce window's changed paths, divided into the commits they will become.
///
/// **Three classes, committed in this order** — foreign, then heal, then the user's:
///
/// - *Foreign first* is 06's own ordering, stated as a consequence of flush-before-overwrite:
///   "flush-before-overwrite already orders them: foreign first, then the user's overwrite". The log
///   then reads causally — what arrived, then what the user did about it.
/// - *Heal in the middle* is a judgment call, recorded: DESIGN fixes the heal's **separation** and
///   not its position. A scheduled heal repairs what a load found, so it follows the foreign change
///   that usually caused it and precedes the user's gesture, which is the order the three actually
///   happened in.
public struct CommitSplit: Sendable, Equatable {

    /// Changes nobody vouched for — an agent, a text editor, a terminal, or a blind window at launch.
    public var foreign: [GitChangedPath] = []

    /// The scheduled healers' paths, heal-marked in the ledger by the Writer operations that made
    /// them (`EchoLedger.markHeal`).
    public var heal: [GitChangedPath] = []

    /// The user acting through the app.
    public var user: [GitChangedPath] = []

    public init() {}

    /// One class of one window's changes, ready to become a commit.
    public struct Group: Sendable, Equatable {
        public let paths: [GitChangedPath]
        /// Which class it is — carried rather than re-derived, so the planner never has to ask a
        /// list whether it contains its own members.
        public let kind: Kind

        public enum Kind: Sendable, Equatable { case foreign, heal, user }
    }

    /// The classes in commit order, empty ones dropped — what the planner turns into `PlannedCommit`s.
    public var ordered: [Group] {
        [
            Group(paths: foreign, kind: .foreign),
            Group(paths: heal, kind: .heal),
            Group(paths: user, kind: .user)
        ].filter { !$0.paths.isEmpty }
    }

    public var isEmpty: Bool { foreign.isEmpty && heal.isEmpty && user.isEmpty }
}

// MARK: - CommitAttribution

/// **Who a commit is by** (06-history-undo.md ▸ Interaction with external writers: "Commit
/// attribution is structural, not just a message convention").
///
/// A pure enum of statics over values: the changed paths, the harvested receipts, and the bytes on
/// disk. Nothing here opens a repository, so every rule below is provable from a fixture rather than
/// from a commit graph.
public enum CommitAttribution {

    // MARK: The pinned identities

    /// **API, not decoration** (06): "The strings are API (users script against them; the `.invalid`
    /// TLD honestly marks a non-routable synthetic identity) — they change with the deliberateness
    /// of a schema change."
    public static let externalAuthorName = "Lanework External"
    public static let externalAuthorEmail = "external@lanework.invalid"

    /// The domain a self-reported `modified-by` stamp authors under — "distinct from both the user
    /// and the generic external author".
    public static let agentEmailDomain = "agents.lanework.invalid"

    /// **Who a heal commit is by** (06 ▸ Commit messages ▸ Healing mutations commit separately, ruled
    /// 2026-07-31 — "the third pinned synthetic, joining Lanework External and the agent-slug family;
    /// strings are API"):
    ///
    /// > a heal is a third origin — not the user's gesture, not a foreign writer — and the separation
    /// > exists for audit, so the trail filters by author like every origin; the committer stays the
    /// > user (the recorded-by convention above).
    ///
    /// It replaced authoring heals as the user, which made the separate commit filterable only by
    /// message shape — and the shape vocabulary deliberately never says "healed".
    public static let integrityAuthorName = "Lanework Integrity"
    public static let integrityAuthorEmail = "integrity@lanework.invalid"

    /// The frontmatter key a foreign writer refines its own attribution with
    /// (01-storage-format.md; 08-agent-integration.md teaches it).
    static let modifiedByKey = "modified-by"

    public static var externalIdentity: GitIdentity {
        GitIdentity(name: externalAuthorName, email: externalAuthorEmail)
    }

    /// The heal class's author (`integrityAuthorName`). The *committer* beside it is still the user's
    /// identity, every time — "every commit the app makes, foreign-authored included, records the
    /// user's app as its committer" (06).
    public static var integrityIdentity: GitIdentity {
        GitIdentity(name: integrityAuthorName, email: integrityAuthorEmail)
    }

    /// **A `modified-by` stamp, as an author** (06): "that commit is authored as **X** with the
    /// synthetic email `<slug>@agents.lanework.invalid` (display name verbatim, email local part
    /// slugified)".
    ///
    /// The local part is lowercased on top of the slug — a judgment call, recorded: DESIGN says
    /// "slugified" without fixing case, addresses are conventionally lower, and the guide's own
    /// example stamp is `modified-by: claude`. The display name is untouched, so `Claude Code` still
    /// renders as `Claude Code <claude-code@agents.lanework.invalid>`.
    public static func agentIdentity(named displayName: String) -> GitIdentity {
        let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
        let local = GitIdentity.addressComponent(name, fallback: "agent").lowercased()
        return GitIdentity(name: name.isEmpty ? externalAuthorName : name, email: "\(local)@\(agentEmailDomain)")
    }

    // MARK: - Classification

    /// **Every changed file, sorted into its commit** — the per-file rule 06 states, applied to the
    /// paths `git status` reported.
    ///
    /// A path is the app's when the ledger holds a receipt for it (or for a folder above it) that
    /// **disk still satisfies**; it is a heal when that receipt is heal-marked; it is foreign
    /// otherwise. "No receipt anywhere → foreign" is the launch-catch-up doctrine and the whole of
    /// *the app never vouches for changes it didn't witness*.
    ///
    /// ### Why the walk goes up the folders
    ///
    /// Because the ledger keys some facts at *folders* while git only ever reports *files*. A card
    /// the app moved between lanes has one `.move` receipt on its folder and no receipt at all on
    /// the `index.md` that travelled inside it; a card the app deleted has one `.absence` receipt on
    /// its folder and git reports every file underneath as gone. Asking only the file's own key
    /// would classify both as foreign — the user's own delete, attributed to an agent.
    ///
    /// The **nearest** receipt wins, so a rewritten `index.md` inside a moved folder answers with
    /// its own content receipt rather than with the move above it.
    public static func split(
        _ paths: [GitChangedPath],
        under boardRoot: URL,
        receipts: [String: HarvestedReceipt]
    ) -> CommitSplit {
        var split = CommitSplit()
        for path in paths {
            let absolute = EchoLedger.key(boardRoot.appendingPathComponent(path.path))
            switch vouched(forAbsolutePath: absolute, boardRoot: boardRoot, receipts: receipts) {
            case .none: split.foreign.append(path)
            case .some(true): split.heal.append(path)
            case .some(false): split.user.append(path)
            }
        }
        return split
    }

    /// `nil` when nothing vouches for this path; otherwise whether the vouching receipt was a heal.
    private static func vouched(
        forAbsolutePath absolute: String,
        boardRoot: URL,
        receipts: [String: HarvestedReceipt]
    ) -> Bool? {
        let root = EchoLedger.key(boardRoot)
        var candidate = absolute
        while candidate.hasPrefix(root), candidate.count >= root.count {
            if let held = receipts[candidate] {
                switch held.receipt {
                case let .content(hash):
                    // Content is a claim about *these* bytes, so only the file's own key may answer
                    // with it. A content receipt sitting on an ancestor would be a claim about a
                    // folder's bytes, which is not a thing.
                    if candidate == absolute {
                        return hash == hashOfFile(atPath: absolute) ? held.isHeal : nil
                    }
                case .absence:
                    return exists(candidate) ? nil : held.isHeal
                case let .move(from, to):
                    if candidate == to { return exists(candidate) ? held.isHeal : nil }
                    if candidate == from { return exists(candidate) ? nil : held.isHeal }
                }
            }
            guard candidate != root else { break }
            let parent = (candidate as NSString).deletingLastPathComponent
            guard parent != candidate else { break }
            candidate = parent
        }
        return nil
    }

    // MARK: - The foreign author

    /// **`modified-by` refines foreign attribution** (06): the whole rule, as one function.
    ///
    /// > when every file changed in a foreign debounce window carries the same `modified-by: X`,
    /// > that commit is authored as **X** … Any disagreement between stamps, any unstamped changed
    /// > file, or any true deletion in the window falls back to `Lanework External`.
    ///
    /// **A folder move is not a deletion.** A rename's departure end is a file that is gone from
    /// disk and has no stamp to read, but it is not "a deletion [that] leaves no file to stamp" —
    /// its arrival end is right there in the same window, carrying whatever the writer stamped on
    /// it. So a paired departure is skipped rather than demoting the window. A bare `mv` that
    /// re-stamps nothing still demotes, through the unstamped-file clause, exactly as 06 says it
    /// does — which is why the agent guide teaches re-stamping on move.
    ///
    /// A window of nothing but rename departures leaves no stamp to agree on and falls back too.
    public static func foreignIdentity(for paths: [GitChangedPath], under boardRoot: URL) -> GitIdentity {
        var stamps: Set<String> = []
        for path in paths {
            if path.isDeletion {
                guard path.isRename else { return externalIdentity }
                continue
            }
            guard let stamp = modifiedBy(atRelativePath: path.path, under: boardRoot) else {
                return externalIdentity
            }
            stamps.insert(stamp)
        }
        guard stamps.count == 1, let name = stamps.first else { return externalIdentity }
        return agentIdentity(named: name)
    }

    /// The `modified-by` a changed file carries, or `nil` for a file that carries none — **which
    /// every non-`index.md` path does, by construction**: a stray, an attachment, and `CLAUDE.md`
    /// have no frontmatter to stamp, so they are unstamped changed files and demote the window.
    static func modifiedBy(atRelativePath relativePath: String, under boardRoot: URL) -> String? {
        guard relativePath == BoardLoader.indexFileName
            || relativePath.hasSuffix("/" + BoardLoader.indexFileName) else { return nil }
        let url = boardRoot.appendingPathComponent(relativePath)
        guard let text = try? String(contentsOf: url, encoding: .utf8),
              let document = try? FrontmatterDocument.parse(text),
              let raw = document.rawValue(for: modifiedByKey) else { return nil }
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }

    // MARK: - Disk

    private static func exists(_ path: String) -> Bool {
        FileManager.default.fileExists(atPath: path)
    }

    /// The hash of what is at `path` now, or `nil` when nothing is — the same digest the ledger's
    /// receipts were minted with, so the comparison is the ledger's own.
    private static func hashOfFile(atPath path: String) -> String? {
        guard let data = FileManager.default.contents(atPath: path) else { return nil }
        return EchoLedger.hash(of: data)
    }
}
