from pathlib import Path
from threading import Thread
from urllib.error import HTTPError
from urllib.request import Request, urlopen
import base64
import json
import socket
import sqlite3

import pytest

from rights_filter.analysis.face_person_detection import FacePersonSignal
from rights_filter.server.http_app import build_server
from rights_filter.server.image_store import LocalSubmissionImageStore
from rights_filter.server import sqlite_store as sqlite_store_module
from rights_filter.server.sqlite_store import CopyrighterStore
from rights_filter.integrations.env_clients import build_provider_runtime


class FakeJsonTransport:
    def __init__(self, response=None):
        self.response = response or {
            "items": [
                {
                    "title": "Winter official image",
                    "link": "https://example.test/winter.jpg",
                    "thumbnail": "https://example.test/winter-thumb.jpg",
                    "page_url": "https://example.test/winter-page",
                }
            ]
        }

    def request_json(self, method, url, headers=None, payload=None, timeout=10):
        return self.response


class RoutingFakeJsonTransport:
    def __init__(
        self,
        google_response,
        naver_response,
        naver_blog_response=None,
        naver_web_response=None,
        google_custom_image_response=None,
        google_custom_web_response=None,
    ):
        self.google_response = google_response
        self.naver_response = naver_response
        self.naver_blog_response = naver_blog_response or {"items": []}
        self.naver_web_response = naver_web_response or {"items": []}
        self.google_custom_image_response = google_custom_image_response or {"items": []}
        self.google_custom_web_response = google_custom_web_response or {"items": []}

    def request_json(self, method, url, headers=None, payload=None, timeout=10):
        if "www.googleapis.com/customsearch/v1" in url:
            if "searchType=image" in url:
                return self.google_custom_image_response
            return self.google_custom_web_response
        if "vision.googleapis.com" in url:
            return self.google_response
        if "openapi.naver.com/v1/search/blog" in url:
            return self.naver_blog_response
        if "openapi.naver.com/v1/search/webkr" in url:
            return self.naver_web_response
        if "openapi.naver.com" in url:
            return self.naver_response
        return {"response": "summary"}


class OneFaceDetector:
    def detect(self, image):
        return FacePersonSignal(face_count=1, person_count=1)


@pytest.fixture(autouse=True)
def use_explicit_test_face_detector(monkeypatch):
    monkeypatch.setattr(sqlite_store_module, "HeuristicFacePersonDetector", lambda: OneFaceDetector())


def _fixtures(tmp_path: Path):
    static_dir = tmp_path / "static"
    static_dir.mkdir()
    (static_dir / "index.html").write_text("<!doctype html><title>ok</title>", encoding="utf-8")

    image_root = tmp_path / "submissions"
    image_dir = image_root / "images"
    image_dir.mkdir(parents=True)
    (image_dir / "sample.svg").write_text("<svg><!-- FACE --></svg>", encoding="utf-8")
    (image_root / "submissions.json").write_text(
        """
        [
          {
            "id": "SUB-API1",
            "title": "API sample",
            "file": "images/sample.svg",
            "width": 320,
            "height": 240
          }
        ]
        """,
        encoding="utf-8",
    )

    image_store = LocalSubmissionImageStore(image_root)
    store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
    store.initialize()
    store.seed_from_image_store(image_store)
    return static_dir, image_store, store


def _png_fixtures(tmp_path: Path):
    from PIL import Image

    static_dir = tmp_path / "static"
    static_dir.mkdir()
    (static_dir / "index.html").write_text("<!doctype html><title>ok</title>", encoding="utf-8")

    image_root = tmp_path / "submissions"
    image_dir = image_root / "images"
    image_dir.mkdir(parents=True)
    Image.new("RGB", (320, 240), color=(230, 220, 210)).save(image_dir / "sample.png")
    (image_root / "submissions.json").write_text(
        """
        [
          {
            "id": "SUB-API1",
            "title": "API sample",
            "file": "images/sample.png",
            "width": 320,
            "height": 240
          }
        ]
        """,
        encoding="utf-8",
    )

    image_store = LocalSubmissionImageStore(image_root)
    store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
    store.initialize()
    store.seed_from_image_store(image_store)
    return static_dir, image_store, store


def _candidate_png_bytes(color=(210, 220, 230), marker: str = "") -> bytes:
    from io import BytesIO
    from PIL import Image, ImageDraw

    buffer = BytesIO()
    image = Image.new("RGB", (80, 60), color=color)
    draw = ImageDraw.Draw(image)
    if marker == "left":
        draw.rectangle((0, 0, 36, 60), fill=(20, 50, 90))
        draw.rectangle((36, 0, 80, 20), fill=(235, 235, 245))
    elif marker == "right":
        draw.rectangle((44, 0, 80, 60), fill=(120, 40, 30))
        draw.ellipse((8, 8, 42, 42), fill=(245, 245, 235))
    image.save(buffer, format="PNG")
    return buffer.getvalue()


def _start(server):
    thread = Thread(target=server.serve_forever, daemon=True)
    thread.start()
    return thread


def _json(url: str, method: str = "GET", body: dict | None = None, timeout: int = 5):
    data = None if body is None else json.dumps(body).encode("utf-8")
    request = Request(
        url,
        data=data,
        method=method,
        headers={"Content-Type": "application/json"},
    )
    with urlopen(request, timeout=timeout) as response:
        return json.loads(response.read().decode("utf-8"))


def test_http_server_does_not_auto_import_on_startup(tmp_path: Path):
    image_root = tmp_path / "startup-submissions"
    images_root = image_root / "images"
    images_root.mkdir(parents=True)
    (images_root / "cold.svg").write_text("<svg><!-- COLD --></svg>", encoding="utf-8")
    (image_root / "submissions.json").write_text(
        """
        [
          {
            "id": "SUB-COLD",
            "title": "Cold Start Submission",
            "file": "images/cold.svg",
            "width": 320,
            "height": 240,
            "submitted_at": "2026-05-26 10:00"
          }
        ]
        """,
        encoding="utf-8",
    )

    static_dir = tmp_path / "static"
    static_dir.mkdir()
    (static_dir / "index.html").write_text("<!doctype html><title>ok</title>", encoding="utf-8")

    store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
    store.initialize()
    image_store = LocalSubmissionImageStore(image_root)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        bootstrap = _json(base + "/api/bootstrap")
        import_payload = _json(
            base + "/api/submissions/import-folder",
            method="POST",
            body={"path": str(image_root)},
            timeout=20,
        )
    finally:
        server.shutdown()

    assert bootstrap["submissionQueue"] is None
    assert bootstrap["submissions"] == []
    assert import_payload["submissionQueue"]["folderPath"] == str(image_root.resolve())
    assert [item["id"] for item in import_payload["submissions"]] == ["SUB-COLD"]

def test_http_server_exposes_health_bootstrap_and_mutations(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        health = _json(base + "/health")
        bootstrap = _json(base + "/api/bootstrap")
        decision = _json(
            base + "/api/submissions/SUB-API1/decision",
            method="POST",
            body={"decision": "held", "memo": "needs review"},
        )
        providers = _json(
            base + "/api/providers/naver",
            method="PATCH",
            body={"enabled": False},
        )
    finally:
        server.shutdown()

    assert health["status"] == "ok"
    assert bootstrap["submissions"][0]["id"] == "SUB-API1"
    assert decision["decisionStatus"] == "held"
    assert next(provider for provider in providers if provider["id"] == "naver")["enabled"] is False


def test_http_server_rejects_enabling_unconfigured_google_custom_search(tmp_path: Path):
    static_dir, image_store, _ = _fixtures(tmp_path)
    store = CopyrighterStore(
        tmp_path / "copyrighter-missing-google-search.sqlite3",
        provider_runtime=build_provider_runtime(env={}, transport=FakeJsonTransport({})),
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        with pytest.raises(Exception):
            _json(
                base + "/api/providers/google_search",
                method="PATCH",
                body={"enabled": True},
            )
    finally:
        server.shutdown()

    google_search = next(provider for provider in store.providers() if provider["id"] == "google_search")
    assert google_search["enabled"] is False


def test_http_patch_unknown_provider_without_enabled_returns_404(tmp_path: Path):
    from urllib.error import HTTPError

    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    # No "enabled" in the body forces the auto-toggle path, which must look the
    # provider up; an unknown id should produce a clean 404, not a crash.
    try:
        with pytest.raises(HTTPError) as exc_info:
            _json(base + "/api/providers/does-not-exist", method="PATCH", body={})
    finally:
        server.shutdown()

    assert exc_info.value.code == 404


def test_active_image_store_lookup_is_lazy_for_routes_that_do_not_need_it(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    calls = {"n": 0}
    original = store.active_submission_image_store

    def counting(root):
        calls["n"] += 1
        return original(root)

    store.active_submission_image_store = counting
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    # /health and /api/bootstrap never use the active store, so the SQLite-backed
    # lookup must not run for them.
    try:
        _json(base + "/health")
        _json(base + "/api/bootstrap")
    finally:
        server.shutdown()

    assert calls["n"] == 0


def test_http_server_manual_google_custom_search_compares_result_image(tmp_path: Path):
    static_dir, image_store, _ = _png_fixtures(tmp_path)
    runtime = build_provider_runtime(
        env={
            "GOOGLE_CUSTOM_SEARCH_API_KEY": "google-search-key",
            "GOOGLE_CUSTOM_SEARCH_CX": "search-engine-id",
        },
        transport=FakeJsonTransport(
            {
                "items": [
                    {
                        "title": "IU official image",
                        "link": "https://example.test/manual-google-iu.png",
                        "displayLink": "example.test",
                        "snippet": "official profile",
                        "image": {
                            "thumbnailLink": "https://example.test/manual-google-iu-thumb.png",
                            "contextLink": "https://example.test/manual-google-profile",
                        },
                    }
                ]
            }
        ),
    )
    runtime.llm_assistant = None
    store = CopyrighterStore(
        tmp_path / "copyrighter-google.sqlite3",
        provider_runtime=runtime,
        candidate_image_fetcher=lambda url: image_store.image_payload("SUB-API1").content,
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        review = _json(
            base + "/api/search/manual",
            method="POST",
            body={
                "submission_id": "SUB-API1",
                "provider": "google_search",
                "query": "IU official profile",
            },
        )
    finally:
        server.shutdown()

    assert review["queryHistory"][0]["provider"] == "google_search"
    assert any(
        item["source"] == "fingerprint"
        and item["matchType"] == "search_result_image"
        and item["remoteImageUrl"] == "https://example.test/manual-google-iu.png"
        for item in review["evidence"]
    )


def test_http_server_marks_evidence_status_and_creates_watchlist_on_case_decision(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    face_evidence = next(item for item in store.review("SUB-API1")["evidence"] if item["source"] == "face")

    try:
        status_payload = _json(
            base + f"/api/evidence/{face_evidence['id']}/status",
            method="POST",
            body={"submission_id": "SUB-API1", "status": "used_for_judgment", "note": "판정 근거"},
        )
        _json(
            base + "/api/submissions/SUB-API1/decision",
            method="POST",
            body={"decision": "held", "memo": "권리 확인 필요"},
        )
        bootstrap = _json(base + "/api/bootstrap")
    finally:
        server.shutdown()

    marked = next(item for item in status_payload["evidence"] if item["id"] == face_evidence["id"])
    entry = bootstrap["knowledgeEntries"][0]
    assert marked["operatorStatus"] == "used_for_judgment"
    assert entry["entryStatus"] == "watchlist"
    assert entry["sourceEvidenceIds"] == [face_evidence["id"]]
    assert entry["sampleFingerprints"]


def test_http_server_promotes_and_excludes_watchlist_candidate(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    store.record_decision("SUB-API1", "held", "권리 확인 필요", image_store=image_store)
    entry_id = store.bootstrap()["knowledgeEntries"][0]["id"]
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        promoted = _json(base + f"/api/knowledge/{entry_id}/promote-watchlist", method="POST", body={})
        excluded = _json(
            base + f"/api/knowledge/{entry_id}/exclude-watchlist",
            method="POST",
            body={"reason": "오탐 확인"},
        )
    finally:
        server.shutdown()

    assert next(item for item in promoted["knowledgeEntries"] if item["id"] == entry_id)["entryStatus"] == "confirmed"
    excluded_entry = next(item for item in excluded["knowledgeEntries"] if item["id"] == entry_id)
    assert excluded_entry["entryStatus"] == "excluded"
    assert excluded_entry["active"] is False


def test_http_server_reloads_new_submissions_without_restart(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    image_root = tmp_path / "submissions"

    try:
        (image_root / "images" / "second.svg").write_text("<svg><!-- NEW --></svg>", encoding="utf-8")
        payload = _json(
            base + "/api/submissions/reload",
            method="POST",
            body={},
        )
    finally:
        server.shutdown()

    assert payload["imported"] == 1
    assert [item["id"] for item in payload["submissions"]] == ["SUB-API1", "second"]
    assert any(event["object"] == "second" for event in payload["auditEvents"])


def test_http_server_imports_submissions_from_custom_folder_and_switches_active_queue(tmp_path: Path):
    static_dir, image_store, _store = _fixtures(tmp_path)
    import_root = tmp_path / "custom-submissions"
    (import_root / "images").mkdir(parents=True)
    (import_root / "images" / "other.svg").write_text("<svg><!-- CUSTOM --></svg>", encoding="utf-8")

    server = build_server(
        host="127.0.0.1",
        port=0,
        image_store=image_store,
        store=_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        payload = _json(
            base + "/api/submissions/import-folder",
            method="POST",
            body={"path": str(import_root)},
        )
        (tmp_path / "submissions" / "images" / "leak.svg").write_text("<svg><!-- LEAK --></svg>", encoding="utf-8")
        payload_after_reload = _json(
            base + "/api/submissions/reload",
            method="POST",
            body={},
        )
    finally:
        server.shutdown()

    assert payload["submissionQueue"] is not None
    assert payload["submissionQueue"]["folderPath"] == str(import_root.resolve())
    assert [item["id"] for item in payload["submissions"]] == ["other"]
    assert payload_after_reload["submissionQueue"]["folderPath"] == str(import_root.resolve())
    assert [item["id"] for item in payload_after_reload["submissions"]] == ["other"]


def test_http_server_uploads_submission_image_into_active_queue(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1",
        port=0,
        image_store=image_store,
        store=store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    image_bytes = b"<svg><!-- UPLOADED --></svg>"

    try:
        payload = _json(
            base + "/api/submissions/upload-image",
            method="POST",
            body={
                "image": {
                    "filename": "new upload.svg",
                    "content_type": "image/svg+xml",
                    "data": base64.b64encode(image_bytes).decode("ascii"),
                }
            },
            timeout=30,
        )
    finally:
        server.shutdown()

    assert payload["imported"] == 1
    assert payload["uploadedSubmissionId"] == "new-upload"
    assert [item["id"] for item in payload["submissions"]] == ["SUB-API1", "new-upload"]
    assert (tmp_path / "submissions" / "images" / "new-upload.svg").read_bytes() == image_bytes


def test_http_server_reimports_same_folder_only_with_remaining_submissions(tmp_path: Path):
    static_dir, image_store, _store = _fixtures(tmp_path)
    repeat_folder = tmp_path / "custom-repeat"
    images = repeat_folder / "images"
    images.mkdir(parents=True)
    (images / "keep.svg").write_text("<svg><!-- KEEP --></svg>", encoding="utf-8")
    (images / "drop.svg").write_text("<svg><!-- DROP --></svg>", encoding="utf-8")
    (repeat_folder / "submissions.json").write_text(
        """
        [
          {
            "id": "SUB-REPEAT-KEEP",
            "title": "Repeat Keep",
            "file": "images/keep.svg",
            "width": 640,
            "height": 480,
            "submitted_at": "2026-05-26 10:00"
          },
          {
            "id": "SUB-REPEAT-DROP",
            "title": "Repeat Drop",
            "file": "images/drop.svg",
            "width": 640,
            "height": 480,
            "submitted_at": "2026-05-26 10:01"
          }
        ]
        """,
        encoding="utf-8",
    )

    server = build_server(
        host="127.0.0.1",
        port=0,
        image_store=image_store,
        store=_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        first_import = _json(
            base + "/api/submissions/import-folder",
            method="POST",
            body={"path": str(repeat_folder)},
            timeout=20,
        )
        (images / "drop.svg").unlink()
        second_import = _json(
            base + "/api/submissions/import-folder",
            method="POST",
            body={"path": str(repeat_folder)},
            timeout=20,
        )
        root_import = _json(
            base + "/api/submissions/import-folder",
            method="POST",
            body={"path": str((tmp_path / "submissions").resolve())},
            timeout=20,
        )
    finally:
        server.shutdown()

    assert first_import["submissionQueue"]["folderPath"] == str(repeat_folder.resolve())
    assert [item["id"] for item in first_import["submissions"]] == ["SUB-REPEAT-DROP", "SUB-REPEAT-KEEP"]
    assert [item["id"] for item in second_import["submissions"]] == ["SUB-REPEAT-KEEP"]
    assert root_import["submissionQueue"]["folderPath"] == str((tmp_path / "submissions").resolve())
    assert [item["id"] for item in root_import["submissions"]] == ["SUB-API1"]


def test_http_server_registers_manual_knowledge_entry_with_reference_image(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    image_bytes = image_store.image_payload("SUB-API1").content

    try:
        payload = _json(
            base + "/api/knowledge/manual",
            method="POST",
            body={
                "name": "Manual reference",
                "type": "rejected_reference",
                "aliases": ["manual alias"],
                "keywords": ["manual keyword"],
                "memo": "Operator supplied reference image.",
                "image": {
                    "filename": "manual.svg",
                    "content_type": "image/svg+xml",
                    "data": base64.b64encode(image_bytes).decode("ascii"),
                },
            },
        )
    finally:
        server.shutdown()

    entry = payload["knowledgeEntries"][0]
    assert entry["name"] == "Manual reference"
    assert entry["imageAsset"].startswith("/knowledge-media/")
    assert entry["sampleFingerprints"]
    assert any(event["event"] == "Knowledge entry manually created" for event in payload["auditEvents"])


def test_http_server_collects_keyword_candidates_and_promotes_candidate(tmp_path: Path):
    static_dir, image_store, _store = _fixtures(tmp_path)
    runtime = build_provider_runtime(
        env={"NAVER_CLIENT_ID": "naver-id", "NAVER_CLIENT_SECRET": "naver-secret"},
        transport=FakeJsonTransport(),
    )
    store = CopyrighterStore(
        tmp_path / "copyrighter.sqlite3",
        provider_runtime=runtime,
        candidate_image_fetcher=lambda url: _candidate_png_bytes((180, 190, 210) if "profile" in url else (80, 120, 170)),
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        collected = _json(
            base + "/api/collections/keyword",
            method="POST",
            body={"query": "winter aespa", "provider": "naver"},
        )
        candidate_id = collected["collectionCandidates"][0]["id"]
        promoted = _json(
            base + f"/api/collections/candidates/{candidate_id}/promote",
            method="POST",
            body={"memo": "reviewed"},
        )
    finally:
        server.shutdown()

    assert collected["collected"] == 1
    assert collected["collectionCandidates"][0]["imageAsset"].startswith("/collected-media/")
    assert promoted["collectionCandidates"][0]["status"] == "promoted"
    assert promoted["knowledgeEntries"][0]["sourceCandidate"] == candidate_id


def test_http_server_collects_google_custom_keyword_candidates(tmp_path: Path):
    static_dir, image_store, _store = _fixtures(tmp_path)
    runtime = build_provider_runtime(
        env={
            "GOOGLE_CUSTOM_SEARCH_API_KEY": "google-search-key",
            "GOOGLE_CUSTOM_SEARCH_CX": "search-engine-id",
        },
        transport=FakeJsonTransport(
            {
                "items": [
                    {
                        "title": "IU official profile",
                        "link": "https://example.test/iu-profile.png",
                        "displayLink": "example.test",
                        "snippet": "official profile image",
                        "image": {
                            "thumbnailLink": "https://example.test/iu-thumb.png",
                            "contextLink": "https://example.test/iu-profile-page",
                        },
                    }
                ]
            }
        ),
    )
    store = CopyrighterStore(
        tmp_path / "copyrighter.sqlite3",
        provider_runtime=runtime,
        candidate_image_fetcher=lambda url: _candidate_png_bytes(
            marker="left" if "profile" in url else "right"
        ),
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        collected = _json(
            base + "/api/collections/keyword",
            method="POST",
            body={"query": "IU official profile", "provider": "google_search"},
        )
    finally:
        server.shutdown()

    assert collected["collected"] == 1
    assert collected["collectionCandidates"][0]["provider"] == "google_search"
    assert collected["collectionCandidates"][0]["sourceUrl"] == "https://example.test/iu-profile-page"


def test_http_server_collects_google_custom_web_page_image_candidates_when_image_search_is_empty(tmp_path: Path):
    static_dir, image_store, _store = _fixtures(tmp_path)
    runtime = build_provider_runtime(
        env={
            "GOOGLE_CUSTOM_SEARCH_API_KEY": "google-search-key",
            "GOOGLE_CUSTOM_SEARCH_CX": "search-engine-id",
        },
        transport=RoutingFakeJsonTransport(
            google_response={},
            naver_response={"items": []},
            google_custom_image_response={"items": []},
            google_custom_web_response={
                "items": [
                    {
                        "title": "IU official page",
                        "link": "https://example.test/iu-profile-page",
                        "displayLink": "example.test",
                        "snippet": "official profile page",
                        "pagemap": {
                            "cse_image": [{"src": "https://example.test/iu-page-image.png"}],
                        },
                    }
                ]
            },
        ),
    )
    store = CopyrighterStore(
        tmp_path / "copyrighter.sqlite3",
        provider_runtime=runtime,
        candidate_image_fetcher=lambda url: _candidate_png_bytes(
            marker="left" if "profile" in url else "right"
        ),
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        collected = _json(
            base + "/api/collections/keyword",
            method="POST",
            body={"query": "IU official profile", "provider": "google_search"},
        )
    finally:
        server.shutdown()

    assert collected["collected"] == 1
    assert collected["collectionCandidates"][0]["imageUrl"] == "https://example.test/iu-page-image.png"
    assert collected["collectionCandidates"][0]["sourceCandidateType"] == "provider_page_image"


def test_http_server_collects_naver_blog_page_image_candidates_when_image_search_is_empty(tmp_path: Path):
    static_dir, image_store, _store = _fixtures(tmp_path)
    runtime = build_provider_runtime(
        env={
            "NAVER_CLIENT_ID": "naver-id",
            "NAVER_CLIENT_SECRET": "naver-secret",
        },
        transport=RoutingFakeJsonTransport(
            google_response={},
            naver_response={"items": []},
            naver_blog_response={
                "items": [
                    {
                        "title": "IU official blog",
                        "description": "official profile page",
                        "link": "https://example.test/iu-blog-post",
                        "bloggername": "official blog",
                        "bloggerlink": "https://blog.example.test",
                        "postdate": "20260527",
                    }
                ]
            },
        ),
    )

    def fetcher(url: str) -> bytes:
        if url == "https://example.test/iu-blog-post":
            return b'<meta property="og:image" content="https://example.test/iu-blog-image.png">'
        if url == "https://example.test/iu-blog-image.png":
            return _candidate_png_bytes()
        raise AssertionError(f"unexpected URL fetched: {url}")

    store = CopyrighterStore(
        tmp_path / "copyrighter.sqlite3",
        provider_runtime=runtime,
        candidate_image_fetcher=fetcher,
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        collected = _json(
            base + "/api/collections/keyword",
            method="POST",
            body={"query": "IU official profile", "provider": "naver"},
        )
    finally:
        server.shutdown()

    assert collected["collected"] == 1
    assert collected["collectionCandidates"][0]["imageUrl"] == "https://example.test/iu-blog-image.png"
    assert collected["collectionCandidates"][0]["sourceCandidateType"] == "html_page_image"


def test_http_server_promotes_multiple_collection_candidates_into_one_knowledge_entry(tmp_path: Path):
    static_dir = tmp_path / "static"
    static_dir.mkdir()
    (static_dir / "index.html").write_text("<!doctype html><title>ok</title>", encoding="utf-8")

    image_root = tmp_path / "submissions"
    image_store = LocalSubmissionImageStore(image_root)
    runtime = build_provider_runtime(
        env={"NAVER_CLIENT_ID": "naver-id", "NAVER_CLIENT_SECRET": "naver-secret"},
        transport=FakeJsonTransport(
            {
                "items": [
                    {
                        "title": "Winter official profile",
                        "link": "https://example.test/winter-profile.jpg",
                        "thumbnail": "https://example.test/winter-profile-thumb.jpg",
                        "page_url": "https://example.test/winter-profile-page",
                    },
                    {
                        "title": "Winter stage image",
                        "link": "https://example.test/winter-stage.jpg",
                        "thumbnail": "https://example.test/winter-stage-thumb.jpg",
                        "page_url": "https://example.test/winter-stage-page",
                    },
                ]
            }
        ),
    )
    store = CopyrighterStore(
        tmp_path / "copyrighter.sqlite3",
        provider_runtime=runtime,
        candidate_image_fetcher=lambda url: _candidate_png_bytes(
            marker="left" if "profile" in url else "right"
        ),
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        collected = _json(
            base + "/api/collections/keyword",
            method="POST",
            body={"query": "winter aespa", "provider": "naver"},
        )
        candidate_ids = [item["id"] for item in collected["collectionCandidates"]]
        promoted = _json(
            base + "/api/collections/candidates/promote-batch",
            method="POST",
            body={
                "candidate_ids": candidate_ids,
                "name": "Winter",
                "type": "public_figure",
                "aliases": ["Winter"],
                "keywords": ["aespa"],
                "memo": "Merged from two collected samples.",
            },
        )
    finally:
        server.shutdown()

    entry = next(item for item in promoted["knowledgeEntries"] if item["name"] == "Winter")
    promoted_candidates = [item for item in promoted["collectionCandidates"] if item["id"] in candidate_ids]

    assert len(candidate_ids) == 2
    assert entry["type"] == "celebrity"
    assert set(entry["sourceCandidates"]) == set(candidate_ids)
    assert len(entry["sampleFingerprints"]) == 2
    assert len(entry["imageAssets"]) == 2
    assert all(item["promotedKnowledgeId"] == entry["id"] for item in promoted_candidates)


def test_http_server_decodes_non_ascii_submission_ids_in_mutation_paths(tmp_path: Path):
    static_dir = tmp_path / "static"
    static_dir.mkdir()
    (static_dir / "index.html").write_text("<!doctype html><title>ok</title>", encoding="utf-8")
    image_root = tmp_path / "submissions"
    image_dir = image_root / "images"
    image_dir.mkdir(parents=True)
    (image_dir / "다운로드.svg").write_text("<svg><!-- FACE --></svg>", encoding="utf-8")
    image_store = LocalSubmissionImageStore(image_root)
    store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
    store.initialize()
    store.seed_from_image_store(image_store)
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        payload = _json(
            base + "/api/submissions/%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C/rerun-enrichment",
            method="POST",
            body={},
        )
    finally:
        server.shutdown()

    assert payload["id"] == "다운로드"


def test_http_server_runs_auto_search_for_submission(tmp_path: Path):
    static_dir, image_store, _ = _fixtures(tmp_path)
    runtime = build_provider_runtime(
        env={
            "NAVER_CLIENT_ID": "naver-id",
            "NAVER_CLIENT_SECRET": "naver-secret",
            "GOOGLE_CUSTOM_SEARCH_API_KEY": "google-search-key",
            "GOOGLE_CUSTOM_SEARCH_CX": "search-engine-id",
            "COPYRIGHTER_AUTO_NAVER_QUERY_LIMIT": "0",
            "COPYRIGHTER_AUTO_GOOGLE_CUSTOM_QUERY_LIMIT": "0",
            "COPYRIGHTER_AUTO_NAVER_BLOG_QUERY_LIMIT": "0",
            "COPYRIGHTER_AUTO_NAVER_WEB_QUERY_LIMIT": "0",
            "COPYRIGHTER_SEARCH_RESULT_COMPARE_LIMIT": "0",
        },
        transport=RoutingFakeJsonTransport(
            google_response={"response": "ignored"},
            naver_response={
                "items": [
                    {
                        "title": "IU official profile",
                        "link": "https://example.test/naver-image.jpg",
                        "thumbnail": "https://example.test/naver-thumb.jpg",
                        "page_url": "https://example.test/naver-page",
                    }
                ]
            },
            google_custom_image_response={
                "items": [
                    {
                        "title": "IU official profile",
                        "link": "https://example.test/custom-image.jpg",
                        "displayLink": "example.test",
                        "snippet": "official profile image",
                        "image": {
                            "thumbnailLink": "https://example.test/custom-thumb.jpg",
                            "contextLink": "https://example.test/custom-page",
                        },
                    }
                ]
            },
            google_custom_web_response={
                "items": [
                    {
                        "title": "IU official profile page",
                        "link": "https://example.test/custom-page.jpg",
                        "displayLink": "example.test",
                        "snippet": "official profile page",
                    }
                ]
            },
        ),
    )
    runtime.llm_assistant = None
    store = CopyrighterStore(
        tmp_path / "copyrighter.sqlite3",
        provider_runtime=runtime,
    )
    store.initialize()
    store.seed_from_image_store(image_store)
    submission = store._get("submissions", "SUB-API1")
    submission["title"] = "IU official profile"
    store._put("submissions", "SUB-API1", submission)

    runtime.auto_naver_query_limit = 1
    runtime.auto_google_custom_query_limit = 1

    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        payload = _json(
            base + "/api/submissions/SUB-API1/search-auto",
            method="POST",
            body={},
        )
    finally:
        server.shutdown()

    assert any(item["source"] == "naver" for item in payload["evidence"])
    assert any(
        item["source"] == "google" and item.get("domain") == "google_custom_search"
        for item in payload["evidence"]
    )
    providers = {item["id"]: item for item in store.providers()}
    assert providers["naver"]["usage"] == 1
    assert providers["google_search"]["usage"] >= 1
    assert any(item["provider"] == "naver" for item in payload["queryHistory"])
    assert any(item["provider"] == "google_search" for item in payload["queryHistory"])


def test_operator_gui_uses_api_bootstrap():
    app_js = Path("web/operator-gui/app.js").read_text(encoding="utf-8")

    assert "/api/bootstrap" in app_js
    assert "refreshFromApi" in app_js


def test_http_server_serves_bundled_font_with_mime_and_immutable_cache(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    fonts_dir = static_dir / "assets" / "fonts"
    fonts_dir.mkdir(parents=True)
    (fonts_dir / "PretendardVariable.woff2").write_bytes(b"wOF2fake")
    server = build_server(
        host="127.0.0.1",
        port=0,
        store=store,
        image_store=image_store,
        static_dir=static_dir,
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        with urlopen(base + "/assets/fonts/PretendardVariable.woff2", timeout=5) as response:
            font_headers = dict(response.headers)
            font_body = response.read()
        with urlopen(base + "/index.html", timeout=5) as response:
            html_headers = dict(response.headers)
    finally:
        server.shutdown()

    assert font_body == b"wOF2fake"
    assert font_headers["Content-Type"] == "font/woff2"
    assert font_headers["Cache-Control"] == "public, max-age=31536000, immutable"
    assert "Cache-Control" not in html_headers


def test_knowledge_entry_update_deactivate_reactivate_lifecycle(tmp_path: Path):
    from urllib.error import HTTPError

    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        _json(
            base + "/api/knowledge/manual",
            method="POST",
            body={"name": "윈터", "type": "public_figure", "aliases": ["에스파 윈터"], "keywords": ["aespa"], "memo": "초상권 주의"},
        )
        entry = next(item for item in store._all("knowledge_entries") if item["name"] == "윈터")
        entry_id = entry["id"]

        # 비활성 항목이 점수 계산용 지식 저장소에서 빠지는지 검증하려면
        # 샘플 지문이 있어야 한다(_knowledge_repository는 지문 없는 항목을 건너뛴다).
        entry["sampleFingerprints"] = ["feedfacefeedface"]
        store._put("knowledge_entries", entry_id, entry)
        assert any(item.id == entry_id for item in store._knowledge_repository().active_knowledge_entries())

        updated = _json(
            base + f"/api/knowledge/{entry_id}",
            method="PATCH",
            body={"aliases": "윈터, winter", "keywords": ["aespa", "윈터 화보"], "memo": "수정된 메모"},
        )
        updated_entry = next(item for item in updated["knowledgeEntries"] if item["id"] == entry_id)
        assert updated_entry["aliases"] == ["윈터", "winter"]
        assert updated_entry["keywords"] == ["aespa", "윈터 화보"]
        assert updated_entry["memo"] == "수정된 메모"

        deactivated = _json(base + f"/api/knowledge/{entry_id}/deactivate", method="POST", body={"reason": "중복 항목"})
        deactivated_entry = next(item for item in deactivated["knowledgeEntries"] if item["id"] == entry_id)
        assert deactivated_entry["active"] is False
        # 비활성화된 항목은 이후 분석의 위험도 산정에 반영되지 않아야 한다.
        assert not any(item.id == entry_id for item in store._knowledge_repository().active_knowledge_entries())

        with pytest.raises(HTTPError) as reactivate_error:
            _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": ""})
        assert reactivate_error.value.code == 400

        reactivated = _json(base + f"/api/knowledge/{entry_id}/reactivate", method="POST", body={"reason": "검토 후 재사용"})
        reactivated_entry = next(item for item in reactivated["knowledgeEntries"] if item["id"] == entry_id)
        assert reactivated_entry["active"] is True
        assert any(item.id == entry_id for item in store._knowledge_repository().active_knowledge_entries())

        with pytest.raises(HTTPError) as empty_patch_error:
            _json(base + f"/api/knowledge/{entry_id}", method="PATCH", body={})
        assert empty_patch_error.value.code == 400

        events = _json(base + "/api/audit-events")
        event_names = [event["event"] for event in events]
        assert "Knowledge entry updated" in event_names
        assert "Knowledge entry deactivated" in event_names
        assert "Knowledge entry reactivated" in event_names
    finally:
        server.shutdown()


class OneFaceBoxDetector:
    def detect(self, image):
        return FacePersonSignal(face_count=1, person_count=1, face_boxes=((40, 40, 120, 120),))


def test_face_crops_are_persisted_served_and_listed_in_review(tmp_path: Path, monkeypatch):
    monkeypatch.setattr(sqlite_store_module, "HeuristicFacePersonDetector", lambda: OneFaceBoxDetector())
    static_dir, image_store, store = _png_fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        review = _json(base + "/api/submissions/SUB-API1/review")
        assert review["faceCrops"], "face crops should be listed for a face submission"
        crop = review["faceCrops"][0]
        assert crop["index"] == 1
        assert crop["box"] == [40, 40, 120, 120]
        assert crop["url"].startswith("/face-crop-media/SUB-API1/")

        with urlopen(base + crop["url"], timeout=5) as response:
            content = response.read()
        assert content[:3] == b"\xff\xd8\xff"  # JPEG magic bytes
    finally:
        server.shutdown()


def test_face_crops_are_empty_for_undetectable_images(tmp_path: Path):
    # autouse OneFaceDetector는 face_boxes를 반환하지 않고, SVG는 PIL로 크롭할 수 없다.
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        review = _json(base + "/api/submissions/SUB-API1/review")
        # 키 자체는 항상 존재해야 한다 — 키 부재는 "아직 동기화 안 됨"을 뜻한다.
        assert review["faceCrops"] == []
    finally:
        server.shutdown()


def test_rerun_enrichment_records_evidence_diff(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    try:
        initial = _json(base + "/api/submissions/SUB-API1/review")
        assert "lastRerunDiff" not in initial

        rerun = _json(base + "/api/submissions/SUB-API1/rerun-enrichment", method="POST", body={})
        diff = rerun["lastRerunDiff"]
        assert isinstance(diff["scoreBefore"], int)
        assert isinstance(diff["scoreAfter"], int)
        assert isinstance(diff["addedEvidenceIds"], list)
        assert isinstance(diff["removedEvidenceIds"], list)
        assert isinstance(diff["removedSummaries"], list)
        assert diff["at"]
        marker_prefix = "ev-SUB-API1-rerun-"
        assert all(not evidence_id.startswith(marker_prefix) for evidence_id in diff["addedEvidenceIds"])

        # 변경이 없는 연속 재분석은 빈 diff를 만들어야 한다 — 재분석 마커와
        # 재생성되는 LLM 요약이 가짜 신규/제거로 잡히면 회귀다.
        rerun_again = _json(base + "/api/submissions/SUB-API1/rerun-enrichment", method="POST", body={})
        second_diff = rerun_again["lastRerunDiff"]
        assert second_diff["addedEvidenceIds"] == []
        assert second_diff["removedSummaries"] == []
        assert second_diff["scoreBefore"] == second_diff["scoreAfter"]
    finally:
        server.shutdown()


def test_json_responses_do_not_send_wildcard_cors(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    try:
        response = urlopen(base + "/api/bootstrap")
        assert response.headers.get("Access-Control-Allow-Origin") is None
    finally:
        server.shutdown()


def test_protected_routes_require_token_when_configured(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(
        host="127.0.0.1", port=0, store=store, image_store=image_store,
        static_dir=static_dir, auth_token="secret",
    )
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    try:
        with pytest.raises(HTTPError) as exc:
            urlopen(base + "/api/bootstrap")
        assert exc.value.code == 401

        # Header auth succeeds and mints an HttpOnly session cookie.
        authed = Request(base + "/api/bootstrap", headers={"Authorization": "Bearer secret"})
        response = urlopen(authed)
        assert response.status == 200
        set_cookie = response.headers.get("Set-Cookie")
        assert set_cookie and "copyrighter_session=secret" in set_cookie
        assert "HttpOnly" in set_cookie

        # The cookie alone authenticates (mimics a browser <img> media load).
        cookie_req = Request(base + "/api/bootstrap", headers={"Cookie": "copyrighter_session=secret"})
        assert urlopen(cookie_req).status == 200

        # A wrong cookie is rejected; the token never travels in the URL.
        bad = Request(base + "/api/bootstrap", headers={"Cookie": "copyrighter_session=nope"})
        with pytest.raises(HTTPError) as exc_bad:
            urlopen(bad)
        assert exc_bad.value.code == 401

        # The GUI shell and liveness probe stay open so the operator can bootstrap.
        assert urlopen(base + "/health").status == 200
        assert urlopen(base + "/").status == 200
    finally:
        server.shutdown()


def test_oversized_body_rejected_with_413(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    try:
        # The server rejects on the Content-Length header before reading the
        # body, so send only headers (a real 11MB upload would race the close).
        big_len = 11 * 1024 * 1024
        request = (
            "POST /api/knowledge/manual HTTP/1.1\r\n"
            "Host: 127.0.0.1\r\n"
            "Content-Type: application/json\r\n"
            f"Content-Length: {big_len}\r\n"
            "Connection: close\r\n"
            "\r\n"
        ).encode("ascii")
        with socket.create_connection(("127.0.0.1", server.server_port), timeout=10) as sock:
            sock.sendall(request)
            response = b""
            while b"\r\n\r\n" not in response:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                response += chunk
        assert b"413" in response.split(b"\r\n", 1)[0]
    finally:
        server.shutdown()


def test_malformed_json_body_returns_400(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    try:
        request = Request(base + "/api/knowledge/manual", data=b"not json", method="POST",
                          headers={"Content-Type": "application/json"})
        with pytest.raises(HTTPError) as exc:
            urlopen(request)
        assert exc.value.code == 400
    finally:
        server.shutdown()


def test_upload_and_import_validation_returns_400(tmp_path: Path):
    static_dir, image_store, store = _fixtures(tmp_path)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"

    def post_expect(path: str, body: dict, expected: int) -> None:
        data = json.dumps(body).encode("utf-8")
        request = Request(base + path, data=data, method="POST", headers={"Content-Type": "application/json"})
        with pytest.raises(HTTPError) as exc:
            urlopen(request, timeout=10)
        assert exc.value.code == expected

    try:
        good_b64 = base64.b64encode(b"x").decode("ascii")
        # unsupported image type
        post_expect("/api/submissions/upload-image", {"image": {"filename": "x.txt", "data": good_b64}}, 400)
        # data is not valid base64
        post_expect("/api/submissions/upload-image", {"image": {"filename": "x.png", "data": "!!notbase64!!"}}, 400)
        # empty image data
        post_expect("/api/submissions/upload-image", {"image": {"filename": "x.png", "data": ""}}, 400)
        # import folder that does not exist
        post_expect("/api/submissions/import-folder", {"path": str(tmp_path / "does-not-exist")}, 400)
    finally:
        server.shutdown()


def test_operational_error_surfaces_as_503(tmp_path: Path, monkeypatch):
    static_dir, image_store, store = _fixtures(tmp_path)

    def boom():
        raise sqlite3.OperationalError("database is locked")

    monkeypatch.setattr(store, "bootstrap", boom)
    server = build_server(host="127.0.0.1", port=0, store=store, image_store=image_store, static_dir=static_dir)
    _start(server)
    base = f"http://127.0.0.1:{server.server_port}"
    try:
        with pytest.raises(HTTPError) as exc:
            urlopen(base + "/api/bootstrap")
        assert exc.value.code == 503
    finally:
        server.shutdown()
