"""Tests for AI metadata detection and removal."""

from __future__ import annotations

import shutil
import struct
import subprocess
from pathlib import Path

import piexif
import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo

from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.metadata import (
    C2PA_UUID,
    _is_ai_key,
    c2pa_marker_in,
    exif_generator,
    get_ai_metadata,
    has_ai_metadata,
    iptc_ai_system,
    remove_ai_metadata,
    samsung_genai,
    strip_and_verify,
    synthid_source,
    xai_signature,
)

# Real, committed C2PA sample images used to ground the SynthID-source tests.
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"


def _png_chunk(kind: bytes, payload: bytes, *, corrupt_crc: bool = False) -> bytes:
    """Encode one PNG chunk, optionally corrupting its CRC."""
    import zlib

    crc = zlib.crc32(kind + payload) & 0xFFFFFFFF
    if corrupt_crc:
        crc ^= 1
    return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)


def _corrupt_c2pa_png(path: Path) -> Path:
    """Write a synthetic PNG that Pillow rejects but OpenCV can decode."""
    Image.new("RGB", (32, 32), (80, 120, 160)).save(path)
    png = path.read_bytes()
    idat_start = png.index(b"IDAT") - 4
    fake_c2pa = _png_chunk(b"caBX", b"\x00\x00\x00\x10jumbsynthetic-c2pa")
    bad_exif = _png_chunk(b"eXIf", b"Exif\x00\x00synthetic", corrupt_crc=True)
    path.write_bytes(png[:idat_start] + fake_c2pa + bad_exif + png[idat_start:])
    return path


# ── Key detection ───────────────────────────────────────────────────


class TestIsAiKey:
    """Tests for _is_ai_key helper."""

    def test_exact_match_lowercase(self):
        assert _is_ai_key("parameters")

    def test_exact_match_mixed_case(self):
        assert _is_ai_key("Parameters")

    def test_keyword_substring(self):
        assert _is_ai_key("stable_diffusion_model_v2")

    def test_c2pa_detected(self):
        assert _is_ai_key("c2pa_chunk")

    def test_standard_key_not_flagged(self):
        assert not _is_ai_key("Author")

    def test_innocuous_key_not_flagged(self):
        assert not _is_ai_key("Title")

    def test_dpi_not_flagged(self):
        assert not _is_ai_key("dpi")


# ── has_ai_metadata / get_ai_metadata ───────────────────────────────


class TestHasAiMetadata:
    """Tests for detecting AI metadata in images."""

    def test_detects_ai_metadata(self, tmp_png_with_ai_metadata):
        assert has_ai_metadata(tmp_png_with_ai_metadata)

    def test_clean_image_no_ai(self, tmp_clean_png):
        assert not has_ai_metadata(tmp_clean_png)

    def test_detects_c2pa_uuid_in_isobmff_container(self, tmp_path: Path):
        """C2PA in AVIF/HEIF/MP4 lives in a ``uuid`` box identified by a fixed UUID.

        Real AVIF/HEIF fixtures aren't shipped, so simulate the container by
        prepending an ISOBMFF-shaped ftyp box and the C2PA UUID bytes.
        """
        from remove_ai_watermarks.metadata import C2PA_UUID

        path = tmp_path / "fake.avif"
        # ftyp box: size(4) + 'ftyp' + 'avif' + minor_version(4) + 'avif'
        ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
        # uuid box: size(4) + 'uuid' + 16-byte UUID + minimal payload
        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"jumb-payload"
        path.write_bytes(ftyp + uuid_box + b"\x00" * 64)
        assert has_ai_metadata(path)

    def test_strip_c2pa_boxes_removes_uuid_box(self, tmp_path: Path):
        """ISOBMFF strip should drop the C2PA uuid box and keep everything else."""
        from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes
        from remove_ai_watermarks.metadata import C2PA_UUID

        ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
        # uuid box: size(4) + 'uuid' + 16-byte UUID + minimal payload (8 bytes -> total 32)
        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"payload!"
        mdat = b"\x00\x00\x00\x10mdat" + b"pixeldat"
        cleaned, stripped = strip_c2pa_boxes(ftyp + uuid_box + mdat)
        assert stripped == 1
        assert cleaned == ftyp + mdat

    def test_strip_c2pa_boxes_passthrough_for_non_isobmff(self):
        """Non-ISOBMFF input must be returned unchanged."""
        from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes

        data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00" * 100
        cleaned, stripped = strip_c2pa_boxes(data)
        assert stripped == 0
        assert cleaned == data

    def test_remove_ai_metadata_strips_c2pa_in_avif(self, tmp_path: Path):
        """End-to-end: ``remove_ai_metadata`` on a fake .avif drops the C2PA box."""
        from remove_ai_watermarks.metadata import C2PA_UUID, remove_ai_metadata

        src = tmp_path / "in.avif"
        ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"payload!"
        mdat = b"\x00\x00\x00\x10mdat" + b"pixeldat"
        src.write_bytes(ftyp + uuid_box + mdat)

        out = tmp_path / "out.avif"
        result = remove_ai_metadata(src, out)
        assert result == out
        assert out.read_bytes() == ftyp + mdat
        # And after stripping, detection must no longer flag the cleaned file.
        from remove_ai_watermarks.metadata import has_ai_metadata

        assert not has_ai_metadata(out)

    def test_remove_ai_metadata_blanks_exif_token_item_in_avif(self, tmp_path: Path):
        """End-to-end: ``remove_ai_metadata`` blanks an AI-generator EXIF token
        stored as a meta-box Exif item (bytes in mdat) without re-encoding."""
        from remove_ai_watermarks.metadata import remove_ai_metadata

        ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
        blob = piexif.dump({"0th": {piexif.ImageIFD.Software: b"Midjourney", piexif.ImageIFD.Make: b"NIKON"}})
        mdat = struct.pack(">I", 8 + len(blob)) + b"mdat" + blob
        src = tmp_path / "in.avif"
        src.write_bytes(ftyp + mdat)

        out = tmp_path / "out.avif"
        remove_ai_metadata(src, out)
        cleaned = out.read_bytes()
        assert len(cleaned) == len(ftyp + mdat)  # in place, no re-encode
        assert b"Midjourney" not in cleaned  # AI token gone
        assert b"NIKON" in cleaned  # camera tag preserved

    def test_detects_iptc_trained_algorithmic_media_marker(self, tmp_path: Path):
        """Some pipelines embed only the IPTC AI marker in XMP, no C2PA manifest."""
        path = tmp_path / "fake.jpg"
        # Minimal JPEG-ish bytes containing the IPTC AI marker in an XMP-like blob.
        xmp = (
            b"<x:xmpmeta><Iptc4xmpExt:DigitalSourceType>"
            b"trainedAlgorithmicMedia"
            b"</Iptc4xmpExt:DigitalSourceType></x:xmpmeta>"
        )
        path.write_bytes(b"\xff\xd8\xff\xe1" + xmp + b"\xff\xd9")
        assert has_ai_metadata(path)

    @pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
    def test_jpeg_metadata_strip_is_pixel_lossless(self, tmp_path: Path):
        """A JPEG metadata strip must NOT re-encode the DCT scan: pixels stay
        bit-identical, only the AI provenance APP segments are removed. Verified on real
        committed fixtures -- grok-1.jpg (EXIF signature), flux-1.jpg (C2PA APP11)."""
        import numpy as np

        from remove_ai_watermarks import image_io
        from remove_ai_watermarks.metadata import remove_ai_metadata

        for name in ("grok-1.jpg", "flux-1.jpg"):
            src = SAMPLES_DIR / name
            if not src.exists():
                continue
            before = image_io.imread(str(src))
            out = tmp_path / name
            remove_ai_metadata(src, out)
            after = image_io.imread(str(out))
            assert before is not None
            assert after is not None
            assert np.array_equal(before, after), f"{name}: pixels changed (DCT was re-encoded)"
            assert not has_ai_metadata(out), f"{name}: AI metadata survived the strip"

    def test_strip_preserves_lossless_content_with_mismatched_extension(self, tmp_path: Path):
        """F1 regression: the save format is chosen by CONTENT, not the file extension.
        PNG content served with a .jpg name must be stripped losslessly as PNG, NOT
        re-encoded into a real JPEG. The extension-driven path silently degraded it,
        breaking the 'work with originals' invariant."""
        import numpy as np
        from PIL import Image
        from PIL.PngImagePlugin import PngInfo

        from remove_ai_watermarks import image_io
        from remove_ai_watermarks.metadata import remove_ai_metadata

        # Random noise: JPEG (even q95 4:4:4) provably shifts it; PNG stays exact.
        arr = np.random.default_rng(0).integers(0, 256, (64, 64, 3), dtype=np.uint8)
        info = PngInfo()
        info.add_text("parameters", "Stable Diffusion prompt")  # an AI-provenance key
        src = tmp_path / "actually_png.jpg"  # PNG bytes under a .jpg name
        Image.fromarray(arr).save(src, format="PNG", pnginfo=info)
        assert has_ai_metadata(src)

        out = tmp_path / "cleaned.jpg"
        remove_ai_metadata(src, out)

        before = image_io.imread(str(src))
        after = image_io.imread(str(out))
        assert before is not None
        assert after is not None
        assert np.array_equal(before, after), "misnamed PNG was lossily re-encoded to JPEG"
        assert not has_ai_metadata(out), "AI metadata survived the strip"

    @staticmethod
    def _xmp_iptc_jpeg(tmp_path: Path, name: str, marker: bytes) -> Path:
        """A real (decodable) JPEG carrying the IPTC AI marker in a well-formed APP1
        XMP segment -- the layout Instagram/Facebook/X and MidJourney/Meta use, where
        ``digitalSourceType`` lives in XMP rather than the APP13 IPTC-IIM record.
        Synthetic (no corpus): a solid cv2 JPEG with the APP1 spliced in after SOI."""
        import cv2
        import numpy as np

        real = tmp_path / f"real-{name}"
        cv2.imwrite(str(real), np.full((32, 32, 3), 200, np.uint8), [cv2.IMWRITE_JPEG_QUALITY, 100])
        data = real.read_bytes()
        xmp = (
            b"http://ns.adobe.com/xap/1.0/\x00"
            b'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF><rdf:Description>'
            b"<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/"
            + marker
            + b"</Iptc4xmpExt:DigitalSourceType></rdf:Description></rdf:RDF></x:xmpmeta>"
        )
        seg = b"\xff\xe1" + (len(xmp) + 2).to_bytes(2, "big") + xmp
        path = tmp_path / name
        path.write_bytes(data[:2] + seg + data[2:])  # splice APP1 right after SOI
        return path

    @pytest.mark.parametrize("marker", [b"trainedAlgorithmicMedia", b"AISystemUsed"])
    def test_jpeg_strip_removes_iptc_marker_in_xmp(self, tmp_path: Path, marker: bytes):
        """Regression: the lossless JPEG strip must drop an AI-bearing APP1 XMP packet
        when the AI signal is an IPTC ``digitalSourceType`` / 2025.1 field, not only a
        C2PA or China-AIGC token. Before the fix these survived because the APP1 branch
        of ``_jpeg_app_carries_ai`` checked only c2pa + AIGC markers, leaving the
        Instagram/MidJourney/Meta 'Made with AI' XMP intact. Pixels stay bit-identical."""
        import numpy as np

        from remove_ai_watermarks import image_io
        from remove_ai_watermarks.metadata import remove_ai_metadata

        src = self._xmp_iptc_jpeg(tmp_path, "iptc-xmp.jpg", marker)
        assert has_ai_metadata(src)  # detected before
        before = image_io.imread(str(src))
        out = tmp_path / "clean.jpg"
        remove_ai_metadata(src, out)
        after = image_io.imread(str(out))
        assert before is not None
        assert after is not None
        assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
        assert not has_ai_metadata(out), "IPTC AI marker in XMP survived the strip"

    def test_remove_ai_metadata_failsafe_on_truncated_png(self, tmp_path: Path):
        """Regression: a truncated / corrupt image must NOT crash remove_ai_metadata (a
        direct library caller like a web worker would 500 on a partial upload). PIL raises
        OSError decoding a truncated PNG; the strip must fail SAFE -- copy the input through
        unchanged and return, mirroring strip_c2pa_boxes."""
        import cv2
        import numpy as np

        from remove_ai_watermarks.metadata import remove_ai_metadata

        real = tmp_path / "real.png"
        cv2.imwrite(str(real), np.random.default_rng(0).integers(0, 256, (128, 128, 3), dtype=np.uint8))
        truncated = tmp_path / "truncated.png"
        truncated.write_bytes(real.read_bytes()[: real.stat().st_size // 2])  # chop the IDAT stream
        out = tmp_path / "out.png"
        # Must not raise; output is the input copied through (undecodable -> nothing to strip).
        result = remove_ai_metadata(truncated, out)
        assert result == out
        assert out.read_bytes() == truncated.read_bytes()

    def test_strip_and_verify_normalizes_decodable_copy_through(self, tmp_path: Path):
        """A decodable raster must not retain C2PA after verified stripping."""
        import numpy as np

        from remove_ai_watermarks import image_io

        src = _corrupt_c2pa_png(tmp_path / "source.png")
        out = tmp_path / "clean.png"
        assert has_ai_metadata(src)
        before = image_io.imread(src)
        assert before is not None

        result, remaining = strip_and_verify(src, out)

        assert result == out
        assert remaining == {}
        assert not has_ai_metadata(out)
        after = image_io.imread(out)
        assert after is not None
        assert np.array_equal(after, before)

    def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path):
        """A truly undecodable file keeps the established fail-safe result."""
        src = _corrupt_c2pa_png(tmp_path / "source.png")
        src.write_bytes(src.read_bytes()[: src.stat().st_size // 2])
        out = tmp_path / "copy.png"

        result, remaining = strip_and_verify(src, out)

        assert result == out
        assert "c2pa_manifest" in remaining
        assert out.read_bytes() == src.read_bytes()


class TestC2paMarkerIn:
    """The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
    a bare 4-byte ``c2pa`` substring (e.g. random compressed pixel data) does not
    false-positive -- the regression behind 4 cleaned PNGs re-flagging C2PA."""

    def test_jumbf_wrapped_c2pa_detected(self):
        assert c2pa_marker_in(b"....jumbc2pa....manifest....") is True

    def test_c2pa_uuid_box_detected(self):
        assert c2pa_marker_in(b"\x00\x00\x00\x18uuid" + C2PA_UUID + b"payload") is True

    def test_bare_c2pa_substring_not_detected(self):
        # The exact false positive: "c2pa" appears in noise but no JUMBF/uuid box.
        assert c2pa_marker_in(b"\x9c\xc3\xa7B1\x11c2pa\x80b\x804\xc5\xf9random idat") is False

    def test_jumb_without_c2pa_not_detected(self):
        assert c2pa_marker_in(b"some jumb box but no manifest label") is False

    def test_empty_not_detected(self):
        assert c2pa_marker_in(b"") is False


class TestSamsungGenai:
    """Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data).

    Synthetic byte blobs -- real Galaxy files are user content and not shipped
    (public repo), same discipline as the Grok/Doubao fixtures.
    """

    @staticmethod
    def _samsung_jpeg(tmp_path: Path, name: str, payload: bytes) -> Path:
        path = tmp_path / name
        path.write_bytes(b"\xff\xd8\xff\xe1" + payload + b"\xff\xd9")
        return path

    def test_nonzero_genai_type_detected(self, tmp_path: Path):
        p = self._samsung_jpeg(
            tmp_path, "galaxy.jpg", b'PhotoEditor_Re_Edit_Data{"connectorType":"srvg","genAIType":1}'
        )
        assert samsung_genai(p) == 1

    def test_other_nonzero_value_detected(self, tmp_path: Path):
        p = self._samsung_jpeg(tmp_path, "galaxy5.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":5}')
        assert samsung_genai(p) == 5

    def test_zero_genai_type_is_none(self, tmp_path: Path):
        """genAIType:0 means no generative AI was used -- not a positive signal."""
        p = self._samsung_jpeg(tmp_path, "edit.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":0}')
        assert samsung_genai(p) is None

    def test_genai_without_editor_container_ignored(self, tmp_path: Path):
        """An incidental genAIType token outside Samsung's editor JSON is ignored."""
        p = self._samsung_jpeg(tmp_path, "stray.jpg", b'some other blob "genAIType":1 elsewhere')
        assert samsung_genai(p) is None

    def test_remove_strips_post_eoi_trailer(self, tmp_path: Path):
        """Regression: Galaxy AI appends ``PhotoEditor_Re_Edit_Data`` as a trailer AFTER
        the JPEG EOI, so the verbatim scan copy in the lossless strip carried it through
        in compatibility testing. The strip now truncates a Samsung AI trailer at EOI;
        pixels stay bit-identical and a non-Samsung trailer is preserved."""
        import cv2
        import numpy as np

        from remove_ai_watermarks import image_io
        from remove_ai_watermarks.metadata import remove_ai_metadata

        real = tmp_path / "real.jpg"
        cv2.imwrite(str(real), np.full((32, 32, 3), 180, np.uint8), [cv2.IMWRITE_JPEG_QUALITY, 100])
        src = tmp_path / "galaxy.jpg"
        src.write_bytes(real.read_bytes() + b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
        assert samsung_genai(src) == 1  # detected before
        before = image_io.imread(str(src))
        out = tmp_path / "clean.jpg"
        remove_ai_metadata(src, out)
        after = image_io.imread(str(out))
        assert before is not None
        assert after is not None
        assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
        assert samsung_genai(out) is None, "Samsung genAIType trailer survived the strip"

    def test_non_samsung_trailer_preserved(self, tmp_path: Path):
        """A benign post-EOI trailer (e.g. an MPF block) must NOT be truncated."""
        import cv2
        import numpy as np

        from remove_ai_watermarks.metadata import _strip_samsung_trailer

        real = tmp_path / "real.jpg"
        cv2.imwrite(str(real), np.full((16, 16, 3), 90, np.uint8))
        tail = real.read_bytes() + b"MPF-benign-trailer-bytes"
        assert _strip_samsung_trailer(tail) == tail

    def test_detects_trailer_past_scan_window(self, tmp_path: Path):
        """Regression: the marker is a trailer AFTER the JPEG EOI, so on a multi-MB
        photo it sits past the 512 KB quick-scan window. Detection must read the file
        tail too, else it disagrees with removal (which reads the whole file). A random
        1400x1400 q100 JPEG exceeds 512 KB; the marker is only in its post-EOI tail."""
        import cv2
        import numpy as np

        real = tmp_path / "big.jpg"
        big = np.random.default_rng(0).integers(0, 256, (1400, 1400, 3), dtype=np.uint8)
        cv2.imwrite(str(real), big, [cv2.IMWRITE_JPEG_QUALITY, 100])
        assert real.stat().st_size > 512 * 1024  # trailer will be past the quick-scan window
        p = tmp_path / "galaxy_big.jpg"
        p.write_bytes(real.read_bytes() + b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
        assert samsung_genai(p) == 1

    def test_clean_image_is_none(self, tmp_clean_png):
        assert samsung_genai(tmp_clean_png) is None

    def test_surfaced_in_get_ai_metadata(self, tmp_path: Path):
        p = self._samsung_jpeg(tmp_path, "galaxy.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
        meta = get_ai_metadata(p)
        assert "samsung_genai" in meta
        assert "genAIType=1" in meta["samsung_genai"]


class TestGetAiMetadata:
    """Tests for extracting AI metadata."""

    def test_extracts_parameters_key(self, tmp_png_with_ai_metadata):
        meta = get_ai_metadata(tmp_png_with_ai_metadata)
        assert "parameters" in meta
        assert "Euler" in meta["parameters"]

    def test_extracts_prompt_key(self, tmp_png_with_ai_metadata):
        meta = get_ai_metadata(tmp_png_with_ai_metadata)
        assert "prompt" in meta

    def test_does_not_extract_author(self, tmp_png_with_ai_metadata):
        meta = get_ai_metadata(tmp_png_with_ai_metadata)
        assert "Author" not in meta

    def test_clean_image_empty_dict(self, tmp_clean_png):
        meta = get_ai_metadata(tmp_clean_png)
        assert meta == {}

    def test_long_value_is_truncated(self, tmp_path: Path):
        img = Image.new("RGB", (32, 32))
        pnginfo = PngInfo()
        pnginfo.add_text("parameters", "x" * 300)
        path = tmp_path / "long.png"
        img.save(path, pnginfo=pnginfo)
        meta = get_ai_metadata(path)
        assert meta["parameters"].endswith("…")
        assert len(meta["parameters"]) <= 205

    def test_unopenable_file_does_not_raise(self, tmp_path: Path):
        # PIL can't open HEIC without pillow-heif; get_ai_metadata must fall
        # through to the binary scan, not propagate UnidentifiedImageError.
        path = tmp_path / "iphone.heic"
        path.write_bytes(b"\x00\x00\x00\x18ftypheic" + b"\x00" * 64)
        assert get_ai_metadata(path) == {}


@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
class TestGetAiMetadataRealSample:
    """get_ai_metadata surfaces the consolidated C2PA fields on real images."""

    def test_openai_sample_fields(self):
        meta = get_ai_metadata(SAMPLES_DIR / "chatgpt-1.png")
        assert "claim_generator" in meta
        assert "OpenAI" in meta["issuer"]
        assert "synthid_watermark" not in meta
        assert "trainedAlgorithmicMedia" in meta["source_type"]
        assert meta["c2pa_integrity"] == "valid"
        assert meta["c2pa_signature"] == "valid"
        assert meta["c2pa_signer_trust"] == "untrusted"
        assert meta["c2pa_signer_validity"] == "expired"


@pytest.mark.parametrize(
    "marker",
    [
        b"trainedAlgorithmicMedia",
        b"compositeSynthetic",
        b"compositeWithTrainedAlgorithmicMedia",
    ],
)
def test_has_ai_metadata_detects_each_iptc_marker(tmp_path: Path, marker: bytes):
    """Each IPTC digitalSourceType AI marker in XMP triggers detection."""
    path = tmp_path / "iptc.jpg"
    path.write_bytes(b"\xff\xd8\xff\xe1<x:xmpmeta>" + marker + b"</x:xmpmeta>\xff\xd9")
    assert has_ai_metadata(path)


def test_bare_algorithmic_media_not_flagged_ai(tmp_path: Path):
    """Regression: the IPTC ``algorithmicMedia`` digitalSourceType is PROCEDURAL (an
    algorithm not trained on sampled data), NOT AI/ML generation. It must NOT be flagged
    -- flagging it made identify assert is_ai=high + has_invisible_target=True, which
    would trigger a diffusion scrub of clean procedural content. It is a distinct token
    from ``trainedAlgorithmicMedia``, so real 'Made with AI' labels are unaffected."""
    path = tmp_path / "proc.jpg"
    path.write_bytes(
        b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:DigitalSourceType>"
        b"http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia"
        b"</Iptc4xmpExt:DigitalSourceType></x:xmpmeta>\xff\xd9"
    )
    assert not has_ai_metadata(path)


# ── SynthID-source detection (metadata proxy) ────────────────────────


@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/fixtures/provenance not present")
class TestSynthIDSource:
    """SynthID detection via the C2PA companion manifest.

    Google provenance implies SynthID under Google's all-media policy. OpenAI
    provenance requires the explicit C2PA watermark action: older ChatGPT
    Content Credentials predate SynthID and must not be upgraded into a pixel
    watermark claim merely because the issuer is OpenAI.
    """

    def test_legacy_openai_chatgpt_is_not_synthid_source(self):
        assert synthid_source(SAMPLES_DIR / "chatgpt-1.png") is None

    def test_legacy_openai_verdict_absent_from_get_ai_metadata(self):
        meta = get_ai_metadata(SAMPLES_DIR / "chatgpt-1.png")
        assert "synthid_watermark" not in meta

    def test_adobe_firefly_is_not_synthid_source(self):
        # Adobe signs C2PA (trainedAlgorithmicMedia) but embeds no SynthID.
        assert synthid_source(SAMPLES_DIR / "firefly-1.png") is None
        assert "synthid_watermark" not in get_ai_metadata(SAMPLES_DIR / "firefly-1.png")

    def test_non_ai_image_is_not_synthid_source(self, clean_photo: Path):
        assert synthid_source(clean_photo) is None


class TestSynthIDSourceNonPng:
    """SynthID-source detection must work beyond PNG.

    ChatGPT/Gemini images saved as JPEG/WebP/AVIF carry their C2PA manifest in
    a non-PNG container (JPEG APP11, ISOBMFF uuid box), so the PNG caBX parser
    misses them. These use synthetic byte blobs (real fixtures aren't shipped).
    """

    def _c2pa_jpeg(
        self,
        tmp_path: Path,
        name: str,
        issuer: bytes,
        marker: bytes = b"trainedAlgorithmicMedia",
        action: bytes = b"",
    ) -> Path:
        path = tmp_path / name
        # Minimal JPEG shell with an embedded C2PA-ish blob.
        blob = b"jumbc2pa" + issuer + b"..." + marker + b"..." + action
        path.write_bytes(b"\xff\xd8\xff\xe1" + blob + b"\xff\xd9")
        return path

    def test_openai_watermark_action_in_jpeg(self, tmp_path: Path):
        path = self._c2pa_jpeg(
            tmp_path,
            "chatgpt.jpg",
            b"OpenAI",
            action=b"c2pa.watermarked.unbound",
        )
        assert synthid_source(path) == "OpenAI"

    def test_preextracted_c2pa_is_reused(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
        """A caller classifying another claim must not parse the manifest twice."""
        from remove_ai_watermarks._internal import c2pa

        path = self._c2pa_jpeg(tmp_path, "chatgpt-reused.jpg", b"OpenAI")
        info = {"synthid_vendors": ["OpenAI"]}

        def unexpected_extract(_path: Path):
            pytest.fail("synthid_source reparsed pre-extracted C2PA")

        monkeypatch.setattr(c2pa, "extract_c2pa_info", unexpected_extract)
        assert synthid_source(path, c2pa_info=info) == "OpenAI"

    def test_legacy_openai_c2pa_without_watermark_action_is_none(self, tmp_path: Path):
        path = self._c2pa_jpeg(tmp_path, "legacy-chatgpt.jpg", b"OpenAI")
        assert synthid_source(path) is None

    def test_google_c2pa_in_jpeg(self, tmp_path: Path):
        path = self._c2pa_jpeg(tmp_path, "gemini.jpg", b"Google")
        assert synthid_source(path) == "Google LLC"

    def test_adobe_c2pa_in_jpeg_is_none(self, tmp_path: Path):
        # Adobe signs C2PA but embeds no SynthID.
        path = self._c2pa_jpeg(tmp_path, "firefly.jpg", b"Adobe")
        assert synthid_source(path) is None

    def test_openai_without_ai_marker_is_none(self, tmp_path: Path):
        # Issuer present but no AI digital-source marker -> not a SynthID source.
        path = self._c2pa_jpeg(tmp_path, "edited.jpg", b"OpenAI", marker=b"")
        assert synthid_source(path) is None


# ── remove_ai_metadata ──────────────────────────────────────────────


class TestRemoveAiMetadata:
    """Tests for stripping AI metadata."""

    def test_removes_ai_keys(self, tmp_png_with_ai_metadata):
        output = tmp_png_with_ai_metadata.parent / "cleaned.png"
        remove_ai_metadata(tmp_png_with_ai_metadata, output)

        with Image.open(output) as img:
            assert "parameters" not in img.info
            assert "prompt" not in img.info

    def test_keeps_standard_metadata(self, tmp_png_with_ai_metadata):
        output = tmp_png_with_ai_metadata.parent / "cleaned.png"
        remove_ai_metadata(tmp_png_with_ai_metadata, output, keep_standard=True)

        with Image.open(output) as img:
            assert "Author" in img.info
            assert img.info["Author"] == "Test Author"

    def test_remove_all_metadata(self, tmp_png_with_ai_metadata):
        output = tmp_png_with_ai_metadata.parent / "cleaned.png"
        remove_ai_metadata(tmp_png_with_ai_metadata, output, keep_standard=False)
        with Image.open(output) as img:
            assert "Author" not in img.info
            assert "parameters" not in img.info

    def test_overwrite_in_place(self, tmp_path):
        """When output_path is None, should overwrite source."""
        img = Image.new("RGB", (32, 32))
        pnginfo = PngInfo()
        pnginfo.add_text("parameters", "test data")
        path = tmp_path / "inplace.png"
        img.save(path, pnginfo=pnginfo)

        result = remove_ai_metadata(path)
        assert result == path

        with Image.open(path) as cleaned:
            assert "parameters" not in cleaned.info

    def test_jpeg_output(self, tmp_path):
        """Test metadata removal for JPEG format."""
        img = Image.new("RGB", (64, 64), color=(100, 150, 200))
        pnginfo = PngInfo()
        pnginfo.add_text("parameters", "test")
        png_path = tmp_path / "source.png"
        img.save(png_path, pnginfo=pnginfo)

        jpg_path = tmp_path / "output.jpg"
        result = remove_ai_metadata(png_path, jpg_path)
        assert result == jpg_path
        assert jpg_path.exists()

    def test_jpeg_output_is_high_quality(self, tmp_path):
        """JPEG output uses high quality + 4:4:4 (no chroma subsampling), not the
        lossy PIL defaults (quality 75, 4:2:0) that visibly degrade the image."""
        from PIL.JpegImagePlugin import get_sampling

        img = Image.new("RGB", (64, 64), color=(100, 150, 200))
        png_path = tmp_path / "source.png"
        img.save(png_path)

        jpg_path = tmp_path / "output.jpg"
        remove_ai_metadata(png_path, jpg_path)

        with Image.open(jpg_path) as out:
            assert get_sampling(out) == 0  # 4:4:4, no chroma subsampling
            # quality 95 quantization tables stay well below the q75 defaults
            # (whose max quant value is ~40+); q95 tops out around 12.
            assert max(max(t) for t in out.quantization.values()) <= 15

    def test_webp_output_preserves_format_losslessly(self, tmp_path):
        """A .webp output keeps the WebP format (not silently rewritten to PNG)
        and is pixel-identical to the source (lossless)."""
        import numpy as np

        rng = np.random.default_rng(0)
        arr = rng.integers(0, 255, (48, 48, 3), dtype=np.uint8)
        src = Image.fromarray(arr, "RGB")
        pnginfo = PngInfo()
        pnginfo.add_text("parameters", "ai stuff")
        png_path = tmp_path / "source.png"
        src.save(png_path, pnginfo=pnginfo)

        webp_path = tmp_path / "output.webp"
        remove_ai_metadata(png_path, webp_path)

        with Image.open(webp_path) as out:
            assert out.format == "WEBP"
            assert np.array_equal(np.asarray(out.convert("RGB")), arr)

    def test_creates_parent_directories(self, tmp_path):
        img = Image.new("RGB", (32, 32))
        pnginfo = PngInfo()
        pnginfo.add_text("prompt", "test")
        path = tmp_path / "source.png"
        img.save(path, pnginfo=pnginfo)

        output = tmp_path / "sub" / "dir" / "cleaned.png"
        remove_ai_metadata(path, output)
        assert output.exists()

    def test_returns_path(self, tmp_clean_png):
        output = tmp_clean_png.parent / "out.png"
        result = remove_ai_metadata(tmp_clean_png, output)
        assert isinstance(result, Path)
        assert result == output

    def _sd_png(self, tmp_path: Path) -> Path:
        img = Image.new("RGB", (32, 32), color=(80, 80, 80))
        pnginfo = PngInfo()
        pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler")
        img.save(tmp_path / "sd.png", pnginfo=pnginfo)
        return tmp_path / "sd.png"

    def test_png_to_jpeg_strips_ai(self, tmp_path):
        # Cross-format output: the AI text chunk must not survive the PNG->JPEG
        # re-encode, by detection AND by raw bytes.
        out = tmp_path / "clean.jpg"
        remove_ai_metadata(self._sd_png(tmp_path), out)
        assert not has_ai_metadata(out)
        body = out.read_bytes()
        assert b"parameters" not in body
        assert b"Steps" not in body

    def test_png_to_webp_strips_ai(self, tmp_path):
        out = tmp_path / "clean.webp"
        remove_ai_metadata(self._sd_png(tmp_path), out)
        assert not has_ai_metadata(out)
        body = out.read_bytes()
        assert b"parameters" not in body
        assert b"Steps" not in body


def _img_with_software(tmp_path: Path, fmt: str, software: str) -> Path:
    """Write a tiny image carrying an EXIF Software tag."""
    exif = piexif.dump({"0th": {piexif.ImageIFD.Software: software.encode()}, "Exif": {}, "GPS": {}, "1st": {}})
    path = tmp_path / f"img.{fmt}"
    Image.new("RGB", (64, 64), (100, 90, 80)).save(path, exif=exif)
    return path


class TestExifGenerator:
    """exif_generator extracts AI-tool names from EXIF/XMP across formats."""

    def test_avif_software_ai_tool_detected(self, tmp_path: Path):
        path = _img_with_software(tmp_path, "avif", "Adobe Firefly")
        assert exif_generator(path) == "Adobe Firefly"

    def test_jpeg_software_ai_tool_detected(self, tmp_path: Path):
        path = _img_with_software(tmp_path, "jpg", "ComfyUI v1.2")
        result = exif_generator(path)
        assert result is not None
        assert "ComfyUI" in result

    def test_plain_editor_not_flagged(self, tmp_path: Path):
        # An ordinary editor tag carries no AI token and must not be flagged.
        path = _img_with_software(tmp_path, "jpg", "Adobe Photoshop 25.0")
        assert exif_generator(path) is None

    def test_make_tag_ai_tool_detected(self, tmp_path: Path):
        # Ideogram tags its output with EXIF Make="Ideogram AI" (verified on a
        # real download), so the Make tag must be read too.
        exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
        path = tmp_path / "ideogram.jpg"
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        assert exif_generator(path) == "Ideogram AI"

    def test_camera_make_not_flagged(self, tmp_path: Path):
        # A real camera Make ("Apple") carries no AI token -> not flagged.
        exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Apple"}, "Exif": {}, "GPS": {}, "1st": {}})
        path = tmp_path / "iphone.jpg"
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        assert exif_generator(path) is None

    def test_artist_tag_ai_tool_detected(self, tmp_path: Path):
        # exif_generator also reads the EXIF Artist field for an AI token.
        exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: b"Midjourney"}, "Exif": {}, "GPS": {}, "1st": {}})
        path = tmp_path / "artist.jpg"
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        assert exif_generator(path) == "Midjourney"

    def test_novelai_png_text_chunk_detected(self, tmp_path: Path):
        # NovelAI stamps its generator in PNG tEXt Software/Source/
        # Title chunks, not EXIF -- the PNG-text path must catch it.
        from PIL.PngImagePlugin import PngInfo

        info = PngInfo()
        info.add_text("Software", "NovelAI")
        info.add_text("Source", "NovelAI Diffusion V4.5 C02D4F98")
        info.add_text("Title", "NovelAI generated image")
        path = tmp_path / "novelai.png"
        Image.new("RGB", (64, 64)).save(path, pnginfo=info)
        assert exif_generator(path) == "NovelAI"

    def test_reve_software_detected(self, tmp_path: Path):
        # Reve Image writes EXIF Software="reve.com".
        path = _img_with_software(tmp_path, "jpg", "reve.com")
        assert exif_generator(path) == "reve.com"

    def test_reve_token_not_overmatched(self, tmp_path: Path):
        # The "reve.com" token must not fire on incidental words like "forever".
        path = _img_with_software(tmp_path, "jpg", "Forever Editor 2.0")
        assert exif_generator(path) is None

    def test_luma_ai_png_text_chunks_detected(self, tmp_path: Path):
        # Luma AI stamps tEXt Software="Uni-1" (model name, not a token) plus
        # Source/Comment values carrying "Luma AI"; the Source value must match.
        from PIL.PngImagePlugin import PngInfo

        info = PngInfo()
        info.add_text("Software", "Uni-1")
        info.add_text("Source", "Luma AI")
        info.add_text("Comment", "Generated by Luma AI's Uni-1 model (https://lumalabs.ai)")
        path = tmp_path / "luma.png"
        Image.new("RGB", (64, 64)).save(path, pnginfo=info)
        assert exif_generator(path) == "Luma AI"

    def test_luma_token_not_overmatched(self, tmp_path: Path):
        # The token is "luma ai" with the space: a bare "luma" (e.g. a luma
        # chart tool) must not fire.
        path = _img_with_software(tmp_path, "jpg", "Luma Chart Export 2.0")
        assert exif_generator(path) is None

    def test_luma_removal_parity(self, tmp_path: Path):
        from PIL.PngImagePlugin import PngInfo

        from remove_ai_watermarks.metadata import remove_ai_metadata

        info = PngInfo()
        info.add_text("Software", "Uni-1")
        info.add_text("Source", "Luma AI")
        info.add_text("Comment", "Generated by Luma AI's Uni-1 model (https://lumalabs.ai)")
        src = tmp_path / "luma.png"
        Image.new("RGB", (64, 64)).save(src, pnginfo=info)
        assert exif_generator(src) == "Luma AI"

        out = tmp_path / "clean.png"
        remove_ai_metadata(src, out)
        assert exif_generator(out) is None

    def test_aphrodite_make_detected(self, tmp_path: Path):
        # Aphrodite AI writes EXIF Make="Aphrodite AI".
        exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Aphrodite AI"}, "Exif": {}, "GPS": {}, "1st": {}})
        path = tmp_path / "aphrodite.jpg"
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        assert exif_generator(path) == "Aphrodite AI"

    def test_novelai_removal_parity(self, tmp_path: Path):
        # Detection and removal must stay in parity: NovelAI stamps Title/Source
        # under non-AI keys (AI-shaped VALUE), so removal must drop them by value,
        # not only by key -- else the cleaned file still reads as NovelAI.
        from PIL.PngImagePlugin import PngInfo

        from remove_ai_watermarks.metadata import remove_ai_metadata

        info = PngInfo()
        info.add_text("Software", "NovelAI")
        info.add_text("Source", "NovelAI Diffusion V4.5 C02D4F98")
        info.add_text("Title", "NovelAI generated image")
        src = tmp_path / "novelai.png"
        Image.new("RGB", (64, 64)).save(src, pnginfo=info)
        assert exif_generator(src) == "NovelAI"

        out = tmp_path / "clean.png"
        remove_ai_metadata(src, out)
        assert exif_generator(out) is None

    def test_apple_clean_up_removal_parity(self, tmp_path: Path):
        # The "Apple Photos Clean Up" credit is an AI-edit VALUE under a
        # non-AI key, so removal must drop it by value too.
        from PIL.PngImagePlugin import PngInfo

        from remove_ai_watermarks.metadata import remove_ai_metadata

        info = PngInfo()
        info.add_text("Software", "Apple Photos Clean Up")
        src = tmp_path / "apple.png"
        Image.new("RGB", (64, 64)).save(src, pnginfo=info)
        assert exif_generator(src) is not None

        out = tmp_path / "clean.png"
        remove_ai_metadata(src, out)
        assert exif_generator(out) is None

    def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
        # ...and the EXIF ImageDescription field.
        exif = piexif.dump(
            {"0th": {piexif.ImageIFD.ImageDescription: b"Made with Stable Diffusion"}, "Exif": {}, "GPS": {}, "1st": {}}
        )
        path = tmp_path / "desc.jpg"
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        result = exif_generator(path)
        assert result is not None
        assert "Stable Diffusion" in result

    def test_xmp_creatortool_scan_covers_unopenable(self, tmp_path: Path):
        # PIL can't open this fake HEIF; the raw XMP CreatorTool scan still works.
        path = tmp_path / "fake.heic"
        path.write_bytes(
            b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"
            b"<x:xmpmeta><xmp:CreatorTool>Midjourney v7</xmp:CreatorTool></x:xmpmeta>"
        )
        result = exif_generator(path)
        assert result is not None
        assert "Midjourney" in result

    def test_clean_image_is_none(self, tmp_clean_png: Path):
        assert exif_generator(tmp_clean_png) is None


_FAKE_SIG = "A" * 120  # 64+ base64 chars; real Grok payloads are 300-1004
_FAKE_UUID = "12345678-1234-1234-1234-123456789abc"


def _grok_jpeg(tmp_path: Path, *, signature: str = _FAKE_SIG, artist: str = _FAKE_UUID) -> Path:
    """Write a synthetic Grok-style JPEG: EXIF ImageDescription "Signature: ..."
    + a UUID Artist. Synthetic on purpose -- never commit a real Grok image
    (its Artist UUID + signature are user/session data; this is a public repo)."""
    exif = piexif.dump(
        {
            "0th": {
                piexif.ImageIFD.ImageDescription: f"Signature: {signature}".encode("latin1"),
                piexif.ImageIFD.Artist: artist.encode("latin1"),
            },
            "Exif": {},
            "GPS": {},
            "1st": {},
        }
    )
    path = tmp_path / "grok.jpg"
    Image.new("RGB", (64, 64), (70, 80, 90)).save(path, exif=exif)
    return path


class TestXaiSignature:
    """xAI / Grok's EXIF Signature + UUID-Artist provenance scheme."""

    def test_signature_plus_uuid_detected(self, tmp_path: Path):
        assert xai_signature(_grok_jpeg(tmp_path)) is True

    def test_real_grok_sample_detected(self):
        # Real committed Grok download (data/fixtures/provenance/grok-1.jpg); the EXIF
        # Signature + UUID-Artist pair is the only AI signal it carries.
        assert xai_signature(SAMPLES_DIR / "grok-1.jpg") is True

    def test_signature_without_uuid_artist_not_flagged(self, tmp_path: Path):
        # A "Signature:" blob but a non-UUID Artist is not the Grok pair.
        assert xai_signature(_grok_jpeg(tmp_path, artist="John Doe")) is False

    def test_bare_uuid_artist_not_flagged(self, tmp_path: Path):
        # A UUID Artist alone (no Signature blob) must not false-positive.
        exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: _FAKE_UUID.encode()}, "Exif": {}, "GPS": {}, "1st": {}})
        path = tmp_path / "uuid_only.jpg"
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        assert xai_signature(path) is False

    def test_short_signature_text_not_flagged(self, tmp_path: Path):
        # Incidental short "Signature: ..." text is below the 64-char base64 bar.
        assert xai_signature(_grok_jpeg(tmp_path, signature="ok")) is False

    def test_clean_image_is_false(self, tmp_clean_png: Path):
        assert xai_signature(tmp_clean_png) is False

    def test_surfaced_in_get_ai_metadata(self, tmp_path: Path):
        assert "xai_signature" in get_ai_metadata(_grok_jpeg(tmp_path))

    def test_has_ai_metadata_true(self, tmp_path: Path):
        assert has_ai_metadata(_grok_jpeg(tmp_path)) is True


class TestRemoveAiExif:
    """remove_ai_metadata scrubs AI-provenance EXIF tags but keeps genuine EXIF."""

    @staticmethod
    def _app_aigc_jpeg(tmp_path: Path, name: str, payload: dict) -> Path:
        import json

        exif = piexif.dump(
            {
                "0th": {
                    piexif.ImageIFD.ImageDescription: json.dumps(payload, separators=(",", ":")).encode(),
                    piexif.ImageIFD.Make: b"Canon",
                },
                "Exif": {},
                "GPS": {},
                "1st": {},
            }
        )
        path = tmp_path / name
        Image.new("RGB", (64, 64)).save(path, exif=exif)
        return path

    @pytest.mark.parametrize(
        ("payload", "generator"),
        [
            (
                {
                    "data": {
                        "product": "dreamina_oversea",
                        "source_type": "dreamina_oversea",
                        "exportType": "generation",
                    }
                },
                "Dreamina",
            ),
            (
                {
                    "data": {
                        "product": "aweme",
                        "aigc_info": {
                            "aigc_type": 1,
                            "is_sticker_aigc": 0,
                        },
                    }
                },
                "Aweme",
            ),
            ({"data": {"aigc_info": {"aigc_label_type": 1}}}, "AIGC disclosure"),
            ({"data": {"aigc_info": {"aigc_label_type": 2}}}, "AIGC disclosure"),
        ],
    )
    def test_embedded_app_aigc_detected_and_stripped(self, tmp_path: Path, payload: dict, generator: str):
        src = self._app_aigc_jpeg(tmp_path, "app-aigc.jpg", payload)

        assert generator in (exif_generator(src) or "")
        assert has_ai_metadata(src) is True
        assert "app_aigc" in get_ai_metadata(src)

        out = tmp_path / "clean.jpg"
        _, remaining = strip_and_verify(src, out)

        assert remaining == {}
        assert exif_generator(out) is None
        kept = piexif.load(Image.open(out).info["exif"])["0th"]
        assert kept.get(piexif.ImageIFD.ImageDescription) is None
        assert kept.get(piexif.ImageIFD.Make) == b"Canon"

    @pytest.mark.parametrize(
        "payload",
        [
            {"data": {"product": "aweme"}},
            {"data": {"product": "retouch"}},
            {"data": {"product": "lv", "exportType": "image_export"}},
            {"data": {"aigc_info": {"aigc_label_type": 0}}},
            {"data": {"aigc_info": {"aigc_label_type": 9}}},
            {"data": {"aigc_info": {"aigc_label_type": 10}}},
            {"data": {"product": "aweme", "aigc_info": {"aigc_type": 0}}},
            {"data": {"product": "aweme", "aigc_info": {"aigc_type": 2}}},
            {"data": {"product": "aweme", "aigc_info": {"aigc_type": 10}}},
        ],
    )
    def test_neighboring_app_metadata_is_preserved(self, tmp_path: Path, payload: dict):
        src = self._app_aigc_jpeg(tmp_path, "ordinary-app.jpg", payload)

        assert exif_generator(src) is None
        assert has_ai_metadata(src) is False
        assert get_ai_metadata(src) == {}

        out = tmp_path / "kept.jpg"
        remove_ai_metadata(src, out)

        kept = piexif.load(Image.open(out).info["exif"])["0th"]
        assert kept.get(piexif.ImageIFD.ImageDescription) is not None
        assert kept.get(piexif.ImageIFD.Make) == b"Canon"

    @pytest.mark.parametrize("product", ["doubao", "xinghui", "dreamina", "dreamina_oversea"])
    def test_ai_product_provenance_is_stripped_without_asserting_generation(self, tmp_path: Path, product: str):
        src = self._app_aigc_jpeg(tmp_path, "app-provenance.jpg", {"data": {"product": product}})

        assert exif_generator(src) is None
        assert has_ai_metadata(src) is True
        assert "app_provenance" in get_ai_metadata(src)
        assert identify(src, check_visible=False, check_invisible=False).is_ai_generated is None

        out = tmp_path / "clean-provenance.jpg"
        _, remaining = strip_and_verify(src, out)

        assert remaining == {}
        kept = piexif.load(Image.open(out).info["exif"])["0th"]
        assert kept.get(piexif.ImageIFD.ImageDescription) is None
        assert kept.get(piexif.ImageIFD.Make) == b"Canon"

    @pytest.mark.parametrize("product", ["dreamina", "dreamina_oversea"])
    def test_dreamina_requires_generation_export_for_ai_verdict(self, tmp_path: Path, product: str):
        generated = self._app_aigc_jpeg(
            tmp_path,
            "dreamina-generation.jpg",
            {"data": {"product": product, "exportType": "generation"}},
        )
        edited = self._app_aigc_jpeg(
            tmp_path,
            "dreamina-edit.jpg",
            {"data": {"product": product, "exportType": "image_export"}},
        )

        assert "Dreamina" in (exif_generator(generated) or "")
        assert identify(generated, check_visible=False, check_invisible=False).is_ai_generated is True
        assert exif_generator(edited) is None
        assert has_ai_metadata(edited) is True

    def test_nested_app_aigc_user_comment_is_detected_and_stripped(self, tmp_path: Path):
        import json

        payload = {"data": {"aigc_info": json.dumps({"aigc_label_type": 2})}}
        exif = piexif.dump(
            {
                "0th": {piexif.ImageIFD.Make: b"Canon"},
                "Exif": {piexif.ExifIFD.UserComment: json.dumps(payload, separators=(",", ":")).encode()},
                "GPS": {},
                "1st": {},
            }
        )
        src = tmp_path / "app-aigc-user-comment.jpg"
        Image.new("RGB", (64, 64)).save(src, exif=exif)

        assert has_ai_metadata(src) is True

        out = tmp_path / "clean-user-comment.jpg"
        _, remaining = strip_and_verify(src, out)

        assert remaining == {}
        kept = piexif.load(Image.open(out).info["exif"])
        assert kept["Exif"].get(piexif.ExifIFD.UserComment) is None
        assert kept["0th"].get(piexif.ImageIFD.Make) == b"Canon"

    def test_grok_signature_stripped_on_jpeg_output(self, tmp_path: Path):
        src = _grok_jpeg(tmp_path)
        assert xai_signature(src) is True
        out = tmp_path / "clean.jpg"
        remove_ai_metadata(src, out)
        assert xai_signature(out) is False
        assert has_ai_metadata(out) is False

    def test_generator_make_token_stripped(self, tmp_path: Path):
        # Ideogram's EXIF Make="Ideogram AI" must be scrubbed on removal.
        exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
        src = tmp_path / "ideogram.jpg"
        Image.new("RGB", (64, 64)).save(src, exif=exif)
        out = tmp_path / "clean.jpg"
        remove_ai_metadata(src, out)
        assert exif_generator(out) is None

    def test_real_camera_exif_preserved(self, tmp_path: Path):
        # A real-camera Make ("Apple") carries no AI token and must survive.
        exif = piexif.dump(
            {
                "0th": {piexif.ImageIFD.Make: b"Apple", piexif.ImageIFD.Model: b"iPhone 15"},
                "Exif": {},
                "GPS": {},
                "1st": {},
            }
        )
        src = tmp_path / "photo.jpg"
        Image.new("RGB", (64, 64)).save(src, exif=exif)
        out = tmp_path / "out.jpg"
        remove_ai_metadata(src, out)
        kept = piexif.load(Image.open(out).info["exif"])["0th"]
        assert kept.get(piexif.ImageIFD.Make) == b"Apple"

    def test_xai_pair_stripped_but_genuine_camera_tags_kept(self, tmp_path: Path):
        # An image carrying BOTH the xAI Signature pair (ImageDescription =
        # "Signature: <base64>" + UUID Artist) AND genuine non-AI camera tags.
        # The scrub must delete only the xAI pair, leaving the camera tags intact.
        sig = "Signature: " + "A" * 120
        artist = "12345678-1234-1234-1234-123456789abc"
        exif = piexif.dump(
            {
                "0th": {
                    piexif.ImageIFD.ImageDescription: sig.encode(),
                    piexif.ImageIFD.Artist: artist.encode(),
                    piexif.ImageIFD.Make: b"Canon",
                    piexif.ImageIFD.Model: b"EOS R5",
                },
                "Exif": {piexif.ExifIFD.DateTimeOriginal: b"2024:01:01 12:00:00"},
                "GPS": {piexif.GPSIFD.GPSLatitudeRef: b"N"},
                "1st": {},
            }
        )
        src = tmp_path / "grok_plus_cam.jpg"
        Image.new("RGB", (32, 32)).save(src, exif=exif)
        out = tmp_path / "scrubbed.jpg"
        remove_ai_metadata(src, out)

        # xAI signature pair is gone (xai_signature returns a bool, not None).
        assert xai_signature(out) is False
        kept = piexif.load(Image.open(out).info["exif"])
        assert kept["0th"].get(piexif.ImageIFD.ImageDescription) is None
        assert kept["0th"].get(piexif.ImageIFD.Artist) is None
        # Genuine camera tags are preserved.
        assert kept["0th"].get(piexif.ImageIFD.Make) == b"Canon"
        assert kept["0th"].get(piexif.ImageIFD.Model) == b"EOS R5"
        assert kept["Exif"].get(piexif.ExifIFD.DateTimeOriginal) == b"2024:01:01 12:00:00"
        assert kept["GPS"].get(piexif.GPSIFD.GPSLatitudeRef) == b"N"


class TestAIGCLabel:
    """China TC260 AIGC labeling (Doubao and other China-served generators)."""

    def _aigc_png(self, tmp_path: Path, label: str = "1", producer: str = "TESTPRODUCER001") -> Path:
        from remove_ai_watermarks.metadata import aigc_label  # noqa: F401  (import-time guard)

        p = tmp_path / "doubao.png"
        Image.new("RGB", (32, 32)).save(p)
        # XMP is HTML-entity encoded in real files; aigc_label must unescape it.
        xmp = (
            '<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF '
            'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
            '<rdf:Description rdf:about="" '
            'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/"><TC260:AIGC>'
            f"{{&quot;Label&quot;:&quot;{label}&quot;,&quot;ContentProducer&quot;:&quot;{producer}&quot;}}"
            "</TC260:AIGC></rdf:Description></rdf:RDF></x:xmpmeta>"
        )
        with open(p, "ab") as f:
            f.write(xmp.encode())
        return p

    def test_parses_label_and_producer(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(self._aigc_png(tmp_path))
        assert info is not None
        assert info["Label"] == "1"
        assert info["ContentProducer"] == "TESTPRODUCER001"

    def test_none_when_absent(self, tmp_clean_png):
        from remove_ai_watermarks.metadata import aigc_label

        assert aigc_label(tmp_clean_png) is None

    def test_has_ai_metadata_detects_aigc(self, tmp_path: Path):
        assert has_ai_metadata(self._aigc_png(tmp_path))

    def test_get_ai_metadata_surfaces_aigc(self, tmp_path: Path):
        meta = get_ai_metadata(self._aigc_png(tmp_path))
        assert "aigc_label" in meta
        assert "TC260" in meta["aigc_label"]

    def test_empty_namespaced_label_is_still_surfaced(self, tmp_path: Path):
        """The namespaced element is unambiguous even when its JSON object is empty."""
        from remove_ai_watermarks.metadata import aigc_label

        p = tmp_path / "empty_aigc.png"
        Image.new("RGB", (32, 32)).save(p)
        with open(p, "ab") as f:
            f.write(b"<TC260:AIGC>{}</TC260:AIGC>")

        assert aigc_label(p) == {}
        assert has_ai_metadata(p)
        assert "aigc_label" in get_ai_metadata(p)

    def _aigc_chunk_png(self, tmp_path: Path, producer: str = "doubao") -> Path:
        """Doubao writes the TC260 object as a PNG ``tEXt`` chunk keyed ``AIGC``
        with raw JSON (no XMP, no namespaced marker)."""
        import json

        p = tmp_path / "doubao_chunk.png"
        pnginfo = PngInfo()
        pnginfo.add_text(
            "AIGC",
            json.dumps({"Label": "1", "ContentProducer": producer, "ProduceID": "abc123"}),
        )
        Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
        return p

    def test_parses_png_text_chunk_form(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(self._aigc_chunk_png(tmp_path))
        assert info is not None
        assert info["Label"] == "1"
        assert info["ContentProducer"] == "doubao"

    def test_png_chunk_without_tc260_field_ignored(self, tmp_path: Path):
        """A generic ``AIGC`` chunk with no TC260 field must not false-positive."""
        import json

        from remove_ai_watermarks.metadata import aigc_label

        p = tmp_path / "unrelated.png"
        pnginfo = PngInfo()
        pnginfo.add_text("AIGC", json.dumps({"unrelated": "value"}))
        Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
        assert aigc_label(p) is None

    def test_has_ai_metadata_detects_png_chunk_form(self, tmp_path: Path):
        assert has_ai_metadata(self._aigc_chunk_png(tmp_path))

    def _aigc_serviceprovider_png(self, tmp_path: Path, provider: str = "腾讯云") -> Path:
        """Tencent Cloud's AIGC variant uses a service-provider schema
        (``ServiceProvider`` / ``ServiceUser`` / ``Time`` / ``ContentId``) rather
        than the producer schema, embedded in EXIF ``ImageDescription`` -- none of
        its fields overlap the producer-side ``TC260_AIGC_FIELDS``, so the generic
        ``"AIGC":{...}`` gate must recognize them too."""
        import json

        import piexif

        p = tmp_path / "tencent_aigc.png"
        payload = json.dumps(
            {
                "AIGC": {
                    "ServiceProvider": provider,
                    "ServiceUser": "1379431822",
                    "Time": "2026-06-30 23:54:50.067",
                    "ContentId": "a284dd3d-15ae-4cce-b2dd-010be84921df",
                }
            },
            ensure_ascii=False,
        )
        exif = piexif.dump(
            {"0th": {piexif.ImageIFD.ImageDescription: payload.encode("utf-8")}, "Exif": {}, "GPS": {}, "1st": {}}
        )
        Image.new("RGB", (32, 32)).save(p, exif=exif)
        return p

    def test_parses_serviceprovider_schema(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(self._aigc_serviceprovider_png(tmp_path))
        assert info is not None
        assert "ServiceProvider" in info

    def test_has_ai_metadata_detects_serviceprovider_schema(self, tmp_path: Path):
        assert has_ai_metadata(self._aigc_serviceprovider_png(tmp_path))

    def test_remove_strips_png_chunk_form(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata

        out = tmp_path / "clean.png"
        remove_ai_metadata(self._aigc_chunk_png(tmp_path), out)
        assert aigc_label(out) is None
        assert not has_ai_metadata(out)

    def _aigc_exif_jpeg(self, tmp_path: Path, producer: str = "001191440300708461136T1308L") -> Path:
        """Some China-served generators embed the raw-JSON ``{"AIGC":{...}}``
        block in JPEG EXIF (UserComment) -- no PNG chunk, no namespaced XMP."""
        import json

        import piexif

        p = tmp_path / "aigc_exif.jpg"
        Image.new("RGB", (32, 32)).save(p)
        payload = json.dumps({"AIGC": {"Label": "1", "ContentProducer": producer, "ProduceID": "abc123"}})
        exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
        piexif.insert(piexif.dump(exif), str(p))
        return p

    def test_parses_raw_json_exif_form(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(self._aigc_exif_jpeg(tmp_path))
        assert info is not None
        assert info["Label"] == "1"
        assert info["ContentProducer"] == "001191440300708461136T1308L"

    def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
        assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))

    def test_remove_strips_raw_json_exif_form(self, tmp_path: Path):
        """Regression: the TC260 AIGC ``{"AIGC":{...}}`` block Doubao embeds in EXIF
        UserComment must be scrubbed on removal. Before the fix it survived because
        ``_scrub_ai_exif`` only touched Software/Make/Artist/ImageDescription in the
        0th IFD, never UserComment in the Exif sub-IFD."""
        from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata

        out = tmp_path / "clean.jpg"
        remove_ai_metadata(self._aigc_exif_jpeg(tmp_path), out)
        assert aigc_label(out) is None
        assert not has_ai_metadata(out)

    def _aigc_bare_jpeg(
        self, tmp_path: Path, producer: str = "00119144030008867405X210002", marker: bytes = b"\xff\xe9"
    ) -> Path:
        """Some China-served generators glue the TC260 label straight to its JSON
        as a bare ``AIGC{...}`` blob inside a JPEG APP segment (no ``"AIGC":``
        key wrapper, no PNG chunk, no namespaced XMP) -- seen near the JFIF
        header on real 2026-06 downloads. ``marker`` selects the APP segment
        (default APP9; APP11 is covered by a separate regression)."""
        p = tmp_path / "aigc_bare.jpg"
        Image.new("RGB", (32, 32)).save(p)
        raw = p.read_bytes()
        blob = b'AIGC{"Label":"1","ContentProducer":"' + producer.encode() + b'","ProduceID":"8F995586"}'
        segment = marker + (len(blob) + 2).to_bytes(2, "big") + blob
        p.write_bytes(raw[:2] + segment + raw[2:])  # splice after SOI
        return p

    def test_parses_bare_aigc_jpeg_segment_form(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(self._aigc_bare_jpeg(tmp_path))
        assert info is not None
        assert info["Label"] == "1"
        assert info["ContentProducer"] == "00119144030008867405X210002"

    def test_has_ai_metadata_detects_bare_aigc_jpeg_form(self, tmp_path: Path):
        assert has_ai_metadata(self._aigc_bare_jpeg(tmp_path))

    def test_remove_strips_bare_aigc_jpeg_form(self, tmp_path: Path):
        """Regression: a bare ``AIGC{...}`` blob in a non-standard JPEG APP segment
        (APP9 here) is detected by aigc_label, so removal must drop that segment too.
        Before the fix ``_jpeg_app_carries_ai`` only inspected APP11/APP1-XMP/APP13, so
        the blob survived the lossless strip (detection<->removal parity break)."""
        from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata

        out = tmp_path / "clean.jpg"
        remove_ai_metadata(self._aigc_bare_jpeg(tmp_path), out)
        assert aigc_label(out) is None
        assert not has_ai_metadata(out)

    def test_remove_strips_bare_aigc_in_app11(self, tmp_path: Path):
        """Regression: the bare ``AIGC{...}`` blob lives
        in APP11 (0xEB) on many China gens. That marker's branch in _jpeg_app_carries_ai
        only checked for a C2PA/JUMBF manifest and RETURNED, so the AIGC blob slipped past
        the generic check -> survived the strip. The specific checks must fall through to
        the generic AIGC check."""
        from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata

        src = self._aigc_bare_jpeg(tmp_path, marker=b"\xff\xeb")  # APP11
        assert aigc_label(src) is not None
        out = tmp_path / "clean.jpg"
        remove_ai_metadata(src, out)
        assert aigc_label(out) is None
        assert not has_ai_metadata(out)

    def test_remove_strips_aigc_in_png_text_chunk(self, tmp_path: Path):
        """Regression: the TC260 ``{"AIGC":{...}}`` block in a
        STANDARD PNG text chunk (Description) -- _is_ai_key keeps that key, so removal
        must also drop it on the VALUE carrying an AIGC block."""
        from PIL.PngImagePlugin import PngInfo

        from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata

        p = tmp_path / "aigc_desc.png"
        info = PngInfo()
        info.add_text("Description", '{"AIGC":{"Label":"1","ContentProducer":"00119144030008867405X210002"}}')
        Image.new("RGB", (32, 32)).save(p, pnginfo=info)
        assert aigc_label(p) is not None
        out = tmp_path / "clean.png"
        remove_ai_metadata(p, out)
        assert aigc_label(out) is None

    def test_bare_aigc_without_tc260_field_ignored(self, tmp_path: Path):
        """A bare ``AIGC{...}`` blob with no TC260 field must not false-positive."""
        from remove_ai_watermarks.metadata import aigc_label

        p = tmp_path / "bare_unrelated.jpg"
        Image.new("RGB", (32, 32)).save(p)
        raw = p.read_bytes()
        blob = b'AIGC{"unrelated":"value"}'
        segment = b"\xff\xe9" + (len(blob) + 2).to_bytes(2, "big") + blob
        p.write_bytes(raw[:2] + segment + raw[2:])
        assert aigc_label(p) is None

    def test_raw_json_without_tc260_field_ignored(self, tmp_path: Path):
        """A bare ``{"AIGC":{...}}`` object with no TC260 field must not fire."""
        import json

        import piexif

        from remove_ai_watermarks.metadata import aigc_label

        p = tmp_path / "unrelated.jpg"
        Image.new("RGB", (32, 32)).save(p)
        payload = json.dumps({"AIGC": {"unrelated": "value"}})
        exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
        piexif.insert(piexif.dump(exif), str(p))
        assert aigc_label(p) is None

    def _aigc_attr_png(self, tmp_path: Path, producer: str = "picwish") -> Path:
        """PicWish writes the TC260 label as an XMP *attribute*
        (``TC260:AIGC="{...}"``), not the nested element form."""
        p = tmp_path / "picwish.png"
        Image.new("RGB", (32, 32)).save(p)
        xmp = (
            '<rdf:Description rdf:about="" '
            'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/" '
            f'TC260:AIGC="{{&quot;Label&quot;:&quot;1&quot;,&quot;ContentProducer&quot;:&quot;{producer}&quot;}}"/>'
        )
        with open(p, "ab") as f:
            f.write(xmp.encode())
        return p

    def test_parses_xmp_attribute_form(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(self._aigc_attr_png(tmp_path))
        assert info is not None
        assert info["ContentProducer"] == "picwish"

    def test_scan_head_collects_png_metadata_past_window(self, tmp_path: Path):
        """A PNG metadata chunk beyond the read window is still reachable -- the
        regression for a TC260 XMP packet appended after a large IDAT."""
        import json

        from remove_ai_watermarks.metadata import _png_late_metadata, scan_head

        p = tmp_path / "late.png"
        pnginfo = PngInfo()
        pnginfo.add_text("AIGC", json.dumps({"Label": "1", "ContentProducer": "doubao"}))
        Image.new("RGB", (16, 16)).save(p, pnginfo=pnginfo)
        # window = 8 (just the signature) makes the text chunk "late".
        assert b"ContentProducer" in _png_late_metadata(p, 8)
        assert b"ContentProducer" in scan_head(p, 8)

    def test_scan_head_collects_webp_metadata_past_window(self, tmp_path: Path):
        """WebP stores ``XMP `` AFTER the pixels, so on any WebP above the window a
        fixed read can stop short of an IPTC "Made with AI" tag."""
        from remove_ai_watermarks.metadata import _riff_late_metadata, scan_head

        p = tmp_path / "late.webp"
        xmp = (
            b"<x:xmpmeta><photoshop:DigitalSourceType>trainedAlgorithmicMedia</photoshop:DigitalSourceType></x:xmpmeta>"
        )
        Image.new("RGB", (16, 16)).save(p, "WEBP", xmp=xmp)

        assert b"trainedAlgorithmicMedia" in _riff_late_metadata(p, 12)
        assert b"trainedAlgorithmicMedia" in scan_head(p, 12)

    def test_riff_late_metadata_ignores_the_coded_image(self, tmp_path: Path):
        """The point of stepping chunk by chunk rather than reading through: the
        pixel payload never enters the scan buffer."""
        from remove_ai_watermarks.metadata import _riff_late_metadata

        p = tmp_path / "plain.webp"
        Image.new("RGB", (64, 64), (200, 30, 30)).save(p, "WEBP")

        assert _riff_late_metadata(p, 12) == b""


class TestHuggingFaceJob:
    """HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk)."""

    def _hf_png(self, tmp_path: Path, job_id: str = "ec8380a6-2091-423a-b835-209420f99ee1") -> Path:
        p = tmp_path / "hfjob.png"
        pnginfo = PngInfo()
        pnginfo.add_text("hf-job-id", job_id)
        Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
        return p

    def test_returns_job_id(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import huggingface_job

        assert huggingface_job(self._hf_png(tmp_path)) == "ec8380a6-2091-423a-b835-209420f99ee1"

    def test_none_when_absent(self, tmp_clean_png):
        from remove_ai_watermarks.metadata import huggingface_job

        assert huggingface_job(tmp_clean_png) is None

    def test_has_ai_metadata_detects_hf_job(self, tmp_path: Path):
        assert has_ai_metadata(self._hf_png(tmp_path))

    def test_get_ai_metadata_surfaces_hf_job(self, tmp_path: Path):
        meta = get_ai_metadata(self._hf_png(tmp_path))
        assert "huggingface_job" in meta
        assert "ec8380a6" in meta["huggingface_job"]

    def test_remove_strips_hf_job(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import huggingface_job, remove_ai_metadata

        out = tmp_path / "clean.png"
        remove_ai_metadata(self._hf_png(tmp_path), out)
        assert huggingface_job(out) is None
        assert not has_ai_metadata(out)


@pytest.mark.skipif(not (SAMPLES_DIR / "doubao-1.png").exists(), reason="doubao sample not present")
class TestAIGCRealSample:
    """Real Doubao (ByteDance) sample carries the China TC260 AIGC XMP label."""

    def test_doubao_aigc_label(self):
        from remove_ai_watermarks.metadata import aigc_label

        info = aigc_label(SAMPLES_DIR / "doubao-1.png")
        assert info is not None
        assert info["Label"] == "1"
        assert info["ContentProducer"]  # ByteDance producer code present

    def test_doubao_detected_as_ai(self):
        assert has_ai_metadata(SAMPLES_DIR / "doubao-1.png")
        assert "aigc_label" in get_ai_metadata(SAMPLES_DIR / "doubao-1.png")


class TestSoftBinding:
    """C2PA soft-binding alg identifier -> forensic-watermark vendor name."""

    def test_vendors_in_recognizes_known_algs(self):
        from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in

        assert soft_binding_vendors_in(b"...alg...com.adobe.trustmark.P...") == ["Adobe TrustMark"]
        assert soft_binding_vendors_in(b"com.digimarc.validate.1") == ["Digimarc"]
        assert soft_binding_vendors_in(b"ai.steg.api blah") == ["Steg.AI"]
        # Registry-verified vendors added in v0.6.x.
        assert soft_binding_vendors_in(b"ai.trufo.gen1.image") == ["Trufo"]
        assert soft_binding_vendors_in(b"io.iscc.v0") == ["ISCC (content code)"]

    def test_vendors_in_empty_when_absent(self):
        from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in

        assert soft_binding_vendors_in(b"no soft binding here") == []

    def test_get_ai_metadata_surfaces_soft_binding(self, tmp_path: Path):
        # Non-PNG binary-scan path: a manifest naming a soft-binding vendor.
        p = tmp_path / "fake.jpg"
        p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb com.adobe.trustmark.P \xff\xd9")
        assert get_ai_metadata(p).get("soft_binding") == "Adobe TrustMark"


class TestIptcAiFields:
    """IPTC 2025.1 AI-disclosure XMP properties (Iptc4xmpExt:AISystemUsed etc.)."""

    def test_detects_ai_system_used_element_form(self, tmp_path: Path):
        p = tmp_path / "iptc_ai.jpg"
        p.write_bytes(
            b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:AISystemUsed>ChatGPT DALL-E"
            b"</Iptc4xmpExt:AISystemUsed></x:xmpmeta>\xff\xd9"
        )
        assert has_ai_metadata(p) is True
        assert iptc_ai_system(p) == "ChatGPT DALL-E"
        assert "ChatGPT DALL-E" in get_ai_metadata(p)["ai_system"]

    def test_attribute_serialization(self, tmp_path: Path):
        p = tmp_path / "attr.jpg"
        p.write_bytes(b'\xff\xd8\xff\xe1 Iptc4xmpExt:AISystemUsed="Google Gemini" \xff\xd9')
        assert iptc_ai_system(p) == "Google Gemini"

    def test_present_without_value(self, tmp_path: Path):
        # A disclosure field with no extractable value still flags presence.
        p = tmp_path / "novalue.jpg"
        p.write_bytes(b"\xff\xd8\xff\xe1 Iptc4xmpExt:AIPromptWriterName \xff\xd9")
        assert iptc_ai_system(p) == "fields present"
        assert has_ai_metadata(p) is True

    def test_clean_image_none(self, tmp_clean_png: Path):
        assert iptc_ai_system(tmp_clean_png) is None


# Synthetic MP4 (ISOBMFF): ftyp + C2PA uuid box + mdat. Same box format as AVIF.
_MP4_FTYP = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
_MP4_MDAT = b"\x00\x00\x00\x10mdat" + b"videodat"


def _box(box_type: bytes, payload: bytes) -> bytes:
    """Build a 32-bit-size ISOBMFF box: [size:4][type:4][payload]."""
    return (8 + len(payload)).to_bytes(4, "big") + box_type + payload


class TestVideoC2pa:
    """C2PA in MP4 (ISOBMFF) -- detect + offset-preserving stream blank."""

    def test_detects_c2pa_in_mp4(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import C2PA_UUID

        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
        p = tmp_path / "ai.mp4"
        p.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
        assert has_ai_metadata(p) is True

    def test_strips_c2pa_in_mp4(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import C2PA_UUID

        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
        src = tmp_path / "in.mp4"
        src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
        out = tmp_path / "out.mp4"
        remove_ai_metadata(src, out)
        assert out.read_bytes() == _MP4_FTYP + _box(b"free", b"\x00" * 24) + _MP4_MDAT
        assert has_ai_metadata(out) is False


class TestLateProvenanceBox:
    """A C2PA / provenance box placed AFTER a large mdat (streaming / non-faststart
    MP4) must still be detected -- the fixed first-MB scan would miss it."""

    def _mp4_late_c2pa(self, tmp_path: Path, gap: int = 1_500_000) -> Path:
        from remove_ai_watermarks.metadata import C2PA_UUID

        big_mdat = _box(b"mdat", b"\x00" * gap)  # > 1 MB pushes the manifest past the scan window
        manifest = C2PA_UUID + b"OpenAI jumbf c2pa ... trainedAlgorithmicMedia ..."
        p = tmp_path / "stream.mp4"
        p.write_bytes(_MP4_FTYP + big_mdat + _box(b"uuid", manifest))
        return p

    def test_scan_c2pa_region_finds_late_box(self, tmp_path: Path):
        from remove_ai_watermarks._internal.isobmff import scan_c2pa_region
        from remove_ai_watermarks.metadata import C2PA_UUID

        region = scan_c2pa_region(self._mp4_late_c2pa(tmp_path))
        assert C2PA_UUID in region
        assert b"trainedAlgorithmicMedia" in region

    def test_fixed_window_would_have_missed_it(self, tmp_path: Path):
        # Documents the regression the box walk fixes: the manifest is beyond 1 MB.
        from remove_ai_watermarks.metadata import C2PA_UUID

        p = self._mp4_late_c2pa(tmp_path)
        assert C2PA_UUID not in p.read_bytes()[: 1024 * 1024]

    def test_scan_head_includes_late_box(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import C2PA_UUID, scan_head

        assert C2PA_UUID in scan_head(self._mp4_late_c2pa(tmp_path))

    def test_has_ai_metadata_detects_late_manifest(self, tmp_path: Path):
        assert has_ai_metadata(self._mp4_late_c2pa(tmp_path)) is True

    def test_scan_c2pa_region_non_isobmff_is_empty(self, tmp_path: Path):
        from remove_ai_watermarks._internal.isobmff import scan_c2pa_region

        p = tmp_path / "not.bin"
        p.write_bytes(b"\x89PNG\r\n\x1a\n not an isobmff file")
        assert scan_c2pa_region(p) == b""

    def test_scan_c2pa_region_reads_largesize_uuid(self, tmp_path: Path):
        """A 64-bit largesize (size32 == 1) uuid box must be walked and collected."""
        import struct

        from remove_ai_watermarks._internal.isobmff import scan_c2pa_region

        payload = b"LARGESIZE-C2PA-MANIFEST"
        total = 16 + len(payload)  # 4 (size32=1) + 4 (type) + 8 (largesize) + payload
        uuid_box = struct.pack(">I", 1) + b"uuid" + struct.pack(">Q", total) + payload
        p = tmp_path / "large.mp4"
        p.write_bytes(_MP4_FTYP + uuid_box)
        assert payload in scan_c2pa_region(p)

    def test_scan_c2pa_region_caps_at_max_total(self, tmp_path: Path):
        """The collected payload is bounded by ``max_total`` (never unbounded)."""
        from remove_ai_watermarks._internal.isobmff import scan_c2pa_region

        p = tmp_path / "big.mp4"
        p.write_bytes(_MP4_FTYP + _box(b"uuid", b"A" * 5000))
        assert len(scan_c2pa_region(p, max_total=1000)) <= 1000

    def test_front_placed_manifest_still_detected(self, tmp_path: Path):
        # Regression: a faststart MP4 (manifest before mdat) is unaffected.
        from remove_ai_watermarks.metadata import C2PA_UUID

        manifest = C2PA_UUID + b"OpenAI ... trainedAlgorithmicMedia ..."
        p = tmp_path / "front.mp4"
        p.write_bytes(_MP4_FTYP + _box(b"uuid", manifest) + _box(b"mdat", b"\x00" * 100))
        assert has_ai_metadata(p) is True


_AI_XMP = (
    b'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>'
    b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
    b'<?xpacket end="w"?>'
)
_PLAIN_XMP = (
    b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
    b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
    b'<?xpacket end="w"?>'
)


class TestMetaBoxXmpBlanking:
    """HEIF/AVIF store XMP as a meta-box ``mime`` item (bytes in mdat/idat), out of
    reach of the top-level box stripper. An AI-label XMP packet there is blanked
    in place (same length -> iloc offsets and image data stay intact)."""

    def test_blanks_ai_packet_only(self):
        from remove_ai_watermarks._internal.isobmff import blank_ai_xmp_packets

        before, after = b"IMG_BEFORE" * 4, b"IMG_AFTER" * 4
        data = before + _AI_XMP + after + _PLAIN_XMP
        out, n = blank_ai_xmp_packets(data)
        assert n == 1
        assert len(out) == len(data)  # same length -> no offset shifts
        assert b"TC260:AIGC" not in out  # AI label destroyed
        assert before in out  # surrounding (image) bytes intact
        assert after in out
        assert b"dc:rights" in out  # plain XMP left alone

    def test_no_packet_is_noop(self):
        from remove_ai_watermarks._internal.isobmff import blank_ai_xmp_packets

        data = b"just some mdat bytes, no xmp here"
        assert blank_ai_xmp_packets(data) == (data, 0)

    def test_plain_xmp_untouched(self):
        from remove_ai_watermarks._internal.isobmff import blank_ai_xmp_packets

        out, n = blank_ai_xmp_packets(_PLAIN_XMP)
        assert n == 0
        assert out == _PLAIN_XMP

    def test_remove_ai_metadata_blanks_meta_box_xmp(self, tmp_path: Path):
        # End-to-end: a HEIF with an AI XMP packet inside mdat is cleaned without
        # touching the surrounding (coded image) bytes or the file length.
        heic_ftyp = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
        img = b"CODEDIMAGE" * 8
        mdat = _box(b"mdat", img + _AI_XMP + img)
        src = tmp_path / "ai.heic"
        src.write_bytes(heic_ftyp + mdat)
        assert has_ai_metadata(src) is True

        out = tmp_path / "clean.heic"
        remove_ai_metadata(src, out)
        res = out.read_bytes()
        assert len(res) == src.stat().st_size  # length preserved
        assert b"TC260:AIGC" not in res
        assert img in res  # coded image bytes intact
        assert has_ai_metadata(out) is False


class TestIsobmffMetadataRemoval:
    """Container-level AI-provenance stripping across ISOBMFF image/video/audio."""

    def test_strips_ai_xmp_uuid_box(self):
        # A uuid box carrying a TC260 AIGC label is dropped by content match,
        # regardless of the (non-C2PA) XMP UUID's byte order.
        from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes

        xmp_uuid = bytes(range(16))  # arbitrary, not the C2PA UUID
        payload = b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
        box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
        cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
        assert stripped == 1
        assert cleaned == _MP4_FTYP + _MP4_MDAT

    def test_keeps_plain_non_ai_xmp(self):
        # A uuid box with ordinary (non-AI) XMP must be preserved.
        from remove_ai_watermarks._internal.isobmff import strip_c2pa_boxes

        xmp_uuid = bytes(range(16))
        payload = b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
        box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
        cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
        assert stripped == 0
        assert cleaned == _MP4_FTYP + box + _MP4_MDAT

    def test_m4a_c2pa_stripped(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import C2PA_UUID

        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
        src = tmp_path / "voice.m4a"
        src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
        out = tmp_path / "clean.m4a"
        remove_ai_metadata(src, out)
        assert out.read_bytes() == _MP4_FTYP + _box(b"free", b"\x00" * 24) + _MP4_MDAT

    def test_content_sniff_routes_unknown_suffix(self, tmp_path: Path):
        # An ISOBMFF file with a non-standard extension is still box-stripped.
        from remove_ai_watermarks.metadata import C2PA_UUID

        uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
        src = tmp_path / "mystery.bin"
        src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
        out = tmp_path / "out.bin"
        remove_ai_metadata(src, out)
        assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT

    def test_unparseable_audio_raises(self, tmp_path: Path):
        # Garbage that ffmpeg can't parse must raise a clear error, not crash in
        # the image path. (When ffmpeg is absent this still raises RuntimeError.)
        src = tmp_path / "audio.mp3"
        src.write_bytes(b"ID3\x04\x00\x00\x00\x00\x00\x00 not real mp3 frames")
        out = tmp_path / "out.mp3"
        with pytest.raises(RuntimeError):
            remove_ai_metadata(src, out)


@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
class TestFfmpegMetadataStrip:
    """Lossless container-metadata strip for non-ISOBMFF audio/video via ffmpeg."""

    def _wav_with_tag(self, path: Path, tag: str = "Suno AI") -> None:
        subprocess.run(  # noqa: S603
            [
                shutil.which("ffmpeg"),
                "-y",
                "-loglevel",
                "error",
                "-f",
                "lavfi",
                "-i",
                "sine=frequency=440:duration=0.1",
                "-metadata",
                f"title={tag}",
                str(path),
            ],
            check=True,
        )

    def test_strips_wav_title_metadata(self, tmp_path: Path):
        src = tmp_path / "in.wav"
        self._wav_with_tag(src, "Suno AI generated")
        assert b"Suno AI generated" in src.read_bytes()  # tag is present pre-strip
        out = tmp_path / "clean.wav"
        remove_ai_metadata(src, out)
        assert out.exists()
        assert b"Suno AI generated" not in out.read_bytes()  # tag stripped, audio kept


class TestC2paCloudManifest:
    """C2PA 2.4 Durable Content Credentials: an XMP dcterms:provenance pointer to
    a vendor cloud manifest store survives when the embedded manifest is stripped."""

    def _cloud_png(self, tmp_path: Path, host: bytes = b"cai-manifests.adobe.com") -> Path:
        xmp = (
            b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/">'
            b'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
            b'<rdf:Description rdf:about="" xmlns:dcterms="http://purl.org/dc/terms/" '
            b'dcterms:provenance="https://' + host + b'/manifests/urn-c2pa-abc123"> </rdf:Description>'
            b'</rdf:RDF></x:xmpmeta><?xpacket end="w"?>'
        )
        p = tmp_path / "cloud.png"
        img = Image.new("RGB", (16, 16))
        meta = PngInfo()
        meta.add_itxt("XML:com.adobe.xmp", xmp.decode("latin-1"))
        img.save(p, pnginfo=meta)
        return p

    def test_detects_adobe_cloud_manifest(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import c2pa_cloud_manifest

        assert c2pa_cloud_manifest(self._cloud_png(tmp_path)) == "Adobe Content Authenticity"

    def test_no_provenance_pointer_is_none(self, tmp_clean_png: Path):
        from remove_ai_watermarks.metadata import c2pa_cloud_manifest

        assert c2pa_cloud_manifest(tmp_clean_png) is None

    def test_unknown_host_is_none(self, tmp_path: Path):
        from remove_ai_watermarks.metadata import c2pa_cloud_manifest

        # A dcterms:provenance pointer to an unrecognized host is not attributed.
        assert c2pa_cloud_manifest(self._cloud_png(tmp_path, host=b"manifests.example.com")) is None

    def test_cloud_manifest_does_not_assert_ai(self, tmp_path: Path):
        # Provenance only -- a cloud manifest can describe a human edit, so the
        # verdict must stay 'unknown', not 'AI-generated'.
        from remove_ai_watermarks.identify import identify

        r = identify(self._cloud_png(tmp_path), check_visible=False, check_invisible=False)
        assert r.is_ai_generated is None
        assert any("Durable Content Credentials" in w for w in r.watermarks)
        assert any(s.name == "c2pa_cloud" for s in r.signals)


def test_remove_all_bypasses_lossless_jpeg(tmp_path, monkeypatch):
    # keep_standard=False (--remove-all) must NOT take the AI-only lossless JPEG path,
    # which leaves standard (non-AI) metadata in place; it must fall through to the full
    # re-encode strip (#7).
    import remove_ai_watermarks.metadata as md

    calls: list[int] = []
    real = md._strip_jpeg_metadata_lossless

    def spy(s, o):
        calls.append(1)
        return real(s, o)

    monkeypatch.setattr(md, "_strip_jpeg_metadata_lossless", spy)
    src = tmp_path / "x.jpg"
    Image.new("RGB", (32, 32), (128, 128, 128)).save(src, "JPEG")
    out = tmp_path / "o.jpg"

    md.remove_ai_metadata(src, out, keep_standard=True)
    assert calls, "lossless JPEG strip should run when keeping standard metadata"
    calls.clear()
    md.remove_ai_metadata(src, out, keep_standard=False)
    assert not calls, "keep_standard=False must bypass the AI-only lossless JPEG path"
