"""v0.53.1 #109 — soup deploy autopilot --measure live wiring."""

from __future__ import annotations

import os
from pathlib import Path

import pytest

# --- compute_cache_key ------------------------------------------------------


class TestComputeCacheKey:
    def test_basic(self):
        from soup_cli.utils.deploy_measure import compute_cache_key

        key = compute_cache_key(
            base_sha="abc123", profile_name="rtx-4090-24gb",
            tasks_sha="def456",
        )
        assert isinstance(key, str)
        assert len(key) == 32

    def test_deterministic(self):
        from soup_cli.utils.deploy_measure import compute_cache_key

        k1 = compute_cache_key(
            base_sha="x", profile_name="p", tasks_sha="t",
        )
        k2 = compute_cache_key(
            base_sha="x", profile_name="p", tasks_sha="t",
        )
        assert k1 == k2

    def test_diff_on_each_arg(self):
        from soup_cli.utils.deploy_measure import compute_cache_key

        base = compute_cache_key(base_sha="a", profile_name="b", tasks_sha="c")
        for changed in (
            compute_cache_key(base_sha="A", profile_name="b", tasks_sha="c"),
            compute_cache_key(base_sha="a", profile_name="B", tasks_sha="c"),
            compute_cache_key(base_sha="a", profile_name="b", tasks_sha="C"),
        ):
            assert changed != base

    def test_bool_rejected(self):
        from soup_cli.utils.deploy_measure import compute_cache_key

        with pytest.raises(TypeError):
            compute_cache_key(base_sha=True, profile_name="x", tasks_sha="y")

    def test_null_byte_rejected(self):
        from soup_cli.utils.deploy_measure import compute_cache_key

        with pytest.raises(ValueError):
            compute_cache_key(
                base_sha="ev\x00il", profile_name="x", tasks_sha="y",
            )

    def test_empty_rejected(self):
        from soup_cli.utils.deploy_measure import compute_cache_key

        with pytest.raises(ValueError):
            compute_cache_key(base_sha="", profile_name="x", tasks_sha="y")


# --- sha_of_file ------------------------------------------------------------


class TestShaOfFile:
    def test_basic(self, tmp_path):
        from soup_cli.utils.deploy_measure import sha_of_file

        f = tmp_path / "x.txt"
        f.write_bytes(b"hello world")
        h = sha_of_file(str(f))
        assert isinstance(h, str)
        assert len(h) == 64  # full sha256 hex

    def test_missing_file(self, tmp_path):
        from soup_cli.utils.deploy_measure import sha_of_file

        with pytest.raises(FileNotFoundError):
            sha_of_file(str(tmp_path / "missing"))

    def test_null_byte_rejected(self):
        from soup_cli.utils.deploy_measure import sha_of_file

        with pytest.raises(ValueError):
            sha_of_file("ev\x00il")


# --- measure_candidate ------------------------------------------------------


def _write_tasks(tmp_path: Path) -> Path:
    """Write a 2-row JSONL eval task file."""
    f = tmp_path / "tasks.jsonl"
    f.write_text(
        '{"prompt": "say hello", "expected": "hello", "scoring": "exact"}\n'
        '{"prompt": "say world", "expected": "world", "scoring": "exact"}\n',
        encoding="utf-8",
    )
    return f


class TestMeasureCandidate:
    def test_ok_when_after_matches(self, tmp_path):
        from soup_cli.utils.deploy_measure import measure_candidate

        tasks = _write_tasks(tmp_path)
        def gen(p):
            if "hello" in p:
                return "hello"
            return "world"
        result = measure_candidate(
            candidate="gptq", tasks_file=str(tasks),
            before_gen=gen, after_gen=gen,
        )
        assert result.candidate == "gptq"
        assert result.verdict == "OK"
        assert result.delta == 0.0

    def test_minor_band_at_3pct_drop(self, tmp_path):
        """L1: explicitly exercise the MINOR verdict band (2% < drop < 5%)."""
        from soup_cli.utils.deploy_measure import MeasureResult, measure_candidate

        # Build a 100-task fixture so we can hit a 3% drop
        big = tmp_path / "tasks_big.jsonl"
        big.write_text(
            "\n".join(
                '{"prompt": "p%d", "expected": "ok", "scoring": "exact"}' % i
                for i in range(100)
            ),
            encoding="utf-8",
        )

        def before(p):
            return "ok"

        # After: miss exactly 3 out of 100 → drop=0.03 → MINOR band
        miss_set = {"p7", "p23", "p64"}

        def after(p):
            return "WRONG" if p in miss_set else "ok"

        r = measure_candidate(
            candidate="awq", tasks_file=str(big),
            before_gen=before, after_gen=after,
        )
        assert isinstance(r, MeasureResult)
        assert r.verdict == "MINOR"
        assert 0.02 <= -r.delta < 0.05

    def test_minor_drop(self, tmp_path):
        from soup_cli.utils.deploy_measure import measure_candidate

        tasks = _write_tasks(tmp_path)
        def before(p):
            return "hello" if "hello" in p else "world"
        # Always wrong → score 0.0; before score 1.0 → drop 1.0 → MAJOR
        def after(p):
            return "WRONG"
        r = measure_candidate(
            candidate="awq", tasks_file=str(tasks),
            before_gen=before, after_gen=after,
        )
        assert r.verdict == "MAJOR"
        assert r.delta < 0

    def test_invalid_candidate(self, tmp_path):
        from soup_cli.utils.deploy_measure import measure_candidate

        tasks = _write_tasks(tmp_path)
        with pytest.raises(TypeError):
            measure_candidate(
                candidate=True,  # type: ignore[arg-type]
                tasks_file=str(tasks),
                before_gen=lambda p: "",
                after_gen=lambda p: "",
            )

    def test_empty_candidate(self, tmp_path):
        from soup_cli.utils.deploy_measure import measure_candidate

        tasks = _write_tasks(tmp_path)
        with pytest.raises(ValueError):
            measure_candidate(
                candidate="",
                tasks_file=str(tasks),
                before_gen=lambda p: "",
                after_gen=lambda p: "",
            )

    def test_null_byte_candidate(self, tmp_path):
        from soup_cli.utils.deploy_measure import measure_candidate

        tasks = _write_tasks(tmp_path)
        with pytest.raises(ValueError):
            measure_candidate(
                candidate="ev\x00il",
                tasks_file=str(tasks),
                before_gen=lambda p: "",
                after_gen=lambda p: "",
            )


# --- pick_best --------------------------------------------------------------


class TestPickBest:
    def test_empty_returns_none(self):
        from soup_cli.utils.deploy_measure import pick_best

        assert pick_best([]) is None

    def test_first_ok_wins(self):
        from soup_cli.utils.deploy_measure import MeasureResult, pick_best

        rows = [
            MeasureResult("a", 0.8, 0.79, -0.01, "OK"),
            MeasureResult("b", 0.8, 0.78, -0.02, "MINOR"),
        ]
        assert pick_best(rows).candidate == "a"

    def test_no_ok_picks_highest_after(self):
        from soup_cli.utils.deploy_measure import MeasureResult, pick_best

        rows = [
            MeasureResult("a", 0.8, 0.5, -0.3, "MAJOR"),
            MeasureResult("b", 0.8, 0.6, -0.2, "MAJOR"),
            MeasureResult("c", 0.8, 0.55, -0.25, "MAJOR"),
        ]
        assert pick_best(rows).candidate == "b"


# --- cache load/save round-trip ---------------------------------------------


class TestCacheRoundtrip:
    def test_save_then_load(self, tmp_path):
        from soup_cli.utils.deploy_measure import load_cache, save_cache

        cache_path = tmp_path / "cache.json"
        payload = {"abc123": {"rows": [{"candidate": "gptq",
                                        "before": 0.8, "after": 0.79,
                                        "delta": -0.01, "verdict": "OK"}]}}
        save_cache(payload, str(cache_path))
        loaded = load_cache(str(cache_path))
        assert loaded == payload

    def test_load_missing_returns_empty(self, tmp_path):
        from soup_cli.utils.deploy_measure import load_cache

        assert load_cache(str(tmp_path / "missing.json")) == {}

    def test_load_malformed_returns_empty(self, tmp_path):
        from soup_cli.utils.deploy_measure import load_cache

        bad = tmp_path / "bad.json"
        bad.write_text("not json {{{", encoding="utf-8")
        assert load_cache(str(bad)) == {}


# --- run_measure (full loop) ------------------------------------------------


class TestRunMeasure:
    def test_first_run_misses_then_hits(self, tmp_path):
        from soup_cli.utils.deploy_measure import run_measure

        tasks = _write_tasks(tmp_path)
        cache_path = tmp_path / "cache.json"

        def before(p):
            return "hello" if "hello" in p else "world"

        def after_factory(candidate):
            def gen(p):
                # awq matches; gptq always wrong (MAJOR drop)
                if candidate == "awq":
                    return "hello" if "hello" in p else "world"
                return "WRONG"
            return gen

        results1, hit1 = run_measure(
            profile_name="rtx-4090-24gb",
            base_sha="basetestsha",
            candidates=("awq", "gptq"),
            tasks_file=str(tasks),
            before_gen=before,
            after_gen_factory=after_factory,
            cache_path=str(cache_path),
        )
        assert hit1 is False
        assert [r.candidate for r in results1] == ["awq", "gptq"]
        assert results1[0].verdict == "OK"
        assert results1[1].verdict == "MAJOR"

        # Second invocation must hit cache and skip after_factory entirely
        called = {"count": 0}

        def boom_factory(candidate):
            called["count"] += 1
            return lambda p: "should not be called"

        results2, hit2 = run_measure(
            profile_name="rtx-4090-24gb",
            base_sha="basetestsha",
            candidates=("awq", "gptq"),
            tasks_file=str(tasks),
            before_gen=before,
            after_gen_factory=boom_factory,
            cache_path=str(cache_path),
        )
        assert hit2 is True
        assert called["count"] == 0
        assert [r.candidate for r in results2] == ["awq", "gptq"]

    def test_candidates_empty_rejected(self, tmp_path):
        from soup_cli.utils.deploy_measure import run_measure

        tasks = _write_tasks(tmp_path)
        with pytest.raises(ValueError):
            run_measure(
                profile_name="p", base_sha="b",
                candidates=(),
                tasks_file=str(tasks),
                before_gen=lambda p: "",
                after_gen_factory=lambda c: (lambda p: ""),
                cache_path=str(tmp_path / "cache.json"),
            )

    def test_candidates_string_rejected(self, tmp_path):
        from soup_cli.utils.deploy_measure import run_measure

        tasks = _write_tasks(tmp_path)
        with pytest.raises(TypeError):
            run_measure(
                profile_name="p", base_sha="b",
                candidates="awq",  # type: ignore[arg-type]
                tasks_file=str(tasks),
                before_gen=lambda p: "",
                after_gen_factory=lambda c: (lambda p: ""),
                cache_path=str(tmp_path / "cache.json"),
            )


# --- CLI plumbing -----------------------------------------------------------


class TestDeployAutopilotMeasureCLI:
    def test_help_lists_measure_flag(self):
        import typer
        from typer.testing import CliRunner

        from soup_cli.commands.deploy import autopilot

        app = typer.Typer()
        app.command()(autopilot)
        # Force wide terminal so Rich doesn't wrap option names; CI runners
        # default to 80-col which splits `--measure` mid-line.
        runner = CliRunner()
        result = runner.invoke(
            app, ["--help"], env={"COLUMNS": "200", "TERM": "dumb"},
        )
        assert result.exit_code == 0
        # Inspect registered click params directly so the assertion doesn't
        # depend on Rich's wrapping behaviour at all (CI runners default to
        # 80-col which splits long option names mid-line).
        click_cmd = typer.main.get_command(app)
        registered = {
            opt
            for param in click_cmd.params
            for opt in (param.opts + param.secondary_opts)
        }
        assert "--measure" in registered, registered
        assert "--tasks" in registered, registered

    def test_measure_without_tasks_rejected(self, tmp_path, monkeypatch):
        import typer
        from typer.testing import CliRunner

        from soup_cli.commands.deploy import autopilot

        monkeypatch.chdir(tmp_path)
        app = typer.Typer()
        app.command()(autopilot)
        runner = CliRunner()
        result = runner.invoke(
            app,
            ["--target", "rtx-4090-24gb", "--base", "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
             "--measure"],
        )
        assert result.exit_code != 0
        assert "--tasks" in result.output

    def test_measure_with_injected_generators(self, tmp_path, monkeypatch):
        """End-to-end with injected generators bypassing real model loading."""
        import typer
        from typer.testing import CliRunner

        from soup_cli.commands.deploy import autopilot
        from soup_cli.utils import deploy_measure as _dm

        monkeypatch.chdir(tmp_path)
        tasks = _write_tasks(tmp_path)

        # Inject generators
        def before(p):
            return "hello" if "hello" in p else "world"

        def after_factory(candidate):
            return lambda p: ("hello" if "hello" in p else "world")

        monkeypatch.setattr(
            _dm, "_DEPLOY_MEASURE_BEFORE_GEN", before, raising=False
        )
        monkeypatch.setattr(
            _dm, "_DEPLOY_MEASURE_AFTER_FACTORY", after_factory, raising=False
        )
        # Redirect cache to tmp
        monkeypatch.setenv(
            "SOUP_DEPLOY_AUTOPILOT_CACHE",
            str(tmp_path / "cache.json"),
        )

        app = typer.Typer()
        app.command()(autopilot)
        runner = CliRunner()
        result = runner.invoke(
            app,
            [
                "--target", "rtx-4090-24gb",
                "--base", "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
                "--recipe-out", str(tmp_path / "recipe.yaml"),
                "--script-out", str(tmp_path / "deploy.sh"),
                "--measure",
                "--tasks", str(tasks),
                "--measure-candidates", "awq,gptq",
            ],
        )
        assert result.exit_code == 0, (result.output, repr(result.exception))
        # L4: assert the specific verdict-recommendation line + that the
        # measured candidate name appears in the table.
        assert "Recommended" in result.output
        assert "awq" in result.output


# --- M3: _MAX_CANDIDATES upper bound ---------------------------------------


class TestMaxCandidatesCap:
    def test_too_many_candidates_rejected(self, tmp_path):
        from soup_cli.utils.deploy_measure import run_measure

        tasks = _write_tasks(tmp_path)
        with pytest.raises(ValueError, match="too many candidates"):
            run_measure(
                profile_name="p", base_sha="b",
                candidates=tuple(f"q{i}" for i in range(33)),
                tasks_file=str(tasks),
                before_gen=lambda p: "",
                after_gen_factory=lambda c: (lambda p: ""),
                cache_path=str(tmp_path / "cache.json"),
            )


# --- M4: cache symlink TOCTOU rejection ------------------------------------


class TestCacheSymlinkRejection:
    @pytest.mark.skipif(
        os.name == "nt", reason="symlink rejection POSIX-only"
    )
    def test_load_cache_rejects_symlink_target(self, tmp_path):
        from soup_cli.utils.deploy_measure import load_cache

        real = tmp_path / "real_cache.json"
        real.write_text('{"k": {"rows": []}}', encoding="utf-8")
        link = tmp_path / "link_cache.json"
        link.symlink_to(real)
        # load_cache must refuse to follow the symlink — returns {}
        assert load_cache(str(link)) == {}

    @pytest.mark.skipif(
        os.name == "nt", reason="symlink rejection POSIX-only"
    )
    def test_save_cache_refuses_symlink_target(self, tmp_path):
        from soup_cli.utils.deploy_measure import save_cache

        real = tmp_path / "real_target.json"
        real.write_text("{}", encoding="utf-8")
        link = tmp_path / "link.json"
        link.symlink_to(real)
        # save_cache silently refuses on a pre-placed symlink — no exception,
        # but the underlying real file must NOT be overwritten.
        original = real.read_text(encoding="utf-8")
        save_cache({"k": {"rows": []}}, str(link))
        assert real.read_text(encoding="utf-8") == original


# --- H3: render_measure_table markup-escape regression ---------------------


class TestRenderMeasureTableEscape:
    def test_candidate_with_markup_metacharacters_escaped(self):
        from io import StringIO

        from rich.console import Console

        from soup_cli.utils.deploy_measure import (
            MeasureResult,
            render_measure_table,
        )

        rows = [MeasureResult("[red]evil[/]", 0.8, 0.79, -0.01, "OK")]
        table = render_measure_table(rows)
        buf = StringIO()
        Console(file=buf, force_terminal=False, no_color=True, width=200).print(
            table
        )
        # The raw bracketed text must appear (escaped); the colour markup
        # must NOT have been interpreted as Rich styling.
        output = buf.getvalue()
        assert "[red]evil[/]" in output
