"""
external_diagram.py — External Dimensional Diagram generator.

Produces a standardized installation reference sheet from a STEP file.
NOT a manufacturing drawing. Optimized for readability and non-CAD users.

Primary engine:  build123d
Fallback engine: FreeCAD headless

Output: SVG (always) + PNG + optional PDF via cairosvg
Style:  line_drawing (MR28-style) or rendered (MR16-style)
Layout: single_sheet (auto) or multi_page (large/complex models)

Entry point:
    step_external_diagram(path, mode, mapping_file=None, datablock_file=None, options=None)
"""

import json
import logging
import math
import re
import textwrap
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import numpy as np

from .loader import StepModel, load_step

logger = logging.getLogger("step_processor.external_diagram")

MM_TO_IN = 1 / 25.4

# Sheet sizes in mm (width x height)
SHEET_SIZES = {
    "A4_landscape":     (297, 210),
    "A4_portrait":      (210, 297),
    "A3_landscape":     (420, 297),
    "A3_portrait":      (297, 420),
    "A2_landscape":     (594, 420),
    "letter_landscape": (279.4, 215.9),
    "letter_portrait":  (215.9, 279.4),
}

# Complexity thresholds for auto layout decisions
MULTIPAGE_THRESHOLD_MM = 1200   # any dimension > this triggers multi_page consideration
MULTIPAGE_PART_COUNT   = 60     # part count above this triggers multi_page


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

def step_external_diagram(
    path: str | Path,
    mode: str = "enclosure_only",
    mapping_file: str | Path | None = None,
    datablock_file: str | Path | None = None,
    options: dict | None = None,
) -> dict:
    """
    Generate an external dimensional diagram from a STEP file.

    Parameters
    ----------
    path          : Path to .step/.stp file
    mode          : 'enclosure_only' | 'enclosure_plus_mounting' | 'mounting_only'
    mapping_file  : Optional path to parts_mapping JSON
    datablock_file: Optional path to datablock .md file
    options       : Dict of overrides:
                    style         : 'line_drawing' | 'rendered' (default: auto)
                    layout_mode   : 'single_sheet' | 'multi_page' (default: auto)
                    sheet_size    : key from SHEET_SIZES or 'auto' (default: auto)
                    pdf           : bool (default: False)
                    svg           : bool (default: True)
                    views         : list of view names to include
                    mounting_variant : specific variant name to render
                    dpi           : PNG export DPI (default: 150)

    Returns
    -------
    dict matching ExternalDimensionalDiagram schema (external_diagram_schema.json)
    """
    opts = options or {}
    step_path = Path(path).expanduser().resolve()
    warnings = []
    notes = []

    if not step_path.exists():
        raise FileNotFoundError(f"STEP file not found: {step_path}")

    # --- Load geometry ---
    model = load_step(step_path)
    if model is None:
        raise RuntimeError(f"Could not load {step_path} with build123d or FreeCAD.")
    if model.backend != "build123d":
        warnings.append("Fallback engine (FreeCAD) was used — some geometry queries may be less precise.")

    # --- Load mapping + datablock ---
    mapping = _load_mapping(mapping_file) if mapping_file else None
    datablock = _load_datablock(datablock_file, step_path)

    # --- Select geometry scope ---
    selected_parts, excluded_parts = _select_parts(
        model, mode, mapping, opts.get("mounting_variant")
    )
    if excluded_parts:
        notes.append(f"{len(excluded_parts)} parts excluded per mode/mapping.")

    # --- Compute geometry ---
    bbox = _compute_bounding_box(model, selected_parts)
    overall_w = round(bbox["x_max"] - bbox["x_min"], 2)
    overall_h = round(bbox["z_max"] - bbox["z_min"], 2)
    overall_d = round(bbox["y_max"] - bbox["y_min"], 2)

    active_area = _detect_active_area(model, selected_parts)
    if active_area is None:
        warnings.append("Active area (screen aperture) not detected — omitted from diagram.")
    else:
        notes.append(f"Active area detected: {active_area['width']}×{active_area['height']} mm "
                     f"(confidence: {active_area['detection_confidence']})")

    mounting_dims = None
    if mode != "enclosure_only":
        mounting_dims = _detect_mounting_dimensions(model, selected_parts)
        if mounting_dims is None:
            warnings.append("Mounting dimensions not detected.")

    mounting_variants = _detect_mounting_variants(model, mapping)
    if mounting_variants:
        notes.append(f"{len(mounting_variants)} mounting variant(s) detected in assembly tree.")

    # --- Auto-select style and layout ---
    style = opts.get("style") or _auto_style(model, overall_w, overall_h, overall_d)
    layout_mode = opts.get("layout_mode") or _auto_layout(overall_w, overall_h, overall_d, model)
    sheet_size_key = opts.get("sheet_size") or _auto_sheet(overall_w, overall_h, overall_d, layout_mode)
    dpi = opts.get("dpi", 150)

    # --- Select views ---
    views = opts.get("views") or _auto_views(overall_w, overall_h, overall_d, mode)

    # --- Dimension style ---
    dim_style = _auto_dim_style(overall_w, overall_h, mounting_dims)

    # --- Generate SVG ---
    svg_generator = DiagramSVG(
        model=model,
        bbox=bbox,
        overall=(overall_w, overall_h, overall_d),
        active_area=active_area,
        mounting_dims=mounting_dims,
        style=style,
        views=views,
        layout_mode=layout_mode,
        sheet_size_key=sheet_size_key,
        dim_style=dim_style,
        datablock=datablock,
        selected_parts=selected_parts,
    )
    svg_content, layout_info = svg_generator.render()

    # --- Write outputs ---
    stem = step_path.stem
    out_dir = step_path.parent

    svg_path = out_dir / f"{stem}__external-diagram.svg"
    png_path = out_dir / f"{stem}__external-diagram.png"
    pdf_path = out_dir / f"{stem}__external-diagram.pdf" if opts.get("pdf") else None
    meta_path = out_dir / f"{stem}__meta.json"

    svg_path.write_text(svg_content, encoding="utf-8")
    notes.append(f"SVG written: {svg_path.name}")

    _svg_to_png(svg_content, png_path, dpi=dpi)
    if pdf_path:
        _svg_to_pdf(svg_content, pdf_path)

    # Individual view PNGs (always generated for rendered style, optional for line)
    view_paths = {}
    if style == "rendered" or opts.get("individual_views"):
        view_paths = _render_individual_views(model, step_path, views, selected_parts, dpi)

    # --- Variant diagrams ---
    variant_outputs = None
    if mounting_variants and opts.get("render_variants"):
        variant_outputs = []
        for variant in mounting_variants:
            try:
                v_result = step_external_diagram(
                    path=step_path,
                    mode="enclosure_plus_mounting",
                    mapping_file=mapping_file,
                    datablock_file=datablock_file,
                    options={**opts, "mounting_variant": variant["variant_name"], "render_variants": False},
                )
                variant_outputs.append({
                    "variant_name": variant["variant_name"],
                    "diagram_png": v_result["outputs"].get("diagram_png"),
                    "diagram_pdf": v_result["outputs"].get("diagram_pdf"),
                    "meta_json": v_result["outputs"].get("meta_json"),
                })
            except Exception as e:
                warnings.append(f"Variant '{variant['variant_name']}' diagram failed: {e}")

    # --- Assemble metadata ---
    meta = {
        "schema_version": "1.0",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "source_path": str(step_path),
        "model_name": datablock.get("display_name") or datablock.get("model_number") or stem,
        "mode": mode,
        "style": style,
        "layout_mode": layout_mode,
        "engine_used": model.backend,
        "fallback_invoked": model.backend != "build123d",
        "units": {"primary": "mm", "secondary": "in"},
        "overall_width":  overall_w,
        "overall_height": overall_h,
        "overall_depth":  overall_d,
        "bounding_box": bbox,
        "active_area": active_area,
        "mounting_dimensions": mounting_dims,
        "mounting_variants": mounting_variants,
        "selected_parts": selected_parts,
        "excluded_parts": excluded_parts,
        "mapping_file_used": str(mapping_file) if mapping_file else None,
        "datablock": datablock,
        "layout": {
            "views_included": views,
            "sheet_size": sheet_size_key,
            "scale_ratio": "NTS",
            "dimension_style": dim_style,
            "iso_style": "shaded_render" if style == "rendered" else "line_wireframe",
        },
        "outputs": {
            "diagram_png":    str(png_path) if png_path.exists() else None,
            "diagram_pdf":    str(pdf_path) if pdf_path and pdf_path.exists() else None,
            "diagram_svg":    str(svg_path) if svg_path.exists() else None,
            "iso_png":        view_paths.get("isometric_front"),
            "front_png":      view_paths.get("front"),
            "side_png":       view_paths.get("left") or view_paths.get("right"),
            "rear_png":       view_paths.get("rear"),
            "meta_json":      str(meta_path),
            "variant_outputs": variant_outputs,
        },
        "warnings": warnings,
        "notes": notes,
    }

    meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
    return meta


# ---------------------------------------------------------------------------
# SVG Diagram Generator
# ---------------------------------------------------------------------------

class DiagramSVG:
    """
    Generates a complete dimensional diagram SVG.
    Handles layout, view arrangement, dimension lines, data block.
    """

    # Design constants (mm in SVG coordinate space, 1 unit = 1mm)
    MARGIN          = 15
    VIEW_GAP        = 20
    DIM_OFFSET      = 12     # gap between geometry edge and first dim line
    DIM_LINE_EXT    = 3      # extension line overshoot
    DIM_ARROW_SIZE  = 2.5
    FONT_SIZE_DIM   = 3.5    # mm equiv
    FONT_SIZE_DIM_IMP = 2.5  # imperial secondary, italic
    FONT_SIZE_LABEL = 3.0
    FONT_SIZE_TITLE = 5.0
    DATABLOCK_H     = 22
    LINE_COLOR      = "#1a1a1a"
    DIM_COLOR_METRIC   = "#1565C0"   # blue (MR16 style) for rendered, black for line
    DIM_COLOR_LINE     = "#1a1a1a"
    HIDDEN_COLOR    = "#888888"
    DATABLOCK_BG    = "#f5f5f5"

    def __init__(self, model, bbox, overall, active_area, mounting_dims,
                 style, views, layout_mode, sheet_size_key, dim_style,
                 datablock, selected_parts):
        self.model = model
        self.bbox = bbox
        self.W, self.H, self.D = overall   # width, height, depth
        self.active_area = active_area
        self.mounting_dims = mounting_dims
        self.style = style
        self.views = views
        self.layout_mode = layout_mode
        self.sheet_w, self.sheet_h = SHEET_SIZES.get(sheet_size_key, (420, 297))
        self.dim_style = dim_style
        self.datablock = datablock
        self.selected_parts = selected_parts
        self.dim_color = self.DIM_COLOR_METRIC if style == "rendered" else self.DIM_COLOR_LINE

    def render(self) -> tuple[str, dict]:
        """Returns (svg_string, layout_info_dict)."""
        drawable_h = self.sheet_h - self.MARGIN * 2 - self.DATABLOCK_H
        drawable_w = self.sheet_w - self.MARGIN * 2

        # Compute scale to fit largest view
        max_dim = max(self.W, self.H, self.D)
        # Rough target: primary view takes ~40% of drawable width
        target_view_w = drawable_w * 0.38
        self.scale = target_view_w / max(self.W, 1)

        # Clamp scale so drawings aren't too tiny or too large
        self.scale = max(0.05, min(self.scale, 3.0))

        svg_parts = []
        layout_info = {"scale": self.scale, "views": {}}

        # Build view definitions
        view_defs = self._arrange_views(drawable_w, drawable_h)
        layout_info["views"] = {v["name"]: (v["ox"], v["oy"]) for v in view_defs}

        # SVG header
        svg_parts.append(self._svg_header())

        # Background
        svg_parts.append(
            f'<rect width="{self.sheet_w}" height="{self.sheet_h}" '
            f'fill="white" stroke="none"/>'
        )

        # Border
        m = self.MARGIN / 2
        svg_parts.append(
            f'<rect x="{m}" y="{m}" width="{self.sheet_w - m}" height="{self.sheet_h - m}" '
            f'fill="none" stroke="{self.LINE_COLOR}" stroke-width="0.5"/>'
        )

        # Draw each view
        for vdef in view_defs:
            svg_parts.append(self._render_view(vdef))

        # Data block
        svg_parts.append(self._render_datablock())

        svg_parts.append("</svg>")
        return "\n".join(svg_parts), layout_info

    def _svg_header(self) -> str:
        return (
            f'<svg xmlns="http://www.w3.org/2000/svg" '
            f'width="{self.sheet_w}mm" height="{self.sheet_h}mm" '
            f'viewBox="0 0 {self.sheet_w} {self.sheet_h}" '
            f'font-family="Arial, Helvetica, sans-serif">\n'
            f'<defs>\n'
            f'  <marker id="arrow" markerWidth="6" markerHeight="6" '
            f'refX="3" refY="3" orient="auto">\n'
            f'    <path d="M0,0 L6,3 L0,6 Z" fill="{self.dim_color}"/>\n'
            f'  </marker>\n'
            f'  <marker id="arrowR" markerWidth="6" markerHeight="6" '
            f'refX="3" refY="3" orient="auto-start-reverse">\n'
            f'    <path d="M0,0 L6,3 L0,6 Z" fill="{self.dim_color}"/>\n'
            f'  </marker>\n'
            f'</defs>'
        )

    def _arrange_views(self, dw: float, dh: float) -> list[dict]:
        """
        Arrange views on the sheet. Standard layout mirrors reference images:
        - front (center-left, largest)
        - top (above front)
        - side (right of front)
        - rear (right of side)
        - isometric (bottom-right)
        - bottom (below front, if included)

        Returns list of view dicts with position info.
        """
        s = self.scale
        g = self.VIEW_GAP
        m = self.MARGIN

        # Scaled dimensions with dim-line clearance
        sw = self.W * s   # scaled width
        sh = self.H * s   # scaled height
        sd = self.D * s   # scaled depth (appears as height in top/bottom views)

        # Primary view origin (front): leave room above for top view + dims
        top_h = sd + g * 2 if "top" in self.views else g
        front_x = m + g + sd + g  # room on left for side/left view
        front_y = m + top_h

        vdefs = []

        if "front" in self.views:
            vdefs.append({
                "name": "front", "label": "FRONT",
                "ox": front_x, "oy": front_y,
                "vw": sw, "vh": sh, "vd": sd,
                "projection": "front",
            })

        if "top" in self.views:
            vdefs.append({
                "name": "top", "label": "TOP",
                "ox": front_x, "oy": m + g,
                "vw": sw, "vh": sd, "vd": sd,
                "projection": "top",
            })

        if "bottom" in self.views:
            vdefs.append({
                "name": "bottom", "label": "BOTTOM",
                "ox": front_x, "oy": front_y + sh + g * 2,
                "vw": sw, "vh": sd, "vd": sd,
                "projection": "bottom",
            })

        # Left/side view to the left of front
        if "left" in self.views or "right" in self.views:
            side_name = "left" if "left" in self.views else "right"
            vdefs.append({
                "name": side_name, "label": "SIDE",
                "ox": m + g, "oy": front_y,
                "vw": sd, "vh": sh, "vd": sd,
                "projection": side_name,
            })

        # Rear view to the right of front
        if "rear" in self.views:
            vdefs.append({
                "name": "rear", "label": "REAR",
                "ox": front_x + sw + g + sd + g if ("left" in self.views or "right" in self.views) else front_x + sw + g,
                "oy": front_y,
                "vw": sw, "vh": sh, "vd": sd,
                "projection": "rear",
            })

        # Isometric — bottom right, roughly square
        iso_size = min(sw, sh) * 1.4
        if "isometric_front" in self.views:
            vdefs.append({
                "name": "isometric_front", "label": "ISO",
                "ox": self.sheet_w - self.MARGIN - iso_size - g,
                "oy": self.sheet_h - self.DATABLOCK_H - self.MARGIN - iso_size - g * 2,
                "vw": iso_size, "vh": iso_size, "vd": sd,
                "projection": "isometric_front",
            })

        return vdefs

    def _render_view(self, vdef: dict) -> str:
        """Render a single view with geometry outline and dimensions."""
        parts = []
        name = vdef["name"]
        ox, oy = vdef["ox"], vdef["oy"]
        vw, vh = vdef["vw"], vdef["vh"]
        s = self.scale

        parts.append(f'<g id="view-{name}">')

        if vdef["projection"] == "isometric_front":
            parts.append(self._render_iso_box(ox, oy, vw, vh))
        else:
            # Outer enclosure rectangle
            parts.append(
                f'<rect x="{ox:.2f}" y="{oy:.2f}" width="{vw:.2f}" height="{vh:.2f}" '
                f'fill="none" stroke="{self.LINE_COLOR}" stroke-width="0.7"/>'
            )

            # Active area aperture (front view only)
            if name == "front" and self.active_area:
                aa = self.active_area
                ax = ox + aa.get("offset_left", (self.W - aa["width"]) / 2) * s
                ay = oy + aa.get("offset_top", (self.H - aa["height"]) / 2) * s
                aw = aa["width"] * s
                ah = aa["height"] * s
                parts.append(
                    f'<rect x="{ax:.2f}" y="{ay:.2f}" width="{aw:.2f}" height="{ah:.2f}" '
                    f'fill="none" stroke="{self.LINE_COLOR}" stroke-width="0.5"/>'
                )
                # Diagonal measurement line (like MR28 reference)
                if aa.get("diagonal_inches"):
                    parts.append(
                        f'<line x1="{ax:.2f}" y1="{ay + ah:.2f}" '
                        f'x2="{ax + aw:.2f}" y2="{ay:.2f}" '
                        f'stroke="{self.LINE_COLOR}" stroke-width="0.35" stroke-dasharray="2,1"/>'
                    )
                    diag_label = f'{aa["diagonal"]:.2f} ({aa["diagonal_inches"]:.1f}")'
                    mid_x = ax + aw / 2
                    mid_y = oy + vh / 2
                    parts.append(
                        f'<text x="{mid_x:.2f}" y="{mid_y:.2f}" '
                        f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{self.LINE_COLOR}" '
                        f'text-anchor="middle" transform="rotate(-{self._atan_deg(aw, ah):.1f} {mid_x:.2f} {mid_y:.2f})">'
                        f'{diag_label}</text>'
                    )

            # View label
            parts.append(
                f'<text x="{ox:.2f}" y="{oy - 3:.2f}" '
                f'font-size="{self.FONT_SIZE_LABEL:.1f}" fill="{self.LINE_COLOR}" '
                f'font-weight="bold">{vdef["label"]}</text>'
            )

            # Dimensions
            if name == "front":
                parts.append(self._dim_horizontal(ox, oy + vh, vw, self.W, offset_factor=1.5))
                parts.append(self._dim_vertical(ox, oy, vh, self.H, side="left"))
                # Active area width
                if self.active_area:
                    aa = self.active_area
                    ax_offset = aa.get("offset_left", (self.W - aa["width"]) / 2) * s
                    parts.append(self._dim_horizontal(
                        ox + ax_offset, oy + vh, aa["width"] * s,
                        aa["width"], offset_factor=0.7, style="inner"
                    ))

            elif name in ("left", "right"):
                parts.append(self._dim_horizontal(ox, oy + vh, vw, self.D, offset_factor=1.5))
                parts.append(self._dim_vertical(ox + vw, oy, vh, self.H, side="right"))
                # Mounting chain on side view if vertical
                if self.mounting_dims and self.mounting_dims.get("spacing_chain_y"):
                    parts.append(self._dim_chain_vertical(
                        ox + vw, oy, s, self.mounting_dims["spacing_chain_y"]
                    ))

            elif name == "rear":
                parts.append(self._dim_horizontal(ox, oy + vh, vw, self.W, offset_factor=1.5))

            elif name == "top":
                parts.append(self._dim_horizontal(ox, oy - self.DIM_OFFSET * 0.5, vw, self.W, offset_factor=0, above=True))
                parts.append(self._dim_vertical(ox + vw, oy, vh, self.D, side="right"))
                # Mounting hole chain on top view if horizontal
                if self.mounting_dims and self.mounting_dims.get("spacing_chain_x"):
                    parts.append(self._dim_chain_horizontal(
                        ox, oy + vh, s, self.mounting_dims["spacing_chain_x"]
                    ))

            elif name == "bottom":
                parts.append(self._dim_horizontal(ox, oy + vh, vw, self.W, offset_factor=1.5))
                if self.mounting_dims and self.mounting_dims.get("spacing_chain_x"):
                    parts.append(self._dim_chain_horizontal(
                        ox, oy + vh + self.DIM_OFFSET, s, self.mounting_dims["spacing_chain_x"]
                    ))

        parts.append("</g>")
        return "\n".join(parts)

    def _render_iso_box(self, ox: float, oy: float, vw: float, vh: float) -> str:
        """Render an isometric box projection of the enclosure."""
        # Simple oblique/isometric line box — no geometry engine needed
        # Uses 30° isometric projection
        W = self.W * self.scale * 0.6
        H = self.H * self.scale * 0.6
        D = self.D * self.scale * 0.4

        # Isometric angles
        ax = math.cos(math.radians(30)) * D
        ay = math.sin(math.radians(30)) * D

        # Front face corners (bottom-left origin)
        cx = ox + D * 0.4
        cy = oy + ay * 0.5

        p = {
            "fl": (cx,         cy + H),       # front left bottom
            "fr": (cx + W,     cy + H),       # front right bottom
            "flt": (cx,        cy),            # front left top
            "frt": (cx + W,    cy),            # front right top
            "rl": (cx + ax,    cy + H - ay),  # rear left bottom
            "rlt": (cx + ax,   cy - ay),       # rear left top
            "rrt": (cx + W + ax, cy - ay),    # rear right top
        }

        def pt(k): return f"{p[k][0]:.2f},{p[k][1]:.2f}"
        lc = self.LINE_COLOR
        sw = "0.6"

        lines = [
            # Front face
            f'<polygon points="{pt("fl")} {pt("fr")} {pt("frt")} {pt("flt")}" '
            f'fill="#f0f0f0" stroke="{lc}" stroke-width="{sw}"/>',
            # Top face
            f'<polygon points="{pt("flt")} {pt("frt")} {pt("rrt")} {pt("rlt")}" '
            f'fill="#e0e0e0" stroke="{lc}" stroke-width="{sw}"/>',
            # Right face
            f'<polygon points="{pt("fr")} {pt("frt")} {pt("rrt")}" '
            f'fill="#d0d0d0" stroke="{lc}" stroke-width="{sw}"/>',
        ]
        return "\n".join(lines)

    # -------------------------------------------------------------------
    # Dimension line helpers
    # -------------------------------------------------------------------

    def _dim_horizontal(self, x: float, y: float, length: float, value_mm: float,
                         offset_factor: float = 1.5, style: str = "outer",
                         above: bool = False) -> str:
        """Draw a horizontal dimension line below (or above) a view."""
        off = self.DIM_OFFSET * offset_factor * (-1 if above else 1)
        dy = y + off
        dc = self.dim_color
        ars = self.DIM_ARROW_SIZE

        label = self._format_dim(value_mm)
        mid_x = x + length / 2

        return (
            # Extension lines
            f'<line x1="{x:.2f}" y1="{y:.2f}" x2="{x:.2f}" y2="{dy:.2f}" '
            f'stroke="{dc}" stroke-width="0.3"/>\n'
            f'<line x1="{x + length:.2f}" y1="{y:.2f}" x2="{x + length:.2f}" y2="{dy:.2f}" '
            f'stroke="{dc}" stroke-width="0.3"/>\n'
            # Dimension line with arrows
            f'<line x1="{x:.2f}" y1="{dy:.2f}" x2="{x + length:.2f}" y2="{dy:.2f}" '
            f'stroke="{dc}" stroke-width="0.4" '
            f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
            # Value
            f'<text x="{mid_x:.2f}" y="{dy - 1.5:.2f}" '
            f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle">'
            f'{label[0]}</text>\n'
            + (
                f'<text x="{mid_x:.2f}" y="{dy + self.FONT_SIZE_DIM_IMP + 0.5:.2f}" '
                f'font-size="{self.FONT_SIZE_DIM_IMP:.1f}" fill="{dc}" '
                f'font-style="italic" text-anchor="middle">{label[1]}</text>\n'
                if label[1] else ""
            )
        )

    def _dim_vertical(self, x: float, y: float, length: float, value_mm: float,
                       side: str = "left") -> str:
        """Draw a vertical dimension line to the left or right of a view."""
        off = self.DIM_OFFSET * 1.5 * (-1 if side == "left" else 1)
        dx = x + off
        dc = self.dim_color
        label = self._format_dim(value_mm)
        mid_y = y + length / 2

        return (
            f'<line x1="{x:.2f}" y1="{y:.2f}" x2="{dx:.2f}" y2="{y:.2f}" '
            f'stroke="{dc}" stroke-width="0.3"/>\n'
            f'<line x1="{x:.2f}" y1="{y + length:.2f}" x2="{dx:.2f}" y2="{y + length:.2f}" '
            f'stroke="{dc}" stroke-width="0.3"/>\n'
            f'<line x1="{dx:.2f}" y1="{y:.2f}" x2="{dx:.2f}" y2="{y + length:.2f}" '
            f'stroke="{dc}" stroke-width="0.4" '
            f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
            f'<text x="{dx - 1.5:.2f}" y="{mid_y:.2f}" '
            f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle" '
            f'transform="rotate(-90 {dx - 1.5:.2f} {mid_y:.2f})">'
            f'{label[0]}</text>\n'
            + (
                f'<text x="{dx + self.FONT_SIZE_DIM_IMP:.2f}" y="{mid_y:.2f}" '
                f'font-size="{self.FONT_SIZE_DIM_IMP:.1f}" fill="{dc}" '
                f'font-style="italic" text-anchor="middle" '
                f'transform="rotate(-90 {dx + self.FONT_SIZE_DIM_IMP:.2f} {mid_y:.2f})">'
                f'{label[1]}</text>\n'
                if label[1] else ""
            )
        )

    def _dim_chain_horizontal(self, ox: float, oy: float, scale: float,
                               chain: list[float]) -> str:
        """Draw a horizontal dimension chain (e.g. 113-200-200-113)."""
        parts = []
        dc = self.dim_color
        dy = oy + self.DIM_OFFSET * 2.5
        cx = ox
        for seg in chain:
            sw = seg * scale
            label = self._format_dim(seg)
            mid_x = cx + sw / 2
            parts.append(
                f'<line x1="{cx:.2f}" y1="{oy:.2f}" x2="{cx:.2f}" y2="{dy:.2f}" '
                f'stroke="{dc}" stroke-width="0.3"/>\n'
                f'<line x1="{cx:.2f}" y1="{dy:.2f}" x2="{cx + sw:.2f}" y2="{dy:.2f}" '
                f'stroke="{dc}" stroke-width="0.4" '
                f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
                f'<text x="{mid_x:.2f}" y="{dy - 1.5:.2f}" '
                f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle">'
                f'{label[0]}</text>\n'
            )
            cx += sw
        # Close last extension line
        parts.append(
            f'<line x1="{cx:.2f}" y1="{oy:.2f}" x2="{cx:.2f}" y2="{dy:.2f}" '
            f'stroke="{dc}" stroke-width="0.3"/>'
        )
        return "\n".join(parts)

    def _dim_chain_vertical(self, ox: float, oy: float, scale: float,
                             chain: list[float]) -> str:
        """Draw a vertical dimension chain on the side view."""
        parts = []
        dc = self.dim_color
        dx = ox + self.DIM_OFFSET * 2.5
        cy = oy
        for seg in chain:
            sh = seg * scale
            label = self._format_dim(seg)
            mid_y = cy + sh / 2
            parts.append(
                f'<line x1="{ox:.2f}" y1="{cy:.2f}" x2="{dx:.2f}" y2="{cy:.2f}" '
                f'stroke="{dc}" stroke-width="0.3"/>\n'
                f'<line x1="{dx:.2f}" y1="{cy:.2f}" x2="{dx:.2f}" y2="{cy + sh:.2f}" '
                f'stroke="{dc}" stroke-width="0.4" '
                f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
                f'<text x="{dx + 1.5:.2f}" y="{mid_y:.2f}" '
                f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle" '
                f'transform="rotate(90 {dx + 1.5:.2f} {mid_y:.2f})">'
                f'{label[0]}</text>\n'
            )
            cy += sh
        parts.append(
            f'<line x1="{ox:.2f}" y1="{cy:.2f}" x2="{dx:.2f}" y2="{cy:.2f}" '
            f'stroke="{dc}" stroke-width="0.3"/>'
        )
        return "\n".join(parts)

    def _render_datablock(self) -> str:
        """Render the bottom data block / title block."""
        db = self.datablock
        x = self.MARGIN
        y = self.sheet_h - self.DATABLOCK_H - self.MARGIN / 2
        w = self.sheet_w - self.MARGIN * 2
        h = self.DATABLOCK_H
        bg = self.DATABLOCK_BG
        lc = self.LINE_COLOR
        fs = self.FONT_SIZE_LABEL

        parts = [
            f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
            f'fill="{bg}" stroke="{lc}" stroke-width="0.4"/>',
        ]

        # Left section: model name + number
        parts.append(
            f'<text x="{x + 3:.1f}" y="{y + 7:.1f}" '
            f'font-size="{self.FONT_SIZE_TITLE:.1f}" fill="{lc}" font-weight="bold">'
            f'{db.get("display_name", db.get("model_number", ""))} '
            f'— {db.get("model_number", "")}</text>'
        )

        # Second row: standard fields
        standard = [
            ("Units", db.get("units_note", "mm (in)")),
            ("Scale", db.get("scale", "NTS")),
            ("Rev", db.get("revision", "—")),
            ("Date", db.get("drawing_date", "—")),
            ("By", db.get("drawn_by", "—")),
        ]
        col_x = x + 3
        row_y = y + 13
        for label, val in standard:
            parts.append(
                f'<text x="{col_x:.1f}" y="{row_y:.1f}" '
                f'font-size="{fs:.1f}" fill="{lc}">'
                f'<tspan font-weight="bold">{label}: </tspan>{val}</text>'
            )
            col_x += 45

        # Right section: custom fields
        custom = db.get("custom_fields", {})
        if custom:
            cx = self.sheet_w - self.MARGIN - 3
            cy = y + 7
            for k, v in list(custom.items())[:4]:
                parts.append(
                    f'<text x="{cx:.1f}" y="{cy:.1f}" '
                    f'font-size="{fs:.1f}" fill="{lc}" text-anchor="end">'
                    f'<tspan font-weight="bold">{k}: </tspan>{v}</text>'
                )
                cy += 5

        # Company name top-right corner
        parts.append(
            f'<text x="{self.sheet_w - self.MARGIN - 3:.1f}" y="{y + 13:.1f}" '
            f'font-size="{fs + 0.5:.1f}" fill="{lc}" text-anchor="end" font-weight="bold">'
            f'{db.get("company", "")}</text>'
        )

        return "\n".join(parts)

    # -------------------------------------------------------------------
    # Formatting helpers
    # -------------------------------------------------------------------

    def _format_dim(self, value_mm: float) -> tuple[str, str]:
        """Returns (metric_label, imperial_label). Imperial in italic smaller."""
        metric = f"{value_mm:.2f}" if value_mm < 10 else f"{value_mm:.1f}"
        imperial = f"({value_mm * MM_TO_IN:.3f}\")" if value_mm > 1 else ""
        return metric, imperial

    def _atan_deg(self, w: float, h: float) -> float:
        return math.degrees(math.atan2(h, w))


# ---------------------------------------------------------------------------
# Export helpers
# ---------------------------------------------------------------------------

def _svg_to_png(svg_content: str, out_path: Path, dpi: int = 150):
    """Convert SVG to PNG using cairosvg."""
    try:
        import cairosvg
        cairosvg.svg2png(
            bytestring=svg_content.encode("utf-8"),
            write_to=str(out_path),
            dpi=dpi,
        )
        logger.info(f"PNG written: {out_path.name}")
    except ImportError:
        logger.warning("cairosvg not installed — PNG export skipped. pip install cairosvg")
    except Exception as e:
        logger.error(f"PNG export failed: {e}")


def _svg_to_pdf(svg_content: str, out_path: Path):
    """Convert SVG to PDF using cairosvg."""
    try:
        import cairosvg
        cairosvg.svg2pdf(
            bytestring=svg_content.encode("utf-8"),
            write_to=str(out_path),
        )
        logger.info(f"PDF written: {out_path.name}")
    except ImportError:
        logger.warning("cairosvg not installed — PDF export skipped.")
    except Exception as e:
        logger.error(f"PDF export failed: {e}")


def _render_individual_views(model, step_path, views, selected_parts, dpi) -> dict:
    """Render individual PNG views via the renderer module."""
    from .renderer import render_views
    view_map = {
        "front": "front", "rear": "right",  # approximate mappings
        "left": "left", "right": "right",
        "isometric_front": "iso_left", "isometric_rear": "iso_right",
        "top": "top", "bottom": "bottom",
    }
    render_views_list = list({view_map[v] for v in views if v in view_map})
    paths = render_views(model, step_path, render_views_list, width=1024, height=768)
    return {v: str(p) for v, p in zip(render_views_list, paths)}


# ---------------------------------------------------------------------------
# Geometry analysis helpers
# ---------------------------------------------------------------------------

def _compute_bounding_box(model: StepModel, selected_parts: list) -> dict:
    try:
        if model.backend == "build123d":
            bb = model.shape.bounding_box()
            return {
                "x_min": round(bb.min.X, 2), "x_max": round(bb.max.X, 2),
                "y_min": round(bb.min.Y, 2), "y_max": round(bb.max.Y, 2),
                "z_min": round(bb.min.Z, 2), "z_max": round(bb.max.Z, 2),
            }
        else:
            bb = model.shape.BoundBox
            return {
                "x_min": round(bb.XMin, 2), "x_max": round(bb.XMax, 2),
                "y_min": round(bb.YMin, 2), "y_max": round(bb.YMax, 2),
                "z_min": round(bb.ZMin, 2), "z_max": round(bb.ZMax, 2),
            }
    except Exception as e:
        logger.warning(f"Bounding box computation failed: {e}")
        return {"x_min": 0, "x_max": 0, "y_min": 0, "y_max": 0, "z_min": 0, "z_max": 0}


def _detect_active_area(model: StepModel, selected_parts: list) -> dict | None:
    """
    Detect the largest rectangular recessed face or cutout on the front face.
    Used for screen aperture / display area annotation.
    """
    try:
        if model.backend == "build123d":
            from OCC.Core.BRepGProp import BRepGProp
            from OCC.Core.GProp import GProp_GProps
            from OCC.Core.GeomAdaptor import GeomAdaptor_Surface
            from OCC.Core.GeomAbs import GeomAbs_Plane

            best_area = 0
            best_face_data = None
            overall_bb = _compute_bounding_box(model, selected_parts)
            front_y = overall_bb["y_min"]

            for face in model.shape.faces():
                try:
                    from OCC.Core.BRep import BRep_Tool
                    surf = BRep_Tool.Surface_s(face.wrapped)
                    adaptor = GeomAdaptor_Surface(surf)
                    if adaptor.GetType() != GeomAbs_Plane:
                        continue
                    props = GProp_GProps()
                    BRepGProp.SurfaceProperties_s(face.wrapped, props)
                    area = props.Mass()
                    if area > best_area:
                        fb = face.bounding_box()
                        # Must be on a face roughly perpendicular to Y (front-facing)
                        # and smaller than overall enclosure (it's a recess, not the face itself)
                        face_w = fb.size.X
                        face_h = fb.size.Z
                        overall_w = overall_bb["x_max"] - overall_bb["x_min"]
                        overall_h = overall_bb["z_max"] - overall_bb["z_min"]
                        if face_w < overall_w * 0.98 and face_h < overall_h * 0.98 and area > 100:
                            best_area = area
                            diag = math.sqrt(face_w**2 + face_h**2)
                            best_face_data = {
                                "width": round(face_w, 2),
                                "height": round(face_h, 2),
                                "diagonal": round(diag, 2),
                                "diagonal_inches": round(diag * MM_TO_IN, 1),
                                "offset_left": round(fb.min.X - overall_bb["x_min"], 2),
                                "offset_top":  round(overall_bb["z_max"] - fb.max.Z, 2),
                                "detection_confidence": "medium",
                            }
                except Exception:
                    continue
            return best_face_data
    except Exception as e:
        logger.warning(f"Active area detection error: {e}")
    return None


def _detect_mounting_dimensions(model: StepModel, selected_parts: list) -> dict | None:
    """Detect mounting hole pattern from cylindrical faces."""
    from .query_engine import _extract_holes_build123d, _extract_holes_freecad

    try:
        if model.backend == "build123d":
            holes = _extract_holes_build123d(model.shape)
        else:
            holes = _extract_holes_freecad(model.shape)

        if not holes:
            return None

        # Filter to likely mounting holes: small diameter (M3-M8 range = 3-8.5mm)
        mount_holes = [h for h in holes if 2.5 <= h.get("diameter", 0) <= 9.0]
        if not mount_holes:
            mount_holes = holes  # use all if none in range

        positions = [h["position"] for h in mount_holes]
        if len(positions) < 2:
            return None

        # Compute x and z extents of hole centers
        xs = sorted(set(round(p[0], 1) for p in positions))
        zs = sorted(set(round(p[2], 1) for p in positions))

        # Build spacing chains
        def chain(vals):
            if len(vals) < 2:
                return None
            return [round(vals[i+1] - vals[i], 2) for i in range(len(vals)-1)]

        chain_x = chain(xs)
        chain_z = chain(zs)

        # VESA detection
        vesa = None
        if chain_x and chain_z:
            sx = sum(chain_x)
            sz = sum(chain_z)
            for std in [75, 100, 200, 300, 400, 600, 800]:
                if abs(sx - std) < 3 and abs(sz - std) < 3:
                    vesa = f"VESA {std}x{std}"
                    break

        # Offsets from bounding box
        bb = _compute_bounding_box(model, selected_parts)
        first_x = xs[0] - bb["x_min"]
        last_x  = bb["x_max"] - xs[-1]
        first_z = bb["z_max"] - zs[-1]
        last_z  = zs[0] - bb["z_min"]

        return {
            "pattern_type": "vesa" if vesa else ("linear" if len(zs) == 1 or len(xs) == 1 else "grid"),
            "vesa_standard": vesa,
            "hole_diameter": round(mount_holes[0].get("diameter", 0), 2),
            "hole_count": len(mount_holes),
            "spacing_x": round(sum(chain_x) / len(chain_x), 2) if chain_x else None,
            "spacing_y": round(sum(chain_z) / len(chain_z), 2) if chain_z else None,
            "spacing_chain_x": chain_x,
            "spacing_chain_y": chain_z,
            "offset_from_left":   round(first_x, 2),
            "offset_from_right":  round(last_x, 2),
            "offset_from_top":    round(first_z, 2),
            "offset_from_bottom": round(last_z, 2),
        }
    except Exception as e:
        logger.warning(f"Mounting dimension detection error: {e}")
        return None


def _detect_mounting_variants(model: StepModel, mapping: dict | None) -> list:
    if not mapping:
        return []
    return mapping.get("mounting_variants") or []


def _select_parts(model: StepModel, mode: str, mapping: dict | None, variant: str | None) -> tuple:
    """Return (selected_parts, excluded_parts) based on mode and mapping."""
    from .bom import extract_bom
    bom = extract_bom(model)
    all_parts = bom["part_name_original"].tolist()

    if not mapping:
        return all_parts, []

    parts_map = mapping.get("parts", {})
    selected, excluded = [], []

    for part in all_parts:
        cfg = parts_map.get(part, {})
        cat = cfg.get("category", "other")
        include_flag = cfg.get("include_in_diagram", True)
        part_variant = cfg.get("mounting_variant")

        if not include_flag:
            excluded.append(part)
            continue

        if mode == "enclosure_only" and cat != "enclosure":
            excluded.append(part)
        elif mode == "mounting_only" and cat not in ("mounting",):
            excluded.append(part)
        elif mode == "enclosure_plus_mounting":
            if cat in ("internal", "fastener") and not include_flag:
                excluded.append(part)
            elif cat == "mounting" and variant and part_variant and part_variant != variant:
                excluded.append(part)
            else:
                selected.append(part)
        else:
            selected.append(part)

    return selected, excluded


# ---------------------------------------------------------------------------
# Auto-selection helpers
# ---------------------------------------------------------------------------

def _auto_style(model, w, h, d) -> str:
    """line_drawing for simple enclosures, rendered for complex assemblies."""
    try:
        face_count = sum(1 for _ in model.shape.faces()) if model.backend == "build123d" else 0
        if face_count > 200:
            return "rendered"
    except Exception:
        pass
    return "line_drawing"


def _auto_layout(w, h, d, model) -> str:
    """Multi-page for very large or complex models."""
    if max(w, h, d) > MULTIPAGE_THRESHOLD_MM:
        return "multi_page"
    return "single_sheet"


def _auto_sheet(w, h, d, layout_mode) -> str:
    """Select sheet size based on model aspect ratio."""
    ratio = max(w, d) / max(h, 1)
    if ratio > 3.5:
        return "A3_landscape"
    if ratio > 2:
        return "A3_landscape"
    if h > w:
        return "A4_portrait"
    return "A4_landscape"


def _auto_views(w, h, d, mode) -> list:
    """Select view set. Always include front, side, rear, iso."""
    views = ["front", "isometric_front"]
    if d > 30:
        views.append("left")
    views.append("rear")
    if h > w * 0.8:  # roughly square or portrait — add top
        views.append("top")
    else:
        views.append("bottom")
    return views


def _auto_dim_style(w, h, mounting_dims) -> str:
    if mounting_dims and mounting_dims.get("spacing_chain_x"):
        return "chain"
    return "baseline"


# ---------------------------------------------------------------------------
# Datablock loader
# ---------------------------------------------------------------------------

def _load_datablock(datablock_file: Path | None, step_path: Path) -> dict:
    """Parse the datablock MD file or return minimal defaults."""
    result = {
        "model_number": step_path.stem,
        "display_name": step_path.stem,
        "revision": "A",
        "drawing_date": datetime.now().strftime("%Y-%m-%d"),
        "drawn_by": "",
        "company": "MPMedia",
        "units_note": "Dimensions in mm (in)",
        "scale": "NTS",
        "custom_fields": {},
    }

    if not datablock_file:
        # Look for a matching MD file alongside the STEP file
        candidate = step_path.with_suffix(".md")
        if candidate.exists():
            datablock_file = candidate

    if datablock_file:
        try:
            text = Path(datablock_file).read_text(encoding="utf-8")
            for line in text.splitlines():
                line = line.strip()
                if line.startswith("<!--") or not line or line.startswith("#"):
                    continue
                if ":" in line:
                    k, _, v = line.partition(":")
                    k, v = k.strip(), v.strip()
                    std_keys = {"model_number", "display_name", "revision", "drawing_date",
                                "drawn_by", "company", "units_note", "scale"}
                    if k in std_keys:
                        result[k] = v
                    elif k and v:
                        result["custom_fields"][k] = v
        except Exception as e:
            logger.warning(f"Datablock file parse error: {e}")

    return result


def _load_mapping(mapping_file: Path) -> dict | None:
    try:
        return json.loads(Path(mapping_file).read_text(encoding="utf-8"))
    except Exception as e:
        logger.warning(f"Mapping file load error: {e}")
        return None
