import Foundation
import Testing
@testable import Kanban

/// The launch decision and the audit suite's fixture board (10-accessibility.md ▸ Verification).
///
/// Two halves, tested for two different reasons. `LaunchPlan.decide` and the flag predicate are
/// **pure**, so they are pinned here the way every other launch-time rule in this app is — without a
/// `UserDefaults` domain, a live registry, or a running app (`AppModel.shouldRestoreAtLaunch`'s own
/// argument, which this composes).
///
/// The fixture board is tested here for a blunter reason: **the UI suite that consumes it cannot be
/// run in every environment** (it needs Accessibility automation permission and an unlocked
/// display), and a fixture that quietly failed to build would turn every audit into a pass over an
/// empty screen. Loading it back through the ordinary `BoardLoader` is the one check that runs
/// everywhere and would catch that.

// MARK: - The launch plan

@Suite("The launch plan")
struct LaunchPlanTests {

    /// The three-way decision, exhaustively — the two-way gate `AppModel.shouldRestoreAtLaunch`
    /// already owns, plus the fixture's outright precedence over both halves of it.
    ///
    /// The precedence matters more than it looks: a UI-test launch that also restored the
    /// developer's flagged boards would open real documents, run real watchers over them, and stamp
    /// real registry records — during a test run whose whole premise is that nothing outside the
    /// scratch directory is touched.
    @Test(
        "The fixture wins outright; otherwise the restore gate decides",
        arguments: [
            (fixture: true, preference: true, restorables: true, expected: LaunchPlan.uiTestFixture),
            (fixture: true, preference: false, restorables: false, expected: LaunchPlan.uiTestFixture),
            (fixture: false, preference: true, restorables: true, expected: LaunchPlan.restoreBoards),
            (fixture: false, preference: true, restorables: false, expected: LaunchPlan.welcome),
            (fixture: false, preference: false, restorables: true, expected: LaunchPlan.welcome),
            (fixture: false, preference: false, restorables: false, expected: LaunchPlan.welcome),
        ]
    )
    func decision(fixture: Bool, preference: Bool, restorables: Bool, expected: LaunchPlan) {
        #expect(
            LaunchPlan.decide(
                isUITestFixtureLaunch: fixture,
                restorePreference: preference,
                hasRestorables: restorables
            ) == expected
        )
    }

}

// MARK: - The flag

@Suite("The UI-test fixture flag")
struct UITestLaunchFlagTests {

    @Test("An exact occurrence anywhere in the argument list asks for the fixture")
    func flagRecognized() {
        #expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework", UITestLaunch.fixtureFlag]))
        #expect(UITestLaunch.isFixtureLaunch(arguments: [UITestLaunch.fixtureFlag, "-NSTreatUnknownArgumentsAsOpen", "NO"]))
    }

    /// An ordinary launch — including the one XCUITest performs with no arguments of its own — is
    /// never a fixture launch.
    @Test("An absent flag is an ordinary launch")
    func flagAbsent() {
        #expect(UITestLaunch.isFixtureLaunch(arguments: []) == false)
        #expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework"]) == false)
    }

    /// **Exact match, not a prefix.** A launch switch with a fuzzy edge is a launch switch that can
    /// be tripped by accident, and this one redirects the registry — the one place an accident would
    /// look like the user's recents list having been wiped.
    @Test("A near-miss is not the flag")
    func flagNotMatchedLoosely() {
        #expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)s"]) == false)
        #expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)=basic"]) == false)
        #expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-board"]) == false)
    }

    /// The flag is double-dashed so `UserDefaults`' `NSArgumentDomain` — which reads `-key value`
    /// pairs — never sees it as a preference. Stated as a test because the consequence of getting it
    /// wrong is invisible: the app would work, and a stray defaults key would appear.
    @Test("The flag cannot be read as an argument-domain preference key")
    func flagIsNotAPreferenceKey() {
        #expect(UITestLaunch.fixtureFlag.hasPrefix("--"))
    }
}

// MARK: - The variant flags

/// The three fixture shapes and the arguments that name them (`UITestLaunch.FixtureVariant`).
///
/// The parsing is the flag's, restated one level down — exact match, double dash, no `=value` and no
/// `--flag value` pair — so the whole family has one edge rather than two, and the tie-break exists
/// so a launch naming two variants is a decided case rather than an argument-order accident.
@Suite("The UI-test fixture variants")
struct UITestFixtureVariantTests {

    @Test("A bare fixture flag is the standard board", arguments: [
        ["--ui-test-fixture-board"],
        ["/path/to/Lanework", "--ui-test-fixture-board", "-NSTreatUnknownArgumentsAsOpen", "NO"],
        [],
    ])
    func standardIsTheDefault(arguments: [String]) {
        #expect(UITestLaunch.variant(arguments: arguments) == .standard)
    }

    @Test("A variant flag names its variant", arguments: UITestLaunch.FixtureVariant.allCases)
    func variantRecognized(variant: UITestLaunch.FixtureVariant) {
        #expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, variant.flag]) == variant)
        // The pairing every call site uses is base-flag-plus-variant, but the variant alone is
        // enough to mark the launch synthetic — otherwise a bundle that forgot the base flag would
        // get an ordinary launch over the developer's real boards.
        #expect(UITestLaunch.isFixtureLaunch(arguments: [variant.flag]))
    }

    /// The same fuzzy-edge rule the base flag has, applied to the family: a near-miss is not a flag,
    /// and a near-miss is therefore not a fixture launch either.
    @Test("A near-miss is not a variant flag")
    func variantNotMatchedLoosely() {
        let large = UITestLaunch.FixtureVariant.large
        #expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "\(large.flag)r"]) == .standard)
        #expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-board=large"]) == .standard)
        #expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag, "--ui-test-fixture-variant", "large"]) == .standard)
        #expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-large"]) == false)
    }

    /// Declaration order breaks a tie, whatever order the arguments arrived in.
    @Test("Two variants named at once resolve in declaration order")
    func variantTieBreak() {
        let flags = [UITestLaunch.FixtureVariant.malformed.flag, UITestLaunch.FixtureVariant.large.flag]
        #expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags) == .large)
        #expect(UITestLaunch.variant(arguments: [UITestLaunch.fixtureFlag] + flags.reversed()) == .large)
    }

    /// Every variant flag is double-dashed, for the base flag's reason, and every one of them is
    /// distinct from the base flag and from its siblings — the window titles below rest on the same
    /// distinctness, so a duplicate would be two boards claiming one name.
    @Test("The flags and titles are double-dashed and distinct")
    func flagsAreWellFormed() {
        let variants = UITestLaunch.FixtureVariant.allCases
        #expect(variants.allSatisfy { $0.flag.hasPrefix("--") })
        #expect(Set(variants.map(\.flag)).count == variants.count)
        #expect(variants.allSatisfy { $0.flag != UITestLaunch.fixtureFlag })
        #expect(Set(variants.map(\.boardTitle)).count == variants.count)
        // The standard variant's title is the one the audit suite has always waited on.
        #expect(UITestLaunch.FixtureVariant.standard.boardTitle == UITestLaunch.boardTitle)
        #expect(UITestLaunch.fixtureBoardURL == UITestLaunch.fixtureBoardURL(for: .standard))
    }
}

// MARK: - The fixture board

@Suite("The audit fixture board")
struct UITestFixtureBoardTests {

    /// Builds the fixture exactly as a UI-test launch does, then reads it back through the ordinary
    /// loader — the app's own answer to "is this a board", so the assertion is the same one the
    /// board window would make.
    ///
    /// The scratch directory is prepared first (which is what wipes any previous run's board) and
    /// removed afterwards, so this test leaves the container as it found it.
    @Test("It builds, loads, and has the shape the audit suite navigates")
    func fixtureLoads() throws {
        UITestLaunch.prepareScratchDirectory()
        defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }

        let root = try UITestLaunch.materializeFixtureBoard()
        let model = try BoardLoader.load(boardRoot: root).model

        // The window title the UI suite waits on.
        #expect(model.title.value == UITestLaunch.boardTitle)
        #expect(root.lastPathComponent == "\(UITestLaunch.boardTitle).kanban")

        // Three lanes, in the order the fixture names them — which is also the order VoiceOver reads
        // them in (10-accessibility.md ▸ Logical order), so a fixture whose lanes came out shuffled
        // would make the traversal-order check meaningless.
        #expect(model.lanes.map(\.title.value) == UITestLaunch.laneTitles)

        // The first lane is the crowded one, minus the card the fixture deleted — the masonry
        // divergence the audit is most interested in needs more than one card to diverge.
        #expect(model.lanes[0].cards.count == UITestLaunch.cardTitles[0].count - 1)
        #expect(model.lanes[0].cards.allSatisfy { $0.title.value != UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card] })

        // The trashed card is in the trash container and nowhere else — cards only, no lane entries
        // (03-board-ui.md § Trash).
        #expect(model.trash.count == 1)
        #expect(model.trash.first?.title.value == UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card])

        // The rich card: the one the card-window audits open. Its window title is what the UI suite
        // waits on, its body is what Preview renders into the tree, and its attachment is what the
        // card element's value and the sidebar's row are made of.
        let richLane = model.lanes[UITestLaunch.richCardIndex.lane]
        let richCard = try #require(richLane.cards.first)
        #expect(richCard.title.value == UITestLaunch.cardTitles[UITestLaunch.richCardIndex.lane][UITestLaunch.richCardIndex.card])
        #expect(richCard.body.contains("## What this card is for"))
        #expect(richCard.attachments == [UITestLaunch.attachmentName])
    }

    /// The rich card's **comment thread** — what the comments-pane audit is an audit *of*
    /// (10-accessibility.md ▸ Comments).
    ///
    /// The three shapes are the point: an ordinary comment, an unattributed one, and an edited one.
    /// Each drives a different branch of the author line a flattened comment element wears
    /// (`CommentAuthorLine`), and a fixture that quietly lost one of them would leave the audit
    /// passing over a thread of three identical rows.
    @Test("The rich card carries a thread the pane's audit can read")
    func fixtureSeedsAThread() throws {
        UITestLaunch.prepareScratchDirectory()
        defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }

        let root = try UITestLaunch.materializeFixtureBoard()
        let model = try BoardLoader.load(boardRoot: root).model
        let lane = model.lanes[UITestLaunch.richCardIndex.lane]
        let card = try #require(lane.cards.first)
        let cardFolder = ItemPath.card(lane: lane.id, id: card.id).folder(under: root)

        let thread = CommentThread.load(inCard: cardFolder, path: "\(lane.id.rawValue)/\(card.id.rawValue)")
        #expect(thread.comments.count == UITestLaunch.commentBodies.count)
        #expect(thread.strays.isEmpty)
        // Posting restamps `created`, so the thread's chronological order is the order they were made.
        #expect(thread.comments.map(\.body) != [])

        let unattributed = try #require(
            thread.comments.first { $0.body.contains("no `author` key") }
        )
        #expect(unattributed.author.value == nil)
        #expect(!unattributed.isEdited)

        let edited = try #require(thread.comments.first { $0.body == UITestLaunch.editedCommentBody })
        #expect(edited.isEdited, "the edited marker is `modified` differing from `created`, and no extra field")
        #expect(edited.author.value != nil)

        // The board walk never loads any of it — the pane reads its own thread (01 ▸ Enhanced schema).
        #expect(!card.body.contains("specimen thread"))
    }

    /// Everything the fixture launch writes stays inside the app's own container — the sandbox
    /// constraint that decided the whole design (a path handed over on the command line would not be
    /// readable), stated as a test so a future "just use `/tmp`" cannot land quietly.
    ///
    /// Every variant's board, not just the audit's: they share one scratch root by construction, and
    /// this is the assertion that keeps a future variant from inventing a second home.
    @Test("Everything it writes is inside the app container")
    func scratchIsContained() {
        let container = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).standardizedFileURL.path
        #expect(UITestLaunch.scratchRoot.standardizedFileURL.path.hasPrefix(container))
        #expect(UITestLaunch.registryStorageURL.standardizedFileURL.path.hasPrefix(container))
        for variant in UITestLaunch.FixtureVariant.allCases {
            #expect(UITestLaunch.fixtureBoardURL(for: variant).standardizedFileURL.path.hasPrefix(container))
        }
    }

    /// The fixture registry is **not** the real one — the clause that keeps an audit run out of the
    /// user's recents list.
    @Test("The fixture registry is not the app's real registry")
    @MainActor
    func registryIsRedirected() {
        #expect(UITestLaunch.registryStorageURL != BoardRegistry.defaultStorageURL)
    }
}

// MARK: - The large board

/// The performance suite's board (`UITestLaunch.FixtureVariant.large`).
///
/// Tested here for the audit fixture's reason turned up a notch: **the suite that consumes it cannot
/// be run in every environment**, and a large board that quietly came out small would turn a
/// performance measurement into a measurement of something else — one that *passes*, since a smaller
/// board is a faster one. So the counts are asserted, through the ordinary loader, where they can be
/// checked anywhere.
@Suite("The large fixture board")
struct UITestLargeFixtureBoardTests {

    @Test("It builds at the stated size and loads through the ordinary loader")
    func largeBoardLoads() throws {
        UITestLaunch.prepareScratchDirectory()
        defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }

        let root = try UITestLaunch.materializeFixtureBoard(.large)
        let result = try BoardLoader.load(boardRoot: root)
        let model = result.model

        // The window title the performance suite waits on.
        #expect(model.title.value == UITestLaunch.FixtureVariant.large.boardTitle)
        #expect(root == UITestLaunch.fixtureBoardURL(for: .large))

        // The size the budgets in `EndToEndVerification.md` are budgets *for*. A board that came out
        // a different size makes every one of them a number about a different board.
        #expect(model.lanes.count == UITestLaunch.largeLaneCount)
        #expect(model.lanes.allSatisfy { $0.cards.count == UITestLaunch.largeCardsPerLane })
        #expect(model.lanes.map(\.cards.count).reduce(0, +) == UITestLaunch.largeLaneCount * UITestLaunch.largeCardsPerLane)

        // Lanes in board order, exactly as named — the same claim the audit fixture makes, and for
        // the same reason: a shuffled board would make a lane-addressed assertion meaningless.
        #expect(model.lanes.map(\.title.value) == (0 ..< UITestLaunch.largeLaneCount).map(UITestLaunch.largeLaneTitle))

        // Cards in card order within each lane, and every title distinct across the whole board —
        // which is what lets a UI test name one card and mean one card.
        for (laneIndex, lane) in model.lanes.enumerated() {
            let expected = (0 ..< UITestLaunch.largeCardsPerLane).map {
                UITestLaunch.largeCardTitle(lane: laneIndex, card: $0)
            }
            #expect(lane.cards.map(\.title.value) == expected)
        }
        let titles = model.lanes.flatMap { $0.cards.compactMap(\.title.value) }
        #expect(Set(titles).count == titles.count)

        // Four title lengths, cycled — the masonry has different card heights to balance rather than
        // a perfect grid, which is the one thing about this board that is not simply "a lot of it".
        let firstLane = try #require(model.lanes.first)
        #expect(Set(firstLane.cards.compactMap(\.title.value).prefix(4).map(\.count)).count == 4)

        // Nothing tolerated-but-notable: this board is built through the Writer alone, so a warning
        // here would mean the *builder* left a stray behind.
        #expect(result.warnings.isEmpty)
        #expect(model.trash.isEmpty)
    }
}

// MARK: - The malformed board

/// The fail-fast suite's board (`UITestLaunch.FixtureVariant.malformed`) — and the two claims the UI
/// suite makes about it, pinned where they can be checked without a display.
///
/// 01-storage-format.md § Malformed input is the rule under test: a structurally broken `index.md`
/// rejects **the whole load**, loudly, naming the file — and the app never rewrites what it could not
/// read (the Repair precedent, which `BoardLoader`'s own note states as "a load is a pure function of
/// the tree and writes nothing, ever").
@Suite("The malformed fixture board")
struct UITestMalformedFixtureBoardTests {

    @Test("It builds, and then fails to load — loudly, naming the offending file")
    func malformedBoardFailsFast() throws {
        UITestLaunch.prepareScratchDirectory()
        defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }

        // Building succeeds. That is the point: the failure under test is the *loader's*, so a
        // fixture that threw on the way in would surface a different sentence entirely.
        let root = try UITestLaunch.materializeFixtureBoard(.malformed)
        #expect(root == UITestLaunch.fixtureBoardURL(for: .malformed))

        do {
            _ = try BoardLoader.load(boardRoot: root)
            Issue.record("the malformed board loaded — the fail-fast pass would audit a board that opens")
        } catch let failure as BoardLoadFailure {
            // One broken file, so one defect: the fixture breaks a single card's index, and the
            // aggregate is what the welcome row reads its caption off.
            #expect(failure.defects.count == 1)
            let error = failure.primary
            // The path is board-relative and names the *file*, which is what the welcome row's
            // failure caption carries and what the UI test asserts against.
            #expect(error.path.hasSuffix("/\(BoardLoader.indexFileName)"))
            #expect(error.path.split(separator: "/").count == 3, "the offending path names <lane>/<card>/index.md")

            // The reason is the one the bytes were written to produce — unparseable YAML, not a
            // missing field. A future edit to `malformedIndexText` that accidentally produced a
            // *valid* file with a missing key would still fail the load, and this line is what
            // would notice.
            if case .unparseableYAML = error.reason {} else {
                Issue.record("expected unparseable YAML, got \(error.reason)")
            }

            // The whole sentence, which is what actually reaches the user: file first, then why.
            #expect(failure.description.contains(BoardLoader.indexFileName))
            #expect(failure.description.lowercased().contains("yaml"))
        } catch {
            Issue.record("expected a BoardLoadFailure, got \(error)")
        }
    }

    /// **Nothing is silently repaired.** The refused load leaves the malformed bytes exactly as they
    /// were written — no rewrite, no relocation into `.trash/`, no skip-and-continue — and the intact
    /// siblings are untouched too.
    ///
    /// This is the claim the UI suite can only make opportunistically (it can read the app's
    /// container when the runner can reach it), so it is made unconditionally here.
    @Test("A refused load repairs nothing")
    func malformedBoardIsNotRepaired() throws {
        UITestLaunch.prepareScratchDirectory()
        defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) }

        let root = try UITestLaunch.materializeFixtureBoard(.malformed)
        let before = try Self.tree(under: root)

        // Twice, because a repair that only ran on the second attempt would be the worst kind.
        for _ in 0 ..< 2 {
            do {
                _ = try BoardLoader.load(boardRoot: root)
                Issue.record("the malformed board loaded")
            } catch let failure as BoardLoadFailure {
                #expect(failure.primary.path.hasSuffix(BoardLoader.indexFileName))
            } catch {
                Issue.record("expected a BoardLoadFailure, got \(error)")
            }
        }

        #expect(try Self.tree(under: root) == before)

        // And the bytes themselves are the ones the fixture wrote, marker included — the string a UI
        // test searches the container for when it can reach it.
        let malformed = try #require(before.first { $0.value.contains(UITestLaunch.malformationMarker) })
        #expect(malformed.value == UITestLaunch.malformedIndexText)
        #expect(malformed.key.hasSuffix("/\(BoardLoader.indexFileName)"))
    }

    /// Every `index.md` beneath `root`, keyed by its board-relative path, read as raw text. Hidden
    /// entries included, so a relocation into `.trash/` would show up as a new key rather than as a
    /// silence.
    private static func tree(under root: URL) throws -> [String: String] {
        let manager = FileManager.default
        guard let walker = manager.enumerator(atPath: root.path) else { return [:] }
        var files: [String: String] = [:]
        for case let relative as String in walker where relative.hasSuffix(BoardLoader.indexFileName) {
            let data = try Data(contentsOf: root.appendingPathComponent(relative))
            files[relative] = String(decoding: data, as: UTF8.self)
        }
        return files
    }
}
