import Foundation

// MARK: - PrintProfile

/// A named set of print options — "these configurations probably good to persist (as named print
/// profiles) and reused", the card's closing line, as a type.
///
/// ### Its name is its identity
///
/// No UUID, deliberately. A print profile is a **user's own label** for a way of printing ("Standup
/// handout", "Archive, comments on"), and a label is what the popup menu shows, what a rename
/// changes, and what a save collides on. Minting an id beside it would create a second identity the
/// UI never shows and the user could never reconcile — two profiles both called "Handout", one of
/// them unreachable. So names are unique (case-insensitively — see `PrintProfileCatalog`), and
/// renaming *is* re-identifying.
///
/// This is the same reasoning `ItemID` states for a card folder and reaches the opposite conclusion
/// for the opposite reason: a card's title is content that may repeat and must be free to, while a
/// profile's name is a key the user types.
public struct PrintProfile: Codable, Sendable, Equatable, Identifiable {

    public var name: String
    public var options: PrintOptions

    public var id: String { PrintProfileCatalog.key(name) }

    public init(name: String, options: PrintOptions) {
        self.name = name
        self.options = options
    }

    private enum CodingKeys: String, CodingKey { case name, options }

    /// Lenient for `PrintOptions`' reason, one level up: a stored profile missing its name or its
    /// options decodes to a nameless one rather than throwing, and a nameless profile is dropped by
    /// `PrintProfileCatalog.init(profiles:)`. Without this, one malformed entry in the plist would
    /// fail the whole array's decode and cost the user every profile they saved — the exact failure
    /// mode the option decoder exists to rule out.
    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = ((try? container.decodeIfPresent(String.self, forKey: .name)) ?? nil) ?? ""
        options = ((try? container.decodeIfPresent(PrintOptions.self, forKey: .options)) ?? nil) ?? PrintOptions()
    }

    // MARK: The reserved pseudo-profile

    /// **"Last Used" is reserved** and is not a member of the catalog at all — it is the options the
    /// last print ran with, captured automatically, and it exists so that ⌘P opens on *what the user
    /// did last* rather than on a factory default they overrode a hundred prints ago.
    ///
    /// A pseudo-profile rather than a real one because the two behave nothing alike: this one cannot
    /// be renamed, cannot be deleted, and rewrites itself on every print — a named profile does none
    /// of those and would be worthless if it did (the point of "Handout" is that it stays what it was
    /// when you saved it). Keeping the reserved name out of the stored list is also what makes the
    /// rule enforceable rather than remembered: `PrintProfileCatalog` refuses the name, so no code
    /// path can create a shadow of it.
    public static let lastUsedName = "Last Used"

    /// Whether `name` is one a user may claim. The comparison is the catalog's own key rule, so
    /// "last used" and "LAST USED" are refused exactly as the canonical spelling is.
    public static func isReserved(_ name: String) -> Bool {
        PrintProfileCatalog.key(name) == PrintProfileCatalog.key(lastUsedName)
    }
}

// MARK: - PrintProfileCatalog

/// **The named profiles, and every rule about them** — save, rename, delete, look up — as a pure
/// value type with no `UserDefaults` and no `@Observable` anywhere near it.
///
/// The split is the codebase's usual one (`BoardZoom` beside `BoardZoomStore`, `StyleRecents.updated`
/// beside the store that persists it): the rules are the interesting part and a rule that can only be
/// exercised through a preferences domain is a rule nobody tests. `PrintProfileStore` is this type's
/// persistence and its observability, and it owns no rules of its own.
///
/// ### Order is the user's, not the alphabet's
///
/// Profiles keep the order they were saved in, newest last, and a rename does not move one. The popup
/// menu is short by nature (a handful of ways one person prints), and a list that re-sorted itself
/// when a profile was renamed would move the row the user was looking at. `StyleRecents`' most-recent-
/// first list makes the opposite choice for the opposite reason — that list *is* a recency ranking.
public struct PrintProfileCatalog: Codable, Sendable, Equatable {

    public private(set) var profiles: [PrintProfile]

    public init(profiles: [PrintProfile] = []) {
        // Sanitized on the way in for the same reason the option decoder is lenient: this value comes
        // out of a preferences plist a human may have edited. Reserved and blank names are dropped,
        // and a duplicate keeps its first occurrence — the reading a menu can actually render.
        var kept: [PrintProfile] = []
        for profile in profiles {
            let name = Self.normalized(profile.name)
            guard !name.isEmpty, !PrintProfile.isReserved(name) else { continue }
            guard !kept.contains(where: { Self.key($0.name) == Self.key(name) }) else { continue }
            kept.append(PrintProfile(name: name, options: profile.options))
        }
        self.profiles = kept
    }

    // MARK: Names

    /// A name as stored: outer whitespace trimmed, inner text untouched. Trimming is what makes
    /// `" Handout "` and `"Handout"` the same profile rather than two rows that look identical.
    public static func normalized(_ name: String) -> String {
        name.trimmingCharacters(in: .whitespacesAndNewlines)
    }

    /// The comparison key — the normalized name case-folded. Case-insensitive because a user typing
    /// "handout" a week later means the profile they called "Handout", and two rows differing only in
    /// case is a bug report, not a feature.
    ///
    /// `localizedLowercase` rather than `lowercased()`: these are human words in the user's own
    /// language, unlike `ItemID`'s ASCII-hex fold.
    public static func key(_ name: String) -> String {
        normalized(name).localizedLowercase
    }

    /// Whether `name` may be saved or renamed to — blank and reserved refused, an existing name
    /// allowed (a save over one's own profile is an overwrite, which is what the Save button means
    /// when the popup is already on that profile).
    public static func isAcceptable(_ name: String) -> Bool {
        !normalized(name).isEmpty && !PrintProfile.isReserved(name)
    }

    // MARK: Reading

    public func contains(_ name: String) -> Bool {
        profiles.contains { Self.key($0.name) == Self.key(name) }
    }

    /// The named profile's options, or `nil` — the popup's selection resolved. Never a fallback to
    /// anything: a selection that names no profile is a UI out of step with its model, and quietly
    /// substituting the defaults would hide that.
    public func options(named name: String) -> PrintOptions? {
        profiles.first { Self.key($0.name) == Self.key(name) }?.options
    }

    /// The names in list order — the popup's rows below the reserved one.
    public var names: [String] { profiles.map(\.name) }

    // MARK: Writing

    /// Saves `options` under `name`, overwriting a profile of that name in place.
    ///
    /// **Overwrite rather than a second row**, and *in place* rather than moved to the end: saving
    /// again over "Handout" is the gesture "this is what Handout means now", and re-ordering the menu
    /// as a side effect of it would be the list moving under the user's cursor. The spelling is
    /// updated to whatever was typed — `"handout"` saved over `"Handout"` renames the case — because
    /// the last spelling the user typed is the one they meant.
    ///
    /// Refuses a blank or reserved name (`isAcceptable`), answering `false`. A refusal writes nothing.
    @discardableResult
    public mutating func save(_ options: PrintOptions, as name: String) -> Bool {
        guard Self.isAcceptable(name) else { return false }
        let stored = PrintProfile(name: Self.normalized(name), options: options)
        if let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) }) {
            profiles[index] = stored
        } else {
            profiles.append(stored)
        }
        return true
    }

    /// Renames a profile, keeping its position and its options.
    ///
    /// Refuses when the old name names nothing, when the new one is blank or reserved, or when it is
    /// already another profile's — a rename that swallowed a sibling would destroy a profile the user
    /// never mentioned. Renaming to a different **case of its own name** is allowed, and is the one
    /// case where the target name already exists.
    @discardableResult
    public mutating func rename(_ name: String, to newName: String) -> Bool {
        guard Self.isAcceptable(newName),
              let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) })
        else { return false }
        let collision = profiles.firstIndex { Self.key($0.name) == Self.key(newName) }
        guard collision == nil || collision == index else { return false }
        profiles[index].name = Self.normalized(newName)
        return true
    }

    /// Deletes a profile. A name that matches nothing is a no-op — the honest answer for a menu row
    /// that raced a deletion, and one no caller has to guard against.
    public mutating func delete(_ name: String) {
        profiles.removeAll { Self.key($0.name) == Self.key(name) }
    }

    // MARK: Codable

    /// A keyed container rather than a bare array, so the stored shape has somewhere to grow (an
    /// ordering key, a per-profile note) without every existing plist becoming undecodable. The
    /// decode routes through `init(profiles:)`, which is what applies the sanitizing rule to bytes
    /// that may have been hand-edited.
    private enum CodingKeys: String, CodingKey { case profiles }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let decoded = (try? container.decodeIfPresent([PrintProfile].self, forKey: .profiles)) ?? nil
        self.init(profiles: decoded ?? [])
    }
}
