import Foundation
import Testing
@testable import Kanban

/// The comments pane's **two buffers** and the cadences that separate them (05-card-window.md ▸ The
/// comments column):
///
/// - the composer's draft, whose saves are slow — "composer blur, window close, quit, and a lazy
///   interval (~30 s) — **not** the body editor's 700 ms";
/// - an inline comment edit, which *is* the body editor's 700 ms, plus the one thing the body has
///   not got: a Cancel that reverts to session-start bytes.
///
/// `CardBodyEditSessionTests`' shape, and for its reason: both types are a buffer and a clock with a
/// closure for a destination, so this suite is about the rules rather than about files — the fakes
/// below record what they were asked to write, and "writes nothing" is an assertion about a count.
/// The bytes those saves put on disk are `CommentWriteTests`'.

// MARK: - Fakes

/// A stand-in for `BoardStore.saveCommentDraft` / `postComment`.
@MainActor
private final class DraftSpy {
    private(set) var written: [String] = []
    private(set) var posts = 0
    /// `nil` is a save that did not land — a failure, the read-only lock, a vanished card.
    var outcome: CommentDraftOutcome? = .updated
    var postedID: ItemID? = ItemID(rawValue: CommentIdent.one)

    var count: Int { written.count }

    func save(_ text: String) -> CommentDraftOutcome? {
        written.append(text)
        return outcome
    }

    func post() -> ItemID? {
        posts += 1
        return postedID
    }
}

/// A stand-in for `BoardStore.editComment`.
@MainActor
private final class EditSpy {
    private(set) var written: [String] = []
    var lands = true

    var count: Int { written.count }
    var last: String? { written.last }

    func save(_ text: String) -> Bool {
        written.append(text)
        return lands
    }
}

@MainActor
private func makeComposer(_ spy: DraftSpy, draft: CommentDraft? = nil) -> CommentDraftSession {
    let session = CommentDraftSession()
    // Fast enough that a test never waits on the real 30 s. `CardBodyEditSessionTests`' precedent —
    // a production default on the property, the suite dialling it down.
    session.saveInterval = .milliseconds(40)
    session.save = { [spy] text in spy.save(text) }
    session.post = { [spy] in spy.post() }
    session.adopt(draft: draft)
    return session
}

@MainActor
private func makeEditor(_ spy: EditSpy, body: String = "original\n") -> CommentEditSession {
    let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: body)
    session.debounceInterval = .milliseconds(30)
    session.save = { [spy] text in spy.save(text) }
    return session
}

@MainActor
private func waitUntil(_ deadline: Duration = .seconds(2), _ condition: () -> Bool) async {
    let start = ContinuousClock.now
    while !condition() {
        guard ContinuousClock.now - start < deadline else { return }
        try? await Task.sleep(for: .milliseconds(5))
    }
}

// MARK: - The composer's cadence

@MainActor
@Suite("Comment composer ▸ the slow cadence")
struct CommentComposerCadenceTests {

    @Test("The production interval is ~30 s, not the body's 700 ms")
    func theDefaultIntervalIsTheDesignsNumber() {
        // The one thing the seam must not do is quietly become the body's cadence — "so a Pro user's
        // typing never becomes a commit stream" (05 ▸ The comments column).
        #expect(CommentDraftSession().saveInterval == .seconds(30))
        #expect(CommentDraftSession().saveInterval != CardBodyEditSession().debounceInterval)
    }

    @Test("A keystroke does not save; the interval does")
    func theIntervalSaves() async {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("half a thought")
        #expect(spy.count == 0, "not on the keystroke itself")

        await waitUntil { spy.count == 1 }
        #expect(spy.written == ["half a thought"])
        #expect(!session.isDirty)
    }

    @Test("It is an interval, not a debounce: steady typing still lands a save")
    func typingDoesNotPostponeTheSave() async {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        // A debounce would be restarted by each of these and never fire while the user typed. An
        // interval fires on its own schedule — which is the whole difference, and the reason the
        // design says "lazy interval" rather than "debounce".
        let start = ContinuousClock.now
        while spy.count == 0, ContinuousClock.now - start < .seconds(2) {
            session.edited(session.text + "a")
            try? await Task.sleep(for: .milliseconds(5))
        }
        #expect(spy.count == 1)
    }

    @Test("Blur saves — the first of the four cadence moments")
    func blurSaves() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("typed")
        #expect(session.blurred() == .updated)
        #expect(spy.written == ["typed"])
        #expect(!session.isDirty)
    }

    @Test("A flush pre-empts the armed interval, and the interval does not fire behind it")
    func flushPreemptsTheInterval() async {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("typed")
        _ = session.flush()
        #expect(spy.count == 1)

        await waitUntil { spy.count > 1 }
        #expect(spy.count == 1)
    }

    @Test("An untouched composer writes nothing, ever")
    func anUntouchedComposerWritesNothing() async {
        let spy = DraftSpy()
        let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))

        #expect(session.text == "restored\n", "restore-on-reopen is just the read")
        #expect(session.flush() == nil)
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
    }

    @Test("A typed-then-reverted draft writes nothing, and leaves no timer standing")
    func aRevertedDraftWritesNothing() async {
        let spy = DraftSpy()
        let session = makeComposer(spy, draft: CommentDraft(body: "restored\n", attachments: []))

        session.edited("restored\nand more")
        session.edited("restored\n")
        #expect(!session.isDirty)

        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
    }

    @Test("A save that did not land keeps the buffer dirty, and the text")
    func aFailedSaveKeepsTheText() {
        let spy = DraftSpy()
        let session = makeComposer(spy)
        spy.outcome = nil

        session.edited("precious")
        #expect(session.flush() == nil)
        #expect(session.text == "precious")
        #expect(session.isDirty, "the read-only lock, a failure and a vanished card all keep the text")
    }

    @Test("Emptying the composer saves empty text — the Writer owns the delete rule")
    func emptyingSavesEmptyText() {
        let spy = DraftSpy()
        let session = makeComposer(spy, draft: CommentDraft(body: "was here\n", attachments: []))
        spy.outcome = .deleted

        session.edited("")
        #expect(session.flush() == .deleted)
        #expect(spy.written == [""], "no second definition of 'no text and no attachments'")
        #expect(!session.isDirty)
    }
}

// MARK: - Dirty-buffer-wins, in the composer

@MainActor
@Suite("Comment composer ▸ dirty-buffer-wins")
struct CommentComposerAdoptTests {

    @Test("A clean composer follows the file — a draft synced in arrives by itself")
    func aCleanComposerFollowsDisk() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.adopt(draft: CommentDraft(body: "from another machine\n", attachments: ["shot.png"]))
        #expect(session.text == "from another machine\n")
        #expect(session.attachments == ["shot.png"])
        #expect(!session.isDirty)
    }

    @Test("A dirty composer keeps its keystrokes under a reload")
    func aDirtyComposerKeepsItsText() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("mine, unsaved")
        session.adopt(draft: CommentDraft(body: "theirs\n", attachments: []))

        #expect(session.text == "mine, unsaved")
        #expect(session.disk == "theirs\n", "the buffer knows what disk says — it just isn't showing it")
        #expect(session.isDirty)
    }

    @Test("Chips follow the file even while the text is dirty — a drop is not a keystroke")
    func chipsFollowDiskWhileDirty() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("mid-sentence")
        // The user dropped a file into the composer: the import is a write of its own, and its
        // reload must show the chip without disturbing the text above it.
        session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))

        #expect(session.text == "mid-sentence")
        #expect(session.attachments == ["shot.png"])
    }

    @Test("No draft at all is an empty, clean composer")
    func noDraftIsEmpty() {
        let spy = DraftSpy()
        let session = makeComposer(spy, draft: CommentDraft(body: "here\n", attachments: []))

        session.adopt(draft: nil)
        #expect(session.text == "")
        #expect(!session.isDirty)
    }
}

// MARK: - Posting

@MainActor
@Suite("Comment composer ▸ posting")
struct CommentComposerPostTests {

    @Test("There is nothing to post exactly when there would be nothing to keep")
    func canPostMirrorsTheEmptiedDraftRule() {
        let spy = DraftSpy()
        let session = makeComposer(spy)
        #expect(!session.canPost)

        session.edited("   \n  ")
        #expect(!session.canPost, "whitespace is empty, the Writer's own gate")

        session.edited("something")
        #expect(session.canPost)

        session.edited("")
        session.adopt(draft: CommentDraft(body: "", attachments: ["shot.png"]))
        #expect(session.canPost, "a draft with no text but a file in it is still a draft")
    }

    @Test("⌘↩ flushes before it posts — the slow cadence is never visible as lost text")
    func postFlushesFirst() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("about to post")
        #expect(session.postNow() != nil)
        #expect(spy.written == ["about to post"], "the post renames a folder; unwritten text is not in it")
        #expect(spy.posts == 1)
    }

    @Test("After a post the composer is empty and clean — the draft is gone")
    func postEmptiesTheComposer() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        session.edited("posted")
        session.adopt(draft: CommentDraft(body: "posted", attachments: ["shot.png"]))
        _ = session.postNow()

        #expect(session.text == "")
        #expect(session.disk == "")
        #expect(session.attachments.isEmpty)
        #expect(!session.isDirty)
    }

    @Test("An empty composer posts nothing at all")
    func anEmptyComposerPostsNothing() {
        let spy = DraftSpy()
        let session = makeComposer(spy)

        #expect(session.postNow() == nil)
        #expect(spy.posts == 0)
        #expect(spy.count == 0)
    }

    @Test("A post that did not land leaves the composer holding its text")
    func aRefusedPostKeepsTheDraft() {
        let spy = DraftSpy()
        let session = makeComposer(spy)
        spy.postedID = nil

        session.edited("still mine")
        #expect(session.postNow() == nil)
        #expect(session.text == "still mine")
    }
}

// MARK: - The inline edit session

@MainActor
@Suite("Inline comment edit ▸ the body session in miniature")
struct CommentEditSessionTests {

    @Test("The cadence is the body's ~700 ms, not the draft's")
    func theCadenceIsTheBodys() {
        let session = CommentEditSession(commentID: ItemID(rawValue: CommentIdent.one), body: "")
        #expect(session.debounceInterval == CardBodyEditSession().debounceInterval)
    }

    @Test("Typing saves once the keystrokes stop — crash safety without a commit point")
    func typingSaves() async {
        let spy = EditSpy()
        let session = makeEditor(spy)

        session.edited("revised\n")
        #expect(spy.count == 0)
        await waitUntil { spy.count == 1 }
        #expect(spy.written == ["revised\n"])
        #expect(!session.isDirty)
    }

    @Test("A burst is one save, of the last text")
    func aBurstCoalesces() async {
        let spy = EditSpy()
        let session = makeEditor(spy)

        for text in ["a", "ab", "abc"] { session.edited(text) }
        await waitUntil { spy.count >= 1 }
        #expect(spy.written == ["abc"])
    }

    @Test("An untouched session writes nothing, and ends writing nothing")
    func anUntouchedSessionWritesNothing() async {
        let spy = EditSpy()
        let session = makeEditor(spy)

        #expect(!session.commit())
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
    }

    @Test("Save is the commit point: it flushes and ends")
    func commitFlushesAndEnds() {
        let spy = EditSpy()
        let session = makeEditor(spy)

        session.edited("revised\n")
        #expect(session.commit())
        #expect(spy.written == ["revised\n"])
        #expect(session.hasEnded)
        #expect(!session.commit(), "a session ends once")
    }

    @Test("Cancel writes the session-start bytes back")
    func cancelRevertsToSessionStart() {
        let spy = EditSpy()
        let session = makeEditor(spy, body: "original\n")

        session.edited("half a rewrite\n")
        #expect(session.flush())
        #expect(spy.written == ["half a rewrite\n"])

        #expect(session.cancel())
        #expect(spy.written == ["half a rewrite\n", "original\n"])
        #expect(session.text == "original\n")
        #expect(session.hasEnded)
    }

    @Test("A cancel with nothing landed writes nothing — the file stays byte-identical")
    func cancelAfterNoSaveWritesNothing() {
        let spy = EditSpy()
        let session = makeEditor(spy)

        // Typed, but the debounce never fired: nothing of this session's is on disk, so there is
        // nothing to put back and no `modified` stamp to spend.
        session.edited("never landed\n")
        #expect(!session.cancel())
        #expect(spy.count == 0)
    }

    @Test("Cancel after several ticks reverts to the session's start, not to the last tick")
    func cancelUndoesTheWholeSession() {
        let spy = EditSpy()
        let session = makeEditor(spy, body: "original\n")

        session.edited("one\n")
        _ = session.flush()
        session.edited("two\n")
        _ = session.flush()
        session.edited("three\n")
        _ = session.flush()

        #expect(session.cancel())
        #expect(spy.last == "original\n")
    }

    @Test("A window close flushes the session — it never reverts it")
    func closeFlushesRatherThanReverts() {
        let spy = EditSpy()
        let session = makeEditor(spy, body: "original\n")

        session.edited("mid-sentence\n")
        #expect(session.endOnClose())
        #expect(spy.written == ["mid-sentence\n"], "dismissal never eats typed work where a save can land")
        #expect(session.hasEnded)
    }

    @Test("A save that did not land keeps the buffer dirty, and the text")
    func aFailedSaveKeepsTheText() {
        let spy = EditSpy()
        let session = makeEditor(spy)
        spy.lands = false

        session.edited("precious\n")
        #expect(!session.flush())
        #expect(session.text == "precious\n")
        #expect(session.isDirty)
    }

    @Test("Dirty-buffer-wins: a foreign edit never lands under the cursor")
    func dirtyBufferWins() {
        let spy = EditSpy()
        let session = makeEditor(spy, body: "original\n")

        session.edited("mine\n")
        session.adopt(diskBody: "theirs\n")
        #expect(session.text == "mine\n")
        #expect(session.disk == "theirs\n")

        // And a clean buffer follows disk.
        let clean = makeEditor(spy, body: "original\n")
        clean.adopt(diskBody: "theirs\n")
        #expect(clean.text == "theirs\n")
        #expect(!clean.isDirty)
    }

    @Test("Cancel after a foreign edit still writes the session's start bytes — last writer wins")
    func cancelOverAForeignEdit() {
        let spy = EditSpy()
        let session = makeEditor(spy, body: "original\n")

        session.adopt(diskBody: "an agent rewrote it\n")
        #expect(session.cancel())
        #expect(spy.last == "original\n", "the same no-merge-UI philosophy the body's dirty-buffer rule states")
    }
}
