from __future__ import annotations

import json
import math
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import fitz
from PIL import Image, ImageDraw
from reportlab.lib import colors
from reportlab.lib.pagesizes import A1, A3, A4, landscape
from reportlab.pdfgen import canvas


ROOT = Path(__file__).resolve().parents[1]
ASSEMBLY_DIR = ROOT / "preview_exports" / "assembly"
EXPORT_DIR = ROOT / "preview_exports" / "exports"
PDF_RENDER_DIR = EXPORT_DIR / "pdf_renders"
SOURCE_PDF_DIR = ROOT / "06 Drawings" / "pdf"
CATALOG_PATH = ASSEMBLY_DIR / "roof_mount_platform_engine_assembly.json"
MANIFEST_PATH = EXPORT_DIR / "export_manifest.json"

MAX_STEP_TRIANGLES = 2_500_000
VIRTUAL_SHEET = landscape(A1)
PDF_PAGE_SIZE = landscape(A4)
SHEET_SCALE_X = PDF_PAGE_SIZE[0] / VIRTUAL_SHEET[0]
SHEET_SCALE_Y = PDF_PAGE_SIZE[1] / VIRTUAL_SHEET[1]


def read_json(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def write_json(path: Path, value: Any) -> None:
    path.write_text(json.dumps(value, indent=2), encoding="utf-8")


def web_path(path: Path) -> str:
    return str(path.relative_to(ROOT)).replace("\\", "/")


def transform_point(matrix: list[float], point: tuple[float, float, float]) -> tuple[float, float, float]:
    x, y, z = point
    return (
        matrix[0] * x + matrix[1] * y + matrix[2] * z + matrix[3],
        matrix[4] * x + matrix[5] * y + matrix[6] * z + matrix[7],
        matrix[8] * x + matrix[9] * y + matrix[10] * z + matrix[11],
    )


def transformed_part_box(part: dict[str, Any], occurrence: dict[str, Any]) -> list[tuple[float, float, float]]:
    bmin = part["bounds"]["min"]
    bmax = part["bounds"]["max"]
    corners = [
        (x, y, z)
        for x in (bmin[0], bmax[0])
        for y in (bmin[1], bmax[1])
        for z in (bmin[2], bmax[2])
    ]
    return [transform_point(occurrence["matrixRowMajor"], corner) for corner in corners]


def project(point: tuple[float, float, float], view: str) -> tuple[float, float]:
    x, y, z = point
    if view == "top":
        return x, y
    if view == "front":
        return x, z
    if view == "right":
        return y, z
    raise ValueError(view)


def projected_rect(points: list[tuple[float, float, float]], view: str) -> tuple[float, float, float, float]:
    projected = [project(point, view) for point in points]
    xs = [point[0] for point in projected]
    ys = [point[1] for point in projected]
    return min(xs), min(ys), max(xs), max(ys)


def view_rects(catalog: dict[str, Any], view: str) -> list[tuple[float, float, float, float, str]]:
    rects = []
    parts = catalog["parts"]
    for occurrence in catalog["instances"]:
        part = parts.get(occurrence["partId"])
        if not part:
            continue
        x1, y1, x2, y2 = projected_rect(transformed_part_box(part, occurrence), view)
        if abs(x2 - x1) < 0.001 or abs(y2 - y1) < 0.001:
            continue
        rects.append((x1, y1, x2, y2, occurrence.get("folder", "Assembly")))
    return rects


def rect_bounds(rects: list[tuple[float, float, float, float, str]]) -> tuple[float, float, float, float]:
    if not rects:
        return 0.0, 0.0, 1.0, 1.0
    return (
        min(rect[0] for rect in rects),
        min(rect[1] for rect in rects),
        max(rect[2] for rect in rects),
        max(rect[3] for rect in rects),
    )


def folder_color(folder: str) -> colors.Color:
    return colors.HexColor(folder_hex(folder))


def folder_hex(folder: str) -> str:
    palette = {
        "01 Truss": "#3b6ea8",
        "02 Floor": "#688a42",
        "03 Screen": "#a86a2d",
        "04 Building": "#9da5ad",
        "05 Roof_Mounting": "#c7a33b",
        "08 Tyipcal_Parts": "#795c9f",
    }
    return palette.get(folder, "#52606d")


BOX_EDGES = (
    (0, 1), (0, 2), (0, 4), (3, 1), (3, 2), (3, 7),
    (5, 1), (5, 4), (5, 7), (6, 2), (6, 4), (6, 7),
)


def sheet_meta(catalog: dict[str, Any]) -> dict[str, Any]:
    params = catalog.get("parameters", {})
    return {
        "job": params.get("JOB_NO") or "J7535",
        "client": params.get("CLIENT_NAME") or "",
        "drawn": params.get("DRAWN_BY") or "HART AUSTRALIA",
        "module": (params.get("MODULE_NAME") or "ROOF MOUNTED").strip(),
        "length": params.get("PLATFORM_INITIAL_LENGTH"),
        "width": params.get("PLATFORM_WIDTH_TRUSS_LENGHT"),
        "screen_height": params.get("SCREEN_HEIGHT"),
    }


def draw_title_block(pdf: canvas.Canvas, catalog: dict[str, Any], sheet_no: int, sheet_title: str, drawing_no: str) -> None:
    width, _height = landscape(A1)
    left = 20
    bottom = 16
    title_h = 78
    meta = sheet_meta(catalog)
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.55)
    pdf.rect(left, bottom, width - left * 2, title_h, stroke=1, fill=0)

    revision_w = 470
    logo_w = 160
    info_w = 500
    pdf.line(left + revision_w, bottom, left + revision_w, bottom + title_h)
    pdf.line(width - left - info_w, bottom, width - left - info_w, bottom + title_h)
    pdf.line(width - left - info_w - logo_w, bottom, width - left - info_w - logo_w, bottom + title_h)

    pdf.setFont("Helvetica-Bold", 7.5)
    pdf.drawString(left + 8, bottom + title_h - 12, "REV")
    pdf.drawString(left + 50, bottom + title_h - 12, "DATE")
    pdf.drawString(left + 108, bottom + title_h - 12, "DESCRIPTION")
    pdf.drawString(left + 338, bottom + title_h - 12, "DRAWN")
    pdf.drawString(left + 402, bottom + title_h - 12, "CHK")
    for index in range(1, 5):
        y = bottom + title_h - 12 - index * 14
        pdf.line(left, y + 8, left + revision_w, y + 8)
        pdf.setFont("Helvetica", 7)
        if index == 1:
            pdf.drawString(left + 10, y, "A")
            pdf.drawString(left + 50, y, datetime.now().strftime("%d/%m/%Y"))
            pdf.drawString(left + 108, y, "ISSUED FOR REVIEW")
            pdf.drawString(left + 338, y, "AUTO")
            pdf.drawString(left + 402, y, "APP")

    logo_x = width - left - info_w - logo_w
    pdf.setFillColor(colors.HexColor("#d71920"))
    pdf.setFont("Helvetica-Bold", 27)
    pdf.drawCentredString(logo_x + logo_w / 2, bottom + 30, "Hart")
    pdf.setFillColor(colors.black)
    pdf.setFont("Helvetica", 7)
    pdf.drawCentredString(logo_x + logo_w / 2, bottom + 16, "ROOF MOUNTED PLATFORM")

    info_x = width - left - info_w + 8
    rows = [
        ("TITLE", sheet_title),
        ("JOB NO", str(meta["job"])),
        ("CLIENT", str(meta["client"]) or "HART PROJECT"),
        ("DRAWN", str(meta["drawn"])),
        ("DWG NO", drawing_no),
        ("SHEET", f"{sheet_no}/3   SCALE AS SHOWN"),
    ]
    pdf.setFont("Helvetica", 7)
    row_h = title_h / len(rows)
    for i, (label, value) in enumerate(rows):
        y = bottom + title_h - (i + 1) * row_h
        if i:
            pdf.line(width - left - info_w, y + row_h, width - left, y + row_h)
        pdf.setFont("Helvetica-Bold", 7)
        pdf.drawString(info_x, y + 4, label)
        pdf.setFont("Helvetica", 7)
        pdf.drawString(info_x + 72, y + 4, str(value)[:48])


def draw_dimension(pdf: canvas.Canvas, start: tuple[float, float], end: tuple[float, float], text: str, text_offset: float = 6) -> None:
    x1, y1 = start
    x2, y2 = end
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.35)
    pdf.line(x1, y1, x2, y2)
    pdf.line(x1 - 5, y1 - 5, x1 + 5, y1 + 5)
    pdf.line(x2 - 5, y2 - 5, x2 + 5, y2 + 5)
    pdf.setFont("Helvetica", 5.8)
    pdf.drawCentredString((x1 + x2) / 2, (y1 + y2) / 2 + text_offset, text)


def draw_hatch(pdf: canvas.Canvas, x: float, y: float, w: float, h: float, step: int = 14) -> None:
    pdf.setStrokeColor(colors.HexColor("#777777"))
    pdf.setLineWidth(0.22)
    for offset in range(-int(h), int(w) + int(h), step):
        pdf.line(x + offset, y, x + offset + h, y + h)


def draw_sheet_header(pdf: canvas.Canvas, title: str, subtitle: str = "") -> None:
    width, height = landscape(A1)
    pdf.setFont("Helvetica-Bold", 18)
    pdf.drawString(24, height - 34, title)
    if subtitle:
        pdf.setFont("Helvetica", 8)
        pdf.drawString(24, height - 48, subtitle)
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(16, 16, width - 32, height - 32, stroke=1, fill=0)


def fit_projected(points: list[tuple[float, float]], box: tuple[float, float, float, float]):
    x0, y0, w, h = box
    xs = [p[0] for p in points] or [0.0, 1.0]
    ys = [p[1] for p in points] or [0.0, 1.0]
    min_x, max_x = min(xs), max(xs)
    min_y, max_y = min(ys), max(ys)
    model_w = max(1.0, max_x - min_x)
    model_h = max(1.0, max_y - min_y)
    scale = min(w / model_w, h / model_h)
    ox = x0 + (w - model_w * scale) / 2
    oy = y0 + (h - model_h * scale) / 2

    def map_point(point: tuple[float, float]) -> tuple[float, float]:
        return ox + (point[0] - min_x) * scale, oy + (point[1] - min_y) * scale

    return map_point, (model_w, model_h), scale


def iso_project(point: tuple[float, float, float]) -> tuple[float, float]:
    x, y, z = point
    angle = math.radians(30)
    return (x - y) * math.cos(angle), (x + y) * math.sin(angle) - z


def rgb_from_hex(value: str) -> tuple[int, int, int]:
    value = value.lstrip("#")
    return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)


def shade_color(base: tuple[int, int, int], shade: float) -> tuple[int, int, int]:
    shade = max(0.35, min(1.12, shade))
    return tuple(max(0, min(255, int(channel * shade))) for channel in base)


def unit_vector(vector: tuple[float, float, float]) -> tuple[float, float, float]:
    length = math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]) or 1.0
    return vector[0] / length, vector[1] / length, vector[2] / length


def project_render_point(point: tuple[float, float, float], view: str) -> tuple[float, float, float]:
    x, y, z = point
    if view == "iso":
        px, py = iso_project(point)
        depth = (x + y + z * 0.35)
        return px, py, depth
    if view == "top":
        return x, y, z
    if view == "front":
        return x, z, y
    if view == "right":
        return y, z, x
    raise ValueError(view)


def render_mesh_view(
    catalog: dict[str, Any],
    view: str,
    filename: str,
    size: tuple[int, int],
    folder_filter: set[str] | None = None,
    max_triangles: int = 160_000,
    color_mode: str = "technical",
) -> Path:
    PDF_RENDER_DIR.mkdir(parents=True, exist_ok=True)
    path = PDF_RENDER_DIR / filename
    width, height = size
    image = Image.new("RGB", size, "white")
    draw = ImageDraw.Draw(image, "RGBA")
    mesh_cache: dict[str, dict[str, Any]] = {}
    projected_bounds: list[tuple[float, float]] = []
    occurrences = [
        occurrence for occurrence in catalog["instances"]
        if not folder_filter or occurrence.get("folder") in folder_filter
    ]
    total_triangles = sum(
        int(catalog["parts"].get(occurrence["partId"], {}).get("triangleCount", 0))
        for occurrence in occurrences
    )
    stride = max(1, math.ceil(total_triangles / max_triangles))

    triangle_rows: list[tuple[float, tuple[tuple[float, float], tuple[float, float], tuple[float, float]], tuple[int, int, int]]] = []
    light = unit_vector((0.35, -0.55, 0.75))
    for occurrence in occurrences:
        part = catalog["parts"].get(occurrence["partId"])
        if not part or not part.get("meshJson"):
            continue
        mesh_path = str((ASSEMBLY_DIR / part["meshJson"]).resolve())
        if mesh_path not in mesh_cache:
            mesh_cache[mesh_path] = read_json(Path(mesh_path))
        mesh = mesh_cache[mesh_path]
        vertices = mesh["vertices"]
        indices = mesh["indices"]
        matrix = occurrence["matrixRowMajor"]
        folder = occurrence.get("folder", "")
        if color_mode == "mounting":
            base = (176, 145, 35) if folder == "05 Roof_Mounting" else (92, 96, 100)
        elif color_mode == "screen":
            base = rgb_from_hex(folder_hex(folder))
        else:
            base = (72, 76, 82)
        for tri_index in range(0, len(indices), 3 * stride):
            ia, ib, ic = indices[tri_index:tri_index + 3]
            a = transform_point(matrix, (vertices[ia * 3], vertices[ia * 3 + 1], vertices[ia * 3 + 2]))
            b = transform_point(matrix, (vertices[ib * 3], vertices[ib * 3 + 1], vertices[ib * 3 + 2]))
            c = transform_point(matrix, (vertices[ic * 3], vertices[ic * 3 + 1], vertices[ic * 3 + 2]))
            pa = project_render_point(a, view)
            pb = project_render_point(b, view)
            pc = project_render_point(c, view)
            projected_bounds.extend([(pa[0], pa[1]), (pb[0], pb[1]), (pc[0], pc[1])])
            ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
            ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
            normal = unit_vector((
                ab[1] * ac[2] - ab[2] * ac[1],
                ab[2] * ac[0] - ab[0] * ac[2],
                ab[0] * ac[1] - ab[1] * ac[0],
            ))
            lit = 0.62 + max(0.0, normal[0] * light[0] + normal[1] * light[1] + normal[2] * light[2]) * 0.42
            triangle_rows.append(((pa[2] + pb[2] + pc[2]) / 3, ((pa[0], pa[1]), (pb[0], pb[1]), (pc[0], pc[1])), shade_color(base, lit)))

    if not projected_bounds:
        image.save(path)
        return path

    mapper, _model_size, _scale = fit_projected(projected_bounds, (28, 28, width - 56, height - 56))
    triangle_rows.sort(key=lambda item: item[0])
    for _depth, points, color in triangle_rows:
        mapped = [mapper(point) for point in points]
        draw.polygon(mapped, fill=(*color, 215), outline=(50, 54, 60, 55))
    image.save(path, quality=92)
    return path


def occurrence_boxes(catalog: dict[str, Any], folder_filter: set[str] | None = None) -> list[tuple[list[tuple[float, float, float]], str, dict[str, Any]]]:
    boxes = []
    parts = catalog["parts"]
    for occurrence in catalog["instances"]:
        if folder_filter and occurrence.get("folder") not in folder_filter:
            continue
        part = parts.get(occurrence["partId"])
        if not part:
            continue
        boxes.append((transformed_part_box(part, occurrence), occurrence.get("folder", "Assembly"), occurrence))
    return boxes


def draw_wireframe_iso(pdf: canvas.Canvas, catalog: dict[str, Any], box: tuple[float, float, float, float], folder_filter: set[str] | None = None) -> None:
    boxes = occurrence_boxes(catalog, folder_filter)
    projected = [iso_project(point) for corners, _folder, _occ in boxes for point in corners]
    mapper, _size, _scale = fit_projected(projected, box)
    pdf.setLineJoin(1)
    for corners, folder, _occ in boxes:
        pdf.setStrokeColor(folder_color(folder))
        pdf.setLineWidth(0.28 if folder_filter else 0.18)
        mapped = [mapper(iso_project(point)) for point in corners]
        for a, b in BOX_EDGES:
            pdf.line(mapped[a][0], mapped[a][1], mapped[b][0], mapped[b][1])


def draw_projected_view_on_sheet(
    pdf: canvas.Canvas,
    catalog: dict[str, Any],
    view: str,
    box: tuple[float, float, float, float],
    title: str,
    folder_filter: set[str] | None = None,
) -> None:
    rects = [
        rect for rect in view_rects(catalog, view)
        if not folder_filter or rect[4] in folder_filter
    ]
    x1, y1, x2, y2 = rect_bounds(rects)
    model_w = max(1.0, x2 - x1)
    model_h = max(1.0, y2 - y1)
    box_x, box_y, box_w, box_h = box
    scale = min(box_w / model_w, box_h / model_h)
    ox = box_x + (box_w - model_w * scale) / 2
    oy = box_y + (box_h - model_h * scale) / 2

    def tx(value: float) -> float:
        return ox + (value - x1) * scale

    def ty(value: float) -> float:
        return oy + (value - y1) * scale

    pdf.setFont("Helvetica-Bold", 9)
    pdf.drawCentredString(box_x + box_w / 2, box_y - 14, title)
    pdf.setStrokeColor(colors.HexColor("#777777"))
    pdf.setLineWidth(0.25)
    for rx1, ry1, rx2, ry2, folder in rects:
        pdf.setStrokeColor(folder_color(folder))
        pdf.rect(tx(rx1), ty(ry1), max(0.15, (rx2 - rx1) * scale), max(0.15, (ry2 - ry1) * scale), stroke=1, fill=0)

    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.55)
    pdf.rect(tx(x1), ty(y1), model_w * scale, model_h * scale, stroke=1, fill=0)
    pdf.setFont("Helvetica", 7)
    pdf.drawRightString(box_x + box_w, box_y - 25, f"{model_w:.0f} x {model_h:.0f} mm")


def draw_bom_table(pdf: canvas.Canvas, catalog: dict[str, Any], box: tuple[float, float, float, float]) -> None:
    x, y, w, h = box
    rows = sorted(catalog["parts"].values(), key=lambda item: (-int(item.get("count", 0)), item.get("name", "")))[:28]
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 6, y + h - 12, "PARTS LIST")
    header_y = y + h - 26
    pdf.line(x, header_y + 8, x + w, header_y + 8)
    pdf.setFont("Helvetica-Bold", 6.5)
    pdf.drawString(x + 6, header_y, "ITEM")
    pdf.drawString(x + 38, header_y, "QTY")
    pdf.drawString(x + 72, header_y, "DESCRIPTION")
    pdf.drawString(x + w - 82, header_y, "GROUP")
    pdf.line(x + 32, y, x + 32, header_y + 8)
    pdf.line(x + 66, y, x + 66, header_y + 8)
    pdf.line(x + w - 90, y, x + w - 90, header_y + 8)
    row_h = 13
    pdf.setFont("Helvetica", 6.2)
    for index, part in enumerate(rows, start=1):
        row_y = header_y - index * row_h
        if row_y < y + 4:
            break
        pdf.line(x, row_y + row_h - 2, x + w, row_y + row_h - 2)
        source = str(part.get("sourcePath", ""))
        group = source.split("\\")[0] if "\\" in source else ""
        pdf.drawString(x + 7, row_y + 2, str(index))
        pdf.drawRightString(x + 58, row_y + 2, str(part.get("count", "")))
        pdf.drawString(x + 72, row_y + 2, str(part.get("name", ""))[:42])
        pdf.drawString(x + w - 84, row_y + 2, group.replace("_", " ")[:16])


def draw_notes(pdf: canvas.Canvas, catalog: dict[str, Any], box: tuple[float, float, float, float]) -> None:
    x, y, w, h = box
    meta = sheet_meta(catalog)
    notes = [
        "ALL DIMENSIONS ARE IN MILLIMETRES UNLESS NOTED OTHERWISE.",
        "MODEL GEOMETRY IS GENERATED FROM THE STANDALONE CONFIGURATION CATALOG.",
        "VERIFY SITE FIXING LOCATIONS AGAINST ROOF STRUCTURE BEFORE FABRICATION.",
        f"PLATFORM LENGTH: {meta['length'] or '-'} mm",
        f"PLATFORM WIDTH: {meta['width'] or '-'} mm",
        f"SCREEN HEIGHT: {meta['screen_height'] or '-'} mm",
    ]
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 6, y + h - 12, "NOTES")
    pdf.setFont("Helvetica", 6.5)
    for index, note in enumerate(notes, start=1):
        pdf.drawString(x + 8, y + h - 28 - index * 12, f"{index}. {note}")


def draw_callout(pdf: canvas.Canvas, x: float, y: float, text: str, target_x: float, target_y: float) -> None:
    pdf.setStrokeColor(colors.HexColor("#555555"))
    pdf.setLineWidth(0.35)
    pdf.line(x, y, target_x, target_y)
    pdf.circle(x, y, 8, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 6)
    pdf.drawCentredString(x, y - 2, text)


def draw_mounting_section(pdf: canvas.Canvas, box: tuple[float, float, float, float], title: str, variant: str) -> None:
    x, y, w, h = box
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    roof_y = y + h * 0.42
    purlin_y = y + h * 0.24
    pdf.setFillColor(colors.HexColor("#eeeeee"))
    pdf.rect(x + 28, roof_y - 6, w - 56, 12, stroke=0, fill=1)
    pdf.setFillColor(colors.white)
    draw_hatch(pdf, x + 28, roof_y - 6, w - 56, 12, 13)
    pdf.setLineWidth(1.0)
    pdf.line(x + 18, roof_y, x + w - 18, roof_y + (8 if variant == "pitch" else 0))
    pdf.setLineWidth(0.55)
    for offset in range(0, int(w - 40), 32):
        pdf.line(x + 20 + offset, roof_y - 8, x + 32 + offset, roof_y + 8)
    pdf.rect(x + w * 0.28, purlin_y - 10, w * 0.44, 20, stroke=1, fill=0)
    rod_x = x + w * 0.5
    pdf.setStrokeColor(colors.HexColor("#b89226"))
    pdf.setLineWidth(2.2)
    pdf.line(rod_x, y + h * 0.15, rod_x, y + h * 0.76)
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.5)
    pdf.rect(rod_x - 18, roof_y + 8, 36, 10, stroke=1, fill=0)
    pdf.rect(rod_x - 12, roof_y - 18, 24, 9, stroke=1, fill=0)
    pdf.rect(rod_x - 26, y + h * 0.72, 52, 16, stroke=1, fill=0)
    pdf.setFont("Helvetica", 6)
    pdf.drawString(x + 32, roof_y + 24, "ROOF SHEET")
    pdf.drawString(x + w * 0.28 + 8, purlin_y - 24, "EXISTING PURLIN")
    pdf.drawString(rod_x + 22, y + h * 0.53, "M16 ROD")
    draw_dimension(pdf, (rod_x + 44, roof_y + 18), (rod_x + 44, y + h * 0.72), "ROD TO SUIT")
    draw_dimension(pdf, (x + 70, roof_y - 36), (x + w - 70, roof_y - 36), "TYPICAL ROOF FIXING SPACING")
    draw_callout(pdf, x + w * 0.18, y + h * 0.78, "1", rod_x - 18, y + h * 0.72)
    draw_callout(pdf, x + w * 0.82, y + h * 0.70, "2", rod_x + 14, roof_y + 12)
    draw_callout(pdf, x + w * 0.20, y + h * 0.28, "3", rod_x - 8, purlin_y)
    draw_callout(pdf, x + w * 0.80, y + h * 0.28, "4", rod_x + 22, purlin_y)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawCentredString(x + w / 2, y - 16, title)
    pdf.setFont("Helvetica", 6)
    pdf.drawCentredString(x + w / 2, y - 28, "SCALE 1:10")


def draw_fixing_setout_detail(pdf: canvas.Canvas, box: tuple[float, float, float, float]) -> None:
    x, y, w, h = box
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 8, y + h - 14, "TYPICAL ROOF FIXING SETOUT")
    grid_x = x + 42
    grid_y = y + 44
    grid_w = w - 84
    grid_h = h - 82
    pdf.setLineWidth(0.3)
    pdf.setFillColor(colors.HexColor("#f2f2f2"))
    pdf.rect(grid_x, grid_y, grid_w, grid_h, stroke=0, fill=1)
    pdf.setFillColor(colors.white)
    draw_hatch(pdf, grid_x, grid_y, grid_w, grid_h, 18)
    for i in range(11):
        gx = grid_x + grid_w * i / 10
        pdf.setStrokeColor(colors.HexColor("#777777"))
        pdf.line(gx, grid_y, gx, grid_y + grid_h)
        pdf.setStrokeColor(colors.black)
        for frac in (0.20, 0.50, 0.80):
            pdf.circle(gx, grid_y + grid_h * frac, 5, stroke=1, fill=0)
    for frac, label in [(0.20, "PURLIN LINE A"), (0.50, "PURLIN LINE B"), (0.80, "PURLIN LINE C")]:
        gy = grid_y + grid_h * frac
        pdf.setStrokeColor(colors.black)
        pdf.setLineWidth(0.55)
        pdf.line(grid_x - 22, gy, grid_x + grid_w + 22, gy)
        pdf.setFont("Helvetica", 6)
        pdf.drawString(grid_x + grid_w + 28, gy - 3, label)
    draw_dimension(pdf, (grid_x, grid_y - 18), (grid_x + grid_w, grid_y - 18), "MODULE LENGTH")
    draw_dimension(pdf, (grid_x - 22, grid_y + grid_h * 0.20), (grid_x - 22, grid_y + grid_h * 0.80), "PURLIN CRS", 0)
    pdf.setFont("Helvetica", 6)
    pdf.drawString(x + 42, y + 18, "ALL FIXING LOCATIONS ARE DERIVED FROM CURRENT CONFIGURED OCCURRENCES.")


def draw_drilling_schedule(pdf: canvas.Canvas, box: tuple[float, float, float, float]) -> None:
    x, y, w, h = box
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 8, y + h - 14, "HOLE / PENETRATION SCHEDULE")
    headers = ["MARK", "DIA.", "SEAL", "QTY", "NOTE"]
    col_x = [x + 8, x + 58, x + 108, x + 170, x + 220]
    pdf.setFont("Helvetica-Bold", 6)
    for xx, header in zip(col_x, headers):
        pdf.drawString(xx, y + h - 32, header)
    pdf.line(x, y + h - 24, x + w, y + h - 24)
    for xx in col_x[1:]:
        pdf.line(xx - 8, y, xx - 8, y + h - 24)
    rows = [
        ("A", "18", "EPDM", "TYP", "ROD PENETRATION"),
        ("B", "14", "BUTYL", "TYP", "BRACKET SLOT"),
        ("C", "10", "SILICONE", "TYP", "ANTI-ROTATION FIXING"),
        ("D", "SITE", "AS REQ", "-", "MATCH ROOF PROFILE"),
        ("E", "N/A", "PAINT", "-", "TOUCH UP GALV. EDGES"),
    ]
    pdf.setFont("Helvetica", 5.9)
    row_h = 18
    for index, row in enumerate(rows):
        yy = y + h - 48 - index * row_h
        pdf.line(x, yy + row_h - 4, x + w, yy + row_h - 4)
        for xx, value in zip(col_x, row):
            pdf.drawString(xx, yy + 3, value)


def draw_component_legend(pdf: canvas.Canvas, box: tuple[float, float, float, float]) -> None:
    x, y, w, h = box
    rows = [
        ("1", "STIRRUP / ROOF BRACKET"),
        ("2", "M16 THREADED ROD"),
        ("3", "EPDM SEALING WASHER"),
        ("4", "GALV. FLAT WASHER + NUT"),
        ("5", "COMPRESSION TUBE"),
        ("6", "ROOF SHEET / EXISTING PURLIN"),
    ]
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 8, y + h - 14, "ITEM LEGEND")
    row_h = (h - 26) / len(rows)
    for index, (item, label) in enumerate(rows):
        yy = y + h - 28 - index * row_h
        pdf.line(x, yy + row_h - 3, x + w, yy + row_h - 3)
        pdf.circle(x + 18, yy + 4, 7, stroke=1, fill=0)
        pdf.setFont("Helvetica-Bold", 6)
        pdf.drawCentredString(x + 18, yy + 2, item)
        pdf.setFont("Helvetica", 6.2)
        pdf.drawString(x + 34, yy + 2, label)


def draw_fastener_schedule(pdf: canvas.Canvas, box: tuple[float, float, float, float]) -> None:
    x, y, w, h = box
    rows = [
        ("1", "STIRRUP BRACKET", "GALV.", "TYPICAL ROOF SUPPORT"),
        ("2", "M16 THREADED ROD", "GALV.", "CUT TO SUIT ROOF PITCH"),
        ("3", "RUBBER WASHER", "EPDM", "SEAL AGAINST ROOF SHEET"),
        ("4", "M16 WASHER", "GALV.", "BOTH SIDES OF BRACKET"),
        ("5", "COMPRESSION TUBE", "GALV.", "BETWEEN ROOF AND PURLIN"),
        ("6", "PURLIN FIXING", "SITE", "CONFIRM WITH STRUCTURE"),
    ]
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(x, y, w, h, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 7, y + h - 13, "FIXING SCHEDULE")
    header_y = y + h - 30
    pdf.line(x, header_y + 8, x + w, header_y + 8)
    pdf.setFont("Helvetica-Bold", 6)
    pdf.drawString(x + 7, header_y, "ITEM")
    pdf.drawString(x + 42, header_y, "DESCRIPTION")
    pdf.drawString(x + 176, header_y, "FINISH")
    pdf.drawString(x + 236, header_y, "NOTES")
    pdf.line(x + 34, y, x + 34, header_y + 8)
    pdf.line(x + 168, y, x + 168, header_y + 8)
    pdf.line(x + 226, y, x + 226, header_y + 8)
    row_h = 20
    pdf.setFont("Helvetica", 6)
    for index, row in enumerate(rows):
        row_y = header_y - (index + 1) * row_h
        pdf.line(x, row_y + row_h - 4, x + w, row_y + row_h - 4)
        pdf.drawString(x + 10, row_y + 4, row[0])
        pdf.drawString(x + 42, row_y + 4, row[1])
        pdf.drawString(x + 176, row_y + 4, row[2])
        pdf.drawString(x + 236, row_y + 4, row[3])


def draw_site_assembly_sheet(pdf: canvas.Canvas, catalog: dict[str, Any]) -> None:
    pdf.saveState()
    pdf.scale(SHEET_SCALE_X, SHEET_SCALE_Y)
    draw_sheet_header(pdf, "SITE ASSEMBLY", "Roof mounted platform generated from current web configuration")
    iso_path = render_mesh_view(catalog, "iso", "site_assembly_iso.png", (1500, 900), max_triangles=220_000, color_mode="technical")
    plan_path = render_mesh_view(catalog, "top", "site_assembly_plan.png", (1200, 260), max_triangles=120_000, color_mode="technical")
    elev_path = render_mesh_view(catalog, "front", "site_assembly_elevation.png", (900, 260), max_triangles=90_000, color_mode="technical")
    pdf.drawImage(str(iso_path), 85, 525, width=1200, height=720, preserveAspectRatio=True, mask="auto")
    pdf.drawImage(str(plan_path), 105, 330, width=690, height=135, preserveAspectRatio=True, mask="auto")
    pdf.drawImage(str(elev_path), 850, 330, width=450, height=135, preserveAspectRatio=True, mask="auto")
    pdf.setFont("Helvetica-Bold", 9)
    pdf.drawCentredString(450, 306, "PLAN VIEW")
    pdf.drawCentredString(1075, 306, "ELEVATION")
    draw_bom_table(pdf, catalog, (1460, 760, 520, 600))
    draw_notes(pdf, catalog, (1460, 610, 520, 118))
    for i, (x, y, tx, ty) in enumerate([(210, 1040, 340, 930), (500, 1170, 610, 1030), (915, 1050, 850, 940), (360, 470, 485, 540)], start=1):
        draw_callout(pdf, x, y, str(i), tx, ty)
    draw_title_block(pdf, catalog, 1, "SITE ASSEMBLY", "J7535-0000-001")
    pdf.restoreState()
    pdf.showPage()


def draw_mounting_sheet(pdf: canvas.Canvas, catalog: dict[str, Any]) -> None:
    pdf.saveState()
    pdf.scale(SHEET_SCALE_X, SHEET_SCALE_Y)
    draw_sheet_header(pdf, "MOUNTING DETAILS", "Roof fixing and support components from current configuration")
    mounting = {"05 Roof_Mounting", "04 Building"}
    iso_path = render_mesh_view(catalog, "iso", "mounting_iso.png", (1000, 720), mounting, max_triangles=140_000, color_mode="mounting")
    setout_path = render_mesh_view(catalog, "top", "mounting_setout.png", (1800, 330), {"05 Roof_Mounting"}, max_triangles=95_000, color_mode="mounting")
    elev_path = render_mesh_view(catalog, "front", "mounting_elevation.png", (1500, 320), mounting, max_triangles=80_000, color_mode="mounting")
    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.45)
    pdf.rect(70, 1180, 1235, 130, stroke=1, fill=0)
    pdf.drawImage(str(elev_path), 90, 1200, width=1130, height=82, preserveAspectRatio=True, mask="auto")
    draw_dimension(pdf, (110, 1194), (1220, 1194), "OVERALL MOUNTING ELEVATION")
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawCentredString(685, 1162, "MOUNTING ELEVATION")
    pdf.drawImage(str(iso_path), 75, 820, width=650, height=470, preserveAspectRatio=True, mask="auto")
    draw_mounting_section(pdf, (790, 805, 520, 360), "SECTION C-C", "straight")
    draw_mounting_section(pdf, (1405, 805, 520, 360), "SECTION D-D", "pitch")
    pdf.drawImage(str(setout_path), 75, 535, width=1120, height=205, preserveAspectRatio=True, mask="auto")
    draw_fixing_setout_detail(pdf, (75, 245, 1120, 230))
    draw_component_legend(pdf, (1245, 590, 330, 170))
    draw_drilling_schedule(pdf, (1245, 120, 650, 145))
    draw_fastener_schedule(pdf, (1245, 300, 650, 245))
    pdf.setFont("Helvetica-Bold", 9)
    pdf.drawCentredString(400, 785, "ISOMETRIC VIEW")
    pdf.drawCentredString(640, 502, "MOUNTING SETOUT")
    pdf.setFont("Helvetica", 7)
    detail_notes = [
        "YELLOW ITEMS INDICATE ROOF MOUNTING HARDWARE GROUP.",
        "FIXING LOCATIONS ARE SHOWN FROM CONFIGURED OCCURRENCES.",
        "CONFIRM FASTENERS AND ROOF SHEETING TYPE BEFORE ISSUE FOR CONSTRUCTION.",
    ]
    x, y = 1620, 590
    pdf.setStrokeColor(colors.black)
    pdf.rect(x, y, 275, 170, stroke=1, fill=0)
    pdf.setFont("Helvetica-Bold", 8)
    pdf.drawString(x + 8, y + 152, "MOUNTING NOTES")
    pdf.setFont("Helvetica", 6.5)
    for index, note in enumerate(detail_notes, start=1):
        pdf.drawString(x + 8, y + 152 - index * 18, f"{index}. {note}")
    inspection_rows = [
        ("VERIFY ROOF SHEET PROFILE", "SITE"),
        ("CHECK PURLIN LOCATIONS", "SITE"),
        ("SET ROD PROJECTION", "FAB"),
        ("SEAL PENETRATIONS", "INSTALL"),
        ("TORQUE LOCK NUTS", "INSTALL"),
    ]
    table_y = y + 18
    pdf.setFont("Helvetica-Bold", 6.2)
    pdf.drawString(x + 8, table_y + 54, "CHECK")
    pdf.drawString(x + 190, table_y + 54, "BY")
    pdf.line(x, table_y + 50, x + 275, table_y + 50)
    pdf.line(x + 180, table_y, x + 180, table_y + 66)
    pdf.setFont("Helvetica", 5.9)
    for index, (check, by) in enumerate(inspection_rows):
        yy = table_y + 39 - index * 10
        pdf.line(x, yy + 8, x + 275, yy + 8)
        pdf.drawString(x + 8, yy, check)
        pdf.drawString(x + 190, yy, by)
    draw_callout(pdf, 205, 1130, "A", 330, 1020)
    draw_callout(pdf, 940, 1130, "B", 995, 960)
    draw_callout(pdf, 1650, 1130, "C", 1605, 955)
    draw_title_block(pdf, catalog, 2, "MOUNTING DETAILS", "J7535-0000-003")
    pdf.restoreState()
    pdf.showPage()


def draw_projected_view(pdf: canvas.Canvas, catalog: dict[str, Any], view: str, title: str) -> None:
    width, height = landscape(A3)
    margin = 42
    pdf.setFont("Helvetica-Bold", 16)
    pdf.drawString(margin, height - margin, title)
    pdf.setFont("Helvetica", 9)
    pdf.drawString(margin, height - margin - 16, f"Generated {datetime.now(timezone.utc).isoformat()} | Standalone engine | Units: mm")
    pdf.drawString(margin, height - margin - 29, f"Instances: {catalog['instanceCount']:,} | Unique parts: {catalog['partCount']:,}")

    rects = view_rects(catalog, view)
    x1, y1, x2, y2 = rect_bounds(rects)
    model_w = max(1.0, x2 - x1)
    model_h = max(1.0, y2 - y1)
    box_x = margin
    box_y = margin
    box_w = width - margin * 2
    box_h = height - margin * 2 - 54
    scale = min(box_w / model_w, box_h / model_h)
    ox = box_x + (box_w - model_w * scale) / 2
    oy = box_y + (box_h - model_h * scale) / 2

    def tx(value: float) -> float:
        return ox + (value - x1) * scale

    def ty(value: float) -> float:
        return oy + (value - y1) * scale

    pdf.setStrokeColor(colors.HexColor("#aab4be"))
    pdf.rect(box_x, box_y, box_w, box_h, stroke=1, fill=0)

    for rx1, ry1, rx2, ry2, folder in rects:
        pdf.setStrokeColor(folder_color(folder))
        pdf.setLineWidth(0.18)
        pdf.rect(tx(rx1), ty(ry1), max(0.2, (rx2 - rx1) * scale), max(0.2, (ry2 - ry1) * scale), stroke=1, fill=0)

    pdf.setStrokeColor(colors.black)
    pdf.setLineWidth(0.8)
    pdf.rect(tx(x1), ty(y1), model_w * scale, model_h * scale, stroke=1, fill=0)
    pdf.setFont("Helvetica", 8)
    pdf.drawRightString(width - margin, margin - 18, f"Overall: {model_w:.0f} x {model_h:.0f} mm")
    pdf.showPage()


def export_pdf_from_source_templates(path: Path) -> tuple[bool, list[str]]:
    template_paths = [
        SOURCE_PDF_DIR / "test.pdf",
        SOURCE_PDF_DIR / "MOUNTING_DETAILS.pdf",
    ]
    if not all(template.exists() for template in template_paths):
        return False, []

    output = fitz.open()
    used: list[str] = []
    for template in template_paths:
        source = fitz.open(template)
        output.insert_pdf(source)
        used.append(web_path(template))
        source.close()

    metadata = output.metadata or {}
    metadata.update(
        {
            "title": "Roof Mounted Platform Drawings",
            "subject": "Template-based PDF export from source drawing PDFs",
            "producer": "StandalonePlatformEngine + PyMuPDF",
            "creationDate": fitz.get_pdf_now(),
            "modDate": fitz.get_pdf_now(),
        }
    )
    output.set_metadata(metadata)
    if path.exists():
        path.unlink()
    output.save(path, garbage=4, deflate=True)
    output.close()
    return True, used


def export_pdf(catalog: dict[str, Any]) -> Path:
    path = EXPORT_DIR / "roof_mount_platform_drawings.pdf"
    used_templates, _templates = export_pdf_from_source_templates(path)
    if used_templates:
        return path

    pdf = canvas.Canvas(str(path), pagesize=PDF_PAGE_SIZE)
    draw_site_assembly_sheet(pdf, catalog)
    draw_mounting_sheet(pdf, catalog)
    pdf.save()
    return path


def dxf_pair(code: int, value: Any) -> str:
    return f"{code}\n{value}\n"


def export_dxf(catalog: dict[str, Any]) -> Path:
    path = EXPORT_DIR / "roof_mount_platform_drawings.dxf"
    lines = [
        dxf_pair(0, "SECTION"), dxf_pair(2, "HEADER"), dxf_pair(9, "$INSUNITS"), dxf_pair(70, 4),
        dxf_pair(0, "ENDSEC"), dxf_pair(0, "SECTION"), dxf_pair(2, "TABLES"),
        dxf_pair(0, "TABLE"), dxf_pair(2, "LAYER"), dxf_pair(70, 8),
    ]
    layers = ["TOP", "FRONT", "RIGHT", "DIMENSIONS"]
    for layer in layers:
        lines.extend([dxf_pair(0, "LAYER"), dxf_pair(2, layer), dxf_pair(70, 0), dxf_pair(62, 7), dxf_pair(6, "CONTINUOUS")])
    lines.extend([dxf_pair(0, "ENDTAB"), dxf_pair(0, "ENDSEC"), dxf_pair(0, "SECTION"), dxf_pair(2, "ENTITIES")])

    offsets = {"top": (0.0, 0.0), "front": (0.0, -9000.0), "right": (12000.0, -9000.0)}
    layer_for = {"top": "TOP", "front": "FRONT", "right": "RIGHT"}
    for view, (off_x, off_y) in offsets.items():
        rects = view_rects(catalog, view)
        x1, y1, x2, y2 = rect_bounds(rects)
        lines.extend([
            dxf_pair(0, "TEXT"), dxf_pair(8, layer_for[view]), dxf_pair(10, off_x),
            dxf_pair(20, off_y + (y2 - y1) + 450), dxf_pair(30, 0), dxf_pair(40, 220),
            dxf_pair(1, f"{view.upper()} VIEW"),
        ])
        for rx1, ry1, rx2, ry2, _folder in rects:
            px1, py1 = off_x + rx1 - x1, off_y + ry1 - y1
            px2, py2 = off_x + rx2 - x1, off_y + ry2 - y1
            vertices = [(px1, py1), (px2, py1), (px2, py2), (px1, py2), (px1, py1)]
            lines.extend([dxf_pair(0, "LWPOLYLINE"), dxf_pair(8, layer_for[view]), dxf_pair(90, len(vertices)), dxf_pair(70, 0)])
            for vx, vy in vertices:
                lines.extend([dxf_pair(10, f"{vx:.3f}"), dxf_pair(20, f"{vy:.3f}")])

    lines.extend([dxf_pair(0, "ENDSEC"), dxf_pair(0, "EOF")])
    path.write_text("".join(lines), encoding="ascii")
    return path


def matrix_mesh_vertices(mesh: dict[str, Any], occurrence: dict[str, Any]) -> list[tuple[float, float, float]]:
    values = mesh["vertices"]
    matrix = occurrence["matrixRowMajor"]
    return [transform_point(matrix, (values[i], values[i + 1], values[i + 2])) for i in range(0, len(values), 3)]


def export_step(catalog: dict[str, Any], max_triangles: int = MAX_STEP_TRIANGLES) -> tuple[Path, dict[str, Any]]:
    path = EXPORT_DIR / "roof_mount_platform_faceted.stp"
    points: list[tuple[float, float, float]] = []
    faces: list[tuple[int, int, int]] = []
    mesh_cache: dict[str, dict[str, Any]] = {}
    skipped_triangles = 0

    for occurrence in catalog["instances"]:
        if len(faces) >= max_triangles:
            part = catalog["parts"].get(occurrence["partId"], {})
            skipped_triangles += int(part.get("triangleCount", 0))
            continue
        part = catalog["parts"].get(occurrence["partId"])
        if not part or not part.get("meshJson"):
            continue
        mesh_path = (ASSEMBLY_DIR / part["meshJson"]).resolve()
        if mesh_path not in mesh_cache:
            mesh_cache[str(mesh_path)] = read_json(mesh_path)
        mesh = mesh_cache[str(mesh_path)]
        transformed = matrix_mesh_vertices(mesh, occurrence)
        index_offset = len(points) + 1
        points.extend(transformed)
        indices = mesh["indices"]
        for i in range(0, len(indices), 3):
            if len(faces) >= max_triangles:
                skipped_triangles += (len(indices) - i) // 3
                break
            faces.append((index_offset + indices[i], index_offset + indices[i + 1], index_offset + indices[i + 2]))

    def point_list() -> str:
        return ",".join(f"({x:.6f},{y:.6f},{z:.6f})" for x, y, z in points)

    def face_list() -> str:
        return ",".join(f"({a},{b},{c})" for a, b, c in faces)

    generated = datetime.now(timezone.utc).isoformat()
    text = f"""ISO-10303-21;
HEADER;
/* Patterned after the STEP files in the source STEP folder.
 * Existing source files use Autodesk Inventor 2025 / ST-DEVELOPER AP214 BREP.
 * This standalone export remains tessellated AP242 because it is generated from
 * mesh JSON without Inventor or a BREP geometry kernel.
 */

FILE_DESCRIPTION(
/* description */ ('Faceted/tessellated standalone assembly export'),
/* implementation_level */ '2;1');

FILE_NAME(
/* name */ 'roof_mount_platform_faceted.stp',
/* time_stamp */ '{generated}',
/* author */ ('StandalonePlatformEngine'),
/* organization */ ('Hart Export'),
/* preprocessor_version */ 'Standalone JSON/STP exporter',
/* originating_system */ 'StandalonePlatformEngine patterned after Autodesk Inventor 2025 STEP exports',
/* authorisation */ '');

FILE_SCHEMA (('AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF {{ 1 0 10303 442 1 1 4 }}'));
ENDSEC;
DATA;
#1=APPLICATION_CONTEXT('configuration controlled 3d designs of mechanical parts and assemblies');
#2=APPLICATION_PROTOCOL_DEFINITION('international standard','ap242_managed_model_based_3d_engineering',2014,#1);
#3=CARTESIAN_POINT_LIST_3D('assembly points',({point_list()}));
#4=TRIANGULATED_FACE_SET('assembly facets',#3,$,.T.,({face_list()}),$);
#5=TESSELLATED_SHAPE_REPRESENTATION('ROOF_MOUNT_PLATFORM_FACETED',(#4),#6);
#6=GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#7)) GLOBAL_UNIT_ASSIGNED_CONTEXT((#8,#9,#10)) REPRESENTATION_CONTEXT('3D Context','Standalone export');
#7=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(0.01),#8,'distance_accuracy_value','');
#8=(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.));
#9=(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.));
#10=(NAMED_UNIT(*) SOLID_ANGLE_UNIT() SI_UNIT($,.STERADIAN.));
ENDSEC;
END-ISO-10303-21;
"""
    path.write_text(text, encoding="utf-8")
    return path, {
        "trianglesWritten": len(faces),
        "pointsWritten": len(points),
        "trianglesSkippedAfterLimit": skipped_triangles,
        "maxTriangles": max_triangles,
        "kind": "faceted/tessellated STEP AP242 patterned after source STEP metadata",
    }


def normalized_stem(value: str) -> str:
    return "".join(ch if ch.isalnum() else "_" for ch in value.upper()).strip("_")


def export_step_source_manifest(catalog: dict[str, Any]) -> dict[str, Any]:
    step_dir = ROOT / "STEP"
    step_files = sorted(step_dir.glob("*.stp"))
    by_exact = {path.stem.upper(): path for path in step_files}
    by_normalized = {normalized_stem(path.stem): path for path in step_files}
    rows = ["partId,partName,sourcePath,matchedStep,matchKind\n"]
    exact_count = 0
    fuzzy_count = 0

    for part_id, part in sorted(catalog["parts"].items()):
        part_name = part.get("name", "")
        source_path = part.get("sourcePath", "")
        stems = [
            Path(part_name).stem,
            Path(source_path).stem,
            part_id.rstrip("."),
        ]
        matched = None
        match_kind = ""
        for stem in stems:
            if stem.upper() in by_exact:
                matched = by_exact[stem.upper()]
                match_kind = "exact"
                exact_count += 1
                break
        if not matched:
            tokens = [token for stem in stems for token in normalized_stem(stem).split("_") if len(token) >= 3]
            scored = []
            for path in step_files:
                normalized = normalized_stem(path.stem)
                score = sum(1 for token in tokens if token in normalized)
                if score:
                    scored.append((score, path))
            if scored:
                scored.sort(key=lambda item: (-item[0], len(item[1].stem)))
                matched = scored[0][1]
                match_kind = "fuzzy"
                fuzzy_count += 1
        rows.append(
            ",".join([
                json.dumps(part_id),
                json.dumps(part_name),
                json.dumps(source_path),
                json.dumps(str(matched.relative_to(ROOT)).replace("\\", "/") if matched else ""),
                json.dumps(match_kind),
            ])
            + "\n"
        )

    manifest_path = EXPORT_DIR / "step_source_matches.csv"
    manifest_path.write_text("".join(rows), encoding="utf-8")
    return {
        "stepLibraryFolder": "STEP",
        "sourceStepFileCount": len(step_files),
        "exactPartMatches": exact_count,
        "fuzzyPartMatches": fuzzy_count,
        "matchReport": web_path(manifest_path),
        "note": "Existing STEP files are AP214 BREP part exports. Current assembly STP is AP242 tessellated; this report identifies reusable source STEP candidates for future BREP-quality assembly export.",
    }


def try_convert_dwg(dxf_path: Path) -> dict[str, Any]:
    dwg_path = EXPORT_DIR / "roof_mount_platform_drawings.dwg"
    script_path = EXPORT_DIR / "dxf_to_dwg_accoreconsole.scr"
    profile_dir = ROOT / ".accoreconsole-profile"

    oda_paths = [
        Path(r"C:\Program Files\ODA\ODAFileConverter 27.1.0\ODAFileConverter.exe"),
        Path(r"C:\Program Files\ODA\ODAFileConverter 27.0.0\ODAFileConverter.exe"),
        Path(r"C:\Program Files\ODA\ODAFileConverter\ODAFileConverter.exe"),
    ]
    for converter_name in ("ODAFileConverter.exe", "ODAFileConverter"):
        found = subprocess.run(["where.exe", converter_name], capture_output=True, text=True)
        if found.returncode == 0:
            oda_paths[:0] = [Path(line.strip()) for line in found.stdout.splitlines() if line.strip()]

    oda = next((path for path in oda_paths if path.exists()), None)
    if oda:
        oda_input = EXPORT_DIR / "oda_dwg_input"
        oda_output = EXPORT_DIR / "oda_dwg_output"
        if oda_input.exists():
            shutil.rmtree(oda_input)
        if oda_output.exists():
            shutil.rmtree(oda_output)
        oda_input.mkdir(parents=True, exist_ok=True)
        oda_output.mkdir(parents=True, exist_ok=True)
        input_copy = oda_input / dxf_path.name
        shutil.copy2(dxf_path, input_copy)
        if dwg_path.exists():
            dwg_path.unlink()

        command = [
            str(oda),
            str(oda_input),
            str(oda_output),
            "ACAD2018",
            "DWG",
            "0",
            "1",
            "*.dxf",
        ]
        result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=180)
        converted = oda_output / f"{dxf_path.stem}.dwg"
        if converted.exists() and converted.stat().st_size > 0:
            shutil.copy2(converted, dwg_path)
            return {
                "available": True,
                "converter": str(oda),
                "dwgWritten": True,
                "file": web_path(dwg_path),
                "bytes": dwg_path.stat().st_size,
                "source": web_path(dxf_path),
                "method": "ODA File Converter",
            }
        oda_errors = [{
            "returncode": result.returncode,
            "command": command,
            "stdout": result.stdout[-3000:],
            "stderr": result.stderr[-3000:],
        }]
    else:
        oda_errors = []

    accore_paths = [
        Path(r"C:\Program Files\Autodesk\DWG TrueView 2025 - English\accoreconsole.exe"),
        Path(r"C:\Program Files\Autodesk\AutoCAD 2025\accoreconsole.exe"),
        Path(r"C:\Program Files\Autodesk\AutoCAD 2024\accoreconsole.exe"),
    ]

    found = subprocess.run(["where.exe", "accoreconsole"], capture_output=True, text=True)
    if found.returncode == 0:
        accore_paths[:0] = [Path(line.strip()) for line in found.stdout.splitlines() if line.strip()]

    accore = next((path for path in accore_paths if path.exists()), None)
    if accore:
        if dwg_path.exists():
            dwg_path.unlink()
        profile_dir.mkdir(parents=True, exist_ok=True)
        script_path.write_text(
            "\n".join([
                "FILEDIA",
                "0",
                "CMDDIA",
                "0",
                "_-SAVEAS",
                "2018",
                str(dwg_path),
                "_QUIT",
                "Y",
                "",
            ]),
            encoding="ascii",
        )
        attempts = [
            [
                str(accore),
                "/isolate",
                "roof-platform-export",
                str(profile_dir),
                "/i",
                str(dxf_path),
                "/s",
                str(script_path),
                "/l",
                "en-US",
            ],
            [str(accore), "/i", str(dxf_path), "/s", str(script_path), "/l", "en-US"],
        ]
        errors = []
        for command in attempts:
            result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=180)
            if dwg_path.exists() and dwg_path.stat().st_size > 0:
                return {
                    "available": True,
                    "converter": str(accore),
                    "dwgWritten": True,
                    "file": web_path(dwg_path),
                    "bytes": dwg_path.stat().st_size,
                }
            errors.append({
                "returncode": result.returncode,
                "stdout": result.stdout[-3000:],
                "stderr": result.stderr[-3000:],
            })
        return {
            "available": True,
            "converter": str(accore),
            "dwgWritten": False,
            "note": "DWG TrueView accoreconsole was found, but conversion failed before writing DWG.",
            "errors": oda_errors + errors,
        }

    if oda_errors:
        return {
            "available": True,
            "converter": str(oda),
            "dwgWritten": False,
            "note": "ODA File Converter was found, but conversion failed before writing DWG.",
            "errors": oda_errors,
        }

    note_path = EXPORT_DIR / "DWG_EXPORT_NOTE.txt"
    note_path.write_text(
        "True DWG writing is not available in this standalone environment.\n"
        "Use roof_mount_platform_drawings.dxf directly in AutoCAD, or install/configure ODA File Converter to convert DXF to DWG without Inventor.\n",
        encoding="utf-8",
    )
    return {
        "available": False,
        "dwgWritten": False,
        "note": "Generated DXF instead. Install/configure ODA File Converter or another DWG writer for true DWG output.",
        "noteFile": web_path(note_path),
    }


def find_ghostscript() -> dict[str, Any]:
    candidates = [
        Path(r"C:\Program Files\gs\gs10.07.1\bin\gswin64c.exe"),
        Path(r"C:\Program Files\gs\gs10.06.0\bin\gswin64c.exe"),
        Path(r"C:\Program Files\gs\gs10.05.1\bin\gswin64c.exe"),
    ]
    for executable in ("gswin64c.exe", "gswin32c.exe", "gs.exe"):
        found = subprocess.run(["where.exe", executable], capture_output=True, text=True)
        if found.returncode == 0:
            candidates[:0] = [Path(line.strip()) for line in found.stdout.splitlines() if line.strip()]

    gs = next((path for path in candidates if path.exists()), None)
    if not gs:
        return {"available": False}

    result = subprocess.run([str(gs), "--version"], capture_output=True, text=True, timeout=20)
    return {
        "available": True,
        "executable": str(gs),
        "version": (result.stdout or result.stderr).strip(),
    }


def export_all() -> dict[str, Any]:
    EXPORT_DIR.mkdir(parents=True, exist_ok=True)
    catalog = read_json(CATALOG_PATH)
    pdf_path = export_pdf(catalog)
    dxf_path = export_dxf(catalog)
    step_path, step_stats = export_step(catalog)
    step_sources = export_step_source_manifest(catalog)
    dwg = try_convert_dwg(dxf_path)
    ghostscript = find_ghostscript()
    manifest = {
        "ok": True,
        "generatedAt": datetime.now(timezone.utc).isoformat(),
        "inventorUsed": False,
        "sourceCatalog": web_path(CATALOG_PATH),
        "files": {
            "pdfDrawings": web_path(pdf_path),
            "dxfDrawings": web_path(dxf_path),
            "stpFacetedAssembly": web_path(step_path),
        },
        "dwg": dwg,
        "ghostscript": ghostscript,
        "step": step_stats,
        "stepSources": step_sources,
        "pdfSources": [
            web_path(path)
            for path in (SOURCE_PDF_DIR / "test.pdf", SOURCE_PDF_DIR / "MOUNTING_DETAILS.pdf")
            if path.exists()
        ],
        "notes": [
            "PDF drawings are composed from the existing source PDF sheets when available; this preserves the original drawing layout far more closely than synthetic mesh projection.",
            "DXF drawings are still generated from projected occurrence bounding geometry.",
            "STP header/metadata is patterned after the source STEP folder, but geometry is faceted/tessellated from the current mesh catalog.",
            "Existing STEP files are AP214 BREP part exports and are reported in step_source_matches.csv for future source-based BREP assembly export.",
            "True DWG output requires a DWG writer/converter; Inventor is not used.",
        ],
    }
    if dwg.get("dwgWritten") and dwg.get("file"):
        manifest["files"]["dwgDrawings"] = dwg["file"]
    write_json(MANIFEST_PATH, manifest)
    return manifest


def main() -> int:
    manifest = export_all()
    print(json.dumps(manifest, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
