"""Tests for eapctf.portfolio: weighting, metrics; and eapctf.sorting.portfolio: long-short."""

from __future__ import annotations

import numpy as np
import pandas as pd
import pytest

try:
    import pypfopt  # noqa: F401
    PYPFOPT_AVAILABLE = True
except ImportError:
    PYPFOPT_AVAILABLE = False

from eapctf.portfolio import (
    ManagedPortfolio,
    PortfolioMetrics,
    cov_matrix,
    evaluate_portfolio,
    mean_variance_weights,
)
from eapctf.sorting.portfolio import PortfolioResult, long_short_portfolio


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


def _make_portfolio_panel(
    n_stocks: int = 30,
    n_months: int = 60,
    seed: int = 42,
) -> pd.DataFrame:
    """Create a simple panel for portfolio construction tests."""
    np.random.seed(seed)
    rows = []
    for date in range(1, n_months + 1):
        for permno in range(1001, 1001 + n_stocks):
            rows.append(
                {
                    "permno": permno,
                    "date": date,
                    "ret": np.random.normal(0.005, 0.05),
                    "mktcap": np.random.lognormal(5, 1),
                    "signal": np.random.normal(0, 1),
                }
            )
    return pd.DataFrame(rows)


def _make_returns_df(T: int = 60, N: int = 10, seed: int = 0) -> pd.DataFrame:
    """T x N returns DataFrame for optimization tests."""
    np.random.seed(seed)
    return pd.DataFrame(
        np.random.normal(0.005, 0.04, (T, N)),
        columns=[f"asset_{i}" for i in range(N)],
    )


# ---------------------------------------------------------------------------
# long_short_portfolio
# ---------------------------------------------------------------------------


class TestLongShortPortfolio:
    def test_happy_path(self) -> None:
        """long_short_portfolio should return a PortfolioResult."""
        panel = _make_portfolio_panel()
        result = long_short_portfolio(panel, signal_col="signal")
        assert isinstance(result, PortfolioResult)
        assert "long_short" in result.returns.columns
        assert isinstance(result.metrics, dict)

    def test_portfolio_returns_shape(self) -> None:
        """portfolio returns should have n_portfolios + 1 (long_short) columns."""
        panel = _make_portfolio_panel(n_months=60)
        n = 5
        result = long_short_portfolio(panel, signal_col="signal", n_portfolios=n)
        port_cols = [c for c in result.returns.columns if c.startswith("port_")]
        assert len(port_cols) == n

    def test_equal_weighted(self) -> None:
        """equal-weighted long_short_portfolio should also work."""
        panel = _make_portfolio_panel()
        result = long_short_portfolio(panel, signal_col="signal", weighting="ew")
        assert isinstance(result, PortfolioResult)
        assert "long_short" in result.returns.columns

    def test_metrics_keys(self) -> None:
        """metrics dict should contain mean_return and sharpe_ratio."""
        panel = _make_portfolio_panel()
        result = long_short_portfolio(panel, signal_col="signal")
        assert "mean_return" in result.metrics
        assert "sharpe_ratio" in result.metrics
        assert "std_return" in result.metrics

    def test_weights_shape(self) -> None:
        """weights DataFrame should be date-indexed and have entity columns."""
        panel = _make_portfolio_panel(n_stocks=10, n_months=20)
        result = long_short_portfolio(panel, signal_col="signal")
        assert isinstance(result.weights, pd.DataFrame)
        assert len(result.weights) == 20

    def test_missing_signal(self) -> None:
        """Panel with NaN in signal should still run."""
        panel = _make_portfolio_panel(n_months=30)
        panel.loc[panel["date"] == 1, "signal"] = np.nan
        result = long_short_portfolio(panel, signal_col="signal")
        assert isinstance(result, PortfolioResult)

    def test_single_date(self) -> None:
        """Single-date panel should work for a simple equal-weighted sort."""
        df = pd.DataFrame(
            {
                "permno": list(range(1001, 1021)),
                "date": [1] * 20,
                "ret": np.random.normal(size=20),
                "mktcap": np.random.lognormal(size=20),
                "signal": np.random.normal(size=20),
            }
        )
        result = long_short_portfolio(df, signal_col="signal", n_portfolios=3)
        assert isinstance(result, PortfolioResult)


# ---------------------------------------------------------------------------
# cov_matrix
# ---------------------------------------------------------------------------


@pytest.mark.skipif(not PYPFOPT_AVAILABLE, reason="pypfopt not installed")
class TestCovMatrix:
    def test_sample_cov(self) -> None:
        """cov_matrix with method='sample' should return a square DataFrame."""
        returns = _make_returns_df()
        cov = cov_matrix(returns, method="sample")
        assert isinstance(cov, pd.DataFrame)
        N = returns.shape[1]
        assert cov.shape == (N, N)

    def test_ledoit_wolf(self) -> None:
        """cov_matrix with Ledoit-Wolf should return positive semi-definite matrix."""
        returns = _make_returns_df()
        cov = cov_matrix(returns, method="ledoit_wolf")
        assert isinstance(cov, pd.DataFrame)
        eigenvalues = np.linalg.eigvalsh(cov.values)
        # All eigenvalues should be non-negative (positive semi-definite)
        assert (eigenvalues >= -1e-8).all()

    def test_unknown_method_raises(self) -> None:
        """Unknown method should raise ValueError."""
        returns = _make_returns_df()
        with pytest.raises(ValueError):
            cov_matrix(returns, method="unknown_method_xyz")


# ---------------------------------------------------------------------------
# mean_variance_weights
# ---------------------------------------------------------------------------


@pytest.mark.skipif(not PYPFOPT_AVAILABLE, reason="pypfopt not installed")
class TestMeanVarianceWeights:
    def test_min_vol(self) -> None:
        """min_vol optimization should return weights summing to ~1."""
        returns = _make_returns_df()
        expected_rets = pd.Series(
            {col: 0.005 for col in returns.columns}
        )
        weights = mean_variance_weights(
            expected_rets,
            returns_data=returns,
            method="min_vol",
            weight_bounds=(0.0, 1.0),
        )
        assert isinstance(weights, pd.Series)
        assert abs(weights.sum() - 1.0) < 0.01

    def test_no_cov_or_returns_raises(self) -> None:
        """Providing neither cov_matrix_input nor returns_data should raise ValueError."""
        expected_rets = pd.Series({"a": 0.01, "b": 0.02})
        with pytest.raises(ValueError):
            mean_variance_weights(expected_rets, cov_matrix_input=None, returns_data=None)


# ---------------------------------------------------------------------------
# ManagedPortfolio
# ---------------------------------------------------------------------------


class TestManagedPortfolio:
    def test_fit_predict(self) -> None:
        """ManagedPortfolio should fit and predict without error."""
        np.random.seed(10)
        T = 80
        idx = pd.RangeIndex(T)
        signal = pd.Series(np.random.normal(0, 1, T), index=idx)
        factor_returns = pd.DataFrame(
            {"mkt": np.random.normal(0.005, 0.04, T)}, index=idx
        )
        mp = ManagedPortfolio(cv_window=20)
        mp.fit(signal, factor_returns)
        managed = mp.predict(signal, factor_returns)
        assert isinstance(managed, pd.Series)
        assert len(managed) == T

    def test_predict_before_fit_raises(self) -> None:
        """predict before fit should raise RuntimeError."""
        mp = ManagedPortfolio()
        signal = pd.Series([1.0, 2.0])
        factors = pd.DataFrame({"mkt": [0.01, 0.02]})
        with pytest.raises(RuntimeError):
            mp.predict(signal, factors)

    def test_lambda_opt_is_float(self) -> None:
        """lambda_opt property should return a float after fit."""
        np.random.seed(11)
        T = 80
        idx = pd.RangeIndex(T)
        signal = pd.Series(np.random.normal(0, 1, T), index=idx)
        factor_returns = pd.DataFrame({"mkt": np.random.normal(0, 0.04, T)}, index=idx)
        mp = ManagedPortfolio(cv_window=20)
        mp.fit(signal, factor_returns)
        assert isinstance(mp.lambda_opt, float)


# ---------------------------------------------------------------------------
# evaluate_portfolio
# ---------------------------------------------------------------------------


class TestEvaluatePortfolio:
    def test_happy_path(self) -> None:
        """evaluate_portfolio should return a PortfolioMetrics object."""
        np.random.seed(20)
        T = 60
        returns = pd.Series(np.random.normal(0.005, 0.04, T))
        result = evaluate_portfolio(returns)
        assert isinstance(result, PortfolioMetrics)

    def test_metrics_types(self) -> None:
        """All metric fields should be floats."""
        np.random.seed(21)
        T = 60
        returns = pd.Series(np.random.normal(0.005, 0.04, T))
        result = evaluate_portfolio(returns)
        assert isinstance(result.annualized_return, float)
        assert isinstance(result.sharpe_ratio, float)
        assert isinstance(result.max_drawdown, float)

    def test_max_drawdown_non_negative(self) -> None:
        """max_drawdown should be >= 0."""
        np.random.seed(22)
        T = 60
        returns = pd.Series(np.random.normal(0.005, 0.04, T))
        result = evaluate_portfolio(returns)
        assert result.max_drawdown >= 0.0

    def test_with_factor_returns(self) -> None:
        """evaluate_portfolio with factor_returns should compute ff_alpha."""
        np.random.seed(23)
        T = 60
        returns = pd.Series(np.random.normal(0.005, 0.04, T))
        factors = pd.DataFrame({"mkt": np.random.normal(0.005, 0.04, T)})
        result = evaluate_portfolio(returns, factor_returns=factors)
        assert isinstance(result.ff_alpha, float)
