import Foundation
import Testing
@testable import Kanban

/// **The board's noise definition, as a parser** (01-storage-format.md § Fractal layout ▸ Rules,
/// ruled 2026-07-31: "`.gitignore` is the noise gate"; 06-history-undo.md ▸ Repository hygiene).
///
/// `GitignoreRules` is pure — text in, verdicts out — so this suite is a table and nothing else: no
/// filesystem, no board, no store. What it pins is that the app's own matcher is **git's**, because
/// `gitignore(5)` is the format's own semantics and a file a user may also be handing to their own
/// git — a matcher that read it differently from git itself would make the file lie to one reader or
/// the other. (Once doubly true while a committer shared this definition too — "on Pro boards the
/// same file governs the committer, so ignored noise neither relocates nor commits" — before
/// app-managed git was excised entirely, `strategy/01-git-excision.md`, 2026-08-08; libgit2 is gone,
/// but the format's own semantics remain the standard regardless.)
///
/// The claims are grouped as `gitignore(5)` states them, in its order, plus the seed the app writes
/// and the three deliberate divergences the type documents.

/// One assertion, spelled the way the file reads: patterns on the left, a path on the right.
private func ignores(_ file: String, _ path: String, isDirectory: Bool = false) -> Bool {
    GitignoreRules(parsing: file).isIgnored(relativePath: path, isDirectory: isDirectory)
}

// MARK: - What is a pattern at all

@Suite("Gitignore ▸ the line grammar")
struct GitignoreLineGrammarTests {

    @Test("Blank lines and comments match nothing")
    func blanksAndComments() {
        let file = """
        # a comment

        #notes.txt
        """
        let rules = GitignoreRules(parsing: file)
        #expect(rules.isEmpty)
        #expect(!rules.isIgnored(relativePath: "notes.txt"))
        #expect(!rules.isIgnored(relativePath: "# a comment"))
    }

    /// An empty file is 06's own escape hatch — "the escape hatch for wanting no exclusions is an
    /// *empty* file, which the app honors" — and it must read as "excludes nothing", never as
    /// "excludes everything".
    @Test("An empty file excludes nothing, and says so")
    func emptyFile() {
        #expect(GitignoreRules(parsing: "").isEmpty)
        #expect(!ignores("", "notes.txt"))
        #expect(!ignores("", "lane/card/notes.txt"))
        #expect(!ignores("\n\n\n", "notes.txt"))
    }

    /// `#` only comments when it *begins* the line, and `\#` writes a pattern that starts with one.
    @Test("A hash is escapable, and only leading hashes comment")
    func escapedHash() {
        #expect(ignores("\\#notes.txt", "#notes.txt"))
        #expect(!ignores("\\#notes.txt", "notes.txt"))
        #expect(ignores("notes#1.txt", "notes#1.txt"))
    }

    /// "Trailing spaces are ignored unless they are quoted with backslash."
    @Test("Trailing spaces are dropped unless escaped")
    func trailingSpaces() {
        #expect(ignores("notes.txt   ", "notes.txt"))
        // The escaped one is part of the name — so the bare name no longer matches, and the
        // space-suffixed one does.
        #expect(ignores("notes.txt\\ ", "notes.txt "))
        #expect(!ignores("notes.txt\\ ", "notes.txt"))
        // Only spaces, and only trailing: a tab is part of the pattern (git trims spaces alone).
        #expect(ignores("notes.txt\t", "notes.txt\t"))
    }

    /// A `.gitignore` hand-edited on Windows must not become a file of patterns nobody can match —
    /// git terminates each pattern before the `\r`, and so does this.
    @Test("CRLF line endings parse, and a BOM is skipped")
    func lineEndingsAndBOM() {
        #expect(ignores("*.tmp\r\nbuild/\r\n", "scratch.tmp"))
        #expect(ignores("\u{FEFF}*.tmp\n", "scratch.tmp"))
    }
}

// MARK: - Anchoring

@Suite("Gitignore ▸ anchoring")
struct GitignoreAnchoringTests {

    /// "If there is no separator … the pattern may also match at any level below" — which is the
    /// property the seed leans on entirely: one `.DS_Store` line covers every folder in the board.
    @Test("A separator-less pattern matches at every depth")
    func unanchoredMatchesEverywhere() {
        let file = ".DS_Store\n"
        #expect(ignores(file, ".DS_Store"))
        #expect(ignores(file, "lane/.DS_Store"))
        #expect(ignores(file, "lane/card/.DS_Store"))
        #expect(!ignores(file, "lane/card/DS_Store"))
        #expect(ignores(file, "lane/.DS_Store/inside.txt"), "and a directory by that name takes everything with it")
    }

    @Test("A leading slash anchors to the board root")
    func leadingSlashAnchors() {
        let file = "/notes.txt\n"
        #expect(ignores(file, "notes.txt"))
        #expect(!ignores(file, "lane/notes.txt"))
        #expect(!ignores(file, "lane/card/notes.txt"))
    }

    /// "If there is a separator at the beginning or middle (or both) … the pattern is relative to
    /// the directory level of the particular `.gitignore` file itself" — which for a board is its
    /// root, and the reason the loose-file gate matches the **board-relative** path.
    @Test("An interior slash anchors too")
    func interiorSlashAnchors() {
        let file = "lane/notes.txt\n"
        #expect(ignores(file, "lane/notes.txt"))
        #expect(!ignores(file, "other/lane/notes.txt"))
        #expect(!ignores(file, "notes.txt"))
    }

    /// A *trailing* separator is the directory marker and does not anchor: `build/` still means "any
    /// folder called build, anywhere".
    @Test("A trailing slash does not anchor")
    func trailingSlashDoesNotAnchor() {
        let file = "build/\n"
        #expect(ignores(file, "build", isDirectory: true))
        #expect(ignores(file, "lane/card/build", isDirectory: true))
        #expect(ignores(file, "lane/card/build/output.o"))
    }
}

// MARK: - Directory-only patterns

@Suite("Gitignore ▸ directory-only patterns")
struct GitignoreDirectoryTests {

    @Test("A trailing slash matches only directories")
    func directoryOnly() {
        let file = "cache/\n"
        #expect(ignores(file, "cache", isDirectory: true))
        #expect(!ignores(file, "cache", isDirectory: false), "a *file* called cache is not what the pattern is about")
        // …and everything inside the directory rides along.
        #expect(ignores(file, "cache/thing.bin"))
    }

    @Test("Without the slash, files and directories both match")
    func withoutTheSlash() {
        #expect(ignores("cache\n", "cache", isDirectory: false))
        #expect(ignores("cache\n", "cache", isDirectory: true))
    }
}

// MARK: - Negation and last-match-wins

@Suite("Gitignore ▸ negation and precedence")
struct GitignoreNegationTests {

    @Test("The last matching pattern decides")
    func lastMatchWins() {
        #expect(!ignores("*.txt\n!notes.txt\n", "notes.txt"))
        #expect(ignores("!notes.txt\n*.txt\n", "notes.txt"), "order is the whole of the rule")
        #expect(ignores("*.txt\n!notes.txt\n*.txt\n", "notes.txt"))
        #expect(ignores("*.txt\n!notes.txt\n", "other.txt"))
    }

    /// "It is not possible to re-include a file if a parent directory of that file is excluded" —
    /// git never descends into an ignored directory, so the rule that would have rescued the file is
    /// never read at all.
    @Test("A negation cannot reach inside an excluded directory")
    func negationCannotEscapeAnExcludedParent() {
        let file = "build/\n!build/keep.txt\n"
        #expect(ignores(file, "build/keep.txt"))
        #expect(ignores(file, "build/deep/keep.txt"))
    }

    /// A directory the file re-includes is not excluded, so its contents are reachable again.
    @Test("A re-included directory lets its contents through")
    func reIncludedDirectory() {
        let file = "lane\n!lane\n"
        #expect(!ignores(file, "lane/card/notes.txt"))
    }

    @Test("A leading bang is escapable")
    func escapedBang() {
        #expect(ignores("\\!important.txt\n", "!important.txt"))
        #expect(!ignores("\\!important.txt\n", "important.txt"))
    }
}

// MARK: - Wildcards

@Suite("Gitignore ▸ wildcards")
struct GitignoreWildcardTests {

    @Test("A star matches any run of characters but never a separator")
    func starDoesNotCrossSeparators() {
        #expect(ignores("*.tmp\n", "scratch.tmp"))
        #expect(ignores("*.tmp\n", "lane/card/scratch.tmp"))
        #expect(ignores("lane/*.tmp\n", "lane/scratch.tmp"))
        #expect(!ignores("lane/*.tmp\n", "lane/card/scratch.tmp"), "one star, one segment")
        #expect(ignores("*\n", "anything"))
    }

    @Test("A star matches nothing at all, at either end")
    func starMatchesEmpty() {
        #expect(ignores("*.tmp\n", ".tmp"))
        #expect(ignores("notes*\n", "notes"))
        #expect(ignores("*notes*\n", "notes"))
    }

    @Test("A question mark is exactly one character, and never a separator")
    func questionMark() {
        #expect(ignores("shot?.png\n", "shot1.png"))
        #expect(!ignores("shot?.png\n", "shot.png"))
        #expect(!ignores("shot?.png\n", "shot10.png"))
        #expect(!ignores("a?b\n", "a/b"))
    }

    @Test("Character classes: sets, ranges, negation, and a literal bracket")
    func characterClasses() {
        #expect(ignores("shot[0-9].png\n", "shot7.png"))
        #expect(!ignores("shot[0-9].png\n", "shotX.png"))
        #expect(ignores("shot[abc].png\n", "shotb.png"))
        #expect(!ignores("shot[!abc].png\n", "shotb.png"))
        #expect(ignores("shot[!abc].png\n", "shotz.png"))
        #expect(ignores("shot[^abc].png\n", "shotz.png"), "^ negates too")
        // A `]` first in the group is a literal member, not the terminator.
        #expect(ignores("weird[]].txt\n", "weird].txt"))
        // An unterminated group is a literal bracket — the reading that cannot lose a character.
        #expect(ignores("draft[1.txt\n", "draft[1.txt"))
    }

    @Test("Escapes make a wildcard literal")
    func escapedWildcards() {
        #expect(ignores("star\\*.txt\n", "star*.txt"))
        #expect(!ignores("star\\*.txt\n", "starry.txt"))
    }
}

// MARK: - Globstar

@Suite("Gitignore ▸ ** segments")
struct GitignoreGlobstarTests {

    @Test("A leading **/ matches at any depth")
    func leadingGlobstar() {
        let file = "**/notes.txt\n"
        #expect(ignores(file, "notes.txt"))
        #expect(ignores(file, "lane/notes.txt"))
        #expect(ignores(file, "lane/card/notes.txt"))
    }

    /// "A trailing `/**` matches everything inside" — everything *inside*, so the directory itself is
    /// not what this pattern is about.
    @Test("A trailing /** matches everything inside, not the folder itself")
    func trailingGlobstar() {
        let file = "lane/**\n"
        #expect(ignores(file, "lane/card"))
        #expect(ignores(file, "lane/card/notes.txt"))
        #expect(!ignores(file, "lane", isDirectory: true))
    }

    /// "`a/**/b` matches `a/b`, `a/x/b`, `a/x/y/b`" — zero or more segments, verbatim.
    @Test("A middle /**/ spans zero or more directories")
    func middleGlobstar() {
        let file = "a/**/b\n"
        #expect(ignores(file, "a/b"))
        #expect(ignores(file, "a/x/b"))
        #expect(ignores(file, "a/x/y/b"))
        #expect(!ignores(file, "b"))
        #expect(!ignores(file, "x/a/b"))
    }

    /// "Other consecutive asterisks are considered regular asterisks" — inside a segment, `**` is
    /// just `*`, so it still cannot cross a separator.
    @Test("Asterisks inside a segment are ordinary stars")
    func consecutiveStarsInsideASegment() {
        #expect(ignores("a**b\n", "axxb"))
        #expect(!ignores("a**b\n", "a/x/b"))
    }
}

// MARK: - The seed

@Suite("Gitignore ▸ the seed the app writes")
struct GitignoreSeedSemanticsTests {

    private let seed = GitignoreRules(parsing: BoardWriter.gitignoreSeed)

    @Test("The seed is exactly the two lines the ruling names")
    func theSeedText() {
        // 06-history-undo.md ▸ Repository hygiene: "`.DS_Store` plus the writer's temp pattern
        // (`.*.lanework-*`)". Files the app creates end with LF.
        #expect(BoardWriter.gitignoreSeed == ".DS_Store\n.*.lanework-*\n")
    }

    @Test("It covers the Finder's litter at every level")
    func finderLitter() {
        #expect(seed.isIgnored(relativePath: ".DS_Store"))
        #expect(seed.isIgnored(relativePath: "\(Ident.lane1)/.DS_Store"))
        #expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/.DS_Store"))
    }

    /// The second line and `BoardWriter.atomicReplace`'s temp name are one fact spelled twice, so
    /// this asserts against a name the Writer's own rule produces rather than a hand-written one.
    @Test("It covers a crashed write's residue")
    func writerTempResidue() {
        let residue = ".index.md.lanework-\(UUID().uuidString)"
        #expect(seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/\(residue)"))
        #expect(seed.isIgnored(relativePath: ".\(IntegrityRules.gitignoreFileName).lanework-\(UUID().uuidString)"))
    }

    @Test("And nothing else — a card's real files are not noise")
    func nothingElse() {
        #expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/notes.txt"))
        #expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/index.md"))
        #expect(!seed.isIgnored(relativePath: "\(Ident.lane1)/\(Ident.card1)/DS_Store.txt"))
        #expect(!seed.isIgnored(relativePath: "CLAUDE.md"))
    }
}

// MARK: - The documented divergences

@Suite("Gitignore ▸ the deliberate divergences")
struct GitignoreDivergenceTests {

    /// Matching is case-sensitive, always: `core.ignorecase` is a repository setting on a file this
    /// app reads on boards that have no repository, and folding a *pattern* would silently widen
    /// what the user wrote.
    @Test("Patterns are case-sensitive")
    func caseSensitive() {
        #expect(ignores("*.tmp\n", "scratch.tmp"))
        #expect(!ignores("*.tmp\n", "scratch.TMP"))
        #expect(!ignores(".DS_Store\n", ".ds_store"))
    }

    /// POSIX bracket expressions are not a grammar this matcher has: `[[:digit:]]` reads as the
    /// ordinary group `[` `[:digt]` — the characters between the brackets — followed by a literal
    /// `]`, so it matches `shot:].png` rather than `shot7.png`. Nothing realistic in a board's noise
    /// file writes one, and a second character-class grammar to hold them would be exactly the
    /// over-engineering the type exists to avoid.
    @Test("POSIX bracket expressions are read as ordinary classes")
    func posixClasses() {
        #expect(ignores("shot[[:digit:]].png\n", "shot:].png"))
        #expect(!ignores("shot[[:digit:]].png\n", "shot7.png"))
    }
}
