import AppKit
import Observation

// MARK: - PrintSourceProvider

/// **One print's content, read at most twice** — once without comments, once with them if the user ever
/// asks.
///
/// ### Why it is a class and not a `PrintSource`
///
/// A thread is a **disk read** and comments are window-scoped, outside the board snapshot
/// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own
/// thread … and the board snapshot never loads comment content"). So a board print with comments on
/// costs one read per card, and comments are **off by default** precisely so nobody pays that without
/// asking (`PrintOptions.includesComments`). A plain value would force the choice at ⌘P, before the user
/// has seen the dialog: either read every thread on every ⌘P, or make the comments toggle inert.
///
/// This is the third answer. The comment-less source is taken eagerly (it is a walk of an
/// already-loaded snapshot and costs nothing), the comment-bearing one is built on first demand, and the
/// result is kept — so flipping the toggle back and forth in the panel, which re-lays-out the document
/// each time, reads each thread exactly once.
///
/// ### The snapshot is frozen either way
///
/// Both sources describe the board **as it was when ⌘P was pressed** (`PrintSource`'s own note). The
/// comment read is the one thing that happens later, which is a deliberate seam rather than a leak: a
/// thread that gained a comment between ⌘P and the toggle prints with it, and that is more useful than
/// a print that hid a comment because a dialog was open when it arrived.
@MainActor
final class PrintSourceProvider {

    private let withoutComments: PrintSource
    private let buildWithComments: () -> PrintSource
    private var cached: PrintSource?

    init(withoutComments: PrintSource, withComments: @escaping () -> PrintSource) {
        self.withoutComments = withoutComments
        buildWithComments = withComments
    }

    /// A source that already carries its comments — the card-window case, where "read the thread" is one
    /// read the window has already done and there is nothing to defer.
    init(complete source: PrintSource) {
        withoutComments = source
        buildWithComments = { source }
        cached = source
    }

    func source(includingComments: Bool) -> PrintSource {
        guard includingComments else { return withoutComments }
        if let cached { return cached }
        let source = buildWithComments()
        cached = source
        return source
    }
}

// MARK: - PrintSession

/// **One ⌘P, from the dialog opening to the sheet coming out** — the live options, the content behind
/// them, and the profile store they are saved into.
///
/// ### Why a session object at all
///
/// Three surfaces have to agree about one set of options while the print panel is up: the accessory's
/// controls (which write them), the document view (which lays out from them, several times, as the user
/// tries things), and the profile popup (which replaces them wholesale when a profile is chosen). A
/// `PrintOptions` value passed by copy to each would give three views of one decision. So the session is
/// the one holder, and it is the thing the accessory and the view are both built around — the
/// `CardComments` / `CardAttachments` pattern applied to a modal rather than a window.
///
/// It is deliberately **per print**, created by `PrintCoordinator` and discarded when the operation
/// finishes. Nothing here outlives the panel except what it writes into `PrintProfileStore`, which is
/// where persistence belongs.
@MainActor
@Observable
final class PrintSession {

    /// The options the panel is currently configured with. Every control in the accessory writes here,
    /// and the document view reads here at layout time.
    var options: PrintOptions

    /// Which row of the profile popup is showing — a name, either the reserved one or a saved profile's
    /// (`PrintProfileStore.menuNames`).
    ///
    /// **It is a label, not a binding.** Choosing a profile copies its options in; editing a control
    /// afterwards does *not* write back to the profile — it marks the selection as modified
    /// (`isModified`), and only Save commits. A popup that silently rewrote the profile the user was
    /// looking at would make named profiles useless, which is `PrintOptions`' value-semantics note one
    /// level up.
    var selectedProfileName: String

    /// Whether the live options have drifted from the selected profile's — what puts the popup's row in
    /// its "(modified)" state and what makes Save meaningful.
    ///
    /// The reserved row is **never** modified: it *is* the last-used options, so drifting from it is
    /// what it is for.
    var isModified: Bool {
        guard !PrintProfile.isReserved(selectedProfileName) else { return false }
        guard let saved = profiles.catalog.options(named: selectedProfileName) else { return true }
        return saved != options
    }

    let profiles: PrintProfileStore

    /// What is being printed. Read through `source(for:)` so the comments toggle decides whether the
    /// threads are read at all.
    @ObservationIgnored
    private let provider: PrintSourceProvider

    /// The printed card's own folder, for a card print — the anchor a relative image resolves against
    /// (`BodyTarget.resolve`). `nil` for a board print, where there is no single folder that would be
    /// right for every card (`PrintDocumentRenderer.appendBody`).
    @ObservationIgnored
    let cardFolder: URL?

    /// The print job's name, as the print queue and the Save-as-PDF panel show it — the board's name for
    /// a board print, the card's for a card print.
    @ObservationIgnored
    let jobTitle: String

    /// The board's name, for the running head. Held beside `jobTitle` rather than derived from it because
    /// the two differ for a card print, and beside the source rather than read out of it because the
    /// source is rebuilt when the comments toggle flips while the title never changes.
    @ObservationIgnored
    let boardTitle: String

    init(
        provider: PrintSourceProvider,
        profiles: PrintProfileStore,
        cardFolder: URL?,
        jobTitle: String,
        boardTitle: String
    ) {
        self.provider = provider
        self.profiles = profiles
        self.cardFolder = cardFolder
        self.jobTitle = jobTitle
        self.boardTitle = boardTitle
        // **⌘P opens on what the user did last**, which is the reserved pseudo-profile's whole purpose
        // (`PrintProfile.lastUsedName`) — and on the factory defaults the very first time, which is the
        // same thing said about a store with nothing in it.
        selectedProfileName = PrintProfile.lastUsedName
        options = profiles.lastUsed ?? PrintOptions()
    }

    // MARK: The document

    /// The blocks this print would lay out, given the options as they stand — one call, so the accessory's
    /// summary, the page count and the drawing can never describe three different documents.
    func blocks() -> [PrintBlock] {
        PrintDocumentBuilder.blocks(
            from: provider.source(includingComments: options.includesComments),
            options: options
        )
    }

    // MARK: Profiles

    /// Applies a profile's options wholesale. A name that resolves to nothing leaves the options alone
    /// and the selection where it was — the honest answer for a menu that raced a deletion.
    func selectProfile(named name: String) {
        guard let applied = profiles.options(named: name) else { return }
        selectedProfileName = name
        options = applied
    }

    /// Saves the live options under `name` and selects it. `false` is a refused name (blank, or the
    /// reserved one), which changes nothing.
    @discardableResult
    func saveProfile(named name: String) -> Bool {
        guard profiles.save(options, as: name) else { return false }
        selectedProfileName = PrintProfileCatalog.normalized(name)
        return true
    }

    @discardableResult
    func renameSelectedProfile(to newName: String) -> Bool {
        guard profiles.rename(selectedProfileName, to: newName) else { return false }
        selectedProfileName = PrintProfileCatalog.normalized(newName)
        return true
    }

    /// Deletes the selected profile and falls back to the reserved row — **keeping the options**. The
    /// user deleted a saved *name*, not the settings they are looking at, and resetting the panel under
    /// them would be the destructive reading of a management gesture.
    func deleteSelectedProfile() {
        guard !PrintProfile.isReserved(selectedProfileName) else { return }
        profiles.delete(selectedProfileName)
        selectedProfileName = PrintProfile.lastUsedName
    }

    /// Whether the selected row can be renamed or deleted — the reserved pseudo-profile cannot
    /// (`PrintProfile.lastUsedName`), and neither can a selection that names nothing.
    var canManageSelection: Bool {
        !PrintProfile.isReserved(selectedProfileName) && profiles.catalog.contains(selectedProfileName)
    }
}
