"""Tests for eapctf.ctf.interface — run_local(), to_ctf_weights(), generate_main_template()."""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pandas as pd
import pytest

from eapctf.ctf.interface import CTF_PREINSTALLED, generate_main_template, run_local


# ---------------------------------------------------------------------------
# run_local()
# ---------------------------------------------------------------------------


class TestRunLocal:
    def test_valid_script_returns_weights(
        self, minimal_model_script: Path, minimal_ctf_data_dir: Path
    ) -> None:
        result = run_local(minimal_model_script, minimal_ctf_data_dir)
        assert isinstance(result, pd.DataFrame)
        for col in ("id", "eom", "w"):
            assert col in result.columns

    def test_missing_script_raises(self, minimal_ctf_data_dir: Path, tmp_path: Path) -> None:
        with pytest.raises(FileNotFoundError):
            run_local(tmp_path / "nonexistent.py", minimal_ctf_data_dir)

    def test_missing_data_file_raises(self, minimal_model_script: Path, tmp_path: Path) -> None:
        empty_dir = tmp_path / "empty"
        empty_dir.mkdir()
        with pytest.raises(FileNotFoundError):
            run_local(minimal_model_script, empty_dir)

    def test_script_without_main_raises(self, tmp_path: Path, minimal_ctf_data_dir: Path) -> None:
        script = tmp_path / "no_main.py"
        script.write_text("x = 1\n")
        with pytest.raises(AttributeError, match="main"):
            run_local(script, minimal_ctf_data_dir)

    def test_same_stem_different_path_no_collision(
        self, tmp_path: Path, minimal_ctf_data_dir: Path
    ) -> None:
        """Two scripts with the same stem in different dirs should not collide."""
        import textwrap

        dir_a = tmp_path / "a"
        dir_b = tmp_path / "b"
        dir_a.mkdir()
        dir_b.mkdir()

        base_src = textwrap.dedent("""\
            import pandas as pd

            def main(chars, features, daily_ret):
                ids = chars["id"].unique()[:2]
                w = 1.0 / len(ids)
                rows = [
                    {"id": int(i), "eom": str(pd.Timestamp(d).date()), "w": w}
                    for d in chars["eom"].unique()
                    for i in ids
                ]
                return pd.DataFrame(rows)
        """)
        (dir_a / "model.py").write_text(base_src)
        (dir_b / "model.py").write_text(base_src)

        r1 = run_local(dir_a / "model.py", minimal_ctf_data_dir)
        r2 = run_local(dir_b / "model.py", minimal_ctf_data_dir)
        assert len(r1) > 0
        assert len(r2) > 0


# ---------------------------------------------------------------------------
# generate_main_template()
# ---------------------------------------------------------------------------


class TestGenerateMainTemplate:
    def test_returns_string(self) -> None:
        def my_model(chars, features, daily_ret):
            pass

        result = generate_main_template(my_model)
        assert isinstance(result, str)

    def test_contains_function_name(self) -> None:
        def my_custom_fn(chars, features, daily_ret):
            pass

        result = generate_main_template(my_custom_fn)
        assert "my_custom_fn" in result

    def test_contains_correct_signature(self) -> None:
        def fn(chars, features, daily_ret):
            pass

        result = generate_main_template(fn)
        assert "def main(" in result
        assert "chars" in result
        assert "features" in result
        assert "daily_ret" in result

    def test_generated_code_is_executable(self) -> None:
        def my_fn(chars, features, daily_ret):
            return daily_ret

        code = generate_main_template(my_fn)
        namespace: dict = {}
        exec(compile(code, "<test>", "exec"), namespace)
        assert "main" in namespace


# ---------------------------------------------------------------------------
# CTF_PREINSTALLED
# ---------------------------------------------------------------------------


class TestCTFPreinstalled:
    def test_contains_required_packages(self) -> None:
        for pkg in ("pandas", "numpy", "pyarrow", "scipy"):
            assert pkg in CTF_PREINSTALLED

    def test_is_set(self) -> None:
        assert isinstance(CTF_PREINSTALLED, set)


# ---------------------------------------------------------------------------
# OOSResult.to_ctf_weights()
# ---------------------------------------------------------------------------


class TestToCtfWeights:
    @pytest.fixture
    def oos_result(self) -> object:
        """Build a minimal OOSResult with predictions.

        Uses default id_col='permno' and date_col='date' to match OOSResult defaults.
        """
        from eapctf.predict.oos import OOSResult

        np.random.seed(10)
        n_stocks = 20
        n_months = 24
        dates = pd.date_range("2020-01-31", periods=n_months, freq="ME")
        ids = list(range(1, n_stocks + 1))

        rows = []
        for d in dates:
            for i in ids:
                rows.append({
                    "permno": i,   # matches id_col default
                    "date": d,     # matches date_col default
                    "pred_signal": np.random.normal(0, 1),
                    "actual": np.random.normal(0.005, 0.02),
                })
        predictions = pd.DataFrame(rows)

        return OOSResult(predictions=predictions)

    def test_long_short_output_schema(self, oos_result: object) -> None:
        weights = oos_result.to_ctf_weights(signal_col="pred_signal", mode="long_short")
        assert isinstance(weights, pd.DataFrame)
        for col in ("id", "eom", "w"):
            assert col in weights.columns

    def test_long_short_no_nan(self, oos_result: object) -> None:
        weights = oos_result.to_ctf_weights(signal_col="pred_signal", mode="long_short")
        assert not weights["w"].isna().any()

    def test_long_short_no_duplicate_id_eom(self, oos_result: object) -> None:
        weights = oos_result.to_ctf_weights(signal_col="pred_signal", mode="long_short")
        dupes = weights.duplicated(subset=["id", "eom"])
        assert not dupes.any(), f"Duplicate (id, eom) pairs: {weights[dupes]}"

    def test_long_only_weights_sum_to_one(self, oos_result: object) -> None:
        weights = oos_result.to_ctf_weights(signal_col="pred_signal", mode="long_only")
        for eom, grp in weights.groupby("eom"):
            assert abs(grp["w"].sum() - 1.0) < 1e-8, f"Weights don't sum to 1 at {eom}"

    def test_long_short_weights_sum_near_zero(self, oos_result: object) -> None:
        # n_stocks=5 ensures both legs are populated (fixture has 20 stocks)
        weights = oos_result.to_ctf_weights(
            signal_col="pred_signal", mode="long_short", n_stocks=5
        )
        for eom, grp in weights.groupby("eom"):
            total = grp["w"].sum()
            assert abs(total) < 1e-8, f"Long-short sum != 0 at {eom}: {total}"
