import Foundation
import Testing
@testable import Kanban

/// The close-with-a-dirty-buffer state machine (02-architecture.md § Write-failure surfacing, "The
/// one modal moment on the write-failure path").
///
/// Everything specific is a closure, so these tests are about one thing only: **whether the close
/// may proceed**. Each of the four exits — the save that just works, the retry that works on the
/// second try, the copy saved elsewhere, and the deliberate discard — has to leave the guard
/// `.idle`, and the failing save has to leave it `.blocked` carrying the error the alert will
/// phrase.

// MARK: - Support

/// Stands in for an editor's dirty buffer: some text, a save that can be made to fail, and a record
/// of where things actually went.
@MainActor
private final class FakeBuffer {
    var text: String
    /// Non-`nil` makes the next save (and every save after it) fail.
    var saveFailure: BoardWriteError?
    /// Set when `writeCopy` should refuse — a save panel pointed at a full disk.
    var copyFails = false

    private(set) var saveAttempts = 0
    private(set) var savedText: String?
    private(set) var copies: [URL: String] = [:]

    init(text: String = "the paragraph that exists nowhere else") {
        self.text = text
    }

    func makeGuard() -> DirtyBufferGuard {
        DirtyBufferGuard(
            attemptSave: { [self] () throws(BoardWriteError) in
                saveAttempts += 1
                if let saveFailure {
                    throw saveFailure
                }
                savedText = text
            },
            writeCopy: { [self] url in
                if copyFails {
                    throw CocoaError(.fileWriteOutOfSpace)
                }
                copies[url] = text
            }
        )
    }
}

private let diskFull = BoardWriteError(
    operation: .style(title: "Fix login"),
    path: "/Boards/Work/todo/fix-login/index.md",
    reason: .io(message: "the disk is full")
)

private func copyDestination() -> URL {
    FileManager.default.temporaryDirectory.appendingPathComponent("dirty-buffer-\(UUID().uuidString).md")
}

// MARK: - Tests

@MainActor
@Suite("DirtyBufferGuard")
struct DirtyBufferGuardTests {

    @Test("A save that lands never blocks — the close proceeds with no modal at all")
    func successfulSaveNeverBlocks() {
        let buffer = FakeBuffer()
        let bufferGuard = buffer.makeGuard()

        #expect(bufferGuard.beginClose())
        #expect(bufferGuard.phase == .idle)
        #expect(buffer.savedText == "the paragraph that exists nowhere else")
        #expect(buffer.saveAttempts == 1)
    }

    @Test("A failing save blocks the close and carries the error the alert will phrase")
    func failingSaveBlocks() {
        let buffer = FakeBuffer()
        buffer.saveFailure = diskFull
        let bufferGuard = buffer.makeGuard()

        #expect(!bufferGuard.beginClose())
        #expect(bufferGuard.phase == .blocked(diskFull))

        guard case let .blocked(error) = bufferGuard.phase else {
            Issue.record("expected the blocked phase")
            return
        }
        #expect(BannerCenter.headline(for: error) == "Couldn't restyle 'Fix login' — the disk is full")
    }

    @Test("Retrying after the cause is fixed unblocks the close")
    func retrySucceedsAndCloses() {
        let buffer = FakeBuffer()
        buffer.saveFailure = diskFull
        let bufferGuard = buffer.makeGuard()
        #expect(!bufferGuard.beginClose())

        // A retry while the disk is still full stays blocked — the alert returns, which is the
        // honest outcome and the reason there is no "close anyway" button.
        #expect(!bufferGuard.retry())
        #expect(bufferGuard.phase == .blocked(diskFull))

        buffer.saveFailure = nil
        #expect(bufferGuard.retry())
        #expect(bufferGuard.phase == .idle)
        #expect(buffer.savedText == "the paragraph that exists nowhere else")
        #expect(buffer.saveAttempts == 3)
    }

    @Test("Saving a copy elsewhere writes the text and unblocks the close")
    func saveCopyWritesAndUnblocks() throws {
        let buffer = FakeBuffer()
        buffer.saveFailure = diskFull
        let bufferGuard = buffer.makeGuard()
        #expect(!bufferGuard.beginClose())

        let destination = copyDestination()
        try bufferGuard.saveCopy(to: destination)

        #expect(bufferGuard.phase == .idle, "the text is safe somewhere; the close may proceed")
        #expect(buffer.copies[destination] == "the paragraph that exists nowhere else")
        // The buffer's real home is still unwritten — that is the trade the user knowingly made.
        #expect(buffer.savedText == nil)
    }

    @Test("A copy that itself fails leaves the close blocked")
    func failedCopyStaysBlocked() {
        let buffer = FakeBuffer()
        buffer.saveFailure = diskFull
        buffer.copyFails = true
        let bufferGuard = buffer.makeGuard()
        #expect(!bufferGuard.beginClose())

        #expect(throws: (any Error).self) {
            try bufferGuard.saveCopy(to: copyDestination())
        }
        #expect(bufferGuard.phase == .blocked(diskFull), "the text is still nowhere but memory")
    }

    @Test("Discarding unblocks the close and writes nothing anywhere")
    func discardUnblocks() {
        let buffer = FakeBuffer()
        buffer.saveFailure = diskFull
        let bufferGuard = buffer.makeGuard()
        #expect(!bufferGuard.beginClose())

        bufferGuard.discard()

        #expect(bufferGuard.phase == .idle)
        #expect(buffer.savedText == nil)
        #expect(buffer.copies.isEmpty)
    }
}
