import Foundation
import Testing
@testable import Kanban

// MARK: - Fixture builder

/// A tiny in-memory-driven builder for synthetic board trees under a temp directory. Smoke
/// coverage only (per 02-architecture.md § Testing) — a comprehensive golden-fixture suite
/// over real trees under `Fixtures/` is a separate, later card.
private struct BoardFixture {
    let root: URL

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

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

    /// Writes `index.md` at `relativePath` (created if needed), with `body` after the
    /// frontmatter delimiters.
    @discardableResult
    func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
        let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        let text = "---\n\(frontmatter)---\n\(body)"
        try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
        return folder
    }

    /// Creates a folder with no `index.md` — the "interrupted two-step create" shape.
    @discardableResult
    func emptyFolder(_ relativePath: String) throws -> URL {
        let folder = root.appendingPathComponent(relativePath, isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        return folder
    }

    /// A stray file, not a folder — never a level candidate regardless of its name.
    func strayFile(_ relativePath: String, contents: String = "stray") throws {
        let url = root.appendingPathComponent(relativePath)
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try contents.write(to: url, atomically: true, encoding: .utf8)
    }
}

/// A fresh folder name with UUIDv4's shape (lowercase hex, `8-4-4-4-12`) — the only shape
/// `BoardLoader` accepts as a lane/card candidate (01-storage-format.md § Fractal layout ▸
/// Rules, "Name shape gates level detection"). Used wherever a test just needs *a* valid
/// lane/card identity and doesn't care about the exact value; tests that need a specific
/// lexicographic ordering use literal UUID-shaped strings instead.
private func uuidFolderName() -> String {
    UUID().uuidString.lowercased()
}

private func expectFailure(
    _ expectedReason: BoardLoadError.Reason,
    path: String,
    _ operation: () throws -> Void
) {
    do {
        try operation()
        Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded")
    } catch let error as BoardLoadError {
        #expect(error.path == path)
        #expect(error.reason == expectedReason)
    } catch {
        Issue.record("expected a BoardLoadError, got \(error)")
    }
}

// MARK: - Well-formed board

struct BoardLoaderWellFormedTests {
    @Test func loadsCompleteStructureWithOrderingAndTombstoneFlags() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let laneA = uuidFolderName()
        let laneB = uuidFolderName()
        let cardFirst = uuidFolderName()
        let cardSecond = uuidFolderName()
        let cardDeleted = uuidFolderName()

        try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n")
        try fixture.index(laneB, "schema: 1\norder: 2048\ntitle: B Lane\n")
        try fixture.index(laneA, "schema: 1\norder: 1024\ntitle: A Lane\n")

        try fixture.index("\(laneA)/\(cardSecond)", "schema: 1\norder: 2048\ntitle: Second\n")
        try fixture.index("\(laneA)/\(cardFirst)", "schema: 1\norder: 1024\ntitle: First\n")
        try fixture.index(
            "\(laneA)/\(cardDeleted)",
            "schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n"
        )

        let result = try BoardLoader.load(boardRoot: fixture.root)
        let model = result.model

        #expect(model.title.value == "Demo Board")
        #expect(model.schema == 1)
        if case let .mapping(pairs) = model.template {
            #expect(pairs.count == 1)
        } else {
            Issue.record("expected template to be a mapping, got \(String(describing: model.template))")
        }

        #expect(model.lanes.map(\.id.rawValue) == [laneA, laneB])

        let lane = try #require(model.lanes.first { $0.id.rawValue == laneA })
        #expect(lane.cards.map(\.id.rawValue) == [cardDeleted, cardFirst, cardSecond])
        #expect(lane.cards.map(\.isDeleted) == [true, false, false])
        #expect(result.warnings.isEmpty)
    }

    /// Tombstone semantics key on the `deleted` key's *presence*, not its validity
    /// (01-storage-format.md § Frontmatter, § Deletion): a `deleted` value with no sensible
    /// date reading still tombstones — the user's intent to delete outranks the broken date.
    @Test func malformedDeletedValueStillTombstonesLaneAndCard() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let card = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\ndeleted: yesterday\n")
        try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        let loadedLane = try #require(result.model.lanes.first { $0.id.rawValue == lane })
        #expect(loadedLane.deleted == .malformed(raw: "yesterday"))
        #expect(loadedLane.isDeleted)

        let loadedCard = try #require(loadedLane.cards.first { $0.id.rawValue == card })
        #expect(loadedCard.deleted == .malformed(raw: "yesterday"))
        #expect(loadedCard.isDeleted)
    }

    @Test func tiesAreBrokenByFolderNameNotTitle() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        // Both UUID-shaped so both are lane candidates; chosen so their lexicographic order
        // ('0' < 'f') is known ahead of time.
        let laneFirst = "00000000-0000-4000-8000-000000000000"
        let laneSecond = "ffffffff-ffff-4fff-8fff-ffffffffffff"

        try fixture.index("", "schema: 1\n")
        // Same order; titles are reversed relative to folder-name order, to catch a loader
        // that accidentally wires title into the tie-break instead of the folder name.
        try fixture.index(laneSecond, "schema: 1\norder: 1024\ntitle: Should Be Second\n")
        try fixture.index(laneFirst, "schema: 1\norder: 1024\ntitle: Should Be First\n")

        let model = try BoardLoader.load(boardRoot: fixture.root).model
        #expect(model.lanes.map(\.id.rawValue) == [laneFirst, laneSecond])
    }
}

// MARK: - Skip rules (UUID-shaped candidates only)

struct BoardLoaderSkipTests {
    @Test func indexlessUUIDFolderBelowRootIsSkippedWithMissingIndexWarning() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let orphanLane = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.emptyFolder(orphanLane)

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.map(\.id.rawValue) == [lane])
        #expect(result.warnings.contains(.missingIndex(path: orphanLane)))
    }

    @Test func indexlessUUIDCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let card = uuidFolderName()
        let orphanCard = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
        try fixture.emptyFolder("\(lane)/\(orphanCard)")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        let loadedLane = try #require(result.model.lanes.first)
        #expect(loadedLane.cards.map(\.id.rawValue) == [card])
        #expect(result.warnings.contains(.missingIndex(path: "\(lane)/\(orphanCard)")))
    }
}

// MARK: - Strays ignored (non-directories, hidden entries, symlinks)

struct BoardLoaderStrayTests {
    @Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let card = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")

        try fixture.strayFile("notes.txt")
        try fixture.strayFile(".DS_Store")
        try fixture.strayFile("\(lane)/notes.txt")
        _ = try fixture.emptyFolder(".git")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.map(\.id.rawValue) == [lane])
        #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card])
        #expect(result.warnings.isEmpty)
    }

    @Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let realLane = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        let realLaneURL = try fixture.index(realLane, "schema: 1\norder: 1024\n")
        try FileManager.default.createSymbolicLink(
            at: fixture.root.appendingPathComponent("linked-lane"),
            withDestinationURL: realLaneURL
        )

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.map(\.id.rawValue) == [realLane])
        #expect(result.warnings.isEmpty)
    }
}

// MARK: - Non-UUID-shaped folders are strays (01-storage-format.md § Fractal layout ▸ Rules,
// "Name shape gates level detection")

struct BoardLoaderNonUUIDStrayTests {
    /// A non-UUID-shaped folder is a stray even when its `index.md` is perfectly valid
    /// lane-shaped content — the name shape gates candidacy before the file is ever read.
    @Test func nonUUIDFolderWithValidLaneShapedIndexIsIgnoredWithWarningAndBoardLoads() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let realLane = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(realLane, "schema: 1\norder: 1024\n")
        try fixture.index("todo", "schema: 1\norder: 2048\ntitle: Hand-authored lane\n")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.map(\.id.rawValue) == [realLane])
        #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo")))
    }

    /// The motivating case: before this rule, a non-UUID folder with a broken `index.md`
    /// (missing required `order`) would fail-fast the whole load. Now the name shape gates it
    /// out as a stray before the loader ever parses the file, so the rest of the board still
    /// loads.
    @Test func nonUUIDFolderWithBrokenIndexIsIgnoredWithWarningAndBoardStillLoads() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let realLane = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(realLane, "schema: 1\norder: 1024\n")
        // Missing 'order' — would be a fail-fast .missingOrder if this were UUID-shaped.
        try fixture.index("todo", "schema: 1\ntitle: Broken hand-authored lane\n")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.map(\.id.rawValue) == [realLane])
        #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "todo")))
    }

    /// Same motivating case, one level down: a non-UUID card-depth folder with a broken
    /// `index.md` is a stray, not a fail-fast, and doesn't stop its lane's other cards loading.
    @Test func nonUUIDCardFolderWithBrokenIndexIsIgnoredWithWarningAndLaneStillLoads() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let realCard = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(realCard)", "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/scratch", "schema: 1\n") // missing 'order' too

        let result = try BoardLoader.load(boardRoot: fixture.root)
        let loadedLane = try #require(result.model.lanes.first)
        #expect(loadedLane.cards.map(\.id.rawValue) == [realCard])
        #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: "\(lane)/scratch")))
    }

    /// Case sensitivity: an uppercase (or mixed-case) UUID string doesn't have UUIDv4's
    /// *lowercase* shape, so it's a stray — folder names are never normalized.
    @Test func uppercaseUUIDFolderIsTreatedAsNonUUIDStray() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let realLane = uuidFolderName()
        let uppercaseLane = UUID().uuidString // Foundation renders this uppercase.

        try fixture.index("", "schema: 1\n")
        try fixture.index(realLane, "schema: 1\norder: 1024\n")
        try fixture.index(uppercaseLane, "schema: 1\norder: 2048\n")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.map(\.id.rawValue) == [realLane])
        #expect(result.warnings.contains(.nonUUIDFolderIgnored(path: uppercaseLane)))
    }

    /// Reserved card children are covered "by construction" now: `attachments/` and
    /// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents
    /// anyway (cards are leaves) — either way, they must never surface a warning.
    @Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let card = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
        try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png")
        try fixture.strayFile("\(lane)/\(card)/comments/whatever.md")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.first?.cards.map(\.id.rawValue) == [card])
        #expect(result.warnings.isEmpty)
    }
}

// MARK: - Board-level deleted

struct BoardLoaderBoardLevelDeletedTests {
    @Test func boardLevelDeletedIsIgnoredAndWarned() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n")

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.model.lanes.isEmpty)
        #expect(result.warnings.contains(.boardLevelDeletedIgnored))
    }
}

// MARK: - Fail-fast

struct BoardLoaderFailFastTests {
    @Test func rootMissingIndexThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        expectFailure(.boardRootMissingIndex, path: "index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }

    @Test func rootThatIsAFileThrowsNotADirectory() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let fileRoot = fixture.root.appendingPathComponent("not-a-folder")
        try "hello".write(to: fileRoot, atomically: true, encoding: .utf8)

        expectFailure(.notADirectory, path: ".") {
            _ = try BoardLoader.load(boardRoot: fileRoot)
        }
    }

    @Test func unreadableRootThrows() throws {
        let missing = FileManager.default.temporaryDirectory
            .appendingPathComponent("BoardLoaderTests-does-not-exist-\(UUID().uuidString)")

        do {
            _ = try BoardLoader.load(boardRoot: missing)
            Issue.record("expected a BoardLoadError but load succeeded")
        } catch {
            #expect(error.path == ".")
            if case .unreadableRoot = error.reason {
                // expected
            } else {
                Issue.record("expected .unreadableRoot, got \(error.reason)")
            }
        }
    }

    @Test func unparseableYAMLThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        // No closing delimiter.
        try "---\nschema: 1\n".write(
            to: fixture.root.appendingPathComponent("index.md"),
            atomically: true,
            encoding: .utf8
        )

        do {
            _ = try BoardLoader.load(boardRoot: fixture.root)
            Issue.record("expected a BoardLoadError but load succeeded")
        } catch {
            #expect(error.path == "index.md")
            if case .unparseableYAML = error.reason {
                // expected
            } else {
                Issue.record("expected .unparseableYAML, got \(error.reason)")
            }
        }
    }

    @Test func missingSchemaThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        try fixture.index("", "title: No Schema\n")

        expectFailure(.missingSchema, path: "index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }

    @Test func malformedSchemaThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        try fixture.index("", "schema: not-a-number\n")

        expectFailure(.malformedSchema(raw: "not-a-number"), path: "index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }

    @Test func schemaNewerThanAppThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        try fixture.index("", "schema: 2\n")

        expectFailure(.schemaNewerThanApp(found: 2), path: "index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }

    /// A UUID-shaped folder still fails fast on structurally-bad content — the name shape only
    /// gates *candidacy*, never the validity of a folder that qualifies.
    @Test func missingOrderOnUUIDLaneThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\n")

        expectFailure(.missingOrder, path: "\(lane)/index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }

    @Test func malformedOrderOnUUIDLaneThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: not-a-number\n")

        expectFailure(.malformedOrder(raw: "not-a-number"), path: "\(lane)/index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }

    @Test func missingOrderOnUUIDCardThrows() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        let lane = uuidFolderName()
        let card = uuidFolderName()

        try fixture.index("", "schema: 1\n")
        try fixture.index(lane, "schema: 1\norder: 1024\n")
        try fixture.index("\(lane)/\(card)", "schema: 1\n")

        expectFailure(.missingOrder, path: "\(lane)/\(card)/index.md") {
            _ = try BoardLoader.load(boardRoot: fixture.root)
        }
    }
}

// MARK: - Encoding strictness

/// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding
/// contract (01-storage-format.md § Fractal layout ▸ Rules) actually holds at load time: a
/// BOM'd file fails the frontmatter delimiter, a non-UTF-8 file is named as such — the same
/// strict decode `BoardWriter` uses, so a file can never load here and then refuse every write.
struct BoardLoaderEncodingTests {
    @Test func bomPrefixedBoardIndexIsRejected() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        var bytes = Data([0xEF, 0xBB, 0xBF])
        bytes.append(Data("---\nschema: 1\n---\nbody\n".utf8))
        try bytes.write(to: fixture.root.appendingPathComponent("index.md"))

        #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
            guard let loadError = error as? BoardLoadError,
                  case .unparseableYAML = loadError.reason else { return false }
            return loadError.path == "index.md"
        }
    }

    @Test func nonUTF8BoardIndexIsRejected() throws {
        let fixture = try BoardFixture()
        defer { fixture.tearDown() }

        // "café" in ISO-8859-1 — 0xE9 is not valid UTF-8.
        var bytes = Data("---\nschema: 1\ntitle: caf".utf8)
        bytes.append(0xE9)
        bytes.append(Data("\n---\n".utf8))
        try bytes.write(to: fixture.root.appendingPathComponent("index.md"))

        #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in
            guard let loadError = error as? BoardLoadError,
                  case let .unparseableYAML(message, _) = loadError.reason else { return false }
            return message == "file is not UTF-8"
        }
    }
}
