"""Long-short portfolio construction from sorted signals.

Provides signal-based long-short portfolio construction (decile sorting approach)
following Fama-French and related methodology. This is distinct from portfolio
optimization (see eapctf.portfolio).

References
----------
Fama, E. F., & French, K. R. (1993). Common risk factors in the returns on
    stocks and bonds. Journal of Financial Economics, 33(1), 3-56.
"""

from __future__ import annotations

from dataclasses import dataclass, field

import numpy as np
import pandas as pd

from eapctf.utils.preprocessing import classify


@dataclass
class PortfolioResult:
    """Container for portfolio construction output.

    Attributes
    ----------
    returns : pd.DataFrame
        Date-indexed DataFrame with portfolio return columns.
        Includes individual decile columns and a 'long_short' column.
    weights : pd.DataFrame
        Date x entity weight matrix (sparse in practice).
    metrics : dict[str, float]
        Summary statistics computed from the long-short return series.
    """

    returns: pd.DataFrame
    weights: pd.DataFrame
    metrics: dict[str, float] = field(default_factory=dict)


def long_short_portfolio(
    data: pd.DataFrame,
    signal_col: str,
    ret_col: str = "ret",
    date_col: str = "date",
    entity_col: str = "permno",
    mktcap_col: str = "mktcap",
    n_portfolios: int = 10,
    weighting: str = "vw",
    long_port: int | None = None,
    short_port: int | None = None,
    transaction_cost_bps: float = 0.0,
) -> PortfolioResult:
    """Construct a long-short portfolio from a signal column.

    Classifies the signal into n_portfolios deciles each period, computes
    value-weighted or equal-weighted returns per decile, then forms the
    long-short spread (top minus bottom decile).

    Parameters
    ----------
    data : pd.DataFrame
        Long-format panel with signal, return, date, entity, and mktcap columns.
    signal_col : str
        Column name of the sorting signal.
    ret_col : str
        Column name for asset returns.
    date_col : str
        Column name for dates.
    entity_col : str
        Column name for entity identifiers.
    mktcap_col : str
        Column name for market capitalization (used for VW).
    n_portfolios : int
        Number of portfolios (deciles = 10).
    weighting : str
        Weighting scheme: "vw" (value-weighted) or "ew" (equal-weighted).
    long_port : int | None
        Which portfolio to go long. Defaults to n_portfolios (top, 1-based).
    short_port : int | None
        Which portfolio to go short. Defaults to 1 (bottom, 1-based).
    transaction_cost_bps : float
        Transaction cost in basis points. Applied as turnover * cost/10000.

    Returns
    -------
    PortfolioResult
        Contains returns (with 'long_short' column), weights, and summary metrics.

    References
    ----------
    Fama, E. F., & French, K. R. (1993). Common risk factors in the
        returns on stocks and bonds. Journal of Financial Economics, 33(1), 3-56.
    """
    # Default long and short portfolios to top and bottom deciles (1-based)
    if long_port is None:
        long_port = n_portfolios
    if short_port is None:
        short_port = 1

    # Make a working copy to avoid modifying input data
    df = data.copy()

    # Assign portfolio labels using quantile classification
    # by_nyse=False since we don't require exchcd column in this interface
    df["_port"] = classify(
        df,
        col=signal_col,
        n_portfolios=n_portfolios,
        ascending=True,
        by_nyse=False,
        date_col=date_col,
    )

    # Drop rows where classification failed (NaN signal)
    df = df.dropna(subset=["_port"])
    df["_port"] = df["_port"].astype(int)

    # Compute portfolio returns per date x decile
    port_returns_list: list[pd.DataFrame] = []
    dates = sorted(df[date_col].unique())

    for date_val in dates:
        date_slice = df.loc[df[date_col] == date_val]
        row: dict[str, object] = {date_col: date_val}

        for p in range(1, n_portfolios + 1):
            port_slice = date_slice.loc[date_slice["_port"] == p]
            if len(port_slice) == 0:
                row[f"port_{p}"] = np.nan
                continue

            if weighting == "vw" and mktcap_col in port_slice.columns:
                # Value-weighted return: sum(w_i * r_i) where w_i = mcap_i / sum(mcap)
                total_cap = port_slice[mktcap_col].sum()
                if total_cap > 0:
                    row[f"port_{p}"] = float(
                        (port_slice[ret_col] * port_slice[mktcap_col]).sum() / total_cap
                    )
                else:
                    row[f"port_{p}"] = float(port_slice[ret_col].mean())
            else:
                # Equal-weighted return
                row[f"port_{p}"] = float(port_slice[ret_col].mean())

        port_returns_list.append(pd.DataFrame([row]))

    # Combine all dates into a single returns DataFrame
    port_returns = pd.concat(port_returns_list, ignore_index=True)
    # Try to convert dates to datetime; if they aren't parseable, keep as-is
    try:
        port_returns[date_col] = pd.to_datetime(port_returns[date_col])
    except (ValueError, TypeError):
        pass
    port_returns = port_returns.set_index(date_col)

    # Compute long-short spread: long decile minus short decile
    long_col = f"port_{long_port}"
    short_col = f"port_{short_port}"
    port_returns["long_short"] = port_returns[long_col] - port_returns[short_col]

    # Build weight matrix: date x entity
    weight_records: list[dict[str, object]] = []

    for date_val in dates:
        date_slice = df.loc[df[date_col] == date_val]
        current_weights: dict[str, object] = {}

        # Long leg weights
        long_slice = date_slice.loc[date_slice["_port"] == long_port]
        short_slice = date_slice.loc[date_slice["_port"] == short_port]

        for leg_slice, sign in [(long_slice, 1.0), (short_slice, -1.0)]:
            if len(leg_slice) == 0:
                continue

            if weighting == "vw" and mktcap_col in leg_slice.columns:
                total_cap = leg_slice[mktcap_col].sum()
                if total_cap > 0:
                    w = (leg_slice[mktcap_col] / total_cap * sign).to_dict()
                    # Map from index to entity id
                    for idx in leg_slice.index:
                        eid = leg_slice.at[idx, entity_col]
                        current_weights[str(eid)] = w.get(idx, 0.0)
                else:
                    n = len(leg_slice)
                    for idx in leg_slice.index:
                        eid = leg_slice.at[idx, entity_col]
                        current_weights[str(eid)] = sign / n
            else:
                n = len(leg_slice)
                for idx in leg_slice.index:
                    eid = leg_slice.at[idx, entity_col]
                    current_weights[str(eid)] = sign / n

        record: dict[str, object] = {date_col: date_val}
        record.update(current_weights)
        weight_records.append(record)

    weights_df = pd.DataFrame(weight_records).set_index(date_col).fillna(0.0)

    # Apply transaction costs based on weight turnover
    if transaction_cost_bps > 0.0:
        cost_rate = transaction_cost_bps / 10_000.0
        # Compute turnover as sum of absolute weight changes between periods
        weight_changes = weights_df.diff().abs().sum(axis=1)
        # First period has no turnover
        weight_changes.iloc[0] = 0.0
        # Deduct costs from long_short return
        cost_series = weight_changes * cost_rate
        port_returns["long_short"] = port_returns["long_short"] - cost_series.values

    # Compute summary metrics for the long-short portfolio
    ls = port_returns["long_short"].dropna()
    mean_r = float(ls.mean())
    std_r = float(ls.std(ddof=1)) if len(ls) > 1 else 0.0
    sharpe = (mean_r / std_r * np.sqrt(12)) if std_r > 0 else 0.0

    metrics: dict[str, float] = {
        "mean_return": mean_r,
        "std_return": std_r,
        "sharpe_ratio": sharpe,
        "n_periods": float(len(ls)),
    }

    return PortfolioResult(
        returns=port_returns,
        weights=weights_df,
        metrics=metrics,
    )
