"""Portfolio analytics facade built on top of PortfolioData.

This module provides :class:`Portfolio`, which extends :class:`PortfolioData`
with analytics, transforms, and attribution tools:

- Lazy composition accessors — :attr:`stats`, :attr:`plots`, :attr:`report`
- Portfolio transforms — :meth:`truncate`, :meth:`lag`, :meth:`smoothed_holding`
- Attribution — :attr:`tilt`, :attr:`timing`, :attr:`tilt_timing_decomp`
- Turnover analysis — :attr:`turnover`, :attr:`turnover_weekly`, :meth:`turnover_summary`
- Cost analysis — :meth:`cost_adjusted_returns`, :meth:`trading_cost_impact`
- Utility — :meth:`correlation`

The raw data layer (inputs, derived P&L series) lives in
:class:`~basanos.analytics._portfolio_data.PortfolioData` and is fully
accessible through the same instance.
"""

import dataclasses

import polars as pl
import polars.selectors as cs

from ..exceptions import (
    IntegerIndexBoundError,
)
from ._plots import Plots
from ._portfolio_data import PortfolioData
from ._report import Report
from ._stats import Stats


@dataclasses.dataclass(frozen=True)
class Portfolio(PortfolioData):
    """Analytics facade extending PortfolioData with transforms and analytics.

    Inherits all data properties from :class:`PortfolioData` (profits, NAV,
    returns, drawdown) and adds:

    - Lazy composition accessors: :attr:`stats`, :attr:`plots`, :attr:`report`
    - Portfolio transforms: :meth:`truncate`, :meth:`lag`,
      :meth:`smoothed_holding`
    - Attribution: :attr:`tilt`, :attr:`timing`, :attr:`tilt_timing_decomp`
    - Turnover: :attr:`turnover`, :attr:`turnover_weekly`,
      :meth:`turnover_summary`
    - Cost analysis: :meth:`cost_adjusted_returns`,
      :meth:`trading_cost_impact`
    - Utility: :meth:`correlation`

    Attributes:
        cashposition: Polars DataFrame of positions per asset over time
            (includes date column if present).
        prices: Polars DataFrame of prices per asset over time (includes date
            column if present).
        aum: Assets under management used as base NAV offset.

    Examples:
        >>> import polars as pl
        >>> from datetime import date
        >>> prices = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [100.0, 110.0]})
        >>> pos = pl.DataFrame({"date": [date(2020, 1, 1), date(2020, 1, 2)], "A": [1000.0, 1000.0]})
        >>> pf = Portfolio(prices=prices, cashposition=pos)
        >>> pf.assets
        ['A']
    """

    # ── Lazy composition accessors ─────────────────────────────────────────────

    @property
    def stats(self) -> Stats:
        """Return a Stats object built from the portfolio's daily returns.

        Constructs a :class:`~basanos.analytics._stats.Stats` instance from
        the portfolio returns. When a ``'date'`` column is present both
        ``'date'`` and ``'returns'`` are passed to Stats; otherwise only
        ``'returns'`` is used.
        """
        cols = ["date", "returns"] if "date" in self.returns.columns else ["returns"]
        return Stats(data=self.returns.select(cols))

    @property
    def plots(self) -> Plots:
        """Convenience accessor returning a Plots facade for this portfolio.

        Use this to create Plotly visualizations such as snapshots, lagged
        performance curves, and lead/lag IR charts.

        Returns:
            :class:`~basanos.analytics._plots.Plots`: Helper object with
            plotting methods.
        """
        return Plots(self)

    @property
    def report(self) -> Report:
        """Convenience accessor returning a Report facade for this portfolio.

        Use this to generate a self-contained HTML performance report
        containing statistics tables and interactive charts.

        Returns:
            :class:`~basanos.analytics._report.Report`: Helper object with
            report methods.
        """
        return Report(self)

    # ── Portfolio transforms ───────────────────────────────────────────────────

    def truncate(self, start: object = None, end: object = None) -> "Portfolio":
        """Return a new Portfolio truncated to the inclusive [start, end] range.

        When a ``'date'`` column is present in both prices and cash positions,
        truncation is performed by comparing the ``'date'`` column against
        ``start`` and ``end`` (which should be date/datetime values or strings
        parseable by Polars).

        When the ``'date'`` column is absent, integer-based row slicing is
        used instead.  In this case ``start`` and ``end`` must be non-negative
        integers representing 0-based row indices.  Passing non-integer bounds
        to an integer-indexed portfolio raises :exc:`TypeError`.

        In all cases the ``aum`` value is preserved.

        Args:
            start: Optional lower bound (inclusive). A date/datetime or
                Polars-parseable string when a ``'date'`` column exists; a
                non-negative int row index when the data has no ``'date'``
                column.
            end: Optional upper bound (inclusive). Same type rules as
                ``start``.

        Returns:
            A new Portfolio instance with prices and cash positions filtered
            to the specified range.

        Raises:
            TypeError: When the portfolio has no ``'date'`` column and a
                non-integer bound is supplied.
        """
        has_date = "date" in self.prices.columns
        if has_date:
            cond = pl.lit(True)
            if start is not None:
                cond = cond & (pl.col("date") >= pl.lit(start))
            if end is not None:
                cond = cond & (pl.col("date") <= pl.lit(end))
            pr = self.prices.filter(cond)
            cp = self.cashposition.filter(cond)
        else:
            if start is not None and not isinstance(start, int):
                raise IntegerIndexBoundError("start", type(start).__name__)
            if end is not None and not isinstance(end, int):
                raise IntegerIndexBoundError("end", type(end).__name__)
            row_start = int(start) if start is not None else 0
            row_end = int(end) + 1 if end is not None else self.prices.height
            length = max(0, row_end - row_start)
            pr = self.prices.slice(row_start, length)
            cp = self.cashposition.slice(row_start, length)
        return Portfolio(prices=pr, cashposition=cp, aum=self.aum)

    def lag(self, n: int) -> "Portfolio":
        """Return a new Portfolio with cash positions lagged by ``n`` steps.

        This method shifts the numeric asset columns in the cashposition
        DataFrame by ``n`` rows, preserving the ``'date'`` column and any
        non-numeric columns unchanged.  Positive ``n`` delays weights (moves
        them down); negative ``n`` leads them (moves them up); ``n == 0``
        returns the current portfolio unchanged.

        Notes:
            Missing values introduced by the shift are left as nulls;
            downstream profit computation already guards and treats nulls as
            zero when multiplying by returns.

        Args:
            n: Number of rows to shift (can be negative, zero, or positive).

        Returns:
            A new Portfolio instance with lagged cash positions and the same
            prices/AUM as the original.
        """
        if not isinstance(n, int):
            raise TypeError
        if n == 0:
            return self

        assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()]
        cp_lagged = self.cashposition.with_columns(pl.col(c).shift(n) for c in assets)
        return Portfolio(prices=self.prices, cashposition=cp_lagged, aum=self.aum)

    def smoothed_holding(self, n: int) -> "Portfolio":
        """Return a new Portfolio with cash positions smoothed by a rolling mean.

        Applies a trailing window average over the last ``n`` steps for each
        numeric asset column (excluding ``'date'``). The window length is
        ``n + 1`` so that:

        - n=0 returns the original weights (no smoothing),
        - n=1 averages the current and previous weights,
        - n=k averages the current and last k weights.

        Args:
            n: Non-negative integer specifying how many previous steps to
                include.

        Returns:
            A new Portfolio with smoothed cash positions and the same
            prices/AUM.
        """
        if not isinstance(n, int):
            raise TypeError
        if n < 0:
            raise ValueError
        if n == 0:
            return self

        assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()]
        window = n + 1
        cp_smoothed = self.cashposition.with_columns(
            pl.col(c).rolling_mean(window_size=window, min_samples=1).alias(c) for c in assets
        )
        return Portfolio(prices=self.prices, cashposition=cp_smoothed, aum=self.aum)

    # ── Attribution ────────────────────────────────────────────────────────────

    @property
    def tilt(self) -> "Portfolio":
        """Return the 'tilt' portfolio with constant average weights.

        Computes the time-average of each asset's cash position (ignoring
        nulls/NaNs) and builds a new Portfolio with those constant weights
        applied across time. Prices and AUM are preserved.
        """
        const_position = self.cashposition.with_columns(
            pl.col(col).drop_nulls().drop_nans().mean().alias(col) for col in self.assets
        )
        return Portfolio.from_cash_position(self.prices, const_position, aum=self.aum)

    @property
    def timing(self) -> "Portfolio":
        """Return the 'timing' portfolio capturing deviations from the tilt.

        Constructs weights as original cash positions minus the tilt's
        constant positions, per asset. This isolates timing (alloc-demeaned)
        effects. Prices and AUM are preserved.
        """
        const_position = self.tilt.cashposition
        position = self.cashposition.with_columns((pl.col(col) - const_position[col]).alias(col) for col in self.assets)
        return Portfolio.from_cash_position(self.prices, position, aum=self.aum)

    @property
    def tilt_timing_decomp(self) -> pl.DataFrame:
        """Return the portfolio's tilt/timing NAV decomposition.

        When a ``'date'`` column is present the three NAV series are joined on
        it. When data is integer-indexed the frames are stacked horizontally.
        """
        if "date" in self.nav_accumulated.columns:
            nav_portfolio = self.nav_accumulated.select(["date", "NAV_accumulated"])
            nav_tilt = self.tilt.nav_accumulated.select(["date", "NAV_accumulated"])
            nav_timing = self.timing.nav_accumulated.select(["date", "NAV_accumulated"])

            merged_df = nav_portfolio.join(nav_tilt, on="date", how="inner", suffix="_tilt").join(
                nav_timing, on="date", how="inner", suffix="_timing"
            )
        else:
            nav_portfolio = self.nav_accumulated.select(["NAV_accumulated"])
            nav_tilt = self.tilt.nav_accumulated.select(["NAV_accumulated"]).rename(
                {"NAV_accumulated": "NAV_accumulated_tilt"}
            )
            nav_timing = self.timing.nav_accumulated.select(["NAV_accumulated"]).rename(
                {"NAV_accumulated": "NAV_accumulated_timing"}
            )
            merged_df = nav_portfolio.hstack(nav_tilt).hstack(nav_timing)

        merged_df = merged_df.rename(
            {"NAV_accumulated_tilt": "tilt", "NAV_accumulated_timing": "timing", "NAV_accumulated": "portfolio"}
        )
        return merged_df

    # ── Turnover ───────────────────────────────────────────────────────────────

    @property
    def turnover(self) -> pl.DataFrame:
        """Daily one-way portfolio turnover as a fraction of AUM.

        Computes the sum of absolute position changes across all assets for
        each period, normalised by AUM.  The first row is always zero because
        there is no prior position to form a difference against.

        Returns:
            pl.DataFrame: Frame with an optional ``'date'`` column and a
            ``'turnover'`` column (dimensionless fraction of AUM).

        Examples:
            >>> import polars as pl
            >>> from datetime import date
            >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)]
            >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]})
            >>> pos = pl.DataFrame({"date": prices["date"], "A": [1000.0, 1200.0, 900.0]})
            >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5)
            >>> pf.turnover["turnover"].to_list()
            [0.0, 0.002, 0.003]
        """
        assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()]
        daily_abs_chg = (pl.sum_horizontal(pl.col(c).diff().abs().fill_null(0.0) for c in assets) / self.aum).alias(
            "turnover"
        )
        cols: list[str | pl.Expr] = []
        if "date" in self.cashposition.columns:
            cols.append("date")
        cols.append(daily_abs_chg)
        return self.cashposition.select(cols)

    @property
    def turnover_weekly(self) -> pl.DataFrame:
        """Weekly aggregated one-way portfolio turnover as a fraction of AUM.

        When a ``'date'`` column is present, sums the daily turnover within
        each calendar week (Monday-based ``group_by_dynamic``).  Without a
        date column, a rolling 5-period sum with ``min_samples=5`` is returned
        (the first four rows will be ``null``).

        Returns:
            pl.DataFrame: Frame with an optional ``'date'`` column (week
            start) and a ``'turnover'`` column (fraction of AUM, summed over
            the week).
        """
        daily = self.turnover
        if "date" not in daily.columns or not daily["date"].dtype.is_temporal():
            return daily.with_columns(pl.col("turnover").rolling_sum(window_size=5, min_samples=5))
        return daily.group_by_dynamic("date", every="1w").agg(pl.col("turnover").sum()).sort("date")

    def turnover_summary(self) -> pl.DataFrame:
        """Return a summary DataFrame of turnover statistics.

        Computes three metrics from the daily turnover series:

        - ``mean_daily_turnover``: mean of daily one-way turnover (fraction
          of AUM).
        - ``mean_weekly_turnover``: mean of weekly-aggregated turnover
          (fraction of AUM).
        - ``turnover_std``: standard deviation of daily turnover (fraction of
          AUM); complements the mean to detect regime switches.

        Returns:
            pl.DataFrame: One row per metric with columns ``'metric'`` and
            ``'value'``.

        Examples:
            >>> import polars as pl
            >>> from datetime import date, timedelta
            >>> import numpy as np
            >>> start = date(2020, 1, 1)
            >>> dates = pl.date_range(start=start, end=start + timedelta(days=9), interval="1d", eager=True)
            >>> prices = pl.DataFrame({"date": dates, "A": pl.Series(np.ones(10) * 100.0)})
            >>> pos = pl.DataFrame({"date": dates, "A": pl.Series([float(i) * 100 for i in range(10)])})
            >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e4)
            >>> summary = pf.turnover_summary()
            >>> list(summary["metric"])
            ['mean_daily_turnover', 'mean_weekly_turnover', 'turnover_std']
        """
        daily_col = self.turnover["turnover"]
        _mean = daily_col.mean()
        mean_daily = float(_mean) if isinstance(_mean, (int, float)) else 0.0
        _std = daily_col.std()
        std_daily = float(_std) if isinstance(_std, (int, float)) else 0.0
        weekly_col = self.turnover_weekly["turnover"].drop_nulls()
        _weekly_mean = weekly_col.mean()
        mean_weekly = (
            float(_weekly_mean) if weekly_col.len() > 0 and isinstance(_weekly_mean, (int, float)) else float("nan")
        )
        return pl.DataFrame(
            {
                "metric": ["mean_daily_turnover", "mean_weekly_turnover", "turnover_std"],
                "value": [mean_daily, mean_weekly, std_daily],
            }
        )

    # ── Cost analysis ──────────────────────────────────────────────────────────

    def cost_adjusted_returns(self, cost_bps: float) -> pl.DataFrame:
        """Return daily portfolio returns net of estimated one-way trading costs.

        Trading costs are modelled as a linear function of daily one-way
        turnover: for every unit of AUM traded, the strategy incurs
        ``cost_bps`` basis points (i.e. ``cost_bps / 10_000`` fractional
        cost). The daily cost deduction is therefore::

            daily_cost = turnover * (cost_bps / 10_000)

        where ``turnover`` is the fraction-of-AUM one-way turnover already
        computed by :attr:`turnover`.  The deduction is applied to the
        ``returns`` column of :attr:`returns`, leaving all other columns
        (including ``date``) untouched.

        Args:
            cost_bps: One-way trading cost in basis points per unit of AUM
                traded.  Must be non-negative.

        Returns:
            pl.DataFrame: Same schema as :attr:`returns` but with the
            ``returns`` column reduced by the per-period trading cost.

        Raises:
            ValueError: If ``cost_bps`` is negative.

        Examples:
            >>> import polars as pl
            >>> from datetime import date
            >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)]
            >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]})
            >>> pos = pl.DataFrame({"date": _d, "A": [1000.0, 1200.0, 900.0]})
            >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5)
            >>> adj = pf.cost_adjusted_returns(0.0)
            >>> float(adj["returns"][1]) == float(pf.returns["returns"][1])
            True
        """
        if cost_bps < 0:
            raise ValueError
        base = self.returns
        daily_cost = self.turnover["turnover"] * (cost_bps / 10_000.0)
        return base.with_columns((pl.col("returns") - daily_cost).alias("returns"))

    def trading_cost_impact(self, max_bps: int = 20) -> pl.DataFrame:
        """Estimate the impact of trading costs on the Sharpe ratio.

        Computes the annualised Sharpe ratio of cost-adjusted returns for
        each integer cost level from 0 up to and including ``max_bps`` basis
        points (1 bp = 0.01 %).  The result lets you quickly assess at what
        cost level the strategy's edge is eroded.

        Args:
            max_bps: Maximum one-way trading cost to evaluate, in basis
                points.  Defaults to 20 (i.e., evaluates 0, 1, 2, …, 20
                bps).  Must be a positive integer.

        Returns:
            pl.DataFrame: Frame with columns ``'cost_bps'`` (Int64) and
            ``'sharpe'`` (Float64), one row per cost level from 0 to
            ``max_bps`` inclusive.

        Raises:
            ValueError: If ``max_bps`` is not a positive integer.

        Examples:
            >>> import polars as pl
            >>> from datetime import date, timedelta
            >>> import numpy as np
            >>> start = date(2020, 1, 1)
            >>> dates = pl.date_range(
            ...     start=start, end=start + timedelta(days=99), interval="1d", eager=True
            ... )
            >>> rng = np.random.default_rng(0)
            >>> prices = pl.DataFrame({
            ...     "date": dates,
            ...     "A": pl.Series(np.cumprod(1 + rng.normal(0.001, 0.01, 100)) * 100),
            ... })
            >>> pos = pl.DataFrame({"date": dates, "A": pl.Series(np.ones(100) * 1000.0)})
            >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5)
            >>> impact = pf.trading_cost_impact(max_bps=5)
            >>> list(impact["cost_bps"])
            [0, 1, 2, 3, 4, 5]
        """
        if not isinstance(max_bps, int) or max_bps < 1:
            raise ValueError
        cost_levels = list(range(0, max_bps + 1))
        sharpe_values: list[float] = []
        for bps in cost_levels:
            adj = self.cost_adjusted_returns(float(bps))
            cols = ["date", "returns"] if "date" in adj.columns else ["returns"]
            sharpe_val = Stats(data=adj.select(cols)).sharpe().get("returns", float("nan"))
            sharpe_values.append(float(sharpe_val) if sharpe_val is not None else float("nan"))
        return pl.DataFrame({"cost_bps": pl.Series(cost_levels, dtype=pl.Int64), "sharpe": pl.Series(sharpe_values)})

    # ── Utility ────────────────────────────────────────────────────────────────

    def correlation(self, frame: pl.DataFrame, name: str = "portfolio") -> pl.DataFrame:
        """Compute a correlation matrix of asset returns plus the portfolio.

        Computes percentage changes for all numeric columns in ``frame``,
        appends the portfolio profit series under the provided ``name``, and
        returns the Pearson correlation matrix across all numeric columns.

        Args:
            frame: A Polars DataFrame containing at least the asset price
                columns (and a date column which will be ignored if
                non-numeric).
            name: The column name to use when adding the portfolio profit
                series to the input frame.

        Returns:
            A square Polars DataFrame where each cell is the correlation
            between a pair of series (values in [-1, 1]).
        """
        p = frame.with_columns(cs.by_dtype(pl.Float32, pl.Float64).pct_change())
        p = p.with_columns(pl.Series(name, self.profit["profit"]))
        corr_matrix = p.select(cs.numeric()).fill_null(0.0).corr()
        return corr_matrix
