import AppKit
import Foundation
import Quartz
import UniformTypeIdentifiers

/// **The preview's whole appearance** — a `BoardOutline` turned into one self-contained HTML
/// document plus the images it references.
///
/// ### Why the styling values are resolved here and not in the walk
///
/// `BoardOutline` carries `background`, `icon` and `iconColor` **as written** — a palette name, a
/// hex, an SF Symbol name — exactly as `BoardModel` does, because "an unrecognized value renders as
/// the default and the bytes stay as written" is a *rendering* rule and this file is the renderer
/// (Palette.swift ▸ "Lenient, never an error"; ItemSymbol.swift ▸ the same for symbols). Resolution
/// goes through `Palette` itself rather than through a second copy of the twelve-and-twelve table,
/// so a board tinted `smokey-ocean` is the same colour in the preview as it is in the app.
///
/// ### Why the board tint is a wash and not a band
///
/// A board's colour is chosen to sit *behind a whole window* of app chrome, where the app picks
/// readable ink for it against the window backdrop (`ContrastMath` ▸ `BoardTextInk`). A preview has
/// no such machinery and should not grow one for a header strip, so the tint is applied at low alpha
/// under the title and at full strength only as a lane's edge accent — narrow marks that carry no
/// text. Contrast is then never in question, in either appearance, for any of the 16.7 million
/// values a hand-written hex can be.
enum BoardPreviewPage {

    /// The size Quick Look draws its loading state at, and its opening guess at the panel. Only a
    /// hint: HTML has no intrinsic size, so the panel is the user's to resize and the page reflows.
    static let contentSizeHint = CGSize(width: 820, height: 560)

    /// The board's default symbol when it carries no `icon` — `ItemSymbol.board`'s value
    /// (03-board-ui.md § Styling ▸ Capabilities: "`icon`: SF Symbol per item with per-level
    /// defaults").
    ///
    /// Restated rather than shared: `ItemSymbol` reaches `StyleLevel` for its per-level lookup, which
    /// reaches the live store, which has no business inside a preview extension. One string is the
    /// cheaper coupling, and it is the board's default — the only one of the three this file can ever
    /// need.
    private static let defaultSymbol = "rectangle.split.3x1"

    /// The mark's two bakings. A PNG has one colour and a preview has two appearances, so the page
    /// carries both and lets CSS choose — see `attachments(for:)`.
    private static let lightMarkID = "board-mark-light"
    private static let darkMarkID = "board-mark-dark"

    // MARK: - The page

    static func html(for outline: BoardOutline) -> Data {
        var out = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
        out += "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
        out += "<title>\(escape(outline.title))</title>\n"
        out += "<style>\n\(stylesheet(for: outline))\n</style>\n</head>\n<body>\n"
        out += "<main>\n"
        out += header(for: outline)
        out += lanes(of: outline)
        out += "</main>\n</body>\n</html>\n"
        return Data(out.utf8)
    }

    private static func header(for outline: BoardOutline) -> String {
        var out = "<header class=\"board\">\n"
        out += "<img class=\"mark light-only\" src=\"cid:\(lightMarkID)\" alt=\"\">"
        out += "<img class=\"mark dark-only\" src=\"cid:\(darkMarkID)\" alt=\"\">\n"
        out += "<div class=\"board-text\">\n"
        out += "<h1>\(escape(outline.title))</h1>\n"
        out += "<p class=\"meta\">\(escape(laneCountPhrase(for: outline)))</p>\n"
        out += "</div>\n</header>\n"
        return out
    }

    /// "3 lanes", "1 lane", "No lanes yet" — and "100+ lanes" for the board whose count the walk
    /// stopped taking (`BoardOutline.laneCountIsCapped`), because a number that is not the number is
    /// worse than an honest floor.
    private static func laneCountPhrase(for outline: BoardOutline) -> String {
        guard outline.laneCount > 0 else { return "No lanes yet" }
        let count = outline.laneCountIsCapped ? "\(outline.laneCount)+" : "\(outline.laneCount)"
        return "\(count) \(outline.laneCount == 1 && !outline.laneCountIsCapped ? "lane" : "lanes")"
    }

    private static func lanes(of outline: BoardOutline) -> String {
        // A board with nothing in it, and a folder that is not a board at all, reach here the same
        // way and say so once — the header's "No lanes yet" is the count, this is the invitation.
        guard outline.laneCount > 0 else {
            return "<p class=\"empty\">This board has no lanes yet.</p>\n"
        }

        var out = "<div class=\"lanes\">\n"
        for lane in outline.lanes {
            let accent = cssColor(lane.background).map { " style=\"--accent: \($0)\"" } ?? ""
            out += "<section class=\"lane\"\(accent)>\n"
            out += "<h2><span class=\"lane-title\">\(escape(lane.title ?? "Untitled"))</span>"
            out += "<span class=\"count\">\(escape(cardCountPhrase(for: lane)))</span></h2>\n"
            if lane.cardTitles.isEmpty {
                out += "<p class=\"empty-lane\">Empty</p>\n"
            } else {
                out += "<ul>\n"
                for title in lane.cardTitles {
                    out += "<li>\(escape(title ?? "Untitled"))</li>\n"
                }
                out += "</ul>\n"
            }
            if lane.hiddenCardCount > 0 {
                out += "<p class=\"more\">+\(lane.hiddenCardCount) more</p>\n"
            }
            out += "</section>\n"
        }
        out += "</div>\n"

        if outline.hiddenLaneCount > 0 {
            let suffix = outline.laneCountIsCapped ? "+" : ""
            out += "<p class=\"more-lanes\">+\(outline.hiddenLaneCount)\(suffix) more lanes</p>\n"
        }
        return out
    }

    /// A lane's badge: its exact card count, or the floor the count stopped at
    /// (`LaneOutline.cardCountIsCapped`).
    private static func cardCountPhrase(for lane: LaneOutline) -> String {
        lane.cardCountIsCapped ? "\(lane.cardCount)+" : "\(lane.cardCount)"
    }

    // MARK: - Style

    private static func stylesheet(for outline: BoardOutline) -> String {
        let wash = cssColor(outline.background, alpha: 0.16) ?? "transparent"
        let rule = cssColor(outline.background, alpha: 0.55) ?? "var(--hairline)"
        return """
        :root {
          --ink: #1d1d1f;
          --ink-quiet: #6e6e73;
          --page: #ffffff;
          --plate: #f5f5f7;
          --hairline: rgba(0, 0, 0, 0.12);
          --wash: \(wash);
          --rule: \(rule);
          --accent: var(--hairline);
        }
        @media (prefers-color-scheme: dark) {
          :root {
            --ink: #f5f5f7;
            --ink-quiet: #98989d;
            --page: #1e1e1e;
            --plate: #2a2a2c;
            --hairline: rgba(255, 255, 255, 0.16);
          }
        }
        * { box-sizing: border-box; }
        body {
          margin: 0;
          background: var(--page);
          color: var(--ink);
          font: 13px/1.45 -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
          -webkit-font-smoothing: antialiased;
        }
        main { padding: 20px 22px 26px; }
        header.board {
          display: flex;
          align-items: center;
          gap: 12px;
          padding: 12px 14px;
          border-radius: 10px;
          background: var(--wash);
          border-bottom: 2px solid var(--rule);
          /* The wash and the rule are both the board's own colour, so a board tinted the same shade
             as the page it is previewed on paints nothing at all — a `#1E1E1E` board in the dark
             appearance. This hairline is what keeps the block a block regardless: the tint is the
             board's, the edge is the page's. */
          box-shadow: inset 0 0 0 1px var(--hairline);
        }
        .mark { width: 34px; height: 34px; flex: none; object-fit: contain; }
        .light-only { display: block; }
        .dark-only { display: none; }
        @media (prefers-color-scheme: dark) {
          .light-only { display: none; }
          .dark-only { display: block; }
        }
        .board-text { min-width: 0; }
        h1 {
          margin: 0;
          font-size: 20px;
          font-weight: 600;
          letter-spacing: -0.01em;
          overflow-wrap: anywhere;
        }
        .meta { margin: 2px 0 0; color: var(--ink-quiet); font-size: 12px; }
        .lanes {
          display: flex;
          flex-wrap: wrap;
          gap: 12px;
          margin-top: 18px;
          align-items: flex-start;
        }
        .lane {
          flex: 1 1 190px;
          min-width: 170px;
          max-width: 300px;
          padding: 10px 12px 10px 13px;
          border-radius: 8px;
          background: var(--plate);
          border-left: 3px solid var(--accent);
        }
        .lane h2 {
          display: flex;
          align-items: baseline;
          justify-content: space-between;
          gap: 8px;
          margin: 0 0 8px;
          font-size: 13px;
          font-weight: 600;
        }
        .lane-title { overflow-wrap: anywhere; }
        .count {
          flex: none;
          color: var(--ink-quiet);
          font-variant-numeric: tabular-nums;
          font-weight: 400;
        }
        .lane ul { margin: 0; padding: 0; list-style: none; }
        .lane li {
          padding: 4px 0;
          border-top: 1px solid var(--hairline);
          overflow-wrap: anywhere;
        }
        .lane li:first-child { border-top: none; padding-top: 0; }
        .more, .empty-lane { margin: 6px 0 0; color: var(--ink-quiet); font-size: 12px; }
        .more-lanes, .empty { margin: 14px 0 0; color: var(--ink-quiet); font-size: 12px; }
        """
    }

    // MARK: - Colour

    /// A stored styling value as a CSS colour — `Palette`'s resolution (a palette name or a
    /// `#RRGGBB[AA]` hex), re-emitted as `rgba()` so the value's own alpha and the caller's can be
    /// combined. `nil` for every shape `Palette` declines to read, which every call site treats as
    /// "there is no colour" rather than as an error.
    private static func cssColor(_ value: String?, alpha: Double = 1) -> String? {
        guard let value,
              let color = Palette.nsColor(for: value)?.usingColorSpace(.sRGB)
        else { return nil }
        let channel = { (component: CGFloat) in Int((component * 255).rounded()) }
        let combined = Double(color.alphaComponent) * alpha
        return "rgba(\(channel(color.redComponent)), \(channel(color.greenComponent)), "
            + "\(channel(color.blueComponent)), \(String(format: "%.3f", combined)))"
    }

    // MARK: - The mark

    /// The board's symbol, baked twice: once in the light appearance's ink and once in the dark
    /// one's, so a page rendered in either reads correctly. A board that names its own `iconColor`
    /// gets that colour in both — a chosen tint is not an appearance-dependent value.
    ///
    /// Both entries are always present, even when the symbol will not resolve: an attachment
    /// dictionary missing a `cid:` the page references is a broken-image glyph, and this way a bad
    /// `icon:` degrades to the *default* symbol exactly as it does in the app.
    static func attachments(for outline: BoardOutline) -> [String: QLPreviewReplyAttachment] {
        let symbol = outline.icon.flatMap(resolvedSymbol) ?? defaultSymbol
        let chosen = outline.iconColor.flatMap(Palette.nsColor(for:))
        var attachments: [String: QLPreviewReplyAttachment] = [:]
        let inks: [(id: String, fallback: NSColor)] = [
            (lightMarkID, NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1)),
            (darkMarkID, NSColor(srgbRed: 0.96, green: 0.96, blue: 0.97, alpha: 1)),
        ]
        for ink in inks {
            guard let png = markPNG(symbol: symbol, tint: chosen ?? ink.fallback) else { continue }
            attachments[ink.id] = QLPreviewReplyAttachment(data: png, contentType: .png)
        }
        return attachments
    }

    /// `name` if the running system can draw it as an SF Symbol — `ItemSymbol.exists(_:)`'s rule and
    /// its reasoning ("`NSImage(systemSymbolName:)` is the only honest test").
    private static func resolvedSymbol(_ name: String) -> String? {
        guard !name.isEmpty, NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
        else { return nil }
        return name
    }

    /// One symbol as tinted PNG bytes.
    ///
    /// Drawn at twice the size the page displays it at (34 CSS pixels), which is the whole reason for
    /// the point size below: a PNG has no vector fallback, and a Retina panel would show a 34-pixel
    /// image soft. `nil` for a symbol the system declines to draw or an image that will not encode —
    /// the page then simply shows no mark, which is `Palette`'s "there is no colour, so show none"
    /// one medium over.
    private static func markPNG(symbol: String, tint: NSColor) -> Data? {
        let configuration = NSImage.SymbolConfiguration(pointSize: 68, weight: .regular)
        guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
            .withSymbolConfiguration(configuration)
        else { return nil }

        // `sourceAtop` over the drawn glyph rather than a palette configuration: it flattens a
        // multicolour symbol to the one tint, which is what the app's own `foregroundStyle` does to
        // it anyway, and it cannot come out black the way a template image can.
        let tinted = NSImage(size: image.size, flipped: false) { rect in
            image.draw(in: rect)
            tint.setFill()
            rect.fill(using: .sourceAtop)
            return true
        }

        guard let tiff = tinted.tiffRepresentation,
              let bitmap = NSBitmapImageRep(data: tiff)
        else { return nil }
        return bitmap.representation(using: .png, properties: [:])
    }

    // MARK: - Escaping

    /// Every character that could end an attribute, open a tag or start an entity — the page is
    /// assembled from board text that is entirely the user's, so nothing reaches the document without
    /// passing through here.
    private static func escape(_ text: String) -> String {
        var escaped = ""
        escaped.reserveCapacity(text.count)
        for character in text {
            switch character {
            case "&": escaped += "&amp;"
            case "<": escaped += "&lt;"
            case ">": escaped += "&gt;"
            case "\"": escaped += "&quot;"
            case "'": escaped += "&#39;"
            default: escaped.append(character)
            }
        }
        return escaped
    }
}
