import AppKit
import Foundation
import Testing
@testable import Kanban

/// An appearance store on a scratch defaults domain — the override is app-wide and persisted, so a
/// suite that used `.standard` would touch the developer's own appearance
/// (`BoardZoomTests.makeStore`'s reason). The apply seam defaults to a no-op so a test that is not
/// exercising it never touches `NSApp`.
@MainActor
private func makeStore(
    seeding stored: String? = nil,
    apply: @escaping (NSAppearance.Name?) -> Void = { _ in }
) -> (AppearanceStore, UserDefaults, () -> Void) {
    let name = "dev.rzen.indie.Kanban.appearance-tests.\(UUID().uuidString)"
    let defaults = UserDefaults(suiteName: name)!
    if let stored { defaults.set(stored, forKey: AppPreferences.appearanceKey) }
    return (AppearanceStore(defaults: defaults, apply: apply), defaults,
            { UserDefaults.standard.removePersistentDomain(forName: name) })
}

// MARK: - The pure resolver

/// `AppearanceStore.appearanceName(for:)` — no `NSApp`, no live application, provable with nothing but
/// the enum (`BoardZoomLadderTests`'s reason for testing `BoardZoom`'s pure functions on their own).
@Suite("Appearance ▸ the pure resolver")
struct AppearanceResolverTests {

    @Test("Light resolves to aqua, dark to darkAqua, and Auto to nothing at all")
    func resolvesToTheRightName() {
        #expect(AppearanceStore.appearanceName(for: .light) == .aqua)
        #expect(AppearanceStore.appearanceName(for: .dark) == .darkAqua)
        #expect(AppearanceStore.appearanceName(for: nil) == nil)
    }
}

// MARK: - The persisted override

@Suite("Appearance ▸ the persisted override")
@MainActor
struct AppearanceStorePersistenceTests {

    @Test("A fresh domain opens on Auto")
    func freshDomainIsAuto() {
        let (store, _, tearDown) = makeStore()
        defer { tearDown() }
        #expect(store.override == nil)
    }

    @Test("Light and Dark survive the trip through defaults")
    func overrideRoundTrips() {
        let (store, defaults, tearDown) = makeStore()
        defer { tearDown() }

        store.setOverride(.light)
        #expect(AppearanceStore(defaults: defaults).override == .light)

        store.setOverride(.dark)
        #expect(AppearanceStore(defaults: defaults).override == .dark)
    }

    /// The remove-at-default idiom, `BoardZoomStore`'s neighbours already keep it: Auto is meant to
    /// read as "no override on file" to anyone who inspects the domain, not as a third stored spelling
    /// of the same thing.
    @Test("Setting Auto removes the key rather than writing a third spelling of it")
    func autoRemovesTheKey() {
        let (store, defaults, tearDown) = makeStore(seeding: "light")
        defer { tearDown() }
        #expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")

        store.setOverride(nil)

        #expect(defaults.string(forKey: AppPreferences.appearanceKey) == nil)
        #expect(store.override == nil)
    }

    /// The trap this exists for: a hand edit, or a future build's spelling read by an older one, must
    /// degrade rather than crash or silently misapply an override nobody asked for.
    @Test("A stored string that is neither light nor dark degrades to Auto")
    func unknownStringIsAuto() {
        for stored in ["sepia", "", "Light", "LIGHT", "light "] {
            let (store, _, tearDown) = makeStore(seeding: stored)
            defer { tearDown() }
            #expect(store.override == nil, "\"\(stored)\" must not resolve to an override")
        }
    }

    @Test("Every case survives its own round trip through the raw value")
    func everyCaseRoundTrips() {
        for override in AppAppearance.allCases {
            let (store, defaults, tearDown) = makeStore()
            defer { tearDown() }
            store.setOverride(override)
            #expect(AppearanceStore(defaults: defaults).override == override)
        }
    }
}

// MARK: - The apply seam

/// The one write path — `setOverride` and `applyCurrent` — and the seam it hands its answer to,
/// proven with a recording closure rather than `NSApp` (`AppearanceStore.init`'s own reason for taking
/// one).
@Suite("Appearance ▸ the apply seam")
@MainActor
struct AppearanceApplySeamTests {

    @Test("Setting an override invokes the apply seam with the resolved name, in order")
    func setInvokesApply() {
        var applied: [NSAppearance.Name?] = []
        let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
        defer { tearDown() }

        store.setOverride(.light)
        store.setOverride(.dark)
        store.setOverride(nil)

        #expect(applied == [.aqua, .darkAqua, nil])
    }

    /// `BoardZoomStore.setLevel`'s own guard, load-bearing for the same reason: `@Observable` notifies
    /// on every assignment, equal or not, so an ungated write would hand the apply seam a repeat call
    /// for a selection that never moved.
    @Test("An unchanged value writes and applies nothing")
    func unchangedValueIsANoOp() {
        var applyCount = 0
        let (store, defaults, tearDown) = makeStore(apply: { _ in applyCount += 1 })
        defer { tearDown() }

        store.setOverride(.light)
        #expect(applyCount == 1)
        store.setOverride(.light)
        #expect(applyCount == 1, "the same value again must not re-apply")
        #expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")

        store.setOverride(nil)
        #expect(applyCount == 2)
        store.setOverride(nil)
        #expect(applyCount == 2, "Auto set twice must not re-apply either")
    }

    /// Launch's whole job: `init` only reads, so nothing has applied yet until this is called.
    @Test("applyCurrent re-applies the stored value, and reads nothing new")
    func applyCurrentReappliesTheStoredValue() {
        var applied: [NSAppearance.Name?] = []
        let (store, _, tearDown) = makeStore(seeding: "dark", apply: { applied.append($0) })
        defer { tearDown() }
        #expect(applied.isEmpty, "construction alone must not touch the apply seam")

        store.applyCurrent()

        #expect(applied == [.darkAqua])
        #expect(store.override == .dark, "applyCurrent hands out what init already resolved")
    }

    @Test("applyCurrent on a fresh Auto domain applies nil")
    func applyCurrentOnAutoAppliesNil() {
        var applied: [NSAppearance.Name?] = []
        let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
        defer { tearDown() }

        store.applyCurrent()

        #expect(applied == [nil])
    }
}
