import Foundation
import Testing
@testable import Kanban

/// The card window's shell is mostly window plumbing, which a unit test cannot see. What it *can*
/// see is the three seams that plumbing hangs off, and each of them is a thing that fails silently:
/// a subtitle that stops following its card reads as correct until the card moves, a sidebar width
/// written down in points looks fine until the system text size changes, and a second window for one
/// card looks like a window that simply did not focus.
///
/// So the seams are pure functions and values, and this is their suite. The dismissal verdict —
/// vanished, tombstoned, ancestor-tombstoned, cross-board — is `CardWindowFateTests`'.

// MARK: - Fixtures

/// A two-lane board with the card in the first one, plus an untitled lane for the placeholder case.
///
/// - "Todo": one live card
/// - "Doing": empty, the card's destination
/// - untitled: empty
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
    try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
    try fixture.item(Ident.lane3, "---\nschema: 1\norder: 3072\n---\nno title here\n")
    return fixture
}

private func snapshot(of fixture: WriterFixture) throws -> BoardModel {
    try BoardLoader.load(boardRoot: fixture.root).model
}

/// Exactly what `CardWindowHost.windowSubtitle` composes — the fate's lane, through the subtitle
/// rule — so this helper cannot pass while the window shows something else.
@MainActor
private func subtitle(forCard cardID: String, boardNamed board: String, in model: BoardModel) -> String? {
    guard case let .shows(placement) = CardWindowHost.cardWindowFate(cardID: cardID, in: model) else {
        return nil
    }
    return CardWindowHost.subtitle(board: board, lane: placement.lane.title.value)
}

/// A registry whose file lives in temp rather than in the app's real Application Support home.
@MainActor
private struct RegistryStorage {
    let folder: URL

    var url: URL { folder.appendingPathComponent("board-registry.json", isDirectory: false) }

    init() throws {
        folder = FileManager.default.temporaryDirectory
            .appendingPathComponent("CardWindowShellTests-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
    }

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

// MARK: - Subtitle

@MainActor
@Suite("Card window subtitle")
struct CardWindowSubtitleTests {

    @Test("The subtitle is ⟨board⟩ › ⟨lane⟩")
    func subtitleNamesBoardAndLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Todo")
    }

    @Test("The subtitle follows the card between lanes")
    func subtitleFollowsTheCard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Todo")

        // A lane move, as the disk sees it: the card's folder changes parent, its UUID does not.
        // The window's key names neither lane, so the window stays — and the subtitle is the one
        // part of it that has to notice.
        try FileManager.default.moveItem(
            at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
            to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
        )

        #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Doing")
    }

    @Test("An untitled lane subtitles with the placeholder, never with a blank")
    func anUntitledLaneRendersThePlaceholder() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try FileManager.default.moveItem(
            at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
            to: fixture.url("\(Ident.lane3)/\(Ident.card1)")
        )

        // "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — a subtitle reading
        // "Work › " would look like a bug in the app rather than a lane nobody has named.
        #expect(subtitle(forCard: Ident.card1, boardNamed: "Work", in: try snapshot(of: fixture)) == "Work › Untitled")
    }

    @Test("A card with no window has no subtitle to compose")
    func aDismissedCardHasNoSubtitle() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        #expect(subtitle(forCard: Ident.card4, boardNamed: "Work", in: try snapshot(of: fixture)) == nil)
    }

    @Test("The board half is whatever the board is currently called")
    func theBoardHalfIsTheDisplayName() {
        // `AppModel.displayName(of:)` is what the host passes in — title, falling back to the folder
        // name — so a board rename lands here for free. The rule itself is `AppModelTests`'.
        #expect(CardWindowHost.subtitle(board: "Renamed", lane: "Todo") == "Renamed › Todo")
        #expect(CardWindowHost.subtitle(board: "Work", lane: nil) == "Work › Untitled")
    }
}

// MARK: - Metrics

@Suite("Card window metrics")
struct CardWindowMetricsTests {

    @Test("The sidebar's width is a character count in the body font")
    func sidebarWidthIsDerivedFromFontMetrics() {
        // 26 characters at half an em, plus a one-em gutter on each side. Pinned at one point size
        // so a change to the derivation has to be a deliberate one: 26 × 0.5 × 16 + 2 × 16 = 240.
        #expect(CardWindowMetrics.sidebarWidth(bodyPointSize: 16) == 240)
        #expect(
            CardWindowMetrics.sidebarWidth(bodyPointSize: 16)
                == CardWindowMetrics.columnWidth(characters: CardWindowMetrics.sidebarCharacters, bodyPointSize: 16)
        )
    }

    @Test("The sidebar scales with the body font, in both directions")
    func sidebarWidthScalesWithTheFont() {
        // The whole point of deriving it: at the largest system text sizes the sidebar grows with
        // the text it holds instead of truncating everything in it (10-accessibility.md ▸ Text).
        let small = CardWindowMetrics.sidebarWidth(bodyPointSize: 11)
        let standard = CardWindowMetrics.sidebarWidth(bodyPointSize: 13)
        let large = CardWindowMetrics.sidebarWidth(bodyPointSize: 24)

        #expect(small < standard)
        #expect(standard < large)
        // And it is the same width every time it is asked, which is what "fixed width" means here:
        // the window's resize flex goes to the body, never to this column.
        #expect(CardWindowMetrics.sidebarWidth(bodyPointSize: 13) == standard)
    }

    @Test("The sidebar is the narrow column and the body is the wide one")
    func theSidebarIsNarrowerThanTheBodysFloor() {
        for size in [11.0, 13.0, 18.0, 24.0] as [CGFloat] {
            #expect(
                CardWindowMetrics.sidebarWidth(bodyPointSize: size)
                    < CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size),
                "even squeezed to its floor, the body column stays the wide one"
            )
        }
    }

    @Test("The window's minimum is the sidebar plus the body's floor")
    func theWindowMinimumIsTheSumOfTheColumns() {
        let size: CGFloat = 13
        #expect(
            CardWindowMetrics.minimumSize(bodyPointSize: size).width
                == CardWindowMetrics.sidebarWidth(bodyPointSize: size)
                    + CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size),
            "the minimum is what the two columns need, not a number somebody liked"
        )
        #expect(CardWindowMetrics.minimumSize(bodyPointSize: size).height > 0)
    }

    @Test("Hiding the sidebar shrinks the window's minimum by exactly its width")
    func hidingTheSidebarShrinksTheMinimum() {
        // View ▸ Show Sidebar's own mirror of the comments column's own rule
        // (`CommentsWindowMinimumTests.theMinimumGrowsOnlyBeside`): a pane costs the window no width
        // while it is not shown.
        let size: CGFloat = 13
        let shown = CardWindowMetrics.minimumSize(bodyPointSize: size)
        let hidden = CardWindowMetrics.minimumSize(bodyPointSize: size, sidebar: false)

        #expect(shown.width == hidden.width + CardWindowMetrics.sidebarWidth(bodyPointSize: size))
        #expect(hidden.width == CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size))
        #expect(hidden.height == shown.height, "hiding the sidebar costs width, never height")
    }

    @Test("Hiding the sidebar composes with the comments column's own minimum")
    func hidingTheSidebarComposesWithTheCommentsColumn() {
        let size: CGFloat = 13
        let minimum = CardWindowMetrics.minimumSize(bodyPointSize: size, sidebar: false, commentsColumn: true)

        #expect(
            minimum.width
                == CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size)
                    + CardWindowMetrics.commentsMinimumWidth(bodyPointSize: size),
            "sidebar hidden and comments beside: the minimum is the body's floor plus the comments floor, nothing else"
        )
    }

    @Test("A first card window opens larger than the minimum")
    func theDefaultSizeIsRoomier() {
        let size: CGFloat = 13
        let minimum = CardWindowMetrics.minimumSize(bodyPointSize: size)
        let initial = CardWindowMetrics.defaultSize(bodyPointSize: size)

        #expect(initial.width > minimum.width)
        #expect(initial.height > minimum.height)
    }
}

// MARK: - The body column's mode

/// 05-card-window.md ▸ Mode grammar, as the two rules a unit test can hold: **Preview is the resting
/// state unless the body is empty**, and **the flip is one flip** whichever key produced it.
///
/// Both are silent failures of exactly the kind this file exists for. A card that opened in Preview
/// with an empty body would look like a working window showing nothing, and the user would have to
/// discover ⌘E to write the first word of a card they just made — which is the ceremony the rule was
/// settled to remove ("a new card has nothing to preview, so ⌘↩ during creation flows title → body
/// without a mode stop").
@MainActor
@Suite("Card body mode")
struct CardBodyModeTests {

    @Test("A card with a body opens in Preview")
    func aCardWithABodyOpensInPreview() {
        #expect(CardBodyMode.opening(body: "# Notes\n") == .preview)
        #expect(CardBodyMode.opening(body: "x") == .preview)
    }

    @Test("An empty body opens straight into Edit — whitespace included")
    func anEmptyBodyOpensInEdit() {
        #expect(CardBodyMode.opening(body: "") == .edit)
        #expect(CardBodyMode.opening(body: "\n") == .edit)
        // A card the app itself just minted has exactly this body: `BoardWriter.newDocumentText`
        // writes frontmatter and nothing after the closing delimiter.
        #expect(CardBodyMode.opening(body: "   \n\t\n") == .edit)
    }

    @Test("⌘E, Return in Preview and Escape in Edit are one flip")
    func theToggleIsSymmetric() {
        #expect(CardBodyMode.preview.toggled == .edit)
        #expect(CardBodyMode.edit.toggled == .preview)
        #expect(CardBodyMode.preview.toggled.toggled == .preview)
    }

    @Test("The opening rule runs once, not on every snapshot")
    func theOpeningRuleIsAppliedOnce() {
        let presentation = CardBodyPresentation()
        #expect(presentation.openIfNeeded(body: "# Notes\n") == .preview)

        // The user presses ⌘E …
        presentation.toggleMode()
        #expect(presentation.mode == .edit)

        // … and a watcher reload arrives with the same body. Re-deciding here would throw the user
        // out of the surface they just asked for.
        #expect(presentation.openIfNeeded(body: "# Notes\n") == .edit)
        #expect(presentation.mode == .edit)
    }

    @Test("A window that opened into Edit is not dragged back to Preview when the body fills up")
    func aLaterBodyDoesNotReopenTheRule() {
        let presentation = CardBodyPresentation()
        #expect(presentation.openIfNeeded(body: "") == .edit)

        // The user types; the reload brings the text back. The rule is about *opening* a card, and
        // this window is already open.
        #expect(presentation.openIfNeeded(body: "first words") == .edit)
    }
}

// MARK: - Identity and frames

@MainActor
@Suite("Card window identity")
struct CardWindowIdentityTests {

    @Test("Opening the same card twice is one window's worth of bookkeeping")
    func reopeningACardDoesNotDuplicateIt() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let storage = try RegistryStorage()
        defer { storage.tearDown() }

        let model = AppModel(
            registryStorageURL: storage.url,
            clipboardStagingRoot: storage.folder.appendingPathComponent("Clipboard", isDirectory: true)
        )
        let board = BoardWindowRef(url: fixture.root)
        let recordID = model.boardRegistry.recordOpen(of: fixture.root)
        let store = try model.storeRegistry.acquire(fixture.root)
        model.beginSession(ref: board, store: store, recordID: recordID, access: nil)
        defer { model.storeRegistry.release(store) }

        // The dedup itself is the scene's: `openWindow(value:)` with an equal ref focuses the window
        // that ref already has (`WindowRefsTests` pins the equality). What this side must not do is
        // *record* a second one — a duplicate here would have the close flush wait on a window that
        // does not exist.
        let first = CardWindowRef(board: board, cardID: ItemID(rawValue: Ident.card1))
        let reopened = CardWindowRef(board: board, cardID: ItemID(rawValue: Ident.card1.uppercased()))
        #expect(first == reopened)

        model.registerCardWindow(first, session: CardWindowSession())
        model.registerCardWindow(reopened, session: CardWindowSession())
        #expect(model.session(for: board)?.cardRefs.count == 1)

        // And one unregister drains it, because there was only ever one.
        model.unregisterCardWindow(reopened)
        #expect(model.session(for: board)?.cardRefs.isEmpty == true)
    }

    @Test("A card window's frame is remembered per card, on its board's record")
    func cardFramesArePerCardAndSurviveARelaunch() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let storage = try RegistryStorage()
        defer { storage.tearDown() }

        let registry = BoardRegistry(storageURL: storage.url)
        let recordID = registry.recordOpen(of: fixture.root)
        let one = ItemID(rawValue: Ident.card1)
        let two = ItemID(rawValue: Ident.card2)

        #expect(registry.cardWindowFrame(id: recordID, cardID: one) == nil, "an unopened card cascades instead")

        registry.updateCardWindowFrame(id: recordID, cardID: one, frame: WindowFrame(x: 10, y: 20, width: 700, height: 600))
        registry.updateCardWindowFrame(id: recordID, cardID: two, frame: WindowFrame(x: 90, y: 80, width: 500, height: 400))

        // A new instance is a relaunch: the frames come back from the file, per card, which is the
        // whole of "frames restore per card across relaunch where state restoration allows" in an
        // app whose scene restoration is deliberately off.
        let relaunched = BoardRegistry(storageURL: storage.url)
        #expect(relaunched.cardWindowFrame(id: recordID, cardID: one) == WindowFrame(x: 10, y: 20, width: 700, height: 600))
        #expect(relaunched.cardWindowFrame(id: recordID, cardID: two) == WindowFrame(x: 90, y: 80, width: 500, height: 400))

        // Keyed the way the window itself is keyed: a folder respelled in caps is the same card, so
        // it must find the frame the other spelling stored rather than opening somewhere else.
        let respelled = ItemID(rawValue: Ident.card1.uppercased())
        #expect(relaunched.cardWindowFrame(id: recordID, cardID: respelled) == WindowFrame(x: 10, y: 20, width: 700, height: 600))
    }
}
