from __future__ import annotations

import base64
import hashlib
import html
import ipaddress
import json
import mimetypes
import os
import re
import shutil
import socket
import sqlite3
import threading
from html.parser import HTMLParser
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Any, Callable
from urllib.parse import parse_qsl, unquote, urljoin, urlparse
from urllib.request import HTTPRedirectHandler, Request, build_opener

from rights_filter.analysis.face_person_detection import HeuristicFacePersonDetector
from rights_filter.analysis.fingerprints import FingerprintService
from rights_filter.analysis.internal_analyzer import InternalAnalyzer
from rights_filter.analysis.preprocessing import build_external_derivative, build_face_crop_derivatives
from rights_filter.analysis.risk_scoring import RiskScorer
from rights_filter.analysis.search_query_generation import SearchQueryGenerator
from rights_filter.analysis.search_result_promoter import SearchResultPromoter
from rights_filter.domain.records import (
    Evidence,
    EvidenceSource,
    InMemoryRightsFilterRepository,
    KnowledgeBaseEntry,
    KnowledgeEntryType,
    KnowledgeProvenance,
)
from rights_filter.integrations.cloud_vision_web_detection import (
    CloudVisionWebDetectionAdapter,
    FakeWebDetectionClient,
)
from rights_filter.integrations.env_clients import ProviderRuntime, build_provider_runtime
from rights_filter.integrations.external_policy import ExternalApiPolicy
from rights_filter.jobs.batch_analyzer import BatchAnalyzer, SubmissionImage
from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES


EVIDENCE_OPERATOR_STATUSES = {
    "used_for_judgment": "판단에 사용",
    "irrelevant": "무관",
    "false_positive": "오탐",
    "pending": "보류",
}
NON_CONTRIBUTING_OPERATOR_STATUSES = {"irrelevant", "false_positive"}
SUBMISSION_FINAL_STATUSES = {"approved", "rejected", "corrected"}
QUEUE_ID_PLACEHOLDER = ""
STORE_TABLES = {
    "submissions",
    "evidence",
    "providers",
    "knowledge_entries",
    "collection_candidates",
    "corrections",
    "audit_events",
    "submission_queues",
}
SCHEMA_VERSION = 2
RISK_BANDS = ("low", "medium", "high", "failed", "pending")
DECISION_STATUSES = ("unreviewed", "held", "rejected", "approved", "corrected")
EVIDENCE_STATUSES = (
    "active",
    "auto",
    "manual",
    "queued",
    "rerun",
    "weak",
    "used_for_judgment",
    "irrelevant",
    "false_positive",
    "pending",
)
KNOWLEDGE_STATUSES = ("confirmed", "watchlist", "excluded")
COLLECTION_STATUSES = ("candidate", "promoted")
DEFAULT_COVERAGE_GOOD_THRESHOLD = 70
DEFAULT_COVERAGE_WARN_THRESHOLD = 40
DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD = 70
DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD = 40
MIN_COVERAGE_THRESHOLD = 0
MAX_COVERAGE_THRESHOLD = 100
DEFAULT_FACE_CROP_RETENTION_DAYS = 90
CONSTRAINED_TABLE_SCHEMAS = {
    "submissions": """
        create table {table} (
            id text primary key,
            title text not null default '',
            risk_score integer not null default 0 check (risk_score >= 0 and risk_score <= 100),
            risk_band text not null default 'low' check (risk_band in ('low', 'medium', 'high', 'failed', 'pending')),
            decision_status text not null default 'unreviewed' check (decision_status in ('unreviewed', 'held', 'rejected', 'approved', 'corrected')),
            submitted_epoch integer not null default 0 check (submitted_epoch >= 0),
            queue_id text not null default '',
            payload text not null check (json_valid(payload))
        )
    """,
    "evidence": """
        create table {table} (
            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' check (status in ('active', 'auto', 'manual', 'queued', 'rerun', 'weak', 'used_for_judgment', 'irrelevant', 'false_positive', 'pending')),
            contributed integer not null default 1 check (contributed in (0, 1)),
            payload text not null check (json_valid(payload)),
            foreign key (submission_id) references submissions(id) on delete cascade
        )
    """,
    "providers": """
        create table {table} (
            id text primary key,
            name text not null default '',
            enabled integer not null default 0 check (enabled in (0, 1)),
            usage integer not null default 0 check (usage >= 0),
            quota integer not null default 0 check (quota >= 0),
            payload text not null check (json_valid(payload))
        )
    """,
    "knowledge_entries": """
        create table {table} (
            id text primary key,
            name text not null default '',
            entry_type text not null default 'other',
            entry_status text not null default 'confirmed' check (entry_status in ('confirmed', 'watchlist', 'excluded')),
            active integer not null default 1 check (active in (0, 1)),
            source_submission_id text not null default '',
            payload text not null check (json_valid(payload))
        )
    """,
    "collection_candidates": """
        create table {table} (
            id text primary key,
            provider text not null default '',
            query text not null default '',
            status text not null default 'candidate' check (status in ('candidate', 'promoted')),
            collected_epoch integer not null default 0 check (collected_epoch >= 0),
            payload text not null check (json_valid(payload))
        )
    """,
    "corrections": """
        create table {table} (
            id text primary key,
            decision_id text not null default '',
            status text not null default '',
            payload text not null check (json_valid(payload))
        )
    """,
    "audit_events": """
        create table {table} (
            id integer primary key autoincrement,
            timestamp text not null default '',
            actor text not null default '',
            event text not null default '',
            object_ref text not null default '',
            payload text not null check (json_valid(payload))
        )
    """,
}
LEGACY_REBUILD_ORDER = (
    "submissions",
    "providers",
    "knowledge_entries",
    "collection_candidates",
    "corrections",
    "audit_events",
    "evidence",
)
PAYLOAD_REQUIRED_FIELDS = {
    "submissions": {
        "id",
        "title",
        "asset",
        "riskScore",
        "riskBand",
        "decisionStatus",
        "providerState",
        "fileFacts",
        "evidence",
    },
    "evidence": {
        "id",
        "source",
        "title",
        "confidence",
        "status",
        "submission_id",
    },
    "providers": {
        "id",
        "name",
        "enabled",
        "usage",
        "quota",
        "lastSuccess",
        "lastFailure",
    },
    "knowledge_entries": {
        "id",
        "name",
        "type",
        "provenance",
        "active",
        "entryStatus",
        "sampleFingerprints",
    },
    "collection_candidates": {
        "id",
        "provider",
        "query",
        "title",
        "status",
        "sourceUrl",
        "collectedEpoch",
        "sampleFingerprints",
    },
    "corrections": {
        "id",
    },
}
TYPED_COLUMNS = {
    "submissions": [
        ("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 ''"),
    ],
    "evidence": [
        ("source", "text not null default ''"),
        ("confidence", "real not null default 0"),
        ("status", "text not null default 'active'"),
        ("contributed", "integer not null default 1"),
    ],
    "providers": [
        ("name", "text not null default ''"),
        ("enabled", "integer not null default 0"),
        ("usage", "integer not null default 0"),
        ("quota", "integer not null default 0"),
    ],
    "knowledge_entries": [
        ("name", "text not null default ''"),
        ("entry_type", "text not null default 'other'"),
        ("entry_status", "text not null default 'confirmed'"),
        ("active", "integer not null default 1"),
        ("source_submission_id", "text not null default ''"),
    ],
    "collection_candidates": [
        ("provider", "text not null default ''"),
        ("query", "text not null default ''"),
        ("status", "text not null default 'candidate'"),
        ("collected_epoch", "integer not null default 0"),
    ],
    "corrections": [
        ("decision_id", "text not null default ''"),
        ("status", "text not null default ''"),
    ],
    "audit_events": [
        ("timestamp", "text not null default ''"),
        ("actor", "text not null default ''"),
        ("event", "text not null default ''"),
        ("object_ref", "text not null default ''"),
    ],
}


def _ensure_typed_columns(conn: sqlite3.Connection) -> None:
    for table, columns in TYPED_COLUMNS.items():
        existing = {
            str(row["name"])
            for row in conn.execute(f"pragma table_info({table})").fetchall()
        }
        for name, definition in columns:
            if name not in existing:
                conn.execute(f"alter table {table} add column {name} {definition}")


def _ensure_schema_version(conn: sqlite3.Connection) -> None:
    current = int(conn.execute("pragma user_version").fetchone()[0])
    if current < SCHEMA_VERSION:
        conn.execute(f"pragma user_version = {SCHEMA_VERSION}")


def _ensure_queue_schema(conn: sqlite3.Connection) -> None:
    conn.execute(
        """
        create table if not exists submission_queues (
            id text primary key,
            folder_path text not null unique,
            label text not null default '',
            is_active integer not null default 0 check (is_active in (0, 1)),
            created_at text not null default '',
            created_epoch integer not null default 0 check (created_epoch >= 0),
            last_imported_epoch integer not null default 0 check (last_imported_epoch >= 0),
            last_imported_at text not null default ''
        )
        """
    )
    conn.execute(
        "create index if not exists idx_submission_queues_active on submission_queues(is_active, id)"
    )


def _drop_stale_rebuild_temp_tables(conn: sqlite3.Connection) -> None:
    rows = conn.execute(
        "select name from sqlite_master where type = 'table' and name like '\\_\\_copyrighter\\_%\\_new' escape '\\'"
    ).fetchall()
    for row in rows:
        conn.execute(f"drop table if exists {row['name']}")


def _ensure_constrained_schema(conn: sqlite3.Connection) -> None:
    pending = [
        table for table in LEGACY_REBUILD_ORDER if _table_needs_constraint_rebuild(conn, table)
    ]
    if not pending:
        return
    # Disable foreign-key enforcement for the duration of the rebuilds. The
    # rebuild drops each parent table before its children are recreated, and a
    # `drop table submissions` while foreign keys are ON would fire evidence's
    # `on delete cascade` and silently wipe every evidence row. Toggling FKs off
    # (the procedure SQLite documents for constraint-changing migrations) must
    # happen with no transaction pending, so commit first and restore on exit.
    conn.commit()
    # Clean up any half-built temp table left by a prior crashed/failed run so we
    # retry cleanly instead of tripping on an orphan.
    _drop_stale_rebuild_temp_tables(conn)
    conn.execute("pragma foreign_keys = off")
    try:
        for table in pending:
            _rebuild_constrained_table(conn, table)
        conn.commit()
    except Exception:
        # The copy step raises before the original table is dropped (e.g. legacy
        # rows violate the new CHECK), so the source data is intact; remove the
        # orphaned temp table so the next startup can retry.
        _drop_stale_rebuild_temp_tables(conn)
        conn.commit()
        raise
    finally:
        conn.execute("pragma foreign_keys = on")


def _table_needs_constraint_rebuild(conn: sqlite3.Connection, table: str) -> bool:
    row = conn.execute(
        "select sql from sqlite_master where type = 'table' and name = ?",
        (table,),
    ).fetchone()
    if row is None:
        return False
    sql = str(row["sql"] or "").lower()
    if "json_valid(payload)" not in sql:
        return True
    if table == "evidence" and "foreign key" not in sql:
        return True
    if table == "submissions" and "risk_band in" not in sql:
        return True
    return False


def _rebuild_constrained_table(conn: sqlite3.Connection, table: str) -> None:
    schema = CONSTRAINED_TABLE_SCHEMAS[table]
    temp_table = f"__copyrighter_{table}_new"
    conn.execute(f"drop table if exists {temp_table}")
    conn.execute(schema.format(table=temp_table))

    source_columns = _table_columns(conn, table)
    target_columns = _table_columns(conn, temp_table)
    copied_columns = [column for column in target_columns if column in source_columns]
    if copied_columns:
        column_sql = ", ".join(copied_columns)
        conn.execute(
            f"insert into {temp_table} ({column_sql}) select {column_sql} from {table}"
        )
    conn.execute(f"drop table {table}")
    conn.execute(f"alter table {temp_table} rename to {table}")


def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
    return [str(row["name"]) for row in conn.execute(f"pragma table_info({table})").fetchall()]


def _bounded_int_env(value: str | None, default: int, minimum: int, maximum: int) -> int:
    try:
        parsed = int(value) if value is not None else default
    except (TypeError, ValueError):
        return default
    if parsed < minimum:
        return minimum
    if parsed > maximum:
        return maximum
    return parsed


class CopyrighterStore:
    def __init__(
        self,
        db_path: Path | str,
        provider_runtime: ProviderRuntime | None = None,
        knowledge_image_dir: Path | str | None = None,
        knowledge_public_prefix: str = "/knowledge-media",
        collection_image_dir: Path | str | None = None,
        collection_public_prefix: str = "/collected-media",
        candidate_image_fetcher: Callable[[str], bytes] | None = None,
    ) -> None:
        self.db_path = Path(db_path)
        self.provider_runtime = provider_runtime or build_provider_runtime(os.environ)
        self.knowledge_image_dir = (
            Path(knowledge_image_dir)
            if knowledge_image_dir is not None
            else self.db_path.parent / "knowledge" / "images"
        )
        self.knowledge_public_prefix = knowledge_public_prefix.rstrip("/")
        self.collection_image_dir = (
            Path(collection_image_dir)
            if collection_image_dir is not None
            else self.db_path.parent / "collections" / "images"
        )
        self.collection_public_prefix = collection_public_prefix.rstrip("/")
        self.face_crop_image_dir = self.db_path.parent / "face-crops"
        self.face_crop_public_prefix = "/face-crop-media"
        # Biometric face crops are personal data: purge them after this many days.
        # 0 = keep indefinitely. Configurable via env, clamped to a sane range.
        self.face_crop_retention_days = _bounded_int_env(
            os.environ.get("COPYRIGHTER_FACE_CROP_RETENTION_DAYS"),
            default=DEFAULT_FACE_CROP_RETENTION_DAYS,
            minimum=0,
            maximum=3650,
        )
        self.coverage_thresholds = {
            "coverageGoodRate": _bounded_int_env(
                os.environ.get("COPYRIGHTER_COVERAGE_GOOD_THRESHOLD"),
                default=DEFAULT_COVERAGE_GOOD_THRESHOLD,
                minimum=MIN_COVERAGE_THRESHOLD,
                maximum=MAX_COVERAGE_THRESHOLD,
            ),
            "coverageWarnRate": _bounded_int_env(
                os.environ.get("COPYRIGHTER_COVERAGE_WARN_THRESHOLD"),
                default=DEFAULT_COVERAGE_WARN_THRESHOLD,
                minimum=MIN_COVERAGE_THRESHOLD,
                maximum=MAX_COVERAGE_THRESHOLD,
            ),
            "queryGoodRate": _bounded_int_env(
                os.environ.get("COPYRIGHTER_QUERY_COVERAGE_GOOD_THRESHOLD"),
                default=DEFAULT_QUERY_COVERAGE_GOOD_THRESHOLD,
                minimum=MIN_COVERAGE_THRESHOLD,
                maximum=MAX_COVERAGE_THRESHOLD,
            ),
            "queryWarnRate": _bounded_int_env(
                os.environ.get("COPYRIGHTER_QUERY_COVERAGE_WARN_THRESHOLD"),
                default=DEFAULT_QUERY_COVERAGE_WARN_THRESHOLD,
                minimum=MIN_COVERAGE_THRESHOLD,
                maximum=MAX_COVERAGE_THRESHOLD,
            ),
        }
        self._custom_candidate_image_fetcher = candidate_image_fetcher
        self.candidate_image_fetcher = candidate_image_fetcher or _fetch_url_bytes
        self.page_fetcher = candidate_image_fetcher or _fetch_page_url_bytes
        self.stylesheet_fetcher = candidate_image_fetcher or _fetch_stylesheet_url_bytes
        # The server is a ThreadingHTTPServer; serialize read-modify-write store
        # operations (provider usage counters, candidate promotion, seeding)
        # across request threads to prevent lost updates / duplicate side effects.
        # Reentrant so a guarded method may call another guarded helper.
        self._write_lock = threading.RLock()

    def initialize(self) -> None:
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        with self._connect() as conn:
            # WAL lets readers proceed during a write (operators browsing while a
            # rerun writes) instead of blocking on the rollback-journal lock.
            # Persistent per DB file, so set once at init.
            conn.execute("pragma journal_mode = wal")
            conn.executescript(
                """
                create table if not exists submissions (
                    id text primary key,
                    title text not null default '',
                    risk_score integer not null default 0 check (risk_score >= 0 and risk_score <= 100),
                    risk_band text not null default 'low' check (risk_band in ('low', 'medium', 'high', 'failed', 'pending')),
                    decision_status text not null default 'unreviewed' check (decision_status in ('unreviewed', 'held', 'rejected', 'approved', 'corrected')),
                    submitted_epoch integer not null default 0 check (submitted_epoch >= 0),
                    queue_id text not null default '',
                    payload text not null check (json_valid(payload))
                );
                create table if not exists 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' check (status in ('active', 'auto', 'manual', 'queued', 'rerun', 'weak', 'used_for_judgment', 'irrelevant', 'false_positive', 'pending')),
                    contributed integer not null default 1 check (contributed in (0, 1)),
                    payload text not null check (json_valid(payload)),
                    foreign key (submission_id) references submissions(id) on delete cascade
                );
                create table if not exists providers (
                    id text primary key,
                    name text not null default '',
                    enabled integer not null default 0 check (enabled in (0, 1)),
                    usage integer not null default 0 check (usage >= 0),
                    quota integer not null default 0 check (quota >= 0),
                    payload text not null check (json_valid(payload))
                );
                create table if not exists knowledge_entries (
                    id text primary key,
                    name text not null default '',
                    entry_type text not null default 'other',
                    entry_status text not null default 'confirmed' check (entry_status in ('confirmed', 'watchlist', 'excluded')),
                    active integer not null default 1 check (active in (0, 1)),
                    source_submission_id text not null default '',
                    payload text not null check (json_valid(payload))
                );
                create table if not exists collection_candidates (
                    id text primary key,
                    provider text not null default '',
                    query text not null default '',
                    status text not null default 'candidate' check (status in ('candidate', 'promoted')),
                    collected_epoch integer not null default 0 check (collected_epoch >= 0),
                    payload text not null check (json_valid(payload))
                );
                create table if not exists corrections (
                    id text primary key,
                    decision_id text not null default '',
                    status text not null default '',
                    payload text not null check (json_valid(payload))
                );
                create table if not exists audit_events (
                    id integer primary key autoincrement,
                    timestamp text not null default '',
                    actor text not null default '',
                    event text not null default '',
                    object_ref text not null default '',
                    payload text not null check (json_valid(payload))
                );
                """
            )
            _ensure_typed_columns(conn)
            _ensure_constrained_schema(conn)
            _ensure_queue_schema(conn)
            _ensure_schema_version(conn)
            conn.executescript(
                """
                create index if not exists idx_evidence_submission on evidence(submission_id);
                create index if not exists idx_evidence_source on evidence(source);
                create index if not exists idx_submissions_decision on submissions(decision_status);
                create index if not exists idx_submissions_risk on submissions(risk_band, risk_score);
                create index if not exists idx_submissions_queue on submissions(queue_id);
                create index if not exists idx_knowledge_status on knowledge_entries(entry_status, active);
                create index if not exists idx_collection_query on collection_candidates(query, status);
                create index if not exists idx_audit_event on audit_events(event, timestamp);
                """
            )

    @staticmethod
    def _queue_id_for_path(folder_path: Path) -> str:
        return f"queue-{hashlib.sha1(str(folder_path).encode('utf-8')).hexdigest()[:16]}"

    @staticmethod
    def _normalize_queue_folder(folder_path: Path | str) -> Path:
        return Path(folder_path).resolve()

    def _set_active_queue(self, queue_id: str) -> None:
        with self._connect() as conn:
            conn.execute("update submission_queues set is_active = 0")
            conn.execute(
                "update submission_queues set is_active = 1 where id = ?",
                (queue_id,),
            )

    def _queue_row_by_id(self, queue_id: str) -> dict[str, Any] | None:
        with self._connect() as conn:
            row = conn.execute(
                """
                select id, folder_path, label, is_active, created_at, created_epoch,
                       last_imported_epoch, last_imported_at
                from submission_queues
                where id = ?
                """,
                (queue_id,),
            ).fetchone()
        if row is None:
            return None
        return {
            "id": row["id"],
            "folderPath": row["folder_path"],
            "label": row["label"],
            "isActive": bool(row["is_active"]),
            "createdAt": row["created_at"],
            "createdEpoch": row["created_epoch"],
            "lastImportedAt": row["last_imported_at"],
            "lastImportedEpoch": row["last_imported_epoch"],
        }

    def _migrate_legacy_submissions_to_queue(self, queue_id: str, conn: sqlite3.Connection | None = None) -> None:
        should_close = conn is None
        if conn is None:
            conn = self._connect()
        try:
            conn.execute(
                "update submissions set queue_id = ? where queue_id = ''",
                (queue_id,),
            )
            if should_close:
                conn.commit()
        finally:
            if should_close:
                conn.close()

    def bootstrap_active_queue(self) -> dict[str, Any] | None:
        with self._connect() as conn:
            row = conn.execute(
                """
                select id, folder_path, label, is_active, created_at, created_epoch,
                       last_imported_epoch, last_imported_at
                from submission_queues
                where is_active = 1
                limit 1
                """,
            ).fetchone()
        if row is None:
            return None
        return {
            "id": row["id"],
            "folderPath": row["folder_path"],
            "label": row["label"],
            "isActive": bool(row["is_active"]),
            "createdAt": row["created_at"],
            "createdEpoch": row["created_epoch"],
            "lastImportedAt": row["last_imported_at"],
            "lastImportedEpoch": row["last_imported_epoch"],
        }

    def ensure_queue(self, folder_path: Path | str, label: str | None = None) -> dict[str, Any]:
        folder = self._normalize_queue_folder(folder_path)
        queue_id = self._queue_id_for_path(folder)
        now = datetime.now()
        queue_label = str(label or "").strip() or folder.name

        with self._connect() as conn:
            existing = conn.execute(
                "select id from submission_queues where id = ? or folder_path = ?",
                (queue_id, str(folder)),
            ).fetchone()
            if existing is None:
                conn.execute(
                    """
                    insert into submission_queues (
                        id, folder_path, label, is_active, created_at, created_epoch, last_imported_epoch, last_imported_at
                    ) values (?, ?, ?, 1, ?, ?, 0, '')
                    """,
                    (queue_id, str(folder), queue_label, now.isoformat(" ", "seconds"), int(now.timestamp())),
                )
            else:
                conn.execute(
                    "update submission_queues set is_active = 1, folder_path = ?, label = ? where id = ?",
                    (str(folder), queue_label, queue_id),
                )
            conn.execute("update submission_queues set is_active = 0 where id != ?", (queue_id,))
            if self._has_queueless_submissions(conn=conn):
                self._migrate_legacy_submissions_to_queue(queue_id, conn=conn)
            queue_row = conn.execute(
                """
                select id, folder_path, label, is_active, created_at, created_epoch,
                       last_imported_epoch, last_imported_at
                from submission_queues
                where id = ?
                """,
                (queue_id,),
            ).fetchone()
            if queue_row is None:
                return self._queue_row_by_id(queue_id)
            return {
                "id": queue_row["id"],
                "folderPath": queue_row["folder_path"],
                "label": queue_row["label"],
                "isActive": bool(queue_row["is_active"]),
                "createdAt": queue_row["created_at"],
                "createdEpoch": queue_row["created_epoch"],
                "lastImportedAt": queue_row["last_imported_at"],
                "lastImportedEpoch": queue_row["last_imported_epoch"],
            }

    def active_submission_image_store(self, fallback_root: Path | str | None = None) -> LocalSubmissionImageStore:
        queue = self.bootstrap_active_queue()
        if queue is None:
            if fallback_root is None:
                raise ValueError("no active submission queue is configured")
            return LocalSubmissionImageStore(fallback_root)
        return LocalSubmissionImageStore(queue["folderPath"])

    def _has_queueless_submissions(self, conn: sqlite3.Connection | None = None) -> bool:
        should_close = conn is None
        if conn is None:
            conn = self._connect()
        try:
            count_row = conn.execute(
                "select count(*) as count from submissions where queue_id = ''"
            ).fetchone()
            return bool(int(count_row["count"]) > 0) if count_row else False
        finally:
            if should_close:
                conn.close()

    def seed_from_image_store(self, image_store: LocalSubmissionImageStore) -> int:
        # Serialize seeding so two concurrent reload/import/upload requests can't
        # both classify the same record as new and double-run analysis, external
        # searches, and audit writes for it.
        with self._write_lock:
            return self._seed_from_image_store_locked(image_store)

    def _seed_from_image_store_locked(self, image_store: LocalSubmissionImageStore) -> int:
        self.purge_expired_face_crops()
        queue = self.ensure_queue(image_store.root)
        queue_id = str(queue["id"])
        now = datetime.now().isoformat(" ", "seconds")
        self._sync_provider_payloads()
        self._prune_missing_submission_files(image_store, queue_id=queue_id)
        self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id)
        self._refresh_existing_submission_file_facts(image_store, queue_id=queue_id)
        self._refresh_existing_local_face_evidence(image_store, queue_id=queue_id)
        for submission in self._all("submissions", queue_id=queue_id):
            if "faceCrops" not in submission:
                self._sync_face_crops(str(submission["id"]), image_store)
        self._rescore_all_submissions(queue_id=queue_id)
        self._ensure_llm_summaries_for_existing_source_evidence(queue_id=queue_id)
        self._sync_submission_provider_state(queue_id=queue_id)

        existing_submission_ids = {
            submission["id"] for submission in self._all("submissions", queue_id=queue_id)
        }
        records = [
            record
            for record in image_store.submission_records()
            if record["id"] not in existing_submission_ids
        ]
        if not records:
            return 0

        repository = self._knowledge_repository()
        analyzer = InternalAnalyzer(
            repository,
            FingerprintService(),
            HeuristicFacePersonDetector(),
        )
        batch = BatchAnalyzer(
            repository=repository,
            internal_analyzer=analyzer,
            external_adapter=self.provider_runtime.google_adapter or CloudVisionWebDetectionAdapter(FakeWebDetectionClient()),
            external_policy=self.provider_runtime.external_policy if self.provider_runtime.google_adapter else ExternalApiPolicy(disabled=True),
            scorer=RiskScorer(),
        )
        submissions = [
            SubmissionImage(record["id"], image_store.image_payload(record["id"]))
            for record in records
        ]
        if submissions:
            batch.run(submissions)

        for record in records:
            run = repository.analysis_runs_for_submission(record["id"])[-1]
            score = run.score
            submission = _submission_payload(
                record,
                score.score if score else 0,
                score.band if score else "low",
                score.reasons if score else [],
                self.provider_runtime.provider_payloads,
            )
            submission["queueId"] = queue_id
            self._put("submissions", submission["id"], submission)
            for evidence in run.evidence:
                self._put("evidence", _evidence_id(record["id"], evidence), _evidence_payload(record["id"], evidence))
            query_source_evidence = [
                *run.evidence,
                *_submission_search_hint_evidence(record),
            ]
            self._sync_similar_reference_images(record["id"], run.evidence)
            self._sync_search_result_image_similarity(record["id"], run.evidence, image_store)
            self._sync_face_crops(record["id"], image_store)
            self._auto_naver_search(record["id"], query_source_evidence, image_store)
            self._auto_google_custom_search(record["id"], query_source_evidence, image_store)
            self._ensure_llm_summary(record["id"])
            self._increment_knowledge_contribution_counts(record["id"], run.evidence)
            self._rescore_submission(record["id"])
            self.add_audit_event(
                actor="system",
                event="Analysis run created",
                object_id=record["id"],
                change="seeded from local image store",
            )
        self._sync_submission_provider_state(queue_id=queue_id)
        self._normalize_saved_google_weak_labels_and_rescore(queue_id=queue_id)
        with self._connect() as conn:
            conn.execute(
                """
                update submission_queues
                set last_imported_epoch = ?, last_imported_at = ?
                where id = ?
                """,
                (int(datetime.now().timestamp()), now, queue_id),
            )
        return len(records)

    def _prune_missing_submission_files(
        self,
        image_store: LocalSubmissionImageStore,
        *,
        queue_id: str,
    ) -> int:
        public_prefix = image_store.public_prefix.rstrip("/") + "/"
        missing_ids: list[str] = []
        for submission in self._all("submissions", queue_id=queue_id):
            asset = str(submission.get("asset", ""))
            if not asset.startswith(public_prefix):
                continue
            relative_path = asset.removeprefix(public_prefix)
            if not image_store.media_path(relative_path).exists():
                missing_ids.append(str(submission["id"]))

        if not missing_ids:
            return 0

        with self._connect() as conn:
            conn.executemany(
                "delete from submissions where id = ? and queue_id = ?",
                [(submission_id, queue_id) for submission_id in missing_ids],
            )

        for submission_id in missing_ids:
            crop_dir = self.face_crop_image_dir / submission_id
            if crop_dir.exists():
                shutil.rmtree(crop_dir, ignore_errors=True)

        for submission_id in missing_ids:
            self.add_audit_event(
                actor="system",
                event="Submission pruned",
                object_id=submission_id,
                change="source image file missing",
            )
        return len(missing_ids)

    def bootstrap(self) -> dict[str, list[dict[str, Any]]]:
        queue = self.bootstrap_active_queue()
        queue_id = str(queue["id"]) if queue else None
        submissions = self._all("submissions", queue_id=queue_id)
        evidence = self._evidence_by_submission(queue_id=queue_id)
        for submission in submissions:
            submission["evidence"] = evidence.get(submission["id"], [])
        return {
            "submissions": submissions,
            "submissionQueue": queue,
            "providers": self._all("providers"),
            "knowledgeEntries": self._all("knowledge_entries"),
            "collectionCandidates": self._all("collection_candidates"),
            "corrections": self._all("corrections"),
            "auditEvents": self.audit_events(),
            "coverageThresholds": dict(self.coverage_thresholds),
            "searchCoverage": self._search_coverage(submissions, evidence),
        }

    def _search_coverage(self, submissions: list[dict[str, Any]], evidence_by_submission: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
        providers = {
            "naver": "네이버 텍스트 검색",
            "google": "구글 비전",
            "google_search": "구글 맞춤 검색",
            "llm": "Ollama 요약",
        }
        provider_stats: dict[str, dict[str, int]] = {
            provider: {
                "queryEntries": 0,
                "querySubmissions": 0,
                "evidenceSubmissions": 0,
                "resultCount": 0,
                "manualQueries": 0,
                "autoQueries": 0,
                "failedQueries": 0,
                "skippedQueries": 0,
            }
            for provider in providers
        }
        total_submissions = len(submissions)
        query_entries_total = 0
        query_submissions = 0
        manual_query_submissions = 0
        auto_query_submissions = 0
        failed_query_submissions = 0
        skipped_query_submissions = 0
        coverage_submissions = 0
        manual_query_count = 0
        auto_query_count = 0
        failed_query_count = 0
        skipped_query_count = 0

        def normalize_provider(provider_id: str) -> str:
            if provider_id in {"naver", "naver_blog", "naver_web"}:
                return "naver"
            if provider_id in {"google", "google_search", "google_custom"}:
                return provider_id.replace("_custom", "")
            if provider_id == "llm":
                return "llm"
            return ""

        for submission in submissions:
            submission_id = str(submission["id"])
            history = submission.get("queryHistory", []) or []
            history_providers = set()
            evidence = evidence_by_submission.get(submission_id, [])
            has_search_evidence = False

            # Attribute via _evidence_matches_provider (domain-aware) so Google
            # Custom Search (source "google", domain "google_custom_search") is
            # counted under google_search, not the Vision "google" bucket.
            for search_provider in ("naver", "google", "google_search"):
                if any(_evidence_matches_provider(item, search_provider) for item in evidence):
                    has_search_evidence = True
                    provider_stats[search_provider]["evidenceSubmissions"] += 1
            if has_search_evidence:
                coverage_submissions += 1

            llm_evidence = [item for item in evidence if _evidence_matches_provider(item, "llm")]
            if llm_evidence:
                llm_results = sum(1 for item in llm_evidence if _provider_item_has_result(item))
                llm_failures = sum(1 for item in llm_evidence if _provider_item_failed(item))
                provider_stats["llm"]["queryEntries"] += 1
                provider_stats["llm"]["querySubmissions"] += 1
                provider_stats["llm"]["resultCount"] += llm_results
                provider_stats["llm"]["autoQueries"] += llm_results
                provider_stats["llm"]["failedQueries"] += llm_failures
                if llm_results:
                    provider_stats["llm"]["evidenceSubmissions"] += 1

            has_manual_history = False
            has_auto_history = False
            has_failed_history = False
            has_skipped_history = False

            for item in history:
                provider = normalize_provider(str(item.get("provider", "")))
                if not provider:
                    continue
                history_providers.add(provider)
                provider_stats[provider]["queryEntries"] += 1
                provider_stats[provider]["resultCount"] += int(item.get("count", 0) or 0)
                status = str(item.get("status", ""))
                if status == "manual":
                    provider_stats[provider]["manualQueries"] += 1
                    manual_query_count += 1
                    has_manual_history = True
                elif status == "failed":
                    provider_stats[provider]["failedQueries"] += 1
                    failed_query_count += 1
                    has_failed_history = True
                elif status == "skipped":
                    provider_stats[provider]["skippedQueries"] += 1
                    skipped_query_count += 1
                    has_skipped_history = True
                else:
                    provider_stats[provider]["autoQueries"] += 1
                    auto_query_count += 1
                    has_auto_history = True

            for provider in history_providers:
                provider_stats[provider]["querySubmissions"] += 1

            if history:
                query_entries_total += len(history)
                query_submissions += 1
                if has_manual_history:
                    manual_query_submissions += 1
                if has_auto_history:
                    auto_query_submissions += 1
                if has_failed_history:
                    failed_query_submissions += 1
                if has_skipped_history:
                    skipped_query_submissions += 1

        coverage_rate = (coverage_submissions / total_submissions * 100.0) if total_submissions else 0.0
        query_rate = (query_submissions / total_submissions * 100.0) if total_submissions else 0.0

        return {
            "submissions": {
                "total": total_submissions,
                "coverageSubmissions": coverage_submissions,
                "coverageRate": round(coverage_rate, 1),
                "queriedSubmissions": query_submissions,
                "queriedRate": round(query_rate, 1),
                "manualQuerySubmissions": manual_query_submissions,
                "autoQuerySubmissions": auto_query_submissions,
                "failedQuerySubmissions": failed_query_submissions,
                "skippedQuerySubmissions": skipped_query_submissions,
            },
            "queries": {
                "entries": query_entries_total,
                "manual": manual_query_count,
                "auto": auto_query_count,
                "failed": failed_query_count,
                "skipped": skipped_query_count,
            },
            "providers": [
                {
                    "id": provider_id,
                    "name": provider_name,
                    "queryEntries": provider_stats[provider_id]["queryEntries"],
                    "querySubmissions": provider_stats[provider_id]["querySubmissions"],
                    "evidenceSubmissions": provider_stats[provider_id]["evidenceSubmissions"],
                    "resultCount": provider_stats[provider_id]["resultCount"],
                    "manualQueries": provider_stats[provider_id]["manualQueries"],
                    "autoQueries": provider_stats[provider_id]["autoQueries"],
                    "failedQueries": provider_stats[provider_id]["failedQueries"],
                    "skippedQueries": provider_stats[provider_id]["skippedQueries"],
                    "queryCoverage": (
                        provider_stats[provider_id]["querySubmissions"] / total_submissions * 100.0
                        if total_submissions
                        else 0.0
                    ),
                    "evidenceCoverage": (
                        provider_stats[provider_id]["evidenceSubmissions"] / total_submissions * 100.0
                        if total_submissions
                        else 0.0
                    ),
                }
                for provider_id, provider_name in providers.items()
            ],
        }

    def review(self, submission_id: str) -> dict[str, Any]:
        submission = self._get("submissions", submission_id)
        submission["evidence"] = self._evidence_by_submission().get(submission_id, [])
        return submission

    def record_decision(
        self,
        submission_id: str,
        decision: str,
        memo: str = "",
        image_store: LocalSubmissionImageStore | None = None,
    ) -> dict[str, Any]:
        if decision not in {"approved", "held", "rejected"}:
            raise ValueError("unsupported decision")
        if decision == "rejected" and not memo.strip():
            raise ValueError("reject decision requires memo")

        with self._transaction() as conn:
            submission = self._get("submissions", submission_id, conn=conn)
            submission["decisionStatus"] = decision
            submission["applicantStatus"] = "승인 대기" if decision == "approved" else "반려 예정" if decision == "rejected" else "검토 중"
            self._put("submissions", submission_id, submission, conn=conn)

            if decision in {"held", "rejected"}:
                self._create_or_update_watchlist_entry(submission_id, decision, memo, image_store, conn=conn)

            self.add_audit_event(
                actor="rights.ops",
                event="Operator decision created",
                object_id=submission_id,
                change=f"{decision} · {memo or 'memo optional'}",
                conn=conn,
            )
        return self.review(submission_id)

    def mark_evidence_status(
        self,
        submission_id: str,
        evidence_id: str,
        status: str,
        note: str = "",
    ) -> dict[str, Any]:
        if status not in EVIDENCE_OPERATOR_STATUSES:
            raise ValueError("unsupported evidence status")
        payload = self._get("evidence", evidence_id)
        if str(payload.get("submission_id", "")) != submission_id:
            raise ValueError("evidence does not belong to submission")

        payload["operatorStatus"] = status
        payload["operatorStatusLabel"] = EVIDENCE_OPERATOR_STATUSES[status]
        payload["operatorStatusNote"] = note.strip()
        payload["operatorStatusAt"] = _now_label()
        payload["operatorStatusActor"] = "rights.ops"
        if status in NON_CONTRIBUTING_OPERATOR_STATUSES:
            payload["contributed"] = False
        else:
            payload["contributed"] = _default_evidence_contribution(payload)
        self._put("evidence", evidence_id, payload)
        self._rescore_submission(submission_id)
        self.add_audit_event(
            actor="rights.ops",
            event="Evidence status changed",
            object_id=evidence_id,
            change=f"{EVIDENCE_OPERATOR_STATUSES[status]} · {note or 'note optional'}",
        )
        return self.review(submission_id)

    def promote_watchlist_entry(self, entry_id: str) -> dict[str, Any]:
        entry = self._get("knowledge_entries", entry_id)
        if entry.get("entryStatus") != "watchlist":
            raise ValueError("knowledge entry is not a watchlist candidate")
        entry["entryStatus"] = "confirmed"
        entry["active"] = True
        entry["excludedReason"] = ""
        entry["confirmedAt"] = _now_label()
        entry["confirmedBy"] = "rights.ops"
        self._put("knowledge_entries", entry_id, entry)
        self.add_audit_event(
            "rights.ops",
            "Watchlist candidate promoted",
            str(entry.get("name", entry_id)),
            "promoted into confirmed reference DB",
        )
        return self.bootstrap()

    def exclude_watchlist_entry(self, entry_id: str, reason: str = "") -> dict[str, Any]:
        entry = self._get("knowledge_entries", entry_id)
        if entry.get("entryStatus") not in {"watchlist", "confirmed"}:
            raise ValueError("knowledge entry cannot be excluded")
        entry["entryStatus"] = "excluded"
        entry["active"] = False
        entry["excludedReason"] = reason.strip() or "오탐 또는 무관 후보"
        entry["excludedAt"] = _now_label()
        entry["excludedBy"] = "rights.ops"
        self._put("knowledge_entries", entry_id, entry)
        self.add_audit_event(
            "rights.ops",
            "Watchlist candidate excluded",
            str(entry.get("name", entry_id)),
            entry["excludedReason"],
        )
        return self.bootstrap()

    def update_knowledge_entry(self, entry_id: str, payload: dict[str, Any]) -> dict[str, Any]:
        entry = self._get("knowledge_entries", entry_id)
        updates: dict[str, Any] = {}
        if "aliases" in payload:
            updates["aliases"] = _text_list(payload.get("aliases"))
        if "keywords" in payload:
            updates["keywords"] = _text_list(payload.get("keywords"))
        if "memo" in payload:
            updates["memo"] = str(payload.get("memo", "")).strip()
        if not updates:
            raise ValueError("aliases, keywords, memo 중 수정할 값이 필요합니다")

        before = {key: entry.get(key) for key in updates}
        entry.update(updates)
        self._put("knowledge_entries", entry_id, entry)
        self.add_audit_event(
            "rights.ops",
            "Knowledge entry updated",
            str(entry.get("name", entry_id)),
            f"{json.dumps(before, ensure_ascii=False)} -> {json.dumps(updates, ensure_ascii=False)}",
        )
        return self.bootstrap()

    def deactivate_knowledge_entry(self, entry_id: str, reason: str = "") -> dict[str, Any]:
        entry = self._get("knowledge_entries", entry_id)
        if entry.get("entryStatus", "confirmed") != "confirmed":
            raise ValueError("확정 DB 항목만 비활성화할 수 있습니다")
        if not entry.get("active", False):
            raise ValueError("이미 비활성 상태입니다")
        entry["active"] = False
        entry["deactivatedAt"] = _now_label()
        entry["deactivatedBy"] = "rights.ops"
        entry["deactivatedReason"] = reason.strip()
        self._put("knowledge_entries", entry_id, entry)
        self.add_audit_event(
            "rights.ops",
            "Knowledge entry deactivated",
            str(entry.get("name", entry_id)),
            reason.strip() or "운영자 비활성화",
        )
        return self.bootstrap()

    def reactivate_knowledge_entry(self, entry_id: str, reason: str) -> dict[str, Any]:
        if not reason.strip():
            raise ValueError("재활성에는 사유 메모가 필요합니다")
        entry = self._get("knowledge_entries", entry_id)
        if entry.get("entryStatus", "confirmed") != "confirmed":
            raise ValueError("확정 DB 항목만 재활성화할 수 있습니다")
        if entry.get("active", False):
            raise ValueError("이미 활성 상태입니다")
        entry["active"] = True
        entry["reactivatedAt"] = _now_label()
        entry["reactivatedBy"] = "rights.ops"
        entry["reactivatedReason"] = reason.strip()
        self._put("knowledge_entries", entry_id, entry)
        self.add_audit_event(
            "rights.ops",
            "Knowledge entry reactivated",
            str(entry.get("name", entry_id)),
            reason.strip(),
        )
        return self.bootstrap()

    def _create_or_update_watchlist_entry(
        self,
        submission_id: str,
        decision: str,
        memo: str,
        image_store: LocalSubmissionImageStore | None,
        *,
        conn: sqlite3.Connection | None = None,
    ) -> None:
        submission = self._get("submissions", submission_id, conn=conn)
        evidence = self._evidence_by_submission().get(submission_id, [])
        selected_evidence = _watchlist_source_evidence(evidence)
        selected_evidence_ids = [str(item.get("id", "")) for item in selected_evidence if item.get("id")]
        sample_fingerprints = self._watchlist_fingerprints(submission_id, image_store)

        entry_id = _stable_id("kb-watchlist", submission_id)
        try:
            existing = self._get("knowledge_entries", entry_id, conn=conn)
        except KeyError:
            existing = {}

        keywords = _unique_texts(
            [
                *[str(item) for item in submission.get("reasons", [])[:3]],
                *[str(item.get("title", "")) for item in selected_evidence[:3]],
            ]
        )
        entry = {
            **existing,
            "id": entry_id,
            "name": submission.get("derivedPreview", {}).get("entryName") or f"{submission.get('title', submission_id)} / {submission_id}",
            "type": "rejected_image",
            "aliases": _unique_texts([submission_id, str(submission.get("title", ""))]),
            "keywords": keywords,
            "memo": memo.strip() or ("보류 판정으로 자동 생성" if decision == "held" else "반려 판정으로 자동 생성"),
            "provenance": "automatic",
            "active": True,
            "entryStatus": "watchlist",
            "originDecisionStatus": decision,
            "sourceDecision": f"DEC-{submission_id}",
            "sourceSubmissionId": submission_id,
            "sourceEvidenceIds": selected_evidence_ids,
            "sampleFingerprints": sample_fingerprints or _text_list(existing.get("sampleFingerprints")),
            "imageAsset": str(submission.get("asset", "")),
            "imageFacts": submission.get("fileFacts", {}),
            "contributionCount": int(existing.get("contributionCount", 0) or 0),
            "matchedSubmissionIds": _text_list(existing.get("matchedSubmissionIds")),
            "lastOriginDecisionAt": _now_label(),
        }
        self._put("knowledge_entries", entry_id, entry, conn=conn)

    def _watchlist_fingerprints(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore | None,
    ) -> list[str]:
        if image_store is None:
            return []
        try:
            fingerprints = FingerprintService().fingerprints_for(
                image_store.image_payload(submission_id).content
            )
        except Exception:
            return []
        return [fingerprints.perceptual]

    def rerun_enrichment(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore | None = None,
    ) -> dict[str, Any]:
        submission = self._get("submissions", submission_id)
        score_before = int(submission.get("riskScore", 0) or 0)
        evidence_before = {
            str(item.get("id", "")): item
            for item in self._evidence_by_submission().get(submission_id, [])
        }
        submission["lastAnalysis"] = _now_label()
        self._put("submissions", submission_id, submission)
        evidence = {
            "id": f"ev-{submission_id}-rerun-{_timestamp_id()}",
            "group": "internal",
            "source": "fingerprint",
            "title": "재분석 요청이 접수됨",
            "confidence": 0,
            "query": "",
            "domain": "internal",
            "url": "",
            "retrievedAt": _now_label(),
            "contributed": False,
            "sourceEvidenceIds": [],
            "status": "queued",
        }
        self._put("evidence", evidence["id"], {**evidence, "submission_id": submission_id})

        if image_store is not None:
            self._rerun_internal_analysis(submission_id, image_store)
            google_evidence = self._rerun_google_image_search(submission_id, image_store)
            query_source_evidence = [
                *google_evidence,
                *_submission_search_hint_evidence(submission),
            ]
            self._auto_naver_search(submission_id, query_source_evidence, image_store)
            self._auto_google_custom_search(submission_id, query_source_evidence, image_store)

        self._ensure_llm_summary(submission_id)

        self.add_audit_event("rights.ops", "Analysis run created", submission_id, "operator rerun")
        self._rescore_submission(submission_id)
        self._sync_submission_provider_state()

        evidence_after = {
            str(item.get("id", "")): item
            for item in self._evidence_for_submission(submission_id)
        }
        rerun_marker_prefix = f"ev-{submission_id}-rerun-"
        # LLM 요약은 재분석마다 삭제 후 재생성되어 id가 항상 바뀌므로(요약의
        # source_evidence_ids에 타임스탬프 마커 id가 섞임) diff에 포함하면
        # 변경이 없어도 매번 신규+제거로 잡힌다 — diff 대상에서 제외한다.
        added_ids = [
            evidence_id
            for evidence_id in evidence_after
            if evidence_id not in evidence_before
            and not evidence_id.startswith(rerun_marker_prefix)
            and str(evidence_after[evidence_id].get("source", "")) != "llm"
        ]
        removed_items = [
            evidence_before[evidence_id]
            for evidence_id in evidence_before
            if evidence_id not in evidence_after
            and str(evidence_before[evidence_id].get("source", "")) != "llm"
        ]
        refreshed = self._get("submissions", submission_id)
        refreshed["lastRerunDiff"] = {
            "at": _now_label(),
            "scoreBefore": score_before,
            "scoreAfter": int(refreshed.get("riskScore", 0) or 0),
            "addedEvidenceIds": added_ids,
            "removedEvidenceIds": [str(item.get("id", "")) for item in removed_items],
            "removedSummaries": [
                {"source": str(item.get("source", "")), "reason": str(item.get("title", ""))}
                for item in removed_items
            ],
        }
        self._put("submissions", submission_id, refreshed)
        return self.review(submission_id)

    def run_auto_search(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore | None = None,
    ) -> dict[str, Any]:
        submission = self._get("submissions", submission_id)
        submission["lastAnalysis"] = _now_label()
        self._put("submissions", submission_id, submission)

        existing_evidence = self._evidence_by_submission().get(submission_id, [])
        query_source_evidence = [
            _domain_evidence_from_ui(item)
            for item in existing_evidence
            if item.get("source") in {"google", "naver", "face", "fingerprint", "llm", "failure"}
        ]
        query_source_evidence.extend(_submission_search_hint_evidence(submission))

        self._auto_naver_search(submission_id, query_source_evidence, image_store)
        self._auto_google_custom_search(submission_id, query_source_evidence, image_store)

        self._ensure_llm_summary(submission_id)
        self._rescore_submission(submission_id)
        self._sync_submission_provider_state()
        self.add_audit_event(
            "rights.ops",
            "Provider called",
            f"auto-search / {submission_id}",
            "operator request for auto text search",
        )
        return self.review(submission_id)

    def manual_search(
        self,
        submission_id: str,
        provider: str,
        query: str,
        image_store: LocalSubmissionImageStore | None = None,
    ) -> dict[str, Any]:
        submission = self._get("submissions", submission_id)
        provider_payload = self._get("providers", provider)
        if not provider_payload["enabled"]:
            raise ValueError(f"{provider} provider disabled")

        domain_evidence, provider_call_count = self._manual_search_evidence(
            submission_id,
            provider,
            query,
            image_store,
        )
        for item in domain_evidence:
            evidence = _evidence_payload(submission_id, item)
            evidence["status"] = "manual"
            self._put("evidence", evidence["id"], evidence)

        submission.setdefault("queryHistory", []).insert(
            0,
            {
                "provider": provider,
                "query": query,
                "status": "manual",
                "timestamp": _now_label(),
                "count": len(domain_evidence),
            },
        )
        self._put("submissions", submission_id, submission)
        if any(
            item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
            for item in domain_evidence
        ):
            provider_payload["lastFailure"] = domain_evidence[0].reason if domain_evidence else f"{provider} search failed"
        else:
            provider_payload["lastSuccess"] = _now_label()
            provider_payload["lastFailure"] = "없음"
        self._apply_provider_usage_delta(provider, provider_call_count, provider_payload)
        self._ensure_llm_summary(submission_id)
        if image_store is not None:
            self._rescore_submission(submission_id)
        self._sync_submission_provider_state()
        self.add_audit_event("rights.ops", "Provider called", f"{provider} / {submission_id}", f"manual text query: {query}")
        return self.review(submission_id)

    def _ensure_llm_summaries_for_existing_source_evidence(self, queue_id: str | None = None) -> None:
        for submission in self._all("submissions", queue_id=queue_id):
            self._ensure_llm_summary(str(submission["id"]), only_if_missing=True)

    def _ensure_llm_summary(self, submission_id: str, *, only_if_missing: bool = False) -> bool:
        if self.provider_runtime.llm_assistant is None:
            return False

        llm_provider = self._get("providers", "llm")
        if not llm_provider.get("enabled"):
            return False

        evidence_payloads = self._evidence_by_submission().get(submission_id, [])
        if only_if_missing and any(
            _evidence_matches_provider(item, "llm") and _provider_item_has_result(item)
            for item in evidence_payloads
        ):
            return False

        source_evidence = [
            _domain_evidence_from_ui(item)
            for item in evidence_payloads
            if item.get("source") in {"fingerprint", "face", "google", "naver"}
        ]
        if not source_evidence:
            return False

        llm_evidence = self.provider_runtime.llm_assistant.summarize(
            submission_id,
            source_evidence,
        )
        self._delete_llm_summary_evidence(submission_id)
        self._put(
            "evidence",
            _evidence_id(submission_id, llm_evidence),
            _evidence_payload(submission_id, llm_evidence),
        )
        if llm_evidence.source == EvidenceSource.ENRICHMENT_FAILURE:
            llm_provider["lastFailure"] = llm_evidence.reason
        else:
            llm_provider["lastSuccess"] = _now_label()
            llm_provider["lastFailure"] = "없음"
        self._apply_provider_usage_delta("llm", 1, llm_provider)
        return True

    def _delete_llm_summary_evidence(self, submission_id: str) -> None:
        with self._connect() as conn:
            conn.execute(
                """
                delete from evidence
                where submission_id = ?
                  and (
                    source = 'llm'
                    or (
                      source = 'failure'
                      and json_extract(payload, '$.title') like 'LLM assistance failed%'
                    )
                  )
                """,
                (submission_id,),
            )

    def _manual_search_evidence(
        self,
        submission_id: str,
        provider: str,
        query: str,
        image_store: LocalSubmissionImageStore | None,
    ) -> tuple[list[Evidence], int]:
        if provider == "naver":
            if self.provider_runtime.naver_adapter is None:
                raise ValueError(f"{provider} provider not connected")
            promoter = SearchResultPromoter()
            can_compare_search_images = self._can_compare_search_result_images(
                submission_id,
                image_store,
            )
            domain_evidence = self.provider_runtime.naver_adapter.search(
                submission_id,
                query,
                self.provider_runtime.search_policy,
            )
            domain_evidence = promoter.promote(domain_evidence)
            call_count = 1 if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in domain_evidence) else 0
            similarity_evidence: list[Evidence] = []
            if can_compare_search_images and image_store is not None:
                similarity_evidence = self._sync_search_result_image_similarity(
                    submission_id,
                    domain_evidence,
                    image_store,
                    status="manual",
                )
            page_similarity_evidence: list[Evidence] = []
            if can_compare_search_images and image_store is not None and not similarity_evidence:
                page_evidence = self.provider_runtime.naver_adapter.search_pages(
                    submission_id,
                    query,
                    self.provider_runtime.search_policy,
                )
                if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in page_evidence):
                    call_count += 1
                page_evidence = promoter.promote(page_evidence)
                domain_evidence.extend(page_evidence)
                page_similarity_evidence = self._sync_search_result_image_similarity(
                    submission_id,
                    page_evidence,
                    image_store,
                    status="manual",
                )
            if (
                can_compare_search_images
                and image_store is not None
                and not similarity_evidence
                and not page_similarity_evidence
            ):
                web_evidence = self.provider_runtime.naver_adapter.search_web_pages(
                    submission_id,
                    query,
                    self.provider_runtime.search_policy,
                )
                if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in web_evidence):
                    call_count += 1
                web_evidence = promoter.promote(web_evidence)
                domain_evidence.extend(web_evidence)
                self._sync_search_result_image_similarity(
                    submission_id,
                    web_evidence,
                    image_store,
                    status="manual",
                )
            return domain_evidence, call_count

        if provider == "google_search":
            if self.provider_runtime.google_custom_search_adapter is None:
                raise ValueError(f"{provider} provider not connected")
            can_compare_search_images = self._can_compare_search_result_images(
                submission_id,
                image_store,
            )
            domain_evidence = self.provider_runtime.google_custom_search_adapter.search_images(
                submission_id,
                query,
                self.provider_runtime.google_custom_search_policy,
            )
            call_count = 1 if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in domain_evidence) else 0
            similarity_evidence: list[Evidence] = []
            if can_compare_search_images and image_store is not None:
                similarity_evidence = self._sync_search_result_image_similarity(
                    submission_id,
                    domain_evidence,
                    image_store,
                    status="manual",
                )
            if can_compare_search_images and image_store is not None and not similarity_evidence:
                web_evidence = self.provider_runtime.google_custom_search_adapter.search_web_pages(
                    submission_id,
                    query,
                    self.provider_runtime.google_custom_search_policy,
                )
                if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in web_evidence):
                    call_count += 1
                domain_evidence.extend(web_evidence)
                self._sync_search_result_image_similarity(
                    submission_id,
                    web_evidence,
                    image_store,
                    status="manual",
                )
            return domain_evidence, call_count

        raise ValueError(f"{provider} provider not connected")

    def register_manual_knowledge_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
        name = str(payload.get("name", "")).strip()
        if not name:
            raise ValueError("knowledge name required")

        entry_id = f"kb-manual-{_timestamp_id()}"
        image_record = self._store_manual_knowledge_image(entry_id, payload.get("image"))
        sample_fingerprints = [image_record["perceptualFingerprint"]] if image_record else []
        entry = {
            "id": entry_id,
            "name": name,
            "type": _knowledge_type_value(str(payload.get("type", "other"))),
            "aliases": _text_list(payload.get("aliases")),
            "keywords": _text_list(payload.get("keywords")),
            "memo": str(payload.get("memo", "")).strip(),
            "provenance": "manual",
            "active": True,
            "entryStatus": "confirmed",
            "sourceDecision": "",
            "sampleFingerprints": sample_fingerprints,
            "imageAsset": image_record["asset"] if image_record else "",
            "imageFacts": image_record["facts"] if image_record else {},
        }
        self._put("knowledge_entries", entry_id, entry)
        self.add_audit_event(
            "rights.ops",
            "Knowledge entry manually created",
            name,
            "manual image reference" if image_record else "manual text reference",
        )
        return entry

    def collect_keyword_candidates(self, query: str, provider: str = "naver") -> dict[str, Any]:
        query = query.strip()
        if not query:
            raise ValueError("collection query required")
        if provider not in {"naver", "google_search"}:
            raise ValueError(f"{provider} provider not supported for candidate collection")

        self._sync_provider_payloads()
        provider_payload = self._get("providers", provider)
        if not provider_payload["enabled"]:
            raise ValueError(f"{provider} provider disabled")
        if provider == "naver" and self.provider_runtime.naver_adapter is None:
            raise ValueError("naver provider not connected")
        if provider == "google_search" and self.provider_runtime.google_custom_search_adapter is None:
            raise ValueError("google_search provider not connected")

        self._clear_collection_candidates()
        if provider == "google_search":
            domain_evidence = self.provider_runtime.google_custom_search_adapter.search_images(
                "candidate-collection",
                query,
                self.provider_runtime.google_custom_search_policy,
            )
        else:
            domain_evidence = self.provider_runtime.naver_adapter.search(
                "candidate-collection",
                query,
                self.provider_runtime.search_policy,
            )
        collected = 0
        provider_call_count = 1 if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in domain_evidence) else 0
        for candidate in self._collection_candidates_from_evidence(query, domain_evidence, provider):
            self._put("collection_candidates", candidate["id"], candidate)
            collected += 1

        if provider == "google_search" and collected == 0:
            web_evidence = self.provider_runtime.google_custom_search_adapter.search_web_pages(
                "candidate-collection",
                query,
                self.provider_runtime.google_custom_search_policy,
            )
            if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in web_evidence):
                provider_call_count += 1
            domain_evidence.extend(web_evidence)
            for candidate in self._collection_candidates_from_evidence(query, web_evidence, provider):
                self._put("collection_candidates", candidate["id"], candidate)
                collected += 1
        if provider == "naver" and collected == 0:
            page_evidence = self.provider_runtime.naver_adapter.search_pages(
                "candidate-collection",
                query,
                self.provider_runtime.search_policy,
            )
            if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in page_evidence):
                provider_call_count += 1
            domain_evidence.extend(page_evidence)
            for candidate in self._collection_candidates_from_evidence(query, page_evidence, provider):
                self._put("collection_candidates", candidate["id"], candidate)
                collected += 1
        if provider == "naver" and collected == 0:
            web_evidence = self.provider_runtime.naver_adapter.search_web_pages(
                "candidate-collection",
                query,
                self.provider_runtime.search_policy,
            )
            if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in web_evidence):
                provider_call_count += 1
            domain_evidence.extend(web_evidence)
            for candidate in self._collection_candidates_from_evidence(query, web_evidence, provider):
                self._put("collection_candidates", candidate["id"], candidate)
                collected += 1

        if any(
            item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
            for item in domain_evidence
        ):
            provider_payload["lastFailure"] = domain_evidence[0].reason if domain_evidence else f"{provider} collection failed"
        else:
            provider_payload["lastSuccess"] = _now_label()
            provider_payload["lastFailure"] = "없음"
        self._apply_provider_usage_delta(provider, provider_call_count, provider_payload)
        self.add_audit_event("rights.ops", "Keyword candidates collected", provider, f"{query} · {collected} candidates")
        payload = self.bootstrap()
        payload["collected"] = collected
        return payload

    def promote_collection_candidate(self, candidate_id: str, payload: dict[str, Any]) -> dict[str, Any]:
        with self._write_lock:
            candidate = self._get("collection_candidates", candidate_id)
            # Idempotent: a double-click / retry must not create a second
            # confirmed knowledge entry for the same candidate.
            if candidate.get("status") == "promoted" and candidate.get("promotedKnowledgeId"):
                return self.bootstrap()
            # Deterministic id so a racing retry upserts the same row instead of
            # minting a new timestamped id.
            entry_id = _stable_id("kb-candidate", candidate_id)
            query = str(candidate.get("query", ""))
            name = str(payload.get("name", "")).strip() or str(candidate.get("title", "")).strip() or query
            memo = str(payload.get("memo", "")).strip() or f"키워드 후보 수집에서 편입: {query}"
            entry = {
                "id": entry_id,
                "name": name,
                "type": _knowledge_type_value(str(payload.get("type", "rejected_reference"))),
                "aliases": _text_list(payload.get("aliases")) or [query],
                "keywords": _text_list(payload.get("keywords")) or [query],
                "memo": memo,
                "provenance": "manual",
                "active": True,
                "entryStatus": "confirmed",
                "sourceDecision": "",
                "sourceCandidate": candidate_id,
                "sampleFingerprints": _text_list(candidate.get("sampleFingerprints")),
                "imageAsset": str(candidate.get("imageAsset", "")),
                "imageFacts": candidate.get("imageFacts", {}),
            }
            with self._transaction() as conn:
                self._put("knowledge_entries", entry_id, entry, conn=conn)
                candidate["status"] = "promoted"
                candidate["promotedKnowledgeId"] = entry_id
                self._put("collection_candidates", candidate_id, candidate, conn=conn)
                self.add_audit_event(
                    "rights.ops", "Knowledge entry manually created", name,
                    f"promoted candidate {candidate_id}", conn=conn,
                )
        return self.bootstrap()

    def promote_collection_candidates(self, payload: dict[str, Any]) -> dict[str, Any]:
        candidate_ids = _unique_texts(_text_list(payload.get("candidate_ids", payload.get("candidateIds"))))
        if not candidate_ids:
            raise ValueError("candidate_ids required")

        candidates = [self._get("collection_candidates", candidate_id) for candidate_id in candidate_ids]
        sample_fingerprints = _unique_texts(
            fingerprint
            for candidate in candidates
            for fingerprint in _text_list(candidate.get("sampleFingerprints"))
        )
        if not sample_fingerprints:
            raise ValueError("selected candidates have no sample fingerprints")

        entry_id = f"kb-candidate-{_timestamp_id()}"
        queries = _unique_texts(str(candidate.get("query", "")) for candidate in candidates)
        image_assets = _unique_texts(str(candidate.get("imageAsset", "")) for candidate in candidates)
        image_facts = [
            candidate.get("imageFacts", {})
            for candidate in candidates
            if isinstance(candidate.get("imageFacts", {}), dict) and candidate.get("imageFacts", {})
        ]
        fallback_name = next(
            (
                str(candidate.get("title", "")).strip() or str(candidate.get("query", "")).strip()
                for candidate in candidates
                if str(candidate.get("title", "")).strip() or str(candidate.get("query", "")).strip()
            ),
            "Collected reference",
        )
        name = str(payload.get("name", "")).strip() or fallback_name
        memo = str(payload.get("memo", "")).strip() or f"Promoted from collected candidates: {', '.join(queries)}"
        entry = {
            "id": entry_id,
            "name": name,
            "type": _knowledge_type_value(str(payload.get("type", "rejected_reference"))),
            "aliases": _text_list(payload.get("aliases")) or queries,
            "keywords": _text_list(payload.get("keywords")) or queries,
            "memo": memo,
            "provenance": "manual",
            "active": True,
            "entryStatus": "confirmed",
            "sourceDecision": "",
            "sourceCandidate": candidate_ids[0] if len(candidate_ids) == 1 else "",
            "sourceCandidates": candidate_ids,
            "sampleFingerprints": sample_fingerprints,
            "imageAsset": image_assets[0] if image_assets else "",
            "imageAssets": image_assets,
            "imageFacts": {
                "samples": len(candidates),
                "queries": queries,
                "items": image_facts,
            },
        }
        self._put("knowledge_entries", entry_id, entry)
        for candidate in candidates:
            candidate["status"] = "promoted"
            candidate["promotedKnowledgeId"] = entry_id
            self._put("collection_candidates", str(candidate["id"]), candidate)
        self.add_audit_event(
            "rights.ops",
            "Knowledge entry manually created",
            name,
            f"promoted {len(candidates)} collected candidates",
        )
        return self.bootstrap()

    def providers(self) -> list[dict[str, Any]]:
        return self._all("providers")

    def set_provider_enabled(self, provider_id: str, enabled: bool) -> list[dict[str, Any]]:
        provider = self._get("providers", provider_id)
        if enabled and provider_id != "internal":
            runtime_provider = self.provider_runtime.provider_payloads.get(provider_id, {})
            if not runtime_provider.get("enabled", False):
                reason = str(runtime_provider.get("lastFailure", "")) or f"{provider_id} provider not connected"
                raise ValueError(reason)
        provider["enabled"] = bool(enabled)
        self._put("providers", provider_id, provider)
        self._sync_submission_provider_state()
        self.add_audit_event(
            actor="admin.ops",
            event="Provider setting changed",
            object_id=provider["name"],
            change="enabled" if enabled else "disabled",
        )
        return self.providers()

    def emergency_disable_external_providers(self) -> list[dict[str, Any]]:
        for provider in self.providers():
            if provider["id"] != "internal":
                provider["enabled"] = False
                self._put("providers", provider["id"], provider)
        external_provider_ids = [provider["id"] for provider in self.providers() if provider["id"] != "internal"]
        for submission in self._all("submissions"):
            provider_state = submission.setdefault("providerState", {})
            for provider_id in external_provider_ids:
                provider_state[provider_id] = "disabled"
            self._put("submissions", submission["id"], submission)
        self.add_audit_event("admin.ops", "Provider setting changed", "external providers", "emergency disabled")
        return self.providers()

    def audit_events(self, limit: int = 500) -> list[dict[str, Any]]:
        # bootstrap() calls this on nearly every API request; cap to the newest
        # `limit` rows so the full, unbounded audit log is not re-read and
        # re-serialized on every operation.
        with self._connect() as conn:
            rows = conn.execute(
                "select payload from audit_events order by id desc limit ?",
                (limit,),
            ).fetchall()
        return [json.loads(row["payload"]) for row in rows]

    def knowledge_media_path(self, relative_path: str) -> Path:
        root = self.knowledge_image_dir.resolve()
        path = (root / relative_path.lstrip("/")).resolve()
        if path != root and root not in path.parents:
            raise ValueError("knowledge media path points outside image store")
        return path

    def collected_media_path(self, relative_path: str) -> Path:
        root = self.collection_image_dir.resolve()
        path = (root / relative_path.lstrip("/")).resolve()
        if path != root and root not in path.parents:
            raise ValueError("collected media path points outside image store")
        return path

    def face_crop_media_path(self, relative_path: str) -> Path:
        root = self.face_crop_image_dir.resolve()
        path = (root / relative_path.lstrip("/")).resolve()
        if path != root and root not in path.parents:
            raise ValueError("face crop media path points outside image store")
        return path

    def add_audit_event(
        self,
        actor: str,
        event: str,
        object_id: str,
        change: str,
        *,
        conn: sqlite3.Connection | None = None,
    ) -> None:
        payload = {
            "timestamp": _now_label(),
            "actor": actor,
            "event": event,
            "object": object_id,
            "change": change,
        }
        with self._conn_ctx(conn) as conn:
            conn.execute(
                """
                insert into audit_events (timestamp, actor, event, object_ref, payload)
                values (?, ?, ?, ?, ?)
                """,
                (
                    payload["timestamp"],
                    payload["actor"],
                    payload["event"],
                    payload["object"],
                    json.dumps(payload, ensure_ascii=False),
                ),
            )

    def _sync_provider_payloads(self) -> None:
        existing = {provider["id"]: provider for provider in self._all("providers")}
        for provider_id, payload in self.provider_runtime.provider_payloads.items():
            merged = {
                **payload,
                "usage": existing.get(provider_id, {}).get("usage", payload["usage"]),
            }
            self._put("providers", provider_id, merged)
        self._sync_submission_provider_state()

    def _sync_submission_provider_state(self, queue_id: str | None = None) -> None:
        provider_payloads = {provider["id"]: provider for provider in self._all("providers")}
        evidence_by_submission = self._evidence_by_submission(queue_id=queue_id)
        for submission in self._all("submissions", queue_id=queue_id):
            provider_state = submission.get("providerState", {})
            evidence = evidence_by_submission.get(str(submission["id"]), [])
            provider_state["internal"] = "ok"
            for provider_id in _external_provider_ids(provider_payloads):
                provider_state[provider_id] = _external_provider_state_for_submission(
                    provider_payloads,
                    provider_id,
                    submission,
                    evidence,
                )
            submission["providerState"] = provider_state
            self._put("submissions", submission["id"], submission)

    def _normalize_saved_google_weak_labels_and_rescore(self, queue_id: str | None = None) -> None:
        changed_submissions: set[str] = set()
        with self._connect() as conn:
            rows = conn.execute(
                """
                select id, submission_id, payload
                from evidence
            """.strip() + (
                    " where submission_id in (select id from submissions where queue_id = ?)"
                    if queue_id is not None
                    else ""
                ),
                (queue_id,) if queue_id is not None else (),
            ).fetchall()

        for row in rows:
            payload = json.loads(row["payload"])
            if _is_google_weak_label_payload(payload):
                payload["title"] = _google_weak_label_title(str(payload.get("title", "")))
                payload["confidence"] = 0.0
                payload["contributed"] = False
                payload["status"] = "weak"
                self._put("evidence", row["id"], payload)
                changed_submissions.add(str(row["submission_id"]))

        for submission_id in changed_submissions:
            self._rescore_submission(submission_id)

    def _refresh_existing_local_face_evidence(
        self,
        image_store: LocalSubmissionImageStore,
        *,
        queue_id: str,
    ) -> None:
        existing_submission_ids = {submission["id"] for submission in self._all("submissions", queue_id=queue_id)}
        if not existing_submission_ids:
            return

        evidence_by_submission = self._evidence_by_submission(queue_id=queue_id)
        detector = HeuristicFacePersonDetector()
        for record in image_store.submission_records():
            submission_id = str(record["id"])
            if submission_id not in existing_submission_ids:
                continue
            if any(
                item.get("source") == "face"
                for item in evidence_by_submission.get(submission_id, [])
            ):
                continue

            signal = detector.detect(image_store.image_payload(submission_id))
            if not signal.present:
                continue

            evidence = Evidence(
                source=EvidenceSource.FACE_PERSON,
                reason="Face/person detected",
                confidence=0.8,
                data={
                    "face_count": signal.face_count,
                    "person_count": signal.person_count,
                },
            )
            self._put(
                "evidence",
                _evidence_id(submission_id, evidence),
                _evidence_payload(submission_id, evidence),
            )
            self._rescore_submission(submission_id)

    def _sync_face_crops(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore | None,
    ) -> None:
        if image_store is None:
            return
        try:
            original = image_store.image_payload(submission_id)
        except Exception:
            return

        signal = HeuristicFacePersonDetector().detect(original)
        crop_dir = self.face_crop_image_dir / submission_id
        if crop_dir.exists():
            for stale in crop_dir.glob("crop-*.jpg"):
                try:
                    stale.unlink()
                except OSError:
                    continue

        face_crops: list[dict[str, Any]] = []
        for index, box in enumerate(signal.face_boxes[:3], start=1):
            crops = build_face_crop_derivatives(original, [box])
            if not crops:
                continue
            try:
                crop_dir.mkdir(parents=True, exist_ok=True)
                crop_path = crop_dir / f"crop-{index}.jpg"
                crop_path.write_bytes(crops[0].content)
            except OSError:
                continue
            face_crops.append(
                {
                    "index": index,
                    "url": f"{self.face_crop_public_prefix}/{submission_id}/crop-{index}.jpg",
                    "box": [int(value) for value in box],
                }
            )

        submission = self._get("submissions", submission_id)
        submission["faceCrops"] = face_crops
        self._put("submissions", submission_id, submission)

    def purge_expired_face_crops(self, *, now_epoch: float | None = None) -> int:
        # Delete biometric face-crop files older than the retention window and
        # drop their references from the owning submission, with an audit trail.
        retention_days = int(self.face_crop_retention_days)
        if retention_days <= 0 or not self.face_crop_image_dir.exists():
            return 0
        now = now_epoch if now_epoch is not None else datetime.now().timestamp()
        cutoff = now - retention_days * 86400
        purged = 0
        with self._write_lock:
            for crop_dir in sorted(p for p in self.face_crop_image_dir.iterdir() if p.is_dir()):
                submission_id = crop_dir.name
                removed: list[str] = []
                for crop_file in crop_dir.glob("crop-*.jpg"):
                    try:
                        if crop_file.stat().st_mtime >= cutoff:
                            continue
                        crop_file.unlink()
                    except OSError:
                        continue
                    removed.append(crop_file.name)
                    purged += 1
                if not removed:
                    continue
                change = f"retention {retention_days}d: removed {len(removed)} biometric crop(s)"
                try:
                    submission = self._get("submissions", submission_id)
                except KeyError:
                    self.add_audit_event("system", "Face crop purged", submission_id, change)
                    continue
                remaining = [
                    crop
                    for crop in submission.get("faceCrops", [])
                    if f"crop-{crop.get('index')}.jpg" not in removed
                ]
                with self._transaction() as conn:
                    submission["faceCrops"] = remaining
                    self._put("submissions", submission_id, submission, conn=conn)
                    self.add_audit_event("system", "Face crop purged", submission_id, change, conn=conn)
        return purged

    def _refresh_existing_submission_file_facts(
        self,
        image_store: LocalSubmissionImageStore,
        *,
        queue_id: str,
    ) -> None:
        existing_submission_ids = {submission["id"] for submission in self._all("submissions", queue_id=queue_id)}
        for record in image_store.submission_records():
            submission_id = str(record["id"])
            if submission_id not in existing_submission_ids:
                continue
            submission = self._get("submissions", submission_id)
            submission["asset"] = record["asset"]
            submission["fileFacts"] = {
                **submission.get("fileFacts", {}),
                "size": f"{record.get('width', 1)} x {record.get('height', 1)}",
                "format": record.get("format", "FILE"),
            }
            self._put("submissions", submission_id, submission)

    def _rerun_internal_analysis(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore,
    ) -> list[Evidence]:
        repository = self._knowledge_repository()
        analyzer = InternalAnalyzer(
            repository,
            FingerprintService(),
            HeuristicFacePersonDetector(),
        )
        domain_evidence = analyzer.analyze(
            submission_id,
            image_store.image_payload(submission_id),
        )
        for item in domain_evidence:
            payload = _evidence_payload(submission_id, item)
            payload["status"] = "rerun"
            self._put("evidence", payload["id"], payload)
        self._sync_similar_reference_images(submission_id, domain_evidence)
        self._increment_knowledge_contribution_counts(submission_id, domain_evidence)
        self._rescore_submission(submission_id)
        self._sync_face_crops(submission_id, image_store)
        return domain_evidence

    def _rerun_google_image_search(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore,
    ) -> list[Evidence]:
        if self.provider_runtime.google_adapter is None:
            return []

        original_image = image_store.image_payload(submission_id)
        derivative = build_external_derivative(original_image)
        domain_evidence = self.provider_runtime.google_adapter.detect(
            submission_id,
            derivative,
            self.provider_runtime.external_policy,
        )
        call_count = 1
        face_crop_evidence, face_crop_calls = self._google_face_crop_web_detection(
            submission_id,
            original_image,
        )
        call_count += face_crop_calls
        domain_evidence.extend(face_crop_evidence)
        for item in domain_evidence:
            payload = _evidence_payload(submission_id, item)
            payload["status"] = "rerun" if payload["status"] == "active" else payload["status"]
            self._put("evidence", payload["id"], payload)
        self._sync_search_result_image_similarity(
            submission_id,
            domain_evidence,
            image_store,
            status="rerun",
        )

        google_provider = self._get("providers", "google")
        if any(
            item.source in {EvidenceSource.FAILURE, EvidenceSource.EXTERNAL_SKIPPED}
            for item in domain_evidence
        ):
            google_provider["lastFailure"] = domain_evidence[0].reason if domain_evidence else "Google image search failed"
        else:
            google_provider["lastSuccess"] = _now_label()
            google_provider["lastFailure"] = "없음"
        self._apply_provider_usage_delta("google", call_count, google_provider)
        self.add_audit_event(
            "rights.ops",
            "Provider called",
            f"google / {submission_id}",
            "image web detection rerun",
        )
        self._rescore_submission(submission_id)
        return domain_evidence

    def _google_face_crop_web_detection(
        self,
        submission_id: str,
        original_image: Any,
    ) -> tuple[list[Evidence], int]:
        if self.provider_runtime.google_adapter is None:
            return [], 0
        if not self.provider_runtime.face_crop_web_detection_enabled:
            return [], 0

        submission = self._get("submissions", submission_id)
        stored_crops = submission.get("faceCrops")
        if stored_crops is None:
            signal = HeuristicFacePersonDetector().detect(original_image)
            indexed_boxes = [
                (index, box)
                for index, box in enumerate(signal.face_boxes[:3], start=1)
            ]
        else:
            # 저장된 faceCrops의 index를 그대로 사용해 증거의 crop_index가
            # 워크벤치 썸네일 번호와 항상 일치하게 한다(중간 박스 크롭 실패 시
            # 재열거하면 번호가 어긋난다).
            indexed_boxes = [
                (int(item.get("index", 0)), tuple(int(value) for value in item.get("box", [])))
                for item in stored_crops
                if len(item.get("box", [])) == 4
            ]
        if not indexed_boxes:
            return [], 0

        evidence: list[Evidence] = []
        call_count = 0
        for crop_index, box in indexed_boxes:
            crops = build_face_crop_derivatives(original_image, [box])
            if not crops:
                continue
            crop = crops[0]
            call_count += 1
            crop_evidence = self.provider_runtime.google_adapter.detect(
                f"{submission_id}:face-crop-{crop_index}",
                crop,
                self.provider_runtime.external_policy,
            )
            for item in crop_evidence:
                face_web_evidence = _face_crop_web_evidence(submission_id, crop_index, item)
                evidence.append(face_web_evidence)
                crop_matches = self._face_crop_search_result_similarity_evidence(
                    submission_id,
                    crop_index,
                    crop,
                    face_web_evidence,
                )
                if crop_matches:
                    evidence.extend(crop_matches)
        if call_count:
            # Governance: personal (biometric) images left the system to a
            # third-party API. The external call itself is permitted, but the
            # transmission must be auditable.
            self.add_audit_event(
                "system",
                "Personal image sent to external provider",
                submission_id,
                f"{call_count} face crop(s) -> Google Vision web detection",
            )
        return evidence, call_count

    def _auto_google_custom_search(
        self,
        submission_id: str,
        source_evidence: list[Evidence],
        image_store: LocalSubmissionImageStore | None = None,
    ) -> None:
        if self.provider_runtime.google_custom_search_adapter is None:
            return
        if self.provider_runtime.auto_google_custom_query_limit <= 0:
            return

        query_plan = SearchQueryGenerator().plan(
            source_evidence,
            [],
            max_queries=self.provider_runtime.auto_google_custom_query_limit,
        )
        if not query_plan:
            return

        existing_signatures = _existing_google_custom_query_signatures(
            self._evidence_by_submission().get(submission_id, [])
        )
        can_compare_search_images = self._can_compare_search_result_images(
            submission_id,
            image_store,
        )
        all_domain_evidence: list[Evidence] = []
        history_entries: list[dict[str, Any]] = []
        external_call_count = 0

        for planned in query_plan:
            query = planned.query
            image_signature = _google_custom_image_query_signature(query)
            if image_signature in existing_signatures:
                continue
            existing_signatures.add(image_signature)

            image_evidence = self.provider_runtime.google_custom_search_adapter.search_images(
                submission_id,
                query,
                self.provider_runtime.google_custom_search_policy,
            )
            if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in image_evidence):
                external_call_count += 1
            image_evidence = [
                replace(
                    item,
                    data={
                        **item.data,
                        "query_strategy": planned.strategy,
                        "query_source": planned.source,
                        "query_priority": planned.priority,
                    },
                )
                for item in image_evidence
            ]
            all_domain_evidence.extend(image_evidence)
            for item in image_evidence:
                payload = _evidence_payload(submission_id, item)
                payload["status"] = "auto" if payload["status"] == "active" else payload["status"]
                self._put("evidence", payload["id"], payload)

            similarity_evidence: list[Evidence] = []
            remaining_matches = self._search_result_similarity_remaining_budget(
                submission_id,
                image_store,
            )
            if can_compare_search_images and remaining_matches > 0:
                similarity_evidence = self._sync_search_result_image_similarity(
                    submission_id,
                    image_evidence,
                    image_store,
                    status="auto",
                    max_matches=remaining_matches,
                )
                all_domain_evidence.extend(similarity_evidence)

            history_entries.append(
                {
                    "provider": "google_search",
                    "query": query,
                    "status": _query_history_status(image_evidence),
                    "strategy": planned.strategy,
                    "source": planned.source,
                    "timestamp": _now_label(),
                    "count": len(image_evidence),
                }
            )

            web_signature = _google_custom_web_query_signature(query)
            if web_signature in existing_signatures:
                continue
            existing_signatures.add(web_signature)

            web_evidence = self.provider_runtime.google_custom_search_adapter.search_web_pages(
                submission_id,
                query,
                self.provider_runtime.google_custom_search_policy,
            )
            if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in web_evidence):
                external_call_count += 1
            web_evidence = [
                replace(
                    item,
                    data={
                        **item.data,
                        "query_strategy": planned.strategy,
                        "query_source": planned.source,
                        "query_priority": planned.priority,
                    },
                )
                for item in web_evidence
            ]
            all_domain_evidence.extend(web_evidence)
            for item in web_evidence:
                payload = _evidence_payload(submission_id, item)
                payload["status"] = "auto" if payload["status"] == "active" else payload["status"]
                self._put("evidence", payload["id"], payload)
            all_domain_evidence.extend(
                self._sync_search_result_image_similarity(
                    submission_id,
                    web_evidence,
                    image_store,
                    status="auto",
                    max_matches=self._search_result_similarity_remaining_budget(
                        submission_id,
                        image_store,
                    ),
                )
            )
            history_entries.append(
                {
                    "provider": "google_search",
                    "query": query,
                    "status": _query_history_status(web_evidence),
                    "strategy": planned.strategy,
                    "source": planned.source,
                    "timestamp": _now_label(),
                    "count": len(web_evidence),
                }
            )

        if not history_entries:
            return

        submission = self._get("submissions", submission_id)
        submission["queryHistory"] = [*history_entries, *submission.get("queryHistory", [])]
        self._put("submissions", submission_id, submission)

        google_search_provider = self._get("providers", "google_search")
        if any(
            item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
            for item in all_domain_evidence
        ):
            google_search_provider["lastFailure"] = all_domain_evidence[0].reason if all_domain_evidence else "Google custom search failed"
        else:
            google_search_provider["lastSuccess"] = _now_label()
            google_search_provider["lastFailure"] = "없음"
        self._apply_provider_usage_delta("google_search", external_call_count, google_search_provider)
        self.add_audit_event(
            "system",
            "Provider called",
            f"google_search / {submission_id}",
            f"auto custom search query batch: {', '.join(item['query'] for item in history_entries)}",
        )

    def _auto_naver_search(
        self,
        submission_id: str,
        source_evidence: list[Evidence],
        image_store: LocalSubmissionImageStore | None = None,
    ) -> None:
        if self.provider_runtime.naver_adapter is None:
            return
        if self.provider_runtime.auto_naver_query_limit <= 0:
            return

        query_plan = SearchQueryGenerator().plan(
            source_evidence,
            [],
            max_queries=self.provider_runtime.auto_naver_query_limit,
        )
        if not query_plan:
            return

        existing_signatures = _existing_naver_query_signatures(
            self._evidence_by_submission().get(submission_id, [])
        )
        can_compare_search_images = self._can_compare_search_result_images(
            submission_id,
            image_store,
        )
        all_domain_evidence: list[Evidence] = []
        history_entries: list[dict[str, Any]] = []
        external_call_count = 0
        blog_query_count = 0
        web_query_count = 0
        promoter = SearchResultPromoter()

        for planned in query_plan:
            query = planned.query
            signature = _naver_query_signature(query)
            if signature in existing_signatures:
                continue
            existing_signatures.add(signature)

            domain_evidence = self.provider_runtime.naver_adapter.search(
                submission_id,
                query,
                self.provider_runtime.search_policy,
            )
            if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in domain_evidence):
                external_call_count += 1
            domain_evidence = [
                replace(
                    item,
                    data={
                        **item.data,
                        "query_strategy": planned.strategy,
                        "query_source": planned.source,
                        "query_priority": planned.priority,
                    },
                )
                for item in domain_evidence
            ]
            domain_evidence = promoter.promote(domain_evidence)
            all_domain_evidence.extend(domain_evidence)
            for item in domain_evidence:
                payload = _evidence_payload(submission_id, item)
                payload["status"] = "auto" if payload["status"] == "active" else payload["status"]
                self._put("evidence", payload["id"], payload)
            similarity_evidence: list[Evidence] = []
            remaining_matches = self._search_result_similarity_remaining_budget(
                submission_id,
                image_store,
            )
            if can_compare_search_images and remaining_matches > 0:
                similarity_evidence = self._sync_search_result_image_similarity(
                    submission_id,
                    domain_evidence,
                    image_store,
                    status="auto",
                    max_matches=remaining_matches,
                )
                all_domain_evidence.extend(similarity_evidence)

            history_entries.append(
                {
                    "provider": "naver",
                    "query": query,
                    "status": _query_history_status(domain_evidence),
                    "strategy": planned.strategy,
                    "source": planned.source,
                    "timestamp": _now_label(),
                    "count": len(domain_evidence),
                }
            )

            page_similarity_evidence: list[Evidence] = []
            remaining_matches = self._search_result_similarity_remaining_budget(
                submission_id,
                image_store,
            )
            if blog_query_count < self.provider_runtime.auto_naver_blog_query_limit:
                blog_signature = _naver_blog_query_signature(query)
                if blog_signature in existing_signatures:
                    continue
                existing_signatures.add(blog_signature)
                blog_query_count += 1

                page_evidence = self.provider_runtime.naver_adapter.search_pages(
                    submission_id,
                    query,
                    self.provider_runtime.search_policy,
                )
                if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in page_evidence):
                    external_call_count += 1
                page_evidence = [
                    replace(
                        item,
                        data={
                            **item.data,
                            "query_strategy": planned.strategy,
                            "query_source": planned.source,
                            "query_priority": planned.priority,
                        },
                    )
                    for item in page_evidence
                ]
                page_evidence = promoter.promote(page_evidence)
                all_domain_evidence.extend(page_evidence)
                for item in page_evidence:
                    payload = _evidence_payload(submission_id, item)
                    payload["status"] = "auto" if payload["status"] == "active" else payload["status"]
                    self._put("evidence", payload["id"], payload)
                page_similarity_evidence = self._sync_search_result_image_similarity(
                    submission_id,
                    page_evidence,
                    image_store,
                    status="auto",
                    max_matches=remaining_matches if can_compare_search_images else 0,
                )
                all_domain_evidence.extend(page_similarity_evidence)
                history_entries.append(
                    {
                        "provider": "naver_blog",
                        "query": query,
                        "status": _query_history_status(page_evidence),
                        "strategy": planned.strategy,
                        "source": planned.source,
                        "timestamp": _now_label(),
                        "count": len(page_evidence),
                    }
                )

            if web_query_count < self.provider_runtime.auto_naver_web_query_limit:
                web_signature = _naver_web_query_signature(query)
                if web_signature in existing_signatures:
                    continue
                existing_signatures.add(web_signature)
                web_query_count += 1

                web_evidence = self.provider_runtime.naver_adapter.search_web_pages(
                    submission_id,
                    query,
                    self.provider_runtime.search_policy,
                )
                if any(item.source != EvidenceSource.SEARCH_SKIPPED for item in web_evidence):
                    external_call_count += 1
                web_evidence = [
                    replace(
                        item,
                        data={
                            **item.data,
                            "query_strategy": planned.strategy,
                            "query_source": planned.source,
                            "query_priority": planned.priority,
                        },
                    )
                    for item in web_evidence
                ]
                web_evidence = promoter.promote(web_evidence)
                all_domain_evidence.extend(web_evidence)
                for item in web_evidence:
                    payload = _evidence_payload(submission_id, item)
                    payload["status"] = "auto" if payload["status"] == "active" else payload["status"]
                    self._put("evidence", payload["id"], payload)
                all_domain_evidence.extend(
                    self._sync_search_result_image_similarity(
                        submission_id,
                        web_evidence,
                        image_store,
                        status="auto",
                        max_matches=(
                            self._search_result_similarity_remaining_budget(
                                submission_id,
                                image_store,
                            )
                            if can_compare_search_images
                            else 0
                        ),
                    )
                )
                history_entries.append(
                    {
                        "provider": "naver_web",
                        "query": query,
                        "status": _query_history_status(web_evidence),
                        "strategy": planned.strategy,
                        "source": planned.source,
                        "timestamp": _now_label(),
                        "count": len(web_evidence),
                    }
                )

        if not history_entries:
            return

        submission = self._get("submissions", submission_id)
        submission["queryHistory"] = [*history_entries, *submission.get("queryHistory", [])]
        self._put("submissions", submission_id, submission)

        naver_provider = self._get("providers", "naver")
        if any(
            item.source in {EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}
            for item in all_domain_evidence
        ):
            naver_provider["lastFailure"] = all_domain_evidence[0].reason if all_domain_evidence else "Naver search failed"
        else:
            naver_provider["lastSuccess"] = _now_label()
            naver_provider["lastFailure"] = "없음"
        self._apply_provider_usage_delta("naver", external_call_count, naver_provider)
        self.add_audit_event(
            "system",
            "Provider called",
            f"naver / {submission_id}",
            f"auto text query batch: {', '.join(item['query'] for item in history_entries)}",
        )

    def _knowledge_repository(self) -> InMemoryRightsFilterRepository:
        repository = InMemoryRightsFilterRepository()
        for payload in self._all("knowledge_entries"):
            if not payload.get("active", True):
                continue
            if payload.get("entryStatus") == "excluded":
                continue
            sample_fingerprints = _text_list(
                payload.get("sampleFingerprints", payload.get("sample_fingerprints", []))
            )
            if not sample_fingerprints:
                continue
            repository.save_knowledge_entry(
                KnowledgeBaseEntry(
                    id=str(payload.get("id", "")),
                    entry_type=_knowledge_entry_type(str(payload.get("type", "other"))),
                    name=str(payload.get("name", "")),
                    provenance=_knowledge_provenance(str(payload.get("provenance", "manual"))),
                    aliases=_text_list(payload.get("aliases")),
                    related_keywords=_text_list(payload.get("keywords")),
                    policy_memo=str(payload.get("memo", "")),
                    sample_fingerprints=sample_fingerprints,
                    source_decision_id=str(payload.get("sourceDecision", "")) or None,
                    entry_status=str(payload.get("entryStatus", "confirmed")),
                    source_submission_id=str(payload.get("sourceSubmissionId", "")),
                    active=bool(payload.get("active", True)),
                )
            )
        return repository

    def _sync_similar_reference_images(
        self,
        submission_id: str,
        evidence: list[Evidence],
    ) -> None:
        matched_entry_ids = [
            str(item.data.get("knowledge_entry_id", ""))
            for item in evidence
            if item.source == EvidenceSource.FINGERPRINT and item.data.get("knowledge_entry_id")
        ]
        if not matched_entry_ids:
            return

        submission = self._get("submissions", submission_id)
        similar = list(submission.get("similar", []))
        existing_assets = {str(item.get("asset", "")) for item in similar}
        for entry_id in matched_entry_ids:
            try:
                entry = self._get("knowledge_entries", entry_id)
            except KeyError:
                continue
            asset = str(entry.get("imageAsset", ""))
            if not asset or asset in existing_assets:
                continue
            similar.append(
                {
                    "asset": asset,
                    "label": f"{entry.get('name', entry_id)} / internal match",
                }
            )
            existing_assets.add(asset)
        submission["similar"] = similar
        self._put("submissions", submission_id, submission)

    def _sync_search_result_image_similarity(
        self,
        submission_id: str,
        evidence: list[Evidence],
        image_store: LocalSubmissionImageStore,
        status: str = "active",
        max_matches: int | None = None,
    ) -> list[Evidence]:
        submission_fingerprint = self._submission_perceptual_fingerprint(
            submission_id,
            image_store,
        )
        if submission_fingerprint is None:
            return []

        if max_matches is None:
            max_matches = self.provider_runtime.search_result_compare_limit
        else:
            max_matches = min(
                max_matches,
                self.provider_runtime.search_result_compare_limit,
            )
        if max_matches <= 0:
            return []

        similarity_evidence: list[Evidence] = []
        for item in evidence:
            if len(similarity_evidence) >= max_matches:
                break
            matches = self._search_result_image_similarity_evidence(
                submission_id,
                submission_fingerprint,
                item,
            )
            if not matches:
                continue
            for match in matches:
                if len(similarity_evidence) >= max_matches:
                    break
                payload = _evidence_payload(submission_id, match)
                payload["status"] = status
                self._put("evidence", payload["id"], payload)
                similarity_evidence.append(match)
        if similarity_evidence:
            self._rescore_submission(submission_id)
        return similarity_evidence

    def _can_compare_search_result_images(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore | None,
    ) -> bool:
        if image_store is None:
            return False
        return self._submission_perceptual_fingerprint(submission_id, image_store) is not None

    def _search_result_similarity_count(self, submission_id: str) -> int:
        return sum(
            1
            for item in self._evidence_by_submission().get(submission_id, [])
            if item.get("source") == "fingerprint"
            and str(item.get("matchType") or "").startswith("search_result")
        )

    def _search_result_similarity_remaining_budget(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore | None,
    ) -> int:
        if not self._can_compare_search_result_images(submission_id, image_store):
            return 0
        return max(
            0,
            self.provider_runtime.search_result_compare_limit
            - self._search_result_similarity_count(submission_id),
        )

    def _submission_perceptual_fingerprint(
        self,
        submission_id: str,
        image_store: LocalSubmissionImageStore,
    ) -> str | None:
        try:
            fingerprint = FingerprintService().fingerprints_for(
                image_store.image_payload(submission_id).content
            ).perceptual
        except Exception:
            return None
        if fingerprint.startswith("phash:unavailable:"):
            return None
        return fingerprint

    def _search_result_image_similarity_evidence(
        self,
        submission_id: str,
        submission_fingerprint: str,
        source_evidence: Evidence,
    ) -> list[Evidence]:
        if source_evidence.source not in {EvidenceSource.NAVER_SEARCH, EvidenceSource.WEB_DETECTION}:
            return []
        if source_evidence.data.get("weak_hint"):
            return []

        matches: list[Evidence] = []

        for image_url in _unique_texts(
            [
                str(source_evidence.data.get("image_url", "")),
                str(source_evidence.data.get("thumbnail_url", "")),
            ]
        ):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                submission_fingerprint,
                source_evidence,
                image_url,
                match_type="search_result_image",
                candidate_source="result_image_url",
            )
            if match is not None:
                return [match]

        for image_url in _search_result_direct_image_urls(source_evidence):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                submission_fingerprint,
                source_evidence,
                image_url,
                match_type="search_result_page_image",
                candidate_source="result_page_direct_image",
            )
            if match is not None:
                return [match]

        for image_url in _unique_texts(source_evidence.data.get("page_image_urls", [])):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                submission_fingerprint,
                source_evidence,
                image_url,
                match_type="search_result_page_image",
                candidate_source="provider_page_image",
            )
            if match is not None:
                return [match]

        for image_url, candidate_source in self._search_result_page_image_candidates(source_evidence):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                submission_fingerprint,
                source_evidence,
                image_url,
                match_type="search_result_page_image",
                candidate_source=candidate_source,
            )
            if match is not None:
                return [match]
        return matches

    def _face_crop_search_result_similarity_evidence(
        self,
        submission_id: str,
        crop_index: int,
        crop: Any,
        source_evidence: Evidence,
    ) -> list[Evidence]:
        try:
            crop_fingerprint = FingerprintService().fingerprints_for(crop.content).perceptual
        except Exception:
            return []
        if crop_fingerprint.startswith("phash:unavailable:"):
            return []

        matches: list[Evidence] = []

        extra_data = {
            "face_crop_search": True,
            "crop_index": crop_index,
            "weak_hint": True,
            "privacy_note": "얼굴 영역만 웹 탐지한 참고 근거이며 동일인 판정이 아닙니다.",
        }
        for image_url in _unique_texts(
            [
                str(source_evidence.data.get("image_url", "")),
                str(source_evidence.data.get("thumbnail_url", "")),
            ]
        ):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                crop_fingerprint,
                source_evidence,
                image_url,
                match_type="face_crop_search_result_image",
                candidate_source="face_crop_result_image_url",
                extra_data=extra_data,
            )
            if match is not None:
                return [match]

        for image_url in _search_result_direct_image_urls(source_evidence):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                crop_fingerprint,
                source_evidence,
                image_url,
                match_type="face_crop_search_result_page_image",
                candidate_source="face_crop_result_page_direct_image",
                extra_data=extra_data,
            )
            if match is not None:
                return [match]

        for image_url in _unique_texts(source_evidence.data.get("page_image_urls", [])):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                crop_fingerprint,
                source_evidence,
                image_url,
                match_type="face_crop_search_result_page_image",
                candidate_source="face_crop_provider_page_image",
                extra_data=extra_data,
            )
            if match is not None:
                return [match]

        for image_url, candidate_source in self._search_result_page_image_candidates(source_evidence):
            match = self._search_result_candidate_image_evidence(
                submission_id,
                crop_fingerprint,
                source_evidence,
                image_url,
                match_type="face_crop_search_result_page_image",
                candidate_source=f"face_crop_{candidate_source}",
                extra_data=extra_data,
            )
            if match is not None:
                return [match]
        return matches

    def _search_result_candidate_image_evidence(
        self,
        submission_id: str,
        submission_fingerprint: str,
        source_evidence: Evidence,
        image_url: str,
        match_type: str,
        candidate_source: str,
        extra_data: dict[str, Any] | None = None,
    ) -> Evidence | None:
        image_url = _normalized_remote_image_url(image_url)
        result_url = str(
            source_evidence.data.get("result_url", source_evidence.data.get("url", ""))
            or image_url
        )
        image_id = _stable_id(
            "searchimg",
            submission_id,
            str(source_evidence.source),
            match_type,
            image_url,
            str(source_evidence.data.get("query", "")),
        )
        image_record = self._store_candidate_image(image_id, image_url, referer_url=result_url)
        if not image_record:
            return None

        similarity = FingerprintService().similarity(
            submission_fingerprint,
            str(image_record["perceptualFingerprint"]),
        )
        if similarity < self.provider_runtime.search_result_similarity_threshold:
            return None
        return Evidence(
            source=EvidenceSource.FINGERPRINT,
            reason=f"Search result image similarity {similarity:.2f}",
            confidence=similarity,
            data={
                "submission_id": submission_id,
                "provider": source_evidence.data.get("provider", ""),
                "query": source_evidence.data.get("query", ""),
                "query_signature": source_evidence.data.get("query_signature", ""),
                "query_strategy": source_evidence.data.get("query_strategy", ""),
                "query_source": source_evidence.data.get("query_source", ""),
                "url": result_url,
                "result_url": result_url,
                "image_url": image_record["asset"],
                "thumbnail_url": image_record["asset"],
                "remote_image_url": image_url,
                "source_page_url": result_url,
                "image_candidate_source": candidate_source,
                "page_title": source_evidence.data.get("page_title", source_evidence.data.get("title", "")),
                "match": match_type,
                "similarity": similarity,
                "source_evidence_ids": [_evidence_id(submission_id, source_evidence)],
                "contributed": True,
                **(extra_data or {}),
            },
        )

    def _search_result_page_image_candidates(self, source_evidence: Evidence) -> list[tuple[str, str]]:
        page_url = str(
            source_evidence.data.get("result_url", source_evidence.data.get("url", ""))
        )
        limit = getattr(self.provider_runtime, "search_result_page_image_limit", 3)
        if not page_url or limit <= 0 or not _is_http_url(page_url):
            return []
        if _url_looks_like_image(page_url):
            return []
        try:
            content = self.page_fetcher(page_url)
        except Exception:
            return []
        if _content_has_comparable_image_fingerprint(content):
            return [(page_url, "result_page_direct_image")]
        image_urls = _extract_page_image_urls(content, page_url, limit)
        if len(image_urls) < limit:
            image_urls.extend(
                self._search_result_stylesheet_image_urls(
                    content,
                    page_url,
                    limit - len(image_urls),
                )
            )
        return [(image_url, "html_page_image") for image_url in _unique_texts(image_urls)[:limit]]

    def _search_result_stylesheet_image_urls(
        self,
        page_content: bytes,
        page_url: str,
        limit: int,
    ) -> list[str]:
        if limit <= 0:
            return []
        image_urls: list[str] = []
        for stylesheet_url in _extract_page_stylesheet_urls(page_content, page_url, limit):
            try:
                stylesheet_content = self.stylesheet_fetcher(stylesheet_url)
            except Exception:
                continue
            for image_url in _extract_css_image_urls(stylesheet_content, stylesheet_url, limit - len(image_urls)):
                image_urls.append(image_url)
                if len(image_urls) >= limit:
                    return image_urls
        return image_urls

    def _search_result_page_image_urls(self, source_evidence: Evidence) -> list[str]:
        return [
            image_url
            for image_url, _candidate_source in self._search_result_page_image_candidates(source_evidence)
        ]

    def _increment_knowledge_contribution_counts(
        self,
        submission_id: str,
        evidence: list[Evidence],
    ) -> None:
        matched_entry_ids = _unique_texts(
            str(item.data.get("knowledge_entry_id", ""))
            for item in evidence
            if item.source == EvidenceSource.FINGERPRINT
            and item.data.get("knowledge_entry_status") == "watchlist"
            and item.data.get("knowledge_entry_id")
        )
        for entry_id in matched_entry_ids:
            try:
                entry = self._get("knowledge_entries", entry_id)
            except KeyError:
                continue
            if entry.get("entryStatus") != "watchlist":
                continue
            if str(entry.get("sourceSubmissionId", "")) == submission_id:
                continue
            matched_submission_ids = _text_list(entry.get("matchedSubmissionIds"))
            if submission_id in matched_submission_ids:
                continue
            matched_submission_ids.append(submission_id)
            entry["matchedSubmissionIds"] = matched_submission_ids
            entry["contributionCount"] = int(entry.get("contributionCount", 0) or 0) + 1
            entry["lastMatchedSubmissionId"] = submission_id
            entry["lastMatchedAt"] = _now_label()
            self._put("knowledge_entries", entry_id, entry)

    def _store_manual_knowledge_image(
        self,
        entry_id: str,
        image_payload: Any,
    ) -> dict[str, Any] | None:
        if not image_payload:
            return None
        if not isinstance(image_payload, dict):
            raise ValueError("knowledge image must be an object")

        data = str(image_payload.get("data", ""))
        if not data:
            raise ValueError("knowledge image data required")
        if "," in data and data.split(",", 1)[0].startswith("data:"):
            data = data.split(",", 1)[1]
        try:
            content = base64.b64decode(data, validate=True)
        except Exception as exc:
            raise ValueError("knowledge image data must be base64") from exc
        if not content:
            raise ValueError("knowledge image is empty")

        filename = str(image_payload.get("filename", "reference")).strip() or "reference"
        suffix = _safe_image_suffix(filename, str(image_payload.get("content_type", "")))
        safe_stem = _safe_filename(Path(filename).stem) or "reference"
        target_name = f"{entry_id}-{safe_stem}{suffix}"
        self.knowledge_image_dir.mkdir(parents=True, exist_ok=True)
        root = self.knowledge_image_dir.resolve()
        target = (root / target_name).resolve()
        if target != root and root not in target.parents:
            raise ValueError("knowledge image path points outside image store")
        target.write_bytes(content)

        width, height = _image_size_from_bytes(content)
        fingerprints = FingerprintService().fingerprints_for(content)
        return {
            "asset": f"{self.knowledge_public_prefix}/{target_name}",
            "perceptualFingerprint": fingerprints.perceptual,
            "facts": {
                "filename": filename,
                "format": suffix.lstrip(".").upper(),
                "size": f"{width} x {height}",
                "fingerprints": 1,
            },
        }

    def _collection_candidates_from_evidence(
        self,
        query: str,
        evidence: list[Evidence],
        provider: str,
    ) -> list[dict[str, Any]]:
        candidates: list[dict[str, Any]] = []
        for item in evidence:
            if item.source not in {EvidenceSource.NAVER_SEARCH, EvidenceSource.WEB_DETECTION}:
                continue
            if item.data.get("image_url"):
                candidate = self._candidate_payload_from_evidence(
                    query,
                    item,
                    provider,
                    source_candidate_type="search_result_image",
                )
                if candidate is not None:
                    candidates.append(candidate)
                    continue
            candidate_count = len(candidates)
            for image_url in _unique_texts(item.data.get("page_image_urls", [])):
                candidate = self._candidate_payload_from_evidence(
                    query,
                    item,
                    provider,
                    image_url=image_url,
                    thumbnail_url=image_url,
                    source_candidate_type="provider_page_image",
                )
                if candidate is not None:
                    candidates.append(candidate)
            if len(candidates) > candidate_count:
                continue
            for image_url in _search_result_direct_image_urls(item):
                candidate = self._candidate_payload_from_evidence(
                    query,
                    item,
                    provider,
                    image_url=image_url,
                    thumbnail_url=image_url,
                    source_candidate_type="result_page_direct_image",
                )
                if candidate is not None:
                    candidates.append(candidate)
            if len(candidates) > candidate_count:
                continue
            for image_url, source_candidate_type in self._search_result_page_image_candidates(item):
                candidate = self._candidate_payload_from_evidence(
                    query,
                    item,
                    provider,
                    image_url=image_url,
                    thumbnail_url=image_url,
                    source_candidate_type=source_candidate_type,
                )
                if candidate is not None:
                    candidates.append(candidate)
                    break
        return candidates

    def _candidate_payload_from_evidence(
        self,
        query: str,
        evidence: Evidence,
        provider: str = "naver",
        image_url: str | None = None,
        thumbnail_url: str | None = None,
        source_candidate_type: str = "search_result_image",
    ) -> dict[str, Any] | None:
        image_url = _normalized_remote_image_url(
            str(image_url if image_url is not None else evidence.data.get("image_url", ""))
        )
        thumbnail_url = _normalized_remote_image_url(
            str(thumbnail_url if thumbnail_url is not None else evidence.data.get("thumbnail_url", ""))
        )
        result_url = str(evidence.data.get("result_url", ""))
        candidate_id = _stable_id("cand", provider, source_candidate_type, query, image_url, thumbnail_url, result_url)
        image_record = None
        stored_image_url = ""
        for candidate_url in _unique_texts([image_url, thumbnail_url]):
            image_record = self._store_candidate_image(
                candidate_id,
                candidate_url,
                referer_url=result_url,
            )
            if image_record is not None:
                stored_image_url = candidate_url
                break
        if image_record is None:
            return None
        display_image_url = stored_image_url or image_url
        return {
            "id": candidate_id,
            "provider": provider,
            "query": query,
            "title": _strip_html(str(evidence.data.get("title", ""))),
            "status": "candidate",
            "rank": evidence.data.get("rank", ""),
            "imageUrl": display_image_url,
            "thumbnailUrl": thumbnail_url,
            "resultUrl": result_url,
            "sourceUrl": result_url or display_image_url,
            "sourceCandidateType": source_candidate_type,
            "imageAsset": image_record["asset"],
            "sampleFingerprints": [image_record["perceptualFingerprint"]],
            "imageFacts": image_record["facts"],
            "collectedAt": _now_label(),
            "collectedEpoch": int(datetime.now().timestamp()),
            "promotedKnowledgeId": "",
        }

    def _store_candidate_image(
        self,
        candidate_id: str,
        url: str,
        referer_url: str = "",
    ) -> dict[str, Any] | None:
        if not url:
            return None
        suffix = _image_suffix_from_url(url)
        target_name = f"{candidate_id}{suffix}"
        root = self.collection_image_dir.resolve()
        target = (root / target_name).resolve()
        if target != root and root not in target.parents:
            raise ValueError("candidate image path points outside image store")
        if target.exists() and target.is_file():
            try:
                record = self._candidate_image_record_from_content(
                    target_name,
                    url,
                    suffix,
                    target.read_bytes(),
                )
            except Exception:
                record = None
            if record is not None:
                return record
        try:
            content = self._fetch_candidate_image_content(url, referer_url)
        except Exception:
            return None
        image_record = self._candidate_image_record_from_content(
            target_name,
            url,
            suffix,
            content,
        )
        if image_record is None:
            return None
        self.collection_image_dir.mkdir(parents=True, exist_ok=True)
        target.write_bytes(content)
        return image_record

    def _candidate_image_record_from_content(
        self,
        target_name: str,
        url: str,
        suffix: str,
        content: bytes,
    ) -> dict[str, Any] | None:
        if not content:
            return None
        width, height = _image_size_from_bytes(content)
        fingerprints = FingerprintService().fingerprints_for(content)
        if fingerprints.perceptual.startswith("phash:unavailable:"):
            return None
        return {
            "asset": f"{self.collection_public_prefix}/{target_name}",
            "perceptualFingerprint": fingerprints.perceptual,
            "facts": {
                "source": url,
                "format": suffix.lstrip(".").upper(),
                "size": f"{width} x {height}",
                "fingerprints": 1,
            },
        }

    def _fetch_candidate_image_content(self, url: str, referer_url: str = "") -> bytes:
        if self._custom_candidate_image_fetcher is not None:
            return self._custom_candidate_image_fetcher(url)
        return _fetch_url_bytes(url, referer_url=referer_url)

    def _rescore_submission(self, submission_id: str) -> None:
        submission = self._get("submissions", submission_id)
        evidence = [
            _domain_evidence_from_ui(item)
            for item in self._evidence_for_submission(submission_id)
        ]
        score = RiskScorer().score(evidence)
        submission["riskScore"] = score.score
        submission["riskBand"] = score.band
        submission["reasons"] = score.reasons or ["분석 근거 없음"]
        self._put("submissions", submission_id, submission)

    def _rescore_all_submissions(self, queue_id: str | None = None) -> None:
        for submission in self._all("submissions", queue_id=queue_id):
            self._rescore_submission(str(submission["id"]))

    def _count(self, table: str) -> int:
        _validate_table(table)
        with self._connect() as conn:
            return int(conn.execute(f"select count(*) from {table}").fetchone()[0])

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(self.db_path)
        conn.execute("pragma foreign_keys = on")
        # The server is a ThreadingHTTPServer: concurrent operator requests open
        # their own connection. Without a busy timeout the second writer raises
        # "database is locked" immediately; wait for the lock instead.
        conn.execute("pragma busy_timeout = 5000")
        conn.row_factory = sqlite3.Row
        return conn

    @contextmanager
    def _conn_ctx(self, conn: sqlite3.Connection | None = None):
        # When a caller passes an open connection (inside _transaction), reuse it
        # and let the caller own commit/rollback. Otherwise behave exactly like
        # `with self._connect() as conn:` — sqlite3 commits on success / rolls
        # back on error.
        if conn is not None:
            yield conn
            return
        with self._connect() as owned:
            yield owned

    @contextmanager
    def _transaction(self):
        # A single connection + single commit for multi-step writes that must be
        # atomic (e.g. a state change and its audit event), so a mid-sequence
        # failure leaves no partial state.
        conn = self._connect()
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    def _put(
        self,
        table: str,
        id_value: str,
        payload: dict[str, Any],
        *,
        conn: sqlite3.Connection | None = None,
    ) -> None:
        _validate_payload(table, id_value, payload)
        payload_json = json.dumps(payload, ensure_ascii=False)
        with self._conn_ctx(conn) as conn:
            if table == "submissions":
                conn.execute(
                    """
                    insert into submissions
                    (id, title, risk_score, risk_band, decision_status, submitted_epoch, queue_id, payload)
                    values (?, ?, ?, ?, ?, ?, ?, ?)
                    on conflict(id) do update set
                        title = excluded.title,
                        risk_score = excluded.risk_score,
                        risk_band = excluded.risk_band,
                        decision_status = excluded.decision_status,
                        submitted_epoch = excluded.submitted_epoch,
                        queue_id = excluded.queue_id,
                        payload = excluded.payload
                    """,
                    (
                        id_value,
                        str(payload.get("title", "")),
                        int(payload.get("riskScore", 0) or 0),
                        str(payload.get("riskBand", "low")),
                        str(payload.get("decisionStatus", "unreviewed")),
                        int(payload.get("submittedEpoch", 0) or 0),
                        str(payload.get("queueId", "")),
                        payload_json,
                    ),
                )
                return
            if table == "evidence":
                conn.execute(
                    """
                    insert into evidence
                    (id, submission_id, source, confidence, status, contributed, payload)
                    values (?, ?, ?, ?, ?, ?, ?)
                    on conflict(id) do update set
                        submission_id = excluded.submission_id,
                        source = excluded.source,
                        confidence = excluded.confidence,
                        status = excluded.status,
                        contributed = excluded.contributed,
                        payload = excluded.payload
                    """,
                    (
                        id_value,
                        str(payload["submission_id"]),
                        str(payload.get("source", "")),
                        # Clamp to the column's CHECK (confidence >= 0 and <= 1).
                        # Some sources (e.g. Google Vision web-entity relevance
                        # scores) are unbounded and would raise IntegrityError.
                        max(0.0, min(1.0, float(payload.get("confidence", 0) or 0))),
                        str(payload.get("status", "active")),
                        1 if bool(payload.get("contributed", True)) else 0,
                        payload_json,
                    ),
                )
                return
            if table == "providers":
                conn.execute(
                    """
                    insert into providers
                    (id, name, enabled, usage, quota, payload)
                    values (?, ?, ?, ?, ?, ?)
                    on conflict(id) do update set
                        name = excluded.name,
                        enabled = excluded.enabled,
                        usage = excluded.usage,
                        quota = excluded.quota,
                        payload = excluded.payload
                    """,
                    (
                        id_value,
                        str(payload.get("name", "")),
                        1 if bool(payload.get("enabled", False)) else 0,
                        int(payload.get("usage", 0) or 0),
                        int(payload.get("quota", 0) or 0),
                        payload_json,
                    ),
                )
                return
            if table == "knowledge_entries":
                conn.execute(
                    """
                    insert into knowledge_entries
                    (id, name, entry_type, entry_status, active, source_submission_id, payload)
                    values (?, ?, ?, ?, ?, ?, ?)
                    on conflict(id) do update set
                        name = excluded.name,
                        entry_type = excluded.entry_type,
                        entry_status = excluded.entry_status,
                        active = excluded.active,
                        source_submission_id = excluded.source_submission_id,
                        payload = excluded.payload
                    """,
                    (
                        id_value,
                        str(payload.get("name", "")),
                        str(payload.get("type", "other")),
                        str(payload.get("entryStatus", "confirmed")),
                        1 if bool(payload.get("active", True)) else 0,
                        str(payload.get("sourceSubmissionId", "")),
                        payload_json,
                    ),
                )
                return
            if table == "collection_candidates":
                conn.execute(
                    """
                    insert into collection_candidates
                    (id, provider, query, status, collected_epoch, payload)
                    values (?, ?, ?, ?, ?, ?)
                    on conflict(id) do update set
                        provider = excluded.provider,
                        query = excluded.query,
                        status = excluded.status,
                        collected_epoch = excluded.collected_epoch,
                        payload = excluded.payload
                    """,
                    (
                        id_value,
                        str(payload.get("provider", "")),
                        str(payload.get("query", "")),
                        str(payload.get("status", "candidate")),
                        int(payload.get("collectedEpoch", 0) or 0),
                        payload_json,
                    ),
                )
                return
            if table == "corrections":
                conn.execute(
                    """
                    insert into corrections
                    (id, decision_id, status, payload)
                    values (?, ?, ?, ?)
                    on conflict(id) do update set
                        decision_id = excluded.decision_id,
                        status = excluded.status,
                        payload = excluded.payload
                    """,
                    (
                        id_value,
                        str(payload.get("decisionId", payload.get("decision_id", ""))),
                        str(payload.get("status", "")),
                        payload_json,
                    ),
                )
                return
            conn.execute(
                f"insert or replace into {table} (id, payload) values (?, ?)",
                (id_value, payload_json),
            )

    def _get(
        self,
        table: str,
        id_value: str,
        *,
        conn: sqlite3.Connection | None = None,
    ) -> dict[str, Any]:
        _validate_table(table)
        with self._conn_ctx(conn) as conn:
            row = conn.execute(f"select payload from {table} where id = ?", (id_value,)).fetchone()
        if row is None:
            raise KeyError(id_value)
        return json.loads(row["payload"])

    def _apply_provider_usage_delta(
        self, provider_id: str, count: int, payload: dict[str, Any]
    ) -> None:
        # Re-read current usage under the write lock and apply the delta so
        # concurrent provider calls don't lose increments. The rest of `payload`
        # (lastSuccess / lastFailure) is last-writer-wins, which is acceptable for
        # status fields.
        with self._write_lock:
            current = self._get("providers", provider_id)
            payload["usage"] = int(current.get("usage", 0) or 0) + int(count)
            self._put("providers", provider_id, payload)

    def _clear_collection_candidates(self) -> None:
        with self._connect() as conn:
            conn.execute("delete from collection_candidates")

    def _all(self, table: str, queue_id: str | None = None) -> list[dict[str, Any]]:
        _validate_table(table)
        with self._connect() as conn:
            if table == "submissions" and queue_id is not None:
                rows = conn.execute(
                    "select payload from submissions where queue_id = ? order by id",
                    (queue_id,),
                ).fetchall()
            else:
                rows = conn.execute(f"select payload from {table} order by id").fetchall()
        values = [json.loads(row["payload"]) for row in rows]
        if table == "providers":
            order = {"internal": 0, "naver": 1, "google": 2, "google_search": 3, "llm": 4}
            values.sort(key=lambda item: order.get(item["id"], 99))
        if table == "collection_candidates":
            values.sort(key=lambda item: int(item.get("collectedEpoch", 0)), reverse=True)
        return values

    def _evidence_rows(self, queue_id: str | None = None):
        with self._connect() as conn:
            if queue_id is None:
                rows = conn.execute("select submission_id, payload from evidence order by id").fetchall()
            else:
                rows = conn.execute(
                    """
                    select e.submission_id, e.payload
                    from evidence e
                    join submissions s on s.id = e.submission_id
                    where s.queue_id = ?
                    order by e.id
                    """,
                    (queue_id,),
                ).fetchall()
        return rows

    def _evidence_for_submission(self, submission_id: str) -> list[dict[str, Any]]:
        # Read only this submission's evidence. _rescore_submission previously
        # called _evidence_by_submission(), which read and JSON-parsed the ENTIRE
        # evidence table on every single-submission rescore (a hot path inside the
        # seed / auto-search / similarity loops).
        with self._connect() as conn:
            rows = conn.execute(
                "select payload from evidence where submission_id = ? order by id",
                (submission_id,),
            ).fetchall()
        evidence: list[dict[str, Any]] = []
        for row in rows:
            payload = json.loads(row["payload"])
            payload.pop("submission_id", None)
            evidence.append(payload)
        return evidence

    def _evidence_by_submission(self, queue_id: str | None = None) -> dict[str, list[dict[str, Any]]]:
        rows = self._evidence_rows(queue_id=queue_id)
        grouped: dict[str, list[dict[str, Any]]] = {}
        for row in rows:
            payload = json.loads(row["payload"])
            payload.pop("submission_id", None)
            grouped.setdefault(row["submission_id"], []).append(payload)
        return grouped


def _validate_table(table: str) -> None:
    if table not in STORE_TABLES:
        raise ValueError(f"unsupported store table: {table}")


def _validate_payload(table: str, id_value: str, payload: dict[str, Any]) -> None:
    _validate_table(table)
    if not isinstance(payload, dict):
        raise ValueError(f"{table} payload must be an object")

    required = PAYLOAD_REQUIRED_FIELDS.get(table, set())
    missing = sorted(field for field in required if field not in payload)
    if missing:
        raise ValueError(f"{table} payload missing required fields: {', '.join(missing)}")

    if table != "audit_events" and "id" in payload and str(payload["id"]) != str(id_value):
        raise ValueError(f"{table} payload id does not match storage id")


def _existing_naver_query_signatures(evidence: list[dict[str, Any]]) -> set[str]:
    return {
        str(item.get("querySignature") or _naver_query_signature(str(item.get("query", ""))))
        for item in evidence
        if item.get("query") and item.get("source") in {"naver", "failure"}
    }


def _existing_google_custom_query_signatures(evidence: list[dict[str, Any]]) -> set[str]:
    return {
        str(item.get("querySignature") or "")
        for item in evidence
        if item.get("query")
        and item.get("source") in {"google", "failure"}
        and str(item.get("domain", "")) == "google_custom_search"
        and item.get("querySignature")
    }


def _google_custom_image_query_signature(query: str) -> str:
    return "google-custom-image:" + " ".join(query.lower().split())


def _google_custom_web_query_signature(query: str) -> str:
    return "google-custom-web:" + " ".join(query.lower().split())


def _naver_query_signature(query: str) -> str:
    return "naver:" + " ".join(query.lower().split())


def _naver_blog_query_signature(query: str) -> str:
    return "naver-blog:" + " ".join(query.lower().split())


def _naver_web_query_signature(query: str) -> str:
    return "naver-web:" + " ".join(query.lower().split())


def _submission_search_hint_evidence(record: dict[str, Any]) -> list[Evidence]:
    hints: list[Evidence] = []
    title = str(record.get("title", "")).strip()
    if title:
        hints.append(_local_query_hint_evidence(title, "title"))

    file_value = str(record.get("file", record.get("asset", ""))).strip()
    file_stem = Path(urlparse(file_value).path).stem if file_value else ""
    if file_stem and file_stem != title:
        hints.append(_local_query_hint_evidence(file_stem, "file"))
    return hints


def _local_query_hint_evidence(query: str, hint_source: str) -> Evidence:
    return Evidence(
        source=EvidenceSource.FINGERPRINT,
        reason="Local submission search hint",
        confidence=0.0,
        data={
            "local_query_hint": True,
            "query": query,
            "hint_source": hint_source,
        },
    )


def _query_history_status(evidence: list[Evidence]) -> str:
    if any(item.source == EvidenceSource.ENRICHMENT_FAILURE for item in evidence):
        return "failed"
    if evidence and all(item.source == EvidenceSource.SEARCH_SKIPPED for item in evidence):
        return "skipped"
    return "auto"


def _text_list(value: Any) -> list[str]:
    if value is None:
        return []
    if isinstance(value, list):
        return [str(item).strip() for item in value if str(item).strip()]
    return [item.strip() for item in str(value).split(",") if item.strip()]


def _unique_texts(values: Any) -> list[str]:
    seen: set[str] = set()
    result: list[str] = []
    for value in values:
        text = str(value).strip()
        if not text or text in seen:
            continue
        seen.add(text)
        result.append(text)
    return result


def _default_evidence_contribution(payload: dict[str, Any]) -> bool:
    source = str(payload.get("source", ""))
    if source in {"llm", "failure"}:
        return False
    if bool(payload.get("faceCropSearch", False)):
        return False
    if _is_google_weak_label_payload(payload):
        return False
    return True


def _watchlist_source_evidence(evidence: list[dict[str, Any]]) -> list[dict[str, Any]]:
    used = [
        item
        for item in evidence
        if item.get("operatorStatus") == "used_for_judgment"
        and item.get("id")
    ]
    if used:
        return _sorted_evidence_for_watchlist(used)[:5]

    candidates = [
        item
        for item in evidence
        if item.get("id")
        and item.get("operatorStatus") not in NON_CONTRIBUTING_OPERATOR_STATUSES
        and item.get("source") not in {"llm", "failure"}
        and item.get("title") != "Image fingerprints generated"
    ]
    if candidates:
        return _sorted_evidence_for_watchlist(candidates)[:5]

    fallback = [
        item
        for item in evidence
        if item.get("id")
        and item.get("operatorStatus") not in NON_CONTRIBUTING_OPERATOR_STATUSES
    ]
    return _sorted_evidence_for_watchlist(fallback)[:5]


def _sorted_evidence_for_watchlist(evidence: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return sorted(
        evidence,
        key=lambda item: (
            1 if item.get("contributed", True) else 0,
            float(item.get("confidence", 0) or 0),
            str(item.get("retrievedAt", "")),
        ),
        reverse=True,
    )


def _knowledge_type_value(value: str) -> str:
    normalized = value.strip() or "other"
    aliases = {
        "public_figure": "celebrity",
        "rejected_reference": "rejected_image",
    }
    return aliases.get(normalized, normalized)


def _knowledge_entry_type(value: str) -> KnowledgeEntryType:
    try:
        return KnowledgeEntryType(_knowledge_type_value(value))
    except ValueError:
        return KnowledgeEntryType.OTHER


def _knowledge_provenance(value: str) -> KnowledgeProvenance:
    if value == "automatic":
        return KnowledgeProvenance.AUTOMATIC_REJECTION
    try:
        return KnowledgeProvenance(value)
    except ValueError:
        return KnowledgeProvenance.EXTERNAL_EVIDENCE


def _safe_image_suffix(filename: str, content_type: str) -> str:
    suffix = Path(filename).suffix.lower()
    if not suffix and content_type:
        suffix = mimetypes.guess_extension(content_type.split(";", 1)[0].strip()) or ""
    if suffix == ".jpe":
        suffix = ".jpg"
    if suffix not in SUPPORTED_IMAGE_SUFFIXES:
        raise ValueError("unsupported knowledge image type")
    return suffix


def _safe_filename(value: str) -> str:
    return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip(".-")


def _image_size_from_bytes(content: bytes) -> tuple[int, int]:
    try:
        from PIL import Image

        with Image.open(BytesIO(content)) as image:
            return int(image.width), int(image.height)
    except Exception:
        return 1, 1


def _stable_id(prefix: str, *parts: str) -> str:
    digest = hashlib.sha1("\x1f".join(parts).encode("utf-8")).hexdigest()[:20]
    return f"{prefix}-{digest}"


def _strip_html(value: str) -> str:
    without_tags = re.sub(r"<[^>]+>", "", value)
    return " ".join(without_tags.split())


def _image_suffix_from_url(url: str) -> str:
    suffix = Path(urlparse(url).path).suffix.lower()
    if suffix == ".jpe":
        suffix = ".jpg"
    if suffix not in SUPPORTED_IMAGE_SUFFIXES:
        return ".jpg"
    return suffix


CANDIDATE_IMAGE_FETCH_LIMIT = 8_000_000
CANDIDATE_IMAGE_FETCH_HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/124.0 Safari/537.36 CopyrighterCandidateCollector/0.1"
    ),
    "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
    "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
    "Sec-Fetch-Dest": "image",
    "Sec-Fetch-Mode": "no-cors",
    "Sec-Fetch-Site": "cross-site",
}
PAGE_FETCH_HEADERS = {
    "User-Agent": CANDIDATE_IMAGE_FETCH_HEADERS["User-Agent"],
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/*,*/*;q=0.8",
    "Accept-Language": CANDIDATE_IMAGE_FETCH_HEADERS["Accept-Language"],
    "Sec-Fetch-Dest": "document",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-Site": "cross-site",
}
STYLESHEET_FETCH_HEADERS = {
    "User-Agent": CANDIDATE_IMAGE_FETCH_HEADERS["User-Agent"],
    "Accept": "text/css,*/*;q=0.8",
    "Accept-Language": CANDIDATE_IMAGE_FETCH_HEADERS["Accept-Language"],
    "Sec-Fetch-Dest": "style",
    "Sec-Fetch-Mode": "no-cors",
    "Sec-Fetch-Site": "cross-site",
}


def _fetch_url_bytes(
    url: str,
    timeout: int = 10,
    limit: int = CANDIDATE_IMAGE_FETCH_LIMIT,
    referer_url: str = "",
) -> bytes:
    headers = _headers_with_referer(CANDIDATE_IMAGE_FETCH_HEADERS, referer_url)
    return _fetch_url_bytes_with_headers(url, headers, timeout, limit)


def _fetch_page_url_bytes(url: str, timeout: int = 10, limit: int = CANDIDATE_IMAGE_FETCH_LIMIT) -> bytes:
    return _fetch_url_bytes_with_headers(url, PAGE_FETCH_HEADERS, timeout, limit)


def _fetch_stylesheet_url_bytes(url: str, timeout: int = 10, limit: int = CANDIDATE_IMAGE_FETCH_LIMIT) -> bytes:
    return _fetch_url_bytes_with_headers(url, STYLESHEET_FETCH_HEADERS, timeout, limit)


def _assert_public_http_url(url: str) -> None:
    # SSRF guard: candidate image / page / stylesheet URLs come from external
    # search-result content (attacker-influenceable), so refuse to fetch
    # anything that resolves to a non-public address (loopback, RFC1918,
    # link-local cloud-metadata 169.254.169.254, etc.).
    parsed = urlparse(url)
    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        raise ValueError("only public http(s) URLs may be fetched")
    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    try:
        infos = socket.getaddrinfo(parsed.hostname, port, proto=socket.IPPROTO_TCP)
    except OSError as exc:
        raise ValueError("could not resolve fetch host") from exc
    for *_, sockaddr in infos:
        ip = ipaddress.ip_address(sockaddr[0])
        if (
            ip.is_private
            or ip.is_loopback
            or ip.is_link_local
            or ip.is_reserved
            or ip.is_multicast
            or ip.is_unspecified
        ):
            raise ValueError("refusing to fetch internal address")


class _ValidatingRedirectHandler(HTTPRedirectHandler):
    # Re-run the SSRF guard on every redirect hop so a public URL cannot bounce
    # to an internal address.
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        _assert_public_http_url(newurl)
        return super().redirect_request(req, fp, code, msg, headers, newurl)


_SSRF_SAFE_OPENER = build_opener(_ValidatingRedirectHandler())


def _open_url(request: Request, timeout: int):
    return _SSRF_SAFE_OPENER.open(request, timeout=timeout)


def _fetch_url_bytes_with_headers(
    url: str,
    headers: dict[str, str],
    timeout: int,
    limit: int,
) -> bytes:
    _assert_public_http_url(url)
    request = Request(url, headers=headers)
    with _open_url(request, timeout) as response:
        data = response.read(limit + 1)
    if len(data) > limit:
        raise ValueError("candidate image exceeds size limit")
    return data


def _headers_with_referer(headers: dict[str, str], referer_url: str) -> dict[str, str]:
    if not referer_url or not _is_http_url(referer_url):
        return headers
    return {**headers, "Referer": referer_url}


class _PageImageParser(HTMLParser):
    def __init__(self, parse_noscript: bool = True) -> None:
        super().__init__()
        self.parse_noscript = parse_noscript
        self.priority_urls: list[str] = []
        self.image_urls: list[str] = []
        self.stylesheet_urls: list[str] = []
        self._script_chunks: list[str] = []
        self._collecting_script_type = ""
        self._style_chunks: list[str] = []
        self._collecting_style = False
        self._noscript_chunks: list[str] = []
        self._collecting_noscript = False

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attr = {name.lower(): (value or "") for name, value in attrs}
        tag_name = tag.lower()
        if tag_name == "noscript" and self.parse_noscript:
            self._collecting_noscript = True
            self._noscript_chunks = []
            return
        if tag_name == "style":
            self._collecting_style = True
            self._style_chunks = []
            return
        if tag_name == "script":
            script_type = attr.get("type", "").lower()
            if "ld+json" in script_type:
                self._collecting_script_type = "json_ld"
                self._script_chunks = []
            elif script_type in {"application/json", "application/problem+json", "application/activity+json"}:
                self._collecting_script_type = "json"
                self._script_chunks = []
            elif script_type in {"", "text/javascript", "application/javascript", "module"}:
                self._collecting_script_type = "javascript"
                self._script_chunks = []
            return
        if attr.get("style"):
            self.image_urls.extend(_css_url_image_urls(attr["style"]))
        if attr.get("poster") and _looks_like_image_reference(attr["poster"]):
            self.priority_urls.append(attr["poster"])
        for name in (
            "data-bg",
            "data-bg-url",
            "data-background",
            "data-background-image",
            "data-background-url",
            "data-lazy-background",
            "data-image",
            "data-image-src",
            "data-image-url",
            "data-img",
            "data-img-src",
            "data-img-url",
            "data-src",
            "data-src-large",
            "data-fallback-src",
            "data-full",
            "data-full-src",
            "data-full-image",
            "data-full-url",
            "data-hires",
            "data-highres",
            "data-large",
            "data-large-src",
            "data-large-image",
            "data-large-url",
            "data-original",
            "data-original-image",
            "data-lazy-src",
            "data-original-src",
            "data-original-url",
            "data-photo",
            "data-photo-url",
            "data-thumb",
            "data-thumb-url",
            "data-thumbnail",
            "data-thumbnail-url",
            "data-zoom-image",
        ):
            if attr.get(name):
                self.image_urls.extend(
                    _data_attribute_image_urls(name, attr[name], known_image_attr=True)
                )
        for name, value in attr.items():
            self.image_urls.extend(_data_attribute_image_urls(name, value))
        if tag_name in {"a", "area"} and attr.get("href") and _looks_like_image_reference(attr["href"]):
            self.image_urls.append(attr["href"])
        if tag_name == "meta":
            key = (attr.get("property") or attr.get("name") or attr.get("itemprop") or "").lower()
            if key in {
                "image",
                "contenturl",
                "thumbnail",
                "thumbnailurl",
                "og:image",
                "og:image:url",
                "og:image:secure_url",
                "twitter:image",
                "twitter:image:src",
                "twitter:image:url",
            }:
                self.priority_urls.append(attr.get("content", ""))
            return
        if tag_name == "link":
            rel = attr.get("rel", "").lower()
            rel_key = rel.replace("-", "_")
            as_value = attr.get("as", "").lower()
            if "stylesheet" in rel and attr.get("href"):
                self.stylesheet_urls.append(attr["href"])
            if "image_src" in rel_key or (as_value == "image" and any(token in rel for token in ("preload", "prefetch"))):
                if attr.get("href"):
                    self.priority_urls.append(attr["href"])
                for name in ("imagesrcset", "image-srcset"):
                    if attr.get(name):
                        self.priority_urls.extend(_srcset_image_urls(attr[name]))
            return
        if tag_name == "source":
            for name in ("srcset", "data-original-srcset", "data-lazy-srcset", "data-srcset"):
                if attr.get(name):
                    self.priority_urls.extend(_srcset_image_urls(attr[name]))
            return
        if tag_name in {"img", "amp-img", "amp-anim"}:
            target_urls = self.priority_urls if _is_likely_primary_image_attrs(attr) else self.image_urls
            for name in ("data-original-srcset", "data-lazy-srcset", "data-srcset", "srcset"):
                if attr.get(name):
                    target_urls.extend(_srcset_image_urls(attr[name]))
            for name in ("data-original", "data-original-src", "data-lazy-src", "data-src", "src"):
                if attr.get(name):
                    target_urls.append(attr[name])
                    return

    def handle_data(self, data: str) -> None:
        if self._collecting_script_type:
            self._script_chunks.append(data)
        if self._collecting_style:
            self._style_chunks.append(data)
        if self._collecting_noscript:
            self._noscript_chunks.append(data)

    def handle_endtag(self, tag: str) -> None:
        tag_name = tag.lower()
        if tag_name == "noscript" and self._collecting_noscript:
            self._collecting_noscript = False
            parser = _PageImageParser(parse_noscript=False)
            parser.feed(html.unescape("".join(self._noscript_chunks)))
            self.priority_urls.extend(parser.priority_urls)
            self.image_urls.extend(parser.image_urls)
            self.stylesheet_urls.extend(parser.stylesheet_urls)
            self._noscript_chunks = []
            return
        if tag_name == "style" and self._collecting_style:
            self._collecting_style = False
            self.image_urls.extend(_css_url_image_urls("".join(self._style_chunks)))
            self._style_chunks = []
            return
        if tag_name != "script" or not self._collecting_script_type:
            return
        script_type = self._collecting_script_type
        self._collecting_script_type = ""
        script_content = "".join(self._script_chunks)
        self._script_chunks = []
        if script_type == "json_ld":
            self.priority_urls.extend(_json_ld_image_urls(script_content))
        elif script_type == "json":
            self.priority_urls.extend(_json_script_image_urls(script_content))
        elif script_type == "javascript":
            self.priority_urls.extend(_javascript_image_urls(script_content))


def _srcset_image_urls(value: str) -> list[str]:
    candidates: list[tuple[float, int, str]] = []
    for order, raw_candidate in enumerate(_split_srcset_candidates(str(value))):
        candidate = raw_candidate.strip()
        if not candidate:
            continue
        parts = candidate.split()
        url = parts[0].strip()
        if not url:
            continue
        score = _srcset_descriptor_score(parts[1] if len(parts) > 1 else "")
        candidates.append((score, order, url))
    return [
        url
        for _, _, url in sorted(
            candidates,
            key=lambda item: (-item[0], item[1]),
        )
    ]


def _split_srcset_candidates(value: str) -> list[str]:
    candidates: list[str] = []
    start = 0
    for index, character in enumerate(value):
        if character != ",":
            continue
        remainder = value[index + 1 :].lstrip()
        if not _starts_srcset_candidate(remainder):
            continue
        candidates.append(value[start:index])
        start = index + 1
    candidates.append(value[start:])
    return candidates


def _starts_srcset_candidate(value: str) -> bool:
    text = str(value).strip()
    if not text:
        return False
    first_token = text.split(None, 1)[0]
    return _is_urlish_reference(first_token) or _is_scheme_less_remote_image_url(first_token)


def _srcset_descriptor_score(value: str) -> float:
    descriptor = value.strip().lower()
    if descriptor.endswith("w"):
        try:
            return float(descriptor[:-1])
        except ValueError:
            return 0.0
    if descriptor.endswith("x"):
        try:
            return float(descriptor[:-1]) * 1000
        except ValueError:
            return 0.0
    return 0.0


def _is_generic_data_image_attr(name: str, value: str) -> bool:
    return bool(_data_attribute_image_urls(name, value))


def _data_attribute_image_urls(
    name: str,
    value: str,
    *,
    known_image_attr: bool = False,
) -> list[str]:
    attr_name = str(name).lower().replace("-", "_").replace(":", "_")
    text = html.unescape(str(value).strip())
    if not attr_name.startswith("data_") or not text or text.lower().startswith("data:"):
        return []
    if _is_srcset_attr_name(attr_name):
        return _srcset_image_urls(text)
    if _looks_like_image_reference(text):
        return [text]
    if _is_urlish_reference(text):
        return [text]
    image_named_attr = _is_image_data_attr_name(attr_name)
    if not known_image_attr and not image_named_attr and not _looks_like_json_attribute_text(text):
        return []
    return _json_attribute_image_urls(
        text,
        allow_plain_url_keys=known_image_attr or image_named_attr,
    )


def _is_image_data_attr_name(attr_name: str) -> bool:
    image_tokens = (
        "avatar",
        "background",
        "bg",
        "image",
        "img",
        "photo",
        "picture",
        "poster",
        "thumb",
        "thumbnail",
    )
    return any(token in attr_name for token in image_tokens)


def _json_attribute_image_urls(
    value: str,
    *,
    allow_plain_url_keys: bool = True,
) -> list[str]:
    text = _json_attribute_text(value)
    if not text:
        return []
    try:
        document = json.loads(text)
    except Exception:
        return []

    urls: list[str] = []

    def collect_likely(value: Any) -> None:
        if isinstance(value, str):
            urls.extend(_json_image_string_candidates(value))
            return
        if isinstance(value, list):
            for item in value:
                collect_likely(item)
            return
        if isinstance(value, dict):
            for key, child in value.items():
                if _is_srcset_key(str(key)):
                    urls.extend(_srcset_image_urls(str(child)))
                    continue
                if _is_json_url_value_key(str(key)) or _is_likely_json_image_key(str(key)):
                    collect_likely(child)
                elif isinstance(child, (dict, list)):
                    collect_likely(child)

    def collect_obvious(value: Any) -> None:
        if isinstance(value, str):
            return
        if isinstance(value, list):
            for item in value:
                collect_obvious(item)
            return
        if isinstance(value, dict):
            for key, child in value.items():
                if _is_srcset_key(str(key)):
                    urls.extend(_srcset_image_urls(str(child)))
                    continue
                if _is_likely_json_image_key(str(key)):
                    collect_likely(child)
                else:
                    collect_obvious(child)

    if allow_plain_url_keys:
        collect_likely(document)
    else:
        collect_obvious(document)
    return _unique_texts(urls)


def _looks_like_json_attribute_text(value: str) -> bool:
    return bool(_json_attribute_text(value))


def _json_attribute_text(value: str) -> str:
    text = html.unescape(str(value).strip())
    if text.startswith(("{", "[")):
        return text
    decoded = _decoded_nested_url(text)
    if decoded and decoded.startswith(("{", "[")):
        return decoded
    return ""


def _is_srcset_attr_name(name: str) -> bool:
    normalized = str(name).lower().replace("-", "_").replace(":", "_")
    return "srcset" in normalized or "src_set" in normalized


def _is_urlish_reference(value: str) -> bool:
    text = str(value).strip()
    return (
        _is_http_url(text)
        or text.startswith(("/", "//", "./", "../"))
        or _url_looks_like_image(text)
    )


def _json_ld_image_urls(script_content: str) -> list[str]:
    try:
        document = json.loads(script_content)
    except Exception:
        return []

    urls: list[str] = []

    def collect_image_value(value: Any) -> None:
        if isinstance(value, str):
            urls.append(value)
            return
        if isinstance(value, list):
            for item in value:
                collect_image_value(item)
            return
        if isinstance(value, dict):
            for key in ("contentUrl", "url", "thumbnailUrl"):
                if key in value:
                    collect_image_value(value[key])

    def visit(value: Any) -> None:
        if isinstance(value, list):
            for item in value:
                visit(item)
            return
        if not isinstance(value, dict):
            return
        for key, child in value.items():
            if str(key).lower() in {
                "image",
                "thumbnail",
                "thumbnailurl",
                "contenturl",
                "primaryimageofpage",
                "associatedmedia",
            }:
                collect_image_value(child)
            else:
                visit(child)

    visit(document)
    return urls


def _json_script_image_urls(script_content: str) -> list[str]:
    try:
        document = json.loads(script_content)
    except Exception:
        return []

    urls: list[str] = []

    def collect(value: Any) -> None:
        if isinstance(value, str):
            urls.extend(_json_image_string_candidates(value))
            return
        if isinstance(value, list):
            for item in value:
                collect(item)
            return
        if isinstance(value, dict):
            for key, child in value.items():
                if _is_srcset_key(str(key)):
                    urls.extend(_srcset_image_urls(str(child)))
                    continue
                if _is_json_url_value_key(str(key)) or _is_likely_json_image_key(str(key)):
                    collect(child)
                elif isinstance(child, (dict, list)):
                    collect(child)
            return

    def visit(value: Any) -> None:
        if isinstance(value, list):
            for item in value:
                visit(item)
            return
        if not isinstance(value, dict):
            return
        for key, child in value.items():
            if _is_srcset_key(str(key)):
                urls.extend(_srcset_image_urls(str(child)))
            elif _is_likely_json_image_key(str(key)):
                collect(child)
            else:
                visit(child)

    visit(document)
    return urls


def _is_likely_json_image_key(key: str) -> bool:
    normalized = key.lower().replace("-", "_")
    return (
        "image" in normalized
        or "thumbnail" in normalized
        or _is_srcset_key(normalized)
        or normalized in {
            "asset_url",
            "content_url",
            "contenturl",
            "media_url",
            "mediaurl",
            "photo",
            "photo_url",
            "poster",
            "poster_url",
            "avatar",
            "avatar_url",
        }
    )


def _is_json_url_value_key(key: str) -> bool:
    normalized = re.sub(r"(?<!^)(?=[A-Z])", "_", str(key)).lower().replace("-", "_")
    normalized = normalized.replace("__", "_")
    return normalized in {
        "content_url",
        "contenturl",
        "download_url",
        "file_url",
        "href",
        "original_url",
        "public_url",
        "secure_url",
        "src",
        "thumbnail_url",
        "thumbnailurl",
        "url",
    }


def _json_image_string_candidates(value: str) -> list[str]:
    raw = str(value).strip()
    decoded = _decoded_nested_url(raw)
    if decoded != raw and (_looks_like_image_reference(decoded) or _is_urlish_reference(decoded)):
        return [decoded]
    if _looks_like_image_reference(raw) or _is_urlish_reference(raw):
        return [raw]
    return []


def _is_srcset_key(key: str) -> bool:
    normalized = str(key).lower().replace("-", "_")
    return normalized in {
        "image_src_set",
        "image_srcset",
        "imagesrcset",
        "photo_src_set",
        "photo_srcset",
        "picture_src_set",
        "picture_srcset",
        "src_set",
        "srcset",
        "thumbnail_src_set",
        "thumbnail_srcset",
    }


def _javascript_image_urls(script_content: str) -> list[str]:
    urls: list[str] = []
    image_key = r"[\w$:-]*(?:image|thumbnail|photo|avatar|poster|picture)[\w$:-]*"
    srcset_key = r"[\w$:-]*(?:srcset|src-set)[\w$:-]*"
    srcset_pattern = re.compile(
        rf"""(?is)["']?({srcset_key})["']?\s*[:=]\s*["']([^"']+)["']"""
    )
    key_value_pattern = re.compile(
        rf"""(?is)["']?({image_key})["']?\s*[:=]\s*["']([^"']+)["']"""
    )
    nested_value_pattern = re.compile(
        rf"""(?is)["']?({image_key})["']?\s*[:=]\s*\{{[^{{}}]{{0,500}}?["'](?:url|src|contentUrl|thumbnailUrl)["']\s*:\s*["']([^"']+)["']"""
    )
    for _key, value in srcset_pattern.findall(script_content):
        urls.extend(_srcset_image_urls(_decode_javascript_string(value)))
    for pattern in (key_value_pattern, nested_value_pattern):
        for _key, value in pattern.findall(script_content):
            candidate = _decode_javascript_string(value)
            if _looks_like_image_reference(candidate):
                urls.append(candidate)
    return _unique_texts(urls)


def _decode_javascript_string(value: str) -> str:
    text = value.replace("\\/", "/")
    if "\\u" not in text and "\\x" not in text:
        return text

    def _replace_escape(match: re.Match[str]) -> str:
        try:
            return chr(int(match.group(1) or match.group(2), 16))
        except (TypeError, ValueError):
            return match.group(0)

    # Decode only explicit \uXXXX / \xXX escapes. The previous
    # bytes(text, "utf-8").decode("unicode_escape") reinterpreted real UTF-8
    # bytes as Latin-1, silently corrupting literal non-ASCII (e.g. Korean) URLs.
    return re.sub(r"\\u([0-9a-fA-F]{4})|\\x([0-9a-fA-F]{2})", _replace_escape, text)


def _looks_like_image_reference(value: str) -> bool:
    text = value.strip()
    if not text or text.lower().startswith("data:"):
        return False
    if _unwrapped_image_url(text):
        return True
    if _relative_wrapped_image_url(text):
        return True
    return _url_looks_like_image(text)


def _relative_wrapped_image_url(value: str) -> str:
    parsed = urlparse(value)
    if parsed.scheme or parsed.netloc:
        return ""
    for key, raw_value in parse_qsl(parsed.query, keep_blank_values=False):
        key_text = key.lower().replace("-", "_")
        if key_text not in {
            "imgurl",
            "imageurl",
            "image_url",
            "mediaurl",
            "media_url",
            "contenturl",
            "content_url",
            "photo",
            "photo_url",
            "src",
            "source",
            "image",
            "img",
            "url",
            "u",
        }:
            continue
        candidate = _decoded_nested_url(raw_value)
        if candidate.startswith("/") or _url_looks_like_image(candidate):
            return candidate
    return ""


def _is_likely_primary_image_attrs(attr: dict[str, str]) -> bool:
    text = " ".join(
        str(attr.get(name, ""))
        for name in (
            "alt",
            "aria-label",
            "class",
            "data-image-type",
            "data-role",
            "id",
            "itemprop",
            "src",
            "data-src",
            "data-original",
            "data-lazy-src",
            "data-original-src",
        )
    ).casefold()
    negative_tokens = (
        "advert",
        "avatar",
        "badge",
        "banner",
        "button",
        "emoji",
        "favicon",
        "icon",
        "logo",
        "sprite",
        "tracking",
    )
    if any(token in text for token in negative_tokens):
        return False

    positive_tokens = (
        "article",
        "cover",
        "full",
        "hero",
        "main",
        "official",
        "photo",
        "picture",
        "portrait",
        "primary",
        "profile",
        "representative",
        "thumbnail",
    )
    if any(token in text for token in positive_tokens):
        return True

    width = _numeric_attr(attr.get("width", ""))
    height = _numeric_attr(attr.get("height", ""))
    loading = attr.get("loading", "").casefold()
    fetchpriority = attr.get("fetchpriority", "").casefold()
    return (
        width >= 300
        and height >= 300
        and (fetchpriority == "high" or loading != "lazy")
    )


def _numeric_attr(value: str) -> int:
    match = re.search(r"\d+", str(value))
    if not match:
        return 0
    try:
        return int(match.group(0))
    except ValueError:
        return 0


def _css_url_image_urls(style: str) -> list[str]:
    direct_urls = [
        match.group(2).strip()
        for match in re.finditer(r"url\(\s*(['\"]?)(.*?)\1\s*\)", style, flags=re.IGNORECASE)
        if match.group(2).strip()
    ]
    return _unique_texts([*_css_image_set_urls(style), *direct_urls])


def _css_image_set_urls(style: str) -> list[str]:
    candidates: list[tuple[float, int, str]] = []
    order = 0
    for body in _css_image_set_bodies(style):
        for raw_candidate in _split_top_level_commas(body):
            url, descriptor = _css_image_set_candidate(raw_candidate)
            if not url:
                continue
            candidates.append((_css_image_set_descriptor_score(descriptor), order, url))
            order += 1
    return [
        url
        for _, _, url in sorted(
            candidates,
            key=lambda item: (-item[0], item[1]),
        )
    ]


def _css_image_set_bodies(style: str) -> list[str]:
    bodies: list[str] = []
    for match in re.finditer(r"(?:-webkit-)?image-set\s*\(", style, flags=re.IGNORECASE):
        start = match.end()
        depth = 1
        quote = ""
        escaped = False
        for index in range(start, len(style)):
            character = style[index]
            if quote:
                if escaped:
                    escaped = False
                elif character == "\\":
                    escaped = True
                elif character == quote:
                    quote = ""
                continue
            if character in {"'", '"'}:
                quote = character
                continue
            if character == "(":
                depth += 1
                continue
            if character == ")":
                depth -= 1
                if depth == 0:
                    bodies.append(style[start:index])
                    break
    return bodies


def _split_top_level_commas(value: str) -> list[str]:
    parts: list[str] = []
    start = 0
    depth = 0
    quote = ""
    escaped = False
    for index, character in enumerate(value):
        if quote:
            if escaped:
                escaped = False
            elif character == "\\":
                escaped = True
            elif character == quote:
                quote = ""
            continue
        if character in {"'", '"'}:
            quote = character
            continue
        if character == "(":
            depth += 1
            continue
        if character == ")":
            depth = max(0, depth - 1)
            continue
        if character == "," and depth == 0:
            parts.append(value[start:index].strip())
            start = index + 1
    parts.append(value[start:].strip())
    return [part for part in parts if part]


def _css_image_set_candidate(value: str) -> tuple[str, str]:
    url_match = re.search(r"url\(\s*(['\"]?)(.*?)\1\s*\)", value, flags=re.IGNORECASE)
    if url_match:
        return url_match.group(2).strip(), value[url_match.end() :]

    quoted_match = re.search(r"""(['"])(.*?)\1""", value)
    if quoted_match:
        return quoted_match.group(2).strip(), value[quoted_match.end() :]

    parts = value.split(None, 1)
    if parts and _looks_like_image_reference(parts[0]):
        return parts[0].strip(), parts[1] if len(parts) > 1 else ""
    return "", ""


def _css_image_set_descriptor_score(value: str) -> float:
    descriptor = value.strip().lower()
    match = re.search(r"([0-9]*\.?[0-9]+)\s*(dppx|dpi|x|w)\b", descriptor)
    if not match:
        return 0.0
    number = float(match.group(1))
    unit = match.group(2)
    if unit == "w":
        return number
    if unit == "dpi":
        return (number / 96) * 1000
    return number * 1000


def _extract_page_image_urls(content: bytes, base_url: str, limit: int) -> list[str]:
    if limit <= 0:
        return []
    return [
        url
        for url in _page_image_references(content, base_url)[0]
        if _is_http_url(url)
    ][:limit]


def _extract_page_stylesheet_urls(content: bytes, base_url: str, limit: int) -> list[str]:
    if limit <= 0:
        return []
    return [
        url
        for url in _page_image_references(content, base_url)[1]
        if _is_http_url(url)
    ][:limit]


def _extract_css_image_urls(content: bytes, base_url: str, limit: int) -> list[str]:
    if limit <= 0:
        return []
    return [
        url
        for url in _unique_texts(
            _normalized_image_url(base_url, url)
            for url in _css_url_image_urls(content.decode("utf-8", errors="replace"))
        )
        if _is_http_url(url)
    ][:limit]


def _page_image_references(content: bytes, base_url: str) -> tuple[list[str], list[str]]:
    parser = _PageImageParser()
    parser.feed(content.decode("utf-8", errors="replace"))
    image_urls = [
        url
        for url in _unique_texts(
            _normalized_image_url(base_url, url)
            for url in [*parser.priority_urls, *parser.image_urls]
        )
        if _is_http_url(url)
    ]
    stylesheet_urls = [
        url
        for url in _unique_texts(
            _normalized_image_url(base_url, url)
            for url in parser.stylesheet_urls
        )
        if _is_http_url(url)
    ]
    return image_urls, stylesheet_urls


def _content_has_comparable_image_fingerprint(content: bytes) -> bool:
    try:
        fingerprint = FingerprintService().fingerprints_for(content).perceptual
    except Exception:
        return False
    return not fingerprint.startswith("phash:unavailable:")


def _search_result_direct_image_urls(source_evidence: Evidence) -> list[str]:
    result_url = str(
        source_evidence.data.get("result_url", source_evidence.data.get("url", ""))
    )
    unwrapped_url = _unwrapped_image_url(result_url)
    if unwrapped_url:
        return [unwrapped_url]
    if _is_http_url(result_url) and _url_looks_like_image(result_url):
        return [result_url]
    return []


def _normalized_image_url(base_url: str, url: str) -> str:
    text = _decoded_url_reference(str(url).strip())
    if not text or text.lower().startswith("data:"):
        return ""
    if _is_scheme_less_remote_image_url(text):
        text = f"https://{text.lstrip('/')}"
    normalized = urljoin(base_url, text)
    return _unwrapped_image_url(normalized) or normalized


def _normalized_remote_image_url(url: str) -> str:
    text = _decoded_url_reference(str(url).strip())
    if not text or text.lower().startswith("data:"):
        return ""
    if _is_scheme_less_remote_image_url(text):
        text = f"https://{text.lstrip('/')}"
    return _unwrapped_image_url(text) or text


def _unwrapped_image_url(url: str) -> str:
    if not _is_http_url(url):
        return ""
    parsed = urlparse(url)
    strong_keys = {
        "imgurl",
        "imageurl",
        "image_url",
        "mediaurl",
        "media_url",
        "contenturl",
        "content_url",
        "photo",
        "photo_url",
        "src",
        "source",
        "image",
        "img",
    }
    weak_keys = {"url", "u", "target", "redirect", "redirect_url"}
    for key, value in parse_qsl(parsed.query, keep_blank_values=False):
        key_text = key.lower().replace("-", "_")
        candidate = _decoded_nested_url(value)
        if not candidate:
            continue
        if not _is_http_url(candidate):
            if candidate.startswith("//"):
                candidate = f"https:{candidate}"
            elif _is_scheme_less_remote_image_url(candidate):
                candidate = f"https://{candidate.lstrip('/')}"
            elif candidate.startswith("/") or _url_looks_like_image(candidate):
                candidate = urljoin(url, candidate)
            else:
                continue
        if key_text in strong_keys:
            return candidate
        if key_text in weak_keys and _url_looks_like_image(candidate):
            return candidate
    return ""


def _is_scheme_less_remote_image_url(value: str) -> bool:
    text = str(value).strip().lstrip("/")
    if not _url_looks_like_image(text):
        return False
    first_segment = text.split("/", 1)[0]
    if first_segment in {".", ".."} or first_segment.startswith("."):
        return False
    return "." in first_segment and " " not in first_segment


def _decoded_url_reference(value: str) -> str:
    raw = str(value).strip()
    decoded = _decoded_nested_url(raw)
    if decoded == raw:
        return raw
    if (
        _is_http_url(decoded)
        or decoded.startswith(("/", "//", "./", "../"))
        or _is_scheme_less_remote_image_url(decoded)
        or _url_looks_like_image(decoded)
    ):
        return decoded
    return raw


def _decoded_nested_url(value: str) -> str:
    candidate = str(value).strip()
    for _ in range(3):
        decoded = unquote(candidate).strip()
        if decoded == candidate:
            break
        candidate = decoded
    return candidate


def _is_http_url(url: str) -> bool:
    parsed = urlparse(url)
    return parsed.scheme in {"http", "https"} and bool(parsed.netloc)


def _url_path_has_image_suffix(url: str) -> bool:
    return Path(urlparse(url).path).suffix.lower() in SUPPORTED_IMAGE_SUFFIXES


def _url_has_image_format_hint(url: str) -> bool:
    image_formats = {suffix.lstrip(".") for suffix in SUPPORTED_IMAGE_SUFFIXES}
    image_format_keys = {"format", "fm", "ext", "extension", "mime", "output", "type"}
    for key, hint in parse_qsl(urlparse(url).query, keep_blank_values=False):
        if key.lower().replace("-", "_") not in image_format_keys:
            continue
        normalized = hint.lower().split(";", 1)[0].strip().lstrip(".")
        if normalized.startswith("image/"):
            normalized = normalized.split("/", 1)[1]
        normalized = normalized.split("+", 1)[0]
        if normalized in image_formats:
            return True
    return False


def _url_looks_like_image(url: str) -> bool:
    return _url_path_has_image_suffix(url) or _url_has_image_format_hint(url)


def _submission_payload(
    record: dict[str, Any],
    score: int,
    band: str,
    reasons: list[str],
    provider_payloads: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
    submission_id = record["id"]
    title = record.get("title", submission_id)
    submitted_at = record.get("submitted_at") or _now_label()
    submitted_epoch = _label_to_epoch(submitted_at)
    provider_payloads = provider_payloads or {}
    provider_state = {"internal": "ok"}
    for provider_id in provider_payloads:
        if provider_id == "internal":
            continue
        provider_state[provider_id] = _external_provider_state(provider_payloads, provider_id)
    return {
        "id": submission_id,
        "title": title,
        "asset": record["asset"],
        "riskScore": score,
        "riskBand": band,
        "submittedAt": submitted_at,
        "submittedEpoch": submitted_epoch,
        "lastAnalysis": _now_label(),
        "applicantStatus": "검토 중",
        "decisionStatus": "unreviewed",
        "applicantVisible": False,
        "reasons": reasons or ["분석 근거 없음"],
        "providerState": provider_state,
        "fileFacts": {
            "size": f"{record.get('width', 1)} x {record.get('height', 1)}",
            "format": record.get("format", "FILE"),
            "submitted": submitted_at,
            "analysis": "v1",
        },
        "derivativeNote": "로컬 이미지 저장소에서 읽은 내부 검토용 이미지입니다.",
        "recommendation": {
            "label": "운영자 검토 필요",
            "detail": "자동 분석은 참고 정보이며 최종 결정은 운영자가 기록합니다.",
        },
        "derivedPreview": {
            "automatic": False,
            "entryName": f"{title} / {submission_id}",
            "effect": "반려 시에만 지식 DB 후보로 기록됩니다.",
        },
        "queryHistory": [],
        "similar": [{"asset": record["asset"], "label": "local submission"}],
        "evidence": [],
    }


def _external_provider_state(
    provider_payloads: dict[str, dict[str, Any]],
    provider_id: str,
) -> str:
    provider = provider_payloads.get(provider_id, {})
    return "pending" if provider.get("enabled") else "disabled"


def _external_provider_state_for_submission(
    provider_payloads: dict[str, dict[str, Any]],
    provider_id: str,
    submission: dict[str, Any],
    evidence: list[dict[str, Any]],
) -> str:
    provider = provider_payloads.get(provider_id, {})
    if not provider.get("enabled"):
        return "disabled"

    matching_evidence = [
        item for item in evidence if _evidence_matches_provider(item, provider_id)
    ]
    matching_history = [
        item
        for item in submission.get("queryHistory", []) or []
        if _history_matches_provider(item, provider_id)
    ]

    if any(_provider_item_failed(item) for item in matching_evidence) or any(
        str(item.get("status", "")) == "failed" for item in matching_history
    ):
        return "failed"
    if any(_provider_item_has_result(item) for item in matching_evidence) or any(
        int(item.get("count", 0) or 0) > 0 and str(item.get("status", "")) in {"auto", "manual"}
        for item in matching_history
    ):
        return "covered"
    if matching_evidence or any(str(item.get("status", "")) in {"auto", "manual"} for item in matching_history):
        return "empty"
    return "not_run"


def _evidence_matches_provider(evidence: dict[str, Any], provider_id: str) -> bool:
    source = str(evidence.get("source", ""))
    domain = str(evidence.get("domain", ""))
    if provider_id == "naver":
        return source == "naver" or domain in {"naver", "naver_blog", "naver_web"}
    if provider_id == "google":
        return source == "google" and domain != "google_custom_search"
    if provider_id == "google_search":
        return domain == "google_custom_search"
    if provider_id == "llm":
        return source == "llm" or (source == "failure" and "LLM" in str(evidence.get("title", "")))
    return domain == provider_id


def _history_matches_provider(history: dict[str, Any], provider_id: str) -> bool:
    provider = str(history.get("provider", ""))
    if provider_id == "naver":
        return provider in {"naver", "naver_blog", "naver_web"}
    return provider == provider_id


def _provider_item_failed(evidence: dict[str, Any]) -> bool:
    title = str(evidence.get("title", "")).lower()
    if "returned no results" in title:
        return False
    if str(evidence.get("source", "")) == "failure":
        return True
    return "failed" in title


def _provider_item_has_result(evidence: dict[str, Any]) -> bool:
    title = str(evidence.get("title", "")).lower()
    if "returned no results" in title:
        return False
    if str(evidence.get("source", "")) == "failure":
        return False
    if str(evidence.get("matchType", "")) == "weak_label":
        return False
    return True


def _external_provider_ids(provider_payloads: dict[str, dict[str, Any]]) -> list[str]:
    return [provider_id for provider_id in provider_payloads if provider_id != "internal"]


def _face_crop_web_evidence(
    submission_id: str,
    crop_index: int,
    evidence: Evidence,
) -> Evidence:
    data = {
        **evidence.data,
        "submission_id": submission_id,
        "face_crop_search": True,
        "crop_index": crop_index,
        "weak_hint": True,
        "privacy_note": "얼굴 영역만 웹 탐지한 참고 근거이며 동일인 판정이 아닙니다.",
    }
    return Evidence(
        source=evidence.source,
        reason=f"Google face crop web evidence: {evidence.reason}",
        confidence=evidence.confidence,
        data=data,
    )


def _evidence_payload(submission_id: str, evidence: Evidence) -> dict[str, Any]:
    source = _ui_source(evidence.source)
    result_url = str(evidence.data.get("url", evidence.data.get("result_url", "")))
    image_url = str(evidence.data.get("image_url", ""))
    page_image_urls = _unique_texts(_text_list(evidence.data.get("page_image_urls", [])))
    thumbnail_url = str(evidence.data.get("thumbnail_url", ""))
    if not thumbnail_url and not image_url and page_image_urls:
        thumbnail_url = page_image_urls[0]
    page_title = str(evidence.data.get("page_title", evidence.data.get("title", "")))
    face_crop_search = bool(evidence.data.get("face_crop_search", False))
    knowledge_entry_status = str(evidence.data.get("knowledge_entry_status", ""))
    return {
        "id": _evidence_id(submission_id, evidence),
        "group": "watchlist" if knowledge_entry_status == "watchlist" else "face_web" if face_crop_search else _ui_group(evidence.source),
        "source": source,
        "title": evidence.reason,
        "confidence": evidence.confidence,
        "query": str(evidence.data.get("query", "")),
        "querySignature": str(evidence.data.get("query_signature", "")),
        "queryStrategy": str(evidence.data.get("query_strategy", "")),
        "querySource": str(evidence.data.get("query_source", "")),
        "searchType": str(evidence.data.get("search_type", "")),
        "domain": str(evidence.data.get("provider", evidence.data.get("domain", "internal"))),
        "url": result_url,
        "imageUrl": image_url,
        "thumbnailUrl": thumbnail_url,
        "pageImageUrls": page_image_urls,
        "remoteImageUrl": str(evidence.data.get("remote_image_url", "")),
        "sourcePageUrl": str(evidence.data.get("source_page_url", "")),
        "imageCandidateSource": str(evidence.data.get("image_candidate_source", "")),
        "bloggerName": str(evidence.data.get("blogger_name", "")),
        "bloggerLink": str(evidence.data.get("blogger_link", "")),
        "postdate": str(evidence.data.get("postdate", "")),
        "pageTitle": page_title,
        "matchType": str(evidence.data.get("match", "")),
        "rank": evidence.data.get("rank", ""),
        "providerScore": evidence.data.get("score", ""),
        "faceCropSearch": face_crop_search,
        "cropIndex": evidence.data.get("crop_index", ""),
        "privacyNote": str(evidence.data.get("privacy_note", "")),
        "knowledgeEntryId": str(evidence.data.get("knowledge_entry_id", "")),
        "knowledgeEntryName": str(evidence.data.get("knowledge_name", "")),
        "knowledgeEntryStatus": knowledge_entry_status,
        "sourceSubmissionId": str(evidence.data.get("source_submission_id", "")),
        "similarity": evidence.data.get("similarity", ""),
        "retrievedAt": _now_label(),
        "contributed": source not in {"llm", "failure"} and not evidence.data.get("weak_hint", False),
        "sourceEvidenceIds": evidence.data.get("source_evidence_ids", []),
        "status": "active",
        "submission_id": submission_id,
    }


def _domain_evidence_from_ui(payload: dict[str, Any]) -> Evidence:
    title = str(payload.get("title", ""))
    return Evidence(
        source=_domain_source_from_ui_payload(payload),
        reason=title,
        confidence=float(payload.get("confidence", 0)),
        data={
            "evidence_id": payload.get("id", ""),
            "query": payload.get("query", ""),
            "query_signature": payload.get("querySignature", ""),
            "query_strategy": payload.get("queryStrategy", ""),
            "query_source": payload.get("querySource", ""),
            "search_type": payload.get("searchType", ""),
            "domain": payload.get("domain", ""),
            "url": payload.get("url", ""),
            "result_url": payload.get("url", ""),
            "image_url": payload.get("imageUrl", ""),
            "thumbnail_url": payload.get("thumbnailUrl", ""),
            "remote_image_url": payload.get("remoteImageUrl", ""),
            "source_page_url": payload.get("sourcePageUrl", ""),
            "image_candidate_source": payload.get("imageCandidateSource", ""),
            "blogger_name": payload.get("bloggerName", ""),
            "blogger_link": payload.get("bloggerLink", ""),
            "postdate": payload.get("postdate", ""),
            "page_title": payload.get("pageTitle", ""),
            "match": payload.get("matchType", ""),
            "rank": payload.get("rank", ""),
            "score": payload.get("providerScore", ""),
            "contributed": payload.get("contributed", True),
            "status": payload.get("status", ""),
            "weak_hint": bool(payload.get("faceCropSearch", False)) or _is_google_weak_label_payload(payload),
            "face_crop_search": bool(payload.get("faceCropSearch", False)),
            "crop_index": payload.get("cropIndex", ""),
            "privacy_note": payload.get("privacyNote", ""),
            "operator_status": payload.get("operatorStatus", ""),
            "knowledge_entry_id": payload.get("knowledgeEntryId", ""),
            "knowledge_name": payload.get("knowledgeEntryName", ""),
            "knowledge_entry_status": payload.get("knowledgeEntryStatus", ""),
            "source_submission_id": payload.get("sourceSubmissionId", ""),
            "similarity": payload.get("similarity", ""),
        },
    )


def _domain_source_from_ui_payload(payload: dict[str, Any]) -> EvidenceSource:
    source = str(payload.get("source", ""))
    title = str(payload.get("title", "")).lower()
    if source == "failure" and (
        "disabled" in title or "skipped" in title or "usage limit" in title
    ):
        return EvidenceSource.EXTERNAL_SKIPPED
    return _domain_source_from_ui(source)


def _domain_source_from_ui(source: str) -> EvidenceSource:
    if source == "google":
        return EvidenceSource.WEB_DETECTION
    if source == "naver":
        return EvidenceSource.NAVER_SEARCH
    if source == "face":
        return EvidenceSource.FACE_PERSON
    if source == "failure":
        return EvidenceSource.FAILURE
    if source == "llm":
        return EvidenceSource.LLM_SUMMARY
    return EvidenceSource.FINGERPRINT


def _is_google_weak_label_payload(payload: dict[str, Any]) -> bool:
    title = str(payload.get("title", ""))
    return (
        payload.get("source") == "google"
        and not payload.get("url")
        and (title.startswith("Best guess label ") or title.startswith("Google weak label "))
    )


def _google_weak_label_title(title: str) -> str:
    if title.startswith("Best guess label "):
        return "Google weak label " + title.removeprefix("Best guess label ")
    return title


def _evidence_id(submission_id: str, evidence: Evidence) -> str:
    base = f"{submission_id}:{evidence.source}:{evidence.reason}:{json.dumps(evidence.data, sort_keys=True, default=str)}"
    return "ev-" + hashlib.sha256(base.encode("utf-8")).hexdigest()[:24]


def _ui_source(source: EvidenceSource) -> str:
    if source == EvidenceSource.WEB_DETECTION:
        return "google"
    if source == EvidenceSource.NAVER_SEARCH:
        return "naver"
    if source == EvidenceSource.LLM_SUMMARY:
        return "llm"
    if source in {EvidenceSource.FAILURE, EvidenceSource.EXTERNAL_SKIPPED, EvidenceSource.SEARCH_SKIPPED, EvidenceSource.ENRICHMENT_FAILURE}:
        return "failure"
    if source == EvidenceSource.FACE_PERSON:
        return "face"
    return "fingerprint"


def _ui_group(source: EvidenceSource) -> str:
    ui_source = _ui_source(source)
    if ui_source in {"fingerprint", "face"}:
        return "internal"
    return ui_source


def _now_label() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def _label_to_epoch(value: str) -> int:
    # Parse the timestamp label into a Unix epoch for chronological sorting in
    # the operator GUI. Falls back to "now" when the label is missing or in an
    # unrecognized format (mirroring the submittedAt `or _now_label()` fallback).
    text = str(value).strip()
    if not text:
        return int(datetime.now().timestamp())
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
        try:
            return int(datetime.strptime(text, fmt).timestamp())
        except ValueError:
            continue
    try:
        return int(datetime.fromisoformat(text).timestamp())
    except ValueError:
        return int(datetime.now().timestamp())


def _timestamp_id() -> str:
    return datetime.now().strftime("%Y%m%d%H%M%S%f")
