import Foundation

/// **The board's noise definition, parsed** — a board-root `.gitignore` read as git itself reads
/// one, and asked one question: is this board-relative path ignored?
///
/// The file outgrew git on 2026-07-31 (01-storage-format.md § Fractal layout ▸ Rules, "`.gitignore`
/// is the noise gate"; 06-history-undo.md ▸ Repository hygiene, retired with app-managed git,
/// `strategy/01-git-excision.md`): it is now the one definition of
/// noise the **loose-file relocation heal** obeys, on every board, git or not. A file matching it
/// keeps the ordinary stray posture — "skipped, preserved verbatim, logged, never relocated, never
/// announced" — and "the exclusion list is exactly that file, nothing hardcoded". (On a Pro board the
/// same file once governed the committer through libgit2 too, so ignored noise neither relocated nor
/// committed — one definition, two consumers; app-managed git is gone now, so the heal is the only
/// consumer left.) This type is the app's half of that — once split from libgit2 because the load
/// path couldn't reach for it while the committer did; libgit2 left the project entirely with the
/// git excision (`strategy/01-git-excision.md`, step 6), so today there is no other implementation to
/// duplicate at all. The posture 12 wrote as the free tier's is simply the app's now (12-editions.md
/// ▸ The free tier and `.git`; ▸ PIVOT 2026-08-08): a board opens without so much as a `fileExists`
/// under `.git`, and the heal runs on boards that have no repository at all.
///
/// **Pure: no I/O, no filesystem, no `URL`.** It parses text and answers about paths. Reading the
/// bytes is `BoardLoader.ignoreRules(atBoardRoot:)`' job, once per walk — the loader stays a pure
/// function of the tree, and this stays a pure function of the file.
///
/// ## The semantics, which are git's
///
/// Implemented from `gitignore(5)`, in its own order:
///
/// - A **blank line** matches nothing; it exists to space the file out.
/// - A line beginning with `#` is a **comment**. `\#` begins a pattern with a literal `#`.
/// - **Trailing spaces are ignored** unless escaped (`\ `).
/// - A leading `!` **negates**, re-including a path an earlier pattern excluded. `\!` is a literal.
/// - A trailing `/` makes the pattern **directory-only**.
/// - A `/` at the beginning or in the middle **anchors** the pattern to the board root; a pattern
///   with no interior separator matches **at any depth** (`.DS_Store` matches every one of them).
/// - `*` matches any run of characters but never `/`; `?` matches exactly one non-`/` character;
///   `[…]` is a character class, negatable with `!` or `^` and carrying ranges (`[0-9]`).
/// - A path segment that is exactly `**` matches **zero or more** segments: leading `**/` matches at
///   any depth, a trailing `/**` matches everything inside, and `a/**/b` matches `a/b` as well as
///   `a/x/y/b`. Asterisks anywhere else are ordinary `*`s, exactly as git says.
/// - **Last match wins.** The verdict is the last pattern in file order that matched, negations
///   included — which is why the rules are kept in file order and evaluated in it.
/// - **An ignored directory is final.** A path under an excluded directory is excluded whatever a
///   later negation says about it — git does not descend into an ignored directory, so a rule about
///   something inside it is never consulted. `isIgnored(relativePath:isDirectory:)` walks the
///   ancestors for exactly this reason.
///
/// ## The deliberate divergences, and they are three
///
/// - **Only this file.** Nested `.gitignore` files are never consulted, `.git/info/exclude` and the
///   user's global excludes are never read, and `core.excludesFile` does not exist here. That is the
///   ruling's own wording — "nested `.gitignore` files are ordinary strays the heal never consults"
///   — and it is what makes the noise definition something the user can see in one place.
/// - **Case-sensitive matching**, always. Git's is `core.ignorecase`'s to decide, which is a
///   repository setting on a file this app reads on boards that have no repository. The board's own
///   name comparisons go the other way (`IntegrityRules.reservedCardChildNames` is lowercased
///   "because the filesystem this runs on usually is"), but a *pattern* is the user's text and
///   folding it would silently widen what they wrote.
/// - **No POSIX bracket expressions** (`[[:digit:]]`): a `[` that does not close is a literal `[`,
///   and `[:digit:]` reads as the ordinary class it looks like. Nothing realistic in a board's noise
///   file uses them, and inventing a second character-class grammar to hold them would be the
///   over-engineering this type exists to avoid.
public struct GitignoreRules: Sendable, Equatable {

    /// The file's patterns, **in file order** — which is the whole of last-match-wins.
    private let patterns: [Pattern]

    /// Parses a whole `.gitignore` body.
    ///
    /// **An empty file parses to no patterns and ignores nothing** — the escape hatch 06 names by
    /// hand ("the escape hatch for wanting no exclusions is an *empty* file, which the app honors and
    /// never rewrites"), and the same answer a board carrying no `.gitignore` at all gets. The two
    /// are deliberately indistinguishable to every consumer: one of them is a statement and the other
    /// is a silence, but neither excludes anything.
    ///
    /// Lines are split on **any** newline (`Character.isNewline`), which is git's own reading of a
    /// CRLF file — `dir.c` terminates each pattern before the `\r` — arrived at from the other
    /// direction: Swift treats `\r\n` as a single grapheme, so splitting on newline-ness drops the
    /// carriage return by construction rather than by trimming it afterwards. A board hand-edited on
    /// Windows must not end up carrying patterns nobody can match. A leading UTF-8 BOM is skipped for
    /// the reason git skips one: it is a byte-order mark, not the first character of a pattern.
    public init(parsing text: String) {
        var body = Substring(text)
        if body.hasPrefix("\u{FEFF}") { body = body.dropFirst() }
        patterns = body.split(whereSeparator: \.isNewline).compactMap(Pattern.init(line:))
    }

    /// Whether the file said anything at all. `true` for a board with no `.gitignore` and for one
    /// whose `.gitignore` is empty or all comments — the honored-empty state.
    public var isEmpty: Bool { patterns.isEmpty }

    /// Whether `relativePath` — **board-relative**, `/`-separated, no leading slash (`<lane>/<card>/notes.txt`)
    /// — is ignored by this file.
    ///
    /// `isDirectory` decides the directory-only patterns (`build/`) and nothing else. It describes
    /// what is on disk at that path, which is the caller's to know: the loose-file gate always passes
    /// `false`, because the carve-out is exactly *files*.
    ///
    /// **Ancestors are consulted first.** A path inside an excluded directory is excluded, and no
    /// negation reaches it — git never descends into an ignored directory, so it never sees the rule
    /// that would have re-included the file. Everything below `build/` is ignored even where the file
    /// goes on to say `!build/keep.txt`.
    public func isIgnored(relativePath: String, isDirectory: Bool = false) -> Bool {
        guard !patterns.isEmpty else { return false }
        let segments = relativePath.split(separator: "/").map(String.init)
        guard !segments.isEmpty else { return false }

        for depth in 1 ..< segments.count where verdict(for: Array(segments.prefix(depth)), isDirectory: true) == true {
            return true
        }
        return verdict(for: segments, isDirectory: isDirectory) == true
    }

    /// The last-match-wins verdict for one path: `true` ignored, `false` explicitly re-included,
    /// `nil` matched by nothing.
    ///
    /// `nil` and `false` behave identically at every call site today; they are kept apart because the
    /// file's grammar keeps them apart — "this was never mentioned" and "this was mentioned and then
    /// taken back" are different statements, and a future consumer (a listing that shows *why*) would
    /// need the difference the moment it existed.
    private func verdict(for segments: [String], isDirectory: Bool) -> Bool? {
        var answer: Bool?
        for pattern in patterns {
            guard !pattern.directoryOnly || isDirectory else { continue }
            guard pattern.matches(segments) else { continue }
            answer = !pattern.isNegated
        }
        return answer
    }
}

// MARK: - One pattern

extension GitignoreRules {

    /// One line of the file, compiled: what it matches, whether it re-includes, and whether it only
    /// speaks about directories.
    ///
    /// **Anchoring is baked into `segments`** rather than carried as a flag: an unanchored pattern is
    /// exactly its anchored self with a leading `**`, which is what `gitignore(5)` says in prose
    /// ("the pattern may also match at any level below") and what removes a branch from the matcher.
    fileprivate struct Pattern: Sendable, Equatable {
        let segments: [Segment]
        let isNegated: Bool
        let directoryOnly: Bool

        /// `nil` for a line that is not a pattern at all — blank, or a comment.
        init?(line: Substring) {
            var text = Self.trimmingTrailingSpaces(line)
            guard !text.isEmpty, text.first != "#" else { return nil }

            if text.first == "!" {
                isNegated = true
                text = text.dropFirst()
            } else {
                isNegated = false
            }

            // The trailing separator is the directory-only marker, and it is *not* an interior
            // separator for the anchoring question below: `foo/` matches a directory named `foo` at
            // any depth, while `a/b/` is anchored.
            if text.hasSuffix("/") {
                directoryOnly = true
                text = text.dropLast()
            } else {
                directoryOnly = false
            }
            guard !text.isEmpty else { return nil }

            let anchored = text.contains("/")
            let parsed = text
                .split(separator: "/", omittingEmptySubsequences: true)
                .map(Segment.init(text:))
            guard !parsed.isEmpty else { return nil }
            segments = anchored ? parsed : [.globstar] + parsed
        }

        /// Whether this pattern matches the whole of `path`.
        func matches(_ path: [String]) -> Bool {
            Self.match(segments[...], path[...])
        }

        /// The segment walk, with `**`'s zero-or-more the only place it backtracks.
        private static func match(_ pattern: ArraySlice<Segment>, _ path: ArraySlice<String>) -> Bool {
            guard let head = pattern.first else { return path.isEmpty }
            let tail = pattern.dropFirst()

            if case .globstar = head {
                // **A trailing `**` matches everything *inside*** (`gitignore(5)`), so it needs a
                // segment to consume: `a/**` matches `a/b`, never the bare `a`. Where `a` itself is
                // ignored, some other pattern said so.
                guard !tail.isEmpty else { return !path.isEmpty }
                var remaining = path
                while true {
                    if match(tail, remaining) { return true }
                    guard !remaining.isEmpty else { return false }
                    remaining = remaining.dropFirst()
                }
            }

            guard case let .literalOrWildcards(tokens) = head,
                  let name = path.first,
                  Segment.match(tokens[...], Array(name)[...])
            else {
                return false
            }
            return match(tail, path.dropFirst())
        }

        /// Drops the trailing spaces git drops — every one that is not `\`-escaped.
        ///
        /// Only spaces, and only trailing: git trims exactly this (`trim_trailing_spaces`), so a
        /// pattern ending in a tab keeps it, and a filename that really does end in a space stays
        /// reachable by writing `foo\ `.
        private static func trimmingTrailingSpaces(_ line: Substring) -> Substring {
            var end = line.endIndex
            while end > line.startIndex {
                let previous = line.index(before: end)
                guard line[previous] == " " else { break }
                // Escaped when preceded by an odd number of backslashes.
                var backslashes = 0
                var scan = previous
                while scan > line.startIndex {
                    scan = line.index(before: scan)
                    guard line[scan] == "\\" else { break }
                    backslashes += 1
                }
                guard backslashes.isMultiple(of: 2) else { break }
                end = previous
            }
            return line[line.startIndex ..< end]
        }
    }

    /// One `/`-separated piece of a pattern.
    fileprivate enum Segment: Sendable, Equatable {
        /// Exactly `**` — zero or more path segments. "Other consecutive asterisks are considered
        /// regular asterisks" (`gitignore(5)`), which is why this case is reserved for the whole
        /// segment and never for a `**` sitting inside one.
        case globstar
        /// Everything else, tokenized once at parse time.
        case literalOrWildcards([Token])

        init(text: Substring) {
            self = text == "**" ? .globstar : .literalOrWildcards(Token.tokenize(text))
        }

        /// Matches one path segment against one pattern segment — `*`'s backtracking, iteratively,
        /// because a pattern is small and a recursion per `*` is not worth the stack.
        static func match(_ tokens: ArraySlice<Token>, _ name: ArraySlice<Character>) -> Bool {
            var t = tokens.startIndex
            var n = name.startIndex
            // Where to resume from if a `*` guessed short: the star itself and the character it was
            // last asked to swallow up to.
            var starToken: Int?
            var starName = name.startIndex

            while n < name.endIndex {
                if t < tokens.endIndex {
                    switch tokens[t] {
                    case .anyRun:
                        starToken = t
                        starName = n
                        t += 1
                        continue
                    case .anyCharacter:
                        t += 1
                        n += 1
                        continue
                    case let .literal(character) where character == name[n]:
                        t += 1
                        n += 1
                        continue
                    case let .characterClass(group) where group.matches(name[n]):
                        t += 1
                        n += 1
                        continue
                    case .literal, .characterClass:
                        break
                    }
                }
                guard let star = starToken else { return false }
                starName += 1
                n = starName
                t = star + 1
            }

            // Trailing `*`s can still match nothing at all.
            while t < tokens.endIndex, tokens[t] == .anyRun { t += 1 }
            return t == tokens.endIndex
        }
    }

    /// One element of a pattern segment.
    fileprivate enum Token: Sendable, Equatable {
        case literal(Character)
        /// `?`
        case anyCharacter
        /// `*` — any run, `/` excluded by construction (a token never sees a separator).
        case anyRun
        /// `[…]`
        case characterClass(CharacterClass)

        /// Compiles one segment's characters. A `\` escapes whatever follows it; a trailing `\` is a
        /// literal backslash (there is nothing left for it to escape); an unterminated `[` is a
        /// literal `[`, which is git's own reading and the only one that cannot lose a character.
        static func tokenize(_ text: Substring) -> [Token] {
            var tokens: [Token] = []
            var index = text.startIndex
            while index < text.endIndex {
                let character = text[index]
                switch character {
                case "\\":
                    let next = text.index(after: index)
                    guard next < text.endIndex else {
                        tokens.append(.literal("\\"))
                        index = next
                        continue
                    }
                    tokens.append(.literal(text[next]))
                    index = text.index(after: next)
                case "?":
                    tokens.append(.anyCharacter)
                    index = text.index(after: index)
                case "*":
                    // Consecutive asterisks inside a segment are one ordinary `*`.
                    if tokens.last != .anyRun { tokens.append(.anyRun) }
                    index = text.index(after: index)
                case "[":
                    if let (group, end) = CharacterClass.parse(text, from: index) {
                        tokens.append(.characterClass(group))
                        index = end
                    } else {
                        tokens.append(.literal("["))
                        index = text.index(after: index)
                    }
                default:
                    tokens.append(.literal(character))
                    index = text.index(after: index)
                }
            }
            return tokens
        }
    }

    /// A `[…]` group: members, ranges, and the leading `!`/`^` negation.
    fileprivate struct CharacterClass: Sendable, Equatable {
        enum Member: Sendable, Equatable {
            case single(Character)
            case range(ClosedRange<Character>)
        }

        let isNegated: Bool
        let members: [Member]

        func matches(_ character: Character) -> Bool {
            let hit = members.contains { member in
                switch member {
                case let .single(value): value == character
                case let .range(range): range.contains(character)
                }
            }
            return hit != isNegated
        }

        /// Parses from the `[` at `start`, answering the group and the index just past its `]`, or
        /// `nil` when the group never closes.
        ///
        /// A `]` **immediately after** the opening bracket (or its negation mark) is a literal member
        /// rather than the terminator, which is the POSIX rule git inherits — `[]]` matches a bracket.
        static func parse(_ text: Substring, from start: Substring.Index) -> (CharacterClass, Substring.Index)? {
            var index = text.index(after: start)
            var negated = false
            if index < text.endIndex, text[index] == "!" || text[index] == "^" {
                negated = true
                index = text.index(after: index)
            }

            var members: [Member] = []
            var first = true
            while index < text.endIndex {
                let character = text[index]
                if character == "]", !first {
                    return (CharacterClass(isNegated: negated, members: members), text.index(after: index))
                }
                first = false

                var value = character
                if character == "\\" {
                    let next = text.index(after: index)
                    guard next < text.endIndex else { break }
                    value = text[next]
                    index = next
                }

                // A `-` between two members is a range; one at either end of the group is a literal.
                let afterValue = text.index(after: index)
                if afterValue < text.endIndex, text[afterValue] == "-" {
                    let upperIndex = text.index(after: afterValue)
                    if upperIndex < text.endIndex, text[upperIndex] != "]" {
                        var upper = text[upperIndex]
                        var end = upperIndex
                        if upper == "\\" {
                            let escaped = text.index(after: upperIndex)
                            guard escaped < text.endIndex else { break }
                            upper = text[escaped]
                            end = escaped
                        }
                        // An inverted range (`z-a`) is nonsense; git's matcher never matches one, and
                        // `ClosedRange` would trap on it.
                        if value <= upper { members.append(.range(value ... upper)) }
                        index = text.index(after: end)
                        continue
                    }
                }

                members.append(.single(value))
                index = text.index(after: index)
            }
            return nil
        }
    }
}
