"""
ferro_ta.signals — Signal composition and screening.

Provides helpers to combine multiple indicator outputs into a composite score
and to screen/rank symbols by that score.

Functions
---------
compose(signals, weights=None, method='weighted')
    Combine a DataFrame (or 2-D array) of signals into one composite score
    per bar.  Methods: ``'weighted'`` (weighted sum), ``'rank'`` (rank-based),
    ``'mean'`` (equal-weight mean).

screen(scores, top_n=None, bottom_n=None, above=None, below=None)
    Filter/rank a dict or Series of per-symbol scores.

rank_signals(x)
    Compute the fractional rank of each element in *x* (wrapper around Rust).

Rust backend
------------
    ferro_ta._ferro_ta.compose_weighted
    ferro_ta._ferro_ta.rank_series
    ferro_ta._ferro_ta.top_n_indices
    ferro_ta._ferro_ta.bottom_n_indices
"""

from __future__ import annotations

from typing import Any, Optional, Union

import numpy as np
from numpy.typing import ArrayLike, NDArray

from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n
from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank
from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted
from ferro_ta._ferro_ta import rank_series as _rust_rank_series
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n
from ferro_ta._utils import _to_f64

__all__ = [
    "compose",
    "screen",
    "rank_signals",
]


# ---------------------------------------------------------------------------
# rank_signals
# ---------------------------------------------------------------------------


def rank_signals(x: ArrayLike) -> NDArray[np.float64]:
    """Compute the fractional rank of each element (1-based, ascending).

    Ties receive the average of their rank positions.

    Parameters
    ----------
    x : array-like  — 1-D

    Returns
    -------
    numpy.ndarray of ranks in [1, n]

    Examples
    --------
    >>> import numpy as np
    >>> from ferro_ta.analysis.signals import rank_signals
    >>> rank_signals(np.array([3.0, 1.0, 2.0]))
    array([3., 1., 2.])
    """
    return _rust_rank_series(_to_f64(x))


# ---------------------------------------------------------------------------
# compose
# ---------------------------------------------------------------------------


def compose(
    signals: Any,
    weights: Optional[ArrayLike] = None,
    method: str = "weighted",
) -> NDArray[np.float64]:
    """Combine multiple signal columns into one composite score per bar.

    Parameters
    ----------
    signals : pandas.DataFrame or 2-D array-like, shape (n_bars, n_signals)
        Each column is one indicator/signal.
    weights : array-like of length n_signals, optional
        Weights for each signal column.  Required for ``method='weighted'``.
        If ``None`` and method is ``'weighted'``, equal weights are used.
    method : str
        Composition method:
        - ``'weighted'`` (default) — weighted sum (Rust fast path)
        - ``'mean'``  — equal-weight mean (equivalent to weighted with 1/n)
        - ``'rank'``  — sum of per-signal ranks (rank-based scoring)

    Returns
    -------
    numpy.ndarray of length n_bars

    Examples
    --------
    >>> import numpy as np
    >>> from ferro_ta.analysis.signals import compose
    >>> rng = np.random.default_rng(0)
    >>> sigs = rng.standard_normal((50, 3))
    >>> score = compose(sigs, weights=[0.5, 0.3, 0.2])
    >>> score.shape
    (50,)
    """
    try:
        import pandas as pd

        if isinstance(signals, pd.DataFrame):
            arr = signals.values.astype(np.float64, copy=False)
        else:
            arr = np.asarray(signals, dtype=np.float64)
    except ImportError:
        arr = np.asarray(signals, dtype=np.float64)

    if arr.ndim == 1:
        arr = arr.reshape(-1, 1)
    n_bars, n_sigs = arr.shape
    arr = np.ascontiguousarray(arr)

    if method == "mean":
        w = np.full(n_sigs, 1.0 / n_sigs)
        return _rust_compose_weighted(arr, w)
    elif method == "rank":
        return _rust_compose_rank(arr)
    else:
        # weighted (default)
        if weights is None:
            w = np.full(n_sigs, 1.0 / n_sigs)
        else:
            w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64))
        return _rust_compose_weighted(arr, w)


# ---------------------------------------------------------------------------
# screen
# ---------------------------------------------------------------------------


def screen(
    scores: Union[dict[str, float], Any],
    top_n: Optional[int] = None,
    bottom_n: Optional[int] = None,
    above: Optional[float] = None,
    below: Optional[float] = None,
) -> Any:
    """Filter and rank symbols by composite score.

    Parameters
    ----------
    scores : dict {symbol: score} or pandas.Series or array-like
        Per-symbol scores.
    top_n : int, optional
        Return the top-N symbols by score.
    bottom_n : int, optional
        Return the bottom-N symbols by score.
    above : float, optional
        Return all symbols with score > *above*.
    below : float, optional
        Return all symbols with score < *below*.

    Returns
    -------
    dict {symbol: score} sorted by score (descending for top_n, ascending for
    bottom_n), or a pandas.DataFrame if pandas is available and input is a
    Series/DataFrame.

    Examples
    --------
    >>> from ferro_ta.analysis.signals import screen
    >>> scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3}
    >>> result = screen(scores, top_n=2)
    >>> list(result.keys())
    ['MSFT', 'AAPL']
    """
    # Normalise to dict
    try:
        import pandas as pd

        if isinstance(scores, pd.Series):
            symbols = scores.index.tolist()  # type: ignore[union-attr]
            values = scores.values.astype(np.float64)  # type: ignore[union-attr]
        elif isinstance(scores, dict):
            symbols = list(scores.keys())
            values = np.array(list(scores.values()), dtype=np.float64)
        else:
            symbols = list(range(len(scores)))
            values = np.array(list(scores), dtype=np.float64)
    except ImportError:
        if isinstance(scores, dict):
            symbols = list(scores.keys())
            values = np.array(list(scores.values()), dtype=np.float64)
        else:
            symbols = list(range(len(scores)))
            values = np.array(list(scores), dtype=np.float64)

    if top_n is not None:
        idxs = _rust_top_n(values, int(top_n))
        # Sort by score descending
        idxs = sorted(idxs, key=lambda i: -values[i])
        return {symbols[i]: float(values[i]) for i in idxs}
    if bottom_n is not None:
        idxs = _rust_bottom_n(values, int(bottom_n))
        idxs = sorted(idxs, key=lambda i: values[i])
        return {symbols[i]: float(values[i]) for i in idxs}
    if above is not None:
        return {s: float(v) for s, v in zip(symbols, values) if v > above}
    if below is not None:
        return {s: float(v) for s, v in zip(symbols, values) if v < below}
    # Default: return all sorted descending
    order = sorted(range(len(values)), key=lambda i: -values[i])
    return {symbols[i]: float(values[i]) for i in order}
