import Foundation

// MARK: - GitIdentity

/// **Who the app's commits are authored by** (06-history-undo.md ▸ Interaction with external
/// writers ▸ "Where the user's git identity comes from").
///
/// Two sources, in the design's own order — and the order is git's own, which is the point:
///
/// 1. **Repo-local `.git/config` wins when present.** "Standard git semantics, readable in-sandbox
///    because it lives under the board root, and the natural state of adopted/cloned boards." The
///    identity fields write exactly that file: "the setting *is* the file, portable to any git
///    client, per-board by nature". Their home is the **board settings sheet** since the 2026-07-31
///    popover/sheet split (03-board-ui.md); they are hosted in the popover's git section until that
///    sheet is built, which changes nothing about this file.
/// 2. **Absent repo config, the derived default**: "the macOS account's full name plus
///    `shortname@hostname` — git's own no-config fallback shape, zero ceremony."
///
/// What is deliberately *not* a source is `~/.gitconfig`: the app is sandboxed and cannot read it,
/// which 06 states as an honest limit rather than a bug. Nothing here consults libgit2's own config
/// ladder for the same reason — a global layer that is unreachable in the shipped app but readable
/// on a developer's machine would make the app's authorship depend on how it was launched.
///
/// The commits this type does *not* speak for are the synthetic ones: foreign changes commit as
/// `Lanework External <external@lanework.invalid>` and `modified-by`-stamped windows as
/// `<slug>@agents.lanework.invalid` (06). Those are the auto-commit card's, and they are pinned
/// strings rather than derivations — nothing about them belongs in a type about *the user's*
/// identity.
public struct GitIdentity: Sendable, Equatable {

    public let name: String
    public let email: String

    public init(name: String, email: String) {
        self.name = name
        self.email = email
    }
}

// MARK: - The derived default

public extension GitIdentity {

    /// The derived default, as a **pure function of three strings** — so the shape 06 names can be
    /// proven without asserting anything about the machine the tests run on.
    ///
    /// `fullName` is the account's display name (`NSFullUserName()`), `accountName` its short name
    /// (`NSUserName()`), `hostName` the machine's (`ProcessInfo.hostName`). Every one of them can
    /// come back empty or shaped in a way git would reject, so each is defended:
    ///
    /// - An empty full name falls back to the account name — git does the same when GECOS is blank,
    ///   and a commit authored by `"" <me@mac>` is a commit no client renders sensibly.
    /// - The email's local part and host are sanitized to what an address may contain: a signature
    ///   with a space or an angle bracket in it is not merely ugly, libgit2 refuses it outright and
    ///   the commit fails.
    /// - An empty host reads `localhost`, which is what a machine with no name is.
    static func derived(fullName: String, accountName: String, hostName: String) -> GitIdentity {
        let account = accountName.trimmingCharacters(in: .whitespacesAndNewlines)
        let trimmedName = fullName.trimmingCharacters(in: .whitespacesAndNewlines)
        let name = trimmedName.isEmpty ? (account.isEmpty ? "Lanework" : account) : trimmedName

        let localPart = addressComponent(account, fallback: "user")
        // A trailing dot is legal in a fully-qualified name and useless in an address; `.local`
        // hosts keep theirs, which is exactly what git's own fallback produces on a Mac.
        let host = addressComponent(
            hostName.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".")
                ? String(hostName.trimmingCharacters(in: .whitespacesAndNewlines).dropLast())
                : hostName,
            fallback: "localhost"
        )

        return GitIdentity(name: name, email: "\(localPart)@\(host)")
    }

    /// The derived default for *this* machine — the one impure call, kept to one line so everything
    /// above it stays provable.
    static func derivedDefault() -> GitIdentity {
        derived(
            fullName: NSFullUserName(),
            accountName: NSUserName(),
            hostName: ProcessInfo.processInfo.hostName
        )
    }

    /// **The resolution 06 states**, per key rather than wholesale: a repo-local config naming only
    /// `user.name` contributes exactly that and the email still derives — git resolves each key on
    /// its own, and a half-configured repo is a real state (it is what a `git config user.email`
    /// typo leaves behind).
    static func resolve(repoLocal: (name: String?, email: String?), derived: GitIdentity) -> GitIdentity {
        func configured(_ value: String?, or fallback: String) -> String {
            guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
                  !trimmed.isEmpty else { return fallback }
            return trimmed
        }
        return GitIdentity(
            name: configured(repoLocal.name, or: derived.name),
            email: configured(repoLocal.email, or: derived.email)
        )
    }

    /// Characters an address part may carry, with everything else collapsed to `-`. Deliberately
    /// conservative rather than RFC-complete: the input is a Mac account name and a Bonjour host
    /// name, and the only job is that libgit2 accepts the signature and a git client renders it.
    ///
    /// Shared with `CommitAttribution.agentIdentity(named:)` — a `modified-by` stamp is arbitrary
    /// self-reported text and needs exactly this treatment to become an address local part
    /// ("display name verbatim, email local part slugified", 06-history-undo.md). One slug rule for
    /// both, so a name that is safe in a derived default cannot be unsafe in an agent's address.
    static func addressComponent(_ raw: String, fallback: String) -> String {
        let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._"))
        let mapped = String(
            String.UnicodeScalarView(
                raw.unicodeScalars.map { allowed.contains($0) ? $0 : Unicode.Scalar("-") }
            )
        )
        let trimmed = mapped.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
        return trimmed.isEmpty ? fallback : trimmed
    }
}

// MARK: - Repo-local config

/// **The board's own `.git/config`, read as text** (06-history-undo.md: "repo-local `.git/config`
/// wins when present … readable in-sandbox because it lives under the board root").
///
/// Read by hand rather than through libgit2's config ladder, deliberately: `git_repository_config`
/// merges the repository, global and system layers, so a value read through it is not the answer to
/// "what does *this repository* say" — it is the answer to "what does this machine say", which is
/// the question the sandbox makes unanswerable and which 06 rules out of the identity story
/// entirely. Reading the file the design names gives the same answer in the shipped sandboxed app,
/// in a test, and on a developer's machine with a `~/.gitconfig` full of opinions.
///
/// The parse is tolerant by design: it is looking for two keys in one section of a format that
/// allows comments, indentation and quoting, and anything it fails to understand simply reads as
/// absent — which falls through to the derived default, the same place a missing file lands.
enum GitConfigFile {

    /// `user.name` / `user.email` as the config file at `gitDirectory/config` states them; both
    /// `nil` when the file does not exist, cannot be read, or names neither key.
    static func identity(inGitDirectory gitDirectory: URL) -> (name: String?, email: String?) {
        let configURL = gitDirectory.appendingPathComponent("config")
        guard let text = try? String(contentsOf: configURL, encoding: .utf8) else { return (nil, nil) }
        return identity(inConfigText: text)
    }

    /// The parse, over text — the pure half, and where the format's edges are decided.
    ///
    /// **Reads take the last plain-section value** (06-history-undo.md ▸ Interaction with external
    /// writers, blessed 2026-07-31): "the reader — like git itself — takes the last plain-section
    /// value, which is exactly what an append produces."
    ///
    /// *Plain* is load-bearing and is the whole of the subsection rule. `[user "work"]` is a different
    /// key in git's own model — `user.work.name`, not `user.name` — so its values are not answers to
    /// this question at all, and reading one would sign the user's commits with an identity they
    /// filed under a name this app never asked about. Last-wins still holds inside the plain
    /// sections: a later `[user]` overrides an earlier one, which is how an appended section wins
    /// without the writer ever touching what came before it.
    static func identity(inConfigText text: String) -> (name: String?, email: String?) {
        var isPlainUserSection = false
        var name: String?
        var email: String?

        for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
            let line = rawLine.trimmingCharacters(in: .whitespaces)
            if line.isEmpty || line.hasPrefix("#") || line.hasPrefix(";") { continue }

            if line.hasPrefix("[") {
                let header = line.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
                let section = header
                    .split(separator: " ", maxSplits: 1)
                    .first
                    .map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
                isPlainUserSection = section == "user" && !header.contains("\"")
                continue
            }

            guard isPlainUserSection, let separator = line.firstIndex(of: "=") else { continue }
            let key = line[line.startIndex..<separator].trimmingCharacters(in: .whitespaces).lowercased()
            let value = unquoted(line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces))
            switch key {
            case "name": name = value.nonEmpty
            case "email": email = value.nonEmpty
            default: continue
            }
        }

        return (name, email)
    }

    // MARK: Writing

    /// **The identity fields, landing in the file** (06-history-undo.md ▸ Interaction with external
    /// writers: "The board settings sheet's identity section … exposes name/email fields that **write
    /// that repo-local config** — the setting *is* the file, portable to any git client, per-board by
    /// nature"; the fields are popover-hosted until that sheet is built).
    ///
    /// This is the **only** thing in the app that writes `user.name`/`user.email` anywhere, and that
    /// is the design's own line: the derived default "is passed as an explicit per-commit signature,
    /// never written into repo config", because a value the app wrote there would outrank the user's
    /// own global `~/.gitconfig` for their terminal commits in that board. What lands here is what the
    /// user typed and nothing else.
    ///
    /// Skips the disk write entirely when `applying` reports no change: the settings sheet re-reads
    /// this file every 2 s while it is visible (06's visibility-scoped poll), and a write that would
    /// not change a byte must not churn the mtime that poll is watching.
    static func writeIdentity(
        name: String?,
        email: String?,
        inGitDirectory gitDirectory: URL
    ) throws {
        let configURL = gitDirectory.appendingPathComponent("config")
        let existing = (try? String(contentsOf: configURL, encoding: .utf8)) ?? ""
        let updated = applying(name: name, email: email, to: existing)
        guard updated != existing else { return }
        try Data(updated.utf8).write(to: configURL, options: .atomic)
    }

    /// The edit, over text — the pure half, which is where every rule below is decided and the only
    /// half a test needs.
    ///
    /// **"Writes append, reads take the last"** (06-history-undo.md ▸ Interaction with external
    /// writers, blessed 2026-07-31): a *set* never edits or deletes an existing line. It appends one
    /// new plain `[user]` section at the very end of the file — `name` before `email` — even when
    /// plain `[user]` sections already exist; last-wins reading is exactly what makes the appended
    /// value win, "without the writer ever reformatting what it didn't create". "A write whose keys
    /// already read back at their target values is skipped whole, so revisiting the sheet never grows
    /// the file": resolution runs first, through the same `identity(inConfigText:)` this file's reads
    /// use, and a key set to its already-current value — or cleared when already absent — drops out
    /// of the pending work before anything is touched. If nothing remains pending, the input comes
    /// back byte-identical.
    ///
    /// **"Clearing a key is the one sanctioned in-place edit"** (ruled 2026-08-06): "the config
    /// format spells absence one way only — the key not being there", so a clear deletes every
    /// plain-section line for that key, in every plain `[user]` section — "deleting fewer than all of
    /// them changes nothing under last-wins" — and then drops any plain `[user]` header left with no
    /// real key lines under it (only blanks/comments); a section that keeps another key (`signingkey`,
    /// say) keeps its header. **"Subsections stay untouchable in both directions"**: `[user "work"]`
    /// is never edited by a set or a clear, whatever its keys. A combined set-and-clear call does the
    /// clears in place, then appends the set section.
    static func applying(name: String?, email: String?, to text: String) -> String {
        func cleaned(_ value: String?) -> String? {
            guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
                  !trimmed.isEmpty else { return nil }
            return trimmed
        }

        // No-op skip: resolve what the file currently says (last-wins, same reader the rest of the
        // app uses) and drop any key whose target already matches — set-to-current and
        // clear-when-absent both mean "nothing to do" for that key.
        let current = identity(inConfigText: text)
        let targets: [(key: String, target: String?, current: String?)] = [
            ("name", cleaned(name), current.name),
            ("email", cleaned(email), current.email)
        ]
        // `nil` is "clear this key"; a key with no pending work is simply absent from the dictionary.
        var pending: [String: String?] = [:]
        for entry in targets where entry.target != entry.current {
            pending[entry.key] = entry.target
        }
        guard !pending.isEmpty else { return text }

        // Split on `\n` and rejoin, so the file's own trailing-newline shape survives the round trip
        // (`components(separatedBy:)` renders a trailing newline as a final empty element).
        var lines = text.isEmpty ? [] : text.components(separatedBy: "\n")

        let clearedKeys = Set(pending.compactMap { key, value in value == nil ? key : nil })
        if !clearedKeys.isEmpty {
            lines = removingKeys(clearedKeys, fromPlainUserSectionsIn: lines)
            lines = removingEmptyPlainUserSections(from: lines)
        }

        // Name before email, always — a file this app wrote reads the same whichever field was
        // filled first.
        let additions = ["name", "email"].compactMap { key -> String? in
            guard let value = pending[key] ?? nil else { return nil }
            return "\t\(key) = \(value)"
        }
        guard !additions.isEmpty else { return lines.joined(separator: "\n") }

        if let last = lines.last, !last.trimmingCharacters(in: .whitespaces).isEmpty {
            lines.append("")
        }
        lines.append("[user]")
        lines.append(contentsOf: additions)
        lines.append("")

        return lines.joined(separator: "\n")
    }

    /// Whether a trimmed line opens the **plain** `[user]` section — the load-bearing distinction
    /// throughout this file. A subsectioned `[user "work"]` is a different key in git's own model
    /// (`user.work.name`, not `user.name`), so it must never match here: matching it would let a set
    /// or a clear reach into a scope the user filed under a name this app never asked about.
    private static func isPlainUserHeader(_ trimmedLine: String) -> Bool {
        guard trimmedLine.hasPrefix("[") else { return false }
        let header = trimmedLine.drop(while: { $0 == "[" }).prefix(while: { $0 != "]" })
        let section = header
            .split(separator: " ", maxSplits: 1)
            .first
            .map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
        return section == "user" && !header.contains("\"")
    }

    /// The clear's in-place edit: deletes every line, in every plain `[user]` section, whose key
    /// (trimmed, lowercased, before `=`) is in `keys`. Deleting fewer than all of them would change
    /// nothing under last-wins reading, so this walks the whole file rather than stopping at the
    /// first match. Lines outside a plain `[user]` section — including everything inside a `[user
    /// "…"]` subsection — are never inspected for deletion.
    private static func removingKeys(_ keys: Set<String>, fromPlainUserSectionsIn lines: [String]) -> [String] {
        var result: [String] = []
        var isPlainUserSection = false
        for line in lines {
            let trimmed = line.trimmingCharacters(in: .whitespaces)
            if trimmed.hasPrefix("[") {
                isPlainUserSection = isPlainUserHeader(trimmed)
                result.append(line)
                continue
            }
            if isPlainUserSection, let separator = trimmed.firstIndex(of: "=") {
                let key = trimmed[trimmed.startIndex..<separator]
                    .trimmingCharacters(in: .whitespaces)
                    .lowercased()
                if keys.contains(key) { continue }
            }
            result.append(line)
        }
        return result
    }

    /// Drops every plain `[user]` header left with no real key lines under it (only blanks/comments)
    /// — what a clear leaves behind, and what a config the user never touched does not have. Walks
    /// the whole file rather than the first section alone: a clear can empty out more than one plain
    /// `[user]` section in the same call, and a section that still carries another key (`signingkey`,
    /// say) keeps its header.
    private static func removingEmptyPlainUserSections(from lines: [String]) -> [String] {
        var result: [String] = []
        var index = 0
        while index < lines.count {
            guard isPlainUserHeader(lines[index].trimmingCharacters(in: .whitespaces)) else {
                result.append(lines[index])
                index += 1
                continue
            }

            var end = index + 1
            var hasRealKey = false
            while end < lines.count {
                let trimmed = lines[end].trimmingCharacters(in: .whitespaces)
                if trimmed.hasPrefix("[") { break }
                if !trimmed.isEmpty, !trimmed.hasPrefix("#"), !trimmed.hasPrefix(";") { hasRealKey = true }
                end += 1
            }
            if hasRealKey { result.append(contentsOf: lines[index..<end]) }
            index = end
        }
        return result
    }

    /// Strips one layer of surrounding quotes, and an unquoted trailing comment. A `#` inside
    /// quotes is content — git's own rule, and the one place a naive strip would corrupt a name.
    private static func unquoted(_ value: String) -> String {
        if value.hasPrefix("\"") {
            let body = value.dropFirst()
            guard let closing = body.firstIndex(of: "\"") else { return String(body) }
            return String(body[body.startIndex..<closing])
        }
        let uncommented = value.prefix { $0 != "#" && $0 != ";" }
        return uncommented.trimmingCharacters(in: .whitespaces)
    }
}

// MARK: - String conveniences

private extension String {
    var nonEmpty: String? { isEmpty ? nil : self }
}
