#!/usr/bin/env python3
"""Create transparent-background product image copies.

The script never overwrites source files. It removes only near-white pixels that
are connected to the image edges, plus desaturated studio-background shadows.
That protects white labels inside jars because they are not connected to the
outer image border.
"""

from __future__ import annotations

import argparse
import json
import re
from collections import deque
from pathlib import Path
from typing import Iterable

from PIL import Image, ImageFilter


THUMB_RE = re.compile(r"-\d+x\d+\.(jpe?g|png|webp)$", re.IGNORECASE)
try:
    RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
    RESAMPLE_BILINEAR = Image.Resampling.BILINEAR
except AttributeError:
    RESAMPLE_LANCZOS = Image.LANCZOS
    RESAMPLE_BILINEAR = Image.BILINEAR


def iter_images(source: Path) -> Iterable[Path]:
    for path in sorted(source.rglob("*")):
        if not path.is_file():
            continue
        if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
            continue
        if "woocommerce-placeholder" in path.name:
            continue
        if THUMB_RE.search(path.name):
            continue
        yield path


def is_background(
    pixel: tuple[int, int, int, int],
    threshold: int,
    tolerance: int,
    shadow_luma: int,
    shadow_chroma: int,
) -> bool:
    r, g, b, a = pixel
    if a == 0:
        return True
    chroma = max(r, g, b) - min(r, g, b)
    luma = int((0.2126 * r) + (0.7152 * g) + (0.0722 * b))

    if r >= threshold and g >= threshold and b >= threshold and chroma <= tolerance:
        return True

    # Product photos often have grey/off-white paper shadows connected to the
    # edge. Remove those only when they are low-chroma, so fruit, cloth, labels,
    # caps, and dark jar contents are preserved.
    return luma >= shadow_luma and chroma <= shadow_chroma


def edge_connected_mask(
    image: Image.Image,
    threshold: int,
    tolerance: int,
    shadow_luma: int,
    shadow_chroma: int,
    protect_radius: int,
) -> Image.Image:
    rgba = image.convert("RGBA")
    width, height = rgba.size
    total = width * height
    candidates = bytearray(total)
    for idx, pixel in enumerate(rgba.getdata()):
        if is_background(pixel, threshold, tolerance, shadow_luma, shadow_chroma):
            candidates[idx] = 1

    protected = bytearray(total)
    if protect_radius > 0:
        foreground = Image.frombytes(
            "L",
            (width, height),
            bytes(0 if candidate else 255 for candidate in candidates),
        )
        size = max(3, (protect_radius * 2) + 1)
        protected = bytearray(foreground.filter(ImageFilter.MaxFilter(size)).tobytes())

    mask_bytes = bytearray(total)
    queue: deque[int] = deque()

    def push(idx: int) -> None:
        if mask_bytes[idx] or not candidates[idx] or protected[idx]:
            return
        mask_bytes[idx] = 255
        queue.append(idx)

    for x in range(width):
        push(x)
        push((height - 1) * width + x)
    for y in range(height):
        push(y * width)
        push(y * width + width - 1)

    while queue:
        idx = queue.popleft()
        x = idx % width
        if x > 0:
            push(idx - 1)
        if x < width - 1:
            push(idx + 1)
        if idx >= width:
            push(idx - width)
        if idx < total - width:
            push(idx + width)

    return Image.frombytes("L", (width, height), bytes(mask_bytes))


def process_image(
    source: Path,
    output: Path,
    threshold: int,
    tolerance: int,
    shadow_luma: int,
    shadow_chroma: int,
    feather: float,
    expand: int,
    protect_radius: int,
    max_edge: int,
) -> dict:
    image = Image.open(source).convert("RGBA")
    work = image.copy()
    if max_edge and max(image.size) > max_edge:
        work.thumbnail((max_edge, max_edge), RESAMPLE_LANCZOS)

    mask = edge_connected_mask(work, threshold, tolerance, shadow_luma, shadow_chroma, protect_radius)
    if expand > 0:
        size = max(3, (expand * 2) + 1)
        mask = mask.filter(ImageFilter.MaxFilter(size))
    if feather > 0:
        mask = mask.filter(ImageFilter.GaussianBlur(feather))
    if mask.size != image.size:
        mask = mask.resize(image.size, RESAMPLE_BILINEAR)

    alpha = image.getchannel("A")
    alpha = Image.composite(Image.new("L", image.size, 0), alpha, mask)

    result = image.copy()
    result.putalpha(alpha)

    output.parent.mkdir(parents=True, exist_ok=True)
    result.save(output, "PNG", optimize=True)

    removed_pixels = sum(1 for value in mask.getdata() if value > 0)
    total_pixels = image.size[0] * image.size[1]

    return {
        "source": str(source),
        "output": str(output),
        "removed_ratio": round(removed_pixels / total_pixels, 4) if total_pixels else 0,
        "width": image.size[0],
        "height": image.size[1],
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--source", default="storage/app/public/products")
    parser.add_argument("--output", default="storage/app/public/products-cutout")
    parser.add_argument("--manifest", default="storage/app/product-cutouts-manifest.json")
    parser.add_argument("--limit", type=int, default=0)
    parser.add_argument("--image", action="append", default=[])
    parser.add_argument("--list-file", default="")
    parser.add_argument("--max-edge", type=int, default=1600)
    parser.add_argument("--threshold", type=int, default=238)
    parser.add_argument("--tolerance", type=int, default=18)
    parser.add_argument("--shadow-luma", type=int, default=184)
    parser.add_argument("--shadow-chroma", type=int, default=34)
    parser.add_argument("--feather", type=float, default=0.8)
    parser.add_argument("--expand", type=int, default=1)
    parser.add_argument("--protect-radius", type=int, default=10)
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--skip-existing", action="store_true")
    args = parser.parse_args()

    source_root = Path(args.source).resolve()
    output_root = Path(args.output)
    manifest_path = Path(args.manifest)

    records = []
    if args.image:
        images = [Path(path) if Path(path).is_absolute() else Path.cwd() / path for path in args.image]
    elif args.list_file:
        list_root = Path.cwd()
        images = []
        for line in Path(args.list_file).read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line:
                continue
            path = Path(line)
            images.append(path if path.is_absolute() else list_root / path)
    else:
        images = list(iter_images(source_root))
    if args.limit:
        images = images[: args.limit]

    for source in images:
        try:
            relative = source.resolve().relative_to(source_root)
        except ValueError:
            relative = Path(source.name)
        output = output_root / relative.with_suffix(".png")
        if source.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"} or not source.exists():
            records.append({"source": str(source), "output": str(output), "skipped": "missing_or_unsupported"})
            continue
        if args.skip_existing and output.exists():
            records.append({"source": str(source), "output": str(output), "skipped": "exists"})
            continue
        if args.dry_run:
            records.append({"source": str(source), "output": str(output), "dry_run": True})
            continue
        records.append(process_image(
            source,
            output,
            args.threshold,
            args.tolerance,
            args.shadow_luma,
            args.shadow_chroma,
            args.feather,
            args.expand,
            args.protect_radius,
            args.max_edge,
        ))

    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    manifest_path.write_text(json.dumps({
        "source_root": str(source_root),
        "output_root": str(output_root),
        "count": len(records),
        "records": records,
    }, ensure_ascii=False, indent=2), encoding="utf-8")

    print(f"{'Would process' if args.dry_run else 'Processed'} {len(records)} images")
    print(f"Manifest: {manifest_path}")
    return 0


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