// SPDX-FileCopyrightText: 2026 Brent Daignault
// SPDX-License-Identifier: GPL-3.0-or-later

import CoreGraphics
import CoreText
import ImageIO
import Foundation

/// All drawing happens in pure logical-page-local coordinates — (0,0) at the
/// bottom-left of whatever single logical page is currently open.  Neither
/// the booklet/portrait distinction nor any physical-sheet offset is visible
/// at this layer; that is handled entirely by the two renderers.
enum PDFDrawing {

    // MARK: - Fonts (created fresh at the exact size needed; CoreText caches
    // internally, so this carries no real cost for a document generated once)

    private enum Fonts {
        static func regular(_ size: CGFloat) -> CTFont {
            CTFontCreateWithName("Helvetica" as CFString, size, nil)
        }
        static func bold(_ size: CGFloat) -> CTFont {
            CTFontCreateWithName("Helvetica-Bold" as CFString, size, nil)
        }
    }

    // MARK: - Primitives

    static func fillRect(_ ctx: CGContext, _ rect: CGRect, color: CGColor) {
        ctx.setFillColor(color)
        ctx.fill(rect)
    }

    static func strokeRect(_ ctx: CGContext, _ rect: CGRect, color: CGColor, lineWidth: CGFloat) {
        ctx.setStrokeColor(color)
        ctx.setLineWidth(lineWidth)
        ctx.stroke(rect)
    }

    static func hLine(_ ctx: CGContext, x1: CGFloat, y: CGFloat, x2: CGFloat,
                      color: CGColor, lineWidth: CGFloat) {
        ctx.setStrokeColor(color)
        ctx.setLineWidth(lineWidth)
        ctx.move(to: CGPoint(x: x1, y: y))
        ctx.addLine(to: CGPoint(x: x2, y: y))
        ctx.strokePath()
    }

    /// Left-aligned text; (x, y) is the text baseline origin.
    static func string(_ ctx: CGContext, _ text: String, bold: Bool, size: CGFloat,
                       x: CGFloat, y: CGFloat, color: CGColor) {
        guard !text.isEmpty else { return }
        let font = bold ? Fonts.bold(size) : Fonts.regular(size)
        let attrs: [CFString: Any] = [
            kCTFontAttributeName: font,
            kCTForegroundColorAttributeName: color
        ]
        let attrString = CFAttributedStringCreate(nil, text as CFString, attrs as CFDictionary)
        let line = CTLineCreateWithAttributedString(attrString!)
        ctx.textPosition = CGPoint(x: x, y: y)
        CTLineDraw(line, ctx)
    }

    /// Measures the rendered width of `text` at the given size/weight.
    /// Shared by `stringCentred` (for centring) and `wrapLines` (for
    /// deciding where to break a line that's too wide for the page).
    static func measureWidth(_ text: String, bold: Bool, size: CGFloat) -> CGFloat {
        guard !text.isEmpty else { return 0 }
        let font = bold ? Fonts.bold(size) : Fonts.regular(size)
        let attrs: [CFString: Any] = [kCTFontAttributeName: font]
        let attrString = CFAttributedStringCreate(nil, text as CFString, attrs as CFDictionary)
        let line = CTLineCreateWithAttributedString(attrString!)
        // CTLineGetTypographicBounds returns a true C `double`, not CGFloat.
        return CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
    }

    /// Greedy word-wrap: breaks `text` into as many lines as needed so that
    /// none exceeds `maxWidth` at the given size/weight, breaking only at
    /// spaces.  A single word wider than `maxWidth` is kept whole on its own
    /// line rather than split mid-word.
    static func wrapLine(_ text: String, bold: Bool, size: CGFloat, maxWidth: CGFloat) -> [String] {
        let words = text.split(separator: " ")
        guard !words.isEmpty else { return [] }

        var lines: [String] = []
        var current = ""

        for word in words {
            let candidate = current.isEmpty ? String(word) : "\(current) \(word)"
            if current.isEmpty || measureWidth(candidate, bold: bold, size: size) <= maxWidth {
                current = candidate
            } else {
                lines.append(current)
                current = String(word)
            }
        }
        if !current.isEmpty { lines.append(current) }
        return lines
    }

    /// Wraps every line in `lines`, in order, flattening the result.
    static func wrapLines(_ lines: [String], bold: Bool, size: CGFloat, maxWidth: CGFloat) -> [String] {
        lines.flatMap { wrapLine($0, bold: bold, size: size, maxWidth: maxWidth) }
    }

    /// Text horizontally centred on `cx`; `y` is the text baseline.
    static func stringCentred(_ ctx: CGContext, _ text: String, bold: Bool, size: CGFloat,
                              cx: CGFloat, y: CGFloat, color: CGColor) {
        guard !text.isEmpty else { return }
        let font = bold ? Fonts.bold(size) : Fonts.regular(size)
        let attrs: [CFString: Any] = [
            kCTFontAttributeName: font,
            kCTForegroundColorAttributeName: color
        ]
        let attrString = CFAttributedStringCreate(nil, text as CFString, attrs as CFDictionary)
        let line = CTLineCreateWithAttributedString(attrString!)
        let width = measureWidth(text, bold: bold, size: size)
        ctx.textPosition = CGPoint(x: cx - width / 2.0, y: y)
        CTLineDraw(line, ctx)
    }

    // MARK: - Section header

    /// Draws a navy section title and rule.  Returns the y position just
    /// below the rule, ready for the first label.
    @discardableResult
    static func sectionHeader(_ ctx: CGContext, pageW: CGFloat, mx: CGFloat,
                              y: CGFloat, title: String) -> CGFloat {
        let navy = CGColor(srgbRed: 0.10, green: 0.23, blue: 0.36, alpha: 1.0)
        string(ctx, title, bold: true, size: 13.0, x: mx, y: y, color: navy)
        let ruleY = y - Metrics.padBS
        hLine(ctx, x1: mx, y: ruleY, x2: pageW - mx, color: navy, lineWidth: 1.0)
        return ruleY - Metrics.padAL
    }

    // MARK: - Member label

    /// One member's label: a light-grey box with 6 lines of data.
    /// `colW` is passed explicitly so booklet and portrait use different widths.
    static func label(_ ctx: CGContext, colX: CGFloat, colW: CGFloat, y: CGFloat, member: Member) {
        let grey = CGColor(srgbRed: 0.83, green: 0.83, blue: 0.83, alpha: 1.0)
        let black = CGColor(srgbRed: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)

        let box = CGRect(x: colX, y: y - Metrics.labelH, width: colW, height: Metrics.labelH)
        strokeRect(ctx, box, color: grey, lineWidth: 0.6)

        var ty = y - Metrics.padT - Metrics.nsz

        let name = "\(member.first) \(member.last)"
        string(ctx, name, bold: true, size: Metrics.nsz, x: colX + 4.0, y: ty, color: black)
        ty -= Metrics.lead

        if !member.address.isEmpty {
            string(ctx, member.address, bold: false, size: Metrics.fsz,
                  x: colX + 4.0, y: ty, color: black)
        }
        ty -= Metrics.lead

        if !member.city.isEmpty {
            let cityLine = member.pcode.isEmpty
                ? member.city
                : "\(member.city)  \(member.pcode)"
            string(ctx, cityLine, bold: false, size: Metrics.fsz,
                  x: colX + 4.0, y: ty, color: black)
        }
        ty -= Metrics.lead

        let phoneLine: String
        if !member.ph1.isEmpty, !member.ph2.isEmpty {
            phoneLine = "\(member.ph1)    \(member.ph2)"
        } else if !member.ph1.isEmpty {
            phoneLine = member.ph1
        } else if !member.ph2.isEmpty {
            phoneLine = member.ph2
        } else {
            phoneLine = ""
        }
        if !phoneLine.isEmpty {
            string(ctx, phoneLine, bold: false, size: Metrics.fsz,
                  x: colX + 4.0, y: ty, color: black)
        }
        ty -= Metrics.lead

        if !member.email1.isEmpty {
            string(ctx, member.email1, bold: false, size: Metrics.fsz,
                  x: colX + 4.0, y: ty, color: black)
        }
        ty -= Metrics.lead

        if !member.email2.isEmpty {
            string(ctx, member.email2, bold: false, size: Metrics.fsz,
                  x: colX + 4.0, y: ty, color: black)
        }
    }

    // MARK: - Image loading (any ImageIO-supported format)

    /// Loads an image using ImageIO.  Format is detected from file content,
    /// not the file extension — PNG, JPEG, TIFF, GIF, BMP, HEIC, and anything
    /// else the system's image codecs understand all work identically.
    static func loadImage(at path: String) -> CGImage? {
        guard FileManager.default.fileExists(atPath: path) else { return nil }
        let url = URL(fileURLWithPath: path)
        guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
        return CGImageSourceCreateImageAtIndex(source, 0, nil)
    }

    /// Draws an image scaled to fit within (boxW × boxH), aspect-ratio
    /// preserved, centred horizontally, pinned to the top of the box.
    static func drawImageFitted(_ ctx: CGContext, image: CGImage,
                                originX: CGFloat, topY: CGFloat,
                                boxW: CGFloat, boxH: CGFloat) {
        let imgW = CGFloat(image.width)
        let imgH = CGFloat(image.height)
        let scale = (imgW / imgH < boxW / boxH) ? (boxH / imgH) : (boxW / imgW)
        let drawW = imgW * scale
        let drawH = imgH * scale
        let drawX = originX + (boxW - drawW) / 2.0
        let drawY = topY - drawH
        ctx.draw(image, in: CGRect(x: drawX, y: drawY, width: drawW, height: drawH))
    }

    // MARK: - Full-page image (Committee / Schedule)

    static func imagePage(_ ctx: CGContext, path: String, placeholder: String,
                          pageW: CGFloat, pageH: CGFloat, lmargin: CGFloat, tmargin: CGFloat) {
        let boxW = pageW - lmargin * 2.0
        let boxH = pageH - tmargin * 2.0

        if let image = loadImage(at: path) {
            drawImageFitted(ctx, image: image, originX: lmargin, topY: pageH - tmargin,
                            boxW: boxW, boxH: boxH)
        } else {
            let grey = CGColor(srgbRed: 0.83, green: 0.83, blue: 0.83, alpha: 1.0)
            let navy = CGColor(srgbRed: 0.10, green: 0.23, blue: 0.36, alpha: 1.0)
            strokeRect(ctx, CGRect(x: lmargin, y: tmargin, width: boxW, height: boxH),
                      color: grey, lineWidth: 1.0)
            stringCentred(ctx, placeholder, bold: false, size: 9.0,
                         cx: pageW / 2.0, y: pageH * 0.50, color: navy)
        }
    }

    // MARK: - Cover page
    //
    // Top half: cover image, aspect-ratio preserved, pinned to the top.
    // Lower half: white background; title block and subtitle block centred
    // as a unit, both horizontally and vertically, within the lower half.

    static func coverPage(_ ctx: CGContext, pageW: CGFloat, pageH: CGFloat,
                          mx: CGFloat, my: CGFloat, cfg: Config) {
        let boxW = pageW - mx * 2.0
        let boxH = pageH / 2.0 - my

        if let image = loadImage(at: cfg.coverImage) {
            drawImageFitted(ctx, image: image, originX: mx, topY: pageH - my,
                            boxW: boxW, boxH: boxH)
        } else {
            let placeholderBG = CGColor(srgbRed: 0.80, green: 0.86, blue: 0.93, alpha: 1.0)
            fillRect(ctx, CGRect(x: 0, y: pageH / 2.0, width: pageW, height: pageH / 2.0),
                    color: placeholderBG)
            let navy = CGColor(srgbRed: 0.10, green: 0.23, blue: 0.36, alpha: 1.0)
            stringCentred(ctx, "[cover image not found]", bold: false, size: 9.0,
                         cx: pageW / 2.0, y: pageH * 0.75, color: navy)
        }

        // Lower half: white background
        fillRect(ctx, CGRect(x: 0, y: 0, width: pageW, height: pageH / 2.0),
                color: CGColor(srgbRed: 1.0, green: 1.0, blue: 1.0, alpha: 1.0))

        // Wrap any line too wide for the page before doing anything else —
        // the block-height calculation below must reflect the lines that
        // will actually be drawn, not the raw lines from roster.sdd.
        let tsz = cfg.titleFontSize
        let ssz = cfg.subtitleFontSize
        let textMaxWidth = pageW - mx * 2.0
        let titleLines = wrapLines(cfg.titleLines, bold: true, size: tsz, maxWidth: textMaxWidth)
        let subtitleLines = wrapLines(cfg.subtitleLines, bold: false, size: ssz, maxWidth: textMaxWidth)

        // Vertically centre the title+subtitle block within the lower half.
        //
        // NOTE: the C version this was ported from computed the block height
        // assuming LEAD-spaced lines (titleSpan = tsz + (count-1)*LEAD) but
        // the actual drawing loop stepped every line by the full font size
        // (ty -= tsz). Those two didn't agree, which would have pushed the
        // visual centre away from the calculated one. This version makes the
        // span calculation match the actual per-line step (tsz / ssz), so
        // the centring is accurate for any title/subtitle length.
        let titleSpan = tsz * CGFloat(titleLines.count)
        let subtitleSpan = ssz * CGFloat(subtitleLines.count)
        let sectionGap: CGFloat = (!titleLines.isEmpty && !subtitleLines.isEmpty)
            ? Metrics.lead * 1.5 : 0.0
        let blockH = titleSpan + sectionGap + subtitleSpan

        let cx = pageW / 2.0
        var ty = pageH / 4.0 + blockH / 2.0   // baseline of the first title line

        for line in titleLines {
            stringCentred(ctx, line, bold: true, size: tsz, cx: cx, y: ty,
                         color: cfg.titleColor.cgColor)
            ty -= tsz
        }

        ty -= sectionGap
        for line in subtitleLines {
            stringCentred(ctx, line, bold: false, size: ssz, cx: cx, y: ty,
                         color: cfg.subtitleColor.cgColor)
            ty -= ssz
        }
    }
}
