import XCTest

// MARK: - The fixture boards

/// **The three boards the app can build for a UI test**, mirrored from
/// `UITestLaunch.FixtureVariant`.
///
/// It is a *mirror* and not an import because a UI test bundle does not link the app it drives: the
/// two ends of a launch argument are always a pair of literals, and this is the one place this end of
/// the pair is spelled. The app end is `UITestLaunch.FixtureVariant.flag` / `.boardTitle`; a change
/// to either without the matching change here shows up as a launch that opens welcome and a wait that
/// times out, which is the loudest failure available for a string pair.
enum FixtureBoard {

    /// The audit board — three lanes ("To Do", "Doing", "Done"), six cards, one with a rich Markdown
    /// body and an attachment, one already in `.trash/`. The end-to-end flows use it too: every one
    /// of them asserts on a lane's spoken card count, and a shape small enough to state in a sentence
    /// is a shape those assertions can be read against.
    case standard

    /// 8 lanes × 40 cards, for the performance pass. Nothing in it is interesting; there is simply a
    /// lot of it.
    case large

    /// A well-formed board with exactly one unparseable card `index.md`, for the fail-fast pass. Its
    /// window is the only one that never holds a board: the open is attended, so the refusal
    /// transforms the loading content into the decision surface (`FailFastLaunchTests`).
    case malformed

    /// `UITestLaunch.fixtureFlag` plus this variant's own flag. Both, always: the first is what "this
    /// launch is synthetic" means (it is what redirects the registry into the scratch directory), the
    /// second is which board.
    var launchArguments: [String] {
        switch self {
        case .standard: ["--ui-test-fixture-board", "--ui-test-fixture-standard"]
        case .large: ["--ui-test-fixture-board", "--ui-test-fixture-large"]
        case .malformed: ["--ui-test-fixture-board", "--ui-test-fixture-malformed"]
        }
    }

    /// The board's title, which is also its window title (`BoardWindowHost.windowTitle`) and its
    /// folder name.
    var windowTitle: String {
        switch self {
        case .standard: "Audit Board"
        case .large: "Large Board"
        case .malformed: "Malformed Board"
        }
    }
}

// MARK: - The standard board's shape

/// The audit/end-to-end board as the tests address it: lane titles, card titles, and the labels
/// those produce.
///
/// Mirrored from `UITestLaunch` for `FixtureBoard`'s reason, and stated as *names* rather than
/// scattered string literals so that a test reads as a sentence about a board rather than as a
/// sentence about strings.
enum StandardBoard {

    static let toDo = "To Do"
    static let doing = "Doing"
    static let done = "Done"

    /// The first lane's cards, in card order — minus `trashed`, which the fixture deleted on the way
    /// out, so the lane renders three of these four.
    static let firstLaneCards = [
        "Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit",
    ]

    /// The card the fixture deleted into `.trash/` — the trash column's one occupant at launch.
    static let trashed = "Check the trash grammar"

    /// The sole card of "Doing" — the rich one, and the destination every cross-lane flow moves into.
    static let richCard = "Write the smoke script"

    /// The sole card of "Done".
    static let doneCard = "Wire the launch fixture"
}

// MARK: - Phrases

/// The spoken vocabulary the board's elements wear, mirrored from `AccessibilityPhrases`.
///
/// The board's containers are addressed by these labels and nothing else — a lane's element *is*
/// "⟨title⟩, lane, N cards" — which makes a lane's card count assertable without any identifier of
/// our own. That is deliberate: the count is the one thing every one of these flows changes, and
/// asserting on the label asserts the visible badge, the spoken count and the layout all at once,
/// because 10-accessibility.md makes them one number.
enum Phrase {

    /// "3 cards", "1 card" — the app's one plural folding for a card count.
    static func cards(_ count: Int) -> String {
        "\(count) card\(count == 1 ? "" : "s")"
    }

    /// A lane container's label: "⟨title⟩, lane, N cards".
    static func lane(_ title: String, cards count: Int) -> String {
        "\(title), lane, \(cards(count))"
    }

    /// The trash container's label. Its *value* is `cards(count)`; the label is the bare word.
    static let trash = "Trash"

    /// The untitled placeholder — what a lane created by File ▸ New Lane wears until it is renamed.
    static let untitled = "Untitled"

    /// The inline title editor's placeholders (`NewCardStubView`, `LaneView`, `CardFaceView`).
    static let cardTitlePrompt = "Card title"

    /// The comments pane's container label — "Comments, N" (10-accessibility.md ▸ Comments).
    static func comments(_ count: Int) -> String {
        "Comments, \(count)"
    }
}

// MARK: - Driving the app

extension XCUIApplication {

    /// One timeout for the whole suite. Generous, because a cold launch here also builds a board on
    /// disk, and because a UI test that is slow is not a UI test that is wrong.
    static let uiTimeout: TimeInterval = 30

    /// The first element anywhere in the app carrying `label`, whatever type it turned out to be.
    ///
    /// SwiftUI decides for itself which `XCUIElement.ElementType` a flattened accessibility element
    /// lands as — a card face and a lane container are both "one element" by design and neither is
    /// promised to be a `staticText` — so the waits in this file match on the label, which *is*
    /// specified (`AccessibilityPhrases`), rather than on a type that is not.
    @MainActor
    func element(labeled label: String) -> XCUIElement {
        descendants(matching: .any)
            .matching(NSPredicate(format: "label == %@", label))
            .firstMatch
    }

    /// The first element anywhere in the app whose label *contains* `fragment`.
    ///
    /// Used only where the whole label is not the test's to predict — a welcome row combines a board
    /// name, a location and a caption into one element, and the caption is the part under test.
    @MainActor
    func element(labelContaining fragment: String) -> XCUIElement {
        descendants(matching: .any)
            .matching(NSPredicate(format: "label CONTAINS %@", fragment))
            .firstMatch
    }

    /// Launches the app on `board` — the audit fixture unless told otherwise — and waits for its
    /// window.
    ///
    /// **Not for `.malformed`**, whose window never holds a board: since the decision surface (01
    /// § Malformed input, settled 2026-07-31) that variant's window does appear — carrying the
    /// surface rather than lanes — so the wait below would pass while saying nothing. That variant is
    /// launched with `launched(with:)` and the caller waits for the surface instead.
    @MainActor
    static func launchedWithFixtureBoard(_ board: FixtureBoard = .standard) -> XCUIApplication {
        let app = launched(with: board)
        // Waiting on *this* window rather than on `windows.firstMatch` is what makes a failed fixture
        // build a failure here instead of an audit that quietly passed over the welcome window.
        XCTAssertTrue(
            app.windows[board.windowTitle].waitForExistence(timeout: uiTimeout),
            "the \(board.windowTitle) window did not open — check the app's launch log for a fixture build failure"
        )
        return app
    }

    /// Launches the app on `board` and returns as soon as it is frontmost — no window assertion, so
    /// the caller decides what should have appeared.
    @MainActor
    static func launched(with board: FixtureBoard) -> XCUIApplication {
        let app = XCUIApplication()
        app.launchArguments += board.launchArguments
        app.launch()
        XCTAssertTrue(
            app.wait(for: .runningForeground, timeout: uiTimeout),
            "the app did not reach the foreground"
        )
        return app
    }

    /// Clicks a menu-bar row by its title.
    ///
    /// **By title, because the titles are API** (04-interactions.md ▸ Configurable bindings: menu
    /// items are remapped by title through `NSUserKeyEquivalents`, so the strings in
    /// `KanbanApp.menuCommands` are already a published contract). Driving the menus rather than
    /// typing the chords also means these tests exercise the same path a keyboard user takes, and a
    /// row that silently disabled itself fails here as an un-hittable element rather than as a
    /// keystroke that went nowhere.
    @MainActor
    func clickMenuItem(_ title: String, in menu: String) {
        let bar = menuBars.firstMatch
        let menuBarItem = bar.menuBarItems[menu]
        XCTAssertTrue(
            menuBarItem.waitForExistence(timeout: Self.uiTimeout),
            "the \(menu) menu is missing from the menu bar"
        )
        menuBarItem.click()

        let item = bar.menuItems[title]
        XCTAssertTrue(
            item.waitForExistence(timeout: Self.uiTimeout),
            "\(menu) ▸ \(title) is missing"
        )
        XCTAssertTrue(item.isEnabled, "\(menu) ▸ \(title) is disabled")
        item.click()
    }

    /// Whether a menu row is currently enabled — opened, read, and closed again with Escape.
    ///
    /// Its own helper because *asking* is a different act from *clicking*: the template-chooser flow
    /// needs to know that Choose is live without pressing it, since pressing it raises a save panel
    /// this suite cannot drive.
    @MainActor
    func menuItemIsEnabled(_ title: String, in menu: String) -> Bool {
        let bar = menuBars.firstMatch
        let menuBarItem = bar.menuBarItems[menu]
        guard menuBarItem.waitForExistence(timeout: Self.uiTimeout) else { return false }
        menuBarItem.click()
        let item = bar.menuItems[title]
        let enabled = item.waitForExistence(timeout: Self.uiTimeout) && item.isEnabled
        typeKey(.escape, modifierFlags: [])
        return enabled
    }

    /// Selects the fixture's rich card and opens its window.
    ///
    /// **The selection is seeded with one arrow press**, which is the app's own documented rule:
    /// "an empty selection seeds at the first lane's first card" (04-interactions.md ▸ Grammar,
    /// `BoardView.seed`). From there the walk to the rich card is ordinary arrow navigation — the
    /// same keys a user without a pointer would press — so this helper needs to know no identifiers
    /// and no geometry, only the fixture's shape.
    ///
    /// The rich card is the sole card of lane 2 ("Doing"), so: one press to seed into lane 1's first
    /// card, then one `→` to step into lane 2. `→` steps to the nearest card in that direction, and
    /// with one card in the lane there is only one it can be.
    @MainActor
    func openRichCardWindow() throws {
        typeKey(.downArrow, modifierFlags: [])
        typeKey(.rightArrow, modifierFlags: [])
        clickMenuItem("Open Card", in: "Board")
        XCTAssertTrue(
            windows[StandardBoard.richCard].waitForExistence(timeout: Self.uiTimeout),
            "the card window did not open"
        )
    }

    // MARK: - Board assertions

    /// Waits for a lane to be rendering exactly `count` cards, by its label.
    ///
    /// **This is how every flow in the end-to-end suite asserts that a move landed.** A card's own
    /// element carries only its title, which does not change when it changes lanes; the lane's does,
    /// because the label carries the count. So "the card moved from To Do to Doing" is checkable as
    /// two lane labels and needs no identifier, no geometry and no reading of the disk.
    @MainActor
    @discardableResult
    func awaitLane(_ title: String, cards count: Int, _ message: String = "") -> Bool {
        let label = Phrase.lane(title, cards: count)
        let found = element(labeled: label).waitForExistence(timeout: Self.uiTimeout)
        XCTAssertTrue(found, "expected a lane reading \"\(label)\"\(message.isEmpty ? "" : " — \(message)")")
        return found
    }

    /// Waits for the trash column to be rendering exactly `count` cards.
    ///
    /// The trash's *label* is the bare word (it never states a count — 03-board-ui.md § Trash keeps
    /// the header's title stable), so the count lives in its value and this matches on the pair.
    @MainActor
    @discardableResult
    func awaitTrash(cards count: Int) -> Bool {
        let predicate = NSPredicate(
            format: "label == %@ AND value == %@", Phrase.trash, Phrase.cards(count)
        )
        let element = descendants(matching: .any).matching(predicate).firstMatch
        let found = element.waitForExistence(timeout: Self.uiTimeout)
        XCTAssertTrue(found, "expected the trash column to hold \(Phrase.cards(count))")
        return found
    }

    /// Clicks a card by its title — the ordinary single click, which selects it
    /// (`BoardStore.click`).
    @MainActor
    func selectCard(_ title: String) {
        let card = element(labeled: title)
        XCTAssertTrue(card.waitForExistence(timeout: Self.uiTimeout), "the card \"\(title)\" is not on the board")
        card.click()
    }

    /// Waits for an inline title editor and answers it — the shared body of every flow that types a
    /// title (`InlineTitleField`).
    ///
    /// The editor is born focused, so the typing goes to the app rather than to a located element;
    /// what is located is only the *field*, and only so that a flow which never opened one fails here
    /// rather than typing its title into the board's keyboard grammar.
    ///
    /// **Matched by placeholder first, then by "the only text field on screen".** The board window's
    /// search field is an `NSSearchField` and so is not a `textField` at all, which leaves the inline
    /// editor as the sole candidate; the placeholder match is the precise one and the fallback is
    /// what keeps a SwiftUI change to how prompts reach the accessibility tree from failing every
    /// flow at once.
    @MainActor
    func typeIntoInlineEditor(_ text: String, prompt: String, replacingExisting: Bool = false) {
        let byPlaceholder = textFields
            .matching(NSPredicate(format: "placeholderValue == %@", prompt))
            .firstMatch
        if !byPlaceholder.waitForExistence(timeout: 5) {
            XCTAssertTrue(
                textFields.firstMatch.waitForExistence(timeout: Self.uiTimeout),
                "the inline title editor (\(prompt)) did not appear"
            )
        }
        if replacingExisting {
            // The rename editor opens seeded with the current title; Select All inside a focused
            // field is the field editor's, not the board's (the board's own Select All disables
            // while an inline editor is open — 04-interactions.md ▸ Grammar).
            typeKey("a", modifierFlags: .command)
        }
        typeText(text)
        typeKey(.return, modifierFlags: [])
    }

    /// Drags one card onto another — the pointer path (04-interactions.md ▸ Drag and drop), which is
    /// a **real AppKit dragging session**: `CardFaceView` starts it from `.onDrag` with an
    /// `NSItemProvider`, and the lane strip answers with `onDrop` delegates.
    ///
    /// **This is the most environment-sensitive gesture in the suite**, and deliberately the slowest.
    /// The press has to outlast the click-versus-drag threshold before the session arms, the movement
    /// has to be slow enough that the drop delegates see intermediate positions (the reflow is
    /// computed from them), and the hold at the end has to outlast the drop animation. A failure here
    /// is worth re-running once before it is believed.
    @MainActor
    func dragCard(_ title: String, onto destinationTitle: String) {
        let source = element(labeled: title)
        let destination = element(labeled: destinationTitle)
        XCTAssertTrue(source.waitForExistence(timeout: Self.uiTimeout), "the card \"\(title)\" is not on the board")
        XCTAssertTrue(
            destination.waitForExistence(timeout: Self.uiTimeout),
            "the drop target \"\(destinationTitle)\" is not on the board"
        )

        let centre = CGVector(dx: 0.5, dy: 0.5)
        source.coordinate(withNormalizedOffset: centre).press(
            forDuration: 1,
            thenDragTo: destination.coordinate(withNormalizedOffset: centre),
            withVelocity: .slow,
            thenHoldForDuration: 1
        )
    }
}
