"""Scoring Engine service.

Computes dimension scores (technical, sr_quality, sentiment, fundamental,
momentum) each 0-100, composite score as weighted average of available
dimensions with re-normalized weights, staleness marking, explicit refresh
paths, and weight update triggers full recomputation.
"""

from __future__ import annotations

import json
import logging
from collections import defaultdict
from datetime import datetime, timezone

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import insert_for_session
from app.exceptions import NotFoundError, ValidationError
from app.models.score import CompositeScore, DimensionScore
from app.models.ticker import Ticker
from app.services import settings_store

logger = logging.getLogger(__name__)

DIMENSIONS = ["technical", "sr_quality", "sentiment", "fundamental", "momentum"]

DEFAULT_WEIGHTS: dict[str, float] = {
    "technical": 0.25,
    "sr_quality": 0.20,
    "sentiment": 0.10,
    "fundamental": 0.20,
    "momentum": 0.20,
}

SCORING_WEIGHTS_KEY = "scoring_weights"

# Sentiment enters the composite as a signed adjustment around this neutral point,
# not as an averaged-in level (see _sentiment_adjustment / compute_composite_score).
NEUTRAL_SENTIMENT = 50.0


def _sentiment_adjustment(sentiment_score: float | None, sentiment_weight: float) -> float:
    """Signed points sentiment contributes to the base composite.

    +MAX_ADJ at max-confidence bullish (score 100), 0 at neutral (50), -MAX_ADJ at
    max-confidence bearish (score 0), where MAX_ADJ = sentiment weight * 100. A
    50%-confidence call maps to score 50 → no effect (a coin flip carries no info),
    so going from no sentiment to bullish can only ever help.
    """
    if sentiment_score is None:
        return 0.0
    max_adj = sentiment_weight * 100.0
    return max_adj * (sentiment_score - NEUTRAL_SENTIMENT) / 50.0


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
    normalised = symbol.strip().upper()
    result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
    ticker = result.scalar_one_or_none()
    if ticker is None:
        raise NotFoundError(f"Ticker not found: {normalised}")
    return ticker


async def _get_weights(db: AsyncSession) -> dict[str, float]:
    """Load scoring weights from SystemSetting, falling back to defaults."""
    setting = await settings_store.get_setting(db, SCORING_WEIGHTS_KEY)
    if setting is not None:
        try:
            return json.loads(setting.value)
        except (json.JSONDecodeError, TypeError):
            logger.warning("Invalid scoring weights in DB, using defaults")
    return dict(DEFAULT_WEIGHTS)


async def _save_weights(db: AsyncSession, weights: dict[str, float]) -> None:
    """Persist scoring weights to SystemSetting."""
    await settings_store.upsert_setting(db, SCORING_WEIGHTS_KEY, json.dumps(weights))


# ---------------------------------------------------------------------------
# Dimension score computation
# ---------------------------------------------------------------------------

async def _compute_technical_score(
    db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
    """Compute technical dimension score from stored OHLCV (DB wrapper)."""
    from app.services.indicator_service import _extract_ohlcv
    from app.services.price_service import query_ohlcv

    records = await query_ohlcv(db, symbol)
    if not records:
        return None, None

    _, highs, lows, closes, volumes = _extract_ohlcv(records)
    return compute_technical_from_arrays(highs, lows, closes, volumes)


def compute_technical_from_arrays(
    highs: list[float],
    lows: list[float],
    closes: list[float],
    volumes: list[int],
) -> tuple[float | None, dict | None]:
    """Technical score from raw OHLCV arrays — ADX, EMA, RSI, EMA Cross, Volume
    Profile, Pivot Points. Pure (no DB) so the backtest can compute it as-of-date.

    Returns (score, breakdown).
    """
    from app.services.indicator_service import (
        compute_adx,
        compute_ema,
        compute_ema_cross,
        compute_pivot_points,
        compute_rsi,
        compute_volume_profile,
    )

    if not closes:
        return None, None

    formula = (
        "Weighted average: 0.30*ADX + 0.20*EMA + 0.20*RSI + 0.15*EMA_Cross "
        "+ 0.10*Volume_Profile + 0.05*Pivot_Points, re-normalized if any "
        "sub-score unavailable."
    )

    scores: list[tuple[float, float]] = []  # (weight, score)
    sub_scores: list[dict] = []
    unavailable: list[dict[str, str]] = []

    # ADX (weight 0.30) — needs 28+ bars
    try:
        adx_result = compute_adx(highs, lows, closes)
        scores.append((0.30, adx_result["score"]))
        sub_scores.append({
            "name": "ADX",
            "score": adx_result["score"],
            "weight": 0.30,
            "raw_value": adx_result["adx"],
            "description": "ADX value (0-100). Higher = stronger trend.",
        })
    except Exception as exc:
        unavailable.append({"name": "ADX", "reason": str(exc) or "Insufficient data for ADX"})

    # EMA (weight 0.20) — needs period+1 bars
    try:
        ema_result = compute_ema(closes)
        pct_diff = (
            round(
                (ema_result["latest_close"] - ema_result["ema"])
                / ema_result["ema"]
                * 100.0,
                4,
            )
            if ema_result["ema"] != 0
            else 0.0
        )
        scores.append((0.20, ema_result["score"]))
        sub_scores.append({
            "name": "EMA",
            "score": ema_result["score"],
            "weight": 0.20,
            "raw_value": pct_diff,
            "description": f"Price {pct_diff}% {'above' if pct_diff >= 0 else 'below'} EMA(20). Score: 50 + pct_diff * 10.",
        })
    except Exception as exc:
        unavailable.append({"name": "EMA", "reason": str(exc) or "Insufficient data for EMA"})

    # RSI (weight 0.20) — needs 15+ bars
    try:
        rsi_result = compute_rsi(closes)
        scores.append((0.20, rsi_result["score"]))
        sub_scores.append({
            "name": "RSI",
            "score": rsi_result["score"],
            "weight": 0.20,
            "raw_value": rsi_result["rsi"],
            "description": "RSI(14) value. Score equals RSI.",
        })
    except Exception as exc:
        unavailable.append({"name": "RSI", "reason": str(exc) or "Insufficient data for RSI"})

    # EMA Cross (weight 0.15) — needs 51+ bars. Directional trend signal.
    try:
        cross_result = compute_ema_cross(closes)
        cross_score = {"bullish": 80.0, "neutral": 50.0, "bearish": 20.0}[cross_result["signal"]]
        scores.append((0.15, cross_score))
        sub_scores.append({
            "name": "EMA_Cross",
            "score": cross_score,
            "weight": 0.15,
            "raw_value": cross_result["signal"],
            "description": "EMA(20) vs EMA(50): bullish=80, neutral=50, bearish=20.",
        })
    except Exception as exc:
        unavailable.append({"name": "EMA_Cross", "reason": str(exc) or "Insufficient data for EMA Cross"})

    # Volume Profile (weight 0.10) — needs 20+ bars. Price near POC = trading
    # at accepted value; far from POC = extended.
    try:
        vp_result = compute_volume_profile(highs, lows, closes, volumes)
        scores.append((0.10, vp_result["score"]))
        sub_scores.append({
            "name": "Volume_Profile",
            "score": vp_result["score"],
            "weight": 0.10,
            "raw_value": vp_result["poc"],
            "description": "Proximity of price to the Point of Control (volume-accepted value).",
        })
    except Exception as exc:
        unavailable.append({"name": "Volume_Profile", "reason": str(exc) or "Insufficient data for Volume Profile"})

    # Pivot Points (weight 0.05) — needs 5+ bars. Price near swing structure.
    try:
        pivot_result = compute_pivot_points(highs, lows, closes)
        scores.append((0.05, pivot_result["score"]))
        sub_scores.append({
            "name": "Pivot_Points",
            "score": pivot_result["score"],
            "weight": 0.05,
            "raw_value": pivot_result["pivot_count"],
            "description": "Share of swing pivots within 2% of price (structure confluence).",
        })
    except Exception as exc:
        unavailable.append({"name": "Pivot_Points", "reason": str(exc) or "Insufficient data for Pivot Points"})

    if not scores:
        breakdown: dict = {
            "sub_scores": [],
            "formula": formula,
            "unavailable": unavailable,
        }
        return None, breakdown

    total_weight = sum(w for w, _ in scores)
    if total_weight == 0:
        return None, None
    weighted = sum(w * s for w, s in scores) / total_weight
    final_score = max(0.0, min(100.0, weighted))

    breakdown = {
        "sub_scores": sub_scores,
        "formula": formula,
        "unavailable": unavailable,
    }

    return final_score, breakdown


async def _compute_sr_quality_score(
    db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
    """Compute S/R quality dimension score.

    Based on number of strong levels, proximity to current price, avg strength.
    Returns (score, breakdown) where breakdown follows the ScoreBreakdown
    TypedDict shape: {sub_scores, formula, unavailable}.
    """
    from app.services.price_service import query_ohlcv
    from app.services.sr_service import get_sr_levels

    formula = "Sum of sub-scores: Strong Count (max 40) + Proximity (max 30) + Avg Strength (max 30), clamped to [0, 100]."

    records = await query_ohlcv(db, symbol)
    if not records:
        return None, None

    current_price = float(records[-1].close)
    if current_price <= 0:
        return None, None

    try:
        levels = await get_sr_levels(db, symbol)
    except Exception:
        return None, None

    if not levels:
        return None, None

    sub_scores: list[dict] = []

    # Factor 1: Number of strong levels (strength >= 50) — max 40 pts
    strong_count = sum(1 for lv in levels if lv.strength >= 50)
    count_score = min(40.0, strong_count * 10.0)
    sub_scores.append({
        "name": "Strong Count",
        "score": count_score,
        "weight": 40.0,
        "raw_value": strong_count,
        "description": f"{strong_count} strong level(s) (strength >= 50). Score: min(40, count * 10).",
    })

    # Factor 2: Proximity of nearest level to current price — max 30 pts
    distances = [
        abs(lv.price_level - current_price) / current_price for lv in levels
    ]
    nearest_dist = min(distances) if distances else 1.0
    nearest_dist_pct = round(nearest_dist * 100.0, 4)
    # Closer = higher score. 0% distance = 30, 5%+ = 0
    proximity_score = max(0.0, min(30.0, 30.0 * (1.0 - nearest_dist / 0.05)))
    sub_scores.append({
        "name": "Proximity",
        "score": proximity_score,
        "weight": 30.0,
        "raw_value": nearest_dist_pct,
        "description": f"Nearest S/R level is {nearest_dist_pct}% from price. Score: 30 * (1 - dist/5%), clamped to [0, 30].",
    })

    # Factor 3: Average strength — max 30 pts
    avg_strength = sum(lv.strength for lv in levels) / len(levels)
    strength_score = min(30.0, avg_strength * 0.3)
    sub_scores.append({
        "name": "Avg Strength",
        "score": strength_score,
        "weight": 30.0,
        "raw_value": round(avg_strength, 4),
        "description": f"Average level strength: {round(avg_strength, 2)}. Score: min(30, avg * 0.3).",
    })

    total = count_score + proximity_score + strength_score
    final_score = max(0.0, min(100.0, total))

    breakdown: dict = {
        "sub_scores": sub_scores,
        "formula": formula,
        "unavailable": [],
    }

    return final_score, breakdown


async def _compute_sentiment_score(
    db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
    """Compute sentiment dimension score via sentiment service.

    Returns (score, breakdown) where breakdown follows the ScoreBreakdown
    TypedDict shape: {sub_scores, formula, unavailable}.
    """
    from app.services.sentiment_service import (
        compute_sentiment_dimension_score,
        get_sentiment_scores,
    )

    lookback_hours: float = 168  # 7 days — sentiment is collected sparsely to stay in free tier
    decay_rate: float = 0.1

    try:
        scores = await get_sentiment_scores(db, symbol, lookback_hours)
    except Exception:
        return None, None

    if not scores:
        breakdown: dict = {
            "sub_scores": [],
            "formula": (
                f"Time-decay weighted average over {lookback_hours}h window "
                f"with decay_rate={decay_rate}: "
                "sum(base_score * exp(-decay_rate * hours_since)) / sum(exp(-decay_rate * hours_since))"
            ),
            "unavailable": [
                {"name": "sentiment_records", "reason": "No sentiment records in lookback window"}
            ],
        }
        return None, breakdown

    try:
        score = await compute_sentiment_dimension_score(db, symbol, lookback_hours, decay_rate)
    except Exception:
        return None, None

    sub_scores: list[dict] = [
        {
            "name": "record_count",
            "score": score if score is not None else 0.0,
            "weight": 1.0,
            "raw_value": len(scores),
            "description": f"Number of sentiment records used in the lookback window ({lookback_hours}h).",
        },
        {
            "name": "decay_rate",
            "score": score if score is not None else 0.0,
            "weight": 1.0,
            "raw_value": decay_rate,
            "description": "Exponential decay rate applied to older records (higher = faster decay).",
        },
        {
            "name": "lookback_window",
            "score": score if score is not None else 0.0,
            "weight": 1.0,
            "raw_value": lookback_hours,
            "description": f"Lookback window in hours for sentiment records ({lookback_hours}h).",
        },
    ]

    formula = (
        f"Time-decay weighted average over {lookback_hours}h window "
        f"with decay_rate={decay_rate}: "
        "sum(base_score * exp(-decay_rate * hours_since)) / sum(exp(-decay_rate * hours_since))"
    )

    breakdown = {
        "sub_scores": sub_scores,
        "formula": formula,
        "unavailable": [],
    }

    return score, breakdown


async def _compute_fundamental_score(
    db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
    """Compute fundamental dimension score.

    Normalized composite of P/E (lower is better), revenue growth
    (higher is better), earnings surprise (higher is better).

    Returns (score, breakdown) where breakdown follows the ScoreBreakdown
    TypedDict shape: {sub_scores, formula, unavailable}.
    """
    from app.services.fundamental_service import get_fundamental

    fund = await get_fundamental(db, symbol)
    if fund is None:
        return None, None

    weight = 1.0 / 3.0
    scores: list[float] = []
    sub_scores: list[dict] = []
    unavailable: list[dict[str, str]] = []

    formula = (
        "Equal-weighted average of available sub-scores: "
        "(PE_Ratio + Revenue_Growth + Earnings_Surprise) / count. "
        "PE: 100 - (pe - 15) * (100/30), clamped [0,100]. "
        "Revenue Growth: 50 + growth% * 2.5, clamped [0,100]. "
        "Earnings Surprise: 50 + surprise% * 5.0, clamped [0,100]."
    )

    # P/E: lower is better. 0-15 = 100, 15-30 = 50-100, 30+ = 0-50
    if fund.pe_ratio is not None and fund.pe_ratio > 0:
        pe_score = max(0.0, min(100.0, 100.0 - (fund.pe_ratio - 15.0) * (100.0 / 30.0)))
        scores.append(pe_score)
        sub_scores.append({
            "name": "PE Ratio",
            "score": pe_score,
            "weight": weight,
            "raw_value": fund.pe_ratio,
            "description": "PE ratio (lower is better). Score: 100 - (pe - 15) * (100/30), clamped [0,100].",
        })
    else:
        unavailable.append({
            "name": "PE Ratio",
            "reason": "PE ratio not available or not positive",
        })

    # Revenue growth: higher is better. 0% = 50, 20%+ = 100, -20% = 0
    if fund.revenue_growth is not None:
        rg_score = max(0.0, min(100.0, 50.0 + fund.revenue_growth * 2.5))
        scores.append(rg_score)
        sub_scores.append({
            "name": "Revenue Growth",
            "score": rg_score,
            "weight": weight,
            "raw_value": fund.revenue_growth,
            "description": "Revenue growth %. Score: 50 + growth% * 2.5, clamped [0,100].",
        })
    else:
        unavailable.append({
            "name": "Revenue Growth",
            "reason": "Revenue growth data not available",
        })

    # Earnings surprise: higher is better. 0% = 50, 10%+ = 100, -10% = 0
    if fund.earnings_surprise is not None:
        es_score = max(0.0, min(100.0, 50.0 + fund.earnings_surprise * 5.0))
        scores.append(es_score)
        sub_scores.append({
            "name": "Earnings Surprise",
            "score": es_score,
            "weight": weight,
            "raw_value": fund.earnings_surprise,
            "description": "Earnings surprise %. Score: 50 + surprise% * 5.0, clamped [0,100].",
        })
    else:
        unavailable.append({
            "name": "Earnings Surprise",
            "reason": "Earnings surprise data not available",
        })

    # Require at least two real metrics — a single available metric (e.g. only
    # market cap is free on FMP) does not make a meaningful fundamental score.
    MIN_METRICS = 2
    if len(scores) < MIN_METRICS:
        unavailable.append({
            "name": "insufficient_metrics",
            "reason": f"Only {len(scores)} fundamental metric(s) available; need {MIN_METRICS}+ to score.",
        })

    breakdown: dict = {
        "sub_scores": sub_scores,
        "formula": formula,
        "unavailable": unavailable,
    }

    if len(scores) < MIN_METRICS:
        return None, breakdown

    return sum(scores) / len(scores), breakdown


async def _compute_momentum_score(
    db: AsyncSession, symbol: str
) -> tuple[float | None, dict | None]:
    """Compute momentum dimension score.

    Rate of change of price over 5-day and 20-day lookback periods.
    Returns (score, breakdown) where breakdown follows the ScoreBreakdown
    TypedDict shape: {sub_scores, formula, unavailable}.
    """
    from app.services.price_service import query_ohlcv

    records = await query_ohlcv(db, symbol)
    if not records or len(records) < 6:
        return None, None

    closes = [float(r.close) for r in records]
    return compute_momentum_from_closes(closes)


def compute_momentum_from_closes(closes: list[float]) -> tuple[float | None, dict | None]:
    """Momentum score (5- and 20-day ROC) from a close series. Pure (no DB)."""
    formula = "Weighted average: 0.5 * ROC_5 + 0.5 * ROC_20, re-normalized if any sub-score unavailable."

    if not closes or len(closes) < 6:
        return None, None

    latest = closes[-1]

    scores: list[tuple[float, float]] = []  # (weight, score)
    sub_scores: list[dict] = []
    unavailable: list[dict[str, str]] = []

    # 5-day ROC (weight 0.5)
    if len(closes) >= 6 and closes[-6] > 0:
        roc_5 = (latest - closes[-6]) / closes[-6] * 100.0
        # Map: -10% → 0, 0% → 50, +10% → 100
        score_5 = max(0.0, min(100.0, 50.0 + roc_5 * 5.0))
        scores.append((0.5, score_5))
        sub_scores.append({
            "name": "5-day ROC",
            "score": score_5,
            "weight": 0.5,
            "raw_value": round(roc_5, 4),
            "description": f"5-day rate of change: {round(roc_5, 2)}%. Score: 50 + ROC * 5, clamped to [0, 100].",
        })
    else:
        unavailable.append({"name": "5-day ROC", "reason": "Need at least 6 closing prices"})

    # 20-day ROC (weight 0.5)
    if len(closes) >= 21 and closes[-21] > 0:
        roc_20 = (latest - closes[-21]) / closes[-21] * 100.0
        score_20 = max(0.0, min(100.0, 50.0 + roc_20 * 5.0))
        scores.append((0.5, score_20))
        sub_scores.append({
            "name": "20-day ROC",
            "score": score_20,
            "weight": 0.5,
            "raw_value": round(roc_20, 4),
            "description": f"20-day rate of change: {round(roc_20, 2)}%. Score: 50 + ROC * 5, clamped to [0, 100].",
        })
    else:
        unavailable.append({"name": "20-day ROC", "reason": "Need at least 21 closing prices"})

    if not scores:
        breakdown: dict = {
            "sub_scores": [],
            "formula": formula,
            "unavailable": unavailable,
        }
        return None, breakdown

    total_weight = sum(w for w, _ in scores)
    if total_weight == 0:
        return None, None
    weighted = sum(w * s for w, s in scores) / total_weight
    final_score = max(0.0, min(100.0, weighted))

    breakdown = {
        "sub_scores": sub_scores,
        "formula": formula,
        "unavailable": unavailable,
    }

    return final_score, breakdown


_DIMENSION_COMPUTERS = {
    "technical": _compute_technical_score,
    "sr_quality": _compute_sr_quality_score,
    "sentiment": _compute_sentiment_score,
    "fundamental": _compute_fundamental_score,
    "momentum": _compute_momentum_score,
}


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

async def compute_dimension_score(
    db: AsyncSession, symbol: str, dimension: str
) -> float | None:
    """Compute a single dimension score for a ticker.

    Returns the score (0-100) or None if insufficient data.
    Persists the result to the DimensionScore table.
    """
    if dimension not in _DIMENSION_COMPUTERS:
        raise ValidationError(
            f"Unknown dimension: {dimension}. Valid: {', '.join(DIMENSIONS)}"
        )

    ticker = await _get_ticker(db, symbol)
    raw_result = await _DIMENSION_COMPUTERS[dimension](db, symbol)

    # Handle both tuple (score, breakdown) and plain float | None returns
    if isinstance(raw_result, tuple):
        score_val = raw_result[0]
    else:
        score_val = raw_result

    now = datetime.now(timezone.utc)

    # Upsert dimension score
    result = await db.execute(
        select(DimensionScore).where(
            DimensionScore.ticker_id == ticker.id,
            DimensionScore.dimension == dimension,
        )
    )
    existing = result.scalar_one_or_none()

    if score_val is not None:
        score_val = max(0.0, min(100.0, score_val))

    if existing is not None:
        if score_val is not None:
            existing.score = score_val
            existing.is_stale = False
            existing.computed_at = now
        else:
            # Can't compute — mark stale
            existing.is_stale = True
    elif score_val is not None:
        stmt = insert_for_session(db, DimensionScore).values(
            ticker_id=ticker.id,
            dimension=dimension,
            score=score_val,
            is_stale=False,
            computed_at=now,
        )
        await db.execute(
            stmt.on_conflict_do_update(
                index_elements=["ticker_id", "dimension"],
                set_={
                    "score": stmt.excluded.score,
                    "is_stale": False,
                    "computed_at": stmt.excluded.computed_at,
                },
            )
        )

    return score_val


async def compute_all_dimensions(
    db: AsyncSession, symbol: str
) -> dict[str, float | None]:
    """Compute all dimension scores for a ticker. Returns dimension → score map."""
    results: dict[str, float | None] = {}
    for dim in DIMENSIONS:
        results[dim] = await compute_dimension_score(db, symbol, dim)
    return results


async def compute_composite_score(
    db: AsyncSession,
    symbol: str,
    weights: dict[str, float] | None = None,
) -> tuple[float | None, list[str]]:
    """Compute the composite score.

    The non-sentiment dimensions form a re-normalized weighted-average *base*.
    Sentiment is then applied as a signed adjustment around neutral (50), not
    averaged in: neutral leaves the base unchanged, bullish adds and bearish
    subtracts (scaled by confidence), so going from no sentiment to bullish can
    only help. See _sentiment_adjustment.

    Returns (composite_score, missing_dimensions).
    """
    ticker = await _get_ticker(db, symbol)

    if weights is None:
        weights = await _get_weights(db)

    # Get current dimension scores
    result = await db.execute(
        select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
    )
    dim_scores = {ds.dimension: ds for ds in result.scalars().all()}

    def _live(dim: str) -> float | None:
        ds = dim_scores.get(dim)
        if ds is not None and not ds.is_stale and ds.score is not None:
            return ds.score
        return None

    missing = [dim for dim in DIMENSIONS if _live(dim) is None]

    # Base: re-normalized weighted average of the non-sentiment dimensions.
    base_available = [
        (dim, weights.get(dim, 0.0), _live(dim))
        for dim in DIMENSIONS
        if dim != "sentiment" and weights.get(dim, 0.0) > 0 and _live(dim) is not None
    ]
    sentiment_score = _live("sentiment")

    if base_available:
        total_weight = sum(w for _, w, _ in base_available)
        base = sum(w * s for _, w, s in base_available) / total_weight
    elif sentiment_score is not None:
        base = NEUTRAL_SENTIMENT  # only sentiment present → neutral baseline
    else:
        return None, missing  # nothing to score

    delta = _sentiment_adjustment(sentiment_score, weights.get("sentiment", 0.0))
    composite = max(0.0, min(100.0, base + delta))

    # Persist composite score
    now = datetime.now(timezone.utc)
    stmt = insert_for_session(db, CompositeScore).values(
        ticker_id=ticker.id,
        score=composite,
        is_stale=False,
        weights_json=json.dumps(weights),
        computed_at=now,
    )
    await db.execute(
        stmt.on_conflict_do_update(
            index_elements=["ticker_id"],
            set_={
                "score": stmt.excluded.score,
                "is_stale": False,
                "weights_json": stmt.excluded.weights_json,
                "computed_at": stmt.excluded.computed_at,
            },
        )
    )

    return composite, missing



async def get_score(
    db: AsyncSession, symbol: str
) -> dict:
    """Read composite + dimension scores for a ticker without recomputing.

    GET endpoints use this path, so it must not mutate persisted score context.
    Scheduled/manual write paths are responsible for refreshing scores.
    """
    ticker = await _get_ticker(db, symbol)
    weights = await _get_weights(db)

    result = await db.execute(
        select(DimensionScore).where(DimensionScore.ticker_id == ticker.id)
    )
    dim_scores_list = list(result.scalars().all())
    dim_scores = {ds.dimension: ds for ds in dim_scores_list}

    comp_result = await db.execute(
        select(CompositeScore).where(CompositeScore.ticker_id == ticker.id)
    )
    comp = comp_result.scalar_one_or_none()

    dimensions = []
    missing = []
    available_dims: list[str] = []
    for dim in DIMENSIONS:
        found = dim_scores.get(dim)
        if found is not None and not found.is_stale and found.score is not None:
            dimensions.append({
                "dimension": found.dimension,
                "score": found.score,
                "is_stale": found.is_stale,
                "computed_at": found.computed_at,
                "breakdown": None,
            })
            w = weights.get(dim, 0.0)
            if w > 0:
                available_dims.append(dim)
        else:
            missing.append(dim)
            # Still include stale dimensions in the list if they exist in DB
            if found is not None:
                dimensions.append({
                    "dimension": found.dimension,
                    "score": found.score,
                    "is_stale": found.is_stale,
                    "computed_at": found.computed_at,
                    "breakdown": None,
                })

    # Build composite breakdown: the non-sentiment base (re-normalized weighted
    # average) plus sentiment as a signed adjustment around neutral.
    base_dims = [d for d in available_dims if d != "sentiment"]
    available_weight_sum = sum(weights.get(d, 0.0) for d in base_dims)
    if available_weight_sum > 0:
        renormalized_weights = {
            d: weights.get(d, 0.0) / available_weight_sum for d in base_dims
        }
    else:
        renormalized_weights = {}

    fresh = {
        ds.dimension: ds.score
        for ds in dim_scores_list
        if not ds.is_stale and ds.score is not None
    }
    if renormalized_weights:
        base_score = sum(renormalized_weights[d] * fresh[d] for d in base_dims)
    elif "sentiment" in fresh:
        base_score = NEUTRAL_SENTIMENT
    else:
        base_score = None

    sentiment_val = fresh.get("sentiment")
    sentiment_weight = weights.get("sentiment", 0.0)
    sentiment_adjustment = _sentiment_adjustment(sentiment_val, sentiment_weight)

    composite_breakdown = {
        "weights": weights,
        "available_dimensions": base_dims,
        "missing_dimensions": missing,
        "renormalized_weights": renormalized_weights,
        "base_score": base_score,
        "sentiment_score": sentiment_val,
        "sentiment_adjustment": sentiment_adjustment,
        "max_sentiment_adjustment": sentiment_weight * 100.0,
        "formula": (
            "Base = re-normalized weighted average of the non-sentiment dimensions. "
            "Composite = base + sentiment adjustment, where adjustment = "
            "MAX_ADJ * (sentiment - 50) / 50 and MAX_ADJ = sentiment weight * 100."
        ),
    }

    return {
        "symbol": ticker.symbol,
        "composite_score": comp.score if comp else None,
        "composite_stale": comp.is_stale if comp else False,
        "weights": weights,
        "dimensions": dimensions,
        "missing_dimensions": missing,
        "computed_at": comp.computed_at if comp else None,
        "composite_breakdown": composite_breakdown,
    }



async def get_rankings(db: AsyncSession) -> dict:
    """Get all tickers ranked by composite score descending.

    Returns dict suitable for RankingResponse.
    """
    weights = await _get_weights(db)
    tickers = (await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars().all()

    async def _load_scores() -> tuple[dict[int, CompositeScore], dict[int, dict[str, DimensionScore]]]:
        comps = {
            c.ticker_id: c
            for c in (await db.execute(select(CompositeScore))).scalars().all()
        }
        dims: dict[int, dict[str, DimensionScore]] = defaultdict(dict)
        rows = await db.execute(
            select(DimensionScore).order_by(DimensionScore.ticker_id, DimensionScore.id)
        )
        for ds in rows.scalars().all():
            dims[ds.ticker_id][ds.dimension] = ds
        return comps, dims

    comps, dims_by_ticker = await _load_scores()

    rankings = [
        {
            "symbol": ticker.symbol,
            "composite_score": comp.score,
            "composite_stale": comp.is_stale,
            "dimensions": [
                {
                    "dimension": ds.dimension,
                    "score": ds.score,
                    "is_stale": ds.is_stale,
                    "computed_at": ds.computed_at,
                }
                for ds in dims_by_ticker.get(ticker.id, {}).values()
            ],
        }
        for ticker in tickers
        if (comp := comps.get(ticker.id)) is not None
    ]

    rankings.sort(key=lambda r: r["composite_score"], reverse=True)
    return {"rankings": rankings, "weights": weights}


async def update_weights(
    db: AsyncSession, weights: dict[str, float]
) -> dict[str, float]:
    """Update scoring weights and recompute all composite scores.

    Validates that all weights are positive and dimensions are valid.
    Returns the new weights.
    """
    # Validate
    for dim, w in weights.items():
        if dim not in DIMENSIONS:
            raise ValidationError(
                f"Unknown dimension: {dim}. Valid: {', '.join(DIMENSIONS)}"
            )
        if w < 0:
            raise ValidationError(f"Weight for {dim} must be non-negative, got {w}")

    # Ensure all dimensions have a weight (default 0 for unspecified)
    full_weights = {dim: weights.get(dim, 0.0) for dim in DIMENSIONS}

    # Persist
    await _save_weights(db, full_weights)

    # Recompute all composite scores
    result = await db.execute(select(Ticker))
    tickers = list(result.scalars().all())

    for ticker in tickers:
        await compute_composite_score(db, ticker.symbol, full_weights)

    await db.commit()
    return full_weights
