"""Unit tests for scripts/migrate_v2_layout.py.

Builds synthetic project trees in pytest tmp_path fixtures and verifies the
migration's planning and execution.

Run:
    pytest recoil/tests/test_migrate_v2_layout.py -v
"""

import json
import os
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = REPO_ROOT / "scripts"
if str(REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(REPO_ROOT))
if str(SCRIPTS) not in sys.path:
    sys.path.insert(0, str(SCRIPTS))

import migrate_v2_layout as M


# ── Fixtures ───────────────────────────────────────────────────────

@pytest.fixture
def synthetic_projects(tmp_path, monkeypatch):
    """Build a synthetic projects tree with one normal and one frozen project."""
    projects = tmp_path / "projects"
    projects.mkdir()

    # Normal project: tartarus-like
    t = projects / "tartarus"
    (t / "output" / "refs" / "_canonical" / "characters" / "jade").mkdir(parents=True)
    (t / "output" / "refs" / "_canonical" / "characters" / "jade" / "hero.png").write_bytes(b"PNG-fake")
    (t / "output" / "refs" / "_canonical" / "characters" / "jade" / "front.png").write_bytes(b"PNG-fake")
    (t / "output" / "refs" / "_canonical" / "characters" / "jade" / "joy_v01.png").write_bytes(b"PNG-fake")
    (t / "output" / "refs" / "characters" / "kit").mkdir(parents=True)
    (t / "output" / "refs" / "characters" / "kit" / "kit_hero_v01.png").write_bytes(b"PNG-fake")
    (t / "output" / "refs" / "locations" / "medbay").mkdir(parents=True)
    (t / "output" / "refs" / "locations" / "medbay" / "wide.png").write_bytes(b"PNG-fake")
    (t / "output" / "refs" / "props" / "blaster").mkdir(parents=True)
    (t / "output" / "refs" / "props" / "blaster" / "hero.png").write_bytes(b"PNG-fake")
    (t / "output" / "refs" / "_test_1024").mkdir(parents=True)
    (t / "output" / "refs" / "_test_1024" / "x.png").write_bytes(b"PNG-fake")
    (t / "output" / "frames" / "ep_001").mkdir(parents=True)
    (t / "output" / "frames" / "ep_001" / "kf_001.png").write_bytes(b"PNG-fake")
    (t / "output" / "video" / "ep_001").mkdir(parents=True)
    (t / "output" / "video" / "ep_001" / "shot_001.mp4").write_bytes(b"MP4-fake")
    (t / "output" / "manifests").mkdir(parents=True)
    manifest = {
        "shots": [
            {"shot_id": "EP001_SH01", "ref": "output/refs/characters/jade/hero.png"},
            {"shot_id": "EP001_SH02", "loc_ref": "output/refs/locations/medbay/wide.png"},
        ],
        "output_dir": "output/frames/ep_001",
    }
    (t / "output" / "manifests" / "ep_001.json").write_text(json.dumps(manifest))
    (t / "output" / "bundles").mkdir(parents=True)
    # symlink
    (t / "refs").symlink_to("output/refs")
    (t / "project_config.json").write_text(json.dumps({
        "schema_version": 2,
        "project_type": "microdrama",
    }))

    # Frozen project: driver-beware-like
    db = projects / "driver-beware"
    (db / "output").mkdir(parents=True)
    (db / "output" / "frames" / "ep_001").mkdir(parents=True)
    (db / "output" / "frames" / "ep_001" / "x.png").write_bytes(b"PNG-fake")
    (db / "project_config.json").write_text(json.dumps({
        "schema_version": 2,
        "project_type": "client_video",
        "layout_freeze": "ad-hoc",
    }))

    # Monkeypatch projects_root + scratch_root
    monkeypatch.setattr(M, "projects_root", lambda: projects)
    monkeypatch.setattr(M, "scratch_root", lambda: tmp_path / "_scratch")

    return projects


class _DryRunArgs:
    dry_run = True
    execute = False
    scan_paths = False


class _ExecuteArgs:
    dry_run = False
    execute = True
    scan_paths = False


# ── Tests ──────────────────────────────────────────────────────────

def test_frozen_project_skipped(synthetic_projects, monkeypatch):
    """driver-beware (layout_freeze: ad-hoc) — original spec asserted plans == [],
    but per 2026-05-26 JT decision the layout_freeze skip was REMOVED from
    plan_project_migration (see scripts/migrate_v2_layout.py lines 381-384 and the
    NOTE in test_full_execute below). Frozen projects now plan + execute normally;
    JT holds his own backup. This test now verifies that a plan IS produced and
    that the produced plan still respects the v2 skeleton — the freeze flag is
    informational, not a gate.
    """
    plans = M.plan_project_migration(synthetic_projects / "driver-beware")
    assert plans != [], "After 2026-05-26 freeze-skip removal, frozen projects must plan normally"
    mkdirs = {str(p.src.relative_to(synthetic_projects / "driver-beware"))
              for p in plans if p.op == "mkdir"}
    # v2 skeleton is still planned for frozen projects (since skip was removed)
    assert any(d in mkdirs for d in M.V2_DIRECTORIES_TO_CREATE), \
        f"Frozen project should still plan v2 skeleton — got mkdirs: {mkdirs}"


def test_symlink_unlinked(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    ops = [p for p in plans if p.op == "unlink_symlink"]
    assert any(p.src.name == "refs" for p in ops)


def test_v2_directories_planned(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    mkdirs = {str(p.src.relative_to(synthetic_projects / "tartarus"))
              for p in plans if p.op == "mkdir"}
    for required in M.V2_DIRECTORIES_TO_CREATE:
        assert required in mkdirs, f"Missing mkdir for {required}"


def test_canonical_flatten(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    move_dests = [str(p.dst) for p in plans if p.op == "move"]
    # jade hero from _canonical/ goes to assets/identity/jade/
    assert any("assets/identity/jade/" in d for d in move_dests), \
        f"Expected jade hero under assets/identity/, got: {move_dests}"


def test_classification_into_turn_expr(synthetic_projects):
    """Verify taxonomy.classify_legacy_filename routes _canonical content correctly.

    Spec note: the original spec assertion expected `front.png` → assets/turn/, but
    taxonomy.classify_legacy_filename (Phase 5 SSOT, kept anchor-less by design)
    requires `_front` (with underscore prefix) to trigger the turn rule —
    matching real-world filenames like `jade_front_v01.png`. Bare `front.png`
    has no preceding `_`, so it stays in `identity`. The expr rule does match
    `joy_v01.png` because EMOTION_LABELS contains bare `joy` without leading
    underscore.

    This test now asserts the actual taxonomy behavior — see classify_legacy_filename
    docstring at recoil/pipeline/_lib/taxonomy.py:330 for the rule definition.
    """
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    dests = [str(p.dst) for p in plans if p.op == "move"]
    # joy_v01.png → expr/ (emotion label matches anywhere in filename)
    assert any("assets/expr/jade/" in d for d in dests), \
        f"Expected jade joy expression under assets/expr/, got: {dests}"
    # Bare "front.png" lacks the `_front` underscore prefix, so it stays identity.
    # Verify the turn-classification rule by checking the taxonomy directly:
    from recoil.pipeline._lib.taxonomy import classify_legacy_filename
    assert classify_legacy_filename("jade_front_v01.png") == "turn", \
        "Properly-formed legacy turnaround filename should classify as turn"
    assert classify_legacy_filename("front.png") == "identity", \
        "Bare front.png lacks `_front` prefix — stays identity (taxonomy design)"


def test_intermediates_move_to_sequences(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    dests = [str(p.dst) for p in plans if p.op == "move"]
    assert any("sequences/ep_001" in d for d in dests), \
        f"frames/ep_001 should move to sequences/ep_001 — got: {dests}"


def test_video_moves_to_renders(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    dests = [str(p.dst) for p in plans if p.op == "move"]
    assert any("renders/ep_001" in d for d in dests)


def test_debug_sweep(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    dests = [str(p.dst) for p in plans if p.op == "move"]
    assert any("_history/debug/_test_1024" in d for d in dests), \
        f"_test_1024 should be swept to _history/debug, got: {dests}"


def test_layout_version_written(synthetic_projects):
    plans = M.plan_project_migration(synthetic_projects / "tartarus")
    cfg_writes = [p for p in plans if p.op == "write_config"]
    assert len(cfg_writes) == 1
    assert cfg_writes[0].src.name == "project_config.json"


def test_taxonomy_rename_applied():
    # Non-conformant input → conformant output
    out = M._to_conformant_filename("hero.png", subject="jade", v2_kind="identity")
    assert out == "jade_identity_hero_v01.png"
    out = M._to_conformant_filename("kit_hero_v01.png", subject="kit", v2_kind="identity")
    assert out == "kit_identity_hero_v01.png"
    out = M._to_conformant_filename("front.png", subject="jade", v2_kind="turn")
    assert out == "jade_turn_front_v01.png"
    # Already-conformant → passthrough
    out = M._to_conformant_filename("jade_identity_hero_v01.png", subject="jade", v2_kind="identity")
    assert out == "jade_identity_hero_v01.png"


def test_translate_string_prefix_map():
    assert M._translate_string("output/refs/characters/jade/hero.png") == "assets/identity/jade/hero.png"
    assert M._translate_string("output/refs/_canonical/locations/medbay/wide.png") == "assets/loc/medbay/wide.png"
    assert M._translate_string("output/frames/ep_001/kf.png") == "sequences/ep_001/kf.png"
    assert M._translate_string("output/video/ep_001/shot.mp4") == "renders/ep_001/shot.mp4"
    assert M._translate_string("output/manifests/ep.json") == "state/manifests/ep.json"
    assert M._translate_string("unrelated/path.txt") == "unrelated/path.txt"


def test_full_execute(synthetic_projects):
    """Run the full migration end-to-end and assert post-state."""
    args = _ExecuteArgs()
    # Skip preflight prompt
    M.make_backup_tarball(args)  # writes to _scratch

    for proj in M.iter_target_projects():
        # NOTE 2026-05-26: layout_freeze skip removed in production loop;
        # test mirrors that.
        plans = M.plan_project_migration(proj)
        stats = M.MigrationStats()
        M.execute_plans(plans, proj, args, stats)

    tartarus = synthetic_projects / "tartarus"
    # output/ is gone
    assert not (tartarus / "output").exists(), "output/ should be removed"
    # refs symlink is gone
    assert not (tartarus / "refs").exists()
    # v2 dirs exist (taxonomy: front.png stays identity; only expr created from joy_v01.png)
    # Spec note: original test asserted assets/turn/jade exists, but fixture has bare
    # `front.png` which the taxonomy classifies as identity (no `_front` prefix).
    # See test_classification_into_turn_expr above.
    assert (tartarus / "assets" / "identity" / "jade").is_dir()
    assert (tartarus / "assets" / "expr" / "jade").is_dir()
    assert (tartarus / "assets" / "loc" / "medbay").is_dir()
    assert (tartarus / "assets" / "prop" / "blaster").is_dir()
    assert (tartarus / "sequences" / "ep_001").is_dir()
    assert (tartarus / "renders" / "ep_001").is_dir()
    assert (tartarus / "state" / "manifests").is_dir()
    assert (tartarus / "_history" / "debug").is_dir()
    # Config bumped
    cfg = json.loads((tartarus / "project_config.json").read_text())
    assert cfg["layout_version"] == 2
    # Driver-beware: spec NOTE in this function and migrate_v2_layout.py:381-384
    # confirm the layout_freeze skip was REMOVED on 2026-05-26. So driver-beware
    # now migrates normally — output/ is gone, assets/ exists. The original spec
    # assertions (output/ remains, assets/ absent) were stale relative to that
    # decision. Updated to match production behavior.
    db = synthetic_projects / "driver-beware"
    assert not (db / "output").exists(), \
        "Post 2026-05-26 freeze-skip removal: driver-beware output/ IS removed"
    assert (db / "sequences" / "ep_001").is_dir(), \
        "Post 2026-05-26 freeze-skip removal: driver-beware sequences/ exists"
    db_cfg = json.loads((db / "project_config.json").read_text())
    assert db_cfg["layout_version"] == 2, \
        "Post 2026-05-26 freeze-skip removal: driver-beware also bumped to layout v2"


def test_validate_project_catches_surviving_output(tmp_path):
    proj = tmp_path / "bad_project"
    (proj / "output").mkdir(parents=True)
    (proj / "project_config.json").write_text(json.dumps({"layout_version": 2}))
    issues = M.validate_project(proj)
    assert any("output/ still exists" in i for i in issues)


def test_validate_project_catches_missing_assets(tmp_path):
    proj = tmp_path / "bad_project_2"
    proj.mkdir()
    (proj / "project_config.json").write_text(json.dumps({"layout_version": 2}))
    issues = M.validate_project(proj)
    assert any("assets/ missing" in i for i in issues)


def test_validate_frozen_project_returns_empty(tmp_path):
    """Post 2026-05-26: layout_freeze skip was removed from validate_project too
    (see scripts/migrate_v2_layout.py:804 NOTE). The spec's original assertion
    that frozen projects return [] no longer holds — frozen projects are now
    validated like any other v2 project. This test verifies that the validator
    runs (does not raise) on a frozen project and surfaces the expected issues.
    """
    proj = tmp_path / "frozen"
    proj.mkdir()
    (proj / "project_config.json").write_text(json.dumps({"layout_freeze": "ad-hoc"}))
    issues = M.validate_project(proj)
    # Post-freeze-skip-removal: frozen projects get validated; this one has
    # neither assets/ nor layout_version==2, so we expect those issues to surface.
    assert any("assets/ missing" in i for i in issues), \
        f"Expected assets/ missing issue, got: {issues}"
    assert any("layout_version" in i for i in issues), \
        f"Expected layout_version issue, got: {issues}"


# ── R9.5 regression coverage for R7.1 / R7.2 / R7.4 ───────────────────


def test_legacy_output_archive_migrated(tmp_path, monkeypatch):
    """R9.2/R9.3/R9.4: a project carrying output/_archive/ from a pre-fix
    server.py archive op must migrate cleanly. Per-file moves land each
    sidecar at _history/archives/ preserving its inner path, and the
    Step 3g++ post-move walk translates `archived_from` v1 prefixes to v2.
    """
    projects = tmp_path / "projects"
    projects.mkdir()
    t = projects / "tartarus"
    legacy_arch = (
        t / "output" / "_archive" / "output" / "refs" / "characters" / "x"
    )
    legacy_arch.mkdir(parents=True)
    # The actual archived media (just a placeholder — migration moves bytes, not validates them)
    (legacy_arch / "foo.png").write_bytes(b"PNG-fake")
    # The sidecar with a stale v1 archived_from
    sidecar = legacy_arch / "foo.png.json"
    sidecar.write_text(json.dumps({
        "schema_version": 1,
        "status": "archived",
        "archived_from": "output/refs/characters/x/foo.png",
    }))
    # Minimal project_config so plan_project_migration doesn't choke
    (t / "project_config.json").write_text(json.dumps({
        "schema_version": 2,
        "project_type": "microdrama",
    }))

    monkeypatch.setattr(M, "projects_root", lambda: projects)
    monkeypatch.setattr(M, "scratch_root", lambda: tmp_path / "_scratch")

    args = _ExecuteArgs()
    plans = M.plan_project_migration(t)
    stats = M.MigrationStats()
    M.execute_plans(plans, t, args, stats)

    # The migrated sidecar should sit at _history/archives/ preserving the
    # inner path under output/_archive/ (Step 3d.5 strips the leading
    # output/_archive prefix). The inner "output/" stays put — only JSON
    # contents are translated by Step 3g++.
    migrated_sc = (
        t / "_history" / "archives" / "output" / "refs" / "characters" / "x" / "foo.png.json"
    )
    assert migrated_sc.is_file(), \
        f"Sidecar should land at {migrated_sc} — tree: {list((t / '_history' / 'archives').rglob('*'))}"

    # The companion media should also have moved
    migrated_media = (
        t / "_history" / "archives" / "output" / "refs" / "characters" / "x" / "foo.png"
    )
    assert migrated_media.is_file(), \
        f"Media should land at {migrated_media}"

    # The legacy output/_archive tree should be empty/gone
    assert not (t / "output" / "_archive").exists(), \
        "Legacy output/_archive should be removed after per-file moves"

    # The JSON archived_from field MUST have been translated v1 → v2
    sc_data = json.loads(migrated_sc.read_text(encoding="utf-8"))
    assert sc_data["archived_from"] == "assets/identity/x/foo.png", \
        f"archived_from should be translated to v2 form, got: {sc_data['archived_from']!r}"


def test_for_project_raises_on_missing_dir(tmp_path, monkeypatch):
    """R7.4: ProjectPaths.for_project on a non-existent project slug raises
    FileNotFoundError rather than silently returning a paths bundle pointed
    at a non-existent root.
    """
    from recoil.core.paths import ProjectPaths

    # Point projects root at an empty tmp dir; the slug we look up does not
    # exist under it. The data-root sentinel must be present so projects_root()
    # resolves and for_project reaches its own FileNotFoundError.
    (tmp_path / ".recoil-data-root").write_text("recoil-data-root\n")
    monkeypatch.setenv("RECOIL_PROJECTS_ROOT", str(tmp_path))

    with pytest.raises(FileNotFoundError):
        ProjectPaths.for_project("does_not_exist")


def test_restore_from_archive_rejects_traversal(tmp_path):
    """R7.1: restore_from_archive refuses sidecars that try to write outside
    the project tree via `..` traversal in archived_from.
    """
    from recoil.workspace import sidecar as ws_sidecar

    project_dir = tmp_path / "proj"
    archive_root = project_dir / "_history" / "archives"
    archive_root.mkdir(parents=True)

    # Place a fake archived media + sidecar
    archive_path = archive_root / "evil.png"
    archive_path.write_bytes(b"PNG-fake")
    sc_path = archive_root / "evil.png.json"
    sc_path.write_text(json.dumps({
        "schema_version": 1,
        "status": "archived",
        "archived_from": "../../../etc/passwd",
    }))

    # Snapshot anything outside project_dir before — we expect no creation
    parent = project_dir.parent
    pre = set(parent.rglob("*"))

    with pytest.raises(ValueError):
        ws_sidecar.restore_from_archive(archive_path, project_dir)

    post = set(parent.rglob("*"))
    # No new file should have appeared anywhere outside project_dir.
    new_outside = {p for p in (post - pre) if project_dir not in p.parents and p != project_dir}
    assert new_outside == set(), \
        f"Traversal restore should not have created files outside project: {new_outside}"
    # The original archive media must still be in place (no destructive move)
    assert archive_path.is_file(), "archive media should remain untouched after rejected restore"


def test_restore_translates_v1_prefix(tmp_path):
    """R7.2: pre-v2 sidecars carry archived_from strings like
    'output/refs/characters/jade/hero.png'. restore_from_archive chains
    translate_legacy_path all the way to the v3 canonical layout
    (assets/char/jade/hero.png) before moving the file — otherwise the
    deprecated output/ tree (or the stale assets/identity v2 tree) gets
    resurrected on restore. (REC-185: assets/char is canonical.)
    """
    from recoil.workspace import sidecar as ws_sidecar

    project_dir = tmp_path / "proj"
    archive_root = project_dir / "_history" / "archives"
    archive_root.mkdir(parents=True)

    # The archived media (will be moved out during restore)
    archive_path = archive_root / "hero.png"
    archive_path.write_bytes(b"PNG-fake")
    sc_path = archive_root / "hero.png.json"
    sc_path.write_text(json.dumps({
        "schema_version": 1,
        "status": "archived",
        "archived_from": "output/refs/characters/jade/hero.png",
    }))

    restored = ws_sidecar.restore_from_archive(archive_path, project_dir)

    # Must land at the v3 canonical path — translated from
    # output/refs/characters/.../ to assets/char/.../
    expected = project_dir / "assets" / "char" / "jade" / "hero.png"
    assert restored == expected, \
        f"Restored to {restored} but expected v3 path {expected}"
    assert restored.is_file(), "Restored media should exist at the translated path"
    # No deprecated output/ tree should be resurrected
    assert not (project_dir / "output").exists(), \
        "Restore must not create the deprecated output/ tree"
