#!/usr/bin/env python3
"""Local character-image segmentation and SVG vectorization pipeline.

This script is intentionally local-only. It does not upload images, does not
call a remote API, and does not include model weights. If a Segment Anything
checkpoint is available, it uses SAM for masks; otherwise it falls back to a
small color-clustering segmentation pass. If vtracer is installed, each layer
PNG is vectorized to SVG.

Example:
  python sam_vtracer_pipeline.py character.png --out out/assets --sam-checkpoint sam_vit_h_4b8939.pth
  python sam_vtracer_pipeline.py character.png --out out/assets --fallback-colors 8

Optional dependencies:
  pip install pillow numpy opencv-python vtracer
  pip install git+https://github.com/facebookresearch/segment-anything.git
  # Install torch according to your machine/GPU from https://pytorch.org/
"""

from __future__ import annotations

import argparse
import json
import math
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np
from PIL import Image


@dataclass
class Layer:
    index: int
    name: str
    color: tuple[int, int, int]
    area: int
    png_path: Path
    svg_path: Path | None = None


def load_rgba(path: Path, matte: tuple[int, int, int]) -> Image.Image:
    image = Image.open(path).convert("RGBA")
    if image.getextrema()[3] == (255, 255):
        return image
    background = Image.new("RGBA", image.size, (*matte, 255))
    background.alpha_composite(image)
    return background


def resize_for_work(image: Image.Image, max_side: int) -> Image.Image:
    width, height = image.size
    scale = min(1.0, max_side / max(width, height))
    if scale >= 1.0:
        return image
    return image.resize((max(1, round(width * scale)), max(1, round(height * scale))), Image.Resampling.LANCZOS)


def kmeans_palette(rgb: np.ndarray, k: int, sample_cap: int = 24000, iterations: int = 10) -> np.ndarray:
    pixels = rgb.reshape((-1, 3)).astype(np.float32)
    if len(pixels) > sample_cap:
        stride = max(1, len(pixels) // sample_cap)
        samples = pixels[::stride]
    else:
        samples = pixels
    order = np.argsort(samples.sum(axis=1))
    seeds = []
    for i in range(k):
        idx = order[min(len(order) - 1, math.floor(((i + 0.5) / k) * len(order)))]
        seeds.append(samples[idx])
    centers = np.vstack(seeds)

    for _ in range(iterations):
        distances = ((samples[:, None, :] - centers[None, :, :]) ** 2).sum(axis=2)
        labels = distances.argmin(axis=1)
        for i in range(k):
            group = samples[labels == i]
            if len(group) > 0:
                centers[i] = group.mean(axis=0)
    return np.clip(np.round(centers), 0, 255).astype(np.uint8)


def fallback_masks(image: Image.Image, k: int, min_area: int) -> list[dict[str, Any]]:
    rgb = np.array(image.convert("RGB"))
    height, width = rgb.shape[:2]
    centers = kmeans_palette(rgb, k)
    pixels = rgb.reshape((-1, 3)).astype(np.int16)
    distances = ((pixels[:, None, :] - centers[None, :, :].astype(np.int16)) ** 2).sum(axis=2)
    labels = distances.argmin(axis=1).reshape((height, width))
    masks: list[dict[str, Any]] = []
    for idx, color in enumerate(centers):
        mask = labels == idx
        area = int(mask.sum())
        if area >= min_area:
            masks.append({"segmentation": mask, "area": area, "color": tuple(int(v) for v in color)})
    masks.sort(key=lambda item: int(item["area"]), reverse=True)
    return masks


def sam_masks(image: Image.Image, checkpoint: Path, model_type: str, min_area: int) -> list[dict[str, Any]]:
    try:
        import torch
        from segment_anything import SamAutomaticMaskGenerator, sam_model_registry
    except Exception as exc:  # pragma: no cover - optional dependency path
        raise RuntimeError("SAM dependencies are not installed. Use --fallback-colors or install segment-anything and torch.") from exc

    device = "cuda" if torch.cuda.is_available() else "cpu"
    sam = sam_model_registry[model_type](checkpoint=str(checkpoint))
    sam.to(device=device)
    generator = SamAutomaticMaskGenerator(
        sam,
        points_per_side=24,
        pred_iou_thresh=0.88,
        stability_score_thresh=0.9,
        min_mask_region_area=min_area,
    )
    rgb = np.array(image.convert("RGB"))
    masks = generator.generate(rgb)
    masks = [mask for mask in masks if int(mask.get("area", 0)) >= min_area]
    masks.sort(key=lambda item: int(item["area"]), reverse=True)
    return masks


def dominant_color(rgb: np.ndarray, mask: np.ndarray) -> tuple[int, int, int]:
    pixels = rgb[mask]
    if len(pixels) == 0:
        return (0, 0, 0)
    color = np.median(pixels, axis=0)
    return tuple(int(v) for v in np.clip(np.round(color), 0, 255))


def save_layers(image: Image.Image, masks: list[dict[str, Any]], out_dir: Path, min_area: int) -> list[Layer]:
    rgb = np.array(image.convert("RGB"))
    rgba = np.array(image.convert("RGBA"))
    layers: list[Layer] = []
    occupied = np.zeros(rgb.shape[:2], dtype=bool)

    for idx, item in enumerate(masks, start=1):
        mask = np.asarray(item["segmentation"], dtype=bool) & ~occupied
        area = int(mask.sum())
        if area < min_area:
            continue
        occupied |= mask
        color = tuple(item.get("color") or dominant_color(rgb, mask))
        layer_rgba = np.zeros_like(rgba)
        layer_rgba[mask] = rgba[mask]
        png_path = out_dir / f"layer-{idx:02d}-{color[0]:02x}{color[1]:02x}{color[2]:02x}.png"
        Image.fromarray(layer_rgba, "RGBA").save(png_path)
        layers.append(Layer(index=idx, name=f"layer-{idx:02d}", color=color, area=area, png_path=png_path))
    return layers


def vectorize_layers(layers: list[Layer]) -> None:
    try:
        import vtracer
    except Exception:
        return

    for layer in layers:
        svg_path = layer.png_path.with_suffix(".svg")
        vtracer.convert_image_to_svg_py(
            str(layer.png_path),
            str(svg_path),
            colormode="color",
            hierarchical="stacked",
            mode="spline",
            filter_speckle=8,
            color_precision=6,
            layer_difference=16,
            corner_threshold=60,
            length_threshold=4.0,
            max_iterations=10,
            splice_threshold=45,
            path_precision=3,
        )
        layer.svg_path = svg_path


def write_manifest(source: Path, image: Image.Image, layers: list[Layer], out_dir: Path, used_sam: bool) -> None:
    manifest = {
        "source": str(source),
        "width": image.width,
        "height": image.height,
        "segmentation": "sam" if used_sam else "color-kmeans-fallback",
        "privacy": "All processing was local. No server upload or API call was made by this script.",
        "layers": [
            {
                "index": layer.index,
                "name": layer.name,
                "color": "#{:02x}{:02x}{:02x}".format(*layer.color),
                "area_px": layer.area,
                "png": layer.png_path.name,
                "svg": layer.svg_path.name if layer.svg_path else None,
            }
            for layer in layers
        ],
    }
    (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")


def parse_hex_color(value: str) -> tuple[int, int, int]:
    clean = value.strip().lstrip("#")
    if len(clean) != 6:
        raise argparse.ArgumentTypeError("Use a 6-digit hex color such as #ffffff")
    return (int(clean[0:2], 16), int(clean[2:4], 16), int(clean[4:6], 16))


def main() -> int:
    parser = argparse.ArgumentParser(description="Local SAM/vtracer character image to animation asset pipeline.")
    parser.add_argument("image", type=Path)
    parser.add_argument("--out", type=Path, default=Path("animation-assets-out"))
    parser.add_argument("--max-side", type=int, default=1400)
    parser.add_argument("--min-area", type=int, default=160)
    parser.add_argument("--matte", type=parse_hex_color, default=(255, 255, 255))
    parser.add_argument("--sam-checkpoint", type=Path)
    parser.add_argument("--sam-model-type", default="vit_h", choices=("vit_h", "vit_l", "vit_b"))
    parser.add_argument("--fallback-colors", type=int, default=8)
    args = parser.parse_args()

    if not args.image.exists():
        print(f"Input image not found: {args.image}", file=sys.stderr)
        return 2

    args.out.mkdir(parents=True, exist_ok=True)
    image = resize_for_work(load_rgba(args.image, args.matte), args.max_side)

    used_sam = False
    if args.sam_checkpoint and args.sam_checkpoint.exists():
        masks = sam_masks(image, args.sam_checkpoint, args.sam_model_type, args.min_area)
        used_sam = True
    else:
        masks = fallback_masks(image, max(3, args.fallback_colors), args.min_area)

    layers = save_layers(image, masks, args.out, args.min_area)
    vectorize_layers(layers)
    write_manifest(args.image, image, layers, args.out, used_sam)

    print(f"Wrote {len(layers)} layers to {args.out}")
    if not any(layer.svg_path for layer in layers):
        print("vtracer was not available or produced no SVG files; PNG layers and manifest were still written.")
    return 0


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