import Foundation
import Observation
import os

/// The named print profiles and the Last Used capture, persisted — **app-side, never in a board**
/// (02-architecture.md § Per-board app state: "App-wide state has the same home").
///
/// ### Why `UserDefaults` and not the board
///
/// A print profile describes *how this user likes to read on paper*. It is not a property of any
/// board, it is not shared with a collaborator, and it has no business in frontmatter — the same
/// argument `BoardZoomStore` makes for the zoom level, one rung stronger: a lane's `width` genuinely
/// is board data because everyone opening the board sees it, while "Archive, comments on, Palatino
/// 10pt" is one person's habit. Writing it into a `.kanban` package would also make it a thing agents
/// and syncs have to round-trip, for a value no agent will ever read.
///
/// It is not in `BoardRegistry` either, for `BoardZoomStore`'s reason: profiles are not per board, and
/// a board opened on two machines wants the same *preference* applied rather than a per-board memory
/// of one.
///
/// ### Why `@Observable` rather than `@AppStorage`
///
/// The accessory's profile popup and the print operation's live preview both read this, and the
/// preview's refresh is driven by KVO on the accessory controller
/// (`PrintOptionsAccessoryController`), which needs a change it can observe — a property wrapper
/// living inside a SwiftUI view body cannot give a menu row or a print panel that. `BoardZoomStore`'s
/// shape exactly, and for the same two consumers' reasons.
///
/// ### Rules live in `PrintProfileCatalog`
///
/// Everything about names, collisions, ordering and the reserved pseudo-profile is that value type's.
/// This object holds one, publishes it, and persists it — nothing else. `defaults` is injectable so a
/// test drives a suite of its own rather than the developer's own preferences (`BoardZoomStore`'s
/// note, verbatim in intent).
@MainActor
@Observable
public final class PrintProfileStore {

    /// The named profiles. Mutated only through the three methods below, each of which persists.
    public private(set) var catalog: PrintProfileCatalog

    /// **The options the last print ran with** — the reserved "Last Used" pseudo-profile's content
    /// (`PrintProfile.lastUsedName`).
    ///
    /// A first launch has none, and that absence is meaningful rather than a missing value to paper
    /// over: `options(named:)` answers the factory defaults for it, and the popup still shows the row,
    /// because "Last Used" naming today's defaults on the very first print is the truth — nothing else
    /// has been used yet.
    public private(set) var lastUsed: PrintOptions?

    @ObservationIgnored
    private let defaults: UserDefaults

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing")

    /// - Parameter defaults: the domain to persist in. Injected for `BoardZoomStore`'s reason — a test
    ///   must be able to hold its own without touching the user's.
    public init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        catalog = Self.decode(PrintProfileCatalog.self, from: defaults, key: AppPreferences.printProfilesKey)
            ?? PrintProfileCatalog()
        lastUsed = Self.decode(PrintOptions.self, from: defaults, key: AppPreferences.printLastUsedKey)
    }

    // MARK: - Reading

    /// The options a menu selection resolves to: the reserved row answers `lastUsed` (or the factory
    /// defaults on a first launch — see `lastUsed`), a named row answers the catalog, and a name that
    /// matches neither answers `nil`.
    public func options(named name: String) -> PrintOptions? {
        if PrintProfile.isReserved(name) { return lastUsed ?? PrintOptions() }
        return catalog.options(named: name)
    }

    /// What the profile popup lists, top to bottom: the reserved row first, then the named ones in the
    /// order they were saved (`PrintProfileCatalog`'s own ordering note).
    ///
    /// The reserved row leads because it is what ⌘P opens on, and a menu whose first row is not the
    /// selected one reads as a menu that lost the user's place.
    public var menuNames: [String] { [PrintProfile.lastUsedName] + catalog.names }

    // MARK: - Writing

    /// **Captures the options a print just ran with.** Called when the operation ends and only if it ran
    /// (`PrintCompletion`, which carries the reasoning): the sheeted print panel returns control
    /// immediately, so there is no "on the way in" moment at which the user's edits exist yet, and Cancel is
    /// indistinguishable from a printer failure at the end — so the gate is success, and a cancelled print
    /// leaves the remembered settings exactly where they were.
    ///
    /// An unchanged capture writes nothing and publishes nothing, `BoardZoomStore.setLevel`'s guard and
    /// for its load-bearing reason: `@Observable` notifies on every set, and a no-op notification here
    /// would invalidate the accessory's own view mid-print.
    public func captureLastUsed(_ options: PrintOptions) {
        guard options != lastUsed else { return }
        lastUsed = options
        persist(options, key: AppPreferences.printLastUsedKey)
    }

    /// Saves `options` as a named profile, overwriting one of that name — the catalog's rule, persisted.
    /// `false` is a refused name (blank, or the reserved one), which writes nothing.
    @discardableResult
    public func save(_ options: PrintOptions, as name: String) -> Bool {
        var updated = catalog
        guard updated.save(options, as: name) else { return false }
        catalog = updated
        persistCatalog()
        return true
    }

    @discardableResult
    public func rename(_ name: String, to newName: String) -> Bool {
        var updated = catalog
        guard updated.rename(name, to: newName) else { return false }
        catalog = updated
        persistCatalog()
        return true
    }

    public func delete(_ name: String) {
        var updated = catalog
        updated.delete(name)
        guard updated != catalog else { return }
        catalog = updated
        persistCatalog()
    }

    // MARK: - Persistence

    /// JSON in a single `Data` value rather than a plist tree of dictionaries.
    ///
    /// The stored shape is then `Codable`'s, which is the shape the tests round-trip and the shape a
    /// future field extends — as opposed to a hand-written `[[String: Any]]` mapping that would have to
    /// be kept in step with the struct by hand. `UserDefaults` stores `Data` natively, so this costs
    /// nothing but a serialization the app performs at most once per print.
    private func persistCatalog() {
        persist(catalog, key: AppPreferences.printProfilesKey)
    }

    private func persist<Value: Encodable>(_ value: Value, key: String) {
        do {
            defaults.set(try JSONEncoder().encode(value), forKey: key)
        } catch {
            // Nothing user-facing: a preferences write that fails costs a remembered profile, not any
            // of the user's content, and there is no board banner that would be honest about it (02 §
            // Write-failure surfacing is about *board* writes). The log is the whole response.
            Self.logger.error("print profiles could not be persisted: \(error.localizedDescription, privacy: .public)")
        }
    }

    /// Total, by the whole file's doctrine: a key holding the wrong type, truncated JSON, or a shape
    /// from a build that has moved on all answer `nil`, which reads as "nothing stored yet" — the same
    /// degrade `StyleRecents` gives a garbage preference rather than taking the surface down with it.
    private static func decode<Value: Decodable>(_ type: Value.Type, from defaults: UserDefaults, key: String) -> Value? {
        guard let data = defaults.data(forKey: key) else { return nil }
        guard let value = try? JSONDecoder().decode(type, from: data) else {
            logger.warning("stored value for '\(key, privacy: .public)' could not be read — ignored")
            return nil
        }
        return value
    }
}
