"""IDENTITY PROFILES (studio stage (a)) — the durable named-reference-set library.

Exercises video_intel/identity_profiles.py + its HTTP surface in
routes/video_routes.py, WITHOUT polluting the real profiles registry:
  * the STORE is redirected by rebinding identity_profiles.PROJECTS_HOME (the
    module global _store_path() reads) to a temp dir — env-var isolation does NOT
    work here (constants' get_env_value reads the .env file, not os.environ), so a
    direct rebind is the honest lever;
  * the reference-image JAIL is the real UPLOADS_HOME (the route/media_store
    resolve paths against the actual constant), so the test writes its PNG/mp4
    fixtures into a temp subdir UNDER UPLOADS_HOME (auto-cleaned) — the same
    dir=DEFAULT_ROOT approach the studio-preset route test uses.

Locks (each check runs independently so one failure never masks the rest):
  [1] POST create -> 201 {profile}; the store file has the pinned shape
      ({profiles:{slug:{name,reference_images,created_at,notes}}, _deleted:{}}) and
      the atomic write leaves NO stray *.tmp sibling.
  [2] GET list -> the created profile lists (newest-first, slug folded in).
  [3] GET /<slug> -> the profile + its exact reference set; unknown slug -> 404.
  [4] DELETE /<slug> ARCHIVES (never erases): the entry leaves `profiles`, appears
      under `_deleted` with a deleted_at stamp, and the slug then 404s / de-lists.
  [5] POST dup name -> 409 (code "duplicate") — no silent overwrite.
  [6] Validation rejects: jail-escape path, >4 refs, a non-image (video) file,
      empty list, missing name — each a clean 4xx, never a 500.
  [8] PATCH /<slug> partial edit: rename is DISPLAY-ONLY (the slug NEVER
      re-derives from the new name — every identity_profile:<slug> reference
##      stays valid); an omitted field is left untouched; reference_images
      REPLACES the set (not merges); an empty reference_images list is a clean
      400 (an identity keeps >=1 ref, unchanged by the rejected PATCH); an
      unknown slug is a clean 404.

Run:
  cd /srv/share/projects/hugpy/dev/abstract_hugpy_dev
  venv/bin/python tests/test_identity_profiles.py
"""
from __future__ import annotations

import atexit
import json
import logging
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile

logging.disable(logging.INFO)

_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
if _SRC not in sys.path:
    sys.path.insert(0, _SRC)

import importlib  # noqa: E402

from flask import Flask  # noqa: E402

from abstract_hugpy_dev.video_intel import identity_profiles  # noqa: E402
from abstract_hugpy_dev.video_intel import media_bus  # noqa: E402
from abstract_hugpy_dev.imports.src.constants.constants import UPLOADS_HOME, DEFAULT_ROOT  # noqa: E402

# STORE isolation: rebind the module globals the store's path helpers read, so the
# whole identities store lands in temp dirs, never the real trees. Env isolation does
# NOT work here (constants' get_env_value reads the .env file, not os.environ) — the
# direct module rebind is the honest lever, the same one this file already uses.
#
#  * IDENTITIES_HOME is the NEW store root. It MUST sit directly under the real
#    DEFAULT_ROOT (a SIBLING of UPLOADS_HOME) so that (a) the identity-owned ref
#    copies still pass the route's storage jail (_jail_resolve accepts DEFAULT_ROOT),
#    and (b) the persistence invariant is exercised for real — the upload reaper is
#    jailed to UPLOADS_HOME and structurally cannot reach a DEFAULT_ROOT/<tmp> path.
#  * PROJECTS_HOME is the LEGACY store location (only the migration tests seed a file
#    there); pointing it at a temp dir keeps the real projects registry untouched.
_TMP_IDENTITIES = tempfile.mkdtemp(prefix="hugpy-identity-store-", dir=DEFAULT_ROOT)
identity_profiles.IDENTITIES_HOME = _TMP_IDENTITIES
_TMP_PROJECTS = tempfile.mkdtemp(prefix="hugpy-identity-projects-")
identity_profiles.PROJECTS_HOME = _TMP_PROJECTS

# JAIL: the reference images MUST resolve under the real UPLOADS_HOME (the route +
# media_store validate against the actual constant). Put fixtures in a temp subdir
# there (cleaned up), mirroring the preset route test's dir=DEFAULT_ROOT.
_TMP_UPLOADS = tempfile.mkdtemp(prefix="hugpy-identity-uploads-", dir=UPLOADS_HOME)

# The identity_profile enqueue seam (check [7]) posts a real /video/studio/i2v job;
# point the media bus at a TEMP DB so it never writes into the REAL dev catalog
# (mirrors test_studio_presets_route.py's isolation).
_TMP_DB = tempfile.mkstemp(prefix="identity-bus-", suffix=".db")[1]
media_bus.DB_PATH = _TMP_DB
media_bus._initialized = False
with sqlite3.connect(_TMP_DB) as _c:
    _c.execute(
        "CREATE TABLE IF NOT EXISTS media_jobs ("
        "job_id TEXT PRIMARY KEY, name TEXT, status TEXT, spec_json TEXT, "
        "result_json TEXT, claim_token TEXT, created REAL, updated REAL, "
        "progress_json TEXT)")

vr = importlib.import_module("abstract_hugpy_dev.flask_app.app.routes.video_routes")

app = Flask(__name__)
app.register_blueprint(vr.video_bp)
client = app.test_client()


@atexit.register
def _cleanup() -> None:
    for d in (_TMP_IDENTITIES, _TMP_PROJECTS, _TMP_UPLOADS):
        shutil.rmtree(d, ignore_errors=True)
    try:
        os.remove(_TMP_DB)
    except OSError:
        pass


# --------------------------------------------------------------------------- #
# fixtures — real media UNDER the uploads jail so ingest classifies honestly
# --------------------------------------------------------------------------- #
def _make_png(path: str, color=(180, 90, 40)) -> None:
    from PIL import Image

    Image.new("RGB", (64, 64), color).save(path)


def _make_mp4(path: str) -> None:
    """A real 1s mp4 — a NON-image reference (ffprobe classifies it 'video'), so the
    route's image-classify guard rejects it exactly as a bad reference would be."""
    ffmpeg = shutil.which("ffmpeg") or "ffmpeg"
    subprocess.run(
        [ffmpeg, "-y", "-f", "lavfi", "-i", "testsrc=duration=1:size=64x48:rate=8",
         "-pix_fmt", "yuv420p", path],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)


_IMG_A = os.path.join(_TMP_UPLOADS, "mira_a.png")
_IMG_B = os.path.join(_TMP_UPLOADS, "mira_b.png")
_VID = os.path.join(_TMP_UPLOADS, "not_an_image.mp4")
_make_png(_IMG_A, (200, 40, 40))
_make_png(_IMG_B, (40, 200, 40))
_make_mp4(_VID)


# --------------------------------------------------------------------------- #
# helpers for the identity-OWNED storage assertions
# --------------------------------------------------------------------------- #
def _owned(slug: str, index: int, src: str) -> str:
    """The path a copied reference gets inside the identity's own dir:
    ``<IDENTITIES_HOME>/<slug>/ref_NN.<ext-of-src>``."""
    ext = os.path.splitext(src)[1].lower() or ".img"
    return os.path.join(_TMP_IDENTITIES, slug, f"ref_{index:02d}{ext}")


def _not_within_uploads(path: str) -> bool:
    """True iff *path* is OUTSIDE UPLOADS_HOME — the persistence invariant that keeps
    the session-scoped upload reaper (jailed to UPLOADS_HOME) from reaching a ref."""
    rp, ru = os.path.realpath(path), os.path.realpath(UPLOADS_HOME)
    return os.path.commonpath([rp, ru]) != ru


def _same_bytes(a: str, b: str) -> bool:
    with open(a, "rb") as fa, open(b, "rb") as fb:
        return fa.read() == fb.read()


# --------------------------------------------------------------------------- #
# [1] POST create -> 201; store file shape + atomic-write (no stray *.tmp).
# --------------------------------------------------------------------------- #
def test_create_and_store_shape():
    r = client.post(
        "/video/identity-profiles",
        json={"name": "Mira", "reference_images": [_IMG_A, _IMG_B], "notes": "lead"},
    )
    assert r.status_code == 201, (r.status_code, r.get_json())
    prof = r.get_json()["profile"]
    assert prof["slug"] == "mira", prof
    assert prof["name"] == "Mira", prof
    # reference_images now point at the identity-OWNED copies (order preserved), not
    # the ephemeral upload sources — the whole point of the per-identity dir.
    owned = [_owned("mira", 0, _IMG_A), _owned("mira", 1, _IMG_B)]
    assert prof["reference_images"] == owned, prof
    for o, src in zip(owned, (_IMG_A, _IMG_B)):
        assert os.path.isfile(o) and _same_bytes(o, src), o  # a real byte-copy
        assert _not_within_uploads(o), o                     # outside the reaper's jail
    assert isinstance(prof["created_at"], (int, float)), prof
    assert prof["notes"] == "lead", prof

    # The registry lives in IDENTITIES_HOME now (moved out of PROJECTS_HOME).
    store = os.path.join(_TMP_IDENTITIES, "identity_profiles.json")
    assert os.path.isfile(store), store
    with open(store, encoding="utf-8") as f:
        data = json.load(f)
    assert set(data) >= {"profiles", "_deleted"}, data
    entry = data["profiles"]["mira"]
    assert set(entry) >= {"name", "reference_images", "created_at", "notes"}, entry
    assert entry["reference_images"] == owned, entry
    assert data["_deleted"] == {}, data

    # The per-identity dir OWNS its refs + a denormalized profile.json mirror.
    idir = os.path.join(_TMP_IDENTITIES, "mira")
    assert os.path.isdir(idir), idir
    mirror = json.load(open(os.path.join(idir, "profile.json"), encoding="utf-8"))
    assert mirror["slug"] == "mira" and mirror["reference_images"] == owned, mirror

    # Atomic writes left no unique-temp sibling behind (registry or ref copies).
    strays = [n for n in os.listdir(_TMP_IDENTITIES)
              if n.startswith("identity_profiles.json.") and n.endswith(".tmp")]
    assert strays == [], strays
    assert [n for n in os.listdir(idir) if n.endswith(".tmp")] == [], os.listdir(idir)


# --------------------------------------------------------------------------- #
# [2] GET list -> the created profile lists (slug folded in).
# --------------------------------------------------------------------------- #
def test_list_contains_created():
    r = client.get("/video/identity-profiles")
    assert r.status_code == 200, r.status_code
    body = r.get_json()
    assert isinstance(body, dict) and isinstance(body.get("profiles"), list), body
    slugs = {p["slug"] for p in body["profiles"]}
    assert "mira" in slugs, slugs
    row = next(p for p in body["profiles"] if p["slug"] == "mira")
    owned = [_owned("mira", 0, _IMG_A), _owned("mira", 1, _IMG_B)]
    assert row["name"] == "Mira" and row["reference_images"] == owned, row


# --------------------------------------------------------------------------- #
# [3] GET /<slug> -> the profile; unknown -> 404.
# --------------------------------------------------------------------------- #
def test_get_by_slug_and_unknown_404():
    r = client.get("/video/identity-profiles/mira")
    assert r.status_code == 200, r.status_code
    owned = [_owned("mira", 0, _IMG_A), _owned("mira", 1, _IMG_B)]
    assert r.get_json()["profile"]["reference_images"] == owned, r.get_json()

    r404 = client.get("/video/identity-profiles/nobody")
    assert r404.status_code == 404, r404.status_code
    assert "error" in r404.get_json(), r404.get_json()


# --------------------------------------------------------------------------- #
# [5] POST dup name -> 409 (before delete, while "mira" is still active).
# --------------------------------------------------------------------------- #
def test_duplicate_name_409():
    r = client.post(
        "/video/identity-profiles",
        json={"name": "Mira", "reference_images": [_IMG_A]},
    )
    assert r.status_code == 409, (r.status_code, r.get_json())
    assert r.get_json().get("code") == "duplicate", r.get_json()


# --------------------------------------------------------------------------- #
# [4] DELETE -> ARCHIVE (never erase): entry moves under _deleted with a stamp;
#     the slug then de-lists + 404s; a second delete is a clean 404 no-op.
# --------------------------------------------------------------------------- #
def test_delete_archives():
    r = client.delete("/video/identity-profiles/mira")
    assert r.status_code == 200, (r.status_code, r.get_json())
    assert r.get_json().get("archived") is True, r.get_json()

    store = os.path.join(_TMP_IDENTITIES, "identity_profiles.json")
    with open(store, encoding="utf-8") as f:
        data = json.load(f)
    assert "mira" not in data["profiles"], data["profiles"]
    archived_keys = [k for k in data["_deleted"] if k.startswith("mira@")]
    assert archived_keys, data["_deleted"]
    arch = data["_deleted"][archived_keys[0]]
    assert "deleted_at" in arch, arch
    # The archived registry entry recorded the identity-owned paths, and the pixels
    # themselves were RELOCATED under _deleted/<slug>@<ts>/ (never erased) — the
    # active dir is gone, the bytes live on in the graveyard.
    owned = [_owned("mira", 0, _IMG_A), _owned("mira", 1, _IMG_B)]
    assert arch["reference_images"] == owned, arch
    assert not os.path.isdir(os.path.join(_TMP_IDENTITIES, "mira")), "active dir should move"
    graveyard = os.path.join(_TMP_IDENTITIES, "_deleted")
    moved = [n for n in os.listdir(graveyard) if n.startswith("mira@")]
    assert moved, os.listdir(graveyard)
    survivors = []
    for root, _dirs, files in os.walk(os.path.join(graveyard, moved[0])):
        survivors.extend(os.path.join(root, f) for f in files)
    assert any(_same_bytes(p, _IMG_A) for p in survivors), "deleted pixels preserved"

    # De-lists + 404s; idempotent second delete.
    assert client.get("/video/identity-profiles/mira").status_code == 404
    assert "mira" not in {p["slug"] for p in client.get("/video/identity-profiles").get_json()["profiles"]}
    assert client.delete("/video/identity-profiles/mira").status_code == 404


# --------------------------------------------------------------------------- #
# [6] Validation rejects — each a clean 4xx (never a 500).
# --------------------------------------------------------------------------- #
def test_validation_rejects():
    # jail escape
    r = client.post("/video/identity-profiles",
                    json={"name": "Escape", "reference_images": ["/etc/passwd"]})
    assert r.status_code == 400 and "jail" in r.get_json()["error"], r.get_json()

    # > 4 refs
    r = client.post("/video/identity-profiles",
                    json={"name": "TooMany", "reference_images": [_IMG_A] * 5})
    assert r.status_code == 400 and "at most 4" in r.get_json()["error"], r.get_json()

    # a non-image (video) reference -> classified 'video', rejected
    r = client.post("/video/identity-profiles",
                    json={"name": "Vid", "reference_images": [_VID]})
    assert r.status_code == 400 and "not an image" in r.get_json()["error"], r.get_json()

    # empty list
    r = client.post("/video/identity-profiles",
                    json={"name": "Empty", "reference_images": []})
    assert r.status_code == 400, r.get_json()

    # missing name
    r = client.post("/video/identity-profiles",
                    json={"reference_images": [_IMG_A]})
    assert r.status_code == 400 and "name" in r.get_json()["error"], r.get_json()

    # a missing (but jail-valid) path -> 404
    r = client.post("/video/identity-profiles",
                    json={"name": "Gone", "reference_images": [os.path.join(_TMP_UPLOADS, "nope.png")]})
    assert r.status_code == 404, (r.status_code, r.get_json())

    # NONE of the above created anything (the store still has no active profiles).
    assert client.get("/video/identity-profiles").get_json()["profiles"] == [], "no rejects leaked in"


# --------------------------------------------------------------------------- #
# [8] PATCH /<slug> — partial edit. Each of these creates its OWN fresh profile
#     (never reusing "mira", already archived by test_delete_archives above) so
#     this group has no ordering dependency on the earlier checks.
# --------------------------------------------------------------------------- #
def test_patch_rename_is_display_only_slug_stable():
    c = client.post("/video/identity-profiles",
                    json={"name": "Patchy", "reference_images": [_IMG_A], "notes": "orig"})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]
    assert slug == "patchy", slug

    r = client.patch(f"/video/identity-profiles/{slug}", json={"name": "Patchy Prime"})
    assert r.status_code == 200, (r.status_code, r.get_json())
    prof = r.get_json()["profile"]
    assert prof["slug"] == slug, prof  # NEVER re-slugs on rename
    assert prof["name"] == "Patchy Prime", prof
    assert prof["notes"] == "orig", prof  # untouched field is left alone
    assert prof["reference_images"] == [_owned("patchy", 0, _IMG_A)], prof

    # Still reachable at the OLD (only) slug; the new-name-derived slug names
    # nothing — proof the rename never re-slugged.
    assert client.get(f"/video/identity-profiles/{slug}").status_code == 200
    assert client.get("/video/identity-profiles/patchy-prime").status_code == 404


def test_patch_notes_then_refs_replace():
    c = client.post("/video/identity-profiles",
                    json={"name": "Refswap", "reference_images": [_IMG_A, _IMG_B]})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]

    # notes-only PATCH — name/reference_images are left untouched (a true
    # partial update, not a full overwrite).
    r = client.patch(f"/video/identity-profiles/{slug}", json={"notes": "lead, act 2"})
    assert r.status_code == 200, (r.status_code, r.get_json())
    prof = r.get_json()["profile"]
    assert prof["notes"] == "lead, act 2", prof
    assert prof["name"] == "Refswap", prof
    assert prof["reference_images"] == [_owned("refswap", 0, _IMG_A), _owned("refswap", 1, _IMG_B)], prof

    # reference_images PATCH REPLACES the whole set (not appends/merges) — same
    # jail + ingest + image-classify validation POST create runs; the new set is
    # copied in renumbered from ref_00 (so the single new ref is ref_00.png).
    r = client.patch(f"/video/identity-profiles/{slug}", json={"reference_images": [_IMG_B]})
    assert r.status_code == 200, (r.status_code, r.get_json())
    prof = r.get_json()["profile"]
    replaced = [_owned("refswap", 0, _IMG_B)]
    assert prof["reference_images"] == replaced, prof  # replaced, not merged
    assert _same_bytes(replaced[0], _IMG_B), replaced   # holds the NEW content
    assert prof["notes"] == "lead, act 2", prof  # untouched by THIS patch

    # GET reflects the same durable state (not just the response echo).
    g = client.get(f"/video/identity-profiles/{slug}")
    assert g.get_json()["profile"]["reference_images"] == replaced, g.get_json()


def test_patch_empty_reference_images_rejected():
    c = client.post("/video/identity-profiles",
                    json={"name": "Neverempty", "reference_images": [_IMG_A]})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]

    r = client.patch(f"/video/identity-profiles/{slug}", json={"reference_images": []})
    assert r.status_code == 400, (r.status_code, r.get_json())

    # Rejected — the profile keeps its ORIGINAL reference (never left at zero).
    g = client.get(f"/video/identity-profiles/{slug}")
    assert g.get_json()["profile"]["reference_images"] == [_owned("neverempty", 0, _IMG_A)], g.get_json()


def test_patch_unknown_slug_404():
    r = client.patch("/video/identity-profiles/no-such-profile", json={"name": "Whoever"})
    assert r.status_code == 404, (r.status_code, r.get_json())
    assert "error" in r.get_json(), r.get_json()


# --------------------------------------------------------------------------- #
# [7] UNIFIED IDENTITY seam — /video/studio/i2v accepts identity_profile:<slug> in
#     place of raw reference_images (the profile's set is resolved server-side);
#     an unknown slug -> 404; a valid slug enqueues 200 {job_id}.
# --------------------------------------------------------------------------- #
def test_identity_profile_enqueue_seam():
    # A saved profile whose canonical set feeds an id_lock clip enqueue.
    c = client.post("/video/identity-profiles",
                    json={"name": "Enqueue Mira", "reference_images": [_IMG_A]})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]

    # id_lock enqueue by PROFILE (no raw reference_images) -> the route resolves the
    # slug to the profile's refs and enqueues. VACE envelope geometry @ 6 GB.
    body = {
        "capability": "id_lock",
        "width": 832, "height": 480, "fps": 16,
        "vram_budget_gb": 6,
        "prompt": "a portrait, cinematic",
        "identity_profile": slug,
    }
    r = client.post("/video/studio/i2v", json=body)
    assert r.status_code == 200, (r.status_code, r.get_json())
    assert isinstance(r.get_json().get("job_id"), str), r.get_json()

    # An unknown slug is a clean 404 (never a silent empty identity).
    bad = dict(body)
    bad["identity_profile"] = "no-such-profile"
    r404 = client.post("/video/studio/i2v", json=bad)
    assert r404.status_code == 404, (r404.status_code, r404.get_json())


# --------------------------------------------------------------------------- #
# [9] FIRST-CLASS STORAGE — the per-identity dir OWNS its reference images.
# --------------------------------------------------------------------------- #
def test_create_owns_reference_dir():
    c = client.post("/video/identity-profiles",
                    json={"name": "Owner", "reference_images": [_IMG_A, _IMG_B], "notes": "n"})
    assert c.status_code == 201, (c.status_code, c.get_json())
    prof = c.get_json()["profile"]
    slug = prof["slug"]
    idir = os.path.join(_TMP_IDENTITIES, slug)
    assert os.path.isdir(idir), idir

    expect = [(_owned(slug, 0, _IMG_A), _IMG_A), (_owned(slug, 1, _IMG_B), _IMG_B)]
    assert prof["reference_images"] == [o for o, _ in expect], prof["reference_images"]
    for owned, src in expect:
        assert os.path.isfile(owned), owned          # copied in, order preserved
        assert _same_bytes(owned, src), owned        # byte-identical copy of the source
        assert _not_within_uploads(owned), owned     # outside the upload reaper's jail

    # profile.json is a denormalized human-readable MIRROR of the entry.
    mirror = json.load(open(os.path.join(idir, "profile.json"), encoding="utf-8"))
    assert mirror["slug"] == slug and mirror["name"] == "Owner", mirror
    assert mirror["reference_images"] == prof["reference_images"], mirror
    assert mirror["notes"] == "n", mirror


# --------------------------------------------------------------------------- #
# [10] UPDATE renumbers the new set in place; the superseded refs are MOVED to
#      _superseded/ (never erased).
# --------------------------------------------------------------------------- #
def test_update_supersedes_refs_not_erased():
    c = client.post("/video/identity-profiles",
                    json={"name": "Superseder", "reference_images": [_IMG_A]})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]
    idir = os.path.join(_TMP_IDENTITIES, slug)
    old_ref = c.get_json()["profile"]["reference_images"][0]
    assert os.path.isfile(old_ref) and _same_bytes(old_ref, _IMG_A)

    # Replace the set with a different image.
    r = client.patch(f"/video/identity-profiles/{slug}", json={"reference_images": [_IMG_B]})
    assert r.status_code == 200, (r.status_code, r.get_json())
    new_refs = r.get_json()["profile"]["reference_images"]
    assert new_refs == [_owned(slug, 0, _IMG_B)], new_refs   # renumbered from ref_00
    assert _same_bytes(new_refs[0], _IMG_B), new_refs        # holds the NEW content

    # The superseded ref was MOVED under _superseded/<ts>/ (not overwritten/erased):
    # its ORIGINAL bytes survive there.
    sup_root = os.path.join(idir, "_superseded")
    assert os.path.isdir(sup_root), "superseded dir must exist"
    moved = []
    for root, _dirs, files in os.walk(sup_root):
        moved.extend(os.path.join(root, f) for f in files)
    assert any(_same_bytes(p, _IMG_A) for p in moved), "old pixels preserved under _superseded"


# --------------------------------------------------------------------------- #
# [11] DELETE relocates the whole identity dir under _deleted/ — bytes never erased.
# --------------------------------------------------------------------------- #
def test_delete_moves_identity_dir():
    c = client.post("/video/identity-profiles",
                    json={"name": "Deldir", "reference_images": [_IMG_A]})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]
    idir = os.path.join(_TMP_IDENTITIES, slug)
    ref = c.get_json()["profile"]["reference_images"][0]
    assert os.path.isfile(ref)

    d = client.delete(f"/video/identity-profiles/{slug}")
    assert d.status_code == 200, (d.status_code, d.get_json())
    assert not os.path.isdir(idir), "active identity dir should be relocated on delete"

    graveyard = os.path.join(_TMP_IDENTITIES, "_deleted")
    archived = [n for n in os.listdir(graveyard) if n.startswith(f"{slug}@")]
    assert archived, os.listdir(graveyard)
    survivors = []
    for root, _dirs, files in os.walk(os.path.join(graveyard, archived[0])):
        survivors.extend(os.path.join(root, f) for f in files)
    assert any(_same_bytes(p, _IMG_A) for p in survivors), "deleted identity pixels preserved"


# --------------------------------------------------------------------------- #
# [12] PERSISTENCE (operator: identities are persistent, refs must never be reaped).
#      The identity-owned copy lives OUTSIDE UPLOADS_HOME, so the session-scoped
#      upload reaper (jailed to UPLOADS_HOME) structurally cannot reach it: wiping
#      the entire uploads source dir leaves the identity's refs intact + resolving.
# --------------------------------------------------------------------------- #
def test_reaper_cannot_reach_identity_refs():
    up = tempfile.mkdtemp(prefix="hugpy-reap-src-", dir=_TMP_UPLOADS)
    src = os.path.join(up, "keeper.png")
    _make_png(src, (7, 7, 7))
    try:
        r = client.post("/video/identity-profiles",
                        json={"name": "Persist", "reference_images": [src]})
        assert r.status_code == 201, (r.status_code, r.get_json())
        owned = r.get_json()["profile"]["reference_images"][0]
        assert _not_within_uploads(owned), owned        # the invariant, asserted
        assert os.path.isfile(owned)

        # Simulate the reaper: delete everything under the uploads source dir.
        shutil.rmtree(up, ignore_errors=True)
        assert not os.path.exists(src), "the ephemeral upload is gone"

        # The identity's owned ref SURVIVES and still resolves through the store.
        assert os.path.isfile(owned), "identity ref must survive the upload reaper"
        g = client.get("/video/identity-profiles/persist")
        assert g.status_code == 200, g.status_code
        assert g.get_json()["profile"]["reference_images"] == [owned], g.get_json()
    finally:
        shutil.rmtree(up, ignore_errors=True)


# --------------------------------------------------------------------------- #
# [13] MIGRATION happy path — a legacy single-file registry pointing at (present)
#      uploads is materialized into per-identity bundles on first load; the legacy
#      file and the original uploads are left UNTOUCHED (reversible).
# --------------------------------------------------------------------------- #
def test_migration_happy_path():
    old_id, old_pr = identity_profiles.IDENTITIES_HOME, identity_profiles.PROJECTS_HOME
    tmp_id = tempfile.mkdtemp(prefix="hugpy-mig-id-", dir=DEFAULT_ROOT)
    tmp_pr = tempfile.mkdtemp(prefix="hugpy-mig-pr-")
    tmp_up = tempfile.mkdtemp(prefix="hugpy-mig-up-")
    try:
        src = os.path.join(tmp_up, "hero.png")
        _make_png(src, (12, 34, 56))
        legacy = {
            "profiles": {"hero": {"name": "Hero", "reference_images": [src],
                                  "created_at": 100.0, "notes": "lead"}},
            "_deleted": {"ghost@1": {"name": "Ghost", "reference_images": [],
                                     "deleted_at": 1}},
        }
        legacy_path = os.path.join(tmp_pr, "identity_profiles.json")
        with open(legacy_path, "w", encoding="utf-8") as f:
            json.dump(legacy, f)
        legacy_bytes = open(legacy_path, "rb").read()

        identity_profiles.IDENTITIES_HOME = tmp_id
        identity_profiles.PROJECTS_HOME = tmp_pr
        data = identity_profiles._load()  # first load -> migrate

        hero = data["profiles"]["hero"]
        owned = os.path.join(tmp_id, "hero", "ref_00.png")
        assert hero["reference_images"] == [owned], hero
        assert os.path.isfile(owned) and _same_bytes(owned, src), owned
        assert "missing_references" not in hero, hero          # source present
        assert os.path.isfile(os.path.join(tmp_id, "hero", "profile.json"))
        assert "ghost@1" in data["_deleted"], data["_deleted"]  # _deleted carried over
        assert os.path.isfile(os.path.join(tmp_id, "identity_profiles.json"))

        # Legacy registry + original upload left UNTOUCHED (reversibility).
        assert open(legacy_path, "rb").read() == legacy_bytes, "legacy registry mutated"
        assert os.path.isfile(src) and _same_bytes(src, owned), "original upload mutated"

        # Idempotent: a second load does not re-migrate (new registry now present).
        data2 = identity_profiles._load()
        assert data2["profiles"]["hero"]["reference_images"] == [owned], data2
    finally:
        identity_profiles.IDENTITIES_HOME = old_id
        identity_profiles.PROJECTS_HOME = old_pr
        for d in (tmp_id, tmp_pr, tmp_up):
            shutil.rmtree(d, ignore_errors=True)


# --------------------------------------------------------------------------- #
# [14] MIGRATION missing-source — a legacy entry whose refs no longer exist (the
#      luigi case) must NOT crash and must NOT drop the identity: the entry is
#      kept, originals retained, missing recorded; the legacy file stays untouched.
# --------------------------------------------------------------------------- #
def test_migration_missing_source_kept_not_dropped():
    old_id, old_pr = identity_profiles.IDENTITIES_HOME, identity_profiles.PROJECTS_HOME
    tmp_id = tempfile.mkdtemp(prefix="hugpy-mig2-id-", dir=DEFAULT_ROOT)
    tmp_pr = tempfile.mkdtemp(prefix="hugpy-mig2-pr-")
    try:
        gone = [os.path.join(_TMP_UPLOADS, "luigi_a.png"),
                os.path.join(_TMP_UPLOADS, "luigi_b.png")]  # jail-valid but absent
        assert not any(os.path.exists(g) for g in gone)
        legacy = {"profiles": {"luigi": {"name": "Luigi", "reference_images": gone,
                                         "created_at": 50.0, "notes": ""}},
                  "_deleted": {}}
        legacy_path = os.path.join(tmp_pr, "identity_profiles.json")
        with open(legacy_path, "w", encoding="utf-8") as f:
            json.dump(legacy, f)
        legacy_bytes = open(legacy_path, "rb").read()

        identity_profiles.IDENTITIES_HOME = tmp_id
        identity_profiles.PROJECTS_HOME = tmp_pr
        data = identity_profiles._load()  # must NOT crash on the all-missing entry

        luigi = data["profiles"]["luigi"]                     # kept, not dropped
        assert luigi["reference_images"] == gone, luigi       # originals retained
        assert luigi["missing_references"] == gone, luigi     # all recorded missing
        idir = os.path.join(tmp_id, "luigi")
        assert os.path.isdir(idir), idir                      # dir made, but empty of refs
        assert [n for n in os.listdir(idir) if n.startswith("ref_")] == [], os.listdir(idir)
        assert os.path.isfile(os.path.join(idir, "profile.json"))

        # public shape surfaces the broken refs (additive field).
        pub = identity_profiles.get_profile("luigi")
        assert pub["missing_references"] == gone, pub

        # Legacy file untouched.
        assert open(legacy_path, "rb").read() == legacy_bytes, "legacy registry mutated"
    finally:
        identity_profiles.IDENTITIES_HOME = old_id
        identity_profiles.PROJECTS_HOME = old_pr
        for d in (tmp_id, tmp_pr):
            shutil.rmtree(d, ignore_errors=True)


# --------------------------------------------------------------------------- #
# STAGE (b) — RECONSTRUCTION + CANONICAL. These create their OWN fresh profiles
# (never reusing mira/patchy) so they have no ordering dependency on the earlier
# checks. The RENDER itself (Wan id_lock + frame-extract, behind the runner's swap
# seam) needs a GPU worker, so it is NOT exercised here: the store functions are
# driven DIRECTLY with real still fixtures (exactly as the migration checks drive
# the store), and the reconstruction ROUTE is asserted only to ENQUEUE (like the
# id_lock enqueue seam check [7]).
# --------------------------------------------------------------------------- #
def _make_still(name: str, color) -> str:
    """A real PNG standing in for a rendered turnaround still (any readable file
    works — attach_reconstruction just copies the produced paths in)."""
    p = os.path.join(_TMP_UPLOADS, name)
    _make_png(p, color)
    return p


# [15] attach_reconstruction persists a generated set: stills copied into
#      <slug>/reconstruction/<recon_id>/views/, a manifest.json, and an entry
#      appended to the profile's `reconstructions` list.
def test_attach_reconstruction_creates_bundle():
    c = client.post("/video/identity-profiles",
                    json={"name": "Recon One", "reference_images": [_IMG_A], "notes": "hero"})
    assert c.status_code == 201, (c.status_code, c.get_json())
    slug = c.get_json()["profile"]["slug"]

    s0 = _make_still("recon1_v0.png", (11, 22, 33))
    s1 = _make_still("recon1_v1.png", (44, 55, 66))
    rec = identity_profiles.attach_reconstruction(
        slug, "recon_alpha", [s0, s1],
        spec={"job_id": "job-xyz", "prompt": "hero", "seed": 7,
              "prompts": ["front p", "back p"], "view_names": ["front", "back"]})
    assert isinstance(rec, dict) and rec["recon_id"] == "recon_alpha", rec
    assert isinstance(rec["created_at"], (int, float)), rec
    assert rec["job_id"] == "job-xyz" and rec["seed"] == 7, rec

    # views copied into <slug>/reconstruction/recon_alpha/views/view_NN.png (order kept).
    vdir = os.path.join(_TMP_IDENTITIES, slug, "reconstruction", "recon_alpha", "views")
    expect = [os.path.join(vdir, "view_00.png"), os.path.join(vdir, "view_01.png")]
    assert rec["views"] == expect, rec["views"]
    for owned, src in zip(expect, (s0, s1)):
        assert os.path.isfile(owned) and _same_bytes(owned, src), owned  # real byte-copy
        assert _not_within_uploads(owned), owned                         # outside the reaper

    # manifest.json mirrors the record.
    manifest = json.load(open(os.path.join(
        _TMP_IDENTITIES, slug, "reconstruction", "recon_alpha", "manifest.json"),
        encoding="utf-8"))
    assert manifest["recon_id"] == "recon_alpha" and manifest["views"] == expect, manifest
    assert manifest["prompt"] == "hero" and manifest["seed"] == 7, manifest

    # the profile's public shape now lists the reconstruction; canonical still empty.
    prof = client.get(f"/video/identity-profiles/{slug}").get_json()["profile"]
    assert isinstance(prof.get("reconstructions"), list) and len(prof["reconstructions"]) == 1, prof
    assert prof["reconstructions"][0]["recon_id"] == "recon_alpha", prof
    assert prof.get("canonical") == [], prof

    # atomic writes left no stray tmp under the recon bundle.
    strays = []
    for root, _d, files in os.walk(os.path.join(_TMP_IDENTITIES, slug, "reconstruction")):
        strays += [f for f in files if f.endswith(".tmp")]
    assert strays == [], strays


# [16] list_reconstructions / get_reconstruction read helpers.
def test_list_and_get_reconstruction():
    c = client.post("/video/identity-profiles",
                    json={"name": "Recon Two", "reference_images": [_IMG_A]})
    slug = c.get_json()["profile"]["slug"]
    s0 = _make_still("recon2_v0.png", (7, 8, 9))
    identity_profiles.attach_reconstruction(slug, "recon_beta", [s0], spec={"job_id": "j2"})

    lst = identity_profiles.list_reconstructions(slug)
    assert isinstance(lst, list) and len(lst) == 1 and lst[0]["recon_id"] == "recon_beta", lst
    got = identity_profiles.get_reconstruction(slug, "recon_beta")
    assert got is not None and got["recon_id"] == "recon_beta", got
    assert identity_profiles.get_reconstruction(slug, "nope") is None
    assert identity_profiles.list_reconstructions("no-such-slug") is None


# [17] promote_reconstruction_views copies chosen views into the `canonical` set;
#      never-delete; errors-as-data for bad indices / unknown recon.
def test_promote_reconstruction_to_canonical():
    c = client.post("/video/identity-profiles",
                    json={"name": "Recon Three", "reference_images": [_IMG_A]})
    slug = c.get_json()["profile"]["slug"]
    s0 = _make_still("recon3_v0.png", (100, 0, 0))
    s1 = _make_still("recon3_v1.png", (0, 100, 0))
    identity_profiles.attach_reconstruction(slug, "recon_gamma", [s0, s1], spec={"job_id": "j3"})

    prof = identity_profiles.promote_reconstruction_views(slug, "recon_gamma", [1])
    assert isinstance(prof, dict), prof
    expect = [os.path.join(_TMP_IDENTITIES, slug, "canonical", "ref_00.png")]
    assert prof["canonical"] == expect, prof["canonical"]
    assert os.path.isfile(expect[0]) and _same_bytes(expect[0], s1), expect  # chose view 1
    assert _not_within_uploads(expect[0]), expect

    # mirror + GET reflect the promoted canonical set.
    mirror = json.load(open(os.path.join(_TMP_IDENTITIES, slug, "profile.json"),
                            encoding="utf-8"))
    assert mirror["canonical"] == expect, mirror
    g = client.get(f"/video/identity-profiles/{slug}").get_json()["profile"]
    assert g["canonical"] == expect, g

    # errors-as-data (each a ProfileError -> the route's 400).
    for rid, idxs in (("no-such-recon", [0]), ("recon_gamma", [5]),
                      ("recon_gamma", [0, 1, 2, 3, 4])):
        try:
            identity_profiles.promote_reconstruction_views(slug, rid, idxs)
        except identity_profiles.ProfileError:
            pass
        else:
            raise AssertionError(f"expected ProfileError for {(rid, idxs)}")


# [18] the resolver PREFERS a promoted canonical set over the raw reference_images.
def test_resolver_prefers_canonical_when_promoted():
    c = client.post("/video/identity-profiles",
                    json={"name": "Resolver Pref", "reference_images": [_IMG_A]})
    slug = c.get_json()["profile"]["slug"]
    owned_ref = c.get_json()["profile"]["reference_images"]

    # before promotion: the resolver returns the raw reference set.
    refs_before, err = vr._reference_images_from_body({"identity_profile": slug})
    assert err is None and refs_before == owned_ref, (refs_before, owned_ref)

    # attach + promote a DISTINCT still to canonical.
    s0 = _make_still("resolver_v0.png", (5, 150, 250))
    identity_profiles.attach_reconstruction(slug, "recon_delta", [s0], spec={"job_id": "j4"})
    prof = identity_profiles.promote_reconstruction_views(slug, "recon_delta", [0])
    canon = prof["canonical"]

    # after promotion: the resolver PREFERS canonical (and it's not the uploads).
    refs_after, err2 = vr._reference_images_from_body({"identity_profile": slug})
    assert err2 is None and refs_after == canon, (refs_after, canon)
    assert refs_after != owned_ref, refs_after


# [19] the reconstruction + canonical ROUTES: the reconstruction route ENQUEUES an
#      orchestrator job (single-view is valid); the canonical route promotes.
def test_reconstruction_and_canonical_routes():
    c = client.post("/video/identity-profiles",
                    json={"name": "Route Recon", "reference_images": [_IMG_A], "notes": "a knight"})
    slug = c.get_json()["profile"]["slug"]

    # SINGLE-view reconstruction -> 200 {job_id, recon_id} (does NOT run the job here).
    r = client.post(f"/video/identity-profiles/{slug}/reconstruction", json={"views": ["front"]})
    assert r.status_code == 200, (r.status_code, r.get_json())
    body = r.get_json()
    assert isinstance(body.get("job_id"), str) and body["job_id"], body
    assert isinstance(body.get("recon_id"), str) and body["recon_id"].startswith("recon_"), body

    # default views (empty body) also enqueues.
    assert client.post(f"/video/identity-profiles/{slug}/reconstruction",
                       json={}).status_code == 200
    # an empty views list is a clean 400; an unknown slug is a clean 404.
    assert client.post(f"/video/identity-profiles/{slug}/reconstruction",
                       json={"views": []}).status_code == 400
    assert client.post("/video/identity-profiles/no-such/reconstruction",
                       json={}).status_code == 404

    # canonical route: attach a recon via the store, then promote it via the route.
    s0 = _make_still("routerecon_v0.png", (9, 9, 9))
    identity_profiles.attach_reconstruction(slug, "recon_route", [s0], spec={"job_id": "jr"})
    p = client.post(f"/video/identity-profiles/{slug}/canonical",
                    json={"recon_id": "recon_route", "views": [0]})
    assert p.status_code == 200, (p.status_code, p.get_json())
    assert p.get_json()["profile"]["canonical"], p.get_json()

    # unknown recon_id -> 400; unknown slug -> 404.
    assert client.post(f"/video/identity-profiles/{slug}/canonical",
                       json={"recon_id": "nope", "views": [0]}).status_code == 400
    assert client.post("/video/identity-profiles/no-such/canonical",
                       json={"recon_id": "x", "views": [0]}).status_code == 404


# [20] TURNTABLE mode: a turntable recon record carries mode/frame_count/degrees_per_frame
#      and surfaces them in the manifest; a plain (sheet) record defaults mode -> "sheet".
def test_turntable_reconstruction_record_shape():
    c = client.post("/video/identity-profiles",
                    json={"name": "Turn One", "reference_images": [_IMG_A]})
    slug = c.get_json()["profile"]["slug"]

    # 3 "frames" standing in for the extracted orbit degree-views (angular order).
    frames = [_make_still(f"turn1_f{i}.png", (i * 10, i * 10, i * 10)) for i in range(3)]
    rec = identity_profiles.attach_reconstruction(
        slug, "recon_turn", frames,
        spec={"job_id": "jt", "mode": "turntable", "frame_count": 3,
              "degrees_per_frame": round(360.0 / 3, 2),
              "prompt": "hero", "orbit_prompt": "hero, turntable", "seed": 1})
    assert rec["mode"] == "turntable" and rec["frame_count"] == 3, rec
    assert rec["degrees_per_frame"] == 120.0, rec
    assert len(rec["views"]) == 3, rec  # every frame kept, in order

    # the manifest the UI reads (via GET profile) exposes the turntable fields.
    prof = client.get(f"/video/identity-profiles/{slug}").get_json()["profile"]
    r0 = prof["reconstructions"][0]
    assert r0["mode"] == "turntable" and r0["frame_count"] == 3, r0
    assert r0["degrees_per_frame"] == 120.0, r0

    # a plain (sheet) record — no mode in the spec — defaults mode -> "sheet" on read.
    s0 = _make_still("turn1_sheet.png", (1, 2, 3))
    sheet = identity_profiles.attach_reconstruction(slug, "recon_sheet", [s0],
                                                    spec={"job_id": "js"})
    assert sheet["mode"] == "sheet", sheet
    got = identity_profiles.get_reconstruction(slug, "recon_sheet")
    assert got["mode"] == "sheet", got


# [21] the reconstruction route accepts mode:"turntable" (enqueues) and rejects a bad mode.
def test_reconstruction_route_mode_param():
    c = client.post("/video/identity-profiles",
                    json={"name": "Turn Route", "reference_images": [_IMG_A]})
    slug = c.get_json()["profile"]["slug"]

    r = client.post(f"/video/identity-profiles/{slug}/reconstruction",
                    json={"mode": "turntable"})
    assert r.status_code == 200, (r.status_code, r.get_json())
    assert r.get_json()["recon_id"].startswith("recon_"), r.get_json()

    # explicit sheet mode also enqueues; a bogus mode is a clean 400.
    assert client.post(f"/video/identity-profiles/{slug}/reconstruction",
                       json={"mode": "sheet"}).status_code == 200
    assert client.post(f"/video/identity-profiles/{slug}/reconstruction",
                       json={"mode": "spin"}).status_code == 400


CHECKS = [
    ("POST create -> 201; store file shape + atomic write (no stray tmp)", test_create_and_store_shape),
    ("GET list contains the created profile (slug folded in)", test_list_contains_created),
    ("GET /<slug> returns the refs; unknown slug -> 404", test_get_by_slug_and_unknown_404),
    ("POST duplicate name -> 409 (code duplicate)", test_duplicate_name_409),
    ("DELETE archives under _deleted (never erased); de-lists + 404s; idempotent", test_delete_archives),
    ("validation rejects (escape / >4 / non-image / empty / no-name / missing) are clean 4xx", test_validation_rejects),
    ("PATCH rename is display-only — slug never re-derives", test_patch_rename_is_display_only_slug_stable),
    ("PATCH notes-only leaves refs untouched; reference_images REPLACES the set", test_patch_notes_then_refs_replace),
    ("PATCH empty reference_images -> 400; original refs kept", test_patch_empty_reference_images_rejected),
    ("PATCH unknown slug -> 404", test_patch_unknown_slug_404),
    ("identity_profile:<slug> enqueues an id_lock clip; unknown slug -> 404", test_identity_profile_enqueue_seam),
    ("create OWNS a per-identity dir (ref_NN copied in + profile.json; outside uploads)", test_create_owns_reference_dir),
    ("update copies+renumbers the new set; superseded refs MOVED to _superseded (not erased)", test_update_supersedes_refs_not_erased),
    ("delete relocates the identity dir under _deleted (bytes preserved)", test_delete_moves_identity_dir),
    ("PERSISTENCE: owned refs survive an upload-reaper wipe of the uploads source", test_reaper_cannot_reach_identity_refs),
    ("migration happy path materializes per-identity bundles; legacy + uploads untouched", test_migration_happy_path),
    ("migration missing-source keeps the entry (not dropped), records missing; no crash", test_migration_missing_source_kept_not_dropped),
    ("attach_reconstruction copies stills + writes manifest + appends to profile", test_attach_reconstruction_creates_bundle),
    ("list/get reconstruction read helpers", test_list_and_get_reconstruction),
    ("promote_reconstruction_views copies chosen views to canonical; bad input = ProfileError", test_promote_reconstruction_to_canonical),
    ("resolver prefers a promoted canonical set over reference_images", test_resolver_prefers_canonical_when_promoted),
    ("reconstruction route enqueues (single-view ok); canonical route promotes", test_reconstruction_and_canonical_routes),
    ("turntable recon record carries mode/frame_count/degrees_per_frame; sheet defaults mode", test_turntable_reconstruction_record_shape),
    ("reconstruction route accepts mode:turntable; rejects a bad mode", test_reconstruction_route_mode_param),
]


def main() -> int:
    passed = failed = 0
    for i, (name, fn) in enumerate(CHECKS, 1):
        try:
            fn()
        except Exception as exc:  # surface EVERY divergence, not just the first
            failed += 1
            print(f"[{i}] FAIL  {name}: {type(exc).__name__}: {exc}")
        else:
            passed += 1
            print(f"[{i}] PASS  {name}")
    print(f"\n{passed} passed, {failed} failed of {len(CHECKS)}")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())
