"""Regression tests for the code-review fixes.

Each test pins a specific bug found during review so the fix cannot silently
regress. The test name references the behavior being protected.
"""

from __future__ import annotations

import json
import sqlite3
from io import BytesIO
from pathlib import Path

from rights_filter.analysis.preprocessing import ImagePayload, build_external_derivative
from rights_filter.analysis.search_result_promoter import SearchResultPromoter
from rights_filter.domain.records import Evidence, EvidenceSource
from rights_filter.integrations.naver_search import NaverSearchAdapter
from rights_filter.integrations.search_policy import SearchApiPolicy
from rights_filter.server.sqlite_store import CopyrighterStore, _decode_javascript_string


# --- #1 CRITICAL: constraint migration must not cascade-delete evidence -------


def test_constraint_migration_preserves_evidence_with_cascade_fk(tmp_path: Path):
    # An "intermediate" DB: the submissions table predates the risk_band CHECK
    # (so it needs a constraint rebuild) while evidence already carries the
    # `on delete cascade` foreign key. Rebuilding submissions with foreign keys
    # ON would cascade-delete every evidence row.
    db_path = tmp_path / "legacy.sqlite3"
    submission = {
        "id": "SUB-CASCADE",
        "title": "Cascade row",
        "asset": "/media/cascade.png",
        "riskScore": 40,
        "riskBand": "medium",
        "decisionStatus": "unreviewed",
        "providerState": {},
        "fileFacts": {},
        "evidence": [],
    }
    evidence = {
        "id": "ev-cascade",
        "group": "google",
        "source": "google",
        "title": "Cascade evidence",
        "confidence": 0.5,
        "query": "",
        "domain": "google",
        "url": "https://example.test/source",
        "retrievedAt": "2026-05-26 13:08:55",
        "contributed": True,
        "sourceEvidenceIds": [],
        "status": "active",
        "submission_id": "SUB-CASCADE",
    }
    with sqlite3.connect(db_path) as conn:
        conn.execute("pragma foreign_keys = on")
        conn.executescript(
            """
            create table submissions (
                id text primary key,
                title text not null default '',
                risk_score integer not null default 0,
                risk_band text not null default 'low',
                decision_status text not null default 'unreviewed',
                submitted_epoch integer not null default 0,
                queue_id text not null default '',
                payload text not null check (json_valid(payload))
            );
            create table evidence (
                id text primary key,
                submission_id text not null,
                source text not null default '',
                confidence real not null default 0 check (confidence >= 0 and confidence <= 1),
                status text not null default 'active',
                contributed integer not null default 1,
                payload text not null check (json_valid(payload)),
                foreign key (submission_id) references submissions(id) on delete cascade
            );
            """
        )
        conn.execute(
            """
            insert into submissions
            (id, title, risk_score, risk_band, decision_status, submitted_epoch, payload)
            values (?, ?, ?, ?, ?, ?, ?)
            """,
            (
                submission["id"],
                submission["title"],
                submission["riskScore"],
                submission["riskBand"],
                submission["decisionStatus"],
                0,
                json.dumps(submission),
            ),
        )
        conn.execute(
            """
            insert into evidence
            (id, submission_id, source, confidence, status, contributed, payload)
            values (?, ?, ?, ?, ?, ?, ?)
            """,
            (
                evidence["id"],
                evidence["submission_id"],
                evidence["source"],
                evidence["confidence"],
                evidence["status"],
                1,
                json.dumps(evidence),
            ),
        )

    store = CopyrighterStore(db_path)
    store.initialize()

    review = store.review("SUB-CASCADE")
    assert [item["id"] for item in review["evidence"]] == ["ev-cascade"]


# --- #2 HIGH: JavaScript-string decoding must not corrupt non-ASCII URLs ------


def test_decode_javascript_string_preserves_korean_literals():
    assert _decode_javascript_string("https://example.com/사진.jpg") == "https://example.com/사진.jpg"
    # Backslash-escaped forward slashes are still unescaped.
    assert _decode_javascript_string("https:\\/\\/example.com\\/사진.jpg") == "https://example.com/사진.jpg"
    # Explicit \uXXXX escapes are still decoded.
    assert _decode_javascript_string("a\\u002Fb") == "a/b"


# --- #6 HIGH: rights-signal matching must use word boundaries -----------------


def _naver_evidence(**data) -> Evidence:
    return Evidence(
        source=EvidenceSource.NAVER_SEARCH,
        reason="Naver search result found",
        confidence=0.5,
        data=data,
    )


def test_named_rights_signal_ignores_substring_false_positives():
    promoter = SearchResultPromoter()
    # "discover"/"premium" contain "cover"/"iu" as substrings but are not signals.
    benign = promoter.promote(
        [
            _naver_evidence(
                title="premium photography",
                query="premium photography",
                result_url="https://example.test/discover/gallery",
            )
        ]
    )[0]
    assert benign.data["promoted"] is False
    assert benign.confidence == 0.2

    # A standalone marker word still promotes.
    real = promoter.promote(
        [
            _naver_evidence(
                title="IU official album",
                query="IU album",
                result_url="https://official.example.test/iu",
            )
        ]
    )[0]
    assert real.data["promoted"] is True


# --- #7 HIGH: external derivative must strip embedded EXIF/GPS -----------------


def test_external_derivative_strips_embedded_exif_bytes():
    from PIL import Image

    image = Image.new("RGB", (120, 90), color=(40, 80, 160))
    exif = image.getexif()
    exif[270] = "secret-camera-serial"  # ImageDescription
    buffer = BytesIO()
    image.save(buffer, format="JPEG", exif=exif)
    original_bytes = buffer.getvalue()

    # Sanity: the original really does carry EXIF.
    assert Image.open(BytesIO(original_bytes)).getexif().get(270) == "secret-camera-serial"

    original = ImagePayload(content=original_bytes, width=120, height=90, metadata={"format": "JPEG"})
    derivative = build_external_derivative(original)

    assert derivative.metadata == {}
    assert derivative.content != original_bytes
    # The bytes shipped to external APIs must no longer carry the EXIF.
    assert not Image.open(BytesIO(derivative.content)).getexif().get(270)


# --- #13 HIGH: a failed search must not consume daily quota -------------------


class _RaisingNaverClient:
    image_pages = 1

    def search_image(self, query: str) -> dict:
        raise RuntimeError("naver unavailable")


def test_failed_naver_search_does_not_consume_quota():
    policy = SearchApiPolicy(compliance_approved=True, allowed_providers={"naver"}, daily_limit=5)
    adapter = NaverSearchAdapter(_RaisingNaverClient())

    evidence = adapter.search("SUB-1", "query", policy)

    assert evidence[0].source == EvidenceSource.ENRICHMENT_FAILURE
    assert policy.calls_made == 0


# --- #18: submittedEpoch derived from the manifest timestamp ------------------


def test_submitted_epoch_reflects_manifest_timestamp_for_chronological_sort():
    from datetime import datetime
    from rights_filter.server.sqlite_store import _submission_payload

    def _rec(sub_id, submitted_at):
        return {"id": sub_id, "title": sub_id, "asset": f"/media/{sub_id}.svg", "submitted_at": submitted_at}

    older = _submission_payload(_rec("A", "2026-05-26 10:00"), 0, "low", [])
    newer = _submission_payload(_rec("B", "2026-05-26 10:05"), 0, "low", [])
    assert older["submittedEpoch"] == int(datetime.strptime("2026-05-26 10:00", "%Y-%m-%d %H:%M").timestamp())
    assert older["submittedEpoch"] < newer["submittedEpoch"]


def test_submitted_epoch_falls_back_to_now_when_timestamp_missing():
    import time
    from rights_filter.server.sqlite_store import _submission_payload

    before = int(time.time())
    payload = _submission_payload({"id": "C", "title": "C", "asset": "/media/c.svg", "submitted_at": ""}, 0, "low", [])
    after = int(time.time())
    assert before <= payload["submittedEpoch"] <= after


# --- #19: malformed image URLs are dropped, not echoed ------------------------


def test_normalized_image_url_returns_empty_on_parse_failure():
    from rights_filter.integrations.naver_search import _normalized_image_url

    for value in ("javascript:alert(1)", "ftp://example.test/x.png", "not a url", "http://", "example.test"):
        assert _normalized_image_url(value) == ""
    assert _normalized_image_url("https://example.test/image.jpg") == "https://example.test/image.jpg"


# --- #20: malformed Naver response degrades to ENRICHMENT_FAILURE -------------


def test_naver_search_malformed_items_return_enrichment_failure_not_raise():
    from rights_filter.integrations.naver_search import FakeNaverSearchClient

    client = FakeNaverSearchClient(response={"items": ["not-a-dict"]})
    adapter = NaverSearchAdapter(client)
    policy = SearchApiPolicy(compliance_approved=True, allowed_providers={"naver"})

    evidence = adapter.search("submission-1", "IU album cover", policy)

    assert len(evidence) == 1
    assert evidence[0].source == EvidenceSource.ENRICHMENT_FAILURE
    assert evidence[0].reason.startswith("Naver search failed:")
    assert policy.calls_made == 1  # the successful fetch still consumed quota


# --- #21: dead category key is not emitted ------------------------------------


def test_cloud_vision_entity_evidence_omits_category_key():
    from rights_filter.integrations.cloud_vision_web_detection import _map_response

    evidence = _map_response("s1", {"web_entities": [{"description": "IU", "score": 0.9}]})
    entity = next(e for e in evidence if e.data.get("match") == "entity")
    assert "category" not in entity.data
    assert entity.data["entity"] == "IU"


# --- #22 / #24: env-client robustness ----------------------------------------


class _NoopTransport:
    def request_json(self, *args, **kwargs):
        return {}


def test_unset_daily_limit_reports_unlimited_quota_not_zero():
    from rights_filter.integrations.env_clients import build_provider_runtime

    env = {"NAVER_CLIENT_ID": "id", "NAVER_CLIENT_SECRET": "secret"}
    rt = build_provider_runtime(env=env, transport=_NoopTransport())
    assert rt.search_policy.daily_limit is None
    assert rt.provider_payloads["naver"]["quota"] is None

    rt0 = build_provider_runtime(env={**env, "COPYRIGHTER_NAVER_DAILY_LIMIT": "0"}, transport=_NoopTransport())
    assert rt0.provider_payloads["naver"]["quota"] == 0


def test_provider_runtime_falls_back_on_malformed_numeric_env():
    from rights_filter.integrations.env_clients import build_provider_runtime

    runtime = build_provider_runtime(
        env={
            "COPYRIGHTER_AUTO_NAVER_QUERY_LIMIT": "abc",
            "COPYRIGHTER_SEARCH_RESULT_SIMILARITY_THRESHOLD": "high",
            "COPYRIGHTER_NAVER_DAILY_LIMIT": "not-a-number",
        },
        transport=_NoopTransport(),
    )
    assert runtime.auto_naver_query_limit == 3
    assert runtime.search_result_similarity_threshold == 0.9
    assert runtime.search_policy.daily_limit is None


# --- #29: a single bad face box does not discard all crops --------------------


def test_face_crop_skips_bad_box_but_keeps_good_crops():
    from PIL import Image
    from rights_filter.analysis.preprocessing import build_face_crop_derivatives

    source = BytesIO()
    Image.new("RGB", (300, 200), color=(0, 120, 200)).save(source, format="PNG")
    original = ImagePayload(content=source.getvalue(), width=300, height=200, metadata={})

    crops = build_face_crop_derivatives(original, [(50, 20, 60, 40), ("x", 20, 60, 40)], max_crops=3)

    assert len(crops) == 1
    assert crops[0].content.startswith(b"\xff\xd8")


# --- #25: executed_searches counts search CALLS, not result items ------------


def test_executed_searches_counts_calls_not_result_items():
    from rights_filter.analysis.evidence_enrichment import EvidenceEnricher
    from rights_filter.analysis.llm_assistance import FakeInternalLlmClient, InternalLlmAssistant
    from rights_filter.analysis.risk_scoring import RiskScorer
    from rights_filter.analysis.search_query_generation import SearchQueryGenerator
    from rights_filter.domain.records import AnalysisRun, InMemoryRightsFilterRepository, ScoreResult
    from rights_filter.integrations.naver_search import FakeNaverSearchClient

    repo = InMemoryRightsFilterRepository()
    run = AnalysisRun.for_submission("submission-1", "v1")
    run.add_evidence(
        Evidence(
            source=EvidenceSource.WEB_DETECTION,
            reason="Web entity matched IU",
            confidence=0.9,
            data={"entity": "IU", "category": "celebrity"},
        )
    )
    run.score = ScoreResult(score=35, band="medium", reasons=["x"])
    repo.save_analysis_run(run)

    # Two result items per search call must NOT inflate executed_searches.
    client = FakeNaverSearchClient(
        response={
            "items": [
                {"title": "IU official album cover", "link": "https://example.test/1.jpg", "page_url": "https://example.test/p1"},
                {"title": "IU official album cover 2", "link": "https://example.test/2.jpg", "page_url": "https://example.test/p2"},
            ]
        }
    )
    enricher = EvidenceEnricher(
        query_generator=SearchQueryGenerator(),
        naver_adapter=NaverSearchAdapter(client),
        search_policy=SearchApiPolicy(compliance_approved=True, allowed_providers={"naver"}),
        promoter=SearchResultPromoter(),
        llm_assistant=InternalLlmAssistant(FakeInternalLlmClient(summary="Evidence cites a named work.")),
        scorer=RiskScorer(),
    )

    summary = enricher.enrich_latest(repo, "submission-1")

    assert summary.generated_queries == 3
    assert summary.executed_searches == 3
    assert len(client.calls) == 3


# --- #27: overlapping frontal+profile face boxes count once -------------------


def test_face_detector_merges_overlapping_frontal_and_profile_boxes(monkeypatch):
    import sys
    from types import SimpleNamespace
    from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector

    class FakeCascade:
        def __init__(self, path):
            self.path = path

        def empty(self):
            return False

        def detectMultiScale(self, gray, scaleFactor, minNeighbors, minSize):
            # Same physical face reported slightly differently by each cascade.
            if "frontalface" in self.path:
                return [(10, 10, 100, 100)]
            return [(14, 12, 98, 102)]  # IoU ~0.89 vs the frontal box

    fake_cv2 = SimpleNamespace(
        IMREAD_COLOR=1,
        COLOR_BGR2GRAY=2,
        data=SimpleNamespace(haarcascades="cascades/"),
        imdecode=lambda data, flags: "decoded-image",
        cvtColor=lambda image, conversion: "gray-image",
        CascadeClassifier=FakeCascade,
    )
    fake_numpy = SimpleNamespace(uint8="uint8", frombuffer=lambda content, dtype: ["bytes"])
    monkeypatch.setitem(sys.modules, "cv2", fake_cv2)
    monkeypatch.setitem(sys.modules, "numpy", fake_numpy)

    signal = HeuristicFacePersonDetector().detect(
        ImagePayload(content=b"\xff\xd8 image bytes", width=200, height=200, metadata={})
    )

    assert signal.face_count == 1
    assert signal.person_count == 1
    assert signal.face_boxes == ((10, 10, 100, 100),)


# --- #28: one failing submission does not abort the enrichment batch ----------


def test_review_enrichment_job_continues_after_one_submission_fails():
    from rights_filter.analysis.evidence_enrichment import EnrichmentSummary
    from rights_filter.jobs.review_enrichment_job import ReviewEnrichmentJob

    class _Enricher:
        def enrich_latest(self, repository, submission_id):
            if submission_id == "B":
                raise RuntimeError("boom")
            return EnrichmentSummary()

    summary = ReviewEnrichmentJob(_Enricher()).run(repository=None, submission_ids=["A", "B", "C"])

    assert summary.processed == 3
    assert summary.failed == 1
    assert any("B" in reason for reason in summary.failure_reasons)


# --- #42: Google CSE 'num' is clamped to the API's 1..10 range ----------------


class _ItemsTransport:
    def __init__(self):
        self.urls = []

    def request_json(self, method, url, headers=None, payload=None, timeout=10):
        self.urls.append(url)
        return {"items": []}


def test_google_custom_search_clamps_num_to_valid_range():
    from rights_filter.integrations.google_custom_search import GoogleCustomSearchClient

    transport = _ItemsTransport()
    client = GoogleCustomSearchClient("key", "cx", transport=transport, image_num=50, web_num=0)
    client.search_image("q")
    client.search_web("q")
    assert "num=10" in transport.urls[0]   # upper clamp
    assert "num=1" in transport.urls[1]    # lower clamp


# --- #44: a non-JSON 200 body raises HttpJsonError, not JSONDecodeError -------


def test_invalid_json_response_raises_http_json_error(monkeypatch):
    import pytest
    from rights_filter.integrations import http_json
    from rights_filter.integrations.http_json import HttpJsonError, UrllibJsonTransport

    class _Resp:
        def __enter__(self):
            return self

        def __exit__(self, *exc):
            return False

        def read(self):
            return b"<html>not json</html>"

    monkeypatch.setattr(http_json, "urlopen", lambda request, timeout=None: _Resp())
    with pytest.raises(HttpJsonError):
        UrllibJsonTransport().request_json("GET", "http://example.test/x")


# --- #46: provider seed does not fabricate a lastSuccess timestamp ------------


def test_provider_seed_does_not_fabricate_last_success():
    from rights_filter.integrations.env_clients import build_provider_runtime

    rt = build_provider_runtime(
        env={"NAVER_CLIENT_ID": "id", "NAVER_CLIENT_SECRET": "secret"},
        transport=_NoopTransport(),
    )
    for payload in rt.provider_payloads.values():
        assert payload["lastSuccess"] == "없음"


# --- #63: _unique_texts accepts a generator and dedups/strips -----------------


def test_unique_texts_accepts_generator():
    from rights_filter.integrations.google_custom_search import _unique_texts

    assert _unique_texts(value for value in [" a ", "a", "", "b"]) == ["a", "b"]
