"""Tests for executor.py — trial dispatch logic.

Covers:
    - _process_result() score extraction and side_info handling
    - execute_trials() sync dispatch (Docker path)
    - execute_trials() async dispatch (Daytona path)
    - Backend lifecycle (setup/cleanup called by eval.py, not executor)
    - Error handling (trial_error results, sandbox_error with NaN)
    - InvalidEvalOutput propagation
"""

from __future__ import annotations

import math
from pathlib import Path
from unittest import mock

import pytest
from daytona.common.errors import DaytonaError

from clawbench.backend import TrialResult
from clawbench.executor import _process_result, execute_trials
from clawbench.scoring import InvalidEvalOutput


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

class MockBackend:
    """Backend that returns predefined TrialResults."""

    def __init__(self, results_map: dict[str, tuple[float, dict]]):
        self._results_map = results_map
        self.setup_calls = 0
        self.cleanup_calls = 0
        self.trial_calls: list[str] = []

    def setup(self):
        self.setup_calls += 1

    def cleanup(self):
        self.cleanup_calls += 1

    def run_trial(self, candidate, task, *, skills_dir=None, run_id=None):
        name = task["task_name"]
        self.trial_calls.append(name)
        if name in self._results_map:
            score, side_info = self._results_map[name]
            return TrialResult(score=score, side_info=side_info)
        return TrialResult(score=0.0, side_info={"Feedback": {"Status": "no_verifier_result"}})

    async def run_trial_async(self, candidate, task, *, skills_dir=None, run_id=None):
        return self.run_trial(candidate, task, skills_dir=skills_dir, run_id=run_id)


class RaisingBackend:
    """Backend whose run_trial raises an exception."""

    def __init__(self, exc):
        self._exc = exc

    def setup(self): pass
    def cleanup(self): pass

    def run_trial(self, candidate, task, *, skills_dir=None, run_id=None):
        raise self._exc

    async def run_trial_async(self, candidate, task, *, skills_dir=None, run_id=None):
        raise self._exc


# ---------------------------------------------------------------------------
# _process_result tests
# ---------------------------------------------------------------------------

class TestProcessResult:
    """Test _process_result() score extraction from TrialResult."""

    def test_completed_with_score(self):
        result = TrialResult(
            score=0.8,
            side_info={"Feedback": {"Status": "completed", "Rewards": {"score": 0.9}}},
        )
        out = _process_result(result, "task-a", 5.0)
        assert out["score"] == 0.9  # extract_score overrides raw score
        assert out["status"] == "completed"
        assert out["rewards"] == {"score": 0.9}
        assert out["task_name"] == "task-a"
        assert out["duration_sec"] == 5.0

    def test_no_verifier_result(self):
        result = TrialResult(
            score=0.0,
            side_info={"Feedback": {"Status": "no_verifier_result"}},
        )
        out = _process_result(result, "task-a", 3.0)
        assert out["score"] == 0.0
        assert out["status"] == "no_verifier_result"
        assert out["rewards"] == {}

    def test_non_numeric_rewards_raises(self):
        result = TrialResult(
            score=0.0,
            side_info={"Feedback": {"Status": "completed", "Rewards": {"score": "bad"}}},
        )
        with pytest.raises(InvalidEvalOutput, match="must be numeric"):
            _process_result(result, "task-a", 1.0)

    def test_non_dict_rewards_raises(self):
        result = TrialResult(
            score=0.0,
            side_info={"Feedback": {"Status": "completed", "Rewards": "not_a_dict"}},
        )
        with pytest.raises(InvalidEvalOutput, match="must be a dict"):
            _process_result(result, "task-a", 1.0)

    def test_missing_score_key_raises(self):
        result = TrialResult(
            score=0.5,
            side_info={"Feedback": {"Status": "completed", "Rewards": {"precision": 0.8}}},
        )
        with pytest.raises(InvalidEvalOutput, match="neither 'score' nor 'reward'"):
            _process_result(result, "task-a", 1.0)

    def test_reward_fallback_key(self):
        """extract_score falls back from 'score' to 'reward' key."""
        result = TrialResult(
            score=0.0,
            side_info={"Feedback": {"Status": "completed", "Rewards": {"reward": 0.7}}},
        )
        out = _process_result(result, "task-a", 2.0)
        assert out["score"] == 0.7

    def test_passed_threshold(self):
        result = TrialResult(
            score=1.0,
            side_info={"Feedback": {"Status": "completed", "Rewards": {"score": 1.0}}},
        )
        out = _process_result(result, "task-a", 1.0)
        assert out["passed"] is True

        result_low = TrialResult(
            score=0.5,
            side_info={"Feedback": {"Status": "completed", "Rewards": {"score": 0.5}}},
        )
        out_low = _process_result(result_low, "task-b", 1.0)
        assert out_low["passed"] is False


# ---------------------------------------------------------------------------
# execute_trials tests
# ---------------------------------------------------------------------------

class TestExecuteTrials:
    """Test execute_trials() batch dispatch."""

    def test_sync_dispatch_docker(self):
        """Docker path uses sync run_trial."""
        backend = MockBackend({
            "task-a": (1.0, {"Feedback": {"Status": "completed", "Rewards": {"score": 1.0}}}),
            "task-b": (0.5, {"Feedback": {"Status": "completed", "Rewards": {"score": 0.5}}}),
        })
        work = [{"task_name": "task-a"}, {"task_name": "task-b"}]

        results = execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=work,
            environment_type="docker",
        )

        assert len(results) == 2
        assert results[0]["task_name"] == "task-a"
        assert results[0]["score"] == 1.0
        assert results[1]["task_name"] == "task-b"
        assert results[1]["score"] == 0.5
        assert backend.trial_calls == ["task-a", "task-b"]

    def test_async_dispatch_daytona(self):
        """Daytona path uses async run_trial_async via gather."""
        backend = MockBackend({
            "task-a": (1.0, {"Feedback": {"Status": "completed", "Rewards": {"score": 1.0}}}),
        })
        work = [{"task_name": "task-a"}]

        results = execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=work,
            environment_type="daytona",
            n_workers=1,
        )

        assert len(results) == 1
        assert results[0]["score"] == 1.0

    def test_error_returns_trial_error(self):
        """Backend exceptions produce trial_error results, not crashes."""
        backend = RaisingBackend(RuntimeError("Docker crashed"))
        work = [{"task_name": "task-a"}]

        results = execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=work,
            environment_type="docker",
        )

        assert len(results) == 1
        assert results[0]["score"] == 0.0
        assert results[0]["status"] == "trial_error"
        assert "Docker crashed" in results[0]["error"]

    def test_invalid_eval_output_propagates(self):
        """InvalidEvalOutput from scoring is not caught — propagates up."""
        backend = MockBackend({
            "task-a": (0.0, {"Feedback": {"Status": "completed", "Rewards": {"score": "bad"}}}),
        })
        work = [{"task_name": "task-a"}]

        with pytest.raises(InvalidEvalOutput):
            execute_trials(
                backend=backend,
                candidate={"skills": "test"},
                work_items=work,
                environment_type="docker",
            )

    def test_empty_work_items(self):
        """No work items → empty results."""
        backend = MockBackend({})
        results = execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=[],
        )
        assert results == []

    def test_sandbox_error_returns_nan_sync(self):
        """DaytonaError produces sandbox_error with NaN score (sync path)."""
        backend = RaisingBackend(DaytonaError("Failed to create sandbox: BUILD_FAILED"))
        work = [{"task_name": "task-a"}]

        results = execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=work,
            environment_type="docker",
        )

        assert len(results) == 1
        assert math.isnan(results[0]["score"])
        assert results[0]["status"] == "sandbox_error"
        assert "Failed to create sandbox" in results[0]["error"]
        assert results[0]["passed"] is False

    def test_sandbox_error_returns_nan_async(self):
        """DaytonaError produces sandbox_error with NaN score (async/Daytona path)."""
        backend = RaisingBackend(DaytonaError("Failed to create sandbox: BUILD_FAILED"))
        work = [{"task_name": "task-a"}]

        results = execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=work,
            environment_type="daytona",
            n_workers=1,
        )

        assert len(results) == 1
        assert math.isnan(results[0]["score"])
        assert results[0]["status"] == "sandbox_error"

    def test_verbose_labels_repeats(self, capsys):
        """Verbose mode prints repeat labels."""
        backend = MockBackend({
            "task-a": (1.0, {"Feedback": {"Status": "completed", "Rewards": {"score": 1.0}}}),
        })
        work = [{"task_name": "task-a", "_repeat": 0}]

        execute_trials(
            backend=backend,
            candidate={"skills": "test"},
            work_items=work,
            verbose=True,
            n_repeats=3,
        )

        captured = capsys.readouterr()
        assert "task-a (repeat 1/3)" in captured.out
