import Foundation

// MARK: - The three enumerated choices

/// Where a printed board starts a new page — "whether to insert page breaking between cards or
/// between lanes", the card's own second bullet, with the third answer the one the card leaves
/// implicit: *nowhere*.
///
/// **Every case is a promise about paper, not about spacing.** `.betweenLanes` really starts a new
/// sheet at each lane, and `.betweenCards` really starts one at each card — a "break" that only
/// added vertical air would be the option lying about the one thing it exists to control
/// (`PrintDocumentView` is where the promise is kept: a break splits the document into separately
/// paginated sections rather than inserting whitespace into one).
///
/// `.flow` is the default because it is the cheapest print: a ten-lane board under `.betweenLanes`
/// is ten sheets minimum, which is the right answer only when the user asked for it.
public enum PrintPageBreaks: String, Codable, Sendable, CaseIterable {
    /// One continuous document; page boundaries fall wherever the text runs out of sheet.
    case flow
    /// A fresh sheet at each lane. Cards inside a lane still flow.
    case betweenLanes
    /// A fresh sheet at each card — which implies one at each lane too, since a lane begins with a
    /// card. The finest grain the option offers, and the most paper.
    case betweenCards
}

/// Which end of a thread a printed card's comments start from — "whether to include comments and
/// how to sort them" (the card's third bullet).
///
/// The two names are the *reading order* rather than a sort direction, deliberately: the thread's
/// own order is chronology (`CommentThread.sorted` — `created` ascending, undated last), so this
/// chooses whether that order is walked forwards or backwards and never re-sorts by anything else.
/// It mirrors the comments pane's own header control, whose persisted bit is spelled the same way
/// (`AppPreferences.commentsNewestFirstKey`) — but it is a *separate* value: how this user likes to
/// read a thread on screen and how they want it laid out on paper are two preferences, and binding
/// them would make a print profile silently rewrite a window.
public enum PrintCommentSort: String, Codable, Sendable, CaseIterable {
    case oldestFirst
    case newestFirst
}

// MARK: - PrintOptions

/// **Everything a print asks about a document, as one value type** — the card's five bullets
/// (components, page breaks, comments, styling, header/footer) with nothing about *paper* in it:
/// sheet size, orientation, margins, copies and the printer itself are `NSPrintInfo`'s and stay
/// there, because they are the system's questions and the print panel already asks them better than
/// we could.
///
/// ### Why a plain `Codable` struct and not an `@Observable` bag
///
/// This is what a **print profile** is (`PrintProfile`), and a profile has to round-trip through
/// `UserDefaults` byte-for-byte — save, quit, relaunch, restore. A reference type would also make
/// "the options this print is using" and "the options that profile holds" the same object, which is
/// exactly wrong: choosing a profile *copies* its values into the live sheet, and editing the sheet
/// afterwards must not rewrite the profile behind the user's back. Value semantics are that rule,
/// for free.
///
/// ### The decoder is total, on purpose
///
/// Every field decodes through `decodeIfPresent` onto its default, and out-of-range numbers are
/// clamped rather than rejected. A stored profile is a file in a preferences plist that a future
/// build may have written, an older build may be reading, and a human may have hand-edited — the
/// storage layer's own leniency doctrine (01-storage-format.md § Frontmatter: lenient fields
/// degrade, they never refuse) applied to app-side state. The failure mode this rules out is the
/// one that matters: a single unknown key must not cost the user every profile they saved.
public struct PrintOptions: Codable, Sendable, Equatable {

    // MARK: Components — "which constituent components/datapoints to include"

    /// The card's title line. On by default: a printed card with no title is a page of prose with no
    /// idea what it is about.
    public var includesTitle = true

    /// The card's **icon and labels line** — its `icon` symbol followed by whatever the reserved
    /// `labels` key carries (`PrintCard.labels(of:)`).
    ///
    /// One toggle for the pair rather than two, because they are one *line* on paper: an icon with
    /// the labels switched off is a glyph alone on a line, which is furniture rather than
    /// information. 01-storage-format.md § Frontmatter reserves `labels` and this version interprets
    /// nothing by it (05-card-window.md ▸ Details: "ordinary unknown keys in this version"), so what
    /// prints is what the file says, flattened — never a chip, never a colour.
    public var includesLabels = true

    /// The card's body, **rendered** — the Markdown subset Preview draws, through the same parse
    /// (05-card-window.md ▸ Preview; `BodyMarkup`). Never the raw source: a print of the bytes is
    /// what ⌥⌘E is for, and a reader holding paper wants the document, not its markup.
    public var includesBody = true

    // MARK: Comments — "whether to include comments and how to sort them"

    /// **Off by default.** A thread is conversation *about* a card, and the overwhelmingly common
    /// print is the card; a board print with comments on is also the one shape that costs a disk read
    /// per card (`CommentThread` is window-scoped and outside the snapshot — 01 § Enhanced schema),
    /// which is a cost nobody should pay without asking.
    public var includesComments = false

    /// Which end the thread starts from when `includesComments` is on. Ignored entirely when it is
    /// off — kept rather than made optional so toggling comments back on restores the choice the user
    /// last made instead of resetting it.
    public var commentSort: PrintCommentSort = .oldestFirst

    // MARK: Page breaks

    public var pageBreaks: PrintPageBreaks = .flow

    // MARK: Styling — "font face, size & style"

    /// The base font family, or `nil` for the system font.
    ///
    /// **A family name, not a font.** Weight and slant are the document's to decide — a heading is
    /// bold because it is a heading, emphasis is italic because the author wrote `*it*` — so what a
    /// user picks here is the *face* the whole document is set in, and every derived style keeps its
    /// own traits inside it (`PrintTypography.restyled`). A name the running system cannot resolve
    /// degrades to the system font, `ItemSymbol.exists`' posture applied to type: a profile written
    /// on a machine with Palatino installed must still print on one without it.
    public var fontFamily: String?

    /// The body point size. **The one size the document has**: headings, the labels line, comment
    /// bylines and the header/footer are all multiples of it (`PrintTypography`), which is the
    /// "body-vs-headings derive from one base choice" ruling — a print dialog with six size fields is
    /// a typesetting program, and this is a print dialog.
    public var fontSize: Double = 11

    /// The legal range, and the reason it is a range at all: a size of 0 draws nothing and a size of
    /// 400 draws one letter per page, and both are reachable from a hand-edited plist.
    public static let fontSizeRange: ClosedRange<Double> = 6 ... 36

    // MARK: Header and footer — "page footer/header"

    /// The board's title, in the running head.
    public var headerShowsBoardTitle = true

    /// The date the print was run, in the running head. **The print's date, not the board's
    /// `modified`**: a printout's own question is "how old is this piece of paper".
    public var headerShowsPrintDate = true

    /// "Page 3 of 7", in the running foot. On by default — a stapled board print with no folios is
    /// a pile.
    public var footerShowsPageNumbers = true

    /// A line of the user's own in the running foot — a project code, a distribution note, a
    /// confidentiality banner.
    ///
    /// Two fields rather than one so switching the line off keeps the text: the toggle is a
    /// *decision* and the string is *content*, and losing the content on every toggle would make the
    /// pair useless for the case it exists for (a banner used on some prints and not others).
    public var footerShowsCustomLine = false
    public var footerCustomLine = ""

    public init() {}

    // MARK: - Normalizing

    /// `size` inside `fontSizeRange` — the one normalization that happens **on the way in**, because
    /// a stored size is the likeliest defect in this whole structure and the only one that can make a
    /// page undrawable (`AppPreferences.boardZoomLevelKey`'s own trap: an unset or hand-edited number
    /// that renders a document of hairlines).
    public static func clamped(fontSize size: Double) -> Double {
        guard size.isFinite else { return PrintOptions().fontSize }
        return min(max(size, fontSizeRange.lowerBound), fontSizeRange.upperBound)
    }

    /// **The render's reading of these options**, not a rewrite of them — applied by the builder and
    /// the renderer, never by the decoder, so `decode(encode(x)) == x` holds for every value a user
    /// can reach.
    ///
    /// It flattens the two "content without a reason to exist" cases into their honest form: a blank
    /// custom line is the same as not having one, and a whitespace family name is the same as the
    /// system font. Both stay *readings* — the stored profile keeps whatever the user typed
    /// (`footerCustomLine`'s own note), so a banner emptied for one print and typed back in for the
    /// next never loses its toggle.
    public var normalized: PrintOptions {
        var copy = self
        copy.fontSize = Self.clamped(fontSize: fontSize)
        if let family = copy.fontFamily, family.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            copy.fontFamily = nil
        }
        if copy.footerCustomLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            copy.footerShowsCustomLine = false
        }
        return copy
    }

    /// Whether anything at all would print with these options — the Print button's own floor.
    ///
    /// All three component toggles off is a legal state a user can reach one click at a time, and it
    /// prints a document of nothing but running heads. The command does not disable on it (a user
    /// mid-configuration must not have the button taken away), but the builder answers it honestly:
    /// `PrintDocumentBuilder.blocks` returns an empty document, and the operation refuses rather than
    /// spending paper.
    public var describesAnyContent: Bool {
        includesTitle || includesLabels || includesBody || includesComments
    }

    // MARK: - Codable

    private enum CodingKeys: String, CodingKey {
        case includesTitle, includesLabels, includesBody
        case includesComments, commentSort
        case pageBreaks
        case fontFamily, fontSize
        case headerShowsBoardTitle, headerShowsPrintDate
        case footerShowsPageNumbers, footerShowsCustomLine, footerCustomLine
    }

    /// See the type's doc comment: every field falls back to its default, and the two enumerations
    /// fall back to theirs rather than failing the decode, so a value written by a build that knows a
    /// fourth page-break mode reads as `.flow` here instead of taking the whole profile down with it.
    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        var options = PrintOptions()

        /// A key read three ways at once: absent, present-but-the-wrong-type, and present-and-good —
        /// the first two answering the default. `try?` around `decodeIfPresent` is what collapses the
        /// middle case, and the double optional it produces is why this is a function rather than an
        /// expression repeated eleven times.
        func read<T: Decodable>(_ type: T.Type, _ key: CodingKeys) -> T? {
            guard let decoded = try? container.decodeIfPresent(type, forKey: key) else { return nil }
            return decoded
        }

        options.includesTitle = read(Bool.self, .includesTitle) ?? options.includesTitle
        options.includesLabels = read(Bool.self, .includesLabels) ?? options.includesLabels
        options.includesBody = read(Bool.self, .includesBody) ?? options.includesBody
        options.includesComments = read(Bool.self, .includesComments) ?? options.includesComments
        options.commentSort = read(String.self, .commentSort)
            .flatMap(PrintCommentSort.init(rawValue:)) ?? options.commentSort
        options.pageBreaks = read(String.self, .pageBreaks)
            .flatMap(PrintPageBreaks.init(rawValue:)) ?? options.pageBreaks
        options.fontFamily = read(String.self, .fontFamily)
        options.fontSize = read(Double.self, .fontSize) ?? options.fontSize
        options.headerShowsBoardTitle = read(Bool.self, .headerShowsBoardTitle) ?? options.headerShowsBoardTitle
        options.headerShowsPrintDate = read(Bool.self, .headerShowsPrintDate) ?? options.headerShowsPrintDate
        options.footerShowsPageNumbers = read(Bool.self, .footerShowsPageNumbers) ?? options.footerShowsPageNumbers
        options.footerShowsCustomLine = read(Bool.self, .footerShowsCustomLine) ?? options.footerShowsCustomLine
        options.footerCustomLine = read(String.self, .footerCustomLine) ?? options.footerCustomLine
        options.fontSize = Self.clamped(fontSize: options.fontSize)

        self = options
    }

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(includesTitle, forKey: .includesTitle)
        try container.encode(includesLabels, forKey: .includesLabels)
        try container.encode(includesBody, forKey: .includesBody)
        try container.encode(includesComments, forKey: .includesComments)
        try container.encode(commentSort.rawValue, forKey: .commentSort)
        try container.encode(pageBreaks.rawValue, forKey: .pageBreaks)
        try container.encodeIfPresent(fontFamily, forKey: .fontFamily)
        try container.encode(fontSize, forKey: .fontSize)
        try container.encode(headerShowsBoardTitle, forKey: .headerShowsBoardTitle)
        try container.encode(headerShowsPrintDate, forKey: .headerShowsPrintDate)
        try container.encode(footerShowsPageNumbers, forKey: .footerShowsPageNumbers)
        try container.encode(footerShowsCustomLine, forKey: .footerShowsCustomLine)
        try container.encode(footerCustomLine, forKey: .footerCustomLine)
    }
}
