import XCTest

// MARK: - The fixture board

/// A tiny anchor class purely so `Bundle(for:)` can find this test bundle — there is no
/// `Bundle.module` in an xcodeproj target (that's an SPM-only convenience). Same trick
/// `KanbanTests/FixtureBoardTests.swift` uses on the Mac side.
private final class FixtureBundleAnchor {}

/// The `Fixtures/` folder reference, copied into the test bundle's resources verbatim
/// (`project.yml`'s `KanbanMobileUITests` target). Real directories on disk, not synthesized
/// strings — a `.kanban` board is a package, and this test drives the real loader over one.
private func fixturesRoot() -> URL {
    guard let resources = Bundle(for: FixtureBundleAnchor.self).resourceURL else {
        fatalError("test bundle has no resourceURL")
    }
    return resources.appendingPathComponent("Fixtures", isDirectory: true)
}

/// "Rich Demo Board" — 2 lanes, 3 cards, one card per lane's first slot named below. Mirrored from
/// the fixture's own `index.md` files rather than re-derived at runtime, so a test that expects
/// "Doing" to be the first lane is asserting the fixture's own `order:` keys, not guessing them.
enum RichBoard {
    static let title = "Rich Demo Board"
    static let firstLane = "Doing"
    static let firstLaneFirstCard = "Design the fixture taxonomy"

    /// The package's folder name — the thing a move relocates, and what a disk assertion looks for
    /// under one root or the other. Not derived from `title`: the fixture's folder and its `title:`
    /// key deliberately differ (01-storage-format.md § Board naming allows it), and a move preserves
    /// the folder name rather than re-deriving one.
    static let packageName = "rich-board.kanban"
}

// MARK: - Driving the app

extension XCUIApplication {

    /// One timeout for the whole suite. Generous on the boards list in particular: its first
    /// paint follows `BoardIndexStore`'s first scan, which under `LANEWORK_LOCAL_ROOT` is a
    /// directory enumeration rather than a cloud round trip, but still runs off-main behind a
    /// `Task.detached` — a test that is slow here is not a test that is wrong.
    static let uiTimeout: TimeInterval = 20

    /// The first element anywhere in the app whose label *contains* `fragment` — the same
    /// escape hatch `KanbanUITests/UITestSupport.swift` uses on the Mac side, and for the same
    /// reason: a `List` row wrapping a `NavigationLink` is exposed to the accessibility tree as
    /// one flattened element (title + subtitle concatenated), and neither piece is promised its
    /// own queryable node.
    @MainActor
    func element(labelContaining fragment: String) -> XCUIElement {
        descendants(matching: .any)
            .matching(NSPredicate(format: "label CONTAINS %@", fragment))
            .firstMatch
    }

    /// Launches the app against a fresh scratch directory seeded with a copy of
    /// `Fixtures/Valid/rich-board.kanban`, via `LANEWORK_LOCAL_ROOT` — `CloudHome`'s DEBUG
    /// override (`CloudHomeResolver.resolveBlocking()`). No `SIMCTL_CHILD_` prefix: XCUITest's
    /// `launchEnvironment` already lands in the *app's* process, not the test runner's.
    ///
    /// Returns the app (not yet launched into any particular screen — the caller drives
    /// navigation from the Boards tab) and the scratch root, so a test can poll the board's files
    /// on disk after driving an edit through the UI.
    @MainActor
    static func launchedWithFixtureBoard() -> (app: XCUIApplication, root: URL) {
        let root = scratchRoot(seeded: true)
        return (launched(["LANEWORK_LOCAL_ROOT": root.path]), root)
    }

    /// The same launch with a **second** scratch directory behind `LANEWORK_DEVICE_ROOT`
    /// (`DeviceHomeResolver`), so both of the app's homes are directories this test created and
    /// owns — which is what makes a move between them assertable on disk. The fixture starts in the
    /// stand-in cloud root; the device root starts empty.
    ///
    /// Overriding the device root matters as much as overriding the cloud one: the real device home
    /// is the installed app's own `Documents/`, which survives between test runs and between tests.
    @MainActor
    static func launchedWithFixtureBoardInCloud() -> (app: XCUIApplication, cloudRoot: URL, deviceRoot: URL) {
        let cloudRoot = scratchRoot(seeded: true)
        let deviceRoot = scratchRoot(seeded: false)
        let app = launched([
            "LANEWORK_LOCAL_ROOT": cloudRoot.path,
            "LANEWORK_DEVICE_ROOT": deviceRoot.path,
        ])
        return (app, cloudRoot, deviceRoot)
    }

    /// A launch with **no iCloud at all** and the fixture in the device home — the no-account run,
    /// which must still list, open and edit boards.
    ///
    /// `LANEWORK_FORCE_NO_ICLOUD` rather than simply omitting `LANEWORK_LOCAL_ROOT`: without the
    /// override the resolver reaches for the real ubiquity container, and a simulator that happens to
    /// be signed into an account would resolve one — making this test pass or fail on whose machine
    /// it ran.
    @MainActor
    static func launchedWithFixtureBoardOnDevice() -> (app: XCUIApplication, deviceRoot: URL) {
        let deviceRoot = scratchRoot(seeded: true)
        let app = launched([
            "LANEWORK_FORCE_NO_ICLOUD": "1",
            "LANEWORK_DEVICE_ROOT": deviceRoot.path,
        ])
        return (app, deviceRoot)
    }

    /// A directory no other test shares, optionally holding a copy of the fixture board.
    @MainActor
    private static func scratchRoot(seeded: Bool) -> URL {
        let root = FileManager.default.temporaryDirectory
            .appendingPathComponent("KanbanMobileUITests-\(UUID().uuidString)", isDirectory: true)
        do {
            try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
            if seeded {
                let source = fixturesRoot()
                    .appendingPathComponent("Valid/\(RichBoard.packageName)", isDirectory: true)
                try FileManager.default.copyItem(
                    at: source,
                    to: root.appendingPathComponent(RichBoard.packageName, isDirectory: true)
                )
            }
        } catch {
            fatalError("could not seed a scratch root: \(error)")
        }
        return root
    }

    @MainActor
    private static func launched(_ environment: [String: String]) -> XCUIApplication {
        let app = XCUIApplication()
        for (key, value) in environment {
            app.launchEnvironment[key] = value
        }
        app.launch()
        XCTAssertTrue(
            app.wait(for: .runningForeground, timeout: uiTimeout),
            "the app did not reach the foreground"
        )
        return app
    }

    /// The board list row for `title`, optionally narrowed to one that also carries `marker` —
    /// "Local" being the only marker there is. Both fragments have to be matched on the *same*
    /// element because the row is one flattened accessibility node (see `element(labelContaining:)`),
    /// so its label is the whole subtitle line concatenated onto the title.
    @MainActor
    func boardRow(_ title: String, alsoContaining marker: String? = nil) -> XCUIElement {
        var predicate = NSPredicate(format: "label CONTAINS %@", title)
        if let marker {
            predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
                predicate,
                NSPredicate(format: "label CONTAINS %@", marker),
            ])
        }
        return descendants(matching: .any).matching(predicate).firstMatch
    }

    /// Swipes a board row open and taps its Settings action.
    ///
    /// The label is scoped to the list because the boards screen's own nav bar carries a
    /// "Settings" button of its own — the leading gear button that presents `SettingsScreen` as a
    /// sheet, same word, entirely different destination — and an unscoped `buttons["Settings"]` is
    /// a coin flip between them.
    @MainActor
    func openBoardSettings(for title: String) {
        let row = boardRow(title)
        XCTAssertTrue(
            row.waitForExistence(timeout: Self.uiTimeout),
            "the \"\(title)\" row was not there to swipe"
        )
        row.swipeLeft()

        let action = collectionViews.buttons["Settings"]
        XCTAssertTrue(
            action.waitForExistence(timeout: Self.uiTimeout),
            "the row's Settings swipe action never appeared"
        )
        action.tap()

        XCTAssertTrue(
            navigationBars["Board Settings"].waitForExistence(timeout: Self.uiTimeout),
            "the Board Settings sheet never appeared"
        )
    }

    /// Confirms the move in the `confirmationDialog` the sheet raises. Deliberately labelled with a
    /// bare verb, which is also what keeps it distinct from the "Move to …" button that raised it.
    @MainActor
    func confirmMove() {
        let confirm = buttons["Move"]
        XCTAssertTrue(
            confirm.waitForExistence(timeout: Self.uiTimeout),
            "the move confirmation dialog never appeared"
        )
        confirm.tap()
    }

    /// Boards → the fixture's first lane → its first card, landing on `CardDetailScreen`'s read
    /// view. Shared navigation boilerplate for every test that starts on a card.
    @MainActor
    func navigateToFirstCard() {
        let boardRow = element(labelContaining: RichBoard.title)
        XCTAssertTrue(
            boardRow.waitForExistence(timeout: Self.uiTimeout),
            "the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
        )
        boardRow.tap()

        let laneRow = element(labelContaining: RichBoard.firstLane)
        XCTAssertTrue(
            laneRow.waitForExistence(timeout: Self.uiTimeout),
            "the \"\(RichBoard.firstLane)\" lane row never appeared"
        )
        laneRow.tap()

        let cardRow = element(labelContaining: RichBoard.firstLaneFirstCard)
        XCTAssertTrue(
            cardRow.waitForExistence(timeout: Self.uiTimeout),
            "the \"\(RichBoard.firstLaneFirstCard)\" card row never appeared"
        )
        cardRow.tap()
    }

    /// Scrolls the frontmost view upward, a little at a time, until `element` is hittable or
    /// `maxAttempts` is exhausted. A `Form`/`List` does not auto-scroll to reveal what a test asks
    /// for, and this suite's Settings screen puts the About section — everything
    /// `testSettingsAboutSection` addresses — last.
    @MainActor
    func scrollUntilHittable(_ element: XCUIElement, maxAttempts: Int = 8) {
        var attempts = 0
        while !element.isHittable, attempts < maxAttempts {
            swipeUp()
            attempts += 1
        }
    }
}

extension XCUIElement {

    /// Replaces this field's/editor's entire text with `text`, wholesale.
    ///
    /// **Neither ⌘A nor the long-press callout survived contact with this suite.** ⌘A is delivered
    /// as a `UIKeyCommand` down the responder chain, and that routing is not guaranteed wired up the
    /// instant `tap()` returns — behind a `fullScreenCover`'s own presentation transition, or right
    /// after a `TextEditor` is freshly mounted by a pane switch, ⌘A sent too early is silently
    /// dropped while ordinary character input (a different, lower-level path) still lands, so the
    /// retyped text ends up inserted at whatever the stale cursor position was rather than replacing
    /// anything — and a fixed settle before it only wins *sometimes*, because under a full suite
    /// run's extra load the gap it needs to cover stretches past any delay worth hard-coding. The
    /// long-press "Select All" callout fares worse: it never appeared at all in this environment (the
    /// Simulator's hardware keyboard appears to suppress the touch selection UI outright).
    ///
    /// What is left, and has been reliable through every run: plain character `typeText` always
    /// lands, so the fix sidesteps selection entirely. A tap near the field's trailing/bottom edge —
    /// past wherever the current text actually ends — is where both `UITextField` and `UITextView`
    /// place the caret at the *end* of the text (the standard "tap in the empty run-out" behavior,
    /// not an assumption this suite is inventing), which is what makes a plain backspace-per-character
    /// safe here where the original in-place editor's own comment once ruled it out: that caveat was
    /// about a tap landing *mid-text*, not about the technique itself.
    @MainActor
    func replaceAllText(with text: String) {
        let trailingEdge = coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5))
        trailingEdge.tap()
        if let current = value as? String, !current.isEmpty {
            typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count))
        }
        typeText(text)
    }
}

// MARK: - Polling the filesystem

/// Waits up to `timeout` for some file under `root` (searched recursively) to contain
/// `substring`, polling every 0.25s — the shape every "did the write land on disk" assertion in
/// this bundle needs, since a card's write reaches disk asynchronously (`BoardSession.perform`)
/// well after the UI action that triggered it returns.
func waitForFile(under root: URL, containing substring: String, timeout: TimeInterval = 10) -> Bool {
    let deadline = Date().addingTimeInterval(timeout)
    repeat {
        if fileExists(under: root, containing: substring) { return true }
        RunLoop.current.run(until: Date().addingTimeInterval(0.25))
    } while Date() < deadline
    return fileExists(under: root, containing: substring)
}

/// A point-in-time negative check — `waitForFile`'s substring test with no polling, for asserting a
/// draft was never written. Discarding a `CardEditScreen` never calls `session.perform` at all, so
/// there is no async write to wait out; polling the full timeout for something that never becomes
/// true would only slow the suite down for no better an answer than one immediate look.
func fileDoesNotContain(under root: URL, substring: String) -> Bool {
    !fileExists(under: root, containing: substring)
}

/// Waits up to `timeout` for the directory at `url` to exist — or, with `toExist: false`, to be
/// gone. The package-shaped counterpart to `waitForFile(under:containing:)`, and what a move
/// assertion needs: `relocateBoard` hands the actual transfer to a detached task and answers the UI
/// well before either root has settled.
func waitForDirectory(at url: URL, toExist shouldExist: Bool = true, timeout: TimeInterval = 15) -> Bool {
    let deadline = Date().addingTimeInterval(timeout)
    repeat {
        if directoryExists(at: url) == shouldExist { return true }
        RunLoop.current.run(until: Date().addingTimeInterval(0.25))
    } while Date() < deadline
    return directoryExists(at: url) == shouldExist
}

private func directoryExists(at url: URL) -> Bool {
    var isDirectory: ObjCBool = false
    let exists = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)
    return exists && isDirectory.boolValue
}

private func fileExists(under root: URL, containing substring: String) -> Bool {
    guard let enumerator = FileManager.default.enumerator(
        at: root,
        includingPropertiesForKeys: [.isRegularFileKey],
        options: [.skipsHiddenFiles]
    ) else { return false }

    for case let url as URL in enumerator {
        guard url.lastPathComponent == "index.md",
              let contents = try? String(contentsOf: url, encoding: .utf8)
        else { continue }
        if contents.contains(substring) { return true }
    }
    return false
}
