"""Spike — contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.

Forgejo issue #299 (Phase 1 exploration SPIKE). **This is a throwaway probe, NOT
production wiring**: it answers one question — is Potrace contour tracing good
enough to turn floor-plan rasters into compact, readable SVG for the planning UI
catalog and PDF reports? No ML, no room semantics, no DOM.RF coupling.

WHAT IT MEASURES
----------------
For each input raster the pipeline runs:

  1. load (Pillow) → grayscale (``L``)
  2. binarise: pixels below ``--threshold`` (0-255) become ink (black), the rest
     become paper (white). Optional ``--invert`` for light-on-dark plans.
  3. write a 1-bit PBM bitmap (Potrace's native input) to the output dir
  4. ``potrace -s`` → SVG (centerline-free, filled-contour tracing)
  5. record size metrics: raster bytes, svg bytes, ratio (raster / svg)

It also (optionally, ``--render-back``) rasterises the produced SVG back to PNG
via ``rsvg-convert`` so a human can eyeball SVG-vs-original side by side and
judge whether wall/room contours survived the round trip.

WHY POTRACE IS SOURCE-AGNOSTIC
------------------------------
Potrace traces the boundary between ink and paper regions of a bitmap. It does
not care where the raster came from — a Wikimedia line-drawing plan, a scanned
blueprint, or a DOM.RF UI tile all reduce to "dark strokes on a light field"
after thresholding. So the Phase 1 question ("are the contours traceable and how
much do they compress?") is validly answered on any representative floor-plan
rasters; see the spike doc for the explicit DOM.RF caveat.

USAGE
-----
    # vectorise every raster in a folder, collect a metrics table
    uv run python backend/scripts/spike_plan_vectorize.py \
        --in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out

    # also render the SVGs back to PNG for visual QA
    uv run python backend/scripts/spike_plan_vectorize.py \
        --in-dir /tmp/plan-spike/in --out-dir /tmp/plan-spike/out --render-back

Requires the ``potrace`` binary on PATH (``brew install potrace``) and, for
``--render-back``, ``rsvg-convert`` (``brew install librsvg``). Pillow ships with
the backend deps.
"""

from __future__ import annotations

import argparse
import logging
import shutil
import statistics
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

from PIL import Image

logger = logging.getLogger("spike_plan_vectorize")

_RASTER_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"}
_POTRACE_TIMEOUT_S = 120
_RSVG_TIMEOUT_S = 120


@dataclass(frozen=True)
class VectorizeResult:
    """One raster's trip through the pipeline."""

    name: str
    raster_bytes: int
    svg_bytes: int
    svg_path: Path
    rendered_path: Path | None

    @property
    def ratio(self) -> float:
        """Compression ratio raster / svg (>1 means SVG is smaller)."""
        return self.raster_bytes / self.svg_bytes if self.svg_bytes else 0.0

    def metrics_line(self) -> str:
        return (
            f"{self.name:<44} "
            f"raster={self.raster_bytes:>9}B  "
            f"svg={self.svg_bytes:>8}B  "
            f"ratio={self.ratio:>6.2f}x"
        )


def _require_binary(name: str) -> str:
    """Resolve an external binary on PATH or exit with a clear message."""
    path = shutil.which(name)
    if path is None:
        logger.error("required binary %r not found on PATH", name)
        raise SystemExit(f"{name!r} not found — install it (e.g. `brew install {name}`) and retry")
    return path


def raster_to_pbm(src: Path, pbm_path: Path, *, threshold: int, invert: bool) -> None:
    """Load a raster, grayscale + threshold it, and write a 1-bit PBM bitmap.

    Potrace consumes 1-bit bitmaps (PBM/PGM/BMP). We binarise with a fixed
    threshold so the spike's behaviour is deterministic and explainable — black
    (ink) is what Potrace traces, white is background. ``invert`` flips the test
    for light-stroke-on-dark plans.
    """
    with Image.open(src) as im:
        gray = im.convert("L")
    # point(): pixel < threshold → ink (0), else paper (255). Pillow's "1" mode
    # then packs to a true 1-bit bitmap that Potrace reads natively.
    cutoff = threshold
    if invert:
        bitmap = gray.point(lambda px: 0 if px >= cutoff else 255).convert("1")
    else:
        bitmap = gray.point(lambda px: 0 if px < cutoff else 255).convert("1")
    bitmap.save(pbm_path)


def pbm_to_svg(potrace_bin: str, pbm_path: Path, svg_path: Path) -> None:
    """Trace a PBM bitmap to SVG with ``potrace -s`` (SVG backend)."""
    try:
        subprocess.run(
            [potrace_bin, "-s", "-o", str(svg_path), str(pbm_path)],
            check=True,
            capture_output=True,
            timeout=_POTRACE_TIMEOUT_S,
        )
    except subprocess.CalledProcessError as exc:
        stderr = exc.stderr.decode("utf-8", "replace").strip()
        logger.error("potrace failed for %s: %s", pbm_path.name, stderr)
        raise


def svg_to_png(rsvg_bin: str, svg_path: Path, png_path: Path, *, width: int) -> None:
    """Render an SVG back to PNG via ``rsvg-convert`` for visual QA.

    We composite onto an explicit white background. Potrace's SVG is black
    filled paths on a *transparent* canvas; without ``--background-color=white``
    rsvg renders the black fill onto transparency and a flattening viewer shows
    a solid black tile. White matches the real catalog/PDF use case anyway.
    """
    try:
        subprocess.run(
            [
                rsvg_bin,
                "-w",
                str(width),
                "--background-color=white",
                "-o",
                str(png_path),
                str(svg_path),
            ],
            check=True,
            capture_output=True,
            timeout=_RSVG_TIMEOUT_S,
        )
    except subprocess.CalledProcessError as exc:
        stderr = exc.stderr.decode("utf-8", "replace").strip()
        logger.error("rsvg-convert failed for %s: %s", svg_path.name, stderr)
        raise


def vectorize_one(
    src: Path,
    out_dir: Path,
    *,
    potrace_bin: str,
    rsvg_bin: str | None,
    threshold: int,
    invert: bool,
    render_width: int,
) -> VectorizeResult:
    """Run the full PNG/JPG → PBM → SVG (→ PNG) pipeline for a single raster."""
    stem = src.stem
    pbm_path = out_dir / f"{stem}.pbm"
    svg_path = out_dir / f"{stem}.svg"

    raster_to_pbm(src, pbm_path, threshold=threshold, invert=invert)
    pbm_to_svg(potrace_bin, pbm_path, svg_path)

    rendered_path: Path | None = None
    if rsvg_bin is not None:
        rendered_path = out_dir / f"{stem}.rendered.png"
        svg_to_png(rsvg_bin, svg_path, rendered_path, width=render_width)

    return VectorizeResult(
        name=src.name,
        raster_bytes=src.stat().st_size,
        svg_bytes=svg_path.stat().st_size,
        svg_path=svg_path,
        rendered_path=rendered_path,
    )


def discover_rasters(in_dir: Path) -> list[Path]:
    """Return sorted raster files in ``in_dir`` (non-recursive)."""
    return sorted(
        p for p in in_dir.iterdir() if p.is_file() and p.suffix.lower() in _RASTER_SUFFIXES
    )


def summarise(results: list[VectorizeResult]) -> None:
    """Print a per-file metrics table plus min/median/max compression."""
    if not results:
        logger.warning("no results to summarise")
        return

    print("\n=== per-raster metrics ===")
    for r in results:
        print(r.metrics_line())

    ratios = sorted(r.ratio for r in results)
    print("\n=== compression summary ===")
    print(f"samples       : {len(ratios)}")
    print(f"min ratio     : {min(ratios):.2f}x")
    print(f"median ratio  : {statistics.median(ratios):.2f}x")
    print(f"max ratio     : {max(ratios):.2f}x")
    total_raster = sum(r.raster_bytes for r in results)
    total_svg = sum(r.svg_bytes for r in results)
    agg_ratio = total_raster / total_svg if total_svg else float("inf")
    print(
        f"aggregate     : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)"
    )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Spike: contour-vectorize floor-plan rasters (PNG/JPG → SVG) via Potrace.",
    )
    parser.add_argument(
        "--in-dir",
        type=Path,
        required=True,
        help="folder containing input rasters (PNG/JPG/...)",
    )
    parser.add_argument(
        "--out-dir",
        type=Path,
        required=True,
        help="folder for output .pbm/.svg (and .rendered.png with --render-back)",
    )
    parser.add_argument(
        "--threshold",
        type=int,
        default=128,
        help="grayscale ink/paper cutoff 0-255 (default 128)",
    )
    parser.add_argument(
        "--invert",
        action="store_true",
        help="treat light strokes on a dark field as ink",
    )
    parser.add_argument(
        "--render-back",
        action="store_true",
        help="rasterise each SVG back to PNG via rsvg-convert for visual QA",
    )
    parser.add_argument(
        "--render-width",
        type=int,
        default=900,
        help="width in px for --render-back output (default 900)",
    )
    return parser


def main(argv: list[str] | None = None) -> int:
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    args = build_parser().parse_args(argv)

    in_dir: Path = args.in_dir
    out_dir: Path = args.out_dir
    if not in_dir.is_dir():
        logger.error("input dir does not exist: %s", in_dir)
        return 2
    if not 0 <= args.threshold <= 255:
        logger.error("--threshold must be 0-255, got %d", args.threshold)
        return 2
    out_dir.mkdir(parents=True, exist_ok=True)

    potrace_bin = _require_binary("potrace")
    rsvg_bin = _require_binary("rsvg-convert") if args.render_back else None

    rasters = discover_rasters(in_dir)
    if not rasters:
        logger.error("no raster files found in %s", in_dir)
        return 1
    logger.info("found %d raster(s) in %s", len(rasters), in_dir)

    results: list[VectorizeResult] = []
    failed = 0
    for src in rasters:
        try:
            results.append(
                vectorize_one(
                    src,
                    out_dir,
                    potrace_bin=potrace_bin,
                    rsvg_bin=rsvg_bin,
                    threshold=args.threshold,
                    invert=args.invert,
                    render_width=args.render_width,
                )
            )
            logger.info("vectorized %s", src.name)
        except Exception:
            # Log + continue: one bad raster must not abort the batch, but we
            # never swallow silently — the traceback is recorded and the file
            # is counted as a failure in the final tally.
            failed += 1
            logger.exception("failed to vectorize %s", src.name)

    summarise(results)
    if failed:
        logger.warning("%d/%d raster(s) failed", failed, len(rasters))
    return 0 if results else 1


if __name__ == "__main__":
    sys.exit(main())
