import AppKit

/// **An SF Symbol turned into ink** — the one place a symbol name becomes an image a PDF context can
/// actually draw, and the answer to "SF Symbols render poorly in printed/PDF output".
///
/// ### What went wrong, and why it looked like a bug in the renderer
///
/// `NSImage(systemSymbolName:)` answers a **template** image (`isTemplate == true`) backed by a symbol
/// representation, and `withSymbolConfiguration` keeps the flag. A template image is not artwork: it is a
/// *shape to be tinted*, and the tinting is done by the AppKit machinery that draws it — a button cell, an
/// image view, a toolbar item. Hand one to an `NSTextAttachment` and let TextKit draw it straight into a
/// print/PDF context, where none of that machinery is present, and the tint is applied to the image's
/// whole box instead of through its coverage: **a solid dark rectangle where the glyph should be**. That is
/// exactly what the owner's 2026-08-08 report shows, and it reproduces in a bare
/// `NSView.dataWithPDF(inside:)` in three lines. It is not a `PrintDocumentRenderer` bug, not a
/// `PrintDocumentView` bug, and not a font bug: the image was never drawable in that context.
///
/// A second failure hides behind the first. A PDF context is a 1× device, so even a *non*-template symbol
/// image rasterizes at 72 ppi on its way into the page — `pdfimages -list` on such a document reports a
/// 13 × 12 pixel bitmap for an 11 pt icon. On screen at 100% that passes; on paper, or at any zoom, it is
/// the blur the card's first suspected failure mode describes.
///
/// ### The fix: resolve the symbol before it meets the page
///
/// Both failures are the same mistake — leaving work for a context that cannot do it — so both get the
/// same answer. The symbol is drawn **here**, into a bitmap this file owns, at a resolution paper can use,
/// with its colour already chosen; what reaches the attachment is ordinary, concrete, non-template artwork
/// that any context can put down unaltered.
///
/// - **Colour is baked, not deferred.** The configuration carries `paletteColors: [ink]`, so the symbol
///   renders monochrome in the line's own ink rather than in SF Symbols' automatic palette (which would
///   put a yellow star and a blue document on a black-and-white page). The ink is resolved against
///   `PrintTypography.paper` first: a dynamic `NSColor` resolves at *draw* time, and this drawing happens
///   long before the page's forced-light appearance is in effect.
/// - **Resolution is chosen, not inherited.** The bitmap is `paperScale` times the point box, which puts a
///   576 ppi image on the page — past any desktop printer's addressable resolution, and still clean at 8×
///   on screen. True vector would be better still and is not available: `NSSymbolImageRep` rasterizes into
///   whatever context draws it, the symbols are not reachable as font glyphs by name
///   (`CTFontGetGlyphWithName` answers 0 for every system face), and re-wrapping the image in a PDF
///   representation only embeds the same raster one level down. This was measured rather than assumed.
/// - **The baseline comes from the symbol.** See `Rendered.baselineOffset`.
///
/// ### Why this is cached when `ItemSymbol.exists` deliberately is not
///
/// `ItemSymbol` refuses a cache because its work is a lookup AppKit already caches. This work is a
/// rasterization — a real draw into a real bitmap — and a board print runs it once per card, hundreds of
/// times, for a handful of distinct icons, on every re-pagination the print panel asks for. Sharing one
/// `NSImage` between every card that chose the same icon also lets the PDF writer emit the artwork once
/// instead of once per card.
@MainActor
enum PrintSymbol {

    // MARK: - What a caller gets

    /// A symbol ready to be attached to a line of text.
    struct Rendered {

        /// Concrete, non-template artwork at print resolution, sized in points.
        let image: NSImage

        /// The attachment's `bounds.origin.y`: how far the image's box sits **below** the text baseline.
        ///
        /// Taken from the symbol's own `alignmentRect`, which is the metric Apple ships for exactly this
        /// question. Measured across sizes and symbols, the rect's height is the font's cap height and its
        /// origin is the symbol's own baseline within its box — `textformat` sits 1.0 pt up from the box's
        /// bottom edge at 11 pt, `lightbulb` 3.0 pt, `tag` 3.5 pt, and all three scale with the point size.
        /// Placing the box that far below the baseline therefore lands the *symbol's* baseline on the
        /// *text's*, which is the alignment the symbols were drawn for.
        ///
        /// The constant it replaces (`font.descender * 0.5`) knew nothing about the symbol, so every icon
        /// floated by a different amount — visible as an icon row that never quite sat on its line.
        let baselineOffset: CGFloat
    }

    // MARK: - Making one

    /// `name` at `pointSize`, inked in `ink` — or `nil` when this system cannot draw that symbol.
    ///
    /// `nil` rather than a placeholder is deliberate and is `ItemSymbol`'s promise kept on paper: a name
    /// the running OS does not have is a line that prints its labels and no icon, never a box.
    static func rendered(_ name: String, pointSize: CGFloat, ink: NSColor) -> Rendered? {
        let key = Key(name: name, pointSize: pointSize, ink: ink)
        if let cached = cache[key] { return cached }

        guard let source = configured(name, pointSize: pointSize, ink: resolved(ink)) else { return nil }
        guard let image = rasterized(source) else { return nil }

        let rendered = Rendered(image: image, baselineOffset: -source.alignmentRect.origin.y)
        // A wholesale clear rather than an eviction policy: the map is keyed by the icons a document
        // actually uses, so it is a handful of entries in every real print, and a cap that is only ever
        // reached by a pathological board is better spent forgetting everything than ranking it.
        if cache.count >= cacheLimit { cache.removeAll(keepingCapacity: true) }
        cache[key] = rendered
        return rendered
    }

    /// The symbol at the right size and in the right colour, still at 1× and still an `NSSymbolImageRep`.
    private static func configured(_ name: String, pointSize: CGFloat, ink: NSColor) -> NSImage? {
        let configuration = NSImage.SymbolConfiguration(pointSize: max(1, pointSize), weight: .regular)
            .applying(NSImage.SymbolConfiguration(paletteColors: [ink]))
        return NSImage(systemSymbolName: name, accessibilityDescription: nil)?
            .withSymbolConfiguration(configuration)
    }

    /// `source` drawn into a bitmap of `paperScale` times its point box.
    ///
    /// The draw happens under the paper appearance for the same reason the ink is resolved under it: a
    /// symbol's own rendering reads the drawing appearance, and a document printed from a dark-mode app
    /// must not carry dark-mode artwork onto white paper.
    private static func rasterized(_ source: NSImage) -> NSImage? {
        let box = source.size
        guard box.width > 0, box.height > 0 else { return nil }

        // A ceiling on the pixel grid, so a hand-edited profile with an enormous body size cannot ask for
        // a bitmap measured in tens of megabytes.
        let scale = min(paperScale, maximumPixels / max(box.width, box.height))
        guard let rep = NSBitmapImageRep(
            bitmapDataPlanes: nil,
            pixelsWide: Int((box.width * scale).rounded(.up)),
            pixelsHigh: Int((box.height * scale).rounded(.up)),
            bitsPerSample: 8,
            samplesPerPixel: 4,
            hasAlpha: true,
            isPlanar: false,
            colorSpaceName: .deviceRGB,
            bytesPerRow: 0,
            bitsPerPixel: 0
        ) else { return nil }
        // The rep's *point* size is the symbol's, so the extra pixels read as resolution rather than as a
        // bigger picture — which is the whole trick.
        rep.size = box

        NSGraphicsContext.saveGraphicsState()
        NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
        NSGraphicsContext.current?.imageInterpolation = .high
        PrintTypography.paper.performAsCurrentDrawingAppearance {
            source.draw(in: CGRect(origin: .zero, size: box), from: .zero, operation: .sourceOver, fraction: 1)
        }
        NSGraphicsContext.restoreGraphicsState()

        let image = NSImage(size: box)
        image.addRepresentation(rep)
        // **The flag that started all this**, turned off explicitly rather than left to the new image's
        // default: what this answers is artwork, and a future reader should see it said so.
        image.isTemplate = false
        return image
    }

    /// A dynamic colour pinned to the value paper needs, since the bitmap is drawn now and shown later.
    private static func resolved(_ ink: NSColor) -> NSColor {
        var answer = ink
        PrintTypography.paper.performAsCurrentDrawingAppearance {
            answer = ink.usingColorSpace(.sRGB) ?? ink
        }
        return answer
    }

    // MARK: - Constants

    /// 8 × 72 ppi = 576 ppi on the page. See the type's note.
    private static let paperScale: CGFloat = 8

    /// The largest edge, in pixels, any one symbol's bitmap may have.
    private static let maximumPixels: CGFloat = 2048

    private static let cacheLimit = 128

    // MARK: - The cache

    private struct Key: Hashable {
        let name: String
        let pointSize: CGFloat
        /// The colour by description rather than by identity, so two equal `NSColor`s are one key.
        let ink: String

        init(name: String, pointSize: CGFloat, ink: NSColor) {
            self.name = name
            self.pointSize = pointSize
            self.ink = "\(ink)"
        }
    }

    private static var cache: [Key: Rendered] = [:]
}
