#!/usr/bin/env python3
"""
Testes de unidade e integração do pipeline Balde C portado.

Cobre:
  1. vlib.py — funções puras (coverage, m1_grid, CAM_ANGLE, get_timeline)
  2. unified.py — motor determinístico com fixtures sintéticas (sem vídeo real)
  3. run_pipeline_no_director.py — fluxo completo --skip-perception
  4. Formato dos JSONs de entrada/saída (placements, media_paths, UNI)
  5. Resiliência: pipeline não crasha com caches vazios

Uso: ./venv/bin/python test_pipeline.py
"""
import json, os, sys, subprocess, tempfile, shutil, unittest

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)


# ══════════════════════════════════════════════════════════════════════════════
# Fixtures
# ══════════════════════════════════════════════════════════════════════════════

def make_workdir(angles=None):
    """Cria workdir temporário com placements.json + media_paths.json sintéticos.
    angles: [(cam_name, startFrame, durationFrames), ...] — default 3 cams de 900 frames."""
    if angles is None:
        angles = [
            ("cam1", 0, 900),
            ("cam2", 0, 900),
            ("cam3", 100, 800),
        ]
    tmp = tempfile.mkdtemp(prefix="bd_test_")
    clips = []
    paths = {}
    for cam, start, dur in angles:
        footage = f"FOOTAGE_{cam}"
        fname = f"{cam}_{footage}.mp4"
        clips.append({
            "name": fname, "cam": cam,
            "startFrame": start, "durationFrames": dur,
            "confidence": 1.0, "orange": False
        })
        paths[footage] = f"/tmp/{fname}"
    with open(os.path.join(tmp, "placements.json"), "w") as f:
        json.dump({"clips": clips}, f)
    with open(os.path.join(tmp, "media_paths.json"), "w") as f:
        json.dump(paths, f)
    os.makedirs(os.path.join(tmp, "versions"), exist_ok=True)
    # Empty JSONs for blocked layers
    for ph in ["missed_inserts.json", "content_inserts.json", "emotion_inserts.json",
               "variety_inserts.json", "luma_switches.json", "stab_switches.json",
               "judge_switches_ok.json", "content_map.json", "content_map_dense.json",
               "storyboard.json", "moments.json"]:
        with open(os.path.join(tmp, ph), "w") as f:
            f.write("[]")
    return tmp


# ══════════════════════════════════════════════════════════════════════════════
# Testes: vlib.py
# ══════════════════════════════════════════════════════════════════════════════

class VlibTests(unittest.TestCase):
    """vlib.py: funções puras que não dependem de ffmpeg/vídeo real."""

    def setUp(self):
        self.old_cwd = os.getcwd()
        self.tmp = make_workdir()
        os.chdir(self.tmp)

    def tearDown(self):
        os.chdir(self.old_cwd)
        shutil.rmtree(self.tmp, ignore_errors=True)

    def test_cam_angle_derivation(self):
        """CAM_ANGLE/CAMS derivam de placements.json."""
        import vlib
        # Recarrega pra pegar o placements do tmp
        self.assertIn("cam1", vlib.ANGLE_CAM.values())
        self.assertIn("cam2", vlib.ANGLE_CAM.values())
        self.assertIn("cam3", vlib.ANGLE_CAM.values())
        self.assertEqual(len(vlib.CAMS), 3)

    def test_coverage(self):
        """coverage() retorna ranges mergeados por ângulo."""
        import vlib
        cov = vlib.coverage()
        self.assertIn(1, cov)  # cam1 → angle 1
        self.assertIn(2, cov)  # cam2 → angle 2
        self.assertIn(3, cov)  # cam3 → angle 3
        # cam1 cobre [0, 900)
        self.assertEqual(cov[1], [(0, 900)])
        # cam3 cobre [100, 900)
        self.assertEqual(cov[3], [(100, 900)])

    def test_covers(self):
        """covers() checa se ângulo cobre um span."""
        import vlib
        cov = vlib.coverage()
        self.assertTrue(vlib.covers(cov, 1, 0, 300))
        self.assertTrue(vlib.covers(cov, 1, 500, 899))
        self.assertFalse(vlib.covers(cov, 3, 0, 50))   # cam3 começa em 100
        self.assertTrue(vlib.covers(cov, 3, 200, 500))

    def test_m1_grid(self):
        """m1_grid() deriva grade da cobertura quando não tem track M1."""
        import vlib
        grid = vlib.m1_grid()
        self.assertTrue(len(grid) > 0)
        # Deve cobrir do 0 ao fim da cam mais longa (900)
        self.assertEqual(grid[0][0], 0)
        self.assertGreaterEqual(grid[-1][1], 900)

    def test_get_timeline_from_placements(self):
        """get_timeline() sintetiza timeline de placements.json."""
        import vlib
        tl = vlib.get_timeline()
        self.assertIn("tracks", tl)
        self.assertEqual(len(tl["tracks"]), 3)
        # Cada track deve ter clips com angleIndex
        for track in tl["tracks"]:
            self.assertIn("clips", track)
            for clip in track["clips"]:
                self.assertIn("angleIndex", clip)
                self.assertIn("startFrame", clip)
                self.assertIn("durationFrames", clip)

    def test_tc_format(self):
        """tc() formata timecode HH:MM:SS:FF."""
        import vlib
        self.assertEqual(vlib.tc(0), "00:00:00:00")
        self.assertEqual(vlib.tc(1), "00:00:01:00")
        self.assertEqual(vlib.tc(60), "00:01:00:00")
        self.assertEqual(vlib.tc(3600), "01:00:00:00")

    def test_load_jerk_empty(self):
        """load_jerk() retorna dict vazio sem shakecache/."""
        import vlib
        jerk = vlib.load_jerk()
        self.assertIsInstance(jerk, dict)
        self.assertEqual(len(jerk), 0)  # sem shakecache

    def test_load_face_empty(self):
        """load_face() retorna dict vazio sem facecache/."""
        import vlib
        face = vlib.load_face()
        self.assertIsInstance(face, dict)
        self.assertEqual(len(face), 0)  # sem facecache

    def test_content_at_empty(self):
        """content_at() retorna {} sem content_map_dense.json."""
        import vlib
        result = vlib.content_at(0, 900)
        self.assertIsInstance(result, dict)
        self.assertEqual(len(result), 0)

    def test_mean_jerk_none(self):
        """mean_jerk() retorna None sem dados de jerk."""
        import vlib
        jerk = {}
        result = vlib.mean_jerk(jerk, "cam1", 0, 300)
        self.assertIsNone(result)

    def test_shake_windows_empty(self):
        """shake_windows() retorna [(w0,w1,None)] sem dados."""
        import vlib
        jerk = {}
        ws = vlib.shake_windows(jerk, "cam1", 0, 300, win=60)
        self.assertEqual(len(ws), 5)  # 300/60 = 5 janelas
        for w0, w1, j in ws:
            self.assertIsNone(j)

    def test_face_window_none(self):
        """face_window() retorna None sem facecache."""
        import vlib
        face = {}
        result = vlib.face_window(face, "cam1", 0, 300)
        self.assertIsNone(result)


# ══════════════════════════════════════════════════════════════════════════════
# Testes: unified.py (motor determinístico)
# ══════════════════════════════════════════════════════════════════════════════

class UnifiedTests(unittest.TestCase):
    """Motor unified.py com fixtures sintéticas (sem vídeo real)."""

    def setUp(self):
        self.old_cwd = os.getcwd()
        self.tmp = make_workdir()
        os.chdir(self.tmp)

    def tearDown(self):
        os.chdir(self.old_cwd)
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _run_unified(self):
        """Roda unified.py no workdir atual. Retorna (rc, stdout, stderr)."""
        # unified.py hard-require N_director.json como base.
        # Cria um mínimo: primeiro ângulo cobre tudo.
        n_dir = os.path.join(self.tmp, "versions", "N_director.json")
        if not os.path.exists(n_dir):
            P = json.load(open(os.path.join(self.tmp, "placements.json")))
            clips = P.get("clips", [])
            if clips:
                first_cam = clips[0]["cam"]
                cams = sorted({c["cam"] for c in clips})
                cam_angle = {c: i+1 for i, c in enumerate(cams)}
                first_angle = cam_angle.get(first_cam, 1)
            else:
                first_angle = 1
            with open(n_dir, "w") as f:
                json.dump({"cutpoints": [{"frame": 0, "angle": first_angle}]}, f)

        unified = os.path.join(SCRIPT_DIR, "unified.py")
        proc = subprocess.run(
            [sys.executable, unified],
            cwd=self.tmp, capture_output=True, text=True, timeout=120
        )
        return proc

    def test_unified_runs_without_caches(self):
        """unified.py roda mesmo sem facecache/shakecache (estágios pulados)."""
        proc = self._run_unified()
        self.assertEqual(proc.returncode, 0,
                         f"unified falhou: {proc.stderr[-500:]}")
        # Deve ter gerado versions/UNI.json
        uni_path = os.path.join(self.tmp, "versions", "UNI.json")
        self.assertTrue(os.path.exists(uni_path),
                        f"UNI.json não gerado. stderr: {proc.stderr[-500:]}")

    def test_uni_output_format(self):
        """versions/UNI.json tem estrutura esperada."""
        self._run_unified()
        uni_path = os.path.join(self.tmp, "versions", "UNI.json")
        uni = json.load(open(uni_path))
        self.assertIn("cutpoints", uni)
        self.assertIsInstance(uni["cutpoints"], list)
        self.assertGreater(len(uni["cutpoints"]), 0,
                           "motor deveria gerar ao menos 1 cutpoint")
        # Cada cutpoint tem frame + angle
        for cp in uni["cutpoints"]:
            self.assertIn("frame", cp)
            self.assertIn("angle", cp)
            self.assertIsInstance(cp["frame"], int)
            self.assertIsInstance(cp["angle"], int)

    def test_uni_cutpoints_ordered(self):
        """Cutpoints devem estar em ordem crescente de frame."""
        self._run_unified()
        uni = json.load(open(os.path.join(self.tmp, "versions", "UNI.json")))
        frames = [cp["frame"] for cp in uni["cutpoints"]]
        self.assertEqual(frames, sorted(frames),
                         "cutpoints fora de ordem")

    def test_uni_idempotent(self):
        """Rodar unified 2x gera output idêntico (determinístico)."""
        self._run_unified()
        uni1 = json.load(open(os.path.join(self.tmp, "versions", "UNI.json")))

        # Segunda rodada: renomeia o UNI.json pra não ser lido como input
        os.rename(
            os.path.join(self.tmp, "versions", "UNI.json"),
            os.path.join(self.tmp, "versions", "_UNI_pass1.json")
        )
        self._run_unified()
        uni2 = json.load(open(os.path.join(self.tmp, "versions", "UNI.json")))

        # Deve ser idêntico (motor determinístico sem caches)
        self.assertEqual(
            json.dumps(uni1, sort_keys=True),
            json.dumps(uni2, sort_keys=True),
            "motor não é determinístico!"
        )

    def test_uni_with_spike_candidates(self):
        """unified pass1 gera spike_candidates.json."""
        self._run_unified()
        spike_path = os.path.join(self.tmp, "spike_candidates.json")
        # Pode ou não existir dependendo dos dados; se existir, verifica formato
        if os.path.exists(spike_path):
            spikes = json.load(open(spike_path))
            self.assertIsInstance(spikes, list)
            for s in spikes:
                self.assertIn("f0", s)
                self.assertIn("f1", s)
                self.assertIn("from", s)
                self.assertIn("to", s)

    def test_unified_error_on_empty_placements(self):
        """unified.py deve falhar com placements vazio."""
        # Sobrescreve placements com array vazio
        with open(os.path.join(self.tmp, "placements.json"), "w") as f:
            json.dump({"clips": []}, f)
        proc = self._run_unified()
        # Deve falhar (sem ângulos = sem cobertura)
        self.assertNotEqual(proc.returncode, 0,
                            "unified deveria falhar com placements vazio")


# ══════════════════════════════════════════════════════════════════════════════
# Testes: pipeline sem director (run_pipeline_no_director.py)
# ══════════════════════════════════════════════════════════════════════════════

class PipelineNoDirectorTests(unittest.TestCase):
    """Fluxo completo --skip-perception (sem vídeo real)."""

    def setUp(self):
        self.old_cwd = os.getcwd()
        self.tmp = make_workdir()
        os.chdir(self.tmp)

    def tearDown(self):
        os.chdir(self.old_cwd)
        shutil.rmtree(self.tmp, ignore_errors=True)

    def _run_pipeline(self, extra_args=None):
        script = os.path.join(SCRIPT_DIR, "run_pipeline_no_director.py")
        args = [sys.executable, script, self.tmp, "--skip-perception"]
        if extra_args:
            args.extend(extra_args)
        proc = subprocess.run(args, capture_output=True, text=True, timeout=180)
        return proc

    def test_pipeline_completes(self):
        """Pipeline sem director roda até o fim (--skip-perception)."""
        proc = self._run_pipeline()
        self.assertEqual(proc.returncode, 0,
                         f"Pipeline falhou (rc={proc.returncode}):\n{proc.stderr[-1000:]}")
        uni_path = os.path.join(self.tmp, "versions", "UNI.json")
        self.assertTrue(os.path.exists(uni_path),
                        "Pipeline deveria gerar UNI.json")

    def test_pipeline_without_judges(self):
        """Pipeline --skip-judges também completa."""
        proc = self._run_pipeline(extra_args=["--skip-judges"])
        self.assertEqual(proc.returncode, 0,
                         f"Pipeline --skip-judges falhou: {proc.stderr[-500:]}")

    def test_pipeline_idempotent(self):
        """Rodar pipeline 2x gera mesmo UNI.json (--skip-perception, sem VLM)."""
        self._run_pipeline()
        uni1 = json.load(open(os.path.join(self.tmp, "versions", "UNI.json")))

        # Segunda rodada
        os.rename(
            os.path.join(self.tmp, "versions", "UNI.json"),
            os.path.join(self.tmp, "versions", "_UNI_pass1.json")
        )
        self._run_pipeline()
        uni2 = json.load(open(os.path.join(self.tmp, "versions", "UNI.json")))

        # Sem caches + sem VLM, deve ser determinístico
        cps1 = uni1.get("cutpoints", [])
        cps2 = uni2.get("cutpoints", [])
        self.assertEqual(len(cps1), len(cps2),
                         f"número de cutpoints diferente: {len(cps1)} vs {len(cps2)}")
        self.assertEqual(
            json.dumps(uni1, sort_keys=True),
            json.dumps(uni2, sort_keys=True),
            "pipeline não é determinístico!"
        )

    def test_pipeline_fails_without_placements(self):
        """Pipeline deve falhar se placements.json não existe."""
        os.remove(os.path.join(self.tmp, "placements.json"))
        proc = self._run_pipeline()
        self.assertNotEqual(proc.returncode, 0,
                            "Pipeline deveria falhar sem placements.json")

    def test_pipeline_fails_without_media_paths(self):
        """Pipeline deve falhar se media_paths.json não existe."""
        os.remove(os.path.join(self.tmp, "media_paths.json"))
        proc = self._run_pipeline()
        self.assertNotEqual(proc.returncode, 0,
                            "Pipeline deveria falhar sem media_paths.json")


# ══════════════════════════════════════════════════════════════════════════════
# Testes: formato dos JSONs (contrato Swift ↔ Python)
# ══════════════════════════════════════════════════════════════════════════════

class JsonContractTests(unittest.TestCase):
    """Verifica que o formato dos JSONs bate com o que os scripts esperam."""

    def setUp(self):
        self.tmp = make_workdir()

    def tearDown(self):
        shutil.rmtree(self.tmp, ignore_errors=True)

    def test_placements_has_required_fields(self):
        """placements.json: cada clip tem name, cam, startFrame, durationFrames."""
        p = json.load(open(os.path.join(self.tmp, "placements.json")))
        for clip in p["clips"]:
            self.assertIn("name", clip)
            self.assertIn("cam", clip)
            self.assertIn("startFrame", clip)
            self.assertIn("durationFrames", clip)
            # name no formato camN_<footage>.<ext>
            self.assertRegex(clip["name"], r"^cam\d+_.+\....$")
            # cam no formato camN
            self.assertRegex(clip["cam"], r"^cam\d+$")
            self.assertIsInstance(clip["startFrame"], int)
            self.assertIsInstance(clip["durationFrames"], int)

    def test_media_paths_maps_footage_to_path(self):
        """media_paths.json: chave = footage, valor = path absoluto."""
        mp = json.load(open(os.path.join(self.tmp, "media_paths.json")))
        self.assertGreater(len(mp), 0)
        for footage, path in mp.items():
            self.assertIsInstance(footage, str)
            self.assertIsInstance(path, str)
            self.assertTrue(path.startswith("/"), f"path não é absoluto: {path}")

    def test_name_extraction_matches(self):
        """v0.4: footage_key(name, cam=) bate com media_paths (não [:-4])."""
        import vlib as V
        p = json.load(open(os.path.join(self.tmp, "placements.json")))
        mp = json.load(open(os.path.join(self.tmp, "media_paths.json")))
        for clip in p["clips"]:
            name = clip["name"]
            footage = V.footage_key(name, cam=clip.get("cam"))
            self.assertIn(footage, mp,
                          f"footage '{footage}' extraído de '{name}' não está em media_paths.json")


# ══════════════════════════════════════════════════════════════════════════════
# Main
# ══════════════════════════════════════════════════════════════════════════════

if __name__ == "__main__":
    # Garante que rodamos do diretório dos scripts (imports relativos)
    os.chdir(SCRIPT_DIR)
    unittest.main(verbosity=2)
