"""Unified CLI for remove-ai-watermarks.

Provides commands for:
  - Visible watermark removal (Gemini sparkle) - works offline, fast
  - Invisible watermark removal (SynthID etc.) - requires GPU/diffusion models
  - AI metadata stripping - lightweight, no ML deps needed
"""

from __future__ import annotations

import contextlib
import json
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NoReturn

import click

from remove_ai_watermarks import __version__, image_io, watermark_registry
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
from remove_ai_watermarks.noai.watermark_profiles import (
    resolve_seed,
    resolve_steps,
    resolve_strength,
    strength_default_help,
    vendor_for_strength,
)

if TYPE_CHECKING:
    from collections.abc import Generator

    from numpy.typing import NDArray


# ── plain-text output layer (replaces rich: no colors, no markup, no boxes) ──


class _Table:
    """Plain-text stand-in for rich.Table."""

    def __init__(self, *args: Any, title: str | None = None, **kwargs: Any) -> None:
        self._title = title
        self._headers: list[str] = []
        self._rows: list[list[str]] = []

    def add_column(self, header: str = "", *args: Any, **kwargs: Any) -> None:
        self._headers.append(str(header))

    def add_row(self, *cells: Any) -> None:
        self._rows.append([str(c) for c in cells])

    def render(self) -> str:
        lines: list[str] = []
        if self._title:
            lines.append(self._title)
        if any(self._headers):
            lines.append("  ".join(self._headers))
        lines.extend("  ".join(row) for row in self._rows)
        return "\n".join(f"  {line}" for line in lines)


class _Progress:
    """No-op stand-in for rich.Progress; results are printed directly instead."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        pass

    def __enter__(self) -> _Progress:
        return self

    def __exit__(self, *exc: object) -> bool:
        return False

    def add_task(self, *args: Any, **kwargs: Any) -> int:
        return 0

    def advance(self, *args: Any, **kwargs: Any) -> None:
        pass

    def update(self, *args: Any, **kwargs: Any) -> None:
        pass


class _Console:
    """Minimal plain-text replacement for rich.Console."""

    def print(self, *objects: Any, **kwargs: Any) -> None:
        click.echo(" ".join(o.render() if isinstance(o, _Table) else str(o) for o in objects))

    @contextlib.contextmanager
    def status(self, message: str = "", **kwargs: Any) -> Generator[None, None, None]:
        if message:
            click.echo(message)
        yield


def _panel(text: str = "", *args: Any, **kwargs: Any) -> str:
    return text


def _column(*args: Any, **kwargs: Any) -> None:
    return None


Panel = _panel
Table = _Table
Progress = _Progress
SpinnerColumn = BarColumn = TextColumn = TimeElapsedColumn = _column
console = _Console()


def _setup_logging(verbose: bool) -> None:
    level = logging.DEBUG if verbose else logging.WARNING
    logging.basicConfig(
        level=level,
        format="%(name)s | %(message)s",
        handlers=[logging.StreamHandler()],
    )


def _banner() -> None:
    console.print(
        Panel(
            f"Remove-AI-Watermarks v{__version__}\nVisible & invisible watermark removal",
            border_style="cyan",
            padding=(0, 2),
        )
    )


def _validate_image(path: Path) -> Path:
    if not path.exists():
        console.print(f"Error: File not found: {path}")
        raise SystemExit(1)
    if path.suffix.lower() not in SUPPORTED_FORMATS:
        console.print(f"Warning: {path.suffix} may not be supported (expected: {', '.join(SUPPORTED_FORMATS)})")
    return path


def _resolved_strength_for_display(
    source: Path,
    strength: float | None,
    vendor: str | None,
    pipeline: str,
) -> float:
    """Resolve the same profile-specific strength the engine will execute."""
    if pipeline == "qwen-zimage" and strength is None:
        from PIL import Image

        from remove_ai_watermarks.noai.qwen_zimage_pipeline import resolution_adaptive_denoise

        with Image.open(source) as image:
            return resolution_adaptive_denoise(image.width, image.height)
    return resolve_strength(strength, vendor, pipeline)


# Shared option decorator for commands that run the invisible-watermark pipeline.
# Both cmd_invisible and cmd_all expose this flag; defining it once avoids
# copy-paste drift.
_controlnet_scale_option = click.option(
    "--controlnet-scale",
    type=float,
    default=1.0,
    help="ControlNet conditioning scale (structure/text preservation strength); "
    "applies to the controlnet pipeline (the default). Higher = closer to original structure.",
)

_min_resolution_option = click.option(
    "--min-resolution",
    type=int,
    default=1024,
    help="Upscale long side UP to this (px) before diffusion when the input is smaller, so SDXL runs "
    "near 1024 (small inputs distort at native); output is restored to the input size. 0 = off. Default 1024.",
)

_unsharp_option = click.option(
    "--unsharp", type=float, default=0.0, help="Unsharp-mask sharpening strength (0 = off, typical: 0.3-0.8)."
)

_upscaler_option = click.option(
    "--upscaler",
    type=click.Choice(["lanczos", "esrgan"]),
    default="lanczos",
    help="How to upscale a small input to the --min-resolution floor: lanczos (default, cv2, no deps) or "
    "esrgan (Real-ESRGAN via the 'esrgan' extra; better detail, slower on CPU). Best for photo/texture "
    "content -- as a generic GAN with no face/glyph prior it can degrade faces (diffusion mitigates) and "
    "thin text, so lanczos stays the default. Falls back to lanczos if the extra is absent. Only when upscaling.",
)

_auto_option = click.option(
    "--auto",
    is_flag=True,
    default=False,
    help="DEPRECATED: controlnet and adaptive polish are already the defaults, so "
    "--auto only emits a warning and changes nothing. Use --no-adaptive-polish "
    "to disable polishing.",
)

_adaptive_polish_option = click.option(
    "--adaptive-polish/--no-adaptive-polish",
    default=True,
    help="Restore the input's detail level after removal (capped unsharp + edge-masked grain "
    "targeting the input's sharpness, sparing text), countering the over-smoothed look. ON by "
    "default except for qwen-zimage, whose upstream-matching output is left unchanged; it "
    "self-limits where there is no detail deficit (text/flat graphics). Pass --adaptive-polish "
    "or --no-adaptive-polish to override. Independent of --unsharp/--humanize.",
)


# Tiled-diffusion knobs, shared by the diffusion commands (invisible/all/batch).
# Tiling avoids an explicit resolution cap for large inputs that OOM on MPS/GPU:
# it regenerates overlapping tiles at the input's native dimensions.
def _tile_options(f: Any) -> Any:
    """Apply the --tile / --tile-size / --tile-overlap options to a command."""
    f = click.option(
        "--tile-overlap",
        type=int,
        default=128,
        help="Overlap between adjacent tiles in px (feather-blended, no seam). Default 128.",
    )(f)
    f = click.option(
        "--tile-size",
        type=int,
        default=1024,
        help="Tile dimension in px for --tile. Default 1024.",
    )(f)
    return click.option(
        "--tile/--no-tile",
        default=False,
        help="Process large images in overlapping tiles instead of one forward pass. This keeps "
        "the input's native dimensions instead of applying --max-resolution, but still regenerates "
        "every tile. Engages only when the long side exceeds --tile-size. Default off.",
    )(f)


# HuggingFace model + CFG knobs, shared by the diffusion commands (invisible/all/batch)
# so the surface stays identical across them.
_model_option = click.option(
    "--model",
    type=str,
    default=None,
    help="HuggingFace model ID for the diffusion pipeline. Default: the SDXL base checkpoint.",
)
_guidance_scale_option = click.option(
    "--guidance-scale",
    type=float,
    default=None,
    help="Classifier-free guidance scale (CFG). Default: 7.5, except qwen-zimage "
    "fixes CFG at 1.0. Lower = follow the prompt less / stay closer to the input.",
)


def _normalize_pipeline(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None:
    """Resolve the legacy ``default`` profile name to ``sdxl`` (click option callback).

    Emits a one-line deprecation notice when the user explicitly passes the outdated
    ``default`` value, pointing at the two current choices (``sdxl`` / ``controlnet``).
    """
    if value is None:
        return None
    from remove_ai_watermarks.noai.watermark_profiles import normalize_profile

    normalized = normalize_profile(value)
    if value.strip().lower() == "default":
        click.echo(
            "Warning: --pipeline default is deprecated and maps to 'sdxl'. "
            "Use --pipeline sdxl (plain SDXL) or --pipeline controlnet (the default).",
            err=True,
        )
    return normalized


# ``controlnet`` (the default-SELECTED value), ``sdxl`` (plain SDXL img2img) and
# ``qwen`` (Qwen-Image, CUDA/cloud-class) are the current profiles; ``default`` is an
# OUTDATED back-compat alias for ``sdxl`` (warned + normalized away by _normalize_pipeline).
_PIPELINE_CHOICES = ["sdxl", "controlnet", "qwen", "qwen-zimage", "default"]
_PIPELINE_HELP = (
    "Pipeline profile. controlnet (DEFAULT) = SDXL + canny ControlNet that preserves "
    "text/faces via edge conditioning while removing SynthID; sdxl = plain SDXL img2img "
    "(lighter, no extra model download, but leaves SynthID on flat-graphic content); "
    "qwen = Qwen-Image (20B, Apache-2.0) img2img, best text/structure preservation but "
    "CUDA/cloud-class; qwen-zimage = Qwen-Image-2512 + Lightning + Canny, followed by "
    "SAM-masked Z-Image face repair (CUDA-only; install the qwen-zimage extra). "
    "('default' is an OUTDATED alias for 'sdxl'.)"
)

# Shared --pipeline / --strength decorators so the three diffusion commands
# (invisible/all/batch) keep an identical surface and the strength help can never
# drift from the watermark_profiles constants (strength_default_help derives it).
_pipeline_option = click.option(
    "--pipeline",
    type=click.Choice(_PIPELINE_CHOICES),
    default="controlnet",
    callback=_normalize_pipeline,
    help=_PIPELINE_HELP,
)
_strength_option = click.option(
    "--strength",
    type=float,
    default=None,
    help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.",
)
_force_option = click.option(
    "--force/--no-force",
    default=False,
    help=(
        "Run the diffusion scrub even when no invisible AI watermark is locally "
        "detectable. Default: skip it (regeneration only degrades a clean image; a "
        "skip never claims the image is watermark-free -- a pixel SynthID is "
        "undetectable once its metadata proxy is gone)."
    ),
)
_cpu_offload_option = click.option(
    "--cpu-offload/--no-cpu-offload",
    default=False,
    help=(
        "Offload model components to CPU between CUDA calls instead of keeping the "
        "whole pipeline in VRAM, at the cost of speed. For qwen-zimage, forces the "
        "face stack to offload instead of using automatic residency. No effect on "
        "cpu/mps."
    ),
)


_visible_backend_option = click.option(
    "--backend",
    "backend",
    type=click.Choice(["auto", "cv2", "migan", "lama"]),
    default="auto",
    help="Fill backend for visible-mark removal (localize -> fill). auto: best available, "
    "LaMa > MI-GAN > cv2 (a learned backend needs the 'lama' or 'migan' extra; else cv2, "
    "with a warning). cv2: classical inpaint (no deps, smears texture). migan: MI-GAN ONNX "
    "(light, ~1 GB, the memory-tight pick). lama: big-LaMa ONNX (best quality, ~4.7 GB).",
)


_visible_sensitivity_option = click.option(
    "--sensitivity",
    "sensitivity",
    type=click.Choice(["auto", "strict"]),
    default="auto",
    help="How hard to trust a borderline mark. auto: relax a mark only when metadata "
    "or a same-product sibling mark corroborates it (safe; clean images untouched). "
    "strict: high-precision visual gate only, never relaxed. To act on a mark YOU can "
    "see but the detector missed, use 'erase --region' or '--mark <name> --no-detect' "
    "rather than a blanket relaxation.",
)


def _resolve_auto_polish(auto: bool, adaptive_polish: bool) -> bool:
    """Warn on the retired ``--auto`` flag, returning ``adaptive_polish`` unchanged.

    ``--auto`` used to plan the pipeline + polish from content detection, but the
    pipeline is now always controlnet (the default) and the adaptive polish is ON by
    default (it self-gates by detail level), so the content detectors were removed and
    ``--auto`` is now a no-op alias: the polish it used to enable is already the default,
    and an explicit ``--no-adaptive-polish`` still wins. So it only emits a deprecation
    warning and passes ``adaptive_polish`` through.
    """
    if auto:
        click.echo(
            "Warning: --auto is deprecated and now does nothing (the adaptive polish it "
            "enabled is ON by default). Use --no-adaptive-polish to turn the polish off.",
            err=True,
        )
    return adaptive_polish


def _resolve_profile_polish(auto: bool, adaptive_polish: bool, pipeline: str) -> bool:
    """Keep the upstream qwen-zimage output unchanged unless polish was explicit."""
    adaptive_polish = _resolve_auto_polish(auto, adaptive_polish)
    if pipeline != "qwen-zimage":
        return adaptive_polish
    ctx = click.get_current_context(silent=True)
    if ctx is None:
        return adaptive_polish
    if ctx.get_parameter_source("adaptive_polish") == click.core.ParameterSource.DEFAULT:
        return False
    return adaptive_polish


def _warn_if_esrgan_unavailable(upscaler: str) -> None:
    """Tell the user once if ``--upscaler esrgan`` will silently fall back to Lanczos.

    The engine downgrades to Lanczos when the ``esrgan`` extra is absent (fail-safe, so
    a batch never breaks mid-run) -- but without this notice the user would believe
    Real-ESRGAN ran. Surfaced at the CLI layer, once per invocation (not per image).
    """
    if upscaler != "esrgan":
        return
    from remove_ai_watermarks import upscaler as _upscaler

    if not _upscaler.is_available():
        console.print("  Note: --upscaler esrgan needs the 'esrgan' extra; falling back to Lanczos.")


def _visible_provenance(path: Path | None) -> frozenset[str]:
    """Vendor keys local metadata confirms, the EVIDENCE that drives ``auto``
    sensitivity. Thin wrapper over the public :func:`api.visible_provenance` (one
    implementation for the CLI and the library), with a None-path guard."""
    if path is None:
        return frozenset()
    from remove_ai_watermarks.api import visible_provenance

    return visible_provenance(path)


def _remove_visible_auto(
    image: NDArray[Any],
    *,
    source_path: Path | None = None,
    backend: str = "auto",
    sensitivity: str = "auto",
) -> tuple[NDArray[Any], str | None]:
    """Remove every auto-detected visible mark via the registry (localize -> fill).

    Routes the ``all``/``batch`` visible step through the same registry path the
    standalone ``visible`` command uses, so every registered mark is handled rather
    than only the Gemini sparkle.
    Returns ``(result, label-or-None)``; when no ``in_auto`` mark fires the image is
    returned unchanged with ``None``. ``backend`` selects the shared fill; ``sensitivity``
    controls how hard a borderline mark is trusted (auto reads metadata provenance)."""
    from remove_ai_watermarks import watermark_registry

    bk: watermark_registry.Backend = backend  # type: ignore[assignment]
    sens = _parse_sensitivity(sensitivity)
    provenance = _visible_provenance(source_path)
    try:
        result, removed = watermark_registry.remove_auto_marks(
            image, sensitivity=sens, provenance=provenance, backend=bk
        )
    except RuntimeError as e:  # e.g. a selected migan/lama backend whose extra is absent
        console.print(f"  Error: {e}")
        raise SystemExit(1) from e
    if not removed:
        return image, None
    return result, ", ".join(removed)


def _parse_sensitivity(value: str) -> watermark_registry.Sensitivity:
    """Map the CLI ``--sensitivity`` choice to the registry literal.

    A pass-through since ``assume-ai`` was removed (2026-07-19); kept as the single
    conversion point so a future kebab-cased choice has an obvious home.
    """
    return "strict" if value == "strict" else "auto"


# Exit code for the standalone ``visible`` command when no visible mark was
# removed -- distinct from success (0) and a hard error (1) so a wrapping
# service can tell "nothing to do here" apart and surface guidance instead of
# re-serving the unchanged input as a finished result.
EXIT_NO_VISIBLE_MARK = 2


def _write_output_or_exit(output: Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> None:
    """Write the final image, or fail with a readable error instead of a traceback.

    `image_io.imwrite` is contractually NON-RAISING: it returns False when the codec
    rejects the image or the path cannot be written. Every caller here follows its write
    with `output.stat()` to report the size, so a silently-failed write (read-only
    directory, full disk) died with a bare `FileNotFoundError` traceback pointing at the
    stat, not at the write. Found by the Tier E adversarial sweep 2026-07-20.
    Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`.
    """
    output.parent.mkdir(parents=True, exist_ok=True)
    if not image_io.write_bgr_with_alpha(output, bgr, alpha):
        console.print(f"  Error: failed to write output (is the destination writable?): {output}")
        raise SystemExit(1)


def _no_visible_mark_exit(source: Path) -> NoReturn:
    """Explain why no visible watermark was removed, then exit non-zero.

    The visible registry handles only known visual marks. Most images carry no
    registered mark and may instead have an invisible or metadata watermark.
    Returning the input
    unchanged with exit 0 reads as success to a caller and re-serves the
    watermarked image -- the recurring "it didn't work" report. Instead, run a
    cheap metadata-only :func:`identify`, tell the user what the image actually
    carries and which command removes it, and exit
    :data:`EXIT_NO_VISIBLE_MARK`.

    When the user can SEE a mark the detector missed, the honest next step is one that
    executes their instruction rather than guessing harder. This used to recommend
    ``--sensitivity assume-ai``, which did the opposite -- it relaxed every mark's gate
    on a blanket assumption -- and that mode is gone (2026-07-19).

    The advice is per-mark, because the forced paths are not equally reliable
    (measured 2026-07-19):
      * ``erase --region`` is always sound: the user supplies the coordinates, so there
        is nothing to guess. This is the primary recommendation.
      * ``--mark <text-mark> --no-detect`` is reasonable for the TEXT marks: the forced
        mask is built from the actual glyph blob, non-empty on 13/13 real marks the
        detector missed.
      * ``--mark gemini --no-detect`` is NOT recommended and is deliberately not
        suggested here: with no detection it falls back to a fixed default sparkle slot,
        which covered the real sparkle on only **31% of 97** genuine sparkles the strict
        gate missed (median offset 63px up-and-left). The other 69% fill a clean corner
        AND report a removal that did not happen -- the worst outcome the tool has.
    """
    from remove_ai_watermarks.identify import identify

    report = identify(source, check_visible=False, check_invisible=False)
    if report.is_ai_generated and report.watermarks:
        plat = report.platform or "an unidentified platform"
        console.print(
            f"  This image carries an invisible/metadata watermark ({plat}), not a visible mark,\n"
            "  so the 'visible' command cannot remove it. Run the full pipeline instead:\n"
            f"    remove-ai-watermarks all {source.name}"
        )
    else:
        console.print(
            "  No visible mark and no readable AI provenance signal. This does not prove\n"
            "  the image is clean: an invisible pixel watermark such as SynthID cannot be\n"
            "  detected here once the metadata proxy is absent (it may have been stripped\n"
            "  earlier). If the image is AI-generated, regenerate the pixels with:\n"
            f"    remove-ai-watermarks all {source.name}\n"
            "  If instead there is a logo or object to remove, target it with the region eraser:\n"
            f"    remove-ai-watermarks erase {source.name} --region x,y,w,h"
        )
    console.print(
        "  If you can SEE a mark here that was not detected, point at it directly --\n"
        "  that removes what you actually see instead of guessing:\n"
        f"    remove-ai-watermarks erase {source.name} --region x,y,w,h\n"
        "  For a known CJK text mark you can also force it by name:\n"
        f"    remove-ai-watermarks visible {source.name} --mark doubao --no-detect"
    )
    raise SystemExit(EXIT_NO_VISIBLE_MARK)


# Same value as EXIT_NO_VISIBLE_MARK (2): a distinct-from-success / distinct-from-
# error code that tells a wrapping service "the diffusion scrub was skipped because
# no invisible watermark was locally detectable", so it can surface the message
# instead of treating an unchanged image as a completed removal.
EXIT_NO_INVISIBLE_SIGNAL = 2


def _no_invisible_signal_exit(source: Path) -> NoReturn:
    """Explain why the diffusion scrub was skipped, then exit non-zero.

    The ``invisible`` command regenerates pixels to remove SynthID / open
    watermarks; that regeneration also degrades a real photo. When
    :func:`identify` finds no locally-detectable invisible AI signal, running it
    anyway would damage a clean image for nothing -- the dominant paid score-0
    cause on no-watermark uploads. So skip it, but do NOT imply the image is
    clean: a pixel SynthID is undetectable here once its metadata proxy is gone.
    Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`; ``--force`` runs
    the scrub regardless.
    """
    console.print(
        "  No invisible AI watermark detected (no C2PA/SynthID proxy, no open\n"
        "  watermark). Skipped the diffusion scrub -- regenerating the pixels would\n"
        "  only degrade the image with nothing to remove, so no output was written.\n"
        "  This does NOT prove the image is clean: a pixel watermark such as SynthID\n"
        "  cannot be detected here once its metadata proxy is absent (it may have\n"
        "  been stripped earlier). If you know the image is AI-generated and want the\n"
        "  pixels regenerated regardless, re-run with --force:\n"
        f"    remove-ai-watermarks invisible {source.name} --force"
    )
    raise SystemExit(EXIT_NO_INVISIBLE_SIGNAL)


def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool:
    """True when the diffusion scrub should be skipped for *image_path*.

    The shared no-signal gate for ``invisible`` / ``all`` / ``batch``: skip when
    ``--force`` is not set AND no invisible AI watermark is locally detectable
    (regenerating pixels would only degrade a clean image -- the dominant paid
    score-0 cause). Centralizes the condition + the lazy ``has_invisible_target``
    import so the three call sites cannot drift. ``--force`` short-circuits the
    detection entirely.
    """
    if force:
        return False
    from remove_ai_watermarks.identify import has_invisible_target

    return not has_invisible_target(image_path)


# ── Main group ──
@click.group(invoke_without_command=True)
@click.version_option(__version__, prog_name="remove-ai-watermarks")
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging.")
@click.pass_context
def main(ctx: click.Context, verbose: bool) -> None:
    """Remove visible and invisible AI watermarks from images."""
    from dotenv import load_dotenv

    load_dotenv()  # Load .env (e.g. HF_TOKEN)

    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose
    _setup_logging(verbose)

    if ctx.invoked_subcommand is None:
        _banner()
        click.echo(ctx.get_help())


# ── Visible (Gemini) watermark removal ──
def _run_visible_auto(
    source: Path,
    output: Path,
    *,
    backend: watermark_registry.Backend,
    sensitivity: watermark_registry.Sensitivity,
    strip_metadata: bool,
) -> None:
    """Run the registry-wide visible pass and render its CLI result."""
    from remove_ai_watermarks import api

    t0 = time.monotonic()
    try:
        with console.status("Detecting & removing visible marks..."):
            result, removed = api.remove_visible(
                str(source),
                str(output),
                sensitivity=sensitivity,
                backend=backend,
                strip_metadata=strip_metadata,
                write_noop=False,
            )
    except RuntimeError as e:  # selected migan/lama backend whose extra is absent
        console.print(f"  Error: {e}")
        raise SystemExit(1) from e
    except (ValueError, OSError) as e:
        # Covers BOTH an unreadable input and an unwritable output, so the message must
        # not assert which: it used to say "cannot read image <input>" while quoting the
        # OUTPUT path, blaming the wrong file (Tier E, 2026-07-20).
        console.print(f"  Error: {e}")
        raise SystemExit(1) from e

    elapsed = time.monotonic() - t0
    h, w = result.shape[:2]
    console.print(f"  Input:  {source.name}  ({w}x{h})")
    if not removed:
        # write_noop=False means nothing was written, so a pre-existing output is intact.
        console.print(f"  No known visible mark detected. Checked: {', '.join(watermark_registry.mark_keys())}.")
        _no_visible_mark_exit(source)
    console.print(f"  Removed: {', '.join(removed)}")
    size_kb = output.stat().st_size / 1024
    console.print(f"  Saved: {output}  ({size_kb:.0f} KB, {elapsed:.2f}s)")


def _run_visible_explicit(
    ctx: click.Context,
    source: Path,
    output: Path,
    *,
    detect: bool,
    mark: str,
    backend: watermark_registry.Backend,
    sensitivity: watermark_registry.Sensitivity,
    resolved_backend: str,
    strip_metadata: bool,
) -> None:
    """Run one explicitly selected visible-mark detector/remover."""
    image, alpha = image_io.read_bgr_and_alpha(source)
    if image is None:
        console.print(f"Error: Failed to read image: {source}")
        raise SystemExit(1)
    h, w = image.shape[:2]
    console.print(f"  Input:  {source.name}  ({w}x{h})")

    provenance = _visible_provenance(source)
    target = "gemini" if mark == "auto" else mark  # --no-detect auto: gemini fallback
    chosen = watermark_registry.get_mark(target)
    # A single explicit mark has no sibling corroboration. Keep its trust resolution
    # aligned with the registry arbiter.
    trust = watermark_registry.resolve_trust(
        chosen.key,
        sensitivity=sensitivity,
        provenance=provenance,
        strict_keys=set(),
    )
    relax = trust != "strict"
    detection = chosen.detect(image, provenance=relax)
    if detect and not detection.detected:
        console.print(f"  {chosen.label} not detected  (conf {detection.confidence:.2f}). Use --no-detect to force.")
        _no_visible_mark_exit(source)
    if detection.detected:
        console.print(f"  {chosen.label} detected  ({chosen.location}, conf {detection.confidence:.2f})")

    t0 = time.monotonic()
    try:
        with console.status(f"Removing {chosen.label}... ({resolved_backend})"):
            result, _ = chosen.remove(image, backend=backend, provenance=relax, force=not detect)
    except RuntimeError as e:  # selected migan/lama backend whose extra is absent
        console.print(f"  Error: {e}")
        raise SystemExit(1) from e
    elapsed = time.monotonic() - t0

    _write_output_or_exit(output, result, alpha)
    if strip_metadata:
        try:
            from remove_ai_watermarks.metadata import remove_ai_metadata

            remove_ai_metadata(output, output)
        except Exception as e:
            if ctx.obj.get("verbose"):
                console.print(f"  Warning: Failed to strip metadata: {e}")

    size_kb = output.stat().st_size / 1024
    console.print(f"  Saved: {output}  ({size_kb:.0f} KB, {elapsed:.2f}s)")


@main.command("visible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
@click.option(
    "--mark",
    type=click.Choice(["auto", *watermark_registry.mark_keys()]),
    default="auto",
    help="Which known visible mark to target (auto picks every detected mark). "
    "The fill backend is chosen by --backend (default auto).",
)
@_visible_backend_option
@_visible_sensitivity_option
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
def cmd_visible(
    ctx: click.Context,
    source: Path,
    output: Path | None,
    detect: bool,
    mark: str,
    backend: str,
    sensitivity: str,
    strip_metadata: bool,
) -> None:
    """Remove a known visible AI watermark from an image.

    Finds registered marks in their expected areas and removes them by localizing
    each mark to a mask, then filling that mask with the selected ``--backend``.
    ``--mark auto`` removes every detected registry entry in one pass. Run
    ``--help`` to see the current mark keys. For arbitrary logos and objects, use
    ``erase``.
    """
    _banner()
    source = _validate_image(source)

    if output is None:
        output = source.with_stem(source.stem + "_clean")

    bk: watermark_registry.Backend = backend  # type: ignore[assignment]
    sens = _parse_sensitivity(sensitivity)
    resolved_backend = watermark_registry.resolve_backend(bk)
    if resolved_backend == "cv2" and not watermark_registry.inpaint_model_available():
        console.print("  Note: using cv2 fill (install the 'migan' extra for a lightweight ONNX model).")

    # ``auto`` removes EVERY detected in_auto mark in one pass (a Jimeng-basic image
    # carries the top-left pill AND the bottom-right wordmark). Delegate the whole
    # read -> provenance -> localize/fill -> write -> metadata-strip to the library
    # entry point, so the CLI and the library go through ONE path (no drift).
    if mark == "auto" and detect:
        _run_visible_auto(source, output, backend=bk, sensitivity=sens, strip_metadata=strip_metadata)
        return

    _run_visible_explicit(
        ctx,
        source,
        output,
        detect=detect,
        mark=mark,
        backend=bk,
        sensitivity=sens,
        resolved_backend=resolved_backend,
        strip_metadata=strip_metadata,
    )


# ── Universal region eraser ──
def _parse_region(spec: str) -> tuple[int, int, int, int]:
    """Parse an ``x,y,w,h`` region string into a 4-int tuple."""
    parts = spec.replace(" ", "").split(",")
    if len(parts) != 4:
        raise click.BadParameter(f"region must be 'x,y,w,h', got: {spec!r}")
    try:
        x, y, w, h = (int(p) for p in parts)
    except ValueError as e:
        raise click.BadParameter(f"region values must be integers: {spec!r}") from e
    if w <= 0 or h <= 0:
        raise click.BadParameter(f"region width/height must be positive: {spec!r}")
    return x, y, w, h


@main.command("erase")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--region", "regions", multiple=True, required=True, help="x,y,w,h box to erase (repeatable).")
@click.option(
    "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option(
    "--backend",
    type=click.Choice(["cv2", "migan", "lama"]),
    default="cv2",
    help="Inpaint backend. cv2: instant, no deps. migan: light ONNX MI-GAN, ~1 GB RAM, "
    "near-LaMa quality (extra 'migan'). lama: big-LaMa, best quality but ~4.7 GB RAM (extra 'lama').",
)
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
@click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.")
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
@click.pass_context
def cmd_erase(
    ctx: click.Context,
    source: Path,
    regions: tuple[str, ...],
    output: Path | None,
    backend: Literal["cv2", "migan", "lama"],
    inpaint_method: str,
    dilate: int,
    strip_metadata: bool,
) -> None:
    """Erase arbitrary region(s) from an image via inpainting.

    Universal and position-agnostic: removes any logo / watermark / object inside
    the boxes you pass, regardless of color or location. Runs on CPU. Use this
    for marks the dedicated ``visible`` registry does not cover.
    """
    from remove_ai_watermarks.region_eraser import erase

    _banner()
    source = _validate_image(source)
    if output is None:
        output = source.with_stem(source.stem + "_clean")

    boxes = [_parse_region(r) for r in regions]

    image, alpha = image_io.read_bgr_and_alpha(source)
    if image is None:
        console.print(f"Error: Failed to read image: {source}")
        raise SystemExit(1)
    h, w = image.shape[:2]
    console.print(f"  Input:  {source.name}  ({w}x{h})  {len(boxes)} region(s), backend={backend}")

    t0 = time.monotonic()
    method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
    try:
        with console.status(f"Erasing ({backend})..."):
            result = erase(image, boxes=boxes, backend=backend, dilate=dilate, cv2_method=method)
    except RuntimeError as e:
        console.print(f"  Error: {e}")
        raise SystemExit(1) from e
    elapsed = time.monotonic() - t0

    _write_output_or_exit(output, result, alpha)

    if strip_metadata:
        try:
            from remove_ai_watermarks.metadata import remove_ai_metadata

            remove_ai_metadata(output, output)
        except Exception as e:
            if ctx.obj.get("verbose"):
                console.print(f"  Warning: Failed to strip metadata: {e}")

    size_kb = output.stat().st_size / 1024
    console.print(f"  Erased {len(boxes)} region(s) -> {output}  ({size_kb:.0f} KB, {elapsed:.2f}s)")


# ── Invisible watermark removal ──
@main.command("invisible")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_strength_option
@click.option(
    "--steps",
    type=int,
    default=None,
    help="Number of denoising steps. Default: 4 for qwen-zimage, 50 otherwise.",
)
@_pipeline_option
@click.option(
    "--device",
    type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
    default="auto",
    help="Inference device.",
)
@click.option(
    "--seed",
    type=int,
    default=None,
    help="Random seed for reproducibility. Default: 0 for qwen-zimage, random otherwise.",
)
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
    "--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@click.option(
    "--max-resolution",
    type=int,
    default=0,
    help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
)
@_controlnet_scale_option
@_min_resolution_option
@_unsharp_option
@_upscaler_option
@_model_option
@_guidance_scale_option
@_auto_option
@_adaptive_polish_option
@_tile_options
@_force_option
@_cpu_offload_option
@click.pass_context
def cmd_invisible(
    ctx: click.Context,
    source: Path,
    output: Path | None,
    strength: float | None,
    steps: int | None,
    pipeline: str,
    device: str,
    seed: int | None,
    hf_token: str | None,
    humanize: float,
    unsharp: float,
    max_resolution: int,
    min_resolution: int,
    controlnet_scale: float,
    upscaler: str,
    model: str | None,
    guidance_scale: float | None,
    auto: bool,
    adaptive_polish: bool,
    tile: bool,
    tile_size: int,
    tile_overlap: int,
    force: bool,
    cpu_offload: bool,
) -> None:
    """Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).

    Uses diffusion-based regeneration. Requires GPU for reasonable speed.
    Requires the [gpu] extra: pip install 'remove-ai-watermarks[gpu]'
    """
    from remove_ai_watermarks.invisible_engine import is_available as invisible_available

    if not invisible_available():
        console.print(
            "Error: GPU dependencies not installed.\n  Install them with: pip install 'remove-ai-watermarks[gpu]'"
        )
        raise SystemExit(1)

    from remove_ai_watermarks.invisible_engine import InvisibleEngine

    source = _validate_image(source)
    steps = resolve_steps(steps, pipeline)
    seed = resolve_seed(seed, pipeline)
    _warn_if_esrgan_unavailable(upscaler)
    adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
    if output is None:
        output = source.with_stem(source.stem + "_clean")

    device_str = None if device == "auto" else device

    # Gate BEFORE building the engine: skip the destructive regeneration when no
    # invisible AI watermark is locally detectable (it would only degrade a clean
    # image -- dominant paid score-0 cause), so the common skip path pays nothing for
    # engine construction. A skip never claims the image is clean; --force overrides.
    if _should_skip_invisible_scrub(force, source):
        _no_invisible_signal_exit(source)

    def progress_cb(msg: str) -> None:
        console.print(f"  {msg}")

    engine = InvisibleEngine(
        model_id=model,
        device=device_str,
        pipeline=pipeline,
        hf_token=hf_token,
        progress_callback=progress_cb,
        controlnet_conditioning_scale=controlnet_scale,
        cpu_offload=cpu_offload,
    )

    # Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the
    # displayed and executed strength agree on the vendor-adaptive default.
    vendor = vendor_for_strength(source)
    console.print(f"  Input:    {source.name}")
    console.print(f"  Pipeline: {pipeline}")
    console.print(f"  Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}  Steps: {steps}")

    t0 = time.monotonic()
    result_path = engine.remove_watermark(
        image_path=source,
        output_path=output,
        strength=strength,
        num_inference_steps=steps,
        guidance_scale=guidance_scale,
        seed=seed,
        humanize=humanize,
        unsharp=unsharp,
        adaptive_polish=adaptive_polish,
        max_resolution=max_resolution,
        min_resolution=min_resolution,
        upscaler=upscaler,
        vendor=vendor,
        tile=tile,
        tile_size=tile_size,
        tile_overlap=tile_overlap,
    )
    elapsed = time.monotonic() - t0

    size_kb = result_path.stat().st_size / 1024
    console.print(f"\n  Saved: {result_path}  ({size_kb:.0f} KB, {elapsed:.1f}s)")


# ── Metadata operations ──
@main.command("metadata")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--check", is_flag=True, help="Check for AI metadata (don't modify).")
@click.option("--remove", is_flag=True, help="Remove AI metadata.")
@click.option(
    "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: overwrite source)."
)
@click.option("--keep-standard/--remove-all", default=True, help="Keep standard metadata (Author, Title, etc.).")
@click.pass_context
def cmd_metadata(
    ctx: click.Context,
    source: Path,
    check: bool,
    remove: bool,
    output: Path | None,
    keep_standard: bool,
) -> None:
    """Check or remove AI-generation metadata (images, video, and audio).

    Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
    China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
    strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
    from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
    untouched.
    """
    from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify

    # No _validate_image() here: unlike the image-only commands, metadata also
    # accepts video/audio containers, so the image-format warning would misfire.
    # click's `exists=True` on the argument already enforces the file exists.
    _banner()

    if check or (not remove):
        has_ai = has_ai_metadata(source)
        if has_ai:
            console.print(f"  Warning: AI metadata detected in {source.name}:")
            meta = get_ai_metadata(source)
            if synthid := meta.get("synthid_watermark"):
                console.print(f"  Warning: SynthID watermark (inferred from C2PA metadata) {synthid}")
            table = Table(show_header=True, header_style="bold")
            table.add_column("Key", style="cyan")
            table.add_column("Value")
            for k, v in meta.items():
                table.add_row(k, str(v)[:80])
            console.print(table)
        else:
            console.print(f"  No AI metadata found in {source.name}")

        if not remove:
            return

    # Remove
    try:
        out, leftover = strip_and_verify(source, output, keep_standard=keep_standard)
    except (OSError, ValueError) as e:  # unreadable / truncated / non-image (PIL raises OSError subclasses)
        console.print(f"  Error: cannot process {source.name}: {e}")
        raise SystemExit(1) from e

    if leftover:
        console.print(f"  FAILED: {len(leftover)} AI metadata marker(s) survived in {out}")
        console.print(f"    still present: {', '.join(sorted(leftover))}")
        console.print("    the file could not be decoded, so it was copied through unchanged")
        raise SystemExit(1)
    console.print(f"  AI metadata stripped -> {out}")


# ── Provenance identification ──
@main.command("identify")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "--no-visible",
    is_flag=True,
    help="Skip pixel-domain detectors (visible sparkle + invisible watermark); metadata-only.",
)
@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON instead of a table.")
@click.pass_context
def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bool) -> None:
    """Identify where an image was made and what watermarks it carries.

    Aggregates supported C2PA, IPTC, EXIF, XMP, generator, visible-mark, and
    optional invisible-watermark signals into one provenance verdict. Absence of
    signals is reported as "unknown", never as "clean" because stripped metadata
    leaves no local proof.
    """
    from dataclasses import asdict

    from remove_ai_watermarks.identify import identify

    source = _validate_image(source)
    report = identify(source, check_visible=not no_visible, check_invisible=not no_visible)

    if as_json:
        click.echo(json.dumps(asdict(report), default=str, indent=2))
        return

    _banner()
    verdict = {True: "AI-generated", False: "not AI", None: "unknown"}[report.is_ai_generated]
    # Sharpen the True verdict when the C2PA source type says the image is a real
    # photo with an AI-composited region rather than a full AI generation, so the
    # caller (and the user) can tell "scrub the whole frame" from "scrub the AI region".
    if report.is_ai_generated and report.ai_source_kind == "enhanced":
        verdict = "AI-enhanced (real content with an AI-composited region)"
    elif report.is_ai_generated and report.ai_source_kind == "generated":
        verdict = "AI-generated (fully synthetic)"
    console.print(f"\n  Verdict: {verdict}  (confidence: {report.confidence})")
    console.print(f"  Platform: {report.platform or 'undetermined'}")

    if report.is_ai_generated is None:
        console.print(
            "  No locally-readable AI signal found. This is not the same as 'clean': "
            "metadata is often stripped by re-encoding, screenshots, or upload, and SynthID-class "
            "pixel watermarks (Gemini / Nano Banana / gpt-image) have no local detector. "
            "See caveats below."
        )

    if report.integrity_clashes:
        console.print("\n  Warning: Integrity clash (provenance signals contradict each other)")
        for clash in report.integrity_clashes:
            console.print(f"  - {clash}")

    if report.watermarks:
        table = Table(show_header=True, header_style="bold", title="Watermarks / provenance markers")
        table.add_column("Marker", style="cyan")
        for wm in report.watermarks:
            table.add_row(wm)
        console.print(table)
    else:
        console.print("  No watermarks or provenance markers found.")

    if report.caveats:
        console.print("\n  Caveats:")
        for c in report.caveats:
            console.print(f"  - {c}")


# ── Combined "all" mode ──
@main.command("all")
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
    "-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_visible_backend_option
@_visible_sensitivity_option
@_strength_option
@click.option(
    "--steps",
    type=int,
    default=None,
    help="Number of denoising steps. Default: 4 for qwen-zimage, 50 otherwise.",
)
@_pipeline_option
@_model_option
@click.option(
    "--device",
    type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
    default="auto",
    help="Inference device.",
)
@click.option(
    "--seed",
    type=int,
    default=None,
    help="Random seed for reproducibility. Default: 0 for qwen-zimage, random otherwise.",
)
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
    "--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@click.option(
    "--max-resolution",
    type=int,
    default=0,
    help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
)
@_controlnet_scale_option
@_min_resolution_option
@_unsharp_option
@_upscaler_option
@_guidance_scale_option
@_auto_option
@_adaptive_polish_option
@_tile_options
@_force_option
@_cpu_offload_option
@click.pass_context
def cmd_all(
    ctx: click.Context,
    source: Path,
    output: Path | None,
    backend: str,
    sensitivity: str,
    strength: float | None,
    steps: int | None,
    pipeline: str,
    model: str | None,
    device: str,
    seed: int | None,
    hf_token: str | None,
    humanize: float,
    unsharp: float,
    max_resolution: int,
    min_resolution: int,
    controlnet_scale: float,
    upscaler: str,
    guidance_scale: float | None,
    auto: bool,
    adaptive_polish: bool,
    tile: bool,
    tile_size: int,
    tile_overlap: int,
    force: bool,
    cpu_offload: bool,
) -> None:
    """Remove ALL watermarks: visible + invisible + metadata.

    Runs the full pipeline in order:
      1. Visible watermark removal (Gemini sparkle / text marks, localize -> fill)
      2. Invisible watermark removal (SynthID etc., diffusion regeneration)
      3. AI metadata stripping (EXIF, PNG text, C2PA)

    If invisible watermark deps are not installed, skips step 2 with a warning.
    """
    _banner()
    source = _validate_image(source)
    steps = resolve_steps(steps, pipeline)
    seed = resolve_seed(seed, pipeline)
    _warn_if_esrgan_unavailable(upscaler)
    adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)

    if output is None:
        output = source.with_stem(source.stem + "_clean")

    t0 = time.monotonic()

    # Tracks whether step 2 (invisible / SynthID removal) was skipped because the
    # GPU extra is missing. A skipped step 2 still produces an output file (visible
    # mark + metadata stripped), so without a loud end-of-run notice + non-zero exit
    # the user mistakes it for a clean result and ships an image that still carries
    # the invisible watermark (recurring reports: #14, #47).
    synthid_skipped = False

    # Use a temp file for intermediate results so the user doesn't see
    # a partial output file during long model downloads.
    import tempfile

    tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=source.suffix)
    tmp_path = Path(tmp_path_str)
    try:
        import os

        os.close(tmp_fd)

        # ── Step 1: Visible watermark ──
        console.print("\n  1) Visible watermark removal")
        image, alpha = image_io.read_bgr_and_alpha(source)
        if image is None:
            console.print(f"Error: Failed to read image: {source}")
            raise SystemExit(1)

        h, w = image.shape[:2]
        console.print(f"    Input: {source.name}  ({w}x{h})")

        with console.status("Removing visible watermark..."):
            result, removed_label = _remove_visible_auto(
                image, source_path=source, backend=backend, sensitivity=sensitivity
            )
            if removed_label is not None:
                console.print(f"    Visible watermark removed ({removed_label})")
            else:
                console.print("    Skipped (no visible watermark detected)")

        # Save to temp file for invisible engine input (preserve alpha if present)
        image_io.write_bgr_with_alpha(tmp_path, result, alpha)

        # ── Step 2: Invisible watermark ──
        console.print("\n  2) Invisible watermark removal")
        from remove_ai_watermarks.invisible_engine import is_available as invisible_available

        if not invisible_available():
            synthid_skipped = True
            console.print(
                "    Warning: Skipped - GPU dependencies not installed.\n"
                "    Install them with: pip install 'remove-ai-watermarks[gpu]'"
            )
        elif _should_skip_invisible_scrub(force, source):
            # No locally-detectable invisible watermark -> skip the destructive
            # regeneration (it would only degrade the image). The visible-removed
            # pixels in tmp_path are kept and step 3 still strips metadata, so this
            # is a SUCCESS (exit 0), unlike the GPU-missing skip above. Read the
            # pristine `source`, not tmp_path whose C2PA the visible pass already
            # dropped. Not a clean-image guarantee; --force overrides.
            console.print(
                "    Skipped (no invisible AI watermark detected; pixels left intact).\n"
                "    Not a clean-image guarantee: a pixel SynthID is undetectable once its\n"
                "    metadata proxy is gone. Re-run with --force to scrub regardless."
            )
        else:
            from remove_ai_watermarks.invisible_engine import InvisibleEngine

            device_str = None if device == "auto" else device

            def progress_cb(msg: str) -> None:
                console.print(f"    {msg}")

            inv_engine = InvisibleEngine(
                model_id=model,
                device=device_str,
                pipeline=pipeline,
                hf_token=hf_token,
                progress_callback=progress_cb,
                controlnet_conditioning_scale=controlnet_scale,
                cpu_offload=cpu_offload,
            )

            # Detect the vendor from the pristine ORIGINAL (`source`); `tmp_path` has
            # already lost its C2PA to the visible-removal pass, so reading it would
            # always resolve to the unknown-vendor default.
            vendor = vendor_for_strength(source)
            console.print(
                f"    Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}  Steps: {steps}"
            )
            inv_engine.remove_watermark(
                image_path=tmp_path,
                output_path=tmp_path,
                strength=strength,
                num_inference_steps=steps,
                guidance_scale=guidance_scale,
                seed=seed,
                humanize=humanize,
                unsharp=unsharp,
                adaptive_polish=adaptive_polish,
                max_resolution=max_resolution,
                min_resolution=min_resolution,
                upscaler=upscaler,
                vendor=vendor,
                tile=tile,
                tile_size=tile_size,
                tile_overlap=tile_overlap,
            )
            console.print("    Invisible watermark removed")

        # ── Step 3: Metadata ──
        console.print("\n  3) AI metadata stripping")
        try:
            from remove_ai_watermarks.metadata import strip_and_verify

            _, leftover = strip_and_verify(tmp_path, tmp_path)
        except Exception as e:
            console.print(f"    Error: metadata strip failed: {e}")
            raise SystemExit(1) from e
        if leftover:
            console.print(f"    Error: metadata stripping was incomplete; {', '.join(sorted(leftover))} survived")
            raise SystemExit(1)
        console.print("    AI metadata stripped")

        # ── Write final result ──
        # The invisible step (and downstream cv2.IMREAD_COLOR paths) drops alpha,
        # so re-attach the original alpha plane unchanged when writing the final
        # output for transparent formats.
        final_bgr, _ = image_io.read_bgr_and_alpha(tmp_path)
        if final_bgr is None:
            console.print(f"Error: Failed to read intermediate file: {tmp_path}")
            raise SystemExit(1)
        _write_output_or_exit(output, final_bgr, alpha)

    finally:
        # Clean up temp file if it still exists
        if tmp_path.exists():
            tmp_path.unlink()

    # ── Done ──
    elapsed = time.monotonic() - t0
    size_kb = output.stat().st_size / 1024
    console.print(f"\n  Done: {output}  ({size_kb:.0f} KB, {elapsed:.1f}s total)")

    # A skipped invisible step is the single most common "it didn't work" report:
    # the output looks processed but still carries the SynthID watermark. Make that
    # impossible to miss -- a prominent banner plus a non-zero exit so scripts and
    # batch callers can detect the incomplete run instead of trusting the file.
    if synthid_skipped:
        console.print(
            "\n  =====================================================================\n"
            "  WARNING: the invisible (SynthID) watermark was NOT removed.\n"
            "  Step 2 was skipped because the GPU dependencies are not installed,\n"
            "  so this output still carries the invisible watermark -- only the\n"
            "  visible mark and metadata were stripped.\n"
            "\n"
            "  Install the extra and rerun to remove it:\n"
            "    pip install 'remove-ai-watermarks[gpu]'\n"
            "  ====================================================================="
        )
        raise SystemExit(1)


# ── Batch command ──
def _passthrough_copy(img_path: Path, out_path: Path) -> None:
    """Copy the input's pixels through to ``out_path`` unchanged (the invisible-mode skip
    paths), so the output dir stays complete without touching the pixels."""
    src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
    if src_bgr is not None and not image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha):
        # The point of this copy is to keep the output dir COMPLETE. A silently-dropped
        # copy defeats that and leaves a hole the caller cannot see (Tier E, 2026-07-20).
        raise OSError(f"failed to copy input through to output: {out_path}")


@dataclass(frozen=True)
class _BatchOptions:
    """Validated processing options shared by every image in one batch.

    Click necessarily exposes these as individual command parameters, but the
    processing core should receive one coherent value instead of a 21-argument
    call. Keeping the object immutable also makes it safe to reuse while the
    batch caches model instances in ``ctx.obj``.
    """

    strength: float | None
    steps: int
    pipeline: str
    device: str
    seed: int | None
    hf_token: str | None
    humanize: float
    backend: str = "auto"
    sensitivity: str = "auto"
    unsharp: float = 0.0
    max_resolution: int = 0
    min_resolution: int = 1024
    controlnet_scale: float = 1.0
    upscaler: str = "lanczos"
    model: str | None = None
    guidance_scale: float | None = None
    adaptive_polish: bool = False
    tile: bool = False
    tile_size: int = 1024
    tile_overlap: int = 128
    force: bool = False
    cpu_offload: bool = False


def _run_batch_invisible(
    ctx: click.Context,
    img_path: Path,
    out_path: Path,
    mode: str,
    options: _BatchOptions,
) -> bool:
    """Run or safely skip the invisible pass for one batch image.

    Returns ``True`` only when a detectable target could not be processed because
    the GPU dependencies are missing. The availability probe is intentionally
    evaluated once so branching cannot observe inconsistent optional-dependency
    state.
    """
    from remove_ai_watermarks.invisible_engine import is_available as invisible_available

    skip_no_signal = _should_skip_invisible_scrub(options.force, img_path)
    available = invisible_available()
    if available and not skip_no_signal:
        from remove_ai_watermarks.invisible_engine import InvisibleEngine

        # Cache the engine in ctx.obj so the batch builds it once (pipeline is a
        # single CLI value, constant across the run).
        engines = ctx.obj.setdefault("_inv_engines", {})
        if options.pipeline not in engines:
            engines[options.pipeline] = InvisibleEngine(
                model_id=options.model,
                device=None if options.device == "auto" else options.device,
                pipeline=options.pipeline,
                hf_token=options.hf_token,
                controlnet_conditioning_scale=options.controlnet_scale,
                cpu_offload=options.cpu_offload,
            )
        engines[options.pipeline].remove_watermark(
            img_path if mode == "invisible" else out_path,
            out_path,
            strength=options.strength,
            num_inference_steps=options.steps,
            guidance_scale=options.guidance_scale,
            seed=options.seed,
            humanize=options.humanize,
            unsharp=options.unsharp,
            adaptive_polish=options.adaptive_polish,
            max_resolution=options.max_resolution,
            min_resolution=options.min_resolution,
            upscaler=options.upscaler,
            tile=options.tile,
            tile_size=options.tile_size,
            tile_overlap=options.tile_overlap,
            # Detect the vendor from the pristine original (`img_path`), not the
            # visible-processed `out_path` whose C2PA is already gone.
            vendor=vendor_for_strength(img_path),
        )
        return False

    # Invisible-only mode has no preceding visible pass to create ``out_path``.
    # Preserve a complete output directory while deliberately leaving pixels intact.
    if mode == "invisible" and not out_path.exists():
        _passthrough_copy(img_path, out_path)
    return not available and not skip_no_signal


def _process_batch_image(
    ctx: click.Context,
    img_path: Path,
    out_path: Path,
    mode: str,
    options: _BatchOptions,
) -> bool:
    """Process a single image for batch mode.

    Applies the requested watermark removal steps (visible, invisible,
    metadata) to *img_path* and writes the result to *out_path*.

    Returns True if the invisible (SynthID) scrub was skipped because the GPU deps
    are missing while a signal was present -- so the batch caller can warn + exit
    non-zero, mirroring the single ``all`` command.

    Raises:
        ValueError: If the image cannot be opened.
    """
    saved_alpha: NDArray[Any] | None = None
    synthid_skipped = False

    if mode in ("visible", "all"):
        # Always read the ORIGINAL source: the visible pass is the first step, so a
        # stale out_path from a previous run must not be re-processed as if it were
        # the input. (The invisible step below reads out_path for `all` -- that chain
        # is within a single run.)
        image, alpha = image_io.read_bgr_and_alpha(img_path)
        if image is None:
            raise ValueError("Failed to read image")

        result, _ = _remove_visible_auto(
            image,
            source_path=img_path,
            backend=options.backend,
            sensitivity=options.sensitivity,
        )

        # RAISE, never SystemExit: the batch loop catches per-image exceptions, counts
        # them and exits non-zero. Discarding this flag made a read-only output directory
        # produce ZERO files and still exit 0 -- silent data loss that also contradicted
        # the documented batch contract (Tier E, 2026-07-20).
        if not image_io.write_bgr_with_alpha(out_path, result, alpha):
            raise OSError(f"failed to write output (is the destination writable?): {out_path}")
        saved_alpha = alpha

    if mode in ("invisible", "all"):
        # Skip the destructive regeneration when no invisible watermark is locally
        # detectable (would only degrade a clean image). Read the pristine `img_path`;
        # `out_path` may already be the visible-processed result. --force overrides.
        synthid_skipped = _run_batch_invisible(ctx, img_path, out_path, mode, options)

    if mode in ("metadata", "all"):
        from remove_ai_watermarks.metadata import strip_and_verify

        # Same verification the single-image command does: the fail-safe copy-through
        # would otherwise leave an AI-reading output and still exit 0, contradicting the
        # batch contract that a failed image must make the run exit non-zero.
        _, leftover = strip_and_verify(img_path if mode == "metadata" else out_path, out_path)
        if leftover:
            msg = f"AI metadata survived the strip ({', '.join(sorted(leftover))}); file could not be decoded"
            raise RuntimeError(msg)

    # In "all" mode, the invisible step (color-only OpenCV paths) drops alpha,
    # so re-attach the cached alpha when the input had transparency.
    if mode == "all" and saved_alpha is not None:
        final_bgr, _ = image_io.read_bgr_and_alpha(out_path)
        if final_bgr is not None and not image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha):
            raise OSError(f"failed to re-attach alpha to output: {out_path}")

    return synthid_skipped


@main.command("batch")
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
@click.option(
    "-o",
    "--output-dir",
    type=click.Path(path_type=Path),
    default=None,
    help="Output directory (default: <dir>_clean/).",
)
@click.option(
    "--mode", type=click.Choice(["visible", "invisible", "metadata", "all"]), default="visible", help="Processing mode."
)
@_strength_option
@click.option(
    "--steps",
    type=int,
    default=None,
    help="Number of denoising steps. Default: 4 for qwen-zimage, 50 otherwise.",
)
@_visible_backend_option
@_visible_sensitivity_option
@click.option(
    "--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@_pipeline_option
@click.option(
    "--device",
    type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
    default="auto",
    help="Inference device.",
)
@click.option(
    "--seed",
    type=int,
    default=None,
    help="Random seed for reproducibility. Default: 0 for qwen-zimage, random otherwise.",
)
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
    "--max-resolution",
    type=int,
    default=0,
    help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
)
@_min_resolution_option
@_unsharp_option
@_upscaler_option
@_controlnet_scale_option
@_model_option
@_guidance_scale_option
@_auto_option
@_adaptive_polish_option
@_tile_options
@_force_option
@_cpu_offload_option
@click.pass_context
def cmd_batch(
    ctx: click.Context,
    directory: Path,
    mode: str,
    output_dir: Path | None,
    strength: float | None,
    steps: int | None,
    pipeline: str,
    device: str,
    seed: int | None,
    hf_token: str | None,
    backend: str,
    sensitivity: str,
    humanize: float,
    unsharp: float,
    max_resolution: int,
    min_resolution: int,
    controlnet_scale: float,
    upscaler: str,
    model: str | None,
    guidance_scale: float | None,
    auto: bool,
    adaptive_polish: bool,
    tile: bool,
    tile_size: int,
    tile_overlap: int,
    force: bool,
    cpu_offload: bool,
) -> None:
    """Process all images in a directory."""
    _banner()

    if output_dir is None:
        output_dir = directory.parent / (directory.name + "_clean")
    output_dir.mkdir(parents=True, exist_ok=True)

    images = sorted(p for p in directory.iterdir() if p.suffix.lower() in SUPPORTED_FORMATS)

    if not images:
        console.print(f"No supported images found in {directory}")
        return

    console.print(f"  Found {len(images)} images in {directory}")
    console.print(f"  Output -> {output_dir}")
    console.print(f"  Mode: {mode}")
    if mode in ("invisible", "all"):
        _warn_if_esrgan_unavailable(upscaler)
    adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
    steps = resolve_steps(steps, pipeline)
    seed = resolve_seed(seed, pipeline)
    options = _BatchOptions(
        strength=strength,
        steps=steps,
        pipeline=pipeline,
        device=device,
        seed=seed,
        hf_token=hf_token,
        humanize=humanize,
        backend=backend,
        sensitivity=sensitivity,
        unsharp=unsharp,
        max_resolution=max_resolution,
        min_resolution=min_resolution,
        controlnet_scale=controlnet_scale,
        upscaler=upscaler,
        model=model,
        guidance_scale=guidance_scale,
        adaptive_polish=adaptive_polish,
        tile=tile,
        tile_size=tile_size,
        tile_overlap=tile_overlap,
        force=force,
        cpu_offload=cpu_offload,
    )

    processed = 0
    errors = 0
    synthid_skipped_count = 0

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(),
        TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
        TimeElapsedColumn(),
        console=console,
    ) as progress:
        task = progress.add_task("Processing...", total=len(images))

        for img_path in images:
            out_path = output_dir / img_path.name
            progress.update(task, description=f"{img_path.name}")

            try:
                if _process_batch_image(
                    ctx=ctx,
                    img_path=img_path,
                    out_path=out_path,
                    mode=mode,
                    options=options,
                ):
                    synthid_skipped_count += 1
                processed += 1

            except Exception as e:
                errors += 1
                if ctx.obj.get("verbose"):
                    console.print(f"  {img_path.name}: {e}")

            progress.advance(task)

    console.print(f"\n  {processed} processed" + (f"  {errors} errors" if errors else ""))

    if synthid_skipped_count:
        # Mirror the single `all` command: a silently retained SynthID watermark is the
        # #1 "it didn't work" report, so make the skipped scrub impossible to miss.
        console.print(
            f"\n  WARNING: the invisible (SynthID) watermark was NOT removed on "
            f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
            f"so those outputs still carry the invisible watermark.\n"
            f"  Install the extra and rerun: pip install 'remove-ai-watermarks[gpu]'"
        )

    # Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
    # to always exit 0, hiding both per-image errors and skipped SynthID scrubs).
    if errors or synthid_skipped_count:
        raise SystemExit(1)


if __name__ == "__main__":
    main()
