"""
Forward estimate selection (final master pass, §21).

## The defect this closes

`ProviderAdapter.get_estimates()` is implemented by FMP and by the demo adapter, `ProviderEstimateRow`
exists, and the `estimates` table exists with a `(security_id, period_end, metric, as_of_date)`
unique constraint. **Nothing ever called `get_estimates()`, nothing ever wrote an `Estimate` row,
and `build_snapshot_from_db()` never passed `forward_eps_estimate`** — so it was `None` on every
real run.

Consequence: `forward_pe` and `eps_growth_forward` were **permanently null**, two of the thirty
metrics dead. `forward_pe` is a member of the Valuation pillar, so every Valuation score in the
platform was computed from six of its seven metrics without anything saying so.

This is the fourth defect of the same shape found across these passes — an engine built, tested,
and never called — after Confidence/Data Quality (A1), peer groups (B2) and Competitive Advantage
(§8).

## What §21 demands, and what this module enforces

> Forward metrics НЕ трябва да използват стари или неизвестни estimates.
> Разграничавай: HISTORICAL ACTUAL / CURRENT ESTIMATE / FORWARD CONSENSUS / STOCKLAB ASSUMPTION.
> Никога не представяй StockLab assumption като analyst consensus.
> Ако няма надежден forward estimate: INSUFFICIENT DATA. Не измисляй forward EPS или forward P/E.

Three rules follow, and all three are enforced here rather than left to callers:

1. **Point-in-time correctness.** An estimate may only be used if it was *published* on or before
   the calculation date (`as_of_date <= as_of`). Using an estimate dated after the decision date is
   look-ahead bias, and it is the single easiest way for a backtest to look brilliant.
2. **Forward means forward.** The estimate's `period_end` must be *after* the calculation date.
   An "estimate" for a period that has already ended is not a forecast — it is a stale consensus
   that the actual result has already superseded, and using it as forward EPS produces a P/E
   against a number nobody expects any more.
3. **Staleness is a refusal, not a warning.** An estimate older than `MAX_ESTIMATE_AGE_DAYS` is
   rejected outright. "Old or unknown estimates" is exactly what §21 forbids, and a forward P/E
   built on an eighteen-month-old consensus is worse than no forward P/E.

When no estimate survives those rules the answer is `None` with a machine-readable reason — never
a fabricated number, and never a StockLab assumption dressed as consensus. `EstimateOrigin` exists
so that distinction is representable in data rather than in prose.

Pure and dependency-free (stdlib only), so it is genuinely testable here.
"""
from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from typing import Iterable, Optional

#: §21's four categories. `PROVIDER_CONSENSUS` is the only one that may ever be labelled
#: "analyst consensus" in the UI. `STOCKLAB_ASSUMPTION` exists so that a platform-generated
#: figure can be carried through the system without being mistakable for one.
ORIGIN_HISTORICAL_ACTUAL = "HISTORICAL_ACTUAL"
ORIGIN_PROVIDER_CONSENSUS = "PROVIDER_CONSENSUS"
ORIGIN_STOCKLAB_ASSUMPTION = "STOCKLAB_ASSUMPTION"

#: An estimate published more than this long before the calculation date is refused. One year is
#: deliberately generous — a full annual forecasting cycle — because the point is to exclude
#: genuinely abandoned consensus, not to demand freshness the providers cannot supply. Stated as a
#: judgment call, not derived from data.
MAX_ESTIMATE_AGE_DAYS = 365


@dataclass(frozen=True)
class EstimateRecord:
    """One consensus estimate, with everything §21 requires be kept alongside the number."""

    metric: str                      # "eps" | "revenue" | ...
    period_end: date                 # the fiscal period being forecast
    consensus_value: Optional[float]
    as_of_date: date                 # when this consensus was published
    num_analysts: Optional[int] = None
    source: Optional[str] = None     # provider name
    origin: str = ORIGIN_PROVIDER_CONSENSUS


@dataclass(frozen=True)
class EstimateSelection:
    """The chosen estimate, or None plus the reason no estimate qualified."""

    record: Optional[EstimateRecord]
    reason: Optional[str] = None
    candidates_considered: int = 0
    rejected: tuple[str, ...] = ()

    @property
    def value(self) -> Optional[float]:
        return self.record.consensus_value if self.record else None

    @property
    def is_consensus(self) -> bool:
        """True only for a real provider consensus. A StockLab assumption must never render as
        'analyst consensus', and this is the property a UI should branch on."""
        return self.record is not None and self.record.origin == ORIGIN_PROVIDER_CONSENSUS

    def as_dict(self) -> dict:
        if self.record is None:
            return {
                "value": None, "reason": self.reason,
                "candidates_considered": self.candidates_considered,
                "rejected": list(self.rejected),
            }
        return {
            "value": self.record.consensus_value,
            "metric": self.record.metric,
            "period_end": self.record.period_end.isoformat(),
            "as_of_date": self.record.as_of_date.isoformat(),
            "num_analysts": self.record.num_analysts,
            "source": self.record.source,
            "origin": self.record.origin,
            "is_consensus": self.is_consensus,
            "candidates_considered": self.candidates_considered,
            "rejected": list(self.rejected),
        }


def select_forward_estimate(
    records: Iterable[EstimateRecord],
    as_of: date,
    metric: str = "eps",
    max_age_days: int = MAX_ESTIMATE_AGE_DAYS,
) -> EstimateSelection:
    """Pick the estimate a point-in-time observer at `as_of` could legitimately have used.

    Selection order among the survivors: the **nearest future period** (the next fiscal year is
    what a forward P/E conventionally means), and within a period the **most recently published**
    consensus. Ties on both go to the estimate with more analysts behind it.
    """
    candidates = [r for r in records if r.metric == metric]
    rejected: list[str] = []
    eligible: list[EstimateRecord] = []

    for r in candidates:
        if r.consensus_value is None:
            rejected.append(f"{r.period_end}: no consensus value")
            continue
        if r.as_of_date > as_of:
            # Look-ahead: this consensus did not exist yet on the calculation date.
            rejected.append(f"{r.period_end}: published {r.as_of_date}, after as_of {as_of}")
            continue
        if r.period_end <= as_of:
            # Not forward-looking: the period has already ended.
            rejected.append(f"{r.period_end}: period already ended on or before {as_of}")
            continue
        age = (as_of - r.as_of_date).days
        if age > max_age_days:
            rejected.append(f"{r.period_end}: consensus is {age} days old (limit {max_age_days})")
            continue
        eligible.append(r)

    if not eligible:
        return EstimateSelection(
            None,
            reason=("no forward estimate is usable — every candidate was missing, published after "
                    "the calculation date, already past, or stale"
                    if candidates else f"no {metric} estimates available at all"),
            candidates_considered=len(candidates),
            rejected=tuple(rejected),
        )

    eligible.sort(key=lambda r: (r.period_end, -r.as_of_date.toordinal(), -(r.num_analysts or 0)))
    return EstimateSelection(
        eligible[0], reason=None, candidates_considered=len(candidates), rejected=tuple(rejected),
    )
