"""Shared image-download and gallery-writing helpers.

Consolidates the three near-identical download loops (make_gallery.download_images,
make_jellyfin_gallery.download_urls + download_person_images) and the duplicated
"write gallery.h5 + .missing_images.json" tail from both builders.

Galleries are written directly as HDF5 — never JSON. Same layout the C++ side
reads/writes (src/gallery/gallery_store.cpp): flat [N,512] embeddings + per-actor
offset/count, parallel imdb_id/tmdb_id/jellyfin_id/name string arrays, a
per-embedding-row source_images array, and an /embedder group carrying the
GR-004 model binding. calibration is left absent (calib_hash=0); the C++
identity_matcher fits and writes it back into the file on first use.

The GR-004 embedder stamp written into that /embedder group lives in sae_stamp
and is re-exported below, so existing callers keep importing it from here.
"""

import io
import json
import sys
from pathlib import Path

import h5py
import numpy as np
import requests
from PIL import Image, UnidentifiedImageError

# Errors that mean "this one image couldn't be fetched/decoded" rather than a bug:
# network/HTTP failures, filesystem errors, and undecodable image bytes. We catch
# exactly these around a single download so a coding error isn't swallowed as a
# spurious "download failed".
DOWNLOAD_ERRORS = (requests.RequestException, OSError, UnidentifiedImageError)

# Wikidata SPARQL: map an IMDB person id (P345) to their image (P18) on
# Wikimedia Commons. Used as a free, CC-licensed headshot fallback when a
# provider (TMDB/Jellyfin) has no usable image. Wikidata requires a
# descriptive User-Agent.
WIKIDATA_SPARQL = "https://query.wikidata.org/sparql"
WIKIDATA_HEADERS = {
    "Accept":     "application/sparql-results+json",
    "User-Agent": "scene-actor-extraction/1.0 (https://github.com/; gallery builder)",
}


def wikidata_image_urls(imdb_person_id: str) -> list[str]:
    """Return Commons image URL(s) for a person via their IMDB id (P345 -> P18)."""
    if not imdb_person_id:
        return []
    query = (
        "SELECT ?image WHERE { "
        f'?person wdt:P345 "{imdb_person_id}" . '
        "?person wdt:P18 ?image . "
        "}"
    )
    try:
        r = requests.get(WIKIDATA_SPARQL, params={"query": query, "format": "json"},
                         headers=WIKIDATA_HEADERS, timeout=15)
        r.raise_for_status()
        bindings = r.json().get("results", {}).get("bindings", [])
        return [b["image"]["value"] for b in bindings if "image" in b]
    except (requests.RequestException, ValueError) as e:
        print(f"  [warn] wikidata lookup failed for {imdb_person_id}: {e}", file=sys.stderr)
        return []


def download_image(url: str, out_path: Path, *,
                   headers: dict | None = None,
                   params: dict | None = None) -> Path | None:
    """Download one image to out_path as JPEG, returning the path or None on failure.

    Skips the download if out_path already exists and is larger than 1 KiB
    (a previously cached, non-truncated image).
    """
    if out_path.exists() and out_path.stat().st_size > 1024:
        return out_path
    try:
        r = requests.get(url, headers=headers, params=params, timeout=15)
        r.raise_for_status()
        ctype = r.headers.get("Content-Type", "")
        if ctype and not ctype.startswith("image/"):
            print(f"  [warn] unexpected response for {url}: "
                  f"status={r.status_code} content-type={ctype!r} len={len(r.content)}",
                  file=sys.stderr)
            return None
        out_path.parent.mkdir(parents=True, exist_ok=True)
        Image.open(io.BytesIO(r.content)).convert("RGB").save(out_path, "JPEG")
        return out_path
    except DOWNLOAD_ERRORS as e:
        print(f"  [warn] image download failed: {url}: {e}", file=sys.stderr)
        return None


def download_images(urls: list[str], dest_dir: Path, n: int,
                    start_index: int = 0, *,
                    headers: dict | None = None,
                    params: dict | None = None) -> list[Path]:
    """Download up to n images from urls into dest_dir, numbered from start_index."""
    dest_dir.mkdir(parents=True, exist_ok=True)
    paths = []
    for i, url in enumerate(urls[:n]):
        out = dest_dir / f"{start_index + i:02d}.jpg"
        path = download_image(url, out, headers=headers, params=params)
        if path is not None:
            paths.append(path)
    return paths


# ── GR-004: gallery ↔ embedder binding ───────────────────────────────────────
# Implemented in sae_stamp (kept dependency-light so the optimizer's replay
# subprocesses can import it without pulling requests/Pillow); re-exported here
# because the gallery writers and every existing caller reach for it via this
# module. See src/gallery/embedder_stamp.hpp for the C++ twin and the rationale.
from sae_stamp import (  # noqa: F401
    EmbedderMismatch,
    check_embedder_stamp,
    describe_stamp,
    embedder_stamp,
    enforce_embedder_stamp,
    read_gallery_stamp,
    require_gallery_stamp_from_env,
    sha256_file,
    verify_gallery_stamp,
    _as_str,
    _stamp_empty,
)


def save_gallery_hdf5(gallery: dict, output: Path, embedder: dict | None = None) -> None:
    """Write a gallery dict ({"actors": [...]}) directly as HDF5 — same schema
    src/gallery/gallery_store.cpp reads/writes. No calibration group; the
    C++ identity_matcher computes and writes it back into this file on first
    use against an unseen set of embeddings.

    `embedder` is the GR-004 stamp (see embedder_stamp()); it may also be carried
    on the gallery dict under "embedder", which is how a filtered/derived gallery
    keeps its binding without the caller having to re-hash anything."""
    embedder = embedder if embedder is not None else gallery.get("embedder")
    actors = gallery["actors"]
    embs, offsets, counts = [], [], []
    imdb, tmdb, jf, name, src_images = [], [], [], [], []
    row = 0
    for a in actors:
        e = a.get("embeddings", [])
        offsets.append(row)
        counts.append(len(e))
        row += len(e)
        embs.extend(e)
        si = a.get("source_images", [])
        for i in range(len(e)):
            src_images.append(si[i] if i < len(si) else "")
        imdb.append(a.get("imdb_id", "") or "")
        tmdb.append(str(a.get("tmdb_id", "") or ""))
        jf.append(a.get("jellyfin_id", a.get("jellyfin_person_id", "")) or "")
        name.append(a.get("name", "") or "")

    emb_arr = np.asarray(embs, dtype=np.float32) if embs else np.zeros((0, 512), np.float32)
    if emb_arr.ndim == 1:
        emb_arr = emb_arr.reshape(0, 512)

    output.parent.mkdir(parents=True, exist_ok=True)
    str_t = h5py.string_dtype("utf-8")
    with h5py.File(output, "w") as f:
        f.create_dataset("embeddings", data=emb_arr)
        f.create_dataset("offset", data=np.asarray(offsets, np.int64))
        f.create_dataset("count", data=np.asarray(counts, np.int32))
        f.create_dataset("imdb_id", data=np.asarray(imdb, dtype=object), dtype=str_t)
        f.create_dataset("tmdb_id", data=np.asarray(tmdb, dtype=object), dtype=str_t)
        f.create_dataset("jellyfin_id", data=np.asarray(jf, dtype=object), dtype=str_t)
        f.create_dataset("name", data=np.asarray(name, dtype=object), dtype=str_t)
        f.create_dataset("source_images", data=np.asarray(src_images, dtype=object), dtype=str_t)
        # TRACES: GR-004 | SR-001
        # omitted entirely when unknown, so "unstamped"
        # round-trips as unstamped rather than as a stamp naming no model.
        if not _stamp_empty(embedder):
            g = f.create_group("embedder")
            g.attrs["model_name"] = embedder.get("model_name", "")
            g.attrs["model_sha256"] = embedder.get("model_sha256", "")
            g.attrs["embed_dim"] = np.int32(embedder.get("embed_dim", 512))
    stamp_note = (f", embedder {embedder['model_name']}" if not _stamp_empty(embedder)
                  else ", NO EMBEDDER STAMP (GR-004)")
    print(f"Saved: {output} ({len(actors)} actors, {emb_arr.shape[0]} embeddings"
          f"{stamp_note})", file=sys.stderr)


def load_gallery_hdf5(path: Path) -> dict:
    """Read an HDF5 gallery back into the same {"actors": [...]} dict shape the
    builders work with in memory (for --merge). Mirrors save_gallery_hdf5."""
    with h5py.File(path, "r") as f:
        emb = f["embeddings"][:]
        offset = f["offset"][:]
        count = f["count"][:]
        imdb = [s.decode() if isinstance(s, bytes) else s for s in f["imdb_id"][:]]
        tmdb = [s.decode() if isinstance(s, bytes) else s for s in f["tmdb_id"][:]]
        jf = [s.decode() if isinstance(s, bytes) else s for s in f["jellyfin_id"][:]]
        name = [s.decode() if isinstance(s, bytes) else s for s in f["name"][:]]
        src_images = None
        if "source_images" in f:
            src_images = [s.decode() if isinstance(s, bytes) else s
                         for s in f["source_images"][:]]
        # TRACES: GR-004 | SR-001
        # carried through so a derived gallery (filter,
        # merge, cast-restrict) keeps the binding of the gallery it came from.
        stamp = None
        if "embedder" in f:
            a = f["embedder"].attrs
            stamp = {"model_name": _as_str(a.get("model_name", "")),
                     "model_sha256": _as_str(a.get("model_sha256", "")),
                     "embed_dim": int(a.get("embed_dim", 512))}

    actors = []
    for a in range(len(offset)):
        s, n = int(offset[a]), int(count[a])
        actor = {"imdb_id": imdb[a], "tmdb_id": tmdb[a], "jellyfin_id": jf[a],
                "name": name[a], "embeddings": [emb[s + i].tolist() for i in range(n)]}
        if src_images is not None:
            actor["source_images"] = [src_images[s + i] for i in range(n)]
        actors.append(actor)
    out = {"actors": actors}
    if stamp is not None:
        out["embedder"] = stamp
    return out


def save_gallery(gallery: dict, missing: list[dict], output: Path,
                 embedder: dict | None = None) -> None:
    """Write the gallery as HDF5 (forcing a .h5 extension) and, if any actors
    lack images, a .missing_images.json sidecar."""
    if output.suffix not in (".h5", ".hdf5"):
        output = output.with_suffix(".h5")
    save_gallery_hdf5(gallery, output, embedder)

    if missing:
        missing_path = output.with_name(output.stem + ".missing_images.json")
        missing_path.write_text(json.dumps(missing, indent=2) + "\n")
        print(f"{len(missing)} actor(s) need images — see {missing_path}", file=sys.stderr)
