import Foundation
import Testing
@testable import Kanban

/// The title field's buffer state machine — `CardBodyEditSessionTests`' own shape, one field over
/// (`CardTitleEditSession`'s doc comment: "a body-edit session in miniature").
///
/// The write gates, dirty-buffer-wins, the debounce and the mode-flip flush are the same claims
/// `CardBodyEditSessionTests` makes about the body, restated here because the two types are siblings
/// rather than one type reused — see `CardTitleEditSession`'s doc comment for why. What is new to this
/// suite is normalization (trim, empty → `nil`) and the newline filter; those get their own section.

// MARK: - The fake destination

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

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

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

@MainActor
private func makeSession(_ spy: TitleSaveSpy, title: String? = "Original") -> CardTitleEditSession {
    let session = CardTitleEditSession()
    session.debounceInterval = .milliseconds(30)
    session.save = { [spy] title in spy.save(title) }
    session.adopt(diskTitle: title)
    return session
}

/// Polls until `condition` holds or the deadline passes — `CardBodyEditSessionTests`' own helper.
@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 title ▸ the write gates")
struct CardTitleWriteGateTests {

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

        #expect(session.flush() == .unchanged)
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
        #expect(session.saveAttempts == 0)
    }

    @Test("An untitled card opened and left writes nothing")
    func anUntitledSessionWritesNothing() async {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: nil)

        #expect(session.text == "")
        #expect(session.flush() == .unchanged)
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
    }

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

        session.edited("Original and more")
        #expect(session.isDirty)
        session.edited("Original")
        #expect(!session.isDirty)

        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 = TitleSaveSpy()
        let session = makeSession(spy)

        session.edited("Typed")
        #expect(session.flush() == .written)
        #expect(spy.written == ["Typed"])

        session.adopt(diskTitle: "Typed")
        #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 = TitleSaveSpy()
        let session = makeSession(spy)

        session.adopt(diskTitle: "Theirs")

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

// MARK: - Normalization

@MainActor
@Suite("Card title ▸ normalization")
struct CardTitleNormalizationTests {

    @Test("Whitespace commits as empty — a title of three spaces is a slip, not a name")
    func whitespaceIsEmpty() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: nil)

        session.edited("   ")
        // Untouched, in the write-gate's own terms: the untitled card's normalized text is still
        // `nil`, so there is nothing to write — `commitRename`'s own "whitespace commits as empty".
        #expect(!session.isDirty)
        #expect(session.flush() == .unchanged)
        #expect(spy.count == 0)
    }

    @Test("Padded text writes trimmed, not with its padding")
    func paddedTextIsTrimmedAtCommit() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: nil)

        session.edited("  Fix login  ")
        #expect(session.isDirty)
        #expect(session.flush() == .written)
        #expect(spy.written == ["Fix login"])
    }

    @Test("Clearing an existing title to spaces removes it")
    func clearingToSpacesRemovesTheTitle() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: "First")

        session.edited("   ")
        #expect(session.isDirty)
        #expect(session.flush() == .written)
        #expect(spy.written == [String?.none])
    }

    @Test("Embedded newlines are stripped as they are typed — the single-line rule")
    func embeddedNewlinesAreStripped() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: nil)

        // A paste bringing in a hard return — never something a plain `TextField`'s Return key can
        // produce on its own (`CardWindowView.titleRow`'s `.onSubmit`), but a paste is not the same
        // door.
        session.edited("Foo\nBar\r\nBaz\r")
        #expect(session.text == "FooBarBaz", "no line break survives into the shown buffer")
        #expect(session.flush() == .written)
        #expect(spy.written == ["FooBarBaz"])
    }

    @Test("A no-op edit — typing then retyping the same normalized text — writes nothing")
    func aNoOpNormalizedEditWritesNothing() async {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: "Fix login")

        // Padding around the exact title already on disk normalizes to the same value.
        session.edited("  Fix login  ")
        #expect(!session.isDirty, "byte-identity discipline: padding around an unchanged title is not a change")
        #expect(session.flush() == .unchanged)
        await waitUntil { spy.count > 0 }
        #expect(spy.count == 0)
    }
}

// MARK: - Dirty-buffer-wins

@MainActor
@Suite("Card title ▸ dirty-buffer-wins")
struct CardTitleDirtyBufferTests {

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

        session.edited("Mine, unsaved")
        session.adopt(diskTitle: "Theirs")

        #expect(session.text == "Mine, unsaved")
        #expect(session.disk == "Theirs")
        #expect(session.isDirty)
    }

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

        session.edited("Mine, unsaved")
        session.adopt(diskTitle: "Theirs")

        #expect(session.flush() == .written)
        #expect(spy.written == ["Mine, unsaved"])
        #expect(!session.isDirty)
    }

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

        session.edited("Precious")
        let outcome = session.flush()

        guard case .failed = outcome else {
            Issue.record("expected the failure to be reported, got \(outcome)")
            return
        }
        #expect(session.text == "Precious")
        #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 = TitleSaveSpy()
        let session = makeSession(spy)
        spy.outcome = .suspended(.unwritableLocation(.permissionDenied))

        session.edited("Held")
        #expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied)))
        #expect(session.isDirty)
        try session.flushOrThrow()
    }

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

        #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 = CardTitleEditSession()
        session.adopt(diskTitle: "Start")
        session.edited("Typed")

        #expect(session.flush() == .vanished)
        #expect(session.isDirty)
        #expect(session.text == "Typed")
    }
}

// MARK: - The debounce

@MainActor
@Suite("Card title ▸ the debounce")
struct CardTitleDebounceTests {

    @Test("Typing saves once the keystrokes stop")
    func typingSavesAfterTheInterval() async {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: nil)

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

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

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

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

        await waitUntil { spy.count >= 1 }
        #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 = TitleSaveSpy()
        let session = makeSession(spy, title: nil)

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

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

    @Test("The production interval is ~700 ms — the body's own default")
    func theDefaultIntervalIsTheDesignsNumber() {
        #expect(CardTitleEditSession().debounceInterval == .milliseconds(700))
    }

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

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

// MARK: - The undo step

@MainActor
@Suite("Card title ▸ the undo step")
struct CardTitleUndoTests {

    @Test("A session's net effect registers exactly once, at endEditSession")
    func oneStepPerSession() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: "First")
        var registered: [(String?, String?)] = []
        session.registerUndo = { prior, new in registered.append((prior, new)) }

        // Two debounced ticks inside one session — still one step.
        session.edited("Second")
        _ = session.flush()
        session.edited("Third")
        _ = session.flush()
        _ = session.endEditSession()

        #expect(registered.count == 1)
        #expect(registered.first?.0 == "First", "the bytes before the session's first landed save")
        #expect(registered.first?.1 == "Third", "the bytes the session left")
    }

    @Test("A session that types its way back to where it started registers nothing")
    func aRoundTripRegistersNothing() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: "First")
        var registered = 0
        session.registerUndo = { _, _ in registered += 1 }

        session.edited("Detour")
        _ = session.flush()
        session.edited("First")
        _ = session.endEditSession()

        #expect(registered == 0)
    }

    @Test("A session that only read registers nothing")
    func aReadOnlySessionRegistersNothing() {
        let spy = TitleSaveSpy()
        let session = makeSession(spy, title: "First")
        var registered = 0
        session.registerUndo = { _, _ in registered += 1 }

        _ = session.endEditSession()

        #expect(registered == 0)
        #expect(spy.count == 0)
    }
}
