import Foundation
import Testing
@testable import Kanban

/// **What a foreign comment change says out loud** — 10-accessibility.md ▸ Comments ("Foreign comment
/// arrivals announce path-shaped ('New comment on '⟨card⟩'')"), 01-storage-format.md ▸ Enhanced schema
/// (the path shape, and the verb family 06 gains with the feature).
///
/// Three layers, three suites, because the rule is three separate claims stacked:
///
/// 1. **What changed** — `CommentThreadChanges`, a pure comparison of two threads.
/// 2. **Whose it was** — the `EchoLedger`'s comment receipts, read through `CommentPath.classify`;
///    the app's own writes never announce.
/// 3. **What that says** — `BoardAnnouncer.commentSpeech(for:onCard:)` over `AccessibilityPhrases`.
///
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`; `CommentIdent`,
/// `commentText` and `makeCommentBoard` from `CommentThreadTests.swift`.

// MARK: - Fixtures

private func comment(_ id: String, body: String) -> Kanban.Comment {
    Kanban.Comment(
        id: ItemID(rawValue: id),
        schema: .valid(1),
        author: .missing,
        created: .missing,
        modified: .missing,
        modifiedBy: .missing,
        attachments: [],
        document: FrontmatterDocument(body: body)
    )
}

private func thread(_ comments: [Kanban.Comment]) -> CommentThread {
    CommentThread(comments: comments, strays: [], defects: [], hasDraft: false)
}

// MARK: - What changed

@Suite("Comment announcements ▸ the thread diff")
struct CommentThreadChangesTests {

    @Test("An arrival, an edit and a departure, each in its own bucket")
    func threeBuckets() {
        let old = thread([comment(CommentIdent.one, body: "one"), comment(CommentIdent.two, body: "two")])
        let new = thread([comment(CommentIdent.one, body: "one, amended"), comment(CommentIdent.three, body: "three")])

        let changes = CommentThreadChanges.between(old, new)
        #expect(changes.arrived == [ItemID(rawValue: CommentIdent.three)])
        #expect(changes.edited == [ItemID(rawValue: CommentIdent.one)])
        #expect(changes.deleted == [ItemID(rawValue: CommentIdent.two)])
    }

    @Test("An unchanged thread is empty — the overwhelmingly common reload")
    func unchangedIsEmpty() {
        let same = thread([comment(CommentIdent.one, body: "one")])
        #expect(CommentThreadChanges.between(same, same).isEmpty)
    }

    @Test("An attachment landing on a comment is not a change here — the comparison is index.md")
    func attachmentsAreNotEdits() {
        let before = comment(CommentIdent.one, body: "one")
        let after = Kanban.Comment(
            id: before.id,
            schema: before.schema,
            author: before.author,
            created: before.created,
            modified: before.modified,
            modifiedBy: before.modifiedBy,
            attachments: ["shot.png"],
            document: before.document
        )
        #expect(CommentThreadChanges.between(thread([before]), thread([after])).isEmpty)
    }

    @Test("Vouched changes are removed one at a time, never all-or-nothing")
    func exclusionIsPerComment() {
        let changes = CommentThreadChanges(
            arrived: [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)],
            edited: [ItemID(rawValue: CommentIdent.three)]
        )
        // The app posted one of the two arrivals; an agent filed the other in the same window.
        let foreign = changes.excluding([ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.three)])
        #expect(foreign.arrived == [ItemID(rawValue: CommentIdent.two)])
        #expect(foreign.edited.isEmpty)
    }
}

// MARK: - Whose it was

@MainActor
@Suite("Comment announcements ▸ the ledger's comment receipts")
struct CommentReceiptTests {

    @Test("A posted comment is vouched for, and the receipts are retired on the way out")
    func postIsVouched() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let store = try BoardStore(rootURL: fixture.root)
        let cardID = ItemID(rawValue: Ident.card1)

        store.saveCommentDraft(inCard: cardID, body: "A remark.")
        let posted = try #require(store.postComment(inCard: cardID))

        #expect(store.vouchedComments(inCard: cardID) == [posted])
        // One write, one echo: a second reload finds nothing vouching for the same comment.
        #expect(store.vouchedComments(inCard: cardID).isEmpty)
        #expect(fixture.exists("\(card)/comments/\(posted.rawValue)"))
    }

    @Test("An inline edit's save is vouched for — the debounce writes without a re-read")
    func editIsVouched() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n"))
        let store = try BoardStore(rootURL: fixture.root)
        let cardID = ItemID(rawValue: Ident.card1)

        #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "after"))
        #expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)])
    }

    @Test("A delete is vouched for at both ends of its move — the thread's and the trash's")
    func deleteIsVouched() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n"))
        let store = try BoardStore(rootURL: fixture.root)
        let cardID = ItemID(rawValue: Ident.card1)

        #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: cardID))
        #expect(store.vouchedComments(inCard: cardID) == [ItemID(rawValue: CommentIdent.one)])
        #expect(store.echoes.outstandingReceipts == 0)
    }

    @Test("A draft save vouches for nothing in the thread — a draft is not in it")
    func draftVouchesForNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try makeCommentBoard(fixture)
        let store = try BoardStore(rootURL: fixture.root)
        let cardID = ItemID(rawValue: Ident.card1)

        store.saveCommentDraft(inCard: cardID, body: "half a thought")
        #expect(store.vouchedComments(inCard: cardID).isEmpty)
        // Retired all the same: a receipt nothing ever collects is a receipt that outlives its write.
        #expect(store.echoes.outstandingReceipts == 0)
    }

    @Test("A comment receipt never makes its card's own edit look foreign")
    func commentReceiptsDoNotPoisonTheCard() throws {
        // The regression this guards: a card's footprint used to resolve *every* receipt under its
        // folder against its attachment listing, so a comment's `index.md` — which no snapshot can
        // observe — read as absent, failed its receipt, and classified the card foreign. The user's
        // own next title or body edit would then have been announced as somebody else's.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let cardFolder = fixture.url(card)

        let old = try BoardLoader.load(boardRoot: fixture.root).model
        try BoardWriter.writeBody(inItemFolder: cardFolder, body: "An amended body.")
        let new = try BoardLoader.load(boardRoot: fixture.root).model
        let card1 = try #require(new.lanes.first?.cards.first)

        let ledger = EchoLedger()
        ledger.recordWrite(
            atPath: EchoLedger.key(cardFolder.appendingPathComponent(BoardLoader.indexFileName)),
            hash: EchoLedger.hash(of: card1.document.serialized())
        )
        ledger.recordWrite(
            atPath: EchoLedger.key(
                CommentThread
                    .commentFolder(ItemID(rawValue: CommentIdent.one), inCard: cardFolder)
                    .appendingPathComponent(BoardLoader.indexFileName)
            ),
            hash: "a hash no snapshot can ever produce"
        )

        let diff = BoardDiff.between(old, new, includingTrash: false)
        let verdicts = ledger.verdicts(from: old, to: new, diff: diff, includingTrash: false)
        #expect(verdicts.foreignItems.isEmpty)
        #expect(!verdicts.foreign.boardChanged)
        // And the comment receipt is still there for the card window to collect.
        #expect(ledger.outstandingReceipts == 1)
    }
}

// MARK: - What it says

@Suite("Comment announcements ▸ the sentence")
struct CommentSpeechTests {

    private let card = "Fix login"

    @Test("An arrival is 10's own sentence")
    func arrivalSpeaks() {
        let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)])
        #expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'")
    }

    @Test("Edits and deletions speak in 06's verb family")
    func theFamilySpeaks() {
        #expect(
            BoardAnnouncer.commentSpeech(
                for: CommentThreadChanges(edited: [ItemID(rawValue: CommentIdent.one)]),
                onCard: card
            ) == "Edit comment on 'Fix login'"
        )
        #expect(
            BoardAnnouncer.commentSpeech(
                for: CommentThreadChanges(deleted: [ItemID(rawValue: CommentIdent.one)]),
                onCard: card
            ) == "Delete comment on 'Fix login'"
        )
    }

    @Test("Counts fold, like every other count in the app")
    func countsFold() {
        let two = [ItemID(rawValue: CommentIdent.one), ItemID(rawValue: CommentIdent.two)]
        #expect(
            BoardAnnouncer.commentSpeech(for: CommentThreadChanges(arrived: two), onCard: card)
                == "2 new comments on 'Fix login'"
        )
        #expect(
            BoardAnnouncer.commentSpeech(for: CommentThreadChanges(edited: two), onCard: card)
                == "2 comments edited on 'Fix login'"
        )
        #expect(
            BoardAnnouncer.commentSpeech(for: CommentThreadChanges(deleted: two), onCard: card)
                == "2 comments deleted on 'Fix login'"
        )
    }

    @Test("Arrivals lead — one polite sentence, chosen by precedence rather than concatenated")
    func arrivalsLead() {
        let changes = CommentThreadChanges(
            arrived: [ItemID(rawValue: CommentIdent.one)],
            edited: [ItemID(rawValue: CommentIdent.two)],
            deleted: [ItemID(rawValue: CommentIdent.three)]
        )
        #expect(BoardAnnouncer.commentSpeech(for: changes, onCard: card) == "New comment on 'Fix login'")
    }

    @Test("An untitled card wears the placeholder the board wears")
    func untitledCard() {
        let changes = CommentThreadChanges(arrived: [ItemID(rawValue: CommentIdent.one)])
        #expect(BoardAnnouncer.commentSpeech(for: changes, onCard: nil) == "New comment on 'Untitled'")
    }

    @Test("Nothing changed is silence")
    func nothingIsSilence() {
        #expect(BoardAnnouncer.commentSpeech(for: CommentThreadChanges(), onCard: card) == nil)
    }
}

// MARK: - The pane's own voice

@MainActor
@Suite("Comment announcements ▸ the pane")
struct CommentPaneAnnouncementTests {

    /// A pane wired to a real store on a real board — the only way the receipts half is honest, since
    /// what silences an echo is a write having actually happened.
    ///
    /// The store rides on the recorder rather than being returned beside it, and that is not tidiness:
    /// `CardWindowHost.configureComments` captures the store **weakly** (a save landing after the board
    /// window has gone must write nothing), so a test that let it go out of scope would be driving a
    /// pane whose every seam answers "vanished" — which looks exactly like a bug in the pane.
    private func makePane(_ fixture: WriterFixture) throws -> (CardComments, Spoken) {
        let store = try BoardStore(rootURL: fixture.root)
        let comments = CardComments()
        comments.isEditable = true
        comments.cardFolder = fixture.url("\(Ident.lane1)/\(Ident.card1)")
        comments.cardTitle = "Fix login"
        CardWindowHost.configureComments(
            comments, store: store, cardID: ItemID(rawValue: Ident.card1), on: CardWindowUndo()
        )
        let spoken = Spoken(store: store)
        comments.announce = { spoken.record($0) }
        return (comments, spoken)
    }

    /// What the pane said, and the store it said it about — held here so the weak seams stay wired.
    @MainActor
    final class Spoken {
        let store: BoardStore
        private(set) var phrases: [String] = []

        init(store: BoardStore) {
            self.store = store
        }

        func record(_ phrase: String?) {
            guard let phrase else { return }
            phrases.append(phrase)
        }
    }

    @Test("Opening a card with a thread announces nothing — the window is not a change")
    func openingIsSilent() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "Already here.\n"))
        let (comments, spoken) = try makePane(fixture)

        comments.open()
        #expect(comments.thread.comments.count == 1)
        #expect(spoken.phrases.isEmpty)
    }

    @Test("A foreign arrival speaks path-shaped, naming the card")
    func foreignArrivalSpeaks() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let (comments, spoken) = try makePane(fixture)
        comments.open()

        try fixture.item("\(card)/comments/\(CommentIdent.two)", commentText(body: "An agent's remark.\n"))
        comments.reload()

        #expect(spoken.phrases == ["New comment on 'Fix login'"])
    }

    @Test("Our own post is silent — the receipt vouches for it")
    func ownPostIsSilent() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try makeCommentBoard(fixture)
        let (comments, spoken) = try makePane(fixture)
        comments.open()

        comments.composer.edited("A remark of our own.")
        comments.composer.flush()
        #expect(comments.composer.postNow() != nil)
        comments.reload()

        #expect(comments.thread.comments.count == 1)
        #expect(spoken.phrases.isEmpty)
    }

    @Test("Our own inline edit is silent, even though nothing re-read the thread in between")
    func ownEditIsSilent() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "before\n"))
        let (comments, spoken) = try makePane(fixture)
        let store = spoken.store
        comments.open()

        // The debounced save the inline session makes, without a reload of its own — so the *next*
        // reload is the first to see it, which is exactly the case the ledger has to cover.
        #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1), body: "after"))
        comments.reload()

        #expect(comments.thread.comments.first?.body == "after")
        #expect(spoken.phrases.isEmpty)
    }

    @Test("Our own delete is silent")
    func ownDeleteIsSilent() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/\(CommentIdent.one)", commentText(body: "doomed\n"))
        let (comments, spoken) = try makePane(fixture)
        comments.open()

        comments.delete(ItemID(rawValue: CommentIdent.one))
        #expect(comments.thread.comments.isEmpty)
        #expect(spoken.phrases.isEmpty)
    }

    @Test("A foreign arrival alongside our own post announces the half nobody vouched for")
    func mixedReloadSpeaksTheForeignHalf() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let (comments, spoken) = try makePane(fixture)
        let store = spoken.store
        comments.open()

        store.saveCommentDraft(inCard: ItemID(rawValue: Ident.card1), body: "Ours.")
        #expect(store.postComment(inCard: ItemID(rawValue: Ident.card1)) != nil)
        try fixture.item("\(card)/comments/\(CommentIdent.three)", commentText(body: "Theirs.\n"))
        comments.reload()

        #expect(comments.thread.comments.count == 2)
        #expect(spoken.phrases == ["New comment on 'Fix login'"])
    }
}
