import Foundation
import Testing
@testable import Kanban

/// The **read** half of the comment thread — the window-scoped loader, the `kind: comment` field
/// table, the claimed names the thread adds, and the path-shape classification the announcer and the
/// composer will read (01-storage-format.md § Enhanced schema, storage specified 2026-07-29).
///
/// Everything here writes raw bytes and reads them back through the real loader, like every other
/// storage suite: `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

// MARK: - Fixtures

/// Literal comment identities. Deliberately spanning cases and versions — the identity predicate is
/// shape-only, and a thread must not sort or dedupe by an uppercase folder's ASCII accident.
enum CommentIdent {
    static let one = "aaaaaaaa-0000-4000-8000-000000000001"
    static let two = "bbbbbbbb-0000-4000-8000-000000000002"
    static let three = "cccccccc-0000-4000-8000-000000000003"
    static let upper = "DDDDDDDD-0000-4000-8000-000000000004"
    static let seven = "eeeeeeee-0000-7000-8000-000000000005"
}

/// A comment's `index.md` with everything a write has to leave alone: an unknown key with an inline
/// comment, a reserved tracker key, a self-reported `author`, and a body.
func commentText(
    author: String? = "Ada Lovelace",
    created: String? = "2026-01-01T09:00:00Z",
    modified: String? = "2026-01-01T09:00:00Z",
    remote: String? = nil,
    body: String = "A comment body — with *markdown*.\n"
) -> String {
    var lines = ["---", "schema: 1"]
    if let author { lines.append("author: \(author)") }
    lines.append("project: lanework       # agent overlay")
    if let remote { lines.append("remote: \(remote)") }
    if let created { lines.append("created: \(created)") }
    if let modified { lines.append("modified: \(modified)") }
    lines.append("kind: comment")
    lines.append("---")
    return lines.joined(separator: "\n") + "\n" + body
}

/// A one-lane, one-card board — the smallest thing a thread can hang off.
@discardableResult
func makeCommentBoard(_ fixture: WriterFixture) throws -> String {
    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"))
    return "\(Ident.lane1)/\(Ident.card1)"
}

func commentPath(_ id: String, inCard cardPath: String) -> String {
    "\(cardPath)/comments/\(id)"
}

// MARK: - Loading

@Suite("Comments ▸ thread load")
struct CommentThreadLoadTests {

    @Test("The thread is the UUID folders under comments/, and nothing else")
    func loadsPostedCommentsOnly() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        try fixture.item("\(card)/comments/.draft", commentText(created: nil, modified: nil))
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
        #expect(thread.hasDraft)
        #expect(thread.strays.isEmpty)
    }

    @Test("A card with no comments/ answers empty, and that is not a defect")
    func noThreadIsEmpty() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread == .empty)
    }

    @Test("Chronology ascending is the order")
    func sortsByCreatedAscending() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        // Written in an order that neither matches the dates nor the folder names, so neither can
        // be the thing producing the answer by accident.
        try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: "2026-03-03T09:00:00Z"))
        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "2026-05-05T09:00:00Z"))
        try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-01-01T09:00:00Z"))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two, CommentIdent.three, CommentIdent.one])
    }

    @Test("Ties break by the canonical lowercase folder name")
    func tiesBreakByCanonicalName() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        // `DDDDDDDD-…` sorts before every lowercase name by raw ASCII and after `cccccccc-…` by the
        // canonical fold, which is the rule (01 § Ordering — "comparing the canonical lowercase
        // spelling … never an uppercase folder's ASCII accident").
        let stamp = "2026-02-02T09:00:00Z"
        try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: stamp))
        try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: stamp))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.three, CommentIdent.upper])
    }

    @Test("A missing or malformed created sorts after every dated sibling, by name")
    func undatedSortLast() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: nil))
        try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: "[not, a, date]"))
        // The one dated sibling, and deliberately the *latest* possible name, so a name-only sort
        // would put it last and a date-first sort puts it first.
        try fixture.item(commentPath(CommentIdent.seven, inCard: card), commentText(created: "2026-09-09T09:00:00Z"))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.seven, CommentIdent.one, CommentIdent.upper])
    }

    @Test("A coerce-tier fallback is logged as a defect, and changes nothing else")
    func coercionsReported() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "[not, a, date]"))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.count == 1, "the comment still renders — coercion changes no behavior")
        let coerced = thread.defects.compactMap { defect -> CoercedFrontmatter? in
            if case let .coercedFrontmatter(work) = defect { work } else { nil }
        }
        #expect(coerced.count == 1)
        #expect(coerced.first?.fields.map(\.key) == [FrontmatterKeys.created])
        #expect(coerced.first?.path == "\(card)/comments/\(CommentIdent.one)/index.md")
    }

    @Test("A malformed author is a coerce-tier fallback too, and the field reads unattributed")
    func authorCoerces() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(author: "[a, b]"))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.first?.author.isMalformed == true)
        #expect(thread.comments.first?.author.value == nil)
        let keys = thread.defects.flatMap { defect -> [String] in
            if case let .coercedFrontmatter(work) = defect { work.fields.map(\.key) } else { [] }
        }
        #expect(keys.contains(FrontmatterKeys.author))
    }

    @Test("Edited is modified differing from created, and nothing else")
    func editedIndicator() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        try fixture.item(
            commentPath(CommentIdent.two, inCard: card),
            commentText(created: "2026-01-01T09:00:00Z", modified: "2026-02-02T09:00:00Z")
        )
        try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: nil, modified: nil))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        let edited = Dictionary(uniqueKeysWithValues: thread.comments.map { ($0.id.rawValue, $0.isEdited) })
        #expect(edited[CommentIdent.one] == false)
        #expect(edited[CommentIdent.two] == true)
        #expect(edited[CommentIdent.three] == false, "a missing pair is not evidence of an edit")
    }

    @Test("Attachments list flat, in Finder order")
    func attachmentsAreFinderOrdered() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)

        try fixture.item(path, commentText())
        try fixture.file("\(path)/attachments/shot 10.png", Data("ten".utf8))
        try fixture.file("\(path)/attachments/shot 2.png", Data("two".utf8))
        try fixture.file("\(path)/attachments/nested/deep.png", Data("deep".utf8))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.first?.attachments == ["shot 2.png", "shot 10.png"])
    }
}

// MARK: - Defect tolerance

@Suite("Comments ▸ defects never refuse")
struct CommentDefectToleranceTests {

    /// Every shape that would have failed a *card* load outright, each beside one healthy sibling
    /// so the claim is "the rest of the thread renders" and not merely "nothing threw".
    @Test("A broken comment is a stray: skipped, logged, and the thread still renders")
    func brokenCommentsAreStrays() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
        // Unparseable YAML.
        try fixture.item(commentPath(CommentIdent.two, inCard: card), "---\nschema: 1\n  bad: [\n---\nbody\n")
        // Not UTF-8.
        try fixture.item(commentPath(CommentIdent.upper, inCard: card), bytes: Data([0xFF, 0xFE, 0x00]))
        // Two-step-create residue: an identity-shaped folder with no index.md.
        try FileManager.default.createDirectory(
            at: fixture.url(commentPath(CommentIdent.seven, inCard: card)),
            withIntermediateDirectories: true
        )
        // A stray folder that is not identity-shaped at all.
        try fixture.item("\(card)/comments/notes", commentText())

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
        #expect(thread.strays.count == 4)
        #expect(thread.strays.contains { $0.name == "notes" && $0.reason == .notIdentityShaped })
        #expect(thread.strays.contains { $0.name == CommentIdent.seven && $0.reason == .missingIndex })
        // Every stray's bytes are still exactly where they were: tolerated is preserved-verbatim.
        #expect(fixture.exists(commentPath(CommentIdent.two, inCard: card)))
        #expect(fixture.exists("\(card)/comments/notes"))
    }

    @Test("A missing schema does not stop a comment's body from being unreadable-tolerated")
    func schemaIsLenientOnAComment() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nauthor: Ada\n---\nstill readable\n")

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.count == 1, "a comment's schema is lenient, unlike every level's")
        #expect(thread.comments.first?.schema.isMissing == true)
        #expect(thread.comments.first?.body == "still readable\n")
    }

    @Test("A board carrying a broken comment still loads")
    func theBoardIsUnaffected() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nnot: [valid\n---\n")

        let model = try fixture.snapshot()
        #expect(model.lanes.first?.cards.count == 1)
    }
}

// MARK: - The per-kind field table

@Suite("Comments ▸ the kind: comment field table")
struct CommentFieldTableTests {

    /// A comment carries no `order` and never gains one ("Ordering is chronology, not ranks"), and
    /// since 2026-07-31 its `schema` is optional too — it sits below the board root like everything
    /// else, so an absent one reads as 1. What still refuses is a schema **newer than this app**,
    /// which is the one `schema` rule that is level-blind.
    @Test("No field is required — order is meaningless here, schema defaults to 1")
    func requiredFields() throws {
        for frontmatter in ["schema: 1\nkind: comment", "kind: comment"] {
            #expect(throws: Never.self) {
                try IntegrityRules.validateIndex(
                    Data("---\n\(frontmatter)\n---\nbody\n".utf8),
                    path: "index.md", kind: .comment, supportedSchema: 1
                )
            }
        }
        let newer = Data("---\nschema: 99\nkind: comment\n---\nbody\n".utf8)
        #expect(throws: BoardLoadError.self) {
            try IntegrityRules.validateIndex(newer, path: "index.md", kind: .comment, supportedSchema: 1)
        }
    }

    @Test("Position places a comment, and the URL form tells the two trashes apart")
    func placement() {
        let uuid = CommentIdent.one
        #expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "comments") == .comment)
        #expect(IntegrityRules.placement(ofFolderNamed: ".draft", inParentNamed: "comments") == .comment)
        // The two-name form cannot tell a thread's `.trash` from the board's.
        #expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash)

        let root = URL(fileURLWithPath: "/b.kanban")
        let card = root.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
        let thread = card.appendingPathComponent("comments")
        #expect(IntegrityRules.placement(ofFolder: thread.appendingPathComponent(uuid)) == .comment)
        #expect(
            IntegrityRules.placement(ofFolder: thread.appendingPathComponent(".trash").appendingPathComponent(uuid))
                == .comment,
            "a comment in its thread's trash is still a comment"
        )
        #expect(
            IntegrityRules.placement(ofFolder: root.appendingPathComponent(".trash").appendingPathComponent(uuid))
                == .insideTrash,
            "the board's trash is unchanged"
        )
    }

    @Test("kind: comment is never a board-trash answer — shape decides there")
    func trashKindIgnoresComment() {
        #expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: false) == .card)
        #expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: true) == .lane)
    }

    @Test("The on-touch backfill stamps kind: comment, never kind: lane")
    func backfillIsComment() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        // A comment folder is identity-shaped under a non-identity-shaped parent — shape-identical
        // to a lane, which is exactly the mistake `Placement.comment` exists to prevent.
        try fixture.item(path, "---\nschema: 1\nauthor: Ada\n---\nbody\n")

        try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login")

        let document = try FrontmatterDocument.parse(fixture.indexText(path))
        #expect(document.kind.value == "comment")
    }

    @Test("author survives every app write; modified-by does not")
    func authorSurvives() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, "---\nschema: 1\nauthor: Ada Lovelace\nmodified-by: claude\nkind: comment\n---\nbody\n")

        try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login")

        let document = try FrontmatterDocument.parse(fixture.indexText(path))
        #expect(document.author.value == "Ada Lovelace")
        #expect(document.modifiedBy.isMissing, "modified-by is overlay; author is content")
    }

    @Test("author is an ordinary unknown key on every other kind — the Details section still shows it")
    func authorIsNotSchemaOwnedElsewhere() throws {
        let document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\nauthor: Ada\n---\nbody\n")
        #expect(document.unknownFields.map(\.key) == [FrontmatterKeys.author])
    }
}

// MARK: - Claimed names

@Suite("Comments ▸ claimed names")
struct CommentClaimedNameTests {

    @Test("comments graduated: a squatter at card level is now scheduled work")
    func commentsDisplacesNow() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.file("\(card)/comments", Data("squatter".utf8))

        let result = try BoardLoader.load(boardRoot: fixture.root)
        let squatters = result.defects.compactMap { defect -> ClaimedNameSquatter? in
            if case let .claimedNameSquatted(work) = defect { work } else { nil }
        }
        #expect(squatters.count == 1)
        #expect(squatters.first?.name == "comments")
        #expect(squatters.first?.found == .file)
        #expect(squatters.first?.location == .card(path: card))
    }

    @Test("The thread read reports .draft and .trash squatters, and a comment's own attachments")
    func threadLevelSquatters() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        try fixture.file("\(card)/comments/.draft", Data("squatter".utf8))
        try fixture.file("\(card)/comments/.trash", Data("squatter".utf8))
        try fixture.file("\(path)/attachments", Data("squatter".utf8))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        let squatters = thread.defects.compactMap { defect -> ClaimedNameSquatter? in
            if case let .claimedNameSquatted(work) = defect { work } else { nil }
        }
        #expect(Set(squatters.map(\.name)) == [".draft", ".trash", "attachments"])
        #expect(squatters.contains { $0.location == .commentThread(cardPath: card) && $0.name == ".draft" })
        #expect(squatters.contains { $0.location == .comment(path: path) && $0.name == "attachments" })
        // The board walk stays out of it — a thread is window-scoped.
        let boardDefects = try BoardLoader.load(boardRoot: fixture.root).defects
        #expect(!boardDefects.contains { if case .claimedNameSquatted = $0 { true } else { false } })
    }

    @Test("A file wearing comments makes the thread empty and the work the whole answer")
    func squattedThreadFolder() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.file("\(card)/comments", Data("squatter".utf8))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        #expect(thread.comments.isEmpty)
        #expect(thread.defects.count == 1)
    }

    @Test("Each location displaces by the Finder ladder, and destroys nothing")
    func displacementLadder() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        try fixture.file("\(card)/comments/.draft", Data("draft squatter".utf8))
        try fixture.file("\(path)/attachments", Data("attachment squatter".utf8))

        let thread = CommentThread.load(inCard: fixture.url(card), path: card)
        for defect in thread.defects {
            guard case let .claimedNameSquatted(squatter) = defect else { continue }
            let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root)
            #expect(freed == "\(squatter.name) 2")
        }

        #expect(try fixture.data("\(card)/comments/.draft 2") == Data("draft squatter".utf8))
        #expect(try fixture.data("\(path)/attachments 2") == Data("attachment squatter".utf8))
        #expect(!fixture.exists("\(card)/comments/.draft"))
        #expect(!fixture.exists("\(path)/attachments"))
    }

    @Test("A displacement that lost its race is success, not an error")
    func displacementReVerifies() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let squatter = ClaimedNameSquatter(
            name: ".draft",
            found: .file,
            expected: .directory,
            location: .commentThread(cardPath: card)
        )
        #expect(try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root) == nil)
    }

    @Test("Two locations sign differently, so one heal's failure is not the other's")
    func signaturesAreDistinct() {
        let thread = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter(
            name: ".trash", found: .file, expected: .directory, location: .commentThread(cardPath: "l/c")
        ))
        let root = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter(
            name: ".trash", found: .file, expected: .directory
        ))
        #expect(root.signatures == ["claimed:.trash:file"], "the board root's spelling is unchanged")
        #expect(thread.signatures == ["claimed:l/c/comments/.trash:file"])
    }
}

// MARK: - Path shape

@Suite("Comments ▸ path shape")
struct CommentPathShapeTests {

    private let lane = Ident.lane1
    private let card = Ident.card1

    @Test("A posted comment's path names its card and its identity")
    func classifiesComment() {
        let path = CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)/index.md")
        #expect(path?.cardPath == "\(lane)/\(card)")
        #expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one)))
        #expect(path?.id == ItemID(rawValue: CommentIdent.one))
    }

    @Test("The folder itself classifies, not only files inside it")
    func classifiesTheFolder() {
        #expect(CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)")?.kind
            == .comment(ItemID(rawValue: CommentIdent.one)))
    }

    @Test("The draft is its own shape, and has no identity")
    func classifiesDraft() {
        let path = CommentPath.classify("\(lane)/\(card)/comments/.draft/index.md")
        #expect(path?.kind == .draft)
        #expect(path?.id == nil)
    }

    @Test("A deleted comment classifies from the thread's own trash")
    func classifiesTrashed() {
        let path = CommentPath.classify("\(lane)/\(card)/comments/.trash/\(CommentIdent.two)/index.md")
        #expect(path?.kind == .trashed(ItemID(rawValue: CommentIdent.two)))
    }

    @Test("A trashed card carries its thread, and the shape reads the same")
    func classifiesUnderTheBoardTrash() {
        let path = CommentPath.classify(".trash/\(card)/comments/\(CommentIdent.one)/index.md")
        #expect(path?.cardPath == ".trash/\(card)")
        #expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one)))
    }

    @Test("Everything else is somebody else's to describe")
    func classifiesNothingElse() {
        let cases = [
            "\(lane)/\(card)/index.md",
            "\(lane)/\(card)/attachments/shot.png",
            "\(lane)/\(card)/comments",
            "\(lane)/\(card)/comments/notes/index.md",
            "\(lane)/\(card)/comments/.trash",
            "\(lane)/\(card)/comments/.trash/notes",
            "\(lane)/notacard/comments/\(CommentIdent.one)/index.md",
            "CLAUDE.md",
            "",
        ]
        for path in cases {
            #expect(CommentPath.classify(path) == nil, "'\(path)' should not classify")
        }
    }
}
