import Foundation

// MARK: - The leaves

/// One comment as paper needs it: who, when, and what they said — `Comment` with everything a
/// *window* needs stripped out (its identity, its attachments, its edited flag, its whole parsed
/// document).
///
/// The narrowing is the point. A print is a snapshot taken once and then re-laid-out several times as
/// the user tries options in the panel, so what crosses into the printing layer should be the smallest
/// thing that can answer every option — anything richer invites the renderer to start making decisions
/// the builder should have made.
public struct PrintComment: Sendable, Equatable {

    /// `nil` renders unattributed, exactly as the pane does — "**Missing renders unattributed**; there
    /// is no identity system behind it and none is implied" (`Comment.author`).
    public var author: String?

    /// `nil` for a comment whose `created` was missing or unreadable — a lenient field, and the byline
    /// simply says less rather than the print refusing.
    public var created: Date?

    public var body: String

    public init(author: String? = nil, created: Date? = nil, body: String) {
        self.author = author
        self.created = created
        self.body = body
    }

    /// A thread, flattened — **in the thread's own order** (`CommentThread.sorted`: `created`
    /// ascending, undated after dated, folder-name tie-break).
    ///
    /// The order arrives already correct and is never re-derived here: `PrintCommentSort.newestFirst`
    /// *reverses* this sequence rather than sorting by a key of its own, which is the same discipline
    /// the comments pane keeps ("The header's sort control reverses it for display and never re-sorts"
    /// — `CardComments.thread`). A second sort would be a second chance to disagree with the format's
    /// own chronology rule about what an undated comment means.
    public static func list(of thread: CommentThread) -> [PrintComment] {
        thread.comments.map {
            PrintComment(author: $0.author.value, created: $0.created.value, body: $0.body)
        }
    }
}

/// One card as paper needs it — the four things the component toggles can ask for, and nothing else.
public struct PrintCard: Sendable, Equatable {

    /// The title as written, or `nil` for an untitled card. The **placeholder is the builder's**
    /// (`PrintDocumentBuilder.untitled`), never stored here: "Untitled" is a rendering, never a value
    /// (03-board-ui.md § Card face), and putting the word in this struct would make it indistinguishable
    /// from a card someone actually named that.
    public var title: String?

    /// The card's `icon` — a name already resolved against the running system, or `nil` when the field
    /// named no symbol this OS can draw. Resolution happens at extraction (`ItemSymbol`), so the
    /// renderer never has to ask whether a glyph exists and the print of a hand-typed typo silently
    /// omits the glyph rather than drawing an empty box.
    public var icon: String?

    /// The reserved `labels` key, flattened to strings (`PrintCard.labels(of:)`).
    public var labels: [String]

    public var body: String

    /// The card's thread, in chronological order. Empty both for a card with no comments and for a
    /// print that never asked for them — the extraction reads a thread only when the options want one
    /// (`PrintSource.board(_:titled:comments:)`), which is what keeps a comment-less board print from
    /// paying a disk read per card.
    public var comments: [PrintComment]

    public init(title: String? = nil, icon: String? = nil, labels: [String] = [], body: String = "", comments: [PrintComment] = []) {
        self.title = title
        self.icon = icon
        self.labels = labels
        self.body = body
        self.comments = comments
    }

    // MARK: Extraction

    /// A snapshot card, narrowed — with its thread supplied by the caller, because a thread is a disk
    /// read and this type is a value.
    public static func from(_ card: Card, comments: [PrintComment] = []) -> PrintCard {
        PrintCard(
            title: card.title.value,
            // `nil` rather than the level default: a print is a document, and a `doc.text` glyph in
            // front of every single card is furniture rather than information. A card whose author
            // *chose* an icon gets it; the board's own defaults stay on screen where they help
            // scanning (03-board-ui.md § Card face).
            icon: card.icon.value.flatMap { ItemSymbol.exists($0) ? $0 : nil },
            labels: labels(of: card.document),
            body: card.body,
            comments: comments
        )
    }

    /// **The reserved `labels` key, read as a list of words** — and the only place in the app that
    /// interprets it at all.
    ///
    /// 01-storage-format.md § Frontmatter reserves `labels` for the tracker-integration story and this
    /// version gives it no life: it is an ordinary unknown key, shown verbatim in the card window's
    /// Details rows and searched by nothing (04 ▸ Search: "labels/tags and their kin are reserved,
    /// inert keys this version"). Printing is the one surface that asks for it by name, because the
    /// card that specifies this feature asks for it by name.
    ///
    /// So the reading is deliberately shallow and deliberately lenient — it is a *display* of bytes,
    /// not the activation of a field:
    ///
    /// - A **sequence** is its scalar members, in order; nested collections are skipped rather than
    ///   flattened, since a list of lists is not a label row.
    /// - A **single scalar** is one label, *except* that a comma-separated one splits — `labels: bug,
    ///   ui` is YAML's one string `"bug, ui"` and is overwhelmingly likely to be a hand-written pair.
    ///   This is the one inference here, and it is the friendly reading of the shape a human types.
    /// - Anything else (a mapping, a null, an empty string) contributes nothing.
    ///
    /// Blank members are dropped and the rest keep their bytes exactly. Nothing here can throw and
    /// nothing can fail — `CardDetails.display`'s posture, one key narrower.
    public static func labels(of document: FrontmatterDocument) -> [String] {
        guard let value = document.value(for: labelsKey) else { return [] }
        switch value {
        case let .sequence(members):
            return members.compactMap(scalarText(of:)).filter { !$0.isEmpty }
        case .mapping, .null:
            return []
        default:
            guard let text = scalarText(of: value), !text.isEmpty else { return [] }
            return text
                .split(separator: ",")
                .map { $0.trimmingCharacters(in: .whitespaces) }
                .filter { !$0.isEmpty }
        }
    }

    /// The reserved key's spelling, in one place — 01's own name for it.
    static let labelsKey = "labels"

    /// A scalar's text, or `nil` for a shape that is not a scalar. `YAMLValue.description` is the
    /// engine's own rendering and is the right answer for every scalar case (a bare `2026-01-01` label
    /// reads as its ISO form, which is what the file means); the collection cases are excluded here
    /// rather than described, since their `description` is diagnostic syntax nobody wants on paper.
    private static func scalarText(of value: YAMLValue) -> String? {
        switch value {
        case .null, .sequence, .mapping: nil
        default: value.description.trimmingCharacters(in: .whitespacesAndNewlines)
        }
    }
}

/// One lane as paper needs it: its heading and its cards, top to bottom.
public struct PrintLane: Sendable, Equatable {

    /// The lane's title, or `nil` for an untitled lane — the placeholder is the builder's, exactly as
    /// a card's is.
    public var title: String?

    public var cards: [PrintCard]

    public init(title: String? = nil, cards: [PrintCard]) {
        self.title = title
        self.cards = cards
    }
}

// MARK: - PrintSource

/// **What is being printed, as one immutable value** — the whole input to `PrintDocumentBuilder`, and
/// the seam between the app and the printing layer.
///
/// ### Why a snapshot of a snapshot
///
/// `BoardModel` is already immutable, so copying out of it looks redundant until you count what a
/// print does with it: the panel's preview re-lays-out the document every time the user flips a
/// toggle, and the board underneath may reload (an agent writing, a sync landing) at any point during
/// that dialog. A print that re-read the live store between preview refreshes would show one document
/// and put another on paper. So the source is taken **once**, when ⌘P is pressed, and the print is of
/// the board as it was at that moment — which is also what a user means by "print this".
///
/// It is also what makes the printing layer testable end to end without a board on disk, a store, or
/// a window: every rule below `PrintSource` is a function of this value and `PrintOptions`.
public struct PrintSource: Sendable, Equatable {

    /// Which of the two ⌘P targets produced this — the board window's, or one card window's.
    ///
    /// The builder needs to know, and cannot infer it: a one-lane board with one card is shape-identical
    /// to a printed card, and the two documents differ (a board print names the board and its lanes; a
    /// card print is the card).
    public enum Scope: Sendable, Equatable {
        case board
        case card
    }

    public var scope: Scope

    /// The board's display name — `AppModel.displayName(of:)`'s answer, which falls back to the folder
    /// name for an untitled board. Carried for both scopes: a printed card's running head names the
    /// board it came from, which is the one piece of context a loose sheet needs.
    public var boardTitle: String

    /// For `.board`, the live lanes in display order. For `.card`, exactly one lane — the card's own,
    /// carried so the print can say which lane it came from and so the two scopes share one shape.
    public var lanes: [PrintLane]

    public init(scope: Scope, boardTitle: String, lanes: [PrintLane]) {
        self.scope = scope
        self.boardTitle = boardTitle
        self.lanes = lanes
    }

    // MARK: Extraction

    /// **A whole board, lane by lane, card by card** — `snapshot.lanes` in display order, each lane's
    /// `cards` in display order, exactly as the loader ranked them (`Ranks.sortedForDisplay`). The
    /// left-to-right strip becomes a top-to-bottom document by reading it in the order it is already
    /// stored in; nothing here sorts anything.
    ///
    /// **The trash is excluded**, and by construction rather than by a filter: `BoardModel.trash` and
    /// `trashedLanes` are sibling containers of `lanes`, not members of it (`BoardModel.trash`'s own
    /// note — "A sibling container of `lanes`, not a lane"), so a walk of `lanes` cannot reach them. A
    /// board print is a print of the board; deleted cards are deleted.
    ///
    /// - Parameter comments: the thread read, per card — supplied by the caller so it can be skipped
    ///   entirely when the options do not want comments, and memoized when they do. Comments are
    ///   window-scoped and outside the snapshot (01 § Enhanced schema), so there is no board-level
    ///   reading of them to inherit; this closure is that read.
    public static func board(
        _ snapshot: BoardModel,
        titled boardTitle: String,
        comments: (Card) -> [PrintComment] = { _ in [] }
    ) -> PrintSource {
        PrintSource(
            scope: .board,
            boardTitle: boardTitle,
            lanes: snapshot.lanes.map { lane in
                PrintLane(
                    title: lane.title.value,
                    cards: lane.cards.map { PrintCard.from($0, comments: comments($0)) }
                )
            }
        )
    }

    /// **One card**, wrapped in its lane so both scopes share one shape.
    public static func card(
        _ card: Card,
        laneTitle: String?,
        boardTitle: String,
        comments: [PrintComment] = []
    ) -> PrintSource {
        PrintSource(
            scope: .card,
            boardTitle: boardTitle,
            lanes: [PrintLane(title: laneTitle, cards: [PrintCard.from(card, comments: comments)])]
        )
    }
}
