"""
Emerging company classification and Emerging Score (final master pass, §20, §52).

## What §20 asks for, and the trap in it

> Emerging company не трябва да бъде наказвана автоматично само заради липса на дълга история.
> Но confidence трябва да бъде по-нисък при недостатъчна история.

Those two sentences pull in opposite directions and both are right. A company with three years of
history is not *worse* than one with ten — it is *younger*, and the platform's scoring machinery
systematically punishes youth: 5- and 10-year CAGRs are `None`, persistence proxies need four
points, `historical_depth` drags Confidence down. A newly listed compounder therefore looks like a
low-quality company, and the one place it should show up — a discovery feed — is exactly where it
never appears.

The resolution is to separate the two facts instead of blending them:

- **The Emerging Score judges the company on what IS measurable**, over the history it actually
  has. Missing long-horizon inputs are *excluded*, never scored as zero.
- **Confidence separately records how thin that evidence is.** A high Emerging Score on two years
  of data is a real signal *and* a low-confidence one, and both halves are reported.

Blending them produces the current behaviour: young companies score badly for being young.

## Classification

`ESTABLISHED` / `EMERGING` / `INSUFFICIENT_HISTORY` — a statement about the *data*, not a judgment
about the business. `INSUFFICIENT_HISTORY` means the platform cannot say anything useful yet, and
is deliberately distinct from a low score.

## Emerging Score

Six signals, each in 0–100, averaged over **only** those that could be computed:

| Signal | Rationale |
|---|---|
| revenue acceleration | growth *rising*, not just high — the inflection §20 names |
| margin expansion | operating margin trending up |
| FCF inflection | crossing from cash-burning to cash-generating |
| ROIC improvement | returns on capital rising |
| balance-sheet room | low leverage: a young company that must refinance is fragile |
| valuation sanity | not already priced for perfection |

No signal is defaulted. Fewer than `MIN_SIGNALS_FOR_EMERGING_SCORE` computable signals returns
`None`, not a number built from one observation.

**What this deliberately is not**: a prediction, a target price, or anything mixed into the Overall
Score. §20 requires Emerging Opportunities to be a separate surface from Top Opportunities, and
§38 requires the same of the Multibagger signal. This module never touches either.

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

from dataclasses import dataclass, field
from datetime import date
from typing import Optional, Sequence

STATUS_ESTABLISHED = "ESTABLISHED"
STATUS_EMERGING = "EMERGING"
STATUS_INSUFFICIENT_HISTORY = "INSUFFICIENT_HISTORY"

#: A company with at least this many annual periods is treated as ESTABLISHED: long enough for a
#: 5-year CAGR and for the persistence proxies to have real inputs.
ESTABLISHED_MIN_YEARS = 6
#: Below this, no classification is attempted at all. Two annual periods give exactly one
#: year-over-year change, which cannot distinguish a trend from a single event.
MIN_YEARS_FOR_ANY_CLASSIFICATION = 3
#: Fewer computable signals than this and the Emerging Score is None rather than a guess.
MIN_SIGNALS_FOR_EMERGING_SCORE = 3


@dataclass(frozen=True)
class EmergingSignal:
    key: str
    score: Optional[float]          # 0-100, or None when the inputs were not available
    reason: str                     # always populated, including when score is None


@dataclass(frozen=True)
class EmergingAssessment:
    status: str
    years_of_history: int
    emerging_score: Optional[float]
    signals: list[EmergingSignal] = field(default_factory=list)
    discovery_reasons: list[str] = field(default_factory=list)
    discovered_on: Optional[date] = None

    @property
    def signals_used(self) -> list[str]:
        return [s.key for s in self.signals if s.score is not None]

    @property
    def signals_unavailable(self) -> list[str]:
        return [s.key for s in self.signals if s.score is None]

    def as_dict(self) -> dict:
        return {
            "status": self.status,
            "years_of_history": self.years_of_history,
            "emerging_score": self.emerging_score,
            "signals": [{"key": s.key, "score": s.score, "reason": s.reason} for s in self.signals],
            "signals_used": self.signals_used,
            "signals_unavailable": self.signals_unavailable,
            "discovery_reasons": self.discovery_reasons,
            "discovered_on": self.discovered_on.isoformat() if self.discovered_on else None,
        }


def classify_history_depth(years_of_history: int) -> str:
    if years_of_history < MIN_YEARS_FOR_ANY_CLASSIFICATION:
        return STATUS_INSUFFICIENT_HISTORY
    if years_of_history >= ESTABLISHED_MIN_YEARS:
        return STATUS_ESTABLISHED
    return STATUS_EMERGING


def _clamp(value: float) -> float:
    return max(0.0, min(100.0, value))


def _scale(value: Optional[float], zero_at: float, hundred_at: float) -> Optional[float]:
    """Linear 0-100 scale between two reference points. `None` in, `None` out — never a default."""
    if value is None:
        return None
    if hundred_at == zero_at:
        return None
    return _clamp(100.0 * (value - zero_at) / (hundred_at - zero_at))


def _latest_two(series: Sequence[Optional[float]]) -> tuple[Optional[float], Optional[float]]:
    """(most recent, previous) from a most-recent-first series, skipping nothing."""
    if len(series) < 2:
        return (series[0] if series else None), None
    return series[0], series[1]


def assess_emerging(
    *,
    years_of_history: int,
    revenue_growth_series: Sequence[Optional[float]] = (),   # most-recent-first, decimals
    operating_margin_series: Sequence[Optional[float]] = (),
    fcf_series: Sequence[Optional[float]] = (),
    roic_series: Sequence[Optional[float]] = (),
    net_debt_to_ebitda: Optional[float] = None,
    pe: Optional[float] = None,
    as_of: Optional[date] = None,
) -> EmergingAssessment:
    """Classify history depth and score the six emerging signals.

    Every argument is a series the caller already has from the metrics engine; nothing is fetched
    and nothing is assumed. A signal whose inputs are missing scores `None` with a stated reason
    rather than a neutral value, so a thin-history company is never quietly given a middling score
    for the things nobody could measure.
    """
    status = classify_history_depth(years_of_history)
    signals: list[EmergingSignal] = []
    reasons: list[str] = []

    # 1. Revenue acceleration — growth rising, not merely high.
    g_now, g_prev = _latest_two(revenue_growth_series)
    if g_now is None or g_prev is None:
        signals.append(EmergingSignal("revenue_acceleration", None,
                                      "needs two consecutive revenue-growth observations"))
    else:
        delta = g_now - g_prev
        signals.append(EmergingSignal(
            "revenue_acceleration", _scale(delta, zero_at=-0.05, hundred_at=0.15),
            f"growth {g_prev:.1%} -> {g_now:.1%} ({delta:+.1%})",
        ))
        if delta > 0.05:
            reasons.append(f"revenue growth accelerating ({g_prev:.1%} -> {g_now:.1%})")

    # 2. Margin expansion.
    m_now, m_prev = _latest_two(operating_margin_series)
    if m_now is None or m_prev is None:
        signals.append(EmergingSignal("margin_expansion", None,
                                      "needs two consecutive operating-margin observations"))
    else:
        delta = m_now - m_prev
        signals.append(EmergingSignal(
            "margin_expansion", _scale(delta, zero_at=-0.03, hundred_at=0.05),
            f"operating margin {m_prev:.1%} -> {m_now:.1%} ({delta:+.1%})",
        ))
        if delta > 0.02:
            reasons.append(f"operating margin expanding ({m_prev:.1%} -> {m_now:.1%})")

    # 3. FCF inflection — the crossing itself is the signal §20 names.
    f_now, f_prev = _latest_two(fcf_series)
    if f_now is None or f_prev is None:
        signals.append(EmergingSignal("fcf_inflection", None,
                                      "needs two consecutive free-cash-flow observations"))
    elif f_prev <= 0 < f_now:
        signals.append(EmergingSignal("fcf_inflection", 100.0,
                                      f"free cash flow turned positive ({f_prev:.0f} -> {f_now:.0f})"))
        reasons.append("free cash flow inflected positive")
    elif f_now > 0 and f_prev > 0:
        signals.append(EmergingSignal("fcf_inflection", 70.0,
                                      "free cash flow positive in both periods"))
    elif f_now > f_prev:
        signals.append(EmergingSignal("fcf_inflection", 40.0,
                                      f"free cash flow still negative but improving ({f_prev:.0f} -> {f_now:.0f})"))
    else:
        signals.append(EmergingSignal("fcf_inflection", 0.0,
                                      f"free cash flow negative and deteriorating ({f_prev:.0f} -> {f_now:.0f})"))

    # 4. ROIC improvement.
    r_now, r_prev = _latest_two(roic_series)
    if r_now is None or r_prev is None:
        signals.append(EmergingSignal("roic_improvement", None,
                                      "needs two consecutive ROIC observations"))
    else:
        delta = r_now - r_prev
        signals.append(EmergingSignal(
            "roic_improvement", _scale(delta, zero_at=-0.02, hundred_at=0.06),
            f"ROIC {r_prev:.1%} -> {r_now:.1%} ({delta:+.1%})",
        ))
        if delta > 0.02:
            reasons.append(f"ROIC improving ({r_prev:.1%} -> {r_now:.1%})")

    # 5. Balance-sheet room. A young company that must refinance to survive is fragile regardless
    #    of how fast it is growing.
    if net_debt_to_ebitda is None:
        signals.append(EmergingSignal("balance_sheet_room", None,
                                      "net debt / EBITDA unavailable or not meaningful"))
    else:
        signals.append(EmergingSignal(
            "balance_sheet_room", _scale(net_debt_to_ebitda, zero_at=4.0, hundred_at=0.0),
            f"net debt / EBITDA = {net_debt_to_ebitda:.2f}x",
        ))
        if net_debt_to_ebitda < 1.0:
            reasons.append(f"low leverage ({net_debt_to_ebitda:.2f}x net debt / EBITDA)")

    # 6. Valuation sanity — not already priced for perfection. A missing or N/M P/E (a loss-maker)
    #    scores None rather than 0: "we cannot value it on earnings" is not "it is expensive".
    if pe is None or pe <= 0:
        signals.append(EmergingSignal("valuation_sanity", None,
                                      "P/E unavailable or not meaningful (negative earnings)"))
    else:
        signals.append(EmergingSignal(
            "valuation_sanity", _scale(pe, zero_at=60.0, hundred_at=10.0), f"P/E = {pe:.1f}x",
        ))

    usable = [s.score for s in signals if s.score is not None]
    emerging_score = (
        round(sum(usable) / len(usable), 2) if len(usable) >= MIN_SIGNALS_FOR_EMERGING_SCORE else None
    )

    return EmergingAssessment(
        status=status,
        years_of_history=years_of_history,
        emerging_score=emerging_score,
        signals=signals,
        discovery_reasons=reasons,
        discovered_on=as_of,
    )
