import AppKit
import Foundation
import SwiftUI
import Testing
@testable import Kanban

/// The board background's **mapping form** — the write side that has to preserve what the app has no
/// control over, and the path rule that decides where an `image:` may point (01-storage-format.md §
/// Frontmatter; 03-board-ui.md § Styling ▸ Capabilities).
///
/// The *readings* live with their siblings in `FrontmatterTests`; what is here is everything that is
/// new machinery rather than a new value: `FrontmatterDocument.setStyleValue` (BackgroundField.swift)
/// and `BoardBackdrop.imageURL(named:inBoardRoot:)`.

// MARK: - Fixtures

private func document(_ frontmatter: String) throws -> FrontmatterDocument {
    try FrontmatterDocument.parse("---\n\(frontmatter)\n---\nbody\n")
}

/// The `background:` line as it now reads on disk, or `nil` when the key is gone.
private func backgroundLine(_ document: FrontmatterDocument) -> String? {
    document.serialized()
        .split(separator: "\n", omittingEmptySubsequences: false)
        .first { $0.hasPrefix("background:") }
        .map(String.init)
}

// MARK: - Writing into the mapping

@Suite("Board background ▸ the write preserves the mapping")
struct BackgroundWriteTests {

    /// The whole point of the field: the app owns the colour well and nothing else, so a colour
    /// change on a board carrying an image has to come back still carrying it.
    @Test("Setting a colour replaces the subkey and keeps the image")
    func setKeepsTheImage() throws {
        var document = try document("background: {color: fern, image: sunset.jpg}")
        document.setStyleValue("dark-teal", for: FrontmatterKeys.background)

        #expect(document.background == .valid("dark-teal"))
        #expect(document.backgroundImage == .valid("sunset.jpg"))
        #expect(backgroundLine(document) == "background: {color: \"dark-teal\", image: \"sunset.jpg\"}")
    }

    /// A colour written into a mapping that had none joins it rather than replacing it — the
    /// image-only board is exactly the board the style editor is most likely to be opened on.
    @Test("Setting a colour on an image-only background adds the subkey")
    func setAddsTheSubkeyToAnImageOnlyMapping() throws {
        var document = try document("background: {image: sunset.jpg}")
        document.setStyleValue("chalk", for: FrontmatterKeys.background)

        #expect(document.background == .valid("chalk"))
        #expect(document.backgroundImage == .valid("sunset.jpg"))
    }

    /// Unknown subkeys ride along like unknown keys do — nothing in the app knows what `blend:`
    /// means and nothing in the app is entitled to drop it.
    @Test("Unknown subkeys survive a colour change, in their own positions")
    func setPreservesUnknownSubkeys() throws {
        var document = try document("background: {blend: multiply, color: fern, opacity: 0.5, tags: [a, b]}")
        document.setStyleValue("obsidian", for: FrontmatterKeys.background)

        #expect(backgroundLine(document)
            == "background: {blend: \"multiply\", color: \"obsidian\", opacity: 0.5, tags: [\"a\", \"b\"]}")
    }

    /// The None well removes the *colour*, not the background: an image the user never chose in the
    /// app must not disappear because they cleared a colour (03-board-ui.md § Styling ▸ Controls).
    @Test("The None well drops the colour subkey alone")
    func removeDropsOnlyTheColour() throws {
        var document = try document("background: {color: fern, image: sunset.jpg}")
        document.setStyleValue(nil, for: FrontmatterKeys.background)

        #expect(document.background == .missing)
        #expect(document.backgroundImage == .valid("sunset.jpg"))
        #expect(backgroundLine(document) == "background: {image: \"sunset.jpg\"}")
    }

    /// A mapping the removal empties takes the key with it — `background: {}` is a key that says
    /// nothing, and the removal's contract is that the field is gone.
    @Test("A mapping emptied by the removal takes the key with it")
    func removeDropsAnEmptiedKey() throws {
        var document = try document("background: {color: fern}")
        document.setStyleValue(nil, for: FrontmatterKeys.background)

        #expect(document.background == .missing)
        #expect(!document.contains(FrontmatterKeys.background))
        #expect(backgroundLine(document) == nil)
    }

    /// A block mapping is the same value as a flow one and is merged the same way. The spelling is
    /// what does not survive — the span editor rewrites a key's value as one line — which is the
    /// documented limit of the verbatim promise on the one key being written.
    @Test("A block mapping merges, collapsing to flow form")
    func blockMappingCollapsesToFlow() throws {
        var document = try FrontmatterDocument.parse(
            "---\nschema: 1\nbackground:\n  color: fern\n  image: sunset.jpg\nicon: tray\n---\nbody\n"
        )
        document.setStyleValue("chalk", for: FrontmatterKeys.background)

        #expect(document.background == .valid("chalk"))
        #expect(document.backgroundImage == .valid("sunset.jpg"))
        #expect(document.serialized() == """
            ---
            schema: 1
            background: {color: "chalk", image: "sunset.jpg"}
            icon: tray
            ---
            body

            """)
    }

    /// **The app always writes the mapping** (01-storage-format.md § Frontmatter): a key that was
    /// absent gets one, and a key holding a shape the schema cannot read — the retired scalar, a
    /// sequence — is *replaced* by one, which is the malformed-value-cleared posture ("choosing any
    /// well replaces it").
    @Test("A colour written onto an absent or unreadable key lands as a mapping")
    func alwaysWritesTheMapping() throws {
        var absent = try document("schema: 1")
        absent.setStyleValue("chalk", for: FrontmatterKeys.background)
        #expect(backgroundLine(absent) == "background: {color: \"chalk\"}")
        #expect(absent.background == .valid("chalk"))

        var scalar = try document("background: fern")
        scalar.setStyleValue("chalk", for: FrontmatterKeys.background)
        #expect(backgroundLine(scalar) == "background: {color: \"chalk\"}")

        var sequence = try document("background: [a, b]")
        sequence.setStyleValue("chalk", for: FrontmatterKeys.background)
        #expect(backgroundLine(sequence) == "background: {color: \"chalk\"}")
    }

    /// A removal over a shape with no subkeys to keep is simply a removal — there is no mapping to
    /// preserve half of, and nothing was readable to begin with.
    @Test("A removal over a scalar or an absent key just removes it")
    func removalOverANonMappingRemovesTheKey() throws {
        var scalar = try document("background: fern")
        scalar.setStyleValue(nil, for: FrontmatterKeys.background)
        #expect(!scalar.contains(FrontmatterKeys.background))

        var absent = try document("schema: 1")
        absent.setStyleValue(nil, for: FrontmatterKeys.background)
        #expect(!absent.contains(FrontmatterKeys.background))
        #expect(absent.serialized() == "---\nschema: 1\n---\nbody\n")
    }

    /// The other style keys are untouched by any of this: their values *are* strings, and a
    /// hand-written `icon: {a: 1}` is a malformed value the write exists to clear — never something
    /// to merge a `color:` subkey into.
    @Test("Title and icon keep the plain scalar path")
    func otherKeysStayScalar() throws {
        var titled = try document("title: Old")
        titled.setStyleValue("New", for: FrontmatterKeys.title)
        #expect(titled.title == .valid("New"))
        #expect(titled.serialized() == "---\ntitle: New\n---\nbody\n")

        var mappedIcon = try document("icon: {a: 1}")
        mappedIcon.setStyleValue("tray", for: FrontmatterKeys.icon)
        #expect(mappedIcon.icon == .valid("tray"))
        #expect(mappedIcon.serialized() == "---\nicon: tray\n---\nbody\n")
    }

    /// The emitted mapping is read back by the very reader the app uses, values with YAML-significant
    /// characters included — the reason strings are always quoted in flow context.
    @Test("An awkward colour and path round-trip through the emitted mapping")
    func awkwardValuesRoundTrip() throws {
        var document = try document("background: {image: \"a, b}.jpg\"}")
        document.setStyleValue("#ff8800", for: FrontmatterKeys.background)

        let reparsed = try FrontmatterDocument.parse(document.serialized())
        #expect(reparsed.background == .valid("#ff8800"))
        #expect(reparsed.backgroundImage == .valid("a, b}.jpg"))
    }
}

// MARK: - Writing the image subkey

/// `FrontmatterDocument.setBackgroundImage` — the colour write's sibling, one subkey over
/// (BackgroundField.swift), and what `BoardStore.applyGeneratedBackground` points at the PNG it just
/// wrote. Every rule the colour write obeys, this one obeys too: that is the whole reason they share
/// a merge.
@Suite("Board background ▸ the image subkey")
struct BackgroundImageWriteTests {

    /// The mirror of `setKeepsTheImage`: the app now writes both subkeys, and neither may take the
    /// other with it.
    @Test("Setting an image replaces the subkey and keeps the colour")
    func setKeepsTheColour() throws {
        var document = try document("background: {color: fern, image: sunset.jpg}")
        document.setBackgroundImage("facets.png")

        #expect(document.background == .valid("fern"))
        #expect(document.backgroundImage == .valid("facets.png"))
        #expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}")
    }

    /// A colour-only board — every board styled from the wells — is the one the generator is most
    /// likely to be pointed at.
    @Test("Setting an image on a colour-only background adds the subkey")
    func setAddsTheSubkeyToAColourOnlyMapping() throws {
        var document = try document("background: {color: fern}")
        document.setBackgroundImage("facets.png")

        #expect(backgroundLine(document) == "background: {color: \"fern\", image: \"facets.png\"}")
    }

    /// Unknown subkeys ride along here exactly as they do through a colour change — the merge is
    /// literally the same one.
    @Test("Unknown subkeys survive an image change, in their own positions")
    func setPreservesUnknownSubkeys() throws {
        var document = try document("background: {blend: multiply, image: old.png, opacity: 0.5}")
        document.setBackgroundImage("facets.png")

        #expect(backgroundLine(document)
            == "background: {blend: \"multiply\", image: \"facets.png\", opacity: 0.5}")
    }

    /// The undo of a first generation: the image subkey goes and the colour the board had — or did
    /// not have — is left to the colour write.
    @Test("Removing the image drops that subkey alone")
    func removeDropsOnlyTheImage() throws {
        var document = try document("background: {color: fern, image: facets.png}")
        document.setBackgroundImage(nil)

        #expect(document.background == .valid("fern"))
        #expect(document.backgroundImage == .missing)
        #expect(backgroundLine(document) == "background: {color: \"fern\"}")
    }

    /// `background: {}` is a key that says nothing — the removal's contract is that the field is
    /// gone, whichever subkey emptied it.
    @Test("A mapping emptied by the removal takes the key with it")
    func removeDropsAnEmptiedKey() throws {
        var document = try document("background: {image: facets.png}")
        document.setBackgroundImage(nil)

        #expect(!document.contains(FrontmatterKeys.background))
        #expect(backgroundLine(document) == nil)
    }

    /// The malformed-value-cleared posture, on this subkey: a shape the schema cannot read has no
    /// subkeys to preserve and is replaced by the mapping the app writes.
    @Test("An image written onto an absent or unreadable key lands as a mapping")
    func alwaysWritesTheMapping() throws {
        var absent = try document("schema: 1")
        absent.setBackgroundImage("facets.png")
        #expect(backgroundLine(absent) == "background: {image: \"facets.png\"}")

        var scalar = try document("background: fern")
        scalar.setBackgroundImage("facets.png")
        #expect(backgroundLine(scalar) == "background: {image: \"facets.png\"}")

        var sequence = try document("background: [a, b]")
        sequence.setBackgroundImage("facets.png")
        #expect(backgroundLine(sequence) == "background: {image: \"facets.png\"}")
    }

    /// Both subkeys written in one edit, which is the shape every generated background lands in —
    /// and the order the schema spells it in, colour first.
    @Test("A colour and an image written together land as one mapping")
    func bothSubkeysTogether() throws {
        var document = try document("schema: 1")
        document.setStyleValue("#E0E5EB", for: FrontmatterKeys.background)
        document.setBackgroundImage("facets.png")

        #expect(backgroundLine(document) == "background: {color: \"#E0E5EB\", image: \"facets.png\"}")
        #expect(document.background == .valid("#E0E5EB"))
        #expect(document.backgroundImage == .valid("facets.png"))
    }

    /// Everything outside the key survives byte for byte, including the comment on the unknown key
    /// beside it — the verbatim promise, which yields on the one key being rewritten and nowhere else.
    @Test("Nothing but the background line moves")
    func leavesEverythingElseAlone() throws {
        var document = try FrontmatterDocument.parse("""
            ---
            schema: 1
            title: Work
            project: lanework       # agent overlay
            background: {color: fern}
            icon: tray
            ---
            Board description.

            """)
        document.setBackgroundImage("facets.png")

        #expect(document.serialized() == """
            ---
            schema: 1
            title: Work
            project: lanework       # agent overlay
            background: {color: "fern", image: "facets.png"}
            icon: tray
            ---
            Board description.

            """)
    }

    /// The emitted mapping is read back by the reader the app uses — a name with YAML-significant
    /// characters in it included, which is the reason strings are always quoted in flow context.
    @Test("An awkward file name round-trips through the emitted mapping")
    func awkwardNamesRoundTrip() throws {
        var document = try document("background: {color: fern}")
        document.setBackgroundImage("a, b}.png")

        let reparsed = try FrontmatterDocument.parse(document.serialized())
        #expect(reparsed.backgroundImage == .valid("a, b}.png"))
        #expect(reparsed.background == .valid("fern"))
    }
}

// MARK: - Where an image may point

@Suite("Board background ▸ the image path stays inside the board")
struct BackgroundImagePathTests {

    private let root = URL(fileURLWithPath: "/Users/someone/Boards/Work.kanban", isDirectory: true)

    @Test("A plain name and a nested path resolve inside the board")
    func resolvesRelativePaths() {
        #expect(BoardBackdrop.imageURL(named: "sunset.jpg", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/sunset.jpg")
        #expect(BoardBackdrop.imageURL(named: "art/backdrops/sunset.png", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/art/backdrops/sunset.png")
    }

    /// **Both locations resolve through the one rule, unchanged** (ruled 2026-08-09): a legacy board
    /// naming a bare `facets.png` at board root and a current one naming the qualified
    /// `.backgrounds/facets.png` both land as ordinary nested-path resolutions — `.backgrounds/` is
    /// simply a subfolder name to this check, exactly as `art/backdrops/` already was above. No new
    /// resolver machinery exists for the app-claimed folder; this is the "minimal change" the ruling
    /// chose, pinned as its own test.
    @Test("A legacy root-level image and a current .backgrounds/ one both resolve")
    func resolvesBothLegacyAndCurrentLocations() {
        #expect(BoardBackdrop.imageURL(named: "facets.png", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/facets.png")
        #expect(BoardBackdrop.imageURL(named: ".backgrounds/facets.png", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/.backgrounds/facets.png")
    }

    /// The check is about where the path *ends up*, not how it is spelled: a climb that lands back
    /// inside the board is an ordinary file in it.
    @Test("A path that climbs and returns is still inside")
    func resolvesPathsThatStandardizeBackInside() {
        #expect(BoardBackdrop.imageURL(named: "art/../sunset.jpg", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/sunset.jpg")
        #expect(BoardBackdrop.imageURL(named: "./sunset.jpg", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/sunset.jpg")
    }

    /// A `.kanban` folder is a document — it gets copied, zipped and handed to somebody else — so a
    /// background that only works on the Mac it was written on resolves to nothing at all.
    @Test("Escapes and absolute paths resolve to nothing")
    func rejectsEscapes() {
        #expect(BoardBackdrop.imageURL(named: "../escape.jpg", inBoardRoot: root) == nil)
        #expect(BoardBackdrop.imageURL(named: "art/../../escape.jpg", inBoardRoot: root) == nil)
        #expect(BoardBackdrop.imageURL(named: "/etc/passwd", inBoardRoot: root) == nil)
        // Appended rather than rejected, an absolute path would land as `<root>/etc/passwd` and pass
        // containment while naming a file the author plainly did not mean.
        #expect(BoardBackdrop.imageURL(named: "/sunset.jpg", inBoardRoot: root) == nil)
        #expect(BoardBackdrop.imageURL(named: "", inBoardRoot: root) == nil)
        #expect(BoardBackdrop.imageURL(named: ".", inBoardRoot: root) == nil)
    }

    /// The trailing separator in the containment test, doing its job: a sibling whose name merely
    /// starts with this board's is a different board.
    @Test("A sibling folder with a prefixed name is not inside")
    func rejectsAPrefixedSibling() {
        #expect(BoardBackdrop.imageURL(named: "../Work.kanban.backup/sunset.jpg", inBoardRoot: root) == nil)
    }

    /// `~` is not expanded and is not special: only a shell ever meant a home folder by it, and a
    /// file honestly named that way sits in the board like any other.
    @Test("A tilde is an ordinary character in a file name")
    func treatsTildeAsAnOrdinaryCharacter() {
        #expect(BoardBackdrop.imageURL(named: "~notes.png", inBoardRoot: root)?.path
            == "/Users/someone/Boards/Work.kanban/~notes.png")
    }

    /// The whole-model reading, which is what the view and the window chrome ask: no key, an
    /// unresolvable one, and a good one.
    @Test("The board-level reading follows the field and the path rule")
    func readsTheBoardsOwnField() throws {
        func board(_ frontmatter: String) throws -> BoardModel {
            BoardModel(
                rootURL: root,
                schema: 1,
                title: .missing,
                created: .missing,
                modified: .missing,
                modifiedBy: .missing,
                deleted: .missing,
                background: .missing,
                backgroundImage: try document(frontmatter).backgroundImage,
                icon: .missing,
                iconColor: .missing,
                template: nil,
                lanes: [],
                document: try document(frontmatter)
            )
        }

        #expect(BoardBackdrop.imageURL(for: try board("background: {color: fern}"), root: root) == nil)
        #expect(BoardBackdrop.imageURL(for: try board("background: {image: ../x.jpg}"), root: root) == nil)
        #expect(BoardBackdrop.imageURL(for: try board("background: {image: sunset.jpg}"), root: root)?.path
            == "/Users/someone/Boards/Work.kanban/sunset.jpg")
    }

    /// **The window-chrome predicate** (`BoardWindowHost`): a board paints a background of its own
    /// when a colour resolves or an image path lands inside the board — a path that could never
    /// paint anything leaves the standard chrome alone.
    @Test("The chrome predicate answers for colour, image, both and neither")
    func answersTheChromePredicate() throws {
        func board(_ frontmatter: String) throws -> BoardModel {
            let parsed = try document(frontmatter)
            return BoardModel(
                rootURL: root,
                schema: 1,
                title: .missing,
                created: .missing,
                modified: .missing,
                modifiedBy: .missing,
                deleted: .missing,
                background: parsed.background,
                backgroundImage: parsed.backgroundImage,
                icon: .missing,
                iconColor: .missing,
                template: nil,
                lanes: [],
                document: parsed
            )
        }

        #expect(BoardBackdrop.isCustom(try board("schema: 1"), root: root) == false)
        #expect(BoardBackdrop.isCustom(try board("background: {color: fern}"), root: root))
        #expect(BoardBackdrop.isCustom(try board("background: {image: sunset.jpg}"), root: root))
        #expect(BoardBackdrop.isCustom(try board("background: {color: fern, image: sunset.jpg}"), root: root))
        // Neither half resolves: an unrecognized colour name and a path that leaves the board.
        #expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false)
    }
}

// MARK: - The window chrome that follows the background

/// **A board that paints a background runs its content under a clear title bar** (03-board-ui.md §
/// Styling ▸ Capabilities), and that posture is said in two places on purpose.
///
/// `NSWindow.titlebarAppearsTransparent` belongs to SwiftUI on a scene window — it is the AppKit
/// face of `toolbarBackgroundVisibility(for: .windowToolbar)`, and SwiftUI writes the value its tree
/// resolves on every pass in which it re-applies a window's configuration. A tree that said nothing
/// resolved `.automatic`, so those passes wrote `false` back over the flag `HostedWindowController`
/// had set out of band: the board opened correct and its title bar turned opaque some time later,
/// painting its own material and its separator hairline across a picture still being drawn full
/// height underneath. So `BoardWindowHost` states it in SwiftUI as well, and the two tests here pin
/// the two halves — the rule SwiftUI is told, and the window the controller writes.
@MainActor
@Suite("Board background ▸ the window chrome follows it")
struct BoardChromeTests {

    @Test("The toolbar background is gone over a board that paints one, and the system's otherwise")
    func toolbarBackgroundFollowsTheBoard() {
        #expect(BoardWindowHost.toolbarBackground(overCustomBackground: true) == .hidden)
        // `.automatic`, never `.visible` — a board with no background of its own keeps whatever the
        // system would have drawn, which is what "the standard chrome, untouched" means.
        #expect(BoardWindowHost.toolbarBackground(overCustomBackground: false) == .automatic)
    }

    @Test("The controller says nothing about a window's title bar until it is told")
    func silentUntilTold() {
        let window = Self.window()
        let controller = HostedWindowController()
        controller.attach(to: window)

        // `nil` and `false` render identically; they differ only in whether this controller has
        // spoken, which is what keeps the loading half from stating a default it does not own.
        #expect(window.titlebarAppearsTransparent == false)
        #expect(window.styleMask.contains(.fullSizeContentView) == false)
    }

    @Test("Both knobs move together, in both directions, however often they are set")
    func bothKnobsMoveTogether() {
        let window = Self.window()
        let controller = HostedWindowController()
        controller.attach(to: window)

        controller.setExtendsContentUnderTitlebar(true)
        #expect(window.titlebarAppearsTransparent)
        #expect(window.styleMask.contains(.fullSizeContentView))

        // Repeat-safe: the board drives this off its snapshot, so it is called on every reload that
        // changes the reading and on plenty that do not.
        controller.setExtendsContentUnderTitlebar(true)
        #expect(window.titlebarAppearsTransparent)
        #expect(window.styleMask.contains(.fullSizeContentView))

        // And back — a `background:` deleted on disk puts the standard chrome back.
        controller.setExtendsContentUnderTitlebar(false)
        #expect(window.titlebarAppearsTransparent == false)
        #expect(window.styleMask.contains(.fullSizeContentView) == false)
    }

    @Test("The posture survives the provisional-window swap")
    func theSlotReappliesToTheNextWindow() {
        // SwiftUI dismantles and re-makes the background representable while it moves a scene's
        // content into its final window, so a posture stated against the first window has to reach
        // the second (`HostedWindowController.detach`).
        let provisional = Self.window()
        let controller = HostedWindowController()
        controller.attach(to: provisional)
        controller.setExtendsContentUnderTitlebar(true)

        controller.detach()
        let real = Self.window()
        controller.attach(to: real)

        #expect(real.titlebarAppearsTransparent)
        #expect(real.styleMask.contains(.fullSizeContentView))
    }

    private static func window() -> NSWindow {
        NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: true
        )
    }
}
