from __future__ import annotations

import attrs
import pytest

from codeflash_core._model import (
    _NS_PER_DAY,
    _NS_PER_HOUR,
    _NS_PER_MIN,
    _NS_PER_MS,
    _NS_PER_S,
    _NS_PER_US,
    BenchmarkDetail,
    Candidate,
    FileDiffContent,
    OptimizationRequest,
    OptimizationReviewResult,
    PrComment,
    humanize_runtime,
)


class TestTimeConstants:
    """Tests for nanosecond conversion constants."""

    def test_ns_per_us(self):
        """
        One microsecond equals 1,000 nanoseconds.
        """
        assert 1_000 == _NS_PER_US

    def test_ns_per_ms(self):
        """
        One millisecond equals 1,000,000 nanoseconds.
        """
        assert 1_000_000 == _NS_PER_MS

    def test_ns_per_s(self):
        """
        One second equals 1,000,000,000 nanoseconds.
        """
        assert 1_000_000_000 == _NS_PER_S

    def test_ns_per_min(self):
        """
        One minute equals 60,000,000,000 nanoseconds.
        """
        assert 60_000_000_000 == _NS_PER_MIN

    def test_ns_per_hour(self):
        """
        One hour equals 3,600,000,000,000 nanoseconds.
        """
        assert 3_600_000_000_000 == _NS_PER_HOUR

    def test_ns_per_day(self):
        """
        One day equals 86,400,000,000,000 nanoseconds.
        """
        assert 86_400_000_000_000 == _NS_PER_DAY

    def test_constants_are_consistent(self):
        """
        Each constant is derivable from the previous one.
        """
        assert _NS_PER_MS == _NS_PER_US * 1_000
        assert _NS_PER_S == _NS_PER_MS * 1_000
        assert _NS_PER_MIN == _NS_PER_S * 60
        assert _NS_PER_HOUR == _NS_PER_MIN * 60
        assert _NS_PER_DAY == _NS_PER_HOUR * 24


class TestOptimizationRequest:
    """Tests for OptimizationRequest."""

    def test_required_fields(self):
        """
        Construction with only required fields succeeds.
        """
        req = OptimizationRequest(
            source_code="def f(): pass",
            language="python",
            language_version="3.12",
        )
        assert "def f(): pass" == req.source_code
        assert "python" == req.language
        assert "3.12" == req.language_version

    def test_default_values(self):
        """
        Optional fields use their documented defaults.
        """
        req = OptimizationRequest(
            source_code="x",
            language="python",
            language_version="3.12",
        )
        assert "" == req.context_code
        assert req.is_async is False
        assert req.is_numerical_code is None
        assert "" == req.codeflash_version
        assert req.baseline_runtime_ns is None
        assert req.loop_count is None
        assert req.line_profiler_results is None
        assert req.test_input_examples is None

    def test_all_fields(self):
        """
        All fields can be set explicitly.
        """
        req = OptimizationRequest(
            source_code="def f(): pass",
            language="javascript",
            language_version="ES2022",
            context_code="import os",
            is_async=True,
            is_numerical_code=True,
            codeflash_version="1.0.0",
            baseline_runtime_ns=5000,
            loop_count=100,
            line_profiler_results="Line # Hits",
            test_input_examples="example()",
        )
        assert "javascript" == req.language
        assert "ES2022" == req.language_version
        assert "import os" == req.context_code
        assert req.is_async is True
        assert req.is_numerical_code is True
        assert "1.0.0" == req.codeflash_version
        assert 5000 == req.baseline_runtime_ns
        assert 100 == req.loop_count
        assert "Line # Hits" == req.line_profiler_results
        assert "example()" == req.test_input_examples

    def test_frozen(self):
        """
        Mutating a field raises FrozenInstanceError.
        """
        req = OptimizationRequest(
            source_code="x",
            language="python",
            language_version="3.12",
        )
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            req.source_code = "y"

    def test_empty_source_code(self):
        """
        An empty source_code string is allowed.
        """
        req = OptimizationRequest(
            source_code="",
            language="python",
            language_version="3.12",
        )
        assert "" == req.source_code


class TestCandidate:
    """Tests for Candidate."""

    def test_required_fields(self):
        """
        Construction with only required fields succeeds.
        """
        c = Candidate(code="def f(): pass", explanation="simplified")
        assert "def f(): pass" == c.code
        assert "simplified" == c.explanation

    def test_default_values(self):
        """
        Optional fields default to empty strings.
        """
        c = Candidate(code="x", explanation="y")
        assert "" == c.candidate_id
        assert "" == c.source
        assert "" == c.parent_id
        assert "" == c.code_markdown

    def test_all_fields(self):
        """
        All fields can be set explicitly.
        """
        c = Candidate(
            code="def f(): pass",
            explanation="optimized",
            candidate_id="abc-123",
            source="ai-service",
            parent_id="parent-0",
            code_markdown="```python\ndef f(): pass\n```",
        )
        assert "abc-123" == c.candidate_id
        assert "ai-service" == c.source
        assert "parent-0" == c.parent_id
        assert "```python\ndef f(): pass\n```" == c.code_markdown

    def test_frozen(self):
        """
        Mutating a field raises FrozenInstanceError.
        """
        c = Candidate(code="x", explanation="y")
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            c.code = "z"

    def test_empty_strings(self):
        """
        Empty strings are valid for required fields.
        """
        c = Candidate(code="", explanation="")
        assert "" == c.code
        assert "" == c.explanation


class TestOptimizationReviewResult:
    """Tests for OptimizationReviewResult."""

    def test_construction(self):
        """
        Both fields are stored correctly.
        """
        r = OptimizationReviewResult(
            review="high",
            explanation="Well-tested optimization.",
        )
        assert "high" == r.review
        assert "Well-tested optimization." == r.explanation

    def test_frozen(self):
        """
        Mutating a field raises FrozenInstanceError.
        """
        r = OptimizationReviewResult(review="low", explanation="risky")
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            r.review = "high"

    def test_empty_strings(self):
        """
        Empty strings are valid for both fields.
        """
        r = OptimizationReviewResult(review="", explanation="")
        assert "" == r.review
        assert "" == r.explanation


class TestFileDiffContent:
    """Tests for FileDiffContent."""

    def test_construction(self):
        """
        Both old and new content are stored.
        """
        d = FileDiffContent(old_content="before", new_content="after")
        assert "before" == d.old_content
        assert "after" == d.new_content

    def test_frozen(self):
        """
        Mutating a field raises FrozenInstanceError.
        """
        d = FileDiffContent(old_content="a", new_content="b")
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            d.old_content = "c"

    def test_empty_strings(self):
        """
        Empty strings are valid for both fields.
        """
        d = FileDiffContent(old_content="", new_content="")
        assert "" == d.old_content
        assert "" == d.new_content


class TestBenchmarkDetail:
    """Tests for BenchmarkDetail."""

    @pytest.fixture(name="detail")
    def _detail(self):
        """
        A sample BenchmarkDetail for testing.
        """
        return BenchmarkDetail(
            benchmark_name="test_suite",
            test_function="test_compute",
            original_timing="100ms",
            expected_new_timing="50ms",
            speedup_percent=50.0,
        )

    def test_construction(self, detail):
        """
        All fields are stored correctly.
        """
        assert "test_suite" == detail.benchmark_name
        assert "test_compute" == detail.test_function
        assert "100ms" == detail.original_timing
        assert "50ms" == detail.expected_new_timing
        assert 50.0 == detail.speedup_percent

    def test_frozen(self, detail):
        """
        Mutating a field raises FrozenInstanceError.
        """
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            detail.benchmark_name = "other"

    def test_to_string(self, detail):
        """
        to_string returns a multi-line summary with the correct format.
        """
        result = detail.to_string()
        expected = (
            "Original timing for test_suite::test_compute: 100ms\n"
            "Expected new timing for test_suite::test_compute: 50ms\n"
            "Benchmark speedup for test_suite::test_compute: 50.00%\n"
        )
        assert expected == result

    def test_to_string_fractional_speedup(self):
        """
        to_string formats fractional speedup percentages to two decimals.
        """
        d = BenchmarkDetail(
            benchmark_name="bench",
            test_function="test_fn",
            original_timing="200ns",
            expected_new_timing="133ns",
            speedup_percent=33.333,
        )
        assert "33.33%" in d.to_string()

    def test_to_dict(self, detail):
        """
        to_dict returns all fields as a plain dictionary.
        """
        expected = {
            "benchmark_name": "test_suite",
            "test_function": "test_compute",
            "original_timing": "100ms",
            "expected_new_timing": "50ms",
            "speedup_percent": 50.0,
        }
        assert expected == detail.to_dict()

    def test_to_dict_roundtrip(self, detail):
        """
        A BenchmarkDetail can be reconstructed from its to_dict output.
        """
        d = detail.to_dict()
        reconstructed = BenchmarkDetail(**d)
        assert detail == reconstructed

    def test_empty_strings(self):
        """
        Empty strings are valid for name and timing fields.
        """
        d = BenchmarkDetail(
            benchmark_name="",
            test_function="",
            original_timing="",
            expected_new_timing="",
            speedup_percent=0.0,
        )
        assert "" == d.benchmark_name
        assert "" == d.test_function


class TestPrComment:
    """Tests for PrComment."""

    @pytest.fixture(name="pr_comment")
    def _pr_comment(self):
        """
        A sample PrComment for testing.
        """
        return PrComment(
            optimization_explanation="Replaced loop with vectorized op.",
            best_runtime=50_000_000,
            original_runtime=100_000_000,
            function_name="compute",
            relative_file_path="src/compute.py",
            speedup_x="2.0x",
            speedup_pct="50%",
            loop_count=10,
            report_table={"test_a": {"before": 100, "after": 50}},
        )

    def test_construction(self, pr_comment):
        """
        All required fields are stored correctly.
        """
        assert "Replaced loop with vectorized op." == (
            pr_comment.optimization_explanation
        )
        assert 50_000_000 == pr_comment.best_runtime
        assert 100_000_000 == pr_comment.original_runtime
        assert "compute" == pr_comment.function_name
        assert "src/compute.py" == pr_comment.relative_file_path
        assert "2.0x" == pr_comment.speedup_x
        assert "50%" == pr_comment.speedup_pct
        assert 10 == pr_comment.loop_count
        assert {"test_a": {"before": 100, "after": 50}} == (
            pr_comment.report_table
        )

    def test_optional_defaults(self, pr_comment):
        """
        Optional fields default to None.
        """
        assert pr_comment.benchmark_details is None
        assert pr_comment.original_async_throughput is None
        assert pr_comment.best_async_throughput is None

    def test_frozen(self, pr_comment):
        """
        Mutating a field raises FrozenInstanceError.
        """
        with pytest.raises(attrs.exceptions.FrozenInstanceError):
            pr_comment.function_name = "other"

    def test_to_json_keys(self, pr_comment):
        """
        to_json returns the expected set of keys.
        """
        result = pr_comment.to_json()
        expected_keys = {
            "optimization_explanation",
            "best_runtime",
            "original_runtime",
            "function_name",
            "file_path",
            "speedup_x",
            "speedup_pct",
            "loop_count",
            "report_table",
            "benchmark_details",
        }
        assert expected_keys == set(result.keys())

    def test_to_json_humanizes_runtimes(self, pr_comment):
        """
        to_json converts runtimes via humanize_runtime.
        """
        result = pr_comment.to_json()
        assert humanize_runtime(50_000_000) == result["best_runtime"]
        assert humanize_runtime(100_000_000) == result["original_runtime"]

    def test_to_json_file_path_key(self, pr_comment):
        """
        to_json maps relative_file_path to the 'file_path' key.
        """
        result = pr_comment.to_json()
        assert "src/compute.py" == result["file_path"]

    def test_to_json_no_benchmark_details(self, pr_comment):
        """
        to_json sets benchmark_details to None when unset.
        """
        result = pr_comment.to_json()
        assert result["benchmark_details"] is None

    def test_to_json_with_benchmark_details(self):
        """
        to_json includes benchmark_details when provided.
        """
        bd = BenchmarkDetail(
            benchmark_name="suite",
            test_function="test_fn",
            original_timing="10ms",
            expected_new_timing="5ms",
            speedup_percent=50.0,
        )
        pc = PrComment(
            optimization_explanation="optimized",
            best_runtime=5_000_000,
            original_runtime=10_000_000,
            function_name="fn",
            relative_file_path="f.py",
            speedup_x="2x",
            speedup_pct="50%",
            loop_count=1,
            report_table={},
            benchmark_details=(bd,),
        )
        result = pc.to_json()
        assert (bd,) == result["benchmark_details"]

    def test_to_json_empty_benchmark_details_tuple(self):
        """
        to_json converts an empty tuple of benchmark_details to None.
        """
        pc = PrComment(
            optimization_explanation="opt",
            best_runtime=1000,
            original_runtime=2000,
            function_name="fn",
            relative_file_path="f.py",
            speedup_x="2x",
            speedup_pct="50%",
            loop_count=1,
            report_table={},
            benchmark_details=(),
        )
        result = pc.to_json()
        assert result["benchmark_details"] is None

    def test_to_json_excludes_async_when_none(self, pr_comment):
        """
        to_json omits async throughput keys when both are None.
        """
        result = pr_comment.to_json()
        assert "original_async_throughput" not in result
        assert "best_async_throughput" not in result

    def test_to_json_includes_async_when_set(self):
        """
        to_json includes async throughput keys when both are set.
        """
        pc = PrComment(
            optimization_explanation="opt",
            best_runtime=1000,
            original_runtime=2000,
            function_name="fn",
            relative_file_path="f.py",
            speedup_x="2x",
            speedup_pct="50%",
            loop_count=1,
            report_table={},
            original_async_throughput=500,
            best_async_throughput=1000,
        )
        result = pc.to_json()
        assert 500 == result["original_async_throughput"]
        assert 1000 == result["best_async_throughput"]

    def test_to_json_excludes_async_when_only_original_set(self):
        """
        to_json omits async keys when only original_async_throughput is set.
        """
        pc = PrComment(
            optimization_explanation="opt",
            best_runtime=1000,
            original_runtime=2000,
            function_name="fn",
            relative_file_path="f.py",
            speedup_x="2x",
            speedup_pct="50%",
            loop_count=1,
            report_table={},
            original_async_throughput=500,
        )
        result = pc.to_json()
        assert "original_async_throughput" not in result
        assert "best_async_throughput" not in result


class TestHumanizeRuntime:
    """Tests for humanize_runtime."""

    def test_single_nanosecond(self):
        """
        1 ns uses the singular 'nanosecond' unit.
        """
        assert "1.00 nanosecond" == humanize_runtime(1)

    def test_small_nanoseconds(self):
        """
        Values below 1 microsecond use the 'nanoseconds' unit.
        """
        assert "500 nanoseconds" == humanize_runtime(500)

    def test_microsecond_range(self):
        """
        Values in the microsecond range display in microseconds.
        """
        result = humanize_runtime(5_000)
        assert "microseconds" in result

    def test_millisecond_range(self):
        """
        Values in the millisecond range display in milliseconds.
        """
        result = humanize_runtime(5_000_000)
        assert "milliseconds" in result

    def test_second_range(self):
        """
        Values in the second range display in seconds.
        """
        result = humanize_runtime(2_000_000_000)
        assert "seconds" in result

    def test_minute_range(self):
        """
        Values in the minute range display in minutes.
        """
        result = humanize_runtime(120_000_000_000)
        assert "minutes" in result

    def test_hour_range(self):
        """
        Values in the hour range display in hours.
        """
        result = humanize_runtime(2 * _NS_PER_HOUR)
        assert "hours" in result

    def test_day_range(self):
        """
        Values in the day range display in days.
        """
        result = humanize_runtime(2 * _NS_PER_DAY)
        assert "days" in result

    def test_exact_one_microsecond(self):
        """
        Exactly 1 microsecond uses the singular 'microsecond' unit.
        """
        result = humanize_runtime(1_000)
        assert "microsecond" in result
        assert "microseconds" not in result

    def test_exact_one_second(self):
        """
        Exactly 1 second uses the singular 'second' unit.
        """
        result = humanize_runtime(_NS_PER_S)
        assert "second" in result
        assert "seconds" not in result

    def test_exact_one_minute(self):
        """
        Exactly 1 minute uses the singular 'minute' unit.
        """
        result = humanize_runtime(_NS_PER_MIN)
        assert "minute" in result
        assert "minutes" not in result

    def test_exact_one_hour(self):
        """
        Exactly 1 hour uses the singular 'hour' unit.
        """
        result = humanize_runtime(_NS_PER_HOUR)
        assert "hour" in result
        assert "hours" not in result

    def test_exact_one_day(self):
        """
        Exactly 1 day uses the singular 'day' unit.
        """
        result = humanize_runtime(_NS_PER_DAY)
        assert "day" in result
        assert "days" not in result

    def test_returns_string(self):
        """
        The return type is always a string.
        """
        assert isinstance(humanize_runtime(42), str)

    def test_contains_space_between_value_and_unit(self):
        """
        The output always has a space between the numeric value and units.
        """
        result = humanize_runtime(12345)
        parts = result.split(" ")
        assert 2 == len(parts)
