import Foundation
import Testing
@testable import Kanban

// The Quick Look preview's reading of a board (`BoardOutline`, Kanban/Storage/BoardOutline.swift):
// what a `.kanban` package says about itself when Space is pressed on it in Finder. The extension
// shell around it — `QLPreviewProvider`, the HTML page, the symbol PNGs — is untested by design and
// by nature: it is a bundle the system loads out of process, and everything in it that could be
// wrong about a *board* is decided here.
//
// The walk is deliberately total (it never throws), so these tests assert the degrade as hard as they
// assert the happy path: a folder that is not a board, a lane whose YAML is broken, a card with no
// title, and every cap.

// MARK: - Fixture builders

/// A synthetic board tree under a temp directory — `BoardLoaderTests`' builder, one suite over,
/// carrying only the two shapes this walk can see (an `index.md`, and a folder without one).
private struct OutlineFixture {
    let root: URL

    init(named name: String = "Board.kanban") throws {
        root = FileManager.default.temporaryDirectory
            .appendingPathComponent("BoardOutlineTests-\(UUID().uuidString)", isDirectory: true)
            .appendingPathComponent(name, isDirectory: true)
        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
    }

    func tearDown() {
        try? FileManager.default.removeItem(at: root.deletingLastPathComponent())
    }

    /// Writes `index.md` at `relativePath` (`""` for the board root), with `frontmatter` between the
    /// delimiters. `frontmatter` must end in a newline, exactly as the loader's own builder requires.
    @discardableResult
    func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
        let folder = relativePath.isEmpty
            ? root
            : root.appendingPathComponent(relativePath, isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        try "---\n\(frontmatter)---\n\(body)"
            .write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
        return folder
    }

    /// A folder with no `index.md` — the interrupted-create shape, which is not a lane and not a card.
    @discardableResult
    func emptyFolder(_ relativePath: String) throws -> URL {
        let folder = root.appendingPathComponent(relativePath, isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        return folder
    }

    func file(_ relativePath: String, contents: String) throws {
        let url = root.appendingPathComponent(relativePath)
        try FileManager.default.createDirectory(
            at: url.deletingLastPathComponent(), withIntermediateDirectories: true
        )
        try contents.write(to: url, atomically: true, encoding: .utf8)
    }
}

/// A UUID-shaped folder name whose *lexicographic* order is the test's to choose — the walk's
/// tie-break is the folder name, so a test about ordering needs names it controls.
private func identity(_ nibble: String) -> String {
    "\(nibble)0000000-0000-4000-8000-000000000000"
}

/// The `Fixtures/` folder reference in the test bundle — `FixtureBoardTests`' resolution, restated
/// here because that file keeps its own private (there is no `Bundle.module` in an xcodeproj target).
private final class OutlineFixtureBundleAnchor {}

private func fixtureBoard(_ relativePath: String) -> URL {
    guard let resources = Bundle(for: OutlineFixtureBundleAnchor.self).resourceURL else {
        fatalError("test bundle has no resourceURL")
    }
    return resources
        .appendingPathComponent("Fixtures", isDirectory: true)
        .appendingPathComponent(relativePath, isDirectory: true)
}

// MARK: - The golden board

/// `Fixtures/Valid/rich-board.kanban` is the disk-backed case: a real tree, hand-authored, with two
/// lanes, three cards, styling at every level and unknown keys throughout. If the preview reads this
/// board correctly it reads the format correctly.
struct BoardOutlineFixtureTests {

    @Test func readsTheRichBoardsNameStylingAndLanes() {
        let summary = BoardOutline.read(boardRoot: fixtureBoard("Valid/rich-board.kanban"))

        #expect(summary.title == "Rich Demo Board")
        #expect(summary.background == "#1E1E1E")
        #expect(summary.icon == "rectangle.stack.fill")
        #expect(summary.iconColor == "purple")
        #expect(summary.laneCount == 2)
        #expect(summary.laneCountIsCapped == false)
        #expect(summary.hiddenLaneCount == 0)

        // Lane display order is `order` ascending — Doing (1024) then Done (2048).
        #expect(summary.lanes.map(\.title) == ["Doing", "Done"])
        #expect(summary.lanes.map(\.cardCount) == [2, 1])
        #expect(summary.lanes.map(\.cardCountIsCapped) == [false, false])
        #expect(summary.lanes.map(\.background) == ["#3478F6", "green"])

        // Cards in display order within each lane, and `attachments/`/`comments/` beside one of them
        // are not cards: they are not identity-shaped, so the count stays at two.
        #expect(summary.lanes[0].cardTitles == [
            "Design the fixture taxonomy", "Wire up the loader's stray tolerance",
        ])
        #expect(summary.lanes[1].cardTitles == ["Ship v1"])
        #expect(summary.lanes.map(\.hiddenCardCount) == [0, 0])
    }
}

// MARK: - The board's own name

struct BoardOutlineTitleTests {

    @Test func fallsBackToTheFolderNameWithoutTheExtension() throws {
        let fixture = try OutlineFixture(named: "Quarterly Plan.kanban")
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\n")

        // **A board never reads "Untitled"** — `AppModel.displayName(of:)`'s rule, one layer down:
        // a board always has a folder, and the folder name is the Finder document name.
        #expect(BoardOutline.read(boardRoot: fixture.root).title == "Quarterly Plan")
    }

    @Test func aBlankTitleIsAnAbsentOne() throws {
        let fixture = try OutlineFixture(named: "Roadmap.kanban")
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\ntitle: \"   \"\n")

        #expect(BoardOutline.read(boardRoot: fixture.root).title == "Roadmap")
    }

    @Test func aFolderThatIsNotABoardStillPreviewsAsItself() throws {
        let fixture = try OutlineFixture(named: "Not A Board.kanban")
        defer { fixture.tearDown() }
        try fixture.file("notes.txt", contents: "nothing to see")

        // No `index.md` at all: the walk cannot throw, so the floor is the folder's own name and no
        // lanes — a truthful preview of a folder that is not a board.
        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.title == "Not A Board")
        #expect(summary.lanes.isEmpty)
        #expect(summary.laneCount == 0)
    }

    @Test func aRootThatDoesNotExistIsNotAnError() {
        let missing = FileManager.default.temporaryDirectory
            .appendingPathComponent("BoardOutlineTests-absent-\(UUID().uuidString).kanban")

        let summary = BoardOutline.read(boardRoot: missing)
        #expect(summary.title.hasPrefix("BoardOutlineTests-absent-"))
        #expect(summary.lanes.isEmpty)
    }

    @Test func anUnreadableRootIndexFallsBackWithoutLosingTheLanes() throws {
        let fixture = try OutlineFixture(named: "Broken Root.kanban")
        defer { fixture.tearDown() }
        // Not UTF-8: `BoardLoader` fails the whole board on this (01-storage-format.md § Malformed
        // input). Here it costs the board's own fields and nothing else — the lanes are enumerated by
        // folder shape and read their own files, so nothing below the root depends on it.
        try Data([0xFF, 0xFE, 0x00]).write(to: fixture.root.appendingPathComponent("index.md"))
        try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n")

        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.title == "Broken Root")
        #expect(summary.background == nil)
        #expect(summary.lanes.map(\.title) == ["Doing"])
    }
}

// MARK: - Untitled lanes and cards

struct BoardOutlineUntitledTests {

    @Test func anUntitledLaneOrCardCarriesNilRatherThanAPlaceholder() throws {
        let fixture = try OutlineFixture()
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\ntitle: Board\n")
        let lane = identity("a")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(identity("2"))", "schema: 1\norder: 2048\ntitle: \"\"\n")

        // **"Untitled" is a rendering, never a value** (03-board-ui.md § Card face): the model says
        // nothing, and the page is where the placeholder appears. A blank title reads the same as an
        // absent one.
        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.lanes.count == 1)
        #expect(summary.lanes[0].title == nil)
        #expect(summary.lanes[0].cardTitles == [nil, nil])
    }

    @Test func aLaneWithBrokenFrontmatterIsKeptRatherThanDropped() throws {
        let fixture = try OutlineFixture()
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\ntitle: Board\n")
        try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n")
        // Not UTF-8 — the strict decode refuses it, exactly as `BoardLoader.parseDocument` does.
        let broken = try fixture.emptyFolder(identity("b"))
        try Data([0xFF, 0xFE, 0x00]).write(to: broken.appendingPathComponent("index.md"))
        try fixture.index("\(identity("b"))/\(identity("1"))", "schema: 1\ntitle: Orphan\n")

        // The unreadable lane is order-less, so it appends past every ranked sibling — and its cards
        // are still counted, because a preview that silently dropped a lane would disagree with
        // Finder about what is in the folder.
        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.laneCount == 2)
        #expect(summary.lanes.map(\.title) == ["Doing", nil])
        #expect(summary.lanes[1].cardCount == 1)
        #expect(summary.lanes[1].cardTitles == ["Orphan"])
    }
}

// MARK: - What counts as a lane or a card

struct BoardOutlineCandidateTests {

    @Test func straysAreNeitherLanesNorCards() throws {
        let fixture = try OutlineFixture()
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\ntitle: Board\n")
        let lane = identity("a")
        try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n")
        try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\ntitle: Real\n")

        // Four shapes that are not levels, each for the reason the loader states:
        try fixture.index("notes", "schema: 1\ntitle: Not a lane\n")        // not identity-shaped
        try fixture.emptyFolder(identity("b"))                              // no index.md
        try fixture.emptyFolder("\(lane)/\(identity("2"))")                 // no index.md
        try fixture.file("\(lane)/stray.md", contents: "loose")             // a file, not a folder
        try fixture.index(".trash/\(identity("9"))", "schema: 1\ntitle: Gone\n")  // hidden

        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.laneCount == 1)
        #expect(summary.lanes[0].cardCount == 1)
        #expect(summary.lanes[0].cardTitles == ["Real"])
    }
}

// MARK: - Ordering

struct BoardOutlineOrderingTests {

    @Test func lanesAndCardsFollowTheBoardsOwnDisplayOrder() throws {
        let fixture = try OutlineFixture()
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\ntitle: Board\n")
        // Folder names ascend a, b, c while `order` descends — so a walk that trusted the listing
        // rather than the rank would read backwards.
        try fixture.index(identity("a"), "schema: 1\norder: 3000\ntitle: Third\n")
        try fixture.index(identity("b"), "schema: 1\norder: 2000\ntitle: Second\n")
        try fixture.index(identity("c"), "schema: 1\norder: 1000\ntitle: First\n")
        try fixture.index("\(identity("c"))/\(identity("1"))", "schema: 1\norder: 20\ntitle: B\n")
        try fixture.index("\(identity("c"))/\(identity("2"))", "schema: 1\norder: 10\ntitle: A\n")

        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.lanes.map(\.title) == ["First", "Second", "Third"])
        #expect(summary.lanes[0].cardTitles == ["A", "B"])
    }

    @Test func anOrderlessSiblingAppendsAtTheEndByFolderName() throws {
        let fixture = try OutlineFixture()
        defer { fixture.tearDown() }
        try fixture.index("", "schema: 1\ntitle: Board\n")
        let lane = identity("a")
        try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n")
        try fixture.index("\(lane)/\(identity("c"))", "schema: 1\ntitle: NoOrderC\n")
        try fixture.index("\(lane)/\(identity("b"))", "schema: 1\ntitle: NoOrderB\n")
        try fixture.index("\(lane)/\(identity("a"))", "schema: 1\norder: 5000\ntitle: Ranked\n")

        // `Ranks.resolvedOrders`' reading: past every ordered sibling, then by folder name
        // (01-storage-format.md § Ordering, re-ruled 2026-07-31).
        let summary = BoardOutline.read(boardRoot: fixture.root)
        #expect(summary.lanes[0].cardTitles == ["Ranked", "NoOrderB", "NoOrderC"])
    }
}

// MARK: - The caps

struct BoardOutlineLimitTests {

    /// A board of `lanes` lanes, each holding `cards` cards, all ranked so display order is the
    /// order they were made in.
    private func board(lanes: Int, cards: Int) throws -> OutlineFixture {
        let fixture = try OutlineFixture()
        try fixture.index("", "schema: 1\ntitle: Board\n")
        for lane in 0..<lanes {
            let name = String(format: "%08x-0000-4000-8000-000000000000", lane)
            try fixture.index(name, "schema: 1\norder: \(1024 * (lane + 1))\ntitle: Lane \(lane)\n")
            for card in 0..<cards {
                let cardName = String(format: "%08x-0000-4000-8000-%012x", lane, card)
                try fixture.index(
                    "\(name)/\(cardName)", "schema: 1\norder: \(10 * (card + 1))\ntitle: Card \(card)\n"
                )
            }
        }
        return fixture
    }

    @Test func cardTitlesStopAtTheCapAndTheRestAreCounted() throws {
        let fixture = try board(lanes: 1, cards: 10)
        defer { fixture.tearDown() }

        let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 3, cardScan: 200, cardCount: 2000)
        let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
        #expect(lane.cardTitles == ["Card 0", "Card 1", "Card 2"])
        #expect(lane.cardCount == 10)
        #expect(lane.cardCountIsCapped == false)
        #expect(lane.hiddenCardCount == 7)
    }

    @Test func lanesStopAtTheCapAndTheRestAreCounted() throws {
        let fixture = try board(lanes: 5, cards: 1)
        defer { fixture.tearDown() }

        let limits = BoardOutlineLimits(lanes: 2, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000)
        let summary = BoardOutline.read(boardRoot: fixture.root, limits: limits)
        #expect(summary.lanes.map(\.title) == ["Lane 0", "Lane 1"])
        #expect(summary.laneCount == 5)
        #expect(summary.laneCountIsCapped == false)
        #expect(summary.hiddenLaneCount == 3)
    }

    @Test func theCardCountStopsCountingAndSaysSo() throws {
        let fixture = try board(lanes: 1, cards: 5)
        defer { fixture.tearDown() }

        let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2)
        let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
        // "2+", not "5" and not "2": the walk stopped, and the flag is how the page avoids stating a
        // number it would be lying about.
        #expect(lane.cardCount == 2)
        #expect(lane.cardCountIsCapped)
    }

    @Test func theLaneCountStopsCountingAndSaysSo() throws {
        let fixture = try board(lanes: 4, cards: 1)
        defer { fixture.tearDown() }

        let limits = BoardOutlineLimits(lanes: 12, laneScan: 2, cardTitles: 6, cardScan: 200, cardCount: 2000)
        let summary = BoardOutline.read(boardRoot: fixture.root, limits: limits)
        #expect(summary.laneCount == 2)
        #expect(summary.laneCountIsCapped)
        #expect(summary.lanes.count == 2)
    }

    @Test func theScanCapKeepsTheCountExactWhileNarrowingTheTitles() throws {
        let fixture = try board(lanes: 1, cards: 6)
        defer { fixture.tearDown() }

        let limits = BoardOutlineLimits(lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 2, cardCount: 2000)
        let lane = BoardOutline.read(boardRoot: fixture.root, limits: limits).lanes[0]
        // The stated cost of `cardScan` (BoardOutlineLimits ▸ cardScan): the titles come from the
        // first two cards **in folder-name order** rather than from the lane's true top, while the
        // count stays exact and `hiddenCardCount` still adds up.
        #expect(lane.cardCount == 6)
        #expect(lane.cardCountIsCapped == false)
        #expect(lane.cardTitles == ["Card 0", "Card 1"])
        #expect(lane.hiddenCardCount == 4)
    }

    @Test func anEmptyLaneShowsNoTitlesAndHidesNothing() throws {
        let fixture = try board(lanes: 1, cards: 0)
        defer { fixture.tearDown() }

        let lane = BoardOutline.read(boardRoot: fixture.root).lanes[0]
        #expect(lane.cardCount == 0)
        #expect(lane.cardTitles.isEmpty)
        #expect(lane.hiddenCardCount == 0)
    }

    @Test func theShippedLimitsAreTheOnesThePreviewUses() {
        // Stated as a test because they are a product decision, not an implementation detail: the
        // preview shows twelve lanes and six titles each, and nothing else in the repo says so.
        #expect(BoardOutlineLimits.preview.lanes == 12)
        #expect(BoardOutlineLimits.preview.cardTitles == 6)
        #expect(BoardOutlineLimits.preview.laneScan == 100)
        #expect(BoardOutlineLimits.preview.cardScan == 200)
        #expect(BoardOutlineLimits.preview.cardCount == 2000)
    }
}
