#!/usr/bin/env python3
"""Combine per-material Substance exports with a Blender Material ID map.

The companion Blender extension writes a PNG and JSON legend.  This tool uses
those exact ID colors as masks, discovers material names in Substance Painter
filenames, and combines matching channels without trusting texture alpha.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Sequence

try:
    from PIL import Image, ImageChops
except ImportError:  # pragma: no cover - exercised by the friendly CLI error.
    Image = None
    ImageChops = None


SUPPORTED_SUFFIXES = {
    ".bmp",
    ".jpeg",
    ".jpg",
    ".png",
    ".tga",
    ".tif",
    ".tiff",
    ".webp",
}
MATERIAL_TOKEN = "{material}"


class CombineError(RuntimeError):
    """A user-correctable texture discovery or composition error."""


@dataclass(frozen=True)
class LegendMaterial:
    slot: int
    name: str
    rgb: tuple[int, int, int]
    filename_name: str


@dataclass(frozen=True)
class TextureCandidate:
    path: Path
    material: LegendMaterial
    relative_parent: Path
    stem_pattern: str
    suffix: str


@dataclass
class TextureGroup:
    relative_parent: Path
    stem_pattern: str
    suffix: str
    textures: dict[str, Path]

    @property
    def output_name(self) -> str:
        stem = self.stem_pattern.replace(MATERIAL_TOKEN, "")
        stem = re.sub(r"[\s_.-]+", "_", stem).strip("_")
        return f"{stem or 'Combined'}{self.suffix.lower()}"

    @property
    def output_parent(self) -> Path:
        parts = []
        for part in self.relative_parent.parts:
            cleaned = part.replace(MATERIAL_TOKEN, "")
            cleaned = re.sub(r"[\s_.-]+", "_", cleaned).strip("_")
            if cleaned:
                parts.append(cleaned)
        return Path(*parts) if parts else Path(".")

    @property
    def description(self) -> str:
        parent = "" if self.relative_parent == Path(".") else f"{self.relative_parent}/"
        return f"{parent}{self.stem_pattern}{self.suffix}"


def _require_pillow() -> None:
    if Image is None or ImageChops is None:
        raise CombineError(
            "Pillow is required. Install it with "
            "'python -m pip install -r tools/requirements.txt'."
        )


def _parse_aliases(values: Sequence[str]) -> dict[str, str]:
    aliases: dict[str, str] = {}
    for value in values:
        if "=" not in value:
            raise CombineError(
                f"Invalid material alias {value!r}; expected 'LEGEND NAME=FILENAME NAME'."
            )
        legend_name, filename_name = (part.strip() for part in value.split("=", 1))
        if not legend_name or not filename_name:
            raise CombineError(f"Invalid material alias {value!r}; both names are required.")
        aliases[legend_name.casefold()] = filename_name
    return aliases


def _rgb8(value: Any, material_name: str) -> tuple[int, int, int]:
    if not isinstance(value, list) or len(value) < 3:
        raise CombineError(f"Legend material {material_name!r} has no valid RGB triplet.")
    try:
        components = tuple(float(component) for component in value[:3])
    except (TypeError, ValueError) as exc:
        raise CombineError(
            f"Legend material {material_name!r} has a non-numeric RGB value."
        ) from exc
    if any(component < 0.0 or component > 1.0 for component in components):
        raise CombineError(
            f"Legend material {material_name!r} has RGB values outside 0.0–1.0."
        )
    return tuple(round(component * 255.0) for component in components)


def load_legend(path: Path, aliases: dict[str, str] | None = None) -> list[LegendMaterial]:
    aliases = aliases or {}
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except OSError as exc:
        raise CombineError(f"Could not read legend {path}: {exc}") from exc
    except json.JSONDecodeError as exc:
        raise CombineError(f"Legend {path} is not valid JSON: {exc}") from exc

    entries = payload.get("materials") if isinstance(payload, dict) else None
    if not isinstance(entries, list) or not entries:
        raise CombineError(f"Legend {path} does not contain a non-empty 'materials' list.")

    materials: list[LegendMaterial] = []
    seen_names: set[str] = set()
    seen_colors: set[tuple[int, int, int]] = set()
    for fallback_slot, entry in enumerate(entries, start=1):
        if not isinstance(entry, dict):
            raise CombineError(f"Legend entry {fallback_slot} is not an object.")
        name = str(entry.get("material", "")).strip()
        if not name:
            raise CombineError(f"Legend entry {fallback_slot} has no material name.")
        folded_name = name.casefold()
        if folded_name in seen_names:
            raise CombineError(f"Legend contains duplicate material name {name!r}.")
        rgb = _rgb8(entry.get("rgb"), name)
        if rgb in seen_colors:
            raise CombineError(
                f"Legend color {rgb} is shared by multiple materials; masks would be ambiguous."
            )
        try:
            slot = int(entry.get("slot", fallback_slot))
        except (TypeError, ValueError) as exc:
            raise CombineError(f"Legend material {name!r} has an invalid slot.") from exc
        materials.append(
            LegendMaterial(
                slot=slot,
                name=name,
                rgb=rgb,
                filename_name=aliases.get(folded_name, name),
            )
        )
        seen_names.add(folded_name)
        seen_colors.add(rgb)
    return materials


def _name_pattern(name: str) -> re.Pattern[str]:
    tokens = re.findall(r"[A-Za-z0-9]+", name)
    if not tokens:
        raise CombineError(f"Material filename name {name!r} has no letters or digits.")
    expression = r"[\s_.-]*".join(re.escape(token) for token in tokens)
    return re.compile(
        rf"(?<![A-Za-z0-9]){expression}(?![A-Za-z0-9])",
        flags=re.IGNORECASE,
    )


def _match_candidate(
    path: Path,
    input_dir: Path,
    materials: Sequence[LegendMaterial],
) -> TextureCandidate | None:
    relative = path.relative_to(input_dir)
    searchable = [("stem", relative.stem)] + [
        (f"parent:{index}", part)
        for index, part in enumerate(relative.parent.parts)
    ]
    matches: list[
        tuple[int, int, LegendMaterial, re.Match[str], str]
    ] = []
    for material in materials:
        pattern = _name_pattern(material.filename_name)
        for location, text in searchable:
            match = pattern.search(text)
            if match:
                matches.append(
                    (
                        match.end() - match.start(),
                        len(material.filename_name),
                        material,
                        match,
                        location,
                    )
                )
    if not matches:
        return None

    _, _, material, match, location = max(
        matches,
        key=lambda item: (item[0], item[1], item[4] == "stem"),
    )
    stem_pattern = relative.stem
    parent_parts = list(relative.parent.parts)
    if location == "stem":
        stem_pattern = (
            f"{stem_pattern[:match.start()]}{MATERIAL_TOKEN}{stem_pattern[match.end():]}"
        )
    else:
        parent_index = int(location.split(":", 1)[1])
        parent_part = parent_parts[parent_index]
        parent_parts[parent_index] = (
            f"{parent_part[:match.start()]}{MATERIAL_TOKEN}{parent_part[match.end():]}"
        )
    relative_parent = Path(*parent_parts) if parent_parts else Path(".")
    return TextureCandidate(
        path=path,
        material=material,
        relative_parent=relative_parent,
        stem_pattern=stem_pattern,
        suffix=path.suffix,
    )


def discover_groups(
    input_dir: Path,
    materials: Sequence[LegendMaterial],
    recursive: bool = False,
    excluded_paths: Iterable[Path] = (),
) -> tuple[list[TextureGroup], list[Path]]:
    excluded = {path.resolve() for path in excluded_paths}
    iterator = input_dir.rglob("*") if recursive else input_dir.glob("*")
    files = sorted(
        path
        for path in iterator
        if path.is_file()
        and path.suffix.lower() in SUPPORTED_SUFFIXES
        and path.resolve() not in excluded
    )

    grouped: dict[tuple[Path, str, str], TextureGroup] = {}
    unmatched: list[Path] = []
    for path in files:
        candidate = _match_candidate(path, input_dir, materials)
        if candidate is None:
            unmatched.append(path)
            continue
        key = (
            candidate.relative_parent,
            candidate.stem_pattern.casefold(),
            candidate.suffix.casefold(),
        )
        group = grouped.setdefault(
            key,
            TextureGroup(
                relative_parent=candidate.relative_parent,
                stem_pattern=candidate.stem_pattern,
                suffix=candidate.suffix,
                textures={},
            ),
        )
        material_key = candidate.material.name.casefold()
        if material_key in group.textures:
            raise CombineError(
                f"Both {group.textures[material_key]} and {candidate.path} match material "
                f"{candidate.material.name!r} in group {group.description!r}."
            )
        group.textures[material_key] = candidate.path

    return sorted(grouped.values(), key=lambda group: group.description.casefold()), unmatched


def _mask_for_color(id_rgb: Any, rgb: tuple[int, int, int], tolerance: int) -> Any:
    channel_masks = []
    for channel, expected in zip(id_rgb.split(), rgb, strict=True):
        lookup = [255 if abs(value - expected) <= tolerance else 0 for value in range(256)]
        channel_masks.append(channel.point(lookup, mode="L"))
    mask = ImageChops.multiply(
        ImageChops.multiply(channel_masks[0], channel_masks[1]),
        channel_masks[2],
    )
    # One bit is sufficient for exact material membership and keeps 4K/8K
    # projects from retaining a full byte per material per pixel.
    return mask.convert("1")


def _common_mode(images: Sequence[Any]) -> str:
    modes = {image.mode for image in images}
    if len(modes) == 1:
        mode = next(iter(modes))
        if mode in {"1", "L", "LA", "RGB", "RGBA", "I", "F"}:
            return mode
    if any("A" in mode or mode == "P" for mode in modes):
        return "RGBA"
    if modes <= {"1", "L", "I", "F"}:
        return "L"
    return "RGB"


def combine_group(
    group: TextureGroup,
    materials: Sequence[LegendMaterial],
    masks: dict[str, Any],
    expected_size: tuple[int, int],
    output_path: Path,
    overwrite: bool = False,
) -> list[str]:
    if output_path.exists() and not overwrite:
        raise CombineError(f"Output already exists: {output_path} (use --overwrite).")

    opened: dict[str, Any] = {}
    try:
        for material in materials:
            path = group.textures.get(material.name.casefold())
            if path is None:
                continue
            image = Image.open(path)
            image.load()
            if image.size != expected_size:
                raise CombineError(
                    f"{path} is {image.size[0]}x{image.size[1]}, but the ID map is "
                    f"{expected_size[0]}x{expected_size[1]}."
                )
            opened[material.name.casefold()] = image

        if not opened:
            raise CombineError(f"Texture group {group.description!r} contains no usable images.")

        output_mode = _common_mode(list(opened.values()))
        output = Image.new(output_mode, expected_size, 0)
        try:
            missing: list[str] = []
            for material in materials:
                key = material.name.casefold()
                source = opened.get(key)
                if source is None:
                    missing.append(material.name)
                    continue
                converted = None
                if source.mode != output_mode:
                    converted = source.convert(output_mode)
                    source = converted
                try:
                    output.paste(source, (0, 0), masks[key])
                finally:
                    if converted is not None:
                        converted.close()

            output_path.parent.mkdir(parents=True, exist_ok=True)
            output.save(output_path)
            return missing
        finally:
            output.close()
    finally:
        for image in opened.values():
            image.close()


def combine_all(
    id_map_path: Path,
    legend_path: Path,
    input_dir: Path,
    output_dir: Path,
    aliases: dict[str, str] | None = None,
    recursive: bool = False,
    tolerance: int | None = None,
    overwrite: bool = False,
    dry_run: bool = False,
) -> tuple[list[Path], list[Path]]:
    _require_pillow()
    if not input_dir.is_dir():
        raise CombineError(f"Input directory does not exist: {input_dir}")
    if tolerance is not None and (tolerance < 0 or tolerance > 32):
        raise CombineError("Color tolerance must be between 0 and 32.")

    materials = load_legend(legend_path, aliases)
    groups, unmatched = discover_groups(
        input_dir,
        materials,
        recursive=recursive,
        excluded_paths=(id_map_path,),
    )
    if not groups:
        names = ", ".join(material.filename_name for material in materials)
        raise CombineError(
            f"No supported textures in {input_dir} contained a legend material name "
            f"({names}). Use --material-alias when Substance texture-set names differ."
        )

    outputs: list[Path] = []
    if dry_run:
        for group in groups:
            outputs.append(output_dir / group.output_parent / group.output_name)
        return outputs, unmatched

    try:
        with Image.open(id_map_path) as loaded_id_map:
            id_map = loaded_id_map.convert("RGB")
            id_map.load()
    except OSError as exc:
        raise CombineError(f"Could not load ID map {id_map_path}: {exc}") from exc

    try:
        masks = {}
        for material in materials:
            material_tolerance = tolerance if tolerance is not None else 0
            mask = _mask_for_color(id_map, material.rgb, material_tolerance)
            if tolerance is None and mask.getbbox() is None:
                # Blender stores byte images after float-to-byte conversion.
                # Values exactly between bytes can differ from ordinary Python
                # rounding by one, so only widen a mask when exact pixels do
                # not exist at all.
                material_tolerance = 1
                mask = _mask_for_color(id_map, material.rgb, material_tolerance)
                if mask.getbbox() is not None:
                    print(
                        f"notice: matched {material.name!r} with automatic ±1 byte tolerance",
                        file=sys.stderr,
                    )
            masks[material.name.casefold()] = mask
        occupied = Image.new("1", id_map.size, 0)
        for material in materials:
            mask = masks[material.name.casefold()]
            if ImageChops.multiply(occupied, mask).getbbox() is not None:
                raise CombineError(
                    f"The mask for {material.name!r} overlaps another material mask. "
                    "Lower --tolerance (exact matching is --tolerance 0)."
                )
            occupied = ImageChops.lighter(occupied, mask)
        empty_masks = [
            material.name
            for material in materials
            if masks[material.name.casefold()].getbbox() is None
        ]
        if empty_masks:
            print(
                "warning: no ID-map pixels matched: " + ", ".join(empty_masks),
                file=sys.stderr,
            )

        output_paths = [
            output_dir / group.output_parent / group.output_name
            for group in groups
        ]
        if len(set(output_paths)) != len(output_paths):
            duplicates = sorted(
                {path for path in output_paths if output_paths.count(path) > 1},
                key=str,
            )
            raise CombineError(
                "Multiple discovered groups resolve to the same output: "
                + ", ".join(str(path) for path in duplicates)
            )

        for group, output_path in zip(groups, output_paths, strict=True):
            missing = combine_group(
                group,
                materials,
                masks,
                id_map.size,
                output_path,
                overwrite=overwrite,
            )
            if missing:
                print(
                    f"warning: {group.description} has no texture for: {', '.join(missing)}",
                    file=sys.stderr,
                )
            outputs.append(output_path)
    finally:
        id_map.close()

    return outputs, unmatched


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Combine Substance Painter textures exported as separate material/texture "
            "sets, using a Material ID bake and JSON legend as exact masks."
        )
    )
    parser.add_argument("--id-map", required=True, type=Path, help="Material ID PNG")
    parser.add_argument(
        "--legend",
        required=True,
        type=Path,
        help="JSON legend written beside the Material ID PNG",
    )
    parser.add_argument(
        "--input-dir",
        required=True,
        type=Path,
        help="Directory containing Substance Painter exports",
    )
    parser.add_argument(
        "--output-dir",
        required=True,
        type=Path,
        help="Directory for combined channel textures",
    )
    parser.add_argument(
        "--material-alias",
        action="append",
        default=[],
        metavar="LEGEND=FILENAME",
        help="Map a Blender material name to a different Substance filename token",
    )
    parser.add_argument("--recursive", action="store_true", help="Search input subdirectories")
    parser.add_argument(
        "--tolerance",
        type=int,
        default=None,
        help=(
            "Fixed per-channel ID color tolerance in 8-bit values; by default the tool "
            "tries exact matching and a ±1 fallback only when exact pixels are absent"
        ),
    )
    parser.add_argument("--overwrite", action="store_true", help="Replace existing outputs")
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Print discovered groups and output paths without reading or writing images",
    )
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        aliases = _parse_aliases(args.material_alias)
        outputs, unmatched = combine_all(
            id_map_path=args.id_map.resolve(),
            legend_path=args.legend.resolve(),
            input_dir=args.input_dir.resolve(),
            output_dir=args.output_dir.resolve(),
            aliases=aliases,
            recursive=args.recursive,
            tolerance=args.tolerance,
            overwrite=args.overwrite,
            dry_run=args.dry_run,
        )
    except CombineError as exc:
        parser.exit(2, f"error: {exc}\n")

    action = "Would write" if args.dry_run else "Wrote"
    for output in outputs:
        print(f"{action}: {output}")
    if unmatched:
        print(f"Skipped {len(unmatched)} image(s) with no material-name match:")
        for path in unmatched:
            print(f"  {path}")
    print(f"{len(outputs)} texture channel(s) {'discovered' if args.dry_run else 'combined'}.")
    return 0


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