import json

import pytest
from fastapi.testclient import TestClient

from simulator.app import create_app


@pytest.fixture
def manifest_path(tmp_path):
    p = tmp_path / "manifest.json"
    p.write_text(json.dumps({
        "clips": [{
            "id": "forest",
            "title": "neutral forest",
            "base_file": "forest/base.mp4",
            "license": "poc", "source": "hef-poc",
            "right_variants": {"4": {"file": "forest/right4.mp4"}},
            "annotations": [{"key": "detected.water", "box": [0.1, 0.2, 0.3, 0.4], "min_level": 1}],
            "strings": {"en": {"detected.water": "flowing water"}},
        }]
    }))
    return p


@pytest.fixture
def client(manifest_path):
    return TestClient(create_app(manifest_path=manifest_path))


def _controls(visual="on", audio="off", left=0, right=0, dark=0, light=0,
              volume=2, brightness=2):
    return dict(visual=visual, audio=audio, left=left, right=right, dark=dark,
                light=light, volume=volume, brightness=brightness)


def test_alteration_returns_the_engine_plan(client):
    resp = client.post("/api/alteration", json={"controls": _controls(left=4, right=2, dark=4)})
    assert resp.status_code == 200
    data = resp.json()
    assert data["plan"]["overlay"]["level"] == 4
    assert data["plan"]["dream"]["strength"] == 2
    assert data["plan"]["dream"]["intensity"] == 0.5
    assert data["plan"]["grade"]["tone"] == -1.0
    assert data["render"]["video"]["shown"] is True


def test_alteration_returns_render_video_and_audio(client):
    body = client.post("/api/alteration", json={"controls": _controls()}).json()
    assert body["render"]["video"]["shown"] is True
    assert body["render"]["audio"] == {"source": "off", "url": None, "altitude_coupled": False}


def test_alteration_visual_off_hides_video(client):
    body = client.post("/api/alteration", json={"controls": _controls(visual="off")}).json()
    assert body["render"]["video"]["shown"] is False


def test_alteration_off_is_silent(ring_client):
    body = ring_client.post("/api/alteration", json={
        "controls": _controls(audio="off"), "altitude_index": 0,
    }).json()
    assert body["render"]["audio"] == {"source": "off", "url": None, "altitude_coupled": False}


def test_alteration_soundtrack_couples_to_the_given_altitude(ring_client):
    # ring fixture order: cosmos(0), forest(1), abyss(2) — each carries its audio
    body = ring_client.post("/api/alteration", json={
        "controls": _controls(audio="soundtrack"), "altitude_index": 0,
    }).json()
    a = body["render"]["audio"]
    assert a["source"] == "soundtrack" and a["altitude_coupled"] is True
    assert a["url"] == "/media/audio/cosmos/pillars.loop.mp3"
    # a different altitude resolves a different soundtrack url
    body2 = ring_client.post("/api/alteration", json={
        "controls": _controls(audio="soundtrack"), "altitude_index": 2,
    }).json()
    assert body2["render"]["audio"]["url"] == "/media/audio/abyss/whale.loop.mp3"


def test_alteration_accepts_calibration(client):
    body = {"controls": _controls(light=4),
            "calibration": {"mood_gain": 0.5, "overlay_gain": 1.0, "dream_gain": 1.0}}
    resp = client.post("/api/alteration", json=body)
    assert resp.json()["plan"]["grade"]["tone"] == 0.5


def test_alteration_rejects_out_of_range_knob(client):
    resp = client.post("/api/alteration", json={"controls": _controls(left=7)})
    assert resp.status_code == 422


def test_alteration_rejects_bad_audio(client):
    resp = client.post("/api/alteration", json={"controls": _controls(audio="music")})
    assert resp.status_code == 422


def test_alteration_rejects_bad_visual(client):
    resp = client.post("/api/alteration", json={"controls": _controls(visual="dim")})
    assert resp.status_code == 422


def test_clips_returns_the_manifest(client):
    resp = client.get("/api/clips")
    assert resp.status_code == 200
    data = resp.json()
    assert data["clips"][0]["id"] == "forest"
    assert data["clips"][0]["right_variants"]["0"]["file"] == "forest/base.mp4"
    assert data["clips"][0]["annotations"][0]["key"] == "detected.water"


def test_media_versions_endpoint_shape(client):
    resp = client.get("/api/media-versions")
    assert resp.status_code == 200
    assert isinstance(resp.json()["versions"], dict)


def test_media_version_is_a_content_hash(tmp_path, monkeypatch):
    import hashlib

    import simulator.app as appmod

    # Point the media root at a temp dir (the served dir is otherwise fixed).
    monkeypatch.setattr(appmod, "MEDIA_DIR", tmp_path)
    (tmp_path / "clip").mkdir()
    f = tmp_path / "clip" / "base.mp4"
    f.write_bytes(b"hello-bytes")
    assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"hello-bytes").hexdigest()[:12]
    # Absent file -> None (it's simply omitted from the versions map).
    assert appmod._media_version("clip/missing.mp4") is None
    # Re-baked under the same path (new bytes) -> a new token, not the cached one.
    f.write_bytes(b"different-bytes-entirely")
    assert appmod._media_version("clip/base.mp4") == hashlib.sha1(b"different-bytes-entirely").hexdigest()[:12]


def test_retired_selection_endpoints_are_gone(client):
    # The route no longer exists; the static catch-all yields 404 on GET and
    # 405 on the (now-unrouted) POST. Either proves the endpoint is gone.
    assert client.post("/api/select", json={}).status_code in (404, 405)
    assert client.get("/api/catalog/meta").status_code == 404


def test_index_is_served():
    client = TestClient(create_app())
    resp = client.get("/")
    assert resp.status_code == 200
    assert "text/html" in resp.headers["content-type"]
    assert "Alteration" in resp.text


def test_app_assets_are_not_cached(client):
    # Dev iteration relies on the browser never serving stale renderer code.
    resp = client.get("/")
    assert "no-store" in resp.headers.get("cache-control", "")


def test_media_is_cacheable(tmp_path, monkeypatch):
    # Media is static: it must be browser-cacheable so the preloader's fetch (and
    # any fallback) doesn't re-hit the network on every altitude change.
    import simulator.app as appmod

    media = tmp_path / "media"
    (media / "cosmos").mkdir(parents=True)
    (media / "cosmos" / "base.mp4").write_bytes(b"\x00\x00\x00\x18ftyp")
    monkeypatch.setattr(appmod, "MEDIA_DIR", media)
    manifest = tmp_path / "manifest.json"
    manifest.write_text(json.dumps({"clips": []}))

    client = TestClient(create_app(manifest_path=manifest))
    resp = client.get("/media/cosmos/base.mp4")
    assert resp.status_code == 200
    cc = resp.headers.get("cache-control", "")
    # Stored but revalidated: not no-store (so the blob preload can cache it), and
    # not pinned immutable (so a re-sourced/re-cropped clip shows on a plain reload).
    assert "no-store" not in cc
    assert "immutable" not in cc
    assert "no-cache" in cc


@pytest.fixture
def ring_manifest_path(tmp_path):
    p = tmp_path / "manifest.json"
    p.write_text(json.dumps({
        "clips": [
            {"id": "cosmos", "title": "NASA cosmos", "base_file": "cosmos/base.mp4",
             "license": "PD", "source": "NASA", "right_variants": {},
             "annotations": [], "strings": {}},
            {"id": "forest", "title": "Yosemite", "base_file": "forest/base.mp4",
             "license": "poc", "source": "hef-poc",
             "right_variants": {"4": {"file": "forest/right4.mp4"}},
             "annotations": [], "strings": {}},
            {"id": "abyss", "title": "NOAA abyss", "base_file": "abyss/base.mp4",
             "license": "PD", "source": "NOAA", "right_variants": {},
             "annotations": [], "strings": {}},
        ],
        "ring": {
            "scales": [
                {"id": "cosmos", "clip_id": "cosmos", "audio": "cosmos/pillars.loop.mp3"},
                {"id": "forest", "clip_id": "forest", "audio": "forest/amb.loop.mp3"},
                {"id": "abyss", "clip_id": "abyss", "audio": "abyss/whale.loop.mp3"},
            ],
            "transitions": [
                {"from": "cosmos", "to": "forest", "file": "transitions/cosmos__forest.mp4", "model": "placeholder"},
                {"from": "forest", "to": "cosmos", "file": "transitions/cosmos__forest.rev.mp4", "model": "placeholder"},
                {"from": "forest", "to": "abyss", "file": "transitions/forest__abyss.mp4", "model": "placeholder"},
                {"from": "abyss", "to": "forest", "file": "transitions/forest__abyss.rev.mp4", "model": "placeholder"},
                {"from": "abyss", "to": "cosmos", "file": "transitions/abyss__cosmos.mp4", "model": "placeholder"},
                {"from": "cosmos", "to": "abyss", "file": "transitions/abyss__cosmos.rev.mp4", "model": "placeholder"},
            ],
        },
    }))
    return p


@pytest.fixture
def ring_client(ring_manifest_path):
    return TestClient(create_app(manifest_path=ring_manifest_path))


def test_ring_returns_scales_and_transitions(ring_client):
    resp = ring_client.get("/api/ring")
    assert resp.status_code == 200
    data = resp.json()
    assert [s["id"] for s in data["scales"]] == ["cosmos", "forest", "abyss"]
    assert data["scales"][0]["title"] == "NASA cosmos"
    assert len(data["transitions"]) == 6   # 3 edges x 2 directions (clip-pair morphs)


def test_ring_exposes_each_scales_audio(ring_client):
    body = ring_client.get("/api/ring").json()
    by_id = {s["id"]: s for s in body["scales"]}
    # every scale advertises its soundtrack path (audio spec §5.1)
    assert by_id["cosmos"]["audio"] == "cosmos/pillars.loop.mp3"
    assert by_id["abyss"]["audio"].endswith(".mp3")
    assert "audio" in by_id["forest"]


def test_ring_advance_inward_one_step(ring_client):
    resp = ring_client.post("/api/ring/advance",
                            json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"})
    assert resp.status_code == 200
    data = resp.json()
    assert data["to_index"] == 1
    assert data["target_clip_id"] == "forest"
    assert data["wrapped"] is False
    step = data["steps"][0]
    assert step["from_clip"] == "cosmos"            # threaded current clip
    assert step["to_clip"] == "forest"              # picked destination
    assert step["file"] == "transitions/cosmos__forest.mp4"   # morph matches landing


def test_ring_advance_picks_then_returns_matching_morph(ring_client):
    # The morph's `to_clip` always equals the locked target, and its file is the
    # directed morph for (from_clip -> to_clip).
    data = ring_client.post("/api/ring/advance",
                            json={"from_index": 0, "delta": 1, "from_clip_id": "cosmos"}).json()
    step = data["steps"][-1]
    assert step["to_clip"] == data["target_clip_id"]
    assert step["file"] == f"transitions/cosmos__{data['target_clip_id']}.mp4"


def test_ring_advance_wraps_smallest_to_largest(ring_client):
    resp = ring_client.post("/api/ring/advance", json={"from_index": 2, "delta": 1})
    data = resp.json()
    assert data["to_index"] == 0
    assert data["target_clip_id"] == "cosmos"
    assert data["wrapped"] is True


def test_ring_advance_outward_plays_reverse_morph(ring_client):
    resp = ring_client.post("/api/ring/advance",
                            json={"from_index": 1, "delta": -1, "from_clip_id": "forest"})
    data = resp.json()
    assert data["to_index"] == 0
    # forest(1) -> cosmos(0) is a zoom-out: the directed (forest, cosmos) morph
    # resolves to the .rev companion.
    assert data["steps"][0]["file"] == "transitions/cosmos__forest.rev.mp4"
    assert data["target_clip_id"] == "cosmos"


def test_ring_advance_small_delta_chains_not_fast(ring_client):
    # 2 detents is below the simulator's fast-spin threshold (3) -> full chain.
    resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 2})
    data = resp.json()
    assert data["fast"] is False
    assert len(data["steps"]) == 2


def test_ring_advance_fast_spin_plays_whole_chain_blended(ring_client):
    # A fast spin (4 detents batched) crosses the threshold -> the whole chain is
    # kept and played fast (each step blended), not collapsed to one pass.
    resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": 4})
    data = resp.json()
    assert data["fast"] is True
    assert len(data["steps"]) == 4
    assert all(s["blended"] for s in data["steps"])
    assert data["to_index"] == 1


def test_ring_404_when_no_ring_in_manifest(client):
    assert client.get("/api/ring").status_code == 404
    assert client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).status_code == 404


def test_ring_advance_rejects_bad_delta(ring_client):
    resp = ring_client.post("/api/ring/advance", json={"from_index": 0, "delta": "x"})
    assert resp.status_code == 422


# --- rotating clip pool (content-pipeline §11.1) ---


@pytest.fixture
def pool_client(tmp_path):
    p = tmp_path / "manifest.json"
    members = ["coast_a", "coast_b", "coast_c", "coast_d"]
    clips = [{"id": "cosmos", "title": "Cosmos", "base_file": "cosmos/base.mp4",
              "license": "PD", "source": "NASA", "right_variants": {},
              "annotations": [], "affect": [], "strings": {}}]
    clips += [{"id": m, "title": m, "base_file": f"{m}/base.mp4", "license": "PD",
               "source": "NPS", "right_variants": {}, "annotations": [], "affect": [],
               "strings": {}} for m in members]
    p.write_text(json.dumps({
        "clips": clips,
        "ring": {
            "scales": [
                {"id": "cosmos", "clip_id": "cosmos"},
                {"id": "coast", "clip_id": members[0], "pool": members},
            ],
            "transitions": [
                {"file": "transitions/cosmos-coast.mp4"},
                {"file": "transitions/coast-cosmos.mp4"},
            ],
        },
    }))
    return TestClient(create_app(manifest_path=p)), members


def test_ring_exposes_each_scales_pool(pool_client):
    client, members = pool_client
    data = client.get("/api/ring").json()
    coast = next(s for s in data["scales"] if s["id"] == "coast")
    assert [m["clip_id"] for m in coast["pool"]] == members
    # a single-clip scale still reports a pool of one
    cosmos = next(s for s in data["scales"] if s["id"] == "cosmos")
    assert [m["clip_id"] for m in cosmos["pool"]] == ["cosmos"]


def test_advance_into_a_pool_lands_on_a_member(pool_client):
    client, members = pool_client
    # landing on the coast scale (index 1) must pick one of its pool members
    seen = set()
    for _ in range(40):
        data = client.post("/api/ring/advance", json={"from_index": 0, "delta": 1}).json()
        assert data["to_index"] == 1
        assert data["target_clip_id"] in members
        seen.add(data["target_clip_id"])
    # over many landings the random pick should cover more than one member
    assert len(seen) > 1


def test_delta_zero_is_an_initial_pool_pick(pool_client):
    client, members = pool_client
    # a no-op (delta 0) advance on the pool scale is the initial / re-roll pick
    data = client.post("/api/ring/advance", json={"from_index": 1, "delta": 0}).json()
    assert data["to_index"] == 1
    assert data["steps"] == []
    assert data["target_clip_id"] in members


# --- author mode (content-pipeline §11.5) ---


@pytest.fixture
def author_client(tmp_path):
    p = tmp_path / "manifest.json"
    p.write_text(json.dumps({
        "clips": [{
            "id": "abyss_wow", "title": "World of Water", "base_file": "abyss_wow/base.mp4",
            "license": "PD", "source": "NOAA", "right_variants": {},
            "annotations": [], "affect": [], "strings": {"en": {}},
        }],
    }))
    return TestClient(create_app(manifest_path=p)), p


def test_author_clip_persists_labels_and_keeps_provenance(author_client):
    client, path = author_client
    body = {
        "clip_id": "abyss_wow",
        "annotations": [{"key": "detected.jelly", "salience": 4,
                         "appear": 0.0, "disappear": 0.5,
                         "track": [{"t": 0.0, "box": [0.1, 0.2, 0.1, 0.1]}]}],
        "affect": [{"key": "feel.unease", "at": [0.5, 0.5], "min_level": 1}],
        "strings": {"en": {"detected.jelly": ["jelly", "comb jelly", "Ctenophora", "Ctenophora · cilia rows"],
                           "feel.unease": ["uh", "unease", "disquiet", "a creeping disquiet"]}},
    }
    resp = client.post("/api/author/clip", json=body)
    assert resp.status_code == 200 and resp.json()["ok"] is True
    # persisted to disk
    saved = json.loads(path.read_text())["clips"][0]
    assert saved["annotations"][0]["key"] == "detected.jelly"
    assert saved["affect"][0]["key"] == "feel.unease"
    assert saved["strings"]["en"]["detected.jelly"][2] == "Ctenophora"
    # provenance preserved (only authored content replaced)
    assert saved["base_file"] == "abyss_wow/base.mp4"
    assert saved["license"] == "PD"
    # reflected by /api/clips without a restart
    served = client.get("/api/clips").json()["clips"][0]
    assert served["annotations"][0]["key"] == "detected.jelly"


def test_author_clip_unknown_clip_404(author_client):
    client, _ = author_client
    resp = client.post("/api/author/clip", json={"clip_id": "nope", "annotations": []})
    assert resp.status_code == 404


def test_author_track_unknown_clip_404(author_client):
    client, _ = author_client
    resp = client.post("/api/author/track", json={
        "clip_id": "nope", "key": "detected.x", "seed_box": [0.1, 0.1, 0.1, 0.1]})
    assert resp.status_code == 404


_REAL_CLIP = __import__("pathlib").Path("simulator/sample_media/abyss_wow/base.mp4")


@pytest.mark.skipif(not _REAL_CLIP.exists(),
                    reason="abyss_wow base absent (run simulator/build_pool_manifest.py)")
def test_author_track_on_real_clip_returns_keyframes():
    # Against the real default manifest + footage: the tracker yields a sparse track.
    client = TestClient(create_app())
    resp = client.post("/api/author/track", json={
        "clip_id": "abyss_wow", "key": "detected.jelly",
        "seed_box": [0.2, 0.3, 0.15, 0.18], "seed_t": 0.0,
        "salience": 4, "n_keyframes": 5, "max_frames": 30,
    })
    assert resp.status_code == 200
    ann = resp.json()
    assert ann["key"] == "detected.jelly"
    assert 2 <= len(ann["track"]) <= 5
    assert 0.0 <= ann["appear"] <= ann["disappear"] <= 1.0
