import AppKit

/// **The printed page** — the view `NSPrintOperation` paginates and draws, and the one place a page
/// break becomes a real sheet boundary.
///
/// ### Why a custom view rather than printing an `NSTextView`
///
/// An `NSTextView` paginates itself, which is most of what this file does, and it was the obvious first
/// choice. It cannot keep the one promise the feature is built on: **"between lanes" must really start a
/// new page** (`PrintPageBreaks`). TextKit has no page-break character — a form feed is laid out as
/// whitespace, not as a boundary — so a text view could only ever have been given padding newlines, which
/// land in the right place at one paper size and drift at every other, and which would silently stop
/// working the day someone changed the margins. A break has to be a *pagination* fact, not a spacing one.
///
/// So the document is split into sections at its breaks (`PrintDocumentRenderer.sections`), each section
/// gets its own TextKit stack, and each section's text is flowed into as many page-sized text containers
/// as it needs. A section therefore always begins at the top of a sheet, at every paper size, with any
/// margins — because a container boundary *is* a page boundary here, by construction.
///
/// **TextKit 1 deliberately** (`NSLayoutManager`, `NSTextContainer`), and not by inertia: card bodies are
/// rendered by `BodyMarkupRenderer`, whose GFM tables are `NSTextTable`s — a TextKit 1 construct, which
/// is the same reason the card window's own body surface runs on TextKit 1 (`CardBodySurfaceView`). The
/// multiple-containers-per-layout-manager flow this file relies on is also TextKit 1's; TextKit 2 models
/// it differently and would have to be a separate design, not a search-and-replace.
///
/// ### Header and footer are drawn here, not handed to AppKit
///
/// `NSView` has a `pageHeader`/`pageFooter` pair and `drawPageBorder(withSize:)` to go with them. They
/// are not used: their content is a single attributed string per page with no control over placement,
/// they draw outside the imageable rect the pagination already accounts for, and their behaviour depends
/// on an `NSPrintInfo` dictionary key rather than on anything this app can state. Drawing the two lines
/// inside the page rect — with the text container's height reduced by exactly their heights — makes the
/// running head, the pagination and the folio one arithmetic instead of three that have to agree.
///
/// ### One known limit, stated rather than hidden
///
/// There is no widow/orphan control: a card title can fall as the last line of a page with its body
/// overleaf. `NSParagraphStyle` has no keep-with-next, so the honest fixes are a measure-and-push pass or
/// per-card sections — the second of which is exactly what `.betweenCards` already offers a user who
/// cares. Left as it is, and noted here so the next reader knows it was a decision.
@MainActor
final class PrintDocumentView: NSView {

    // MARK: - What it prints

    private let session: PrintSession

    /// The imageable area of one sheet — paper minus margins, as `NSPrintInfo` reports it. Fixed for the
    /// operation: the panel's paper and orientation controls rebuild the operation rather than mutating
    /// this.
    private let pageSize: CGSize

    /// The running head's and foot's heights, computed once from the options' own type scale. Zero when
    /// the line has nothing to say, which is what reclaims the paper rather than leaving a blank band
    /// (`PrintRunningHead.Line.isEmpty`).
    private var headerHeight: CGFloat = 0
    private var footerHeight: CGFloat = 0

    /// One page: the layout manager that owns its glyphs, and which of its containers this page is.
    private struct Page {
        let layoutManager: NSLayoutManager
        let containerIndex: Int
    }

    private var pages: [Page] = []

    /// The text storages, held only to keep them alive: a layout manager does not retain its storage, and
    /// a deallocated storage takes the glyphs with it (a page that draws nothing, intermittently).
    private var storages: [NSTextStorage] = []

    /// The options the current pagination was computed from — the guard that keeps `knowsPageRange` from
    /// re-flowing the whole document on every one of the preview's repeated calls when nothing changed.
    private var paginatedOptions: PrintOptions?

    /// A ceiling on the page count, so a pathological layout cannot spin forever inside a modal panel.
    /// It is deliberately far above any real print: a 500-card board with every comment is a few hundred
    /// sheets, and a document that wants more than this has hit a bug, not a use case.
    private static let pageLimit = 5000

    /// The date the print was configured — stamped once, at construction, so every sheet of one job
    /// carries the same date even if the job straddles midnight.
    private let printedAt = Date()

    init(session: PrintSession, printInfo: NSPrintInfo) {
        self.session = session
        pageSize = Self.imageableSize(of: printInfo)
        super.init(frame: CGRect(origin: .zero, size: pageSize))
        // **Forced light appearance, and it is load-bearing.** Bodies come from `BodyMarkupRenderer`,
        // which sets `NSColor.labelColor` and its neighbours — dynamic colours resolved against the
        // drawing appearance at draw time. In a dark-mode app that resolves to near-white, which on paper
        // is a blank sheet. Pinning the view's appearance resolves every one of them the way paper needs,
        // without the renderer having to know it is being printed.
        //
        // The appearance is `PrintTypography`'s rather than a local `NSAppearance(named: .aqua)` because
        // `PrintSymbol` needs the same one — it inks an icon into a bitmap before this view exists, so it
        // cannot inherit this line and the two must not be able to drift apart.
        appearance = PrintTypography.paper
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("PrintDocumentView is created in code") }

    /// Text goes down the page, so the view's y does too — which also makes a page's rect
    /// `(pageIndex × height)` rather than a subtraction from the total.
    override var isFlipped: Bool { true }

    // MARK: - Paper

    /// The imageable content size: the paper minus the four margins the print panel is showing.
    ///
    /// `paperSize` and the four margins rather than `imageablePageBounds`, deliberately: the latter is the
    /// *printer's* hardware limit, and using it would silently override the margins the user set in the
    /// panel — a document that ignored a 1-inch margin because the printer could reach further. The
    /// margins are the document's, and the panel owns them.
    static func imageableSize(of printInfo: NSPrintInfo) -> CGSize {
        let paper = printInfo.paperSize
        let width = paper.width - printInfo.leftMargin - printInfo.rightMargin
        let height = paper.height - printInfo.topMargin - printInfo.bottomMargin
        // A margin set larger than the paper is reachable from a hand-edited print preset; a floor keeps
        // the pagination loop from meeting a container it can never fill.
        return CGSize(width: max(72, width), height: max(72, height))
    }

    /// Where the text lives on a page, once the running head and foot have taken theirs.
    private var textSize: CGSize {
        CGSize(width: pageSize.width, height: max(24, pageSize.height - headerHeight - footerHeight))
    }

    // MARK: - Pagination

    /// **The whole pagination**, run by AppKit before each print and before each preview refresh.
    ///
    /// Re-flowing is guarded on the options rather than done unconditionally: the print panel calls this
    /// several times per interaction, and a 500-card board's layout is not free.
    override func knowsPageRange(_ range: NSRangePointer) -> Bool {
        paginate()
        let count = max(1, pages.count)
        setFrameSize(CGSize(width: pageSize.width, height: pageSize.height * CGFloat(count)))
        range.pointee = NSRange(location: 1, length: count)
        return true
    }

    override func rectForPage(_ page: Int) -> NSRect {
        NSRect(
            x: 0,
            y: CGFloat(page - 1) * pageSize.height,
            width: pageSize.width,
            height: pageSize.height
        )
    }

    /// How many sheets the document currently needs — the folio's denominator, and the summary line's
    /// number. Paginates if it has to, so a caller never has to sequence the two.
    func pageCount() -> Int {
        paginate()
        return max(1, pages.count)
    }

    private func paginate() {
        let options = session.options.normalized
        guard paginatedOptions != options else { return }
        paginatedOptions = options

        measureRunningLines(options: options)

        pages = []
        storages = []

        let sections = PrintDocumentRenderer.sections(
            for: session.blocks(),
            options: options,
            cardFolder: session.cardFolder
        )

        for section in sections {
            let storage = NSTextStorage(attributedString: section)
            let manager = NSLayoutManager()
            // Font leading, so a line of 18pt heading and a line of 11pt body each take the space their
            // own face asks for — the same reason the card window's body surface leaves it on.
            manager.usesFontLeading = true
            storage.addLayoutManager(manager)
            storages.append(storage)

            let total = manager.numberOfGlyphs
            var laidOut = 0
            var containerIndex = 0

            // A section with no glyphs still gets no page: `sections(for:...)` never emits an empty one,
            // and a defensive page here would print a sheet of running heads over nothing.
            while laidOut < total, pages.count < Self.pageLimit {
                let container = NSTextContainer(size: textSize)
                // The renderer's own indents are the document's; a container inset would add a second,
                // invisible one that only printing had.
                container.lineFragmentPadding = 0
                container.widthTracksTextView = false
                container.heightTracksTextView = false
                manager.addTextContainer(container)
                manager.ensureLayout(for: container)

                let glyphs = manager.glyphRange(for: container)
                // A container that accepted nothing cannot be filled by another of the same size — an
                // image or a table wider or taller than the page. Stopping is the only termination this
                // loop can honestly have; the content that did not fit is clipped rather than looping
                // forever inside a modal print panel.
                guard glyphs.length > 0 else { break }

                pages.append(Page(layoutManager: manager, containerIndex: containerIndex))
                containerIndex += 1
                laidOut = glyphs.location + glyphs.length
            }
        }
    }

    /// The two bands' heights, from the running-head font and whether either line has anything in it.
    private func measureRunningLines(options: PrintOptions) {
        let font = PrintTypography.runningHead(options)
        let line = font.ascender - font.descender + font.leading
        let gap = options.fontSize * 0.8

        headerHeight = PrintRunningHead.header(
            options: options,
            boardTitle: session.boardTitle,
            dateText: dateText
        ).isEmpty ? 0 : line + gap

        // Measured against a representative folio rather than the real one: page 1 of 1 and page 9 of 99
        // are the same height, and the count is not known until pagination has run — which is what this
        // measurement is an input to.
        footerHeight = PrintRunningHead.footer(
            options: options,
            pageText: PrintRunningHead.pageText(page: 1, of: 1)
        ).isEmpty ? 0 : line + gap
    }

    // MARK: - Drawing

    override func draw(_ dirtyRect: NSRect) {
        let options = session.options.normalized
        guard let index = pageIndex(in: dirtyRect), pages.indices.contains(index) else { return }

        let page = pages[index]
        let pageTop = CGFloat(index) * pageSize.height
        let container = page.layoutManager.textContainers[page.containerIndex]
        let glyphs = page.layoutManager.glyphRange(for: container)
        let origin = CGPoint(x: 0, y: pageTop + headerHeight)

        page.layoutManager.drawBackground(forGlyphRange: glyphs, at: origin)
        page.layoutManager.drawGlyphs(forGlyphRange: glyphs, at: origin)

        drawRunningLines(options: options, pageIndex: index, pageTop: pageTop)
    }

    /// Which page is being drawn.
    ///
    /// `NSPrintOperation.current?.currentPage` is the authority — it is exactly what the printing
    /// machinery is tracking — and the arithmetic is the fallback for the one case where there is no
    /// operation: a draw on screen, which only happens if someone ever puts this view in a window.
    private func pageIndex(in dirtyRect: NSRect) -> Int? {
        if let page = NSPrintOperation.current?.currentPage, page > 0 {
            return page - 1
        }
        guard pageSize.height > 0 else { return nil }
        return Int((dirtyRect.minY / pageSize.height).rounded(.down))
    }

    private func drawRunningLines(options: PrintOptions, pageIndex: Int, pageTop: CGFloat) {
        let font = PrintTypography.runningHead(options)
        let attributes: [NSAttributedString.Key: Any] = [
            .font: font,
            .foregroundColor: PrintTypography.secondaryInk
        ]

        if headerHeight > 0 {
            draw(
                PrintRunningHead.header(options: options, boardTitle: session.boardTitle, dateText: dateText),
                attributes: attributes,
                in: NSRect(x: 0, y: pageTop, width: pageSize.width, height: headerHeight)
            )
        }
        if footerHeight > 0 {
            let line = PrintRunningHead.footer(
                options: options,
                pageText: PrintRunningHead.pageText(page: pageIndex + 1, of: max(1, pages.count))
            )
            draw(
                line,
                attributes: attributes,
                in: NSRect(
                    x: 0,
                    y: pageTop + pageSize.height - footerHeight,
                    width: pageSize.width,
                    height: footerHeight
                )
            )
        }
    }

    /// One running line, its two ends at the two ends of the measure.
    ///
    /// Each end is drawn separately with its own alignment rather than joined by tabs: a tab stop would
    /// have to be recomputed per paper size, and a leading string long enough to reach the trailing one
    /// would push it off the page instead of truncating. Two rects cannot collide destructively — the
    /// worst case is two texts that meet in the middle, each truncated by its own rect.
    private func draw(_ line: PrintRunningHead.Line, attributes: [NSAttributedString.Key: Any], in rect: NSRect) {
        let inset = rect

        if !line.leading.isEmpty {
            var leading = attributes
            let style = NSMutableParagraphStyle()
            style.alignment = .left
            style.lineBreakMode = .byTruncatingTail
            leading[.paragraphStyle] = style
            NSAttributedString(string: line.leading, attributes: leading)
                .draw(with: CGRect(x: inset.minX, y: inset.minY, width: inset.width * 0.6, height: inset.height),
                      options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
        }
        if !line.trailing.isEmpty {
            var trailing = attributes
            let style = NSMutableParagraphStyle()
            style.alignment = .right
            style.lineBreakMode = .byTruncatingTail
            trailing[.paragraphStyle] = style
            NSAttributedString(string: line.trailing, attributes: trailing)
                .draw(with: CGRect(x: inset.minX + inset.width * 0.6, y: inset.minY, width: inset.width * 0.4, height: inset.height),
                      options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
        }
    }

    /// The print's date, formatted once — the running head's trailing end.
    private var dateText: String {
        printedAt.formatted(date: .abbreviated, time: .shortened)
    }
}
