#!/usr/bin/env python3
"""Render the checked hardware illustrations from the archived source PDFs.

Run from any directory with Python, PyMuPDF and Pillow installed:
    python scripts/render_hardware_assets.py

PDF page numbers in the manifest are one-based. Crop rectangles are in the
unrotated PDF page's point coordinates, with the origin at the top left.
The PDFs are read only. No drawing is redrawn, mirrored or recolored.
"""

from __future__ import annotations

import argparse
import hashlib
import json
from pathlib import Path

import fitz
from PIL import Image


ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = ROOT / "hardware"
DEFAULT_OUTPUT = ROOT / "docs" / "assets" / "hardware"

# Bounds were checked against the full HallPos schematic. Each region includes
# complete symbols, local wires, power/ground ends, and the connecting net names.
SCHEMATIC_REGIONS = [
    ("linear-power.png", [45, 265, 402, 440], "U5 input, U7 regulator, C7/C8/C9, D1 and NC1"),
    ("linear-hall.png", [354, 48, 558, 216], "U1/U2/U4 Hall sensors, C3/C4/C5, ADC1/ADC2/ADC3"),
    ("linear-mcu.png", [45, 15, 314, 260], "U3 MCU, C1/C2, BOOT0/NRST, all used net labels"),
    ("linear-rs485.png", [454, 249, 638, 392], "U6 transceiver, R4/R5, C6, TP2/TP3/TP4, all net labels"),
    ("linear-swd.png", [675, 270, 828, 386], "CN1 SWD connector including power and grounded fixing pads"),
    ("linear-bus-connector.png", [45, 265, 205, 440], "U5 power/RS485 connector, NC1 termination and D1 input protection"),
    ("linear-led.png", [636, 63, 716, 184], "LED1/R1, VCC_5V and LED_STA"),
]


def graphic_bounds(page: fitz.Page) -> fitz.Rect:
    """Find the board drawing, excluding the almost-white PDF export frame.

    Inspect an 8x RGB raster; ink below luminance 220 forms the board bounds.
    Keep two PDF points of padding so the outer board stroke is not cropped.
    """
    probe_scale = 8.0
    pix = page.get_pixmap(matrix=fitz.Matrix(probe_scale, probe_scale), alpha=False)
    image = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
    mask = image.convert("L").point(lambda value: 255 if value < 220 else 0)
    bbox = mask.getbbox()
    if bbox is None:
        raise ValueError("No board drawing found")
    x0, y0, x1, y1 = bbox
    return fitz.Rect(
        x0 / probe_scale - 2, y0 / probe_scale - 2,
        x1 / probe_scale + 2, y1 / probe_scale + 2,
    ) & page.rect


def render(source: Path, output: Path) -> dict:
    output.mkdir(parents=True, exist_ok=True)
    documents: dict[str, fitz.Document] = {}
    entries: list[dict] = []

    def export(filename: str, pdf: str, page_number: int, *, crop=None,
               width: int = 1600, kind: str, description: str,
               automatic_crop: bool = False) -> None:
        if pdf not in documents:
            documents[pdf] = fitz.open(source / ("schematics" if pdf.startswith("SCH_") else "pcb") / pdf)
        page = documents[pdf][page_number - 1]
        bounds = graphic_bounds(page) if automatic_crop else fitz.Rect(crop or page.rect)
        if not page.rect.contains(bounds):
            raise ValueError(f"Crop outside {pdf} page {page_number}: {bounds}")
        scale = width / bounds.width
        pixmap = page.get_pixmap(matrix=fitz.Matrix(scale, scale), clip=bounds, alpha=False)
        target = output / filename
        pixmap.save(target)
        entries.append({
            "file": filename,
            "kind": kind,
            "description": description,
            "source_pdf": "hardware/" + ("schematics/" if pdf.startswith("SCH_") else "pcb/") + pdf,
            "source_sha256": hashlib.sha256((source / ("schematics" if pdf.startswith("SCH_") else "pcb") / pdf).read_bytes()).hexdigest(),
            "page_number": page_number,
            "page_rect_points": [round(n, 4) for n in page.rect],
            "crop_rect_points": [round(n, 4) for n in bounds],
            "crop_method": "8x raster luminance < 220, enclosing bounds + 2 pt padding"
                           if automatic_crop else "reviewed fixed region" if crop else "full page",
            "render_scale": round(scale, 8),
            "pixel_size": [pixmap.width, pixmap.height],
            "orientation": "as exported in source PDF; no mirror or rotation",
        })

    for prefix, pdf in [("linear", "PCB_HallPos.pdf"), ("encoder", "PCB_Encoder.pdf")]:
        export(f"{prefix}-pcb-top.png", pdf, 4, kind="pcb-component-side",
               description="Component-side composite: pads, traces, copper and component outlines; PDF page 4",
               automatic_crop=True)
        export(f"{prefix}-pcb-bottom.png", pdf, 5, kind="pcb-reverse-side",
               description="Reverse-side composite: traces, copper, vias and original markings; PDF page 5",
               automatic_crop=True)
        # Page 1 remains a supplementary locator, never the main PCB illustration.
        export(f"{prefix}-placement.png", pdf, 1, kind="reference-designator-locator",
               description="Supplementary component locator from PDF page 1; not a copper-layer drawing",
               automatic_crop=True, width=1300)

    for prefix, pdf in [("linear", "SCH_HallPos.pdf"), ("encoder", "SCH_Encoder.pdf")]:
        export(f"{prefix}-schematic.png", pdf, 1, width=2400,
               kind="full-schematic", description="Complete schematic, including source title block")

    for filename, crop, description in SCHEMATIC_REGIONS:
        export(filename, "SCH_HallPos.pdf", 1, crop=crop, width=1800 if filename == "linear-power.png" else 1400,
               kind="schematic-detail", description=description)

    manifest = {
        "schema_version": 1,
        "generator": "scripts/render_hardware_assets.py",
        "renderer": f"PyMuPDF {fitz.VersionBind}",
        "coordinate_system": "PDF points, origin top-left, page_number is one-based",
        "board_side_convention": {
            "top": "PDF page 4, identified as the populated/component side from pad and component outlines",
            "bottom": "PDF page 5, opposite side; preserve the source PDF's view",
            "qualification": "PDF pages do not print layer names. These are composite views, not individual Gerber layers.",
        },
        "assets": entries,
    }
    (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    for document in documents.values():
        document.close()
    return manifest


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source-dir", type=Path, default=DEFAULT_SOURCE)
    parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT)
    args = parser.parse_args()
    manifest = render(args.source_dir.resolve(), args.output_dir.resolve())
    for asset in manifest["assets"]:
        print(f"{asset['file']}: {asset['pixel_size'][0]} x {asset['pixel_size'][1]}, "
              f"{Path(asset['source_pdf']).name} p{asset['page_number']}")


if __name__ == "__main__":
    main()
