import AppKit

/// `[PrintBlock]` → the attributed text a page draws: **the drawing half of printing, and only the
/// drawing half**.
///
/// Every decision was already made in `PrintDocumentBuilder` — which components appear, in what order,
/// which end of a thread comes first, where a sheet boundary falls — which is what keeps this file free
/// of policy, exactly as `BodyMarkupRenderer` is kept free of it by `BodyMarkup`. The parallel is not a
/// coincidence: **card bodies are rendered by that very renderer**, through the same
/// `BodyMarkup.parse`, so a printed body is typographically the same document Preview shows. Reading
/// Markdown a second way here would guarantee the two eventually disagreed about a table, a task
/// checkbox or a nested quote.
///
/// ### Sections, not one string
///
/// The output is an **array** of attributed strings, split at `.pageBreak`. That is what makes a page
/// break honest: each section is paginated independently by `PrintDocumentView`, so a section always
/// starts at the top of a sheet. Inserting form feeds or padding newlines into one long string would
/// have been the alternative, and TextKit does not paginate on either — it would have produced a break
/// that looked right at one paper size and drifted at every other.
@MainActor
enum PrintDocumentRenderer {

    // MARK: - Sections

    /// The document, split into independently paginated sections.
    ///
    /// An empty block list answers `[]` rather than one empty section — a document with nothing in it
    /// has no pages, which is the answer `PrintCoordinator` refuses to print rather than spending a
    /// sheet on a running head over blank paper.
    static func sections(for blocks: [PrintBlock], options rawOptions: PrintOptions, cardFolder: URL? = nil) -> [NSAttributedString] {
        let options = rawOptions.normalized
        var sections: [NSAttributedString] = []
        var current = NSMutableAttributedString()

        for block in blocks {
            if case .pageBreak = block {
                if current.length > 0 { sections.append(current) }
                current = NSMutableAttributedString()
                continue
            }
            append(block, to: current, options: options, cardFolder: cardFolder)
        }
        if current.length > 0 { sections.append(current) }
        return sections
    }

    // MARK: - One block

    private static func append(
        _ block: PrintBlock,
        to output: NSMutableAttributedString,
        options: PrintOptions,
        cardFolder: URL?
    ) {
        let size = options.fontSize

        switch block {
        case .pageBreak:
            // Consumed by `sections(for:options:cardFolder:)` before it ever reaches here; switched
            // exhaustively so a future block cannot be forgotten.
            break

        case let .boardHeading(title):
            append(
                title,
                font: PrintTypography.boardHeading(options),
                color: PrintTypography.ink,
                spacingBefore: 0,
                spacingAfter: size * 0.9,
                to: output
            )

        case let .laneHeading(title):
            // A rule under the lane name, which is the one piece of decoration this document has and
            // earns it: in a flowed print the lane heading is the only signal that one column ended and
            // another began.
            append(
                title,
                font: PrintTypography.laneHeading(options),
                color: PrintTypography.ink,
                spacingBefore: size * 1.4,
                spacingAfter: size * 0.6,
                to: output,
                underlined: true
            )

        case let .cardTitle(title):
            append(
                title,
                font: PrintTypography.cardTitle(options),
                color: PrintTypography.ink,
                spacingBefore: size * 1.0,
                spacingAfter: size * 0.2,
                to: output
            )

        case let .cardMeta(icon, labels):
            appendMeta(icon: icon, labels: labels, options: options, to: output)

        case let .cardBody(body):
            appendBody(body, options: options, cardFolder: cardFolder, to: output)

        case let .commentsHeading(count):
            append(
                commentsHeadingText(count: count),
                font: PrintTypography.commentsHeading(options),
                color: PrintTypography.secondaryInk,
                spacingBefore: size * 0.9,
                spacingAfter: size * 0.2,
                to: output
            )

        case let .comment(author, created, body):
            append(
                byline(author: author, created: created),
                font: PrintTypography.secondary(options),
                color: PrintTypography.secondaryInk,
                spacingBefore: size * 0.5,
                spacingAfter: size * 0.1,
                to: output,
                indent: size * 1.5
            )
            appendBody(body, options: options, cardFolder: cardFolder, to: output, indent: size * 1.5)
        }
    }

    /// A card's Markdown, through the app's one Markdown pass and then re-faced.
    ///
    /// `cardFolder` is `nil` for a board print, and that is a real limitation rather than an oversight:
    /// a body's relative image resolves against *its own card's* folder (`BodyTarget.resolve`), and a
    /// board print walks many cards, so passing one folder would resolve some images against the wrong
    /// card. An unresolvable relative image renders as the placeholder chip `BodyMarkupRenderer` already
    /// draws for a remote one, which is the honest degrade. A card print, which has exactly one folder,
    /// passes it and prints its images.
    private static func appendBody(
        _ body: String,
        options: PrintOptions,
        cardFolder: URL?,
        to output: NSMutableAttributedString,
        indent: CGFloat = 0
    ) {
        let markup = BodyMarkup.parse(body)
        let rendered = BodyMarkupRenderer.attributedString(
            for: markup,
            context: BodyMarkupRenderer.Context(pointSize: options.fontSize, cardFolder: cardFolder)
        )
        let faced = PrintTypography.restyled(rendered, family: options.fontFamily)
        guard faced.length > 0 else { return }

        guard indent > 0 else {
            output.append(faced)
            return
        }
        // A comment body sits under its byline, so it is indented with it. The indent is *added* to
        // whatever the body's own paragraph styles already carry (a nested list keeps its nesting),
        // which is why this adjusts the existing styles rather than installing one.
        let indented = NSMutableAttributedString(attributedString: faced)
        indented.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: indented.length)) { value, range, _ in
            let style = ((value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
            style.firstLineHeadIndent += indent
            style.headIndent += indent
            indented.addAttribute(.paragraphStyle, value: style, range: range)
        }
        output.append(indented)
    }

    /// The icon-and-labels line: the symbol, then the labels joined by a middle dot.
    ///
    /// The icon is a **text attachment** rather than a rendered-to-text name: an SF Symbol has no
    /// spelling a reader would recognize, and `NSImage(systemSymbolName:)` is the same resolution
    /// `ItemSymbol` performs everywhere else in the app. A symbol that cannot be made into an image at
    /// print time contributes nothing — the line still prints its labels, which is the same
    /// omit-rather-than-box degrade `ItemSymbol` promises.
    ///
    /// The image itself comes from `PrintSymbol` rather than from `NSImage(systemSymbolName:)` directly,
    /// and that indirection is the whole of the printed-symbols fix: a symbol image is a *template*, which
    /// a print context tints across its whole box instead of through its coverage — a black rectangle
    /// where the icon should be. `PrintSymbol` hands back concrete artwork, already inked and already at
    /// paper resolution, plus the offset that sits it on the baseline; see its note.
    ///
    /// Labels are joined with " · " rather than drawn as chips. A chip is a screen affordance (a
    /// coloured, rounded, hit-testable thing); on paper it is ink around a word, and 01's reserved
    /// `labels` key carries no colour to draw it in anyway.
    private static func appendMeta(icon: String?, labels: [String], options: PrintOptions, to output: NSMutableAttributedString) {
        let font = PrintTypography.secondary(options)
        let style = NSMutableParagraphStyle()
        style.paragraphSpacingBefore = 0
        style.paragraphSpacing = options.fontSize * 0.45

        let line = NSMutableAttributedString()
        if let icon,
           let symbol = PrintSymbol.rendered(icon, pointSize: font.pointSize, ink: PrintTypography.secondaryInk) {
            let attachment = NSTextAttachment()
            attachment.image = symbol.image
            attachment.bounds = CGRect(
                x: 0,
                y: symbol.baselineOffset,
                width: symbol.image.size.width,
                height: symbol.image.size.height
            )
            line.append(NSAttributedString(attachment: attachment))
            if !labels.isEmpty {
                line.append(NSAttributedString(string: "  "))
            }
        }
        if !labels.isEmpty {
            line.append(NSAttributedString(string: labels.joined(separator: " · ")))
        }
        guard line.length > 0 else { return }

        line.append(NSAttributedString(string: "\n"))
        line.addAttributes(
            [.font: font, .foregroundColor: PrintTypography.secondaryInk, .paragraphStyle: style],
            range: NSRange(location: 0, length: line.length)
        )
        output.append(line)
    }

    // MARK: - Plain lines

    private static func append(
        _ text: String,
        font: NSFont,
        color: NSColor,
        spacingBefore: CGFloat,
        spacingAfter: CGFloat,
        to output: NSMutableAttributedString,
        underlined: Bool = false,
        indent: CGFloat = 0
    ) {
        guard !text.isEmpty else { return }

        let style = NSMutableParagraphStyle()
        style.paragraphSpacingBefore = spacingBefore
        style.paragraphSpacing = spacingAfter
        style.firstLineHeadIndent = indent
        style.headIndent = indent
        if underlined {
            // A hairline under the whole measure, drawn by a text block rather than by an underline
            // attribute, so it spans the column instead of only the letters — `BodyMarkupRenderer`'s
            // thematic-break mechanism, reused.
            let rule = NSTextBlock()
            rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .maxY)
            rule.setBorderColor(.separatorColor)
            rule.setWidth(font.pointSize * 0.2, type: .absoluteValueType, for: .padding, edge: .maxY)
            style.textBlocks = [rule]
        }

        output.append(NSAttributedString(string: text + "\n", attributes: [
            .font: font,
            .foregroundColor: color,
            .paragraphStyle: style
        ]))
    }

    // MARK: - The words the document says about itself

    /// "3 comments" / "1 comment" — the thread's heading.
    static func commentsHeadingText(count: Int) -> String {
        count == 1 ? "1 comment" : "\(count) comments"
    }

    /// A comment's byline. Both halves are optional and each is a lenient field, so all four
    /// combinations have to read as a sentence:
    ///
    /// - both → "Ada Lovelace — 9 Aug 2026 at 14:30"
    /// - author only → "Ada Lovelace" (a comment whose `created` was unreadable — the thread already
    ///   sorts those last rather than refusing them)
    /// - date only → the date ("**Missing renders unattributed**" — `Comment.author`)
    /// - neither → "Comment", so the body still has a line announcing it and never runs into the one
    ///   above it
    static func byline(author: String?, created: Date?) -> String {
        let name = author?.trimmingCharacters(in: .whitespacesAndNewlines)
        let stamp = created.map { $0.formatted(date: .abbreviated, time: .shortened) }
        switch (name?.isEmpty == false ? name : nil, stamp) {
        case let (author?, stamp?): return "\(author) — \(stamp)"
        case let (author?, nil): return author
        case let (nil, stamp?): return stamp
        case (nil, nil): return "Comment"
        }
    }
}
