"""Pure read-time derivation of fundamental metrics from stored snapshots.

`fundamental_snapshots` stores one immutable row per accession with **cumulative
YTD** duration facts and period-end balance-sheet instants (A3). This module
derives everything the UI/API shows — discrete quarters, Q4, TTM, YoY growth,
margins, leverage, dilution, and the quarter tape — at read time, per the plan's
schema decision. No I/O, no DB: it takes an issuer's snapshot rows (ORM rows or
any objects with the same attributes) and returns structured metrics.

Rules:
- **Amendment selection:** for each (fiscal_year, fiscal_period), the newest
  `accepted_at` wins **per field**, falling back to the newest row that actually
  reports one. A partial amendment (a 10-K/A adding Part III carries no financial
  facts) must not blank the period.
- **Discrete quarter** = YTD(Qn) − YTD(Qn−1); Q1 = YTD(Q1); **Q4 = YTD(FY) −
  YTD(Q3)**. Any missing period → the derived value is null, never partial.
- **TTM** = sum of the trailing four discrete quarters ending at a period.
- Units follow app convention: percentages are percentage points (21.0 = 21%),
  net-debt/EBITDA is a multiple, net debt is dollars.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date
from types import SimpleNamespace
from typing import Any, Iterable

_FP_TO_Q = {"Q1": 1, "Q2": 2, "Q3": 3, "FY": 4}
_Q_TO_FP = {1: "Q1", 2: "Q2", 3: "Q3", 4: "FY"}
_PREV_FP = {"Q2": "Q1", "Q3": "Q2", "FY": "Q3"}
TAPE_LEN = 4  # quarter-tape length
SPLIT_SUSPECT_SHARE_CHANGE_PCT = 25.0
SPLIT_SENSITIVE_CAVEAT = (
    "Not comparable: share count changed at least 25%; possible split or "
    "corporate action."
)

# Duration (flow) fields differenced from YTD into discrete quarters + summed to TTM.
_FLOW_FIELDS = (
    "revenue", "net_income", "operating_income", "diluted_eps", "cfo", "capex",
    "depreciation_amortization",
)
# Reported facts resolved independently across a period's accessions (see
# _merge_amendments); period identity/provenance is taken from the newest one.
_MERGED_FIELDS = (
    *_FLOW_FIELDS,
    "cash_and_st_investments", "total_debt", "shares_outstanding",
    "shares_outstanding_date", "weighted_avg_diluted_shares",
    # period_start is set alongside revenue by the parser, so it follows the same
    # fallback: a bare amendment reports neither and must not blank it.
    "period_start",
)
_CARRIED_FIELDS = (
    "fiscal_year", "fiscal_period", "period_end", "filed_date",
    "accepted_at", "form", "accession", "cik",
)


@dataclass
class MetricPoint:
    period_end: date
    value: float | None


@dataclass
class MetricSeries:
    value: float | None = None
    history: list[MetricPoint] = field(default_factory=list)  # oldest -> newest, <= TAPE_LEN
    period_end: date | None = None
    filed_date: date | None = None
    caveat: str | None = None


@dataclass
class DerivedFundamentals:
    metrics: dict[str, MetricSeries] = field(default_factory=dict)
    # request-time valuation inputs (ratios are computed in the API with price)
    ttm_diluted_eps: float | None = None
    # Set when ttm_diluted_eps was suppressed rather than simply unavailable.
    ttm_diluted_eps_caveat: str | None = None
    ttm_fcf: float | None = None
    shares_outstanding: float | None = None
    # True when shares_outstanding came from the weighted-average diluted count
    # because the point-in-time cover-page count was absent (always so for
    # multi-class issuers). Consumers must label anything derived from it as
    # estimated — it is a period average, not a point-in-time count.
    shares_outstanding_estimated: bool = False
    latest_period_end: date | None = None
    latest_filed_date: date | None = None


def _prev_q(fy: int, q: int) -> tuple[int, int]:
    return (fy, q - 1) if q > 1 else (fy - 1, 4)


def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
    selected = _select_latest_per_period(snapshots)
    result = DerivedFundamentals()
    if not selected:
        return result

    # Discrete quarter values per flow field: {field: {(fy, q): value}}.
    discrete = {f: _discrete_quarters(selected, f) for f in _FLOW_FIELDS}
    quarters = _ordered_quarters(selected)  # chronological (fy, q) with a row
    latest = quarters[-1]
    latest_row = selected[(latest[0], _Q_TO_FP[latest[1]])]

    result.latest_period_end = latest_row.period_end
    result.latest_filed_date = latest_row.filed_date
    result.shares_outstanding = getattr(latest_row, "shares_outstanding", None)
    if result.shares_outstanding is None:
        # Multi-class issuers (META, CMCSA, BRK-B, CHTR, FOXA, NWSA, LEN) report
        # the cover-page count per class, which is dimensional and so absent from
        # companyfacts — leaving market cap and FCF yield silently unavailable for
        # some of the largest names. The weighted-average diluted count is always
        # present and within ~0.6% of the true count where both exist, so fall
        # back to it and mark the result estimated rather than show nothing.
        result.shares_outstanding = getattr(latest_row, "weighted_avg_diluted_shares", None)
        result.shares_outstanding_estimated = result.shares_outstanding is not None
    result.ttm_diluted_eps = _ttm(discrete["diluted_eps"], *latest)
    ttm_cfo = _ttm(discrete["cfo"], *latest)
    ttm_capex = _ttm(discrete["capex"], *latest)
    result.ttm_fcf = None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex

    # tape = the CONSECUTIVE run of up to TAPE_LEN quarters ending at the latest,
    # stopping at a gap — so trend text never compares non-adjacent periods.
    tape = _consecutive_suffix(quarters, TAPE_LEN)
    result.metrics = {
        "revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape),
        "eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape),
        "operating_margin": _margin_series(discrete["operating_income"], discrete["revenue"], selected, tape),
        "fcf_margin": _fcf_margin_series(discrete, selected, tape),
        "net_debt": _instant_series(selected, tape, _net_debt),
        "net_debt_to_ebitda": _leverage_series(selected, discrete, tape),
        "share_count_change_yoy": _share_change_series(selected, tape),
    }
    # TTM EPS sums four quarters of *per-share* values, so a split inside that
    # window mixes pre- and post-split units — the same distortion the guard
    # already catches for the series, and the one that produced BKNG's P/E of
    # 1.10. Left unguarded it does not merely mislead: a nonsense-low P/E clamps
    # to a perfect 100 fundamental sub-score, so it must null out like the rest.
    if _guard_split_sensitive_metrics(result.metrics):
        result.ttm_diluted_eps = None
        result.ttm_diluted_eps_caveat = SPLIT_SENSITIVE_CAVEAT
    for series in result.metrics.values():
        series.period_end = latest_row.period_end
        series.filed_date = latest_row.filed_date
    return result


# -- period selection --------------------------------------------------------

def _select_latest_per_period(snapshots: Iterable[Any]) -> dict[tuple[int, str], Any]:
    grouped: dict[tuple[int, str], list[Any]] = {}
    for row in snapshots:
        fp = getattr(row, "fiscal_period", None)
        fy = getattr(row, "fiscal_year", None)
        if fp not in _FP_TO_Q or fy is None:
            continue
        grouped.setdefault((fy, fp), []).append(row)
    return {key: _merge_amendments(rows) for key, rows in grouped.items()}


def _merge_amendments(rows: list[Any]) -> Any:
    """Resolve one period from its accessions: newest wins, per field.

    Amendments are frequently partial — a 10-K/A filed only to add Part III
    reports no financial facts at all. Taking the newest accession wholesale
    would blank every field it omits and null the period downstream (and with
    it TTM and YoY, which need an unbroken quarter chain), so each field falls
    back to the newest accession that actually reports it.

    Only rows sharing the newest row's ``period_end`` are merged. A same-key row
    covering a *different* period is a mislabelled filing, not an amendment, and
    blending the two would silently mix fiscal years.
    """
    if len(rows) == 1:
        return rows[0]
    ordered = sorted(rows, key=_amendment_order, reverse=True)  # newest first
    newest = ordered[0]
    same_period = [
        row
        for row in ordered
        if getattr(row, "period_end", None) == getattr(newest, "period_end", None)
    ]
    if len(same_period) == 1:
        return newest
    merged = SimpleNamespace(**{name: getattr(newest, name, None) for name in _CARRIED_FIELDS})
    for name in _MERGED_FIELDS:
        merged_value = None
        for row in same_period:  # newest first
            value = getattr(row, name, None)
            if value is not None:
                merged_value = value
                break
        setattr(merged, name, merged_value)
    return merged


def _amendment_order(row: Any) -> tuple[bool, Any]:
    # (has-timestamp, timestamp) so a row without one sorts oldest instead of
    # raising when compared against a row that has one.
    accepted = _accepted(row)
    return (accepted is not None, accepted)


def _accepted(row: Any):
    return getattr(row, "accepted_at", None) or getattr(row, "filed_date", None)


def _ordered_quarters(selected: dict[tuple[int, str], Any]) -> list[tuple[int, int]]:
    return sorted((fy, _FP_TO_Q[fp]) for (fy, fp) in selected)


def _consecutive_suffix(quarters: list[tuple[int, int]], n: int) -> list[tuple[int, int]]:
    """The run of up to n quarters ending at the latest, walking back only through
    adjacent periods (stop at the first gap). Returned oldest -> newest."""
    if not quarters:
        return []
    present = set(quarters)
    run = [quarters[-1]]
    cur = quarters[-1]
    while len(run) < n:
        prev = _prev_q(*cur)
        if prev not in present:
            break
        run.append(prev)
        cur = prev
    run.reverse()
    return run


# -- discrete + TTM ----------------------------------------------------------

def _discrete_quarters(selected: dict[tuple[int, str], Any], field_name: str) -> dict[tuple[int, int], float]:
    out: dict[tuple[int, int], float] = {}
    for (fy, fp), row in selected.items():
        val = _discrete_value(selected, fy, fp, field_name)
        if val is not None:
            out[(fy, _FP_TO_Q[fp])] = val
    return out


def _discrete_value(selected, fy: int, fp: str, field_name: str) -> float | None:
    cur = getattr(selected[(fy, fp)], field_name, None)
    if cur is None:
        return None
    if fp == "Q1":
        return cur
    prev = selected.get((fy, _PREV_FP[fp]))
    prev_val = getattr(prev, field_name, None) if prev is not None else None
    if prev_val is None:
        return None
    return cur - prev_val


def _ttm(dq: dict[tuple[int, int], float], fy: int, q: int) -> float | None:
    keys = [(fy, q)]
    k = (fy, q)
    for _ in range(3):
        k = _prev_q(*k)
        keys.append(k)
    vals = [dq.get(kk) for kk in keys]
    if any(v is None for v in vals):
        return None
    return sum(vals)


def _pct_change(cur: float | None, prior: float | None) -> float | None:
    # A non-positive prior makes a YoY % meaningless (e.g. loss->profit), so null it.
    if cur is None or prior is None or prior <= 0:
        return None
    return (cur / prior - 1.0) * 100.0


# -- per-metric series (value at latest + tape history) ----------------------

def _period_end(selected, fy: int, q: int) -> date | None:
    row = selected.get((fy, _Q_TO_FP[q]))
    return row.period_end if row is not None else None


def _yoy_growth_series(dq, selected, tape) -> MetricSeries:
    pts = []
    for (fy, q) in tape:
        cur, prior = _ttm(dq, fy, q), _ttm(dq, fy - 1, q)
        pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
    return _series(pts)


def _margin_series(num_dq, den_dq, selected, tape) -> MetricSeries:
    pts = []
    for (fy, q) in tape:
        num, den = _ttm(num_dq, fy, q), _ttm(den_dq, fy, q)
        val = None if num is None or not den else num / den * 100.0
        pts.append(MetricPoint(_period_end(selected, fy, q), val))
    return _series(pts)


def _fcf_margin_series(discrete, selected, tape) -> MetricSeries:
    pts = []
    for (fy, q) in tape:
        cfo, capex, rev = _ttm(discrete["cfo"], fy, q), _ttm(discrete["capex"], fy, q), _ttm(discrete["revenue"], fy, q)
        val = None if cfo is None or capex is None or not rev else (cfo - capex) / rev * 100.0
        pts.append(MetricPoint(_period_end(selected, fy, q), val))
    return _series(pts)


def _instant_series(selected, tape, fn) -> MetricSeries:
    pts = [MetricPoint(_period_end(selected, fy, q), fn(selected.get((fy, _Q_TO_FP[q])))) for (fy, q) in tape]
    return _series(pts)


def _leverage_series(selected, discrete, tape) -> MetricSeries:
    pts = []
    for (fy, q) in tape:
        row = selected.get((fy, _Q_TO_FP[q]))
        nd = _net_debt(row)
        op, da = _ttm(discrete["operating_income"], fy, q), _ttm(discrete["depreciation_amortization"], fy, q)
        ebitda = None if op is None or da is None else op + da
        # Null when EBITDA <= 0: a negative denominator would flip polarity and a
        # "lower is better" read would rank a distressed issuer as favorable.
        val = None if nd is None or ebitda is None or ebitda <= 0 else nd / ebitda
        pts.append(MetricPoint(_period_end(selected, fy, q), val))
    return _series(pts)


def _share_change_series(selected, tape) -> MetricSeries:
    pts = []
    for (fy, q) in tape:
        cur = _shares(selected.get((fy, _Q_TO_FP[q])))
        prior = _shares(selected.get((fy - 1, _Q_TO_FP[q])))
        pts.append(MetricPoint(_period_end(selected, fy, q), _pct_change(cur, prior)))
    return _series(pts)


def _guard_split_sensitive_metrics(metrics: dict[str, MetricSeries]) -> bool:
    """Suppress historical comparisons likely distorted by a corporate action.

    Company Facts has no point-in-time split factors. A large YoY share-count
    move can therefore make both the point-in-time share comparison and
    per-share EPS growth non-comparable. Keep the raw facts in snapshots, but
    expose nulls plus an explicit caveat in the user-facing derived series.

    Returns True when the *latest* period is suspect, so callers can apply the
    same suppression to per-share scalars derived from that window.
    """
    shares = metrics.get("share_count_change_yoy")
    eps = metrics.get("eps_growth_yoy")
    if shares is None or eps is None:
        return False

    suspect_periods = {
        point.period_end
        for point in shares.history
        if point.value is not None
        and abs(point.value) >= SPLIT_SUSPECT_SHARE_CHANGE_PCT
    }
    if not suspect_periods:
        return False

    latest_suspect = False
    for series in (shares, eps):
        latest_guarded = bool(
            series.history and series.history[-1].period_end in suspect_periods
        )
        latest_suspect = latest_suspect or latest_guarded
        for point in series.history:
            if point.period_end in suspect_periods:
                point.value = None
        series.value = series.history[-1].value if series.history else None
        if latest_guarded:
            series.caveat = SPLIT_SENSITIVE_CAVEAT
    return latest_suspect


def _net_debt(row: Any) -> float | None:
    if row is None:
        return None
    cash = getattr(row, "cash_and_st_investments", None)
    debt = getattr(row, "total_debt", None)
    # Require BOTH components — treating a missing side as zero would produce a
    # partial, misleading value.
    if cash is None or debt is None:
        return None
    return debt - cash  # positive = net debt


def _shares(row: Any) -> float | None:
    return getattr(row, "shares_outstanding", None) if row is not None else None


def _series(points: list[MetricPoint]) -> MetricSeries:
    value = points[-1].value if points else None
    return MetricSeries(value=value, history=points)
