import Foundation
import Testing
@testable import Kanban

/// The Edit buffer's state machine (05-card-window.md ▸ Edit, ▸ Write rules): the three write gates,
/// the ~700 ms debounce, and dirty-buffer-wins.
///
/// The session is deliberately a buffer and a clock with a *closure* for its destination, which is
/// what lets this suite be about the rules rather than about files: the fake below records every
/// save it is asked for, so "writes nothing" is an assertion about a count rather than an inference
/// from an `mtime`. The bytes those saves put on disk are `BodyWriteTests`'.

// MARK: - The fake destination

/// A stand-in for `BoardStore.writeCardBody`, recording what it was asked to write and answering
/// with whatever outcome the test wants.
@MainActor
private final class SaveSpy {
    private(set) var written: [String] = []
    var outcome: CardBodyWriteOutcome = .written

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

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

@MainActor
private func makeSession(_ spy: SaveSpy, body: String = "original\n") -> CardBodyEditSession {
    let session = CardBodyEditSession()
    // Fast enough that a test never waits on the real 700 ms, slow enough that a keystroke arriving
    // right after another can still cancel it — the `DragSession.holdTimeout` precedent.
    session.debounceInterval = .milliseconds(30)
    session.save = { [spy] text in spy.save(text) }
    session.adopt(diskBody: body)
    return session
}

/// Polls until `condition` holds or the deadline passes — the suite's shape for anything the
/// debounce has to actually elapse for.
@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 three gates

@MainActor
@Suite("Card body ▸ the write gates")
struct CardBodyWriteGateTests {

    @Test("An untouched session writes nothing, ever")
    func anUntouchedSessionWritesNothing() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

        // Opened, read, and left — the flush that leaving Edit performs, on a buffer nobody typed
        // into. "An untouched body is never rewritten" (05 ▸ Write rules).
        #expect(session.flush() == .unchanged)
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
        #expect(session.saveAttempts == 0)
    }

    @Test("A typed-then-reverted edit writes nothing, and leaves no timer standing")
    func aRevertedEditWritesNothing() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("original\nand more")
        #expect(session.isDirty)
        // Undone before the debounce could fire.
        session.edited("original\n")
        #expect(!session.isDirty)

        // The pending save was cancelled by the revert rather than firing on a no-op — a write that
        // landed here would stamp `modified` for nothing.
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
        #expect(session.flush() == .unchanged)
        #expect(spy.count == 0)
    }

    @Test("The echo of the app's own save is not written back")
    func anEchoIsNotWrittenBack() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

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

        // The watcher's reload arrives carrying what we just wrote. The buffer is clean against it,
        // so nothing is written back — which is what stops a save ringing forever round the one-way
        // flow.
        session.adopt(diskBody: "typed\n")
        #expect(!session.isDirty)
        #expect(session.flush() == .unchanged)
        await waitUntil { spy.count > 1 }
        #expect(spy.count == 1)
    }

    @Test("An external edit under a clean buffer is not written back either")
    func aForeignEditUnderACleanBufferIsNotWrittenBack() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

        // Somebody else rewrote the file while the window sat there reading it. "A clean buffer
        // follows disk" (05 ▸ Write rules) — and following disk is not a reason to write to it.
        session.adopt(diskBody: "theirs\n")

        #expect(session.text == "theirs\n")
        #expect(!session.isDirty)
        #expect(session.flush() == .unchanged)
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
    }
}

// MARK: - Dirty-buffer-wins

@MainActor
@Suite("Card body ▸ dirty-buffer-wins")
struct CardBodyDirtyBufferTests {

    @Test("A snapshot never reloads a dirty buffer under the cursor")
    func aDirtyBufferKeepsItsText() {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("mine, unsaved\n")
        // The board, Preview and every other window take the new snapshot; this buffer does not.
        session.adopt(diskBody: "theirs\n")

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

    @Test("The buffer's own save then lands over the foreign edit — last writer wins")
    func theFlushOverwritesTheForeignEdit() {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("mine, unsaved\n")
        session.adopt(diskBody: "theirs\n")

        #expect(session.flush() == .written)
        #expect(spy.written == ["mine, unsaved\n"], "deliberate last-writer-wins (05 ▸ Write rules)")
        #expect(!session.isDirty)
    }

    @Test("A foreign edit that happens to match the buffer settles it clean, writing nothing")
    func aConvergentForeignEditNeedsNoWrite() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("same text\n")
        // An agent wrote exactly what the user was typing. The file already says what they mean, so
        // re-writing it would only stamp `modified`.
        session.adopt(diskBody: "same text\n")

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

    @Test("A failed save keeps the buffer dirty, and the text")
    func aFailureKeepsTheText() {
        let spy = SaveSpy()
        let session = makeSession(spy)
        spy.outcome = .failed(BoardWriteError(
            operation: .editBody(title: "Notes"),
            path: "/x/index.md",
            reason: .io(message: "the disk is full")
        ))

        session.edited("precious\n")
        let outcome = session.flush()

        guard case .failed = outcome else {
            Issue.record("expected the failure to be reported, got \(outcome)")
            return
        }
        // "Nothing is lost while the window stays open" (02 § Write-failure surfacing): the text is
        // still here, still dirty, and the next flush will try again.
        #expect(session.text == "precious\n")
        #expect(session.isDirty)
    }

    @Test("A suspended save — the read-only lock — also keeps the buffer, and does not throw a close")
    func aSuspendedSaveKeepsTheBuffer() throws {
        let spy = SaveSpy()
        let session = makeSession(spy)
        spy.outcome = .suspended(.unwritableLocation(.permissionDenied))

        session.edited("held\n")
        #expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied)))
        #expect(session.isDirty)
        // The close-time guard treats it as a non-failure: no write was attempted, the lock row has
        // been standing all along, and a modal offering Try Again could only fail again.
        try session.flushOrThrow()
    }

    @Test("A real failure is what the close-time modal is raised on")
    func aFailureThrowsForTheGuard() {
        let spy = SaveSpy()
        let session = makeSession(spy)
        spy.outcome = .failed(BoardWriteError(operation: .editBody(title: nil), path: "/x", reason: .io(message: "nope")))
        session.edited("unsaved\n")

        #expect(throws: BoardWriteError.self) { try session.flushOrThrow() }
    }

    @Test("A session with nowhere to write keeps its text rather than reporting success")
    func aSessionWithNoDestinationHoldsOn() {
        let session = CardBodyEditSession()
        session.adopt(diskBody: "start\n")
        session.edited("typed\n")

        #expect(session.flush() == .vanished)
        #expect(session.isDirty)
        #expect(session.text == "typed\n")
    }
}

// MARK: - The debounce

@MainActor
@Suite("Card body ▸ the debounce")
struct CardBodyDebounceTests {

    @Test("Typing saves once the keystrokes stop")
    func typingSavesAfterTheInterval() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("t\n")
        #expect(spy.count == 0, "not on the keystroke itself")

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

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

        for text in ["a", "ab", "abc", "abcd"] {
            session.edited(text)
        }

        await waitUntil { spy.count >= 1 }
        // Trailing debounce: each keystroke restarts the clock, so the run costs one write rather
        // than one per character.
        #expect(spy.written == ["abcd"])
    }

    @Test("A flush does not wait for the debounce, and the debounce does not fire behind it")
    func aFlushPreemptsThePendingSave() async {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("typed\n")
        #expect(session.flush() == .written)
        #expect(spy.count == 1, "the flush wrote immediately — no flush lag on a mode exit (05)")

        // And the timer it cancelled does not come back to write the same text a second time.
        await waitUntil { spy.count > 1 }
        #expect(spy.count == 1)
    }

    @Test("The production interval is ~700 ms")
    func theDefaultIntervalIsTheDesignsNumber() {
        // The only thing the seam must not do is quietly change the number the design settled
        // (05 ▸ Edit: "Saved on a ~700 ms debounce").
        #expect(CardBodyEditSession().debounceInterval == .milliseconds(700))
    }

    @Test("Ending the session flushes — the Edit→Preview flip is the effective Save")
    func endingTheSessionFlushes() {
        let spy = SaveSpy()
        let session = makeSession(spy)

        session.edited("typed on the way out\n")
        #expect(session.endEditSession() == .written)
        #expect(spy.written == ["typed on the way out\n"])
    }
}

// MARK: - The mode flip

@MainActor
@Suite("Card body ▸ leaving Edit flushes")
struct CardBodyModeFlushTests {

    @Test("Every way out of Edit flushes first")
    func leavingEditFlushes() {
        let presentation = CardBodyPresentation()
        var flushes = 0
        presentation.flushEdits = { flushes += 1 }
        presentation.openIfNeeded(body: "")
        #expect(presentation.mode == .edit)

        // ⌘E / the menu item's toggle …
        presentation.toggleMode()
        #expect(presentation.mode == .preview)
        #expect(flushes == 1)

        // … Escape in the editor, which is the same flip through the same door …
        presentation.setMode(.edit)
        #expect(flushes == 1, "entering Edit flushes nothing — there is nothing to flush yet")
        presentation.setMode(.preview)
        #expect(flushes == 2)
    }

    @Test("Setting the mode it already has flushes nothing")
    func aRedundantSetIsNotAFlush() {
        let presentation = CardBodyPresentation()
        var flushes = 0
        presentation.flushEdits = { flushes += 1 }
        presentation.setMode(.edit)

        presentation.setMode(.edit)
        presentation.setMode(.edit)

        #expect(flushes == 0, "a re-published focus value must not commit an untouched buffer")
    }
}
