"""ANGLE BANK slice (IDENTITY-3D-CONTINUITY-PLAN.md S1+S2).

Exercises view-aware DNA resolution: the identity's turntable RING (already on disk
as ordered ``view_NN.png`` frames under a ``mode:"turntable"`` reconstruction) is read
back as a queryable angle bank, and an optional ``identity_view`` hint selects the
K angle-nearest frames from it — WITHOUT any new persisted state, migration, or write.

Isolation idiom is lifted verbatim from test_identity_versions.py: rebind the store
module globals (IDENTITIES_HOME/PROJECTS_HOME) to temp dirs under the real DEFAULT_ROOT,
jail reference images under the real UPLOADS_HOME, point the media bus at a temp DB, and
build a minimal Flask app with the video blueprint.

Locks (each check runs independently so one failure never masks the rest):
  [bank]
   1. bank_views computes correct azimuths from a synthetic 72-frame turntable recon
      (index N -> N*degrees_per_frame, elevation 0.0, angular order, source turntable).
  [selection]
   2. nearest_bank_views is WRAP-aware and angle-spread: a hint at 350° picks frames
      straddling 0°/340° (proves 350↔0 is 10° apart, not 350°), distinct, no dupes.
  [semantic]
   3. azimuth_for_view maps semantic names -> azimuth (case-insensitive), accepts an
      {azimuth_deg} object (wrapping out-of-range), and errors-as-data on a bad hint.
  [resolver]
   4. _reference_images_from_body WITH a view hint returns bank frames near the hint;
      WITHOUT a hint returns EXACTLY the canonical set (asserted equal to the no-hint path).
   5. an invalid identity_view is a clean 400 tuple (the resolver's error idiom).
   6. a versionless / no-turntable profile WITH a hint degrades to the canonical set
      (no crash, byte-identical to the hintless fallback).
  [route]
   7. GET /video/identity-profiles/<slug> surfaces a per-version ``views`` summary
      (count + degrees_per_frame + azimuth range) — additive, never removing a key.

Run:
  cd /srv/share/projects/hugpy/dev/abstract_hugpy_dev
  venv/bin/python -m pytest tests/test_identity_views.py -q
  venv/bin/python tests/test_identity_views.py
"""
from __future__ import annotations

import atexit
import logging
import os
import shutil
import sqlite3
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 path helpers read (env isolation
# does not work: constants read the .env file, not os.environ). IDENTITIES_HOME must sit
# under the real DEFAULT_ROOT so the identity-owned ref copies still pass the route jail.
_TMP_IDENTITIES = tempfile.mkdtemp(prefix="hugpy-idview-store-", dir=DEFAULT_ROOT)
identity_profiles.IDENTITIES_HOME = _TMP_IDENTITIES
_TMP_PROJECTS = tempfile.mkdtemp(prefix="hugpy-idview-projects-")
identity_profiles.PROJECTS_HOME = _TMP_PROJECTS

# JAIL: reference/frame images must resolve under the real UPLOADS_HOME.
_TMP_UPLOADS = tempfile.mkdtemp(prefix="hugpy-idview-uploads-", dir=UPLOADS_HOME)

# media bus -> temp DB so nothing touches the real catalog.
_TMP_DB = tempfile.mkstemp(prefix="idview-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


def _make_png(path: str, color=(120, 120, 120)) -> None:
    from PIL import Image

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


_IMG_A = os.path.join(_TMP_UPLOADS, "view_a.png")
_IMG_B = os.path.join(_TMP_UPLOADS, "view_b.png")
_make_png(_IMG_A, (200, 40, 40))
_make_png(_IMG_B, (40, 200, 40))


def _fresh_profile(name: str) -> dict:
    """A plain profile (no reconstruction, no version) via the store — its refs copied
    into IDENTITIES_HOME so they exist on disk for the resolver's existence filter."""
    return identity_profiles.create_profile(name, [_IMG_A, _IMG_B], notes="")


def _turntable_profile(name: str, frame_count: int = 72, dpf: float = 5.0):
    """Build a profile whose ACTIVE version is backed by a synthetic ``frame_count``-frame
    turntable reconstruction (degrees_per_frame ``dpf``), returning
    ``(slug, recon_id, ordered_view_paths, canonical)``.

    The frames are real (empty-but-present) files so ``attach_reconstruction`` copies them
    in and the bank's ``os.path.isfile`` filter keeps them; the version's canonical is the
    profile's own first ref (an existing path) so the hintless resolve has a known set."""
    p = _fresh_profile(name)
    slug = p["slug"]
    frame_dir = tempfile.mkdtemp(prefix="tt-src-", dir=_TMP_UPLOADS)
    sources = []
    for i in range(frame_count):
        fp = os.path.join(frame_dir, f"frame_{i:04d}.png")
        # A present file is all the bank's existence filter checks — a byte is enough.
        with open(fp, "wb") as f:
            f.write(b"\x89PNG\r\n")
        sources.append(fp)
    recon_id = "recon_tt_" + slug
    identity_profiles.attach_reconstruction(
        slug, recon_id, sources,
        spec={"mode": "turntable", "degrees_per_frame": dpf, "frame_count": frame_count},
    )
    owned = identity_profiles.get_profile(slug)["reference_images"]
    canonical = [owned[0]]
    identity_profiles.mint_version(slug, recon_id, "textured", canonical)
    # The stored (copied-in) ring frames, in angular order — index N == view_NN.png.
    rec = identity_profiles.get_reconstruction(slug, recon_id)
    return slug, recon_id, list(rec["views"]), canonical


# --------------------------------------------------------------------------- #
# [1] bank_views — azimuths computed from index; angular order; source/elevation
# --------------------------------------------------------------------------- #
def test_bank_views_computes_azimuths_from_index():
    slug, recon_id, views, _canon = _turntable_profile("Bank One")
    profile = identity_profiles.get_profile(slug)
    bank = identity_profiles.bank_views(profile)

    assert len(bank) == 72, len(bank)
    # index N -> azimuth N*5 (mod 360); frame 0 == front == 0°, frame 71 == 355°.
    assert bank[0]["azimuth_deg"] == 0.0
    assert bank[18]["azimuth_deg"] == 90.0      # right-profile per the documented convention
    assert bank[36]["azimuth_deg"] == 180.0     # back
    assert bank[54]["azimuth_deg"] == 270.0     # left-profile
    assert bank[71]["azimuth_deg"] == 355.0     # one 5° step short of a full turn
    # angular order (strictly increasing over the single wrap-free ring) + shape contract.
    assert [b["index"] for b in bank] == list(range(72))
    assert all(b["elevation_deg"] == 0.0 for b in bank)      # single-elevation ring
    assert all(b["source"] == "turntable" for b in bank)
    assert all(os.path.isfile(b["path"]) for b in bank)      # existence-filtered
    assert [b["path"] for b in bank] == views                # angular-order paths preserved


# --------------------------------------------------------------------------- #
# [2] nearest_bank_views — WRAP-aware + angle-spread, no duplicate frames
# --------------------------------------------------------------------------- #
def test_nearest_bank_views_is_wrap_aware_and_spread():
    slug, _recon_id, _views, _canon = _turntable_profile("Bank Two")
    profile = identity_profiles.get_profile(slug)
    bank = identity_profiles.bank_views(profile)

    picked = identity_profiles.nearest_bank_views(bank, 350.0, 4)
    az = [p["azimuth_deg"] for p in picked]
    # 350° is 10° from 0°, NOT 350° — a wrap-blind distance would never reach frame 0.
    assert 0.0 in az, az
    # every pick is within one step-cluster of the target the SHORT way round the circle.
    assert all(identity_profiles._angular_distance(350.0, a) <= 10.0 for a in az), az
    # concrete spread for this bank: {350, 345, 355, 0} — straddles the target, no dupes.
    assert set(az) == {350.0, 345.0, 355.0, 0.0}, az
    assert len({p["path"] for p in picked}) == 4              # distinct frames, never k copies
    assert len(picked) == 4

    # k larger than the bank is clamped; k<=0 / empty bank are empty.
    assert len(identity_profiles.nearest_bank_views(bank, 90.0, 1000)) == 72
    assert identity_profiles.nearest_bank_views(bank, 90.0, 0) == []
    assert identity_profiles.nearest_bank_views([], 90.0, 4) == []


# --------------------------------------------------------------------------- #
# [3] azimuth_for_view — semantic map + object form + errors-as-data
# --------------------------------------------------------------------------- #
def test_azimuth_for_view_semantic_and_object():
    for name, want in (("front", 0.0), ("right-profile", 90.0), ("back", 180.0),
                       ("left-profile", 270.0), ("three-quarter-right", 45.0),
                       ("three-quarter-left", 315.0)):
        az, err = identity_profiles.azimuth_for_view(name)
        assert err is None and az == want, (name, az, err)
    # case-insensitive + trimmed.
    assert identity_profiles.azimuth_for_view("  Back ") == (180.0, None)
    # object form; an out-of-range azimuth wraps into [0, 360).
    assert identity_profiles.azimuth_for_view({"azimuth_deg": 123.0}) == (123.0, None)
    assert identity_profiles.azimuth_for_view({"azimuth_deg": 370, "elevation_deg": 15}) == (10.0, None)
    # errors-as-data (never raises): unknown name, object missing azimuth, wrong type.
    for bad in ("sideways", "overhead", {"elevation_deg": 5}, {"azimuth_deg": "x"}, 123, None):
        az, err = identity_profiles.azimuth_for_view(bad)
        assert az is None and isinstance(err, str) and err, (bad, az, err)


# --------------------------------------------------------------------------- #
# [4] resolver — a hint returns bank frames; NO hint returns EXACTLY the canonical set
# --------------------------------------------------------------------------- #
def test_reference_images_from_body_view_hint_vs_canonical():
    slug, recon_id, views, canonical = _turntable_profile("Resolve Four")

    # NO hint -> the active version's canonical set, existence-filtered (today's behavior).
    no_hint, err = vr._reference_images_from_body({"identity_profile": slug})
    assert err is None and no_hint == canonical, (no_hint, err)

    # WITH a "back" (180°) hint -> the 4 angle-nearest RING frames, not the cardinals.
    hinted, err = vr._reference_images_from_body(
        {"identity_profile": slug, "identity_view": "back"})
    assert err is None, err
    assert len(hinted) == 4
    # 180° == index 36; nearest-4 by (distance, azimuth) == indices {36, 35, 37, 34}.
    assert set(hinted) == {views[36], views[35], views[37], views[34]}, hinted
    # the hint demonstrably changed the DNA away from the flat canonical set.
    assert set(hinted) != set(no_hint)

    # an object hint resolves the same ring by degrees (front == 0°).
    front, err = vr._reference_images_from_body(
        {"identity_profile": slug, "identity_view": {"azimuth_deg": 0.0}})
    assert err is None and set(front) == {views[0], views[1], views[71], views[2]}, front


# --------------------------------------------------------------------------- #
# [5] resolver — an invalid identity_view is a clean 400 tuple
# --------------------------------------------------------------------------- #
def test_reference_images_from_body_invalid_view_is_400():
    slug, _recon_id, _views, _canon = _turntable_profile("Resolve Five")
    refs, err = vr._reference_images_from_body(
        {"identity_profile": slug, "identity_view": "sideways"})
    assert refs is None and err is not None and err[1] == 400, err
    # a malformed object hint is also a clean 400 (never a crash).
    refs, err = vr._reference_images_from_body(
        {"identity_profile": slug, "identity_view": {"elevation_deg": 10}})
    assert refs is None and err is not None and err[1] == 400, err


# --------------------------------------------------------------------------- #
# [6] resolver — a versionless / no-turntable profile with a hint DEGRADES to canonical
# --------------------------------------------------------------------------- #
def test_reference_images_from_body_no_bank_degrades():
    p = _fresh_profile("Resolve Six")  # no reconstruction, no version, no ring
    slug = p["slug"]
    owned = set(identity_profiles.get_profile(slug)["reference_images"])

    # a hint on an identity WITHOUT a ring must not crash and must yield today's set.
    hinted, err = vr._reference_images_from_body(
        {"identity_profile": slug, "identity_view": "back"})
    assert err is None, err
    assert set(hinted) == owned, (hinted, owned)
    # byte-identical to the hintless fallback.
    no_hint, err2 = vr._reference_images_from_body({"identity_profile": slug})
    assert err2 is None and set(no_hint) == set(hinted)


# --------------------------------------------------------------------------- #
# [7] GET route — per-version ``views`` summary (additive)
# --------------------------------------------------------------------------- #
def test_get_profile_surfaces_views_summary():
    slug, _recon_id, _views, _canon = _turntable_profile("Summary Seven")
    r = client.get(f"/video/identity-profiles/{slug}")
    assert r.status_code == 200, (r.status_code, r.get_json())
    prof = r.get_json()["profile"]
    versions = prof["versions"]
    assert versions, prof
    summary = versions[0]["views"]
    assert summary["count"] == 72
    assert summary["degrees_per_frame"] == 5.0
    assert summary["frame_count"] == 72
    assert summary["azimuth_min"] == 0.0 and summary["azimuth_max"] == 355.0
    assert summary["source"] == "turntable"
    # additive: the pre-existing version contract keys are still present.
    for key in ("version_id", "name", "kind", "recon_id", "created_at", "canonical", "notes"):
        assert key in versions[0], key


CHECKS = [
    ("bank_views computes azimuths from index (0/90/180/270/355), order + shape",
     test_bank_views_computes_azimuths_from_index),
    ("nearest_bank_views wrap-aware + angle-spread; distinct frames; clamps",
     test_nearest_bank_views_is_wrap_aware_and_spread),
    ("azimuth_for_view semantic map + {azimuth_deg} object + errors-as-data",
     test_azimuth_for_view_semantic_and_object),
    ("resolver: hint -> ring frames; NO hint -> exactly the canonical set",
     test_reference_images_from_body_view_hint_vs_canonical),
    ("resolver: invalid identity_view -> clean 400 tuple",
     test_reference_images_from_body_invalid_view_is_400),
    ("resolver: versionless / no-ring profile + hint degrades to canonical",
     test_reference_images_from_body_no_bank_degrades),
    ("GET /identity-profiles/<slug> surfaces per-version views summary (additive)",
     test_get_profile_surfaces_views_summary),
]


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())
