"""Pure cross-sectional factor math: winsorized z-scores, value/quality/momentum families, composite,
and the three portfolio constructions (long / long-short / tilt) + realized book return. No I/O."""
from __future__ import annotations

import math
from statistics import fmean, median


def robust_z(values: list[float | None], k: float = 3.0) -> list[float]:
    """Cross-sectional robust z-score with winsorization at ±k. Missing/non-finite -> neutral 0.0.

    Uses a median/MAD location-scale (Gaussian-consistent MAD scaled by 1.4826) rather than
    mean/stdev so a single extreme outlier cannot inflate the scale enough to hide itself below
    the ±k clamp (the classic non-robust degeneracy). This makes "winsorized at ±k" hold for the
    outlier instead of leaving its raw z just under k.
    """
    present = [v for v in values if v is not None and math.isfinite(v)]
    if len(present) < 2:
        return [0.0 for _ in values]
    med = median(present)
    mad = median(sorted(abs(v - med) for v in present))
    scale = 1.4826 * mad
    if scale == 0.0:
        return [0.0 for _ in values]
    out: list[float] = []
    for v in values:
        if v is None or not math.isfinite(v):
            out.append(0.0)
        else:
            out.append(max(-k, min(k, (v - med) / scale)))
    return out


def _mean_z(cols: list[list[float]]) -> list[float]:
    n = len(cols[0])
    return [fmean([col[i] for col in cols]) for i in range(n)]


def value_score(pe: list[float | None], pb: list[float | None]) -> list[float]:
    pe_clean = [None if (p is None or p <= 0) else -p for p in pe]
    pb_clean = [None if (p is None or p <= 0) else -p for p in pb]
    return _mean_z([robust_z(pe_clean), robust_z(pb_clean)])


def quality_score(piotroski: list[float | None], roe: list[float | None],
                  debt_equity: list[float | None], gross_margin: list[float | None]) -> list[float]:
    neg_de = [None if d is None else -d for d in debt_equity]
    return _mean_z([robust_z(piotroski), robust_z(roe), robust_z(neg_de), robust_z(gross_margin)])


def momentum_score(mom_12_1: list[float | None]) -> list[float]:
    return robust_z(mom_12_1)


def composite(value_z: list[float], quality_z: list[float], momentum_z: list[float]) -> list[float]:
    return _mean_z([value_z, quality_z, momentum_z])


def _quintile_cut(scores: list[float], quantile: float) -> tuple[float, float]:
    s = sorted(scores)
    n = len(s)
    lo = s[max(0, int(quantile * n) - 1)]
    hi = s[min(n - 1, n - int(quantile * n))]
    return lo, hi


def construction_weights(scores: list[float], construction: str, quantile: float = 0.2) -> list[float]:
    """Map composite scores -> portfolio weights for one of: 'long', 'ls', 'tilt'."""
    n = len(scores)
    if n == 0:
        return []
    if construction == "tilt":
        med = sorted(scores)[n // 2]
        pos = [max(0.0, s - med) for s in scores]
        tot = sum(pos)
        return [p / tot for p in pos] if tot > 0 else [1.0 / n] * n
    lo, hi = _quintile_cut(scores, quantile)
    top = [i for i, s in enumerate(scores) if s >= hi]
    bot = [i for i, s in enumerate(scores) if s <= lo]
    w = [0.0] * n
    if top:
        for i in top:
            w[i] += 1.0 / len(top)
    if construction == "ls" and bot:
        for i in bot:
            w[i] += -1.0 / len(bot)
    return w


def book_return(prev_weights: dict[str, float], prev_prices: dict[str, float],
                cur_prices: dict[str, float], borrow_daily: float = 0.0) -> float:
    """Realized one-period book return of the held weights, minus per-day borrow cost on short notional."""
    r = 0.0
    short_notional = 0.0
    for sym, wt in prev_weights.items():
        p0, p1 = prev_prices.get(sym), cur_prices.get(sym)
        if p0 and p1 and p0 > 0:
            r += wt * (p1 / p0 - 1.0)
        if wt < 0:
            short_notional += -wt
    return r - borrow_daily * short_notional
