import Foundation
import Testing
@testable import Kanban

/// The executable spec for the write path's fidelity guarantees — the m2 "write-path fidelity
/// test suite" card. `BoardWriterTests.swift` already pins per-operation behavior file by file
/// (one suite per `BoardWriter` call); this file pins the guarantees that cut *across* calls and
/// across the whole tree, the properties no single per-operation test is positioned to state.
///
/// Four of the card's six guarantees are already fully pinned in `BoardWriterTests.swift` and are
/// not repeated here — only referenced:
///
/// - **Byte-identical body round-trip**, by direct byte comparison —
///   `BoardWriterPreservationTests.aFrontmatterEditLeavesTheBodyByteIdentical`.
/// - **Atomicity**: temp-file+rename leaves no residue on success —
///   `BoardWriterPreservationTests.aSuccessfulWriteLeavesNoTempFileBehind`; an injected mid-write
///   failure (an unwritable parent folder) leaves the original bytes *and* the directory listing
///   untouched, temp file included — `BoardWriterFailureTests
///   .aFailedWriteLeavesTheFileAndTheFolderExactlyAsTheyWere`. Audited: both halves of "verifiable
///   temp-file+rename, nothing left behind either way" already exist; there is no missing piece.
/// - **Move/copy identity** — UUID preservation, fresh minting, and the import-boundary collision
///   repair, including the per-folder compound case (a lane arriving with one colliding card stays
///   a lane *move*) — `BoardWriterMoveTests`/`BoardWriterCopyTests` in full, in particular
///   `aLaneMoveRemintsOnlyTheCollidingCard`.
///
/// What follows fills the remaining gaps: minimal-touch stated with **mtimes**, not just bytes
/// (existing sibling-byte assertions never look at the filesystem's own "did this file move"
/// signal); the renumber fallback stated as the *positive* exception across a whole board rather
/// than within one lane; delete→restore at full byte precision against the pre-delete original
/// (existing coverage checks the *result* is undeleted and reordered correctly, not that the
/// bytes differ from the original by exactly one line); unknown-key order through a writer op
/// with keys deliberately interleaved among schema-owned ones (existing coverage groups the
/// unknown keys together); and one end-to-end composite scenario tying every guarantee together.

// MARK: - Shared helpers

/// A file's bytes and on-disk modification date — the two facts "minimal touch" promises stay
/// put for every `index.md` a mutation does not target.
private func snapshot(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
    let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
    let data = try Data(contentsOf: indexURL)
    let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
    guard let modified = attributes[.modificationDate] as? Date else {
        Issue.record("no modification date for \(relativePath)")
        return (data, .distantPast)
    }
    return (data, modified)
}

/// Every folder under the fixture root holding an `index.md`, relative to the root (`""` for the
/// root itself) — recomputed on demand since `createCard`/`createLane` grow the set mid-test.
private func allIndexFolders(_ fixture: WriterFixture) -> [String] {
    guard let walker = FileManager.default.enumerator(
        at: fixture.root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
    ) else { return [] }
    let rootPath = fixture.root.standardizedFileURL.path
    var folders: [String] = []
    for case let url as URL in walker where url.lastPathComponent == "index.md" {
        let folderPath = url.deletingLastPathComponent().standardizedFileURL.path
        if folderPath == rootPath {
            folders.append("")
        } else if folderPath.hasPrefix(rootPath + "/") {
            folders.append(String(folderPath.dropFirst(rootPath.count + 1)))
        }
    }
    return folders.sorted()
}

/// The file's lines minus every line that opens one of `keys` — a local twin of
/// `BoardWriterTests.swift`'s file-private `lines(of:excludingKeys:)`; each file keeps its own
/// since the declaration is `private` (file-scoped) and this file's fixtures are its own.
private func lines(of text: String, excludingKeys keys: [String]) -> [String] {
    text.components(separatedBy: "\n")
        .filter { line in !keys.contains { line.hasPrefix("\($0):") } }
}

/// A couple of extra literal identities beyond `Ident`'s: this file's boards run wider (2×2
/// grids, a renumber lane with an "other lane" alongside it) than `Ident`'s four lane/card slots
/// cover on their own.
private enum MoreIdent {
    static let card5 = "aaaaaaaa-1111-4111-8111-111111111112"
}

// MARK: - Minimal touch, with mtimes

/// 01-storage-format.md § Ordering, "a reorder rewrites only the moved item's `index.md`", and §
/// Fractal layout ▸ Rules' round-trip guarantee — the mtime half. A same-content rewrite is
/// invisible to a byte comparison but not to the filesystem, so "untouched" has to mean both.
struct WriteFidelityMinimalTouchTests {
    /// One realistic editing session — reorder, style, rename, delete, restore, create, import —
    /// run back to back on a 2-lane × 2-card board. After each step, every `index.md` the step
    /// did not target is asserted byte-identical *and* mtime-identical to its state just before
    /// that step; the targeted file is asserted to have actually changed (proof the snapshot
    /// method is sensitive to a real edit, not vacuously passing). The last step — an attachment
    /// import — targets *nothing*: `importAttachments` never opens an `index.md` at all, so even
    /// the host card's own frontmatter must come through unmoved.
    @Test func aSequenceOfEverydayMutationsLeavesEveryOtherIndexByteAndMtimeIdentical() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        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: "Card One"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
        try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Card Three"))
        try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Card Four"))

        var snapshots: [String: (data: Data, modified: Date)] = [:]
        for folder in allIndexFolders(fixture) {
            snapshots[folder] = try snapshot(fixture, folder)
        }

        func step(_ label: String, targeting targets: Set<String>, _ operation: () throws -> Void) throws {
            try operation()

            for (folder, before) in snapshots where !targets.contains(folder) {
                let after = try snapshot(fixture, folder)
                #expect(after.data == before.data, "\(label) touched \(folder)'s bytes")
                #expect(after.modified == before.modified, "\(label) touched \(folder)'s mtime")
            }
            for target in targets {
                let before = try #require(snapshots[target])
                let after = try snapshot(fixture, target)
                #expect(after.data != before.data, "\(label) left \(target) byte-identical")
                snapshots[target] = after
            }
            for folder in allIndexFolders(fixture) where snapshots[folder] == nil {
                snapshots[folder] = try snapshot(fixture, folder)
            }
        }

        try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) {
            try BoardWriter.updateIndex(
                inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: .style(title: nil)
            ) { $0.set(FrontmatterKeys.order, to: .double(1536)) }
        }
        try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
            try BoardWriter.updateIndex(
                inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil)
            ) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
        }
        try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
            try BoardWriter.updateIndex(
                inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil)
            ) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
        }
        try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
            try BoardWriter.deleteItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
        }
        try step("restore", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
            try BoardWriter.restoreItem(at: fixture.url("\(Ident.lane2)/\(Ident.card4)"))
        }

        var createdCard = ""
        try step("create card", targeting: []) {
            createdCard = try BoardWriter.createCard(inLane: fixture.url(Ident.lane1), title: "Fresh").rawValue
        }
        #expect(BoardLoader.isUUIDShaped(createdCard))

        try step("import attachment", targeting: []) {
            // Dot-prefixed so it never becomes a board-root stray the loader has to warn about
            // — the source lives outside the board tree in spirit, just not in path.
            let source = try fixture.file(".sources/shot.png", Data([0x01, 0x02]))
            _ = try BoardWriter.importAttachments([source], intoCard: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        }

        #expect(try BoardLoader.load(boardRoot: fixture.root).warnings.isEmpty)
    }
}

// MARK: - The renumber exception, stated positively

/// 01-storage-format.md § Ordering, "the one exception to the only-the-moved-item rule":
/// `renumberVisibleChildren` rewrites *every* visible sibling in the lane it renumbers — bytes
/// and mtimes both move — while everything outside that lane, tombstones inside it included,
/// stays put. `BoardWriterRenumberTests` already pins the within-lane byte half (tombstones and
/// strays untouched); this test widens the lens to the whole board and adds the mtime half on
/// both sides of the exception.
struct WriteFidelityRenumberTests {
    @Test func renumberRewritesTheWholeLaneButLeavesEverythingElseByteAndMtimeIdentical() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        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: "1.0000003", title: "A"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000001", title: "B"))
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "1.0000002", title: "C"))
        try fixture.item(
            "\(Ident.lane1)/\(Ident.card4)",
            "---\nschema: 1\norder: 0.5\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
        )
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
        try fixture.item("\(Ident.lane2)/\(MoreIdent.card5)", Item.rich(order: "1024", title: "Other Lane's Card"))

        let untouched = ["", Ident.lane1, Ident.lane2, "\(Ident.lane2)/\(MoreIdent.card5)", "\(Ident.lane1)/\(Ident.card4)"]
        let renumbered = ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane1)/\(Ident.card2)", "\(Ident.lane1)/\(Ident.card3)"]
        var before: [String: (data: Data, modified: Date)] = [:]
        for path in untouched + renumbered { before[path] = try snapshot(fixture, path) }

        try BoardWriter.renumberVisibleChildren(of: fixture.url(Ident.lane1))

        for path in untouched {
            let previous = try #require(before[path])
            let after = try snapshot(fixture, path)
            #expect(after.data == previous.data)
            #expect(after.modified == previous.modified)
        }
        for path in renumbered {
            let previous = try #require(before[path])
            let after = try snapshot(fixture, path)
            #expect(after.data != previous.data)
            #expect(after.modified != previous.modified)
        }

        let orders = try [Ident.card2, Ident.card3, Ident.card1].map {
            try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
        }
        #expect(orders == [.valid(1024), .valid(2048), .valid(3072)])
    }
}

// MARK: - Delete → restore, byte precision

/// 01-storage-format.md § Deletion: Put Back "undoes exactly what `deleteItem` wrote". Existing
/// coverage (`BoardWriterDeleteRestoreTests`) checks the *result* — undeleted, reordered
/// correctly — this test checks the *bytes*: after a full delete→restore round trip, the file
/// differs from the pre-delete original in exactly one place, the `modified:` line, with every
/// comment, inline comment, unknown key, and per-line ending untouched, and zero occurrences of
/// `deleted` anywhere in the text.
struct WriteFidelityTombstoneTests {
    @Test func deleteThenRestoreDiffersFromTheOriginalOnlyInTheModifiedTimestamp() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
        let original = "---\n"
            + "schema: 1\n"
            + "# a hand-written note\n"
            + "title: Original\n"
            + "order: 1536\n"
            + "project: lanework       # agent overlay\n"
            + "sphere: work\r\n"
            + "labels: [a, b, c]\n"
            + "modified: 2026-01-01T00:00:00Z\n"
            + "---\n"
            + "Body text.\n\nMore body — with *markdown*.\n"
        let cardPath = "\(Ident.lane1)/\(Ident.card1)"
        let folder = try fixture.item(cardPath, original)
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", "---\nschema: 1\norder: 2048\ntitle: Sibling\n---\n")

        try BoardWriter.deleteItem(at: folder)
        #expect(try FrontmatterDocument.parse(fixture.indexText(cardPath)).deleted.value != nil)

        try BoardWriter.restoreItem(at: folder)
        let after = try fixture.indexText(cardPath)

        #expect(lines(of: after, excludingKeys: [FrontmatterKeys.modified])
            == lines(of: original, excludingKeys: [FrontmatterKeys.modified]))
        // The untouched CRLF unknown-key line and the inline comment both survived verbatim.
        #expect(after.contains("sphere: work\r\n"))
        #expect(after.contains("project: lanework       # agent overlay\n"))
        // No residue of the key that made this item a tombstone, anywhere in the text.
        #expect(!after.contains("deleted"))

        let document = try FrontmatterDocument.parse(after)
        #expect(document.deleted == .missing)
        #expect(document.order == .valid(1536))
        #expect(document.title == .valid("Original"))

        // Position among siblings unchanged: the loader sees the card back at its recorded
        // order, ahead of the sibling that was never touched.
        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.warnings.isEmpty)
        let cards = try #require(result.model.lanes.first?.cards)
        #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
        #expect(cards[0].isDeleted == false)
        #expect(cards[0].order == 1536)
    }
}

// MARK: - Unknown-key order through a writer op

/// 01-storage-format.md § Fractal layout ▸ Rules, "unknown frontmatter keys and their order are
/// preserved verbatim on every rewrite" — load-bearing for agent overlays (08-agent-integration.md).
/// `BoardWriterPreservationTests.aTitleEditTouchesOnlyTheTitleAndTheStamps` already pins this for
/// three unknown keys grouped contiguously; this test strengthens it with five, deliberately
/// interleaved among the schema-owned keys rather than clustered, so the guarantee is about
/// *document order*, not merely "the unknown-keys subsequence".
struct WriteFidelityUnknownKeyOrderTests {
    @Test func interleavedUnknownKeysKeepTheirSequenceThroughATitleRewrite() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let original = """
            ---
            schema: 1
            alpha: 1
            title: Before
            beta: 2
            order: 1024
            gamma: 3
            created: 2026-01-01T00:00:00Z
            delta: 4
            modified: 2026-01-01T00:00:00Z
            epsilon: 5
            ---
            Body.

            """
        let folder = try fixture.item("card", original)

        try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
            document.set(FrontmatterKeys.title, to: .string("After"))
        }

        let after = try fixture.indexText("card")
        let excluded = [FrontmatterKeys.title, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy]
        #expect(lines(of: after, excludingKeys: excluded) == lines(of: original, excludingKeys: excluded))

        let document = try FrontmatterDocument.parse(after)
        #expect(document.unknownFields.map(\.key) == ["alpha", "beta", "gamma", "delta", "epsilon"])
        #expect(document.keys == [
            "schema", "alpha", "title", "beta", "order", "gamma", "created", "delta", "modified", "epsilon",
        ])
        #expect(document.title == .valid("After"))
    }
}

// MARK: - Cross-cutting composite

/// The "a brand-new board is indistinguishable from a hand-edited one" claim, composed into one
/// session: a board built purely through `BoardWriter`, one file hand-edited outside it entirely
/// (simulating an agent or a human in a text editor), then moved, copied, tombstoned, restored,
/// and renumbered — and at the end `BoardLoader.load` succeeds with zero warnings, the hand-added
/// unknown keys survived, and every title and body reads back exactly as set.
struct WriteFidelityCompositeTests {
    @Test func aBoardBuiltThroughTheWriterAndThenHandEditedLoadsCleanlyAfterEveryOperation() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let root = fixture.url("Composite.kanban")

        try BoardWriter.createBoard(at: root, title: "Composite")
        let lane1 = try BoardWriter.createLane(inBoard: root, title: "Todo")
        let lane2 = try BoardWriter.createLane(inBoard: root, title: "Doing")
        let lane1Folder = root.appendingPathComponent(lane1.rawValue, isDirectory: true)
        let lane2Folder = root.appendingPathComponent(lane2.rawValue, isDirectory: true)

        let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First")
        let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second")
        let card3 = try BoardWriter.createCard(inLane: lane1Folder, title: "Third")
        for (id, body) in [(card1, "First body.\n"), (card2, "Second body.\n"), (card3, "Third body.\n")] {
            try BoardWriter.updateIndex(
                inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: .style(title: nil)
            ) { $0.body = body }
        }

        // A hand edit: an agent opens Second's file directly and adds unknown keys plus a
        // comment — no `BoardWriter` call involved, exactly what "indistinguishable" has to
        // survive everything that follows.
        let card2Path = "Composite.kanban/\(lane1.rawValue)/\(card2.rawValue)"
        let handEdited = try fixture.indexText(card2Path).replacingOccurrences(
            of: "modified:",
            with: "project: lanework\nsphere: work  # agent overlay\n# a note\nmodified:"
        )
        try fixture.item(card2Path, handEdited)

        // Move: First crosses into Doing — same board, so identity travels unchanged.
        let moveResult = try BoardWriter.moveItem(
            at: lane1Folder.appendingPathComponent(card1.rawValue),
            toParent: lane2Folder,
            sourceBoardRoot: root,
            destinationBoardRoot: root,
            order: nil
        )
        #expect(moveResult.id == card1)
        #expect(moveResult.reminted.isEmpty)

        // Copy: Third forks into Doing alongside it — a new identity, the original left in Todo.
        let copyID = try BoardWriter.copyItem(
            at: lane1Folder.appendingPathComponent(card3.rawValue), toParent: lane2Folder, order: nil, stamps: .fork
        )
        #expect(copyID != card3)

        // Delete, then restore, the hand-edited card — its unknown keys and comment must come
        // back with no residue of `deleted`.
        try BoardWriter.deleteItem(at: lane1Folder.appendingPathComponent(card2.rawValue))
        try BoardWriter.restoreItem(at: lane1Folder.appendingPathComponent(card2.rawValue))

        // Renumber both lanes — the fallback that touches every visible sibling's `order`.
        try BoardWriter.renumberVisibleChildren(of: lane1Folder)
        try BoardWriter.renumberVisibleChildren(of: lane2Folder)

        let result = try BoardLoader.load(boardRoot: root)
        #expect(result.warnings.isEmpty)
        #expect(result.model.title == .valid("Composite"))

        let lanes = Dictionary(uniqueKeysWithValues: result.model.lanes.map { ($0.id, $0) })
        let todo = try #require(lanes[lane1])
        let doing = try #require(lanes[lane2])

        // Todo kept the two cards that never left: Second (hand-edited, survived a full
        // delete/restore round trip) and Third (untouched original, still resident after being
        // only *copied*, never moved).
        #expect(Set(todo.cards.map(\.id)) == [card2, card3])
        let restoredSecond = try #require(todo.cards.first { $0.id == card2 })
        #expect(restoredSecond.title == .valid("Second"))
        #expect(restoredSecond.body == "Second body.\n")
        #expect(restoredSecond.isDeleted == false)
        #expect(restoredSecond.document.unknownFields.map(\.key) == ["project", "sphere"])
        #expect(restoredSecond.document.rawValue(for: "project") == "lanework")
        let untouchedThird = try #require(todo.cards.first { $0.id == card3 })
        #expect(untouchedThird.title == .valid("Third"))
        #expect(untouchedThird.body == "Third body.\n")

        // Doing holds the mover (original identity) and the fork (a fresh one).
        #expect(Set(doing.cards.map(\.id)) == [card1, copyID])
        let movedFirst = try #require(doing.cards.first { $0.id == card1 })
        #expect(movedFirst.title == .valid("First"))
        #expect(movedFirst.body == "First body.\n")
        let forkedThird = try #require(doing.cards.first { $0.id == copyID })
        #expect(forkedThird.title == .valid("Third"))
        #expect(forkedThird.body == "Third body.\n")
    }
}
