"""Trend projection module — forward-looking trend estimates.

Computes TrendProjection objects by combining current trend momentum,
macro signal decay trajectories, and upcoming catalyst outlook.
Projections are persisted alongside trend_window records.

Requirements: 12.1, 12.2, 12.3, 12.4, 12.5, 12.9, 13.1, 13.2, 13.3, 13.4, 13.5, 13.6
"""
from __future__ import annotations

import json
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING

import asyncpg

from services.shared.schemas import TrendSummary

if TYPE_CHECKING:
    from services.aggregation.regime import V3RegimeClassification

logger = logging.getLogger("projection")

# ---------------------------------------------------------------------------
# TrendProjection dataclass
# ---------------------------------------------------------------------------

VALID_DIRECTIONS = {"bullish", "bearish", "mixed", "neutral"}
VALID_HORIZONS = {"1d", "7d", "30d"}

# Default low-confidence threshold
DEFAULT_CONFIDENCE_THRESHOLD = 0.3

# Macro signal decay half-lives (in days) by estimated_duration
DECAY_HALF_LIFE_DAYS: dict[str, float] = {
    "short_term": 1.0,    # halve impact per day
    "medium_term": 7.0,   # halve impact per week
    "long_term": 30.0,    # halve impact per month
}


@dataclass
class TrendProjection:
    """Forward-looking trend projection for a company."""

    projected_direction: str = "neutral"       # bullish|bearish|mixed|neutral
    projected_strength: float = 0.5            # [0, 1]
    projected_confidence: float = 0.5          # [0, 1]
    projection_horizon: str = "7d"             # 1d|7d|30d
    driving_factors: list[str] = field(default_factory=list)
    macro_contribution_pct: float = 0.0        # [0, 1]
    diverges_from_current: bool = False
    computed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    low_confidence: bool = False


# ---------------------------------------------------------------------------
# Macro impact row type (lightweight, avoids circular import with worker)
# ---------------------------------------------------------------------------

@dataclass
class MacroEventInfo:
    """Minimal macro event info needed for projection computation."""

    event_id: str = ""
    macro_impact_score: float = 0.0
    impact_direction: str = "neutral"
    confidence: float = 0.5
    estimated_duration: str = "short_term"
    severity: str = "low"
    event_age_hours: float = 0.0  # hours since event publication


# ---------------------------------------------------------------------------
# Projection horizon mapping from trend window
# ---------------------------------------------------------------------------

_WINDOW_TO_HORIZON: dict[str, str] = {
    "intraday": "1d",
    "1d": "1d",
    "7d": "7d",
    "30d": "30d",
    "90d": "30d",
}


# ---------------------------------------------------------------------------
# Momentum computation
# ---------------------------------------------------------------------------

def compute_trend_momentum(
    current_strength: float,
    current_direction: str,
    previous_strength: float | None = None,
    previous_direction: str | None = None,
) -> float:
    """Compute trend momentum as rate of change in signed strength.

    Returns a value in [-1, 1] representing the momentum:
    - Positive = strengthening bullish or weakening bearish
    - Negative = strengthening bearish or weakening bullish
    - Zero = no change or no previous data

    When no previous data is available, uses a simple heuristic based
    on current strength and direction.
    """
    dir_sign = _direction_sign(current_direction)

    if previous_strength is None or previous_direction is None:
        # Heuristic: assume momentum proportional to current signed strength
        return round(dir_sign * current_strength * 0.5, 6)

    prev_sign = _direction_sign(previous_direction)
    current_signed = dir_sign * current_strength
    previous_signed = prev_sign * previous_strength

    momentum = current_signed - previous_signed
    return round(max(-1.0, min(1.0, momentum)), 6)


def _direction_sign(direction: str) -> float:
    """Map direction to a sign multiplier."""
    if direction == "bullish":
        return 1.0
    elif direction == "bearish":
        return -1.0
    return 0.0


# ---------------------------------------------------------------------------
# Exponentially weighted momentum (Requirements: 13.1–13.6)
# ---------------------------------------------------------------------------


def compute_ew_momentum(
    strength_changes: list[float],
    lambda_decay: float = 0.7,
) -> float:
    """Compute exponentially weighted momentum from historical strength changes.

    Formula: M_t = Σ_{k=0}^{K-1} λ^k · ΔS_{t-k}
    Normalized by geometric series sum Σ λ^k to produce value in [-1, 1].

    When fewer than 2 historical cycles are available, returns 0.0
    (caller should fall back to heuristic).

    Args:
        strength_changes: List of signed strength changes ΔS, most recent first.
            Each value represents the change in signed trend strength from one
            cycle to the next. Positive = strengthening bullish / weakening bearish.
        lambda_decay: Decay factor λ (default 0.7). Must be in (0, 1).

    Returns:
        Normalized momentum in [-1, 1]. Returns 0.0 for empty or single-element lists.

    Requirements: 13.1, 13.2, 13.3, 13.6
    """
    if len(strength_changes) < 2:
        return 0.0

    # Use up to K=10 most recent changes, filtering out NaN values
    k_max = min(len(strength_changes), 10)
    changes = strength_changes[:k_max]

    weighted_sum = 0.0
    weight_sum = 0.0
    for k, delta_s in enumerate(changes):
        if math.isnan(delta_s):
            continue
        w = lambda_decay ** k
        weighted_sum += w * delta_s
        weight_sum += w

    if weight_sum == 0.0:
        return 0.0

    normalized = weighted_sum / weight_sum
    # Guard against NaN propagation
    if math.isnan(normalized) or math.isinf(normalized):
        return 0.0
    return max(-1.0, min(1.0, normalized))


def compute_volatility_scaled_momentum(
    momentum: float,
    sigma_20: float,
) -> float:
    """Compute volatility-scaled momentum.

    Formula: M_adj = M_t / max(σ_20, 0.01), clamped to [-2.0, 2.0].

    Normalizes momentum relative to the ticker's typical price movement.

    Args:
        momentum: Raw or EW momentum value.
        sigma_20: 20-day return standard deviation.

    Returns:
        Volatility-scaled momentum in [-2.0, 2.0].

    Requirements: 13.4, 13.5
    """
    denominator = max(sigma_20, 0.01)
    scaled = momentum / denominator
    # Guard against NaN propagation
    if math.isnan(scaled) or math.isinf(scaled):
        return 0.0
    return max(-2.0, min(2.0, scaled))


# ---------------------------------------------------------------------------
# Macro signal decay projection
# ---------------------------------------------------------------------------

_SEVERITY_WEIGHT: dict[str, float] = {
    "critical": 1.0,
    "high": 0.75,
    "moderate": 0.5,
    "low": 0.25,
}


def project_macro_decay(
    events: list[MacroEventInfo],
    horizon_days: float,
) -> tuple[float, str]:
    """Project the aggregate macro signal after decay over the horizon.

    For each active macro event, compute the projected remaining impact
    using exponential decay based on estimated_duration:
    - short_term: half-life = 1 day
    - medium_term: half-life = 7 days
    - long_term: half-life = 30 days

    Returns:
        (projected_macro_strength, projected_macro_direction)
        where strength is in [0, 1] and direction is bullish|bearish|mixed|neutral.
    """
    if not events:
        return 0.0, "neutral"

    positive_weight = 0.0
    negative_weight = 0.0

    for ev in events:
        half_life = DECAY_HALF_LIFE_DAYS.get(ev.estimated_duration, 7.0)
        # Current age in days
        current_age_days = ev.event_age_hours / 24.0
        # Projected age at end of horizon
        future_age_days = current_age_days + horizon_days

        # Decay factor: ratio of future impact to current impact
        if half_life > 0:
            future_factor = math.pow(2.0, -future_age_days / half_life)
        else:
            future_factor = 0.0

        severity_w = _SEVERITY_WEIGHT.get(ev.severity, 0.25)
        projected_impact = ev.macro_impact_score * future_factor * severity_w

        if ev.impact_direction == "positive":
            positive_weight += projected_impact
        elif ev.impact_direction == "negative":
            negative_weight += projected_impact
        else:
            # mixed/neutral: split evenly
            positive_weight += projected_impact * 0.5
            negative_weight += projected_impact * 0.5

    total = positive_weight + negative_weight
    if total == 0.0:
        return 0.0, "neutral"

    strength = min(total, 1.0)

    if positive_weight > negative_weight * 1.2:
        direction = "bullish"
    elif negative_weight > positive_weight * 1.2:
        direction = "bearish"
    elif positive_weight > 0 and negative_weight > 0:
        direction = "mixed"
    else:
        direction = "neutral"

    return round(strength, 6), direction


# ---------------------------------------------------------------------------
# Horizon days mapping
# ---------------------------------------------------------------------------

_HORIZON_DAYS: dict[str, float] = {
    "1d": 1.0,
    "7d": 7.0,
    "30d": 30.0,
}


# ---------------------------------------------------------------------------
# Core projection computation
# ---------------------------------------------------------------------------


def compute_projection(
    summary: TrendSummary,
    macro_events: list[MacroEventInfo] | None = None,
    macro_enabled: bool = True,
    confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
    previous_strength: float | None = None,
    previous_direction: str | None = None,
    upcoming_catalysts: list[str] | None = None,
) -> TrendProjection:
    """Compute a forward-looking trend projection.

    Combines:
    1. Trend momentum (rate of change in strength)
    2. Macro signal decay projection
    3. Upcoming catalyst outlook
    4. Current trend baseline

    Args:
        summary: The current trend summary.
        macro_events: Active macro events with their info.
        macro_enabled: Whether the macro layer is enabled.
        confidence_threshold: Below this, mark as low_confidence.
        previous_strength: Previous window's trend strength (optional).
        previous_direction: Previous window's trend direction (optional).
        upcoming_catalysts: Known upcoming catalysts from doc intelligence.

    Returns:
        A TrendProjection with projected direction, strength, and confidence.
    """
    now = datetime.now(timezone.utc)
    current_dir = summary.trend_direction.value
    current_strength = summary.trend_strength
    current_confidence = summary.confidence

    horizon = _WINDOW_TO_HORIZON.get(summary.window.value, "7d")
    horizon_days = _HORIZON_DAYS.get(horizon, 7.0)

    driving_factors: list[str] = []

    # 1. Compute trend momentum
    momentum = compute_trend_momentum(
        current_strength, current_dir,
        previous_strength, previous_direction,
    )
    if abs(momentum) > 0.05:
        if momentum > 0:
            driving_factors.append(f"Positive momentum ({momentum:+.3f}) in recent trend strength")
        else:
            driving_factors.append(f"Negative momentum ({momentum:+.3f}) in recent trend strength")

    # 2. Project macro signal decay
    macro_strength = 0.0
    macro_direction = "neutral"
    macro_contribution = 0.0

    if macro_enabled and macro_events:
        macro_strength, macro_direction = project_macro_decay(macro_events, horizon_days)
        if macro_strength > 0:
            driving_factors.append(
                f"Macro signals project {macro_direction} impact "
                f"(strength {macro_strength:.3f}) over {horizon}"
            )

    # 3. Factor in upcoming catalysts
    catalysts = upcoming_catalysts or []
    for catalyst in catalysts[:3]:  # limit to top 3
        driving_factors.append(f"Upcoming catalyst: {catalyst}")

    catalyst_boost = min(len(catalysts) * 0.02, 0.1)  # small boost per catalyst

    # 4. Combine into projected direction/strength/confidence
    # Momentum-based projection of company-specific trend
    momentum_projected_signed = _direction_sign(current_dir) * current_strength + momentum * 0.5
    momentum_projected_strength = min(abs(momentum_projected_signed), 1.0)

    if macro_enabled and macro_strength > 0:
        # Blend company momentum with macro trajectory
        macro_weight = min(macro_strength * 0.4, 0.4)
        company_weight = 1.0 - macro_weight

        macro_signed = _direction_sign(macro_direction) * macro_strength
        blended_signed = (
            company_weight * momentum_projected_signed
            + macro_weight * macro_signed
        )
        projected_strength = round(min(abs(blended_signed) + catalyst_boost, 1.0), 6)
        macro_contribution = round(macro_weight, 6)

        # Determine projected direction from blended signal
        projected_direction = _signed_to_direction(blended_signed)
    else:
        # Company-only projection
        projected_strength = round(min(momentum_projected_strength + catalyst_boost, 1.0), 6)
        projected_direction = _signed_to_direction(momentum_projected_signed)

    # Compute projected confidence
    base_confidence = current_confidence * 0.8  # projection inherently less certain
    if macro_enabled and macro_strength > 0:
        # Macro data adds information → slight confidence boost
        macro_conf_boost = min(macro_strength * 0.15, 0.1)
        projected_confidence = round(min(base_confidence + macro_conf_boost, 1.0), 6)
    else:
        # Without macro data, reduce confidence further
        if not macro_enabled:
            projected_confidence = round(base_confidence * 0.85, 6)
        else:
            projected_confidence = round(base_confidence, 6)

    # Ensure driving_factors is never empty
    if not driving_factors:
        driving_factors.append(f"Baseline trend continuation: {current_dir} at strength {current_strength:.3f}")

    # 5. Flag divergence
    diverges = projected_direction != current_dir
    if diverges:
        driving_factors.append(
            f"DIVERGENCE: Current trend is {current_dir}, "
            f"projection is {projected_direction}"
        )

    # Mark low confidence
    is_low_confidence = projected_confidence < confidence_threshold

    return TrendProjection(
        projected_direction=projected_direction,
        projected_strength=projected_strength,
        projected_confidence=projected_confidence,
        projection_horizon=horizon,
        driving_factors=driving_factors,
        macro_contribution_pct=macro_contribution,
        diverges_from_current=diverges,
        computed_at=now,
        low_confidence=is_low_confidence,
    )


def _signed_to_direction(signed_value: float) -> str:
    """Convert a signed strength value to a direction string."""
    if signed_value > 0.1:
        return "bullish"
    elif signed_value < -0.1:
        return "bearish"
    elif abs(signed_value) > 0.02:
        return "mixed"
    return "neutral"


# ---------------------------------------------------------------------------
# PostgreSQL persistence
# ---------------------------------------------------------------------------

_INSERT_PROJECTION = """
INSERT INTO trend_projections (
    trend_window_id, projected_direction, projected_strength,
    projected_confidence, projection_horizon, driving_factors,
    macro_contribution_pct, diverges_from_current, computed_at
) VALUES (
    $1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8, $9
)
RETURNING id
"""


async def persist_trend_projection(
    pool: asyncpg.Pool,
    trend_window_id: str,
    projection: TrendProjection,
) -> str:
    """Persist a TrendProjection to the trend_projections table.

    Returns the row UUID.
    """
    row_id = await pool.fetchval(
        _INSERT_PROJECTION,
        trend_window_id,
        projection.projected_direction,
        projection.projected_strength,
        projection.projected_confidence,
        projection.projection_horizon,
        json.dumps(projection.driving_factors),
        projection.macro_contribution_pct,
        projection.diverges_from_current,
        projection.computed_at,
    )
    logger.info(
        "Persisted trend projection for window=%s: direction=%s strength=%.3f confidence=%.3f diverges=%s",
        trend_window_id,
        projection.projected_direction,
        projection.projected_strength,
        projection.projected_confidence,
        projection.diverges_from_current,
    )
    return str(row_id)


# ---------------------------------------------------------------------------
# V3 Posterior State Projection (Requirements: 11.1–11.7)
# ---------------------------------------------------------------------------


# Regime decay factors (phi) — Req 11.3
_V3_PHI_DECAY: dict[str, float] = {
    "panic": 0.35,
    "trend_following": 0.80,
    "mean_reversion": 0.55,
    "uncertainty": 0.50,
}


def _logit(p: float) -> float:
    """Compute logit = ln(p / (1-p)) with boundary guard."""
    p = max(1e-10, min(1 - 1e-10, p))
    return math.log(p / (1 - p))


def _sigmoid(x: float) -> float:
    """Compute sigmoid = 1 / (1 + exp(-x)) with overflow guard."""
    if x > 500:
        return 1.0
    if x < -500:
        return 0.0
    return 1.0 / (1.0 + math.exp(-x))


@dataclass
class V3ProjectionState:
    """V3 posterior state projection result.

    Attributes:
        a_t: Accumulated evidence state A_t.
        p_up_projected: Projected probability sigmoid(logit(P_prior) + phi^h * A_t).
        projected_strength: abs(2 * P_up_projected - 1).
        diverges: True when sign(P_up_projected - 0.5) != sign(P_up_t - 0.5).
        phi_regime: Regime-specific decay factor used.
    """

    a_t: float
    p_up_projected: float
    projected_strength: float
    diverges: bool
    phi_regime: float


def compute_v3_projection(
    a_prev: float,
    cluster_llrs: list[float],
    regime: V3RegimeClassification,
    p_prior: float,
    projection_horizon: int,
    known_catalyst_llr: float = 0.0,
) -> V3ProjectionState:
    """Compute posterior state projection with regime-aware decay.

    Evidence state: A_t = phi_regime * A_{t-1} + sum(LLR_c), init A_0 = 0.0
    Projected alpha: A_projected = phi^h * A_t + known_catalyst_LLR
    P_up_projected = sigmoid(logit(P_prior) + A_projected)
    Projected strength = abs(2 * P_up_projected - 1)
    Divergence flagged when sign(P_up_projected - 0.5) != sign(P_up_t - 0.5)

    Requirements: 11.1–11.7
    """
    # Resolve phi from regime; default to uncertainty (0.50) if unavailable (Req 11.7)
    phi = _V3_PHI_DECAY.get(regime.regime.value, 0.50) if regime else 0.50

    # Evidence state update: A_t = phi * A_{t-1} + sum(LLR_c) — Req 11.1, 11.2
    a_t = phi * a_prev + sum(cluster_llrs)

    # Projected alpha: A_projected = phi^h * A_t + known_catalyst_LLR — Req 11.4
    a_projected = (phi ** projection_horizon) * a_t + known_catalyst_llr

    # P_up_projected = sigmoid(logit(P_prior) + A_projected) — Req 11.5
    p_up_projected = _sigmoid(_logit(p_prior) + a_projected)

    # Projected strength = abs(2 * P_up_projected - 1) — Req 11.6
    projected_strength = abs(2.0 * p_up_projected - 1.0)

    # Compute current P_up_t for divergence check (not projected)
    p_up_t = _sigmoid(_logit(p_prior) + a_t)

    # Flag divergence when projected direction differs from current — Req 11.6
    sign_projected = (p_up_projected - 0.5) >= 0
    sign_current = (p_up_t - 0.5) >= 0
    diverges = sign_projected != sign_current

    return V3ProjectionState(
        a_t=a_t,
        p_up_projected=p_up_projected,
        projected_strength=projected_strength,
        diverges=diverges,
        phi_regime=phi,
    )
