"""Shared scoring primitives.

Strategies express each signal as a 0..1 "sub-score" and combine them with
weights. These helpers turn raw metrics into 0..1 signals in a readable way
so the strategy files stay declarative.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Optional


@dataclass
class Signal:
    name: str
    value: Optional[float]      # the raw metric (for display)
    score: Optional[float]      # normalised 0..1 contribution, or None = exclude
    weight: float
    note: str = ""


@dataclass
class StrategyResult:
    strategy: str
    score: float                 # 0..100 overall
    signals: list[Signal]
    verdict: str

    def as_row(self) -> dict:
        row = {"score": round(self.score, 1)}
        for s in self.signals:
            row[s.name] = s.value
        return row


def higher_better(value: Optional[float], lo: float, hi: float) -> Optional[float]:
    """1.0 when value >= hi, 0.0 when <= lo, linear between.

    None -> None so the signal is *excluded* from the weighted average rather
    than scored a rewarding 0.5. (Damodaran/McKinsey: a missing or negative
    metric should not silently count as neutral — e.g. a loss-maker's P/E.)
    """
    if value is None:
        return None
    if hi == lo:
        return 1.0 if value >= hi else 0.0
    return _clamp((value - lo) / (hi - lo))


def lower_better(value: Optional[float], lo: float, hi: float) -> Optional[float]:
    """1.0 when value <= lo, 0.0 when >= hi. None -> None (excluded)."""
    if value is None:
        return None
    if hi == lo:
        return 1.0 if value <= lo else 0.0
    return _clamp(1.0 - (value - lo) / (hi - lo))


def hump_better(value: Optional[float], rise_from: float, peak: float,
                fade_to: float) -> Optional[float]:
    """Inverted-U: rewards a moderate value, penalises extremes.

    Rises 0->1 as value goes rise_from->peak, then falls 1->0 as it goes
    peak->fade_to. Used for Shiller-style narrative virality, where a story
    *fuels* a move but an *extreme* reading marks the blow-off top.
    """
    if value is None:
        return None
    if value <= peak:
        if peak == rise_from:
            return 1.0
        return _clamp((value - rise_from) / (peak - rise_from))
    if fade_to == peak:
        return 1.0
    return _clamp(1.0 - (value - peak) / (fade_to - peak))


def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
    return max(lo, min(hi, x))


def combine(signals: list[Signal]) -> float:
    """Weighted average of sub-scores, rescaled to 0..100.

    Signals with score None are skipped and their weight removed, so absent
    data neither rewards nor penalises — the score reflects only what we know.
    """
    active = [s for s in signals if s.score is not None]
    total_w = sum(s.weight for s in active)
    if total_w == 0:
        return 0.0
    weighted = sum(s.score * s.weight for s in active)
    return 100.0 * weighted / total_w


def verdict_from(score: float) -> str:
    if score >= 75:
        return "Strong fit"
    if score >= 60:
        return "Good fit"
    if score >= 45:
        return "Mixed"
    return "Weak fit"
