"""Small numeric helpers shared by every metric formula. Stdlib + numpy only."""
from __future__ import annotations

from typing import Optional, Sequence

import numpy as np


def safe_div(numerator: Optional[float], denominator: Optional[float]) -> Optional[float]:
    if numerator is None or denominator is None:
        return None
    if denominator == 0:
        return None
    return numerator / denominator


def yoy(current: Optional[float], prior: Optional[float]) -> Optional[float]:
    """Simple year-over-year growth. None if either value missing, or prior <= 0 (sign-change
    growth rates are not meaningful — see FINANCIAL_FORMULAS.md)."""
    if current is None or prior is None or prior <= 0:
        return None
    return current / prior - 1


def cagr(end: Optional[float], start: Optional[float], years: int) -> Optional[float]:
    """Compound annual growth rate. None if either endpoint is missing/non-positive or years<=0 —
    a CAGR through a sign change is not meaningful."""
    if end is None or start is None or years <= 0:
        return None
    if end <= 0 or start <= 0:
        return None
    return (end / start) ** (1 / years) - 1


def series_at_lag(values: Sequence[Optional[float]], lag: int) -> Optional[float]:
    """values[0] = most recent. Returns values[lag] if present, else None."""
    if lag < 0 or lag >= len(values):
        return None
    return values[lag]


def trend_slope(values: Sequence[Optional[float]]) -> Optional[float]:
    """
    Linear-regression slope over the given series (most-recent-last order expected by caller),
    normalized by the mean absolute value so it's comparable across companies of different scale.
    Returns None if fewer than 3 non-null points are available.
    """
    pts = [(i, v) for i, v in enumerate(values) if v is not None]
    if len(pts) < 3:
        return None
    xs = np.array([p[0] for p in pts], dtype=float)
    ys = np.array([p[1] for p in pts], dtype=float)
    if np.all(ys == ys[0]):
        return 0.0
    slope, _intercept = np.polyfit(xs, ys, 1)
    scale = np.mean(np.abs(ys)) or 1.0
    return float(slope / scale)


def clamp(value: Optional[float], lo: float, hi: float) -> Optional[float]:
    if value is None:
        return None
    return max(lo, min(hi, value))


def in_range(value: Optional[float], lo: float, hi: float) -> bool:
    return value is not None and lo <= value <= hi


def stdev(values: Sequence[Optional[float]]) -> Optional[float]:
    clean = [v for v in values if v is not None]
    if len(clean) < 2:
        return None
    return float(np.std(clean, ddof=1))
