"""Tests for Trace-to-Preference harvester (v0.26.0 Part C)."""

from __future__ import annotations

import json

from typer.testing import CliRunner

from soup_cli.cli import app

runner = CliRunner()


# ---------------------------------------------------------------------------
# LangChain trace parser
# ---------------------------------------------------------------------------


class TestLangChainParser:
    def test_parse_event_with_thumbs_up(self):
        from soup_cli.data.traces.parsers import parse_langchain

        event = {
            "id": "run-1",
            "inputs": {"messages": [{"role": "user", "content": "Hi"}]},
            "outputs": {"generations": [[{"text": "Hello!"}]]},
            "feedback": [{"key": "thumbs", "score": 1}],
        }
        traces = list(parse_langchain([event]))
        assert len(traces) == 1
        assert traces[0].prompt == "Hi"
        assert traces[0].output == "Hello!"
        assert traces[0].signal == "thumbs_up"

    def test_parse_event_with_thumbs_down(self):
        from soup_cli.data.traces.parsers import parse_langchain

        event = {
            "id": "run-2",
            "inputs": {"messages": [{"role": "user", "content": "Q"}]},
            "outputs": {"generations": [[{"text": "Bad"}]]},
            "feedback": [{"key": "thumbs", "score": 0}],
        }
        traces = list(parse_langchain([event]))
        assert traces[0].signal == "thumbs_down"

    def test_parse_skips_missing_outputs(self):
        from soup_cli.data.traces.parsers import parse_langchain

        events = [{"id": "x", "inputs": {"messages": []}, "outputs": None}]
        assert list(parse_langchain(events)) == []


# ---------------------------------------------------------------------------
# OpenAI-style trace parser
# ---------------------------------------------------------------------------


class TestOpenAIParser:
    def test_parse_completion_with_feedback(self):
        from soup_cli.data.traces.parsers import parse_openai

        event = {
            "id": "cmpl-1",
            "messages": [{"role": "user", "content": "Explain"}],
            "choices": [
                {"message": {"role": "assistant", "content": "Answer"}},
            ],
            "feedback": {"rating": "up"},
        }
        traces = list(parse_openai([event]))
        assert len(traces) == 1
        assert traces[0].prompt == "Explain"
        assert traces[0].output == "Answer"
        assert traces[0].signal == "thumbs_up"

    def test_parse_regeneration_chain(self):
        """Two completions with the same prompt → first=rejected, last=chosen."""
        from soup_cli.data.traces.parsers import parse_openai

        events = [
            {
                "id": "a", "messages": [{"role": "user", "content": "Q"}],
                "choices": [{"message": {"role": "assistant",
                                         "content": "First"}}],
                "regenerated_from": None,
            },
            {
                "id": "b", "messages": [{"role": "user", "content": "Q"}],
                "choices": [{"message": {"role": "assistant",
                                         "content": "Second"}}],
                "regenerated_from": "a",
            },
        ]
        traces = list(parse_openai(events))
        assert len(traces) == 2


# ---------------------------------------------------------------------------
# Soup serve trace parser
# ---------------------------------------------------------------------------


class TestSoupServeParser:
    def test_parse_soup_serve_dir(self, tmp_path):
        from soup_cli.data.traces.parsers import parse_soup_serve

        trace_dir = tmp_path / "traces"
        trace_dir.mkdir()
        (trace_dir / "req-1.jsonl").write_text(
            json.dumps({
                "id": "req-1",
                "prompt": "What?",
                "response": "That.",
                "feedback": {"rating": "up"},
            }) + "\n",
            encoding="utf-8",
        )
        traces = list(parse_soup_serve(str(trace_dir)))
        assert len(traces) == 1
        assert traces[0].prompt == "What?"


# ---------------------------------------------------------------------------
# Pair builder
# ---------------------------------------------------------------------------


class TestPairBuilder:
    def test_build_pairs_from_thumbs(self):
        from soup_cli.data.traces.pair_builder import build_pairs
        from soup_cli.data.traces.parsers import Trace

        traces = [
            Trace(trace_id="a", prompt="Q", output="Good", signal="thumbs_up"),
            Trace(trace_id="b", prompt="Q", output="Bad", signal="thumbs_down"),
        ]
        pairs = list(build_pairs(traces, signal="thumbs_up"))
        assert len(pairs) == 1
        assert pairs[0].prompt == "Q"
        assert pairs[0].chosen == "Good"
        assert pairs[0].rejected == "Bad"

    def test_build_pairs_from_regenerations(self):
        from soup_cli.data.traces.pair_builder import build_pairs
        from soup_cli.data.traces.parsers import Trace

        traces = [
            Trace(trace_id="1", prompt="Q", output="First", signal="regenerated",
                  regen_order=0),
            Trace(trace_id="2", prompt="Q", output="Better", signal="regenerated",
                  regen_order=1),
        ]
        pairs = list(build_pairs(traces, signal="regenerations"))
        assert len(pairs) == 1
        assert pairs[0].chosen == "Better"
        assert pairs[0].rejected == "First"

    def test_build_pairs_from_user_edit(self):
        from soup_cli.data.traces.pair_builder import build_pairs
        from soup_cli.data.traces.parsers import Trace

        traces = [
            Trace(trace_id="x", prompt="Q", output="Raw", signal="user_edit",
                  edited_output="Polished"),
        ]
        pairs = list(build_pairs(traces, signal="user_edit"))
        assert len(pairs) == 1
        assert pairs[0].chosen == "Polished"
        assert pairs[0].rejected == "Raw"

    def test_build_pairs_skips_missing_counterpart(self):
        from soup_cli.data.traces.pair_builder import build_pairs
        from soup_cli.data.traces.parsers import Trace

        traces = [
            Trace(trace_id="a", prompt="Q", output="Good", signal="thumbs_up"),
        ]
        pairs = list(build_pairs(traces, signal="thumbs_up"))
        # No rejected counterpart → pair dropped
        assert pairs == []


# ---------------------------------------------------------------------------
# CLI: soup data from-traces
# ---------------------------------------------------------------------------


class TestFromTracesCLI:
    def _write_langchain_log(self, path, prompts_and_ratings):
        """Write a JSONL log where each row is a LangChain run event."""
        rows = []
        for idx, (prompt, output, rating) in enumerate(prompts_and_ratings):
            rows.append({
                "id": f"run-{idx}",
                "inputs": {"messages": [{"role": "user", "content": prompt}]},
                "outputs": {"generations": [[{"text": output}]]},
                "feedback": [{"key": "thumbs", "score": rating}],
            })
        path.write_text(
            "\n".join(json.dumps(r) for r in rows) + "\n",
            encoding="utf-8",
        )

    def test_happy_path(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        logs = tmp_path / "traces.jsonl"
        self._write_langchain_log(logs, [
            ("Hi", "Hello!", 1),
            ("Hi", "Ugh.", 0),
        ])
        output = tmp_path / "prefs.jsonl"

        result = runner.invoke(app, [
            "data", "from-traces",
            "--logs", str(logs),
            "--format", "langchain",
            "--signal", "thumbs_up",
            "--output", str(output),
        ])
        assert result.exit_code == 0, (result.output, repr(result.exception))
        assert output.exists()
        pairs = [json.loads(ln) for ln in output.read_text(encoding="utf-8").splitlines()]
        assert len(pairs) == 1
        assert pairs[0]["chosen"] == "Hello!"
        assert pairs[0]["rejected"] == "Ugh."

    def test_invalid_format_rejected(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        logs = tmp_path / "x.jsonl"
        logs.write_text("{}\n", encoding="utf-8")
        result = runner.invoke(app, [
            "data", "from-traces",
            "--logs", str(logs),
            "--format", "hacker",
            "--signal", "thumbs_up",
            "--output", "prefs.jsonl",
        ])
        assert result.exit_code != 0, (result.output, repr(result.exception))

    def test_invalid_signal_rejected(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        logs = tmp_path / "x.jsonl"
        logs.write_text("{}\n", encoding="utf-8")
        result = runner.invoke(app, [
            "data", "from-traces",
            "--logs", str(logs),
            "--format", "langchain",
            "--signal", "bribery",
            "--output", "prefs.jsonl",
        ])
        assert result.exit_code != 0, (result.output, repr(result.exception))

    def test_path_traversal_on_logs_rejected(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        outside = tmp_path.parent / "outside_traces.jsonl"
        outside.write_text("{}\n", encoding="utf-8")
        try:
            result = runner.invoke(app, [
                "data", "from-traces",
                "--logs", str(outside),
                "--format", "langchain",
                "--signal", "thumbs_up",
                "--output", "prefs.jsonl",
            ])
            assert result.exit_code != 0, (result.output, repr(result.exception))
            assert "outside" in result.output.lower() or "cwd" in result.output.lower()
        finally:
            outside.unlink(missing_ok=True)

    def test_path_traversal_on_output_rejected(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        logs = tmp_path / "x.jsonl"
        logs.write_text("{}\n", encoding="utf-8")
        result = runner.invoke(app, [
            "data", "from-traces",
            "--logs", str(logs),
            "--format", "langchain",
            "--signal", "thumbs_up",
            "--output", str(tmp_path.parent / "escape.jsonl"),
        ])
        assert result.exit_code != 0, (result.output, repr(result.exception))


# ---------------------------------------------------------------------------
# CLI: soup data review
# ---------------------------------------------------------------------------


class TestReviewCLI:
    def test_review_requires_file(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["data", "review"])
        assert result.exit_code != 0, (result.output, repr(result.exception))

    def test_review_missing_file(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        result = runner.invoke(app, ["data", "review", "nope.jsonl"])
        assert result.exit_code != 0, (result.output, repr(result.exception))

    def test_review_shows_sample(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        prefs = tmp_path / "prefs.jsonl"
        prefs.write_text(
            json.dumps({
                "prompt": "Q", "chosen": "A", "rejected": "B",
            }) + "\n",
            encoding="utf-8",
        )
        result = runner.invoke(app, [
            "data", "review", str(prefs), "--sample", "1",
        ])
        assert result.exit_code == 0, (result.output, repr(result.exception))
        assert "Q" in result.output or "prompt" in result.output.lower()


# ---------------------------------------------------------------------------
# Signals / format literals
# ---------------------------------------------------------------------------


class TestEmptyAndMalformed:
    def test_empty_log_yields_no_pairs(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        logs = tmp_path / "empty.jsonl"
        logs.write_text("", encoding="utf-8")
        result = runner.invoke(app, [
            "data", "from-traces",
            "--logs", str(logs),
            "--format", "langchain",
            "--signal", "thumbs_up",
            "--output", "prefs.jsonl",
        ])
        assert result.exit_code == 0, (result.output, repr(result.exception))
        assert (tmp_path / "prefs.jsonl").exists()
        assert (tmp_path / "prefs.jsonl").read_text(encoding="utf-8") == ""

    def test_malformed_lines_skipped(self, tmp_path, monkeypatch):
        monkeypatch.chdir(tmp_path)
        logs = tmp_path / "mixed.jsonl"
        logs.write_text(
            'not valid json\n'
            + json.dumps({
                "id": "ok",
                "inputs": {"messages": [{"role": "user", "content": "Q"}]},
                "outputs": {"generations": [[{"text": "A"}]]},
                "feedback": [{"key": "thumbs", "score": 1}],
            }) + "\n",
            encoding="utf-8",
        )
        result = runner.invoke(app, [
            "data", "from-traces",
            "--logs", str(logs),
            "--format", "langchain",
            "--signal", "thumbs_up",
            "--output", "prefs.jsonl",
        ])
        assert result.exit_code == 0, (result.output, repr(result.exception))

    def test_lone_thumbs_down_drops_pair(self):
        from soup_cli.data.traces.pair_builder import build_pairs
        from soup_cli.data.traces.parsers import Trace

        traces = [
            Trace(trace_id="a", prompt="Q", output="Bad",
                  signal="thumbs_down"),
        ]
        pairs = list(build_pairs(traces, signal="thumbs_up"))
        assert pairs == []

    def test_parse_soup_serve_non_directory_returns_empty(self, tmp_path):
        from soup_cli.data.traces.parsers import parse_soup_serve

        file_not_dir = tmp_path / "file.txt"
        file_not_dir.write_text("hi", encoding="utf-8")
        assert list(parse_soup_serve(str(file_not_dir))) == []


class TestLiterals:
    def test_format_literal_accepts_known(self):
        from soup_cli.data.traces.pair_builder import SUPPORTED_SIGNALS

        # All 3 launch signals should be in the supported set
        for sig in ("thumbs_up", "regenerations", "user_edit"):
            assert sig in SUPPORTED_SIGNALS

    def test_parser_registry(self):
        from soup_cli.data.traces.parsers import SUPPORTED_FORMATS

        assert "langchain" in SUPPORTED_FORMATS
        assert "openai" in SUPPORTED_FORMATS
        assert "soup-serve" in SUPPORTED_FORMATS
