"""Aggregation worker - company-level rolling window trend summaries.

Queries document intelligence and market context for a given ticker,
computes weighted signal scores, and produces TrendSummary objects
persisted to the trend_windows table.

Requirements: 6.1, 6.2, 6.5
"""
from __future__ import annotations

import json
import logging
import math
import time
import uuid as _uuid
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any

import asyncpg

from services.aggregation.bayesian import (
    BayesianPosterior,
    compute_bayesian_posterior,
)
from services.aggregation.contradiction import CatalystEntry, detect_contradictions
from services.aggregation.evidence import (
    EvidenceRankConfig,
    RankedEvidence,
    rank_evidence_detailed,
)
from services.aggregation.evidence import (
    rank_evidence as _rank_evidence_composite,
)
from services.aggregation.interpolation import integrate_macro_signals
from services.aggregation.market_context import fetch_market_context
from services.aggregation.pattern_matcher import find_self_patterns
from services.aggregation.projection import (
    MacroEventInfo,
    compute_projection,
    persist_trend_projection,
)
from services.aggregation.regime import (
    MarketRegime,
    RegimeClassification,
    classify_regime,
)
from services.aggregation.scoring import (
    EvidenceUnit,
    ScoringConfig,
    WeightedSignal,
    compute_signal_weight,
    normalize_impact_scores,
    sentiment_to_numeric,
    weighted_sentiment_average,
)
from services.aggregation.signal_propagation import (
    CompetitiveSignalRecord,
    build_pattern_weighted_signals,
)
from services.aggregation.source_accuracy import fetch_source_accuracy
from services.shared.metrics import (
    AGGREGATION_CONTRADICTION_SCORE,
    AGGREGATION_DURATION,
    AGGREGATION_SIGNALS_PROCESSED,
    AGGREGATION_WINDOWS_COMPUTED,
)
from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow
from services.trading.model_quality_gate import QualityGateResult, evaluate_quality_gate

logger = logging.getLogger(__name__)

# Map TrendWindow values to lookback durations.
WINDOW_DURATIONS: dict[str, timedelta] = {
    TrendWindow.INTRADAY.value: timedelta(hours=12),
    TrendWindow.ONE_DAY.value: timedelta(days=1),
    TrendWindow.SEVEN_DAY.value: timedelta(days=7),
    TrendWindow.THIRTY_DAY.value: timedelta(days=30),
    TrendWindow.NINETY_DAY.value: timedelta(days=90),
}

# How many evidence document IDs to keep in supporting/opposing lists.
MAX_EVIDENCE_REFS = 10


@dataclass
class AggregationConfig:
    """Controls which windows to compute and scoring parameters."""

    windows: list[str] | None = None  # None = all windows
    scoring: ScoringConfig | None = None
    max_evidence: int = MAX_EVIDENCE_REFS
    macro_signal_weight: float = 0.3  # relative weight of macro vs company signals
    macro_enabled: bool = True  # runtime toggle state
    competitive_signal_weight: float = 0.2  # relative weight of pattern signals
    competitive_enabled: bool = True  # runtime toggle state
    probabilistic_scoring_enabled: bool = False  # probabilistic pipeline toggle

    def effective_windows(self) -> list[str]:
        if self.windows:
            return self.windows
        return [w.value for w in TrendWindow]

    def effective_scoring(self) -> ScoringConfig:
        return self.scoring or ScoringConfig()



# ---------------------------------------------------------------------------
# Fetch impact records for a ticker within a time window
# ---------------------------------------------------------------------------

_IMPACT_QUERY = """
SELECT
    di.document_id,
    di.confidence,
    di.novelty_score,
    di.source_credibility,
    dir.sentiment,
    dir.impact_score,
    dir.catalyst_type,
    dir.key_facts,
    dir.risks,
    d.published_at
FROM document_impact_records dir
JOIN document_intelligence di ON di.id = dir.intelligence_id
JOIN documents d ON d.id = di.document_id
WHERE dir.ticker = $1
  AND d.published_at >= $2
  AND d.published_at <= $3
  AND di.validation_status = 'valid'
  AND d.status != 'rejected'
ORDER BY d.published_at DESC
"""


@dataclass
class ImpactRow:
    """Parsed row from the impact query."""

    document_id: str
    confidence: float
    novelty_score: float
    source_credibility: float
    sentiment: str
    impact_score: float
    catalyst_type: str
    key_facts: list[str]
    risks: list[str]
    published_at: datetime


def _parse_impact_row(row: Any) -> ImpactRow:
    """Convert an asyncpg Record to an ImpactRow."""
    key_facts = row["key_facts"]
    if isinstance(key_facts, str):
        key_facts = json.loads(key_facts)
    risks = row["risks"]
    if isinstance(risks, str):
        risks = json.loads(risks)

    return ImpactRow(
        document_id=str(row["document_id"]),
        confidence=float(row["confidence"] or 0.5),
        novelty_score=float(row["novelty_score"] or 0.5),
        source_credibility=float(row["source_credibility"] or 0.5),
        sentiment=row["sentiment"] or "neutral",
        impact_score=float(row["impact_score"] or 0.0),
        catalyst_type=row["catalyst_type"] or "other",
        key_facts=key_facts if isinstance(key_facts, list) else [],
        risks=risks if isinstance(risks, list) else [],
        published_at=row["published_at"],
    )


async def fetch_impact_records(
    pool: asyncpg.Pool,
    ticker: str,
    window_start: datetime,
    window_end: datetime,
) -> list[ImpactRow]:
    """Fetch validated document impact records for a ticker in a time range."""
    rows = await pool.fetch(_IMPACT_QUERY, ticker, window_start, window_end)
    return [_parse_impact_row(r) for r in rows]



# ---------------------------------------------------------------------------
# Fetch macro toggle state from risk_configs
#
# MACRO LAYER TOGGLE BEHAVIOR (Requirements 11.2, 11.3, 11.4):
# - The toggle state is read fresh from PostgreSQL at the start of each
#   aggregation cycle (no caching), so changes take effect immediately on
#   the next cycle.
# - When disabled: ingestion and classification continue normally (historical
#   data is preserved), but interpolation and aggregation integration are
#   skipped — the aggregation engine produces trends using only company-
#   specific signals.
# - When re-enabled: the engine resumes computing macro impact scores using
#   the most recent GlobalEvent classifications, including any events that
#   were ingested and classified while the layer was disabled.
# ---------------------------------------------------------------------------

_MACRO_TOGGLE_QUERY = """
SELECT config->>'macro_enabled' AS macro_enabled
FROM risk_configs
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1
"""


async def fetch_macro_enabled(pool: asyncpg.Pool) -> bool | None:
    """Check macro toggle state from risk_configs table.

    Returns True/False if explicitly set, or None if no config exists
    (caller should fall back to AggregationConfig default).
    """
    row = await pool.fetchrow(_MACRO_TOGGLE_QUERY)
    if row is None or row["macro_enabled"] is None:
        return None
    return row["macro_enabled"].lower() == "true"


# ---------------------------------------------------------------------------
# Fetch competitive toggle state from risk_configs
# ---------------------------------------------------------------------------

_COMPETITIVE_TOGGLE_QUERY = """
SELECT config->>'competitive_enabled' AS competitive_enabled
FROM risk_configs
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1
"""


async def fetch_competitive_enabled(pool: asyncpg.Pool) -> bool | None:
    """Check competitive toggle state from risk_configs table.

    Returns True/False if explicitly set, or None if no config exists
    (caller should fall back to AggregationConfig default).
    """
    row = await pool.fetchrow(_COMPETITIVE_TOGGLE_QUERY)
    if row is None or row["competitive_enabled"] is None:
        return None
    return row["competitive_enabled"].lower() == "true"


# ---------------------------------------------------------------------------
# Fetch probabilistic scoring toggle from risk_configs
#
# PROBABILISTIC PIPELINE TOGGLE (Requirements 16.3, 16.4, 16.5, 16.6, 16.7):
# - Read once per aggregation cycle from the risk_configs table.
# - When False (default): the heuristic pipeline is used — identical outputs
#   to the current system.
# - When True: the new Bayesian, regime-aware, and adaptive formulas are
#   used for all pipeline stages.
# - Defaults to False when the key is missing, the value is invalid, or the
#   database is unreachable (fail-safe to heuristic mode).
# ---------------------------------------------------------------------------

_PROBABILISTIC_TOGGLE_QUERY = """
SELECT config->>'probabilistic_scoring_enabled' AS probabilistic_scoring_enabled
FROM risk_configs
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1
"""


async def fetch_probabilistic_scoring_enabled(pool: asyncpg.Pool) -> bool:
    """Check probabilistic scoring toggle from risk_configs table.

    Returns True when explicitly enabled, False in all other cases
    (missing key, invalid value, no config row, DB error).
    This is fail-safe: any failure defaults to the heuristic pipeline.

    Requirements: 16.3, 16.6
    """
    try:
        row = await pool.fetchrow(_PROBABILISTIC_TOGGLE_QUERY)
        if row is None or row["probabilistic_scoring_enabled"] is None:
            return False
        raw = row["probabilistic_scoring_enabled"]
        if not isinstance(raw, str) or raw.lower() not in ("true", "false"):
            logger.warning(
                "Invalid probabilistic_scoring_enabled value %r in "
                "risk_configs; defaulting to heuristic pipeline",
                raw,
            )
            return False
        return raw.lower() == "true"
    except Exception:
        logger.warning(
            "Failed to read probabilistic_scoring_enabled from risk_configs; "
            "defaulting to heuristic pipeline",
            exc_info=True,
        )
        return False


# ---------------------------------------------------------------------------
# v3 Calibrated Evidence Engine feature flag
#
# V3 ENGINE TOGGLE (Requirements 19.1–19.6):
# - Read fresh from the risk_configs table at the start of each aggregation
#   cycle. When True, the v3 calibrated evidence pipeline is used. When
#   False (or on any read failure), the heuristic pipeline is used.
# - Defaults to False (heuristic) when:
#   * The key is missing from risk_configs
#   * The value is not a recognized truthy string
#   * The database is unreachable
# - This ensures fail-safe behavior: any failure defaults to the stable
#   heuristic pipeline.
# ---------------------------------------------------------------------------

_V3_ENGINE_FLAG_QUERY = """
SELECT config->>'v3_engine_enabled' AS enabled
FROM risk_configs
WHERE name = 'default' AND active = TRUE
LIMIT 1
"""


async def _read_v3_flag(pool: asyncpg.Pool) -> bool:
    """Read v3_engine_enabled from risk_configs JSONB. Default False on error.

    Requirements: 19.3, 19.6
    """
    try:
        row = await pool.fetchrow(_V3_ENGINE_FLAG_QUERY)
        if row and row["enabled"] is not None:
            return row["enabled"].lower() in ("true", "1", "yes")
        return False
    except Exception as e:
        logger.warning("Failed to read v3_engine_enabled flag: %s", e)
        return False


# ---------------------------------------------------------------------------
# v3 Calibrated Evidence Pipeline — Full End-to-End Orchestration
#
# Pipeline stages:
#   EvidenceUnit → q_i (reliability) → LLR → cluster → posterior →
#   contradiction → confidence → data_quality → TrendSummary
#
# Requirements: 20.1, 20.2, 20.3, 20.4, 20.5
# ---------------------------------------------------------------------------


async def _run_v3_pipeline(
    pool: asyncpg.Pool,
    ticker: str,
    window: str,
    reference_time: datetime,
    config: AggregationConfig,
) -> TrendSummary:
    """Run the v3 calibrated evidence pipeline for a single ticker/window.

    Orchestrates the full pipeline:
        1. Fetch signals from DB (company, macro, competitive)
        2. Normalize each into EvidenceUnit
        3. Compute calibrated reliability (q_i) for each unit
        4. Convert to LLR
        5. Cluster evidence by grouping key
        6. Compute n_eff and cluster_llr per cluster
        7. Classify market regime
        8. Compute posterior (p_up, direction, strength)
        9. Compute contradiction
        10. Compute data quality
        11. Compute multiplicative confidence
        12. Assemble TrendSummary with v3 metadata in market_context JSONB

    Requirements: 20.1, 20.2, 20.3, 20.4, 20.5
    """
    from services.aggregation.bayesian import compute_v3_posterior
    from services.aggregation.contradiction import compute_v3_contradiction
    from services.aggregation.scoring import (
        SourceStats,
        compute_llr,
        compute_v3_reliability,
        normalize_company_signal,
        normalize_competitive_signal,
        normalize_macro_signal,
    )

    duration = WINDOW_DURATIONS.get(window, timedelta(days=7))
    window_start = reference_time - duration

    # -----------------------------------------------------------------------
    # Stage 1: Fetch signals from DB
    # -----------------------------------------------------------------------
    impacts = await fetch_impact_records(pool, ticker, window_start, reference_time)

    macro_impacts: list[MacroImpactRow] = []
    macro_enabled = config.macro_enabled
    db_macro_toggle = await fetch_macro_enabled(pool)
    if db_macro_toggle is not None:
        macro_enabled = db_macro_toggle
    if macro_enabled:
        macro_impacts = await fetch_macro_impact_records(
            pool, ticker, window_start, reference_time,
        )

    competitive_signals: list[CompetitiveSignalRecord] = []
    competitive_enabled = config.competitive_enabled
    db_comp_toggle = await fetch_competitive_enabled(pool)
    if db_comp_toggle is not None:
        competitive_enabled = db_comp_toggle
    if competitive_enabled:
        try:
            competitive_signals = await fetch_competitive_signals(
                pool, ticker, window_start, reference_time,
            )
        except Exception:
            logger.warning(
                "v3: Failed to fetch competitive signals for %s/%s",
                ticker, window, exc_info=True,
            )

    # -----------------------------------------------------------------------
    # Stage 2: Normalize into EvidenceUnits
    # -----------------------------------------------------------------------
    units: list[EvidenceUnit] = []
    # Track WeightedSignal intermediates for explainability (Req 20.5)
    weighted_signal_intermediates: list[WeightedSignal] = []

    # Company signals
    for imp in impacts:
        signal_dict: dict[str, Any] = {
            "symbol": ticker,
            "timestamp": imp.published_at,
            "source_id": imp.document_id,
            "event_type": imp.catalyst_type,
            "source_group": "company",
            "horizon": window if window in ("intraday", "1d", "7d", "30d", "90d") else "7d",
            "sentiment": imp.sentiment,
            "sentiment_strength": imp.impact_score,
            "impact": imp.impact_score,
            "extraction_conf": imp.confidence,
            "source_cred": imp.source_credibility,
            "novelty": imp.novelty_score,
        }
        unit = normalize_company_signal(signal_dict)
        if unit is not None:
            units.append(unit)
            # Build WeightedSignal intermediate (Req 20.5)
            weighted_signal_intermediates.append(
                WeightedSignal(
                    document_id=imp.document_id,
                    weight=compute_signal_weight(
                        published_at=imp.published_at,
                        reference_time=reference_time,
                        window=window,
                        source_credibility=imp.source_credibility,
                        novelty_score=imp.novelty_score,
                        extraction_confidence=imp.confidence,
                    ),
                    sentiment_value=sentiment_to_numeric(imp.sentiment),
                    impact_score=imp.impact_score,
                )
            )

    # Macro signals
    for mir in macro_impacts:
        signal_dict = {
            "symbol": ticker,
            "timestamp": mir.event_published_at,
            "source_id": mir.event_id,
            "event_type": "macro",
            "impact_direction": mir.impact_direction,
            "macro_impact_score": mir.macro_impact_score,
            "confidence": mir.confidence,
            "estimated_duration": "medium_term",
            "novelty": 1.0,
            "sentiment_strength": mir.macro_impact_score,
        }
        unit = normalize_macro_signal(signal_dict)
        if unit is not None:
            units.append(unit)

    # Competitive signals
    for cs in competitive_signals:
        signal_dict = {
            "symbol": ticker,
            "target_ticker": cs.target_ticker,
            "timestamp": cs.computed_at,
            "source_id": cs.source_document_id,
            "source_document_id": cs.source_document_id,
            "event_type": cs.catalyst_type,
            "signal_direction": cs.signal_direction,
            "signal_strength": cs.signal_strength,
            "relationship_strength": cs.relationship_strength,
            "pattern_confidence": cs.pattern_confidence,
            "novelty": 1.0,
            "sentiment_strength": cs.signal_strength,
        }
        unit = normalize_competitive_signal(signal_dict)
        if unit is not None:
            units.append(unit)

    # If no valid evidence units, return a neutral TrendSummary
    if not units:
        return _build_empty_v3_summary(ticker, window, reference_time)

    # -----------------------------------------------------------------------
    # Stage 3: Compute calibrated reliability (q_i) for each unit
    # -----------------------------------------------------------------------
    # Use neutral SourceStats (hits=0, misses=0) for initial wiring
    # Historical source tracking will be added in a future task.
    neutral_stats = SourceStats(source_id="default", hits=0, misses=0)

    # Count duplicates per cluster for q_uniqueness
    cluster_seen: dict[str, int] = defaultdict(int)
    q_values: list[float] = []
    reliability_results = []

    for unit in units:
        cluster_pos = cluster_seen[unit.cluster_id]
        cluster_seen[unit.cluster_id] += 1

        reliability = compute_v3_reliability(
            unit=unit,
            source_stats=neutral_stats,
            cluster_position=cluster_pos,
            reference_time=reference_time,
        )
        q_values.append(reliability.q_i)
        reliability_results.append(reliability)

    # -----------------------------------------------------------------------
    # Stage 4: Convert to LLR
    # -----------------------------------------------------------------------
    llrs: list[float] = []
    for unit, q_i in zip(units, q_values):
        llr = compute_llr(unit, q_i)
        llrs.append(llr)

    # -----------------------------------------------------------------------
    # Stage 5: Cluster evidence
    # -----------------------------------------------------------------------
    clusters = cluster_evidence(units, llrs)

    # -----------------------------------------------------------------------
    # Stage 6: Compute n_eff and cluster_llr per cluster
    # -----------------------------------------------------------------------
    for cluster in clusters:
        cluster.n_eff = compute_n_eff(cluster.llrs)
        cluster.cluster_llr = compute_cluster_llr(cluster.llrs, cluster.n_eff)

    # -----------------------------------------------------------------------
    # Stage 7: Classify market regime
    # -----------------------------------------------------------------------
    regime = await _classify_regime_v3(pool, ticker)

    # -----------------------------------------------------------------------
    # Stage 8: Compute posterior
    # -----------------------------------------------------------------------
    posterior = compute_v3_posterior(clusters, regime, p_prior=0.50)

    # -----------------------------------------------------------------------
    # Stage 9: Compute contradiction
    # -----------------------------------------------------------------------
    contradiction = compute_v3_contradiction(clusters)

    # -----------------------------------------------------------------------
    # Stage 10: Compute data quality
    # -----------------------------------------------------------------------
    # Compute extraction_failure_rate: fraction of signals that failed normalization
    total_attempted = len(impacts) + len(macro_impacts) + len(competitive_signals)
    n_valid = len(units)
    extraction_failure_rate = 1.0 - (n_valid / max(total_attempted, 1))

    # Compute age of newest unit
    if units:
        newest_ts = max(u.timestamp for u in units)
        if newest_ts.tzinfo is None:
            newest_ts = newest_ts.replace(tzinfo=timezone.utc)
        age_newest_hours = max(
            (reference_time - newest_ts).total_seconds() / 3600.0, 0.0
        )
    else:
        age_newest_hours = 168.0  # max staleness

    # Count source types (company, macro, competitive)
    source_types = {u.layer for u in units}
    n_source_types = len(source_types)

    data_quality = compute_v3_data_quality(
        units=units,
        extraction_failure_rate=extraction_failure_rate,
        age_newest_hours=age_newest_hours,
        n_source_types=n_source_types,
    )

    # -----------------------------------------------------------------------
    # Stage 11: Compute multiplicative confidence
    # -----------------------------------------------------------------------
    confidence = compute_v3_confidence(
        n_eff_total=posterior.n_eff_total,
        q_values=q_values,
        llrs=llrs,
        strength=posterior.strength,
        regime_confidence_mult=regime.confidence_multiplier,
        contradiction=contradiction,
        data_quality=data_quality,
    )

    # -----------------------------------------------------------------------
    # Stage 12: Assemble TrendSummary with v3 metadata
    # -----------------------------------------------------------------------

    # Map posterior direction to TrendDirection
    if posterior.direction == "bullish":
        trend_direction = TrendDirection.BULLISH
    elif posterior.direction == "bearish":
        trend_direction = TrendDirection.BEARISH
    else:
        trend_direction = TrendDirection.NEUTRAL

    # Check if informational mode should be forced
    force_info, force_reason = should_force_informational_v3(
        data_quality=data_quality,
        units=units,
        extraction_failure_rate=extraction_failure_rate,
    )

    # Build explainability payload: top positive/negative clusters (Req 20.3)
    sorted_pos_clusters = sorted(
        [c for c in clusters if c.cluster_llr > 0],
        key=lambda c: c.cluster_llr,
        reverse=True,
    )[:5]
    sorted_neg_clusters = sorted(
        [c for c in clusters if c.cluster_llr < 0],
        key=lambda c: c.cluster_llr,
    )[:5]

    top_positive_clusters = [
        {
            "cluster_id": c.cluster_id,
            "llr": round(c.cluster_llr, 4),
            "n_eff": round(c.n_eff, 2),
            "n_units": len(c.units),
        }
        for c in sorted_pos_clusters
    ]
    top_negative_clusters = [
        {
            "cluster_id": c.cluster_id,
            "llr": round(c.cluster_llr, 4),
            "n_eff": round(c.n_eff, 2),
            "n_units": len(c.units),
        }
        for c in sorted_neg_clusters
    ]

    # Suppression reasons
    suppression_reasons: list[str] = []
    if force_info:
        suppression_reasons.append(force_reason)
    if contradiction > 0.5:
        suppression_reasons.append("high_contradiction")
    if data_quality < 0.5:
        suppression_reasons.append("low_data_quality")

    # Build v3 metadata for market_context JSONB (Req 20.1, 20.2)
    v3_metadata: dict[str, Any] = {
        "v3_posterior": {
            "p_up": round(posterior.p_up, 6),
            "p_down": round(posterior.p_down, 6),
            "log_odds": round(posterior.log_odds, 6),
            "strength": round(posterior.strength, 6),
            "confidence": round(confidence, 6),
            "contradiction": round(contradiction, 6),
            "n_eff": round(posterior.n_eff_total, 4),
            "data_quality": round(data_quality, 6),
            "regime": posterior.regime,
        },
        "pipeline_mode": "v3",
        "explainability": {
            "top_positive_clusters": top_positive_clusters,
            "top_negative_clusters": top_negative_clusters,
            "suppression_reasons": suppression_reasons,
            "risk_adjustments": [],
        },
    }

    # Derive evidence rankings from weighted signals
    supporting_ids: list[str] = []
    opposing_ids: list[str] = []
    for ws in weighted_signal_intermediates:
        if ws.sentiment_value > 0:
            supporting_ids.append(ws.document_id)
        elif ws.sentiment_value < 0:
            opposing_ids.append(ws.document_id)
    supporting_ids = supporting_ids[:MAX_EVIDENCE_REFS]
    opposing_ids = opposing_ids[:MAX_EVIDENCE_REFS]

    # Extract catalysts and risks from impacts
    catalysts, risks = extract_catalysts_and_risks(
        impacts, weighted_signal_intermediates,
    )

    summary = TrendSummary(
        entity_type="company",
        entity_id=ticker,
        window=TrendWindow(window),
        trend_direction=trend_direction,
        trend_strength=round(posterior.strength, 4),
        confidence=round(confidence, 4),
        top_supporting_evidence=supporting_ids,
        top_opposing_evidence=opposing_ids,
        dominant_catalysts=catalysts,
        material_risks=risks,
        contradiction_score=round(contradiction, 4),
        disagreement_details=[],
        generated_at=reference_time,
        p_bull=round(posterior.p_up, 6),
        regime=posterior.regime,
        pipeline_mode="v3",
    )
    # Assign market_context as dict after creation to avoid MarketContext coercion
    summary.market_context = v3_metadata  # type: ignore[assignment]

    # Persist trend summary and get trend_id
    await persist_trend_summary(pool, summary)

    logger.info(
        "v3 pipeline: %s/%s → p_up=%.4f direction=%s strength=%.3f "
        "confidence=%.3f contradiction=%.3f data_quality=%.3f "
        "n_eff=%.2f regime=%s clusters=%d units=%d",
        ticker, window, posterior.p_up, posterior.direction,
        posterior.strength, confidence, contradiction, data_quality,
        posterior.n_eff_total, posterior.regime, len(clusters), len(units),
    )

    return summary


def _build_empty_v3_summary(
    ticker: str, window: str, reference_time: datetime
) -> TrendSummary:
    """Build a neutral TrendSummary when no evidence is available for v3 pipeline."""
    v3_metadata: dict[str, Any] = {
        "v3_posterior": {
            "p_up": 0.5,
            "p_down": 0.5,
            "log_odds": 0.0,
            "strength": 0.0,
            "confidence": 0.0,
            "contradiction": 0.0,
            "n_eff": 0.0,
            "data_quality": 0.0,
            "regime": "uncertainty",
        },
        "pipeline_mode": "v3",
        "explainability": {
            "top_positive_clusters": [],
            "top_negative_clusters": [],
            "suppression_reasons": ["no_evidence"],
            "risk_adjustments": [],
        },
    }
    summary = TrendSummary(
        entity_type="company",
        entity_id=ticker,
        window=TrendWindow(window),
        trend_direction=TrendDirection.NEUTRAL,
        trend_strength=0.0,
        confidence=0.0,
        top_supporting_evidence=[],
        top_opposing_evidence=[],
        dominant_catalysts=[],
        material_risks=[],
        contradiction_score=0.0,
        disagreement_details=[],
        generated_at=reference_time,
        p_bull=0.5,
        regime="uncertainty",
        pipeline_mode="v3",
    )
    # Assign dict directly to avoid MarketContext coercion
    summary.market_context = v3_metadata  # type: ignore[assignment]
    return summary


# ---------------------------------------------------------------------------
# v3 Regime Classification Helper
# ---------------------------------------------------------------------------


async def _classify_regime_v3(
    pool: asyncpg.Pool,
    ticker: str,
) -> Any:
    """Classify market regime for v3 pipeline using ATR-normalized indicators.

    Fetches closing prices and daily returns from market_data_daily. Computes
    ATR_20 approximation from absolute daily returns (true range proxy when
    high/low data is unavailable).

    Falls back to default uncertainty regime on any failure.
    """
    from services.aggregation.regime import (
        _DEFAULT_V3_UNCERTAINTY,
        classify_regime_v3,
    )

    try:
        price_rows = await pool.fetch(_CLOSING_PRICES_QUERY, ticker)
        if not price_rows or len(price_rows) < 100:
            return _DEFAULT_V3_UNCERTAINTY

        # Prices come in DESC order; reverse to chronological
        closing_prices = [
            float(r["close"]) for r in reversed(price_rows)
            if r["close"] is not None
        ]

        if len(closing_prices) < 100:
            return _DEFAULT_V3_UNCERTAINTY

        return_rows = await pool.fetch(_DAILY_RETURNS_QUERY, ticker)
        daily_returns = [
            float(r["daily_return"])
            for r in reversed(return_rows)
            if r["daily_return"] is not None
        ]

        if len(daily_returns) < 100:
            return _DEFAULT_V3_UNCERTAINTY

        # Approximate ATR_20 from absolute daily returns × latest close
        # (True Range ≈ |daily_return| × close when high/low unavailable)
        recent_abs_returns = [abs(r) for r in daily_returns[-20:]]
        avg_abs_return = sum(recent_abs_returns) / len(recent_abs_returns) if recent_abs_returns else 0.0
        latest_close = closing_prices[-1]
        atr_20 = avg_abs_return * latest_close

        if atr_20 <= 0.0:
            return _DEFAULT_V3_UNCERTAINTY

        return classify_regime_v3(closing_prices, daily_returns, atr_20)

    except Exception:
        logger.warning(
            "v3: Failed to classify regime for %s — defaulting to uncertainty",
            ticker,
            exc_info=True,
        )
        return _DEFAULT_V3_UNCERTAINTY


# ---------------------------------------------------------------------------
# v3 Correlation-Aware Clustering
#
# Groups EvidenceUnits into clusters by their pre-computed cluster_id.
# The cluster_id encodes (symbol, horizon, event_type, source_group,
# time_bucket) — computed during normalization (task 1.1).
#
# Time-bucket resolution per horizon (already baked into cluster_id):
#   intraday → 1h, 1d → 4h, 7d → 24h, 30d → 72h, 90d → 168h
#
# Requirements: 4.1
# ---------------------------------------------------------------------------


@dataclass
class EvidenceCluster:
    """A group of correlated signals sharing the same cluster key."""

    cluster_id: str
    units: list[EvidenceUnit] = field(default_factory=list)
    llrs: list[float] = field(default_factory=list)
    n_eff: float = 0.0  # computed later by compute_n_eff
    cluster_llr: float = 0.0  # computed later by compute_cluster_llr


def cluster_evidence(
    units: list[EvidenceUnit], llrs: list[float]
) -> list[EvidenceCluster]:
    """Group EvidenceUnits into clusters by their pre-computed cluster_id.

    Each EvidenceUnit carries a cluster_id computed during normalization from
    the grouping key (symbol, horizon, event_type, source_group, time_bucket).
    Units with the same cluster_id are grouped into one EvidenceCluster.

    The llrs list must be parallel to units (one LLR per unit, same order).

    Requirements: 4.1
    """
    groups: dict[str, tuple[list[EvidenceUnit], list[float]]] = defaultdict(
        lambda: ([], [])
    )

    for unit, llr in zip(units, llrs):
        group_units, group_llrs = groups[unit.cluster_id]
        group_units.append(unit)
        group_llrs.append(llr)

    return [
        EvidenceCluster(
            cluster_id=cid,
            units=group_units,
            llrs=group_llrs,
        )
        for cid, (group_units, group_llrs) in groups.items()
    ]


# ---------------------------------------------------------------------------
# v3 Effective Evidence Count (n_eff)
#
# Computes the effective number of independent observations in a cluster
# using pairwise correlations to discount redundant signals.
#
# Default pairwise correlations for signals within a cluster:
#   same_wire (same source_group & event)  → 0.80
#   same_event_diff_publisher              → 0.50
#   same_theme_diff_event                  → 0.25
#   independent                            → 0.00
#
# Since signals in the same cluster share (symbol, horizon, event_type,
# source_group, time_bucket), the default intra-cluster rho is 0.80.
#
# Requirements: 4.2, 4.3
# ---------------------------------------------------------------------------

# Default pairwise correlations for signals within a cluster
_V3_DEFAULT_CLUSTER_CORRELATIONS: dict[str, float] = {
    "same_wire": 0.80,
    "same_event_diff_publisher": 0.50,
    "same_theme_diff_event": 0.25,
    "independent": 0.00,
}
_V3_DEFAULT_INTRA_CLUSTER_RHO: float = 0.80  # same source_group & event


def compute_n_eff(
    llrs: list[float], correlations: list[list[float]] | None = None
) -> float:
    """Compute effective evidence count for a cluster.

    n_eff = (sum w_i)² / (sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j)

    Where w_i = |LLR_i| and rho_ij are pairwise correlations.

    If no correlation matrix provided, uses default rho=0.80 for all pairs
    (same wire/source group within a cluster).

    Guards: Returns 1.0 on division by zero.

    Requirements: 4.2, 4.3
    """
    n = len(llrs)
    if n == 0:
        return 1.0
    if n == 1:
        return 1.0

    # Weights are absolute values of LLRs
    weights = [abs(llr) for llr in llrs]

    # Numerator: (sum w_i)²
    sum_w = sum(weights)
    numerator = sum_w * sum_w

    # Denominator: sum w_i² + 2 × sum_{i<j} rho_ij × w_i × w_j
    sum_w_sq = sum(w * w for w in weights)

    cross_term = 0.0
    for i in range(n):
        for j in range(i + 1, n):
            if correlations is not None:
                rho = correlations[i][j]
            else:
                rho = _V3_DEFAULT_INTRA_CLUSTER_RHO
            cross_term += rho * weights[i] * weights[j]

    denominator = sum_w_sq + 2.0 * cross_term

    # Guard against division by zero
    if denominator <= 0.0:
        return 1.0

    n_eff = numerator / denominator
    return n_eff


# ---------------------------------------------------------------------------
# v3 Cluster LLR Computation
#
# Combines individual LLRs within a cluster into a single cluster-level
# log-likelihood ratio using a magnitude-weighted mean scaled by sqrt(n_eff).
#
# weighted_mean(LLR_i, |LLR_i|) = sum(LLR_i × |LLR_i|) / sum(|LLR_i|)
#
# This weights toward higher-magnitude signals so that strong evidence
# dominates over noise within the cluster.
#
# Requirements: 4.4, 4.5
# ---------------------------------------------------------------------------

_V3_CLUSTER_LLR_CLAMP = 2.5


def compute_cluster_llr(llrs: list[float], n_eff: float) -> float:
    """Compute the cluster-level log-likelihood ratio.

    LLR_c = clamp(weighted_mean(LLR_i, |LLR_i|) × sqrt(n_eff), -2.5, 2.5)

    weighted_mean uses |LLR_i| as weights to weight toward stronger signals.

    Special cases:
    - Single signal: LLR_c = clamp(LLR_i, -2.5, 2.5)
    - All-zero LLRs: cluster_llr = 0.0

    Requirements: 4.4, 4.5
    """
    if not llrs:
        return 0.0

    # Single-signal cluster: just clamp it
    if len(llrs) == 1:
        return max(-_V3_CLUSTER_LLR_CLAMP, min(_V3_CLUSTER_LLR_CLAMP, llrs[0]))

    # Check for all-zero LLRs
    abs_sum = sum(abs(llr) for llr in llrs)
    if abs_sum == 0.0:
        return 0.0

    # weighted_mean(LLR_i, |LLR_i|) = sum(LLR_i × |LLR_i|) / sum(|LLR_i|)
    weighted_sum = sum(llr * abs(llr) for llr in llrs)
    weighted_mean = weighted_sum / abs_sum

    # Scale by sqrt(n_eff) and clamp
    raw = weighted_mean * math.sqrt(n_eff)
    return max(-_V3_CLUSTER_LLR_CLAMP, min(_V3_CLUSTER_LLR_CLAMP, raw))


# ---------------------------------------------------------------------------
# v3 Multiplicative Confidence
#
# Computes a single confidence score by multiplying independent quality
# dimensions together. Each dimension can suppress the final score if it
# is weak — the multiplicative structure ensures that *any* weak link
# pulls confidence down.
#
# confidence = clamp(
#     C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05))
#     × regime_confidence_mult × (1 - contradiction) × data_quality,
#     0, 1
# )
#
# Requirements: 8.1, 8.2, 8.3, 8.4, 8.5
# ---------------------------------------------------------------------------


def compute_v3_confidence(
    n_eff_total: float,
    q_values: list[float],
    llrs: list[float],
    strength: float,
    regime_confidence_mult: float,
    contradiction: float,
    data_quality: float,
) -> float:
    """Compute multiplicative confidence for v3 pipeline.

    confidence = clamp(
        C_evidence × sqrt(C_quality) × sqrt(max(strength, 0.05))
        × regime_confidence_mult × (1 - contradiction) × data_quality,
        0, 1
    )

    C_evidence = 1 - exp(-n_eff_total / 5.0)
    C_quality = weighted_mean(q_i, weight=|LLR_i|)
             = sum(q_i × |LLR_i|) / sum(|LLR_i|)
             → 0.0 if sum(|LLR_i|) == 0

    Requirements: 8.1, 8.2, 8.3, 8.4, 8.5
    """
    # C_evidence: saturating evidence mass factor
    c_evidence = 1.0 - math.exp(-n_eff_total / 5.0)

    # C_quality: LLR-magnitude-weighted mean of quality scores
    abs_llrs = [abs(llr) for llr in llrs]
    sum_abs_llrs = sum(abs_llrs)
    if sum_abs_llrs == 0.0:
        c_quality = 0.0
    else:
        c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs

    # Multiplicative confidence formula
    confidence = (
        c_evidence
        * math.sqrt(c_quality)
        * math.sqrt(max(strength, 0.05))
        * regime_confidence_mult
        * (1.0 - contradiction)
        * data_quality
    )

    # Clamp to [0, 1]
    return max(0.0, min(1.0, confidence))


# ---------------------------------------------------------------------------
# v3 Data Quality Score
#
# Multiplicative fail-closed quality score measuring the overall quality
# of the evidence base. Low data quality suppresses confidence and can
# force informational mode.
#
# data_quality = clamp(
#     Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity,
#     0, 1
# )
#
# Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6
# ---------------------------------------------------------------------------


def compute_v3_data_quality(
    units: list[EvidenceUnit],
    extraction_failure_rate: float,
    age_newest_hours: float,
    n_source_types: int,
) -> float:
    """Compute multiplicative fail-closed data quality score.

    data_quality = clamp(Q_parse × sqrt(Q_conf) × Q_fresh × Q_coverage × Q_diversity, 0, 1)

    Components:
        Q_parse = 1 - extraction_failure_rate
        Q_conf = weighted_mean(extraction_conf_i, weight=impact_i)
               → 0.5 if sum(impact_i) == 0 (neutral default)
        Q_fresh = exp(-age_newest_hours / 168)
        Q_coverage = 1 - exp(-N_valid / 5)
        Q_diversity = min(1, log2(1 + N_source_types) / log2(4))

    Requirements: 17.1, 17.2, 17.3, 17.4, 17.5, 17.6
    """
    # Q_parse: parse success rate
    q_parse = 1.0 - extraction_failure_rate

    # Q_conf: impact-weighted mean of extraction confidence
    sum_impact = sum(u.impact for u in units)
    if sum_impact == 0.0:
        q_conf = 0.5  # neutral default
    else:
        q_conf = sum(u.extraction_conf * u.impact for u in units) / sum_impact

    # Q_fresh: exponential freshness decay (half-life ~116h at 168 denominator)
    q_fresh = math.exp(-age_newest_hours / 168.0)

    # Q_coverage: saturating coverage factor based on valid evidence count
    n_valid = len(units)
    q_coverage = 1.0 - math.exp(-n_valid / 5.0)

    # Q_diversity: source type diversity (saturates at 4 source types)
    if n_source_types <= 0:
        q_diversity = 0.0
    else:
        q_diversity = min(1.0, math.log2(1.0 + n_source_types) / math.log2(4.0))

    # Multiplicative combination
    data_quality = q_parse * math.sqrt(q_conf) * q_fresh * q_coverage * q_diversity

    # Clamp to [0, 1]
    return max(0.0, min(1.0, data_quality))


# ---------------------------------------------------------------------------
# v3 Informational Mode Forcing
#
# Determines whether a recommendation should be forced to informational
# mode based on data quality thresholds and evidence composition.
#
# Requirements: 17.7, 17.8
# ---------------------------------------------------------------------------

# Layer classification constants for macro/competitive-only detection
_COMPANY_LAYER = "company"
_MACRO_LAYER = "macro"
_COMPETITIVE_LAYER = "competitive"


def should_force_informational_v3(
    data_quality: float,
    units: list[EvidenceUnit],
    extraction_failure_rate: float,
    macro_only_enabled: bool = False,
) -> tuple[bool, str]:
    """Check if recommendation should be forced to informational mode.

    Returns (should_force, reason) tuple.

    Forcing conditions:
    1. data_quality < 0.50
    2. N_valid < 2 (fewer than 2 valid evidence units)
    3. Q_parse < 0.50 (extraction_failure_rate > 0.50)
    4. Only macro/competitive evidence present (no company signals)
       unless macro_only_enabled is True

    Requirements: 17.7, 17.8
    """
    n_valid = len(units)

    # Condition 1: Low overall data quality
    if data_quality < 0.50:
        return (True, "data_quality_below_threshold")

    # Condition 2: Insufficient evidence volume
    if n_valid < 2:
        return (True, "insufficient_evidence_count")

    # Condition 3: High extraction failure rate (Q_parse < 0.50)
    q_parse = 1.0 - extraction_failure_rate
    if q_parse < 0.50:
        return (True, "extraction_parse_rate_below_threshold")

    # Condition 4: Only macro/competitive evidence (no company layer)
    if not macro_only_enabled:
        has_company = any(u.layer == _COMPANY_LAYER for u in units)
        if not has_company and n_valid > 0:
            return (True, "macro_competitive_only_evidence")

    return (False, "")


# ---------------------------------------------------------------------------
# Fetch competitive signals targeting a ticker within a time window
# ---------------------------------------------------------------------------

_COMPETITIVE_SIGNALS_QUERY = """
SELECT source_document_id, source_ticker, target_ticker, catalyst_type,
       pattern_confidence, signal_direction, signal_strength,
       relationship_strength, computed_at
FROM competitive_signal_records
WHERE target_ticker = $1
  AND computed_at >= $2
  AND computed_at <= $3
ORDER BY computed_at DESC
LIMIT 500
"""


async def fetch_competitive_signals(
    pool: asyncpg.Pool,
    ticker: str,
    window_start: datetime,
    window_end: datetime,
) -> list[CompetitiveSignalRecord]:
    """Fetch competitive signal records targeting a ticker in a time range."""
    rows = await pool.fetch(
        _COMPETITIVE_SIGNALS_QUERY, ticker, window_start, window_end,
    )
    return [
        CompetitiveSignalRecord(
            source_document_id=str(row["source_document_id"]),
            source_ticker=row["source_ticker"],
            target_ticker=row["target_ticker"],
            catalyst_type=row["catalyst_type"],
            pattern_confidence=float(row["pattern_confidence"]),
            signal_direction=row["signal_direction"],
            signal_strength=float(row["signal_strength"]),
            relationship_strength=float(row["relationship_strength"]),
            computed_at=row["computed_at"],
        )
        for row in rows
    ]


# ---------------------------------------------------------------------------
# Fetch macro impact records for a ticker within a time window
# ---------------------------------------------------------------------------

_MACRO_IMPACT_QUERY = """
SELECT
    mir.event_id,
    mir.company_id,
    mir.ticker,
    mir.macro_impact_score,
    mir.impact_direction,
    mir.contributing_factors,
    mir.confidence,
    mir.computed_at,
    ge.source_document_id,
    d.published_at AS event_published_at
FROM macro_impact_records mir
JOIN global_events ge ON ge.id = mir.event_id
JOIN documents d ON d.id = ge.source_document_id
WHERE mir.ticker = $1
  AND mir.computed_at >= $2
  AND mir.computed_at <= $3
ORDER BY mir.computed_at DESC
"""


@dataclass
class MacroImpactRow:
    """Parsed row from the macro impact query."""

    event_id: str
    company_id: str
    ticker: str
    macro_impact_score: float
    impact_direction: str
    contributing_factors: list[str]
    confidence: float
    computed_at: datetime
    source_document_id: str
    event_published_at: datetime


def _parse_macro_impact_row(row: Any) -> MacroImpactRow:
    """Convert an asyncpg Record to a MacroImpactRow."""
    factors = row["contributing_factors"]
    if isinstance(factors, str):
        factors = json.loads(factors)

    return MacroImpactRow(
        event_id=str(row["event_id"]),
        company_id=str(row["company_id"]),
        ticker=row["ticker"],
        macro_impact_score=float(row["macro_impact_score"] or 0.0),
        impact_direction=row["impact_direction"] or "neutral",
        contributing_factors=factors if isinstance(factors, list) else [],
        confidence=float(row["confidence"] or 0.5),
        computed_at=row["computed_at"],
        source_document_id=str(row["source_document_id"]),
        event_published_at=row["event_published_at"],
    )


async def fetch_macro_impact_records(
    pool: asyncpg.Pool,
    ticker: str,
    window_start: datetime,
    window_end: datetime,
) -> list[MacroImpactRow]:
    """Fetch macro impact records for a ticker in a time range."""
    rows = await pool.fetch(_MACRO_IMPACT_QUERY, ticker, window_start, window_end)
    return [_parse_macro_impact_row(r) for r in rows]


# ---------------------------------------------------------------------------
# Convert macro impact records to WeightedSignals
# ---------------------------------------------------------------------------

_DIRECTION_TO_SENTIMENT: dict[str, float] = {
    "positive": 1.0,
    "negative": -1.0,
    "mixed": 0.0,
    "neutral": 0.0,
}


def build_macro_weighted_signals(
    macro_impacts: list[MacroImpactRow],
    reference_time: datetime,
    window: str,
    macro_signal_weight: float = 0.3,
    config: ScoringConfig | None = None,
    *,
    returns: list[float] | None = None,
    volumes: list[float] | None = None,
) -> list[WeightedSignal]:
    """Convert macro impact records into WeightedSignal objects.

    Uses the same scoring pipeline as company signals:
    - document_id = source_document_id (for evidence tracing)
    - sentiment_value mapped from impact_direction
    - impact_score = macro_impact_score * macro_signal_weight
    - recency decay from the global event's publication time
    - confidence gating from the macro record's confidence

    When ``config.probabilistic`` is True, passes returns/volumes for
    regime multiplier computation.
    """
    cfg = config or ScoringConfig()
    signals: list[WeightedSignal] = []
    for mir in macro_impacts:
        sw = compute_signal_weight(
            published_at=mir.event_published_at,
            reference_time=reference_time,
            window=window,
            source_credibility=mir.confidence,
            novelty_score=0.5,
            extraction_confidence=mir.confidence,
            config=cfg,
            returns=returns,
            volumes=volumes,
        )
        sentiment = _DIRECTION_TO_SENTIMENT.get(mir.impact_direction, 0.0)
        impact = mir.macro_impact_score * macro_signal_weight
        signals.append(
            WeightedSignal(
                document_id=mir.source_document_id,
                weight=sw,
                sentiment_value=sentiment,
                impact_score=impact,
            )
        )
    return signals


# ---------------------------------------------------------------------------
# Build weighted signals from impact records
# ---------------------------------------------------------------------------


def build_weighted_signals(
    impacts: list[ImpactRow],
    reference_time: datetime,
    window: str,
    market_ctx: Any | None = None,
    config: ScoringConfig | None = None,
    *,
    source_accuracy_map: dict[str, float] | None = None,
    returns: list[float] | None = None,
    volumes: list[float] | None = None,
) -> list[WeightedSignal]:
    """Convert impact records into WeightedSignal objects using the scoring module.

    When ``config.probabilistic`` is True, passes source accuracy factors,
    event types, and market data (returns/volumes) to the scoring pipeline
    for regime multiplier and adaptive decay computation.
    """
    cfg = config or ScoringConfig()
    accuracy_map = source_accuracy_map or {}
    signals: list[WeightedSignal] = []
    for imp in impacts:
        # Look up source accuracy factor for this document's source
        saf = accuracy_map.get(imp.document_id, 1.0)

        sw = compute_signal_weight(
            published_at=imp.published_at,
            reference_time=reference_time,
            window=window,
            source_credibility=imp.source_credibility,
            novelty_score=imp.novelty_score,
            extraction_confidence=imp.confidence,
            market_ctx=market_ctx,
            config=cfg,
            event_type=imp.catalyst_type if cfg.probabilistic else None,
            impact_score=imp.impact_score,
            source_accuracy_factor=saf,
            returns=returns,
            volumes=volumes,
        )
        signals.append(
            WeightedSignal(
                document_id=imp.document_id,
                weight=sw,
                sentiment_value=sentiment_to_numeric(imp.sentiment),
                impact_score=imp.impact_score,
                info_gain_factor=sw.info_gain_factor,
                source_accuracy_factor=sw.source_accuracy_factor,
            )
        )
    return signals


# ---------------------------------------------------------------------------
# Derive trend direction from weighted sentiment
# ---------------------------------------------------------------------------

# Thresholds for mapping numeric sentiment to direction.
BULLISH_THRESHOLD = 0.15
BEARISH_THRESHOLD = -0.15
MIXED_THRESHOLD = 0.10  # contradiction score above this → mixed


def derive_trend_direction(
    avg_sentiment: float,
    contradiction_score: float = 0.0,
) -> TrendDirection:
    """Map a weighted average sentiment to a TrendDirection.

    If contradiction is high, the direction is MIXED regardless of
    the average sentiment value.
    """
    if contradiction_score > MIXED_THRESHOLD and abs(avg_sentiment) < 0.3:
        return TrendDirection.MIXED
    if avg_sentiment >= BULLISH_THRESHOLD:
        return TrendDirection.BULLISH
    if avg_sentiment <= BEARISH_THRESHOLD:
        return TrendDirection.BEARISH
    return TrendDirection.NEUTRAL


# ---------------------------------------------------------------------------
# Compute contradiction score
# ---------------------------------------------------------------------------


def compute_contradiction_score(signals: list[WeightedSignal]) -> float:
    """Measure how much disagreement exists among weighted signals.

    Returns a value in [0, 1] where 0 means full agreement and 1 means
    equal-weight positive and negative signals.

    The formula computes the ratio of the minority-side total weight to
    the majority-side total weight.
    """
    if not signals:
        return 0.0

    pos_weight = 0.0
    neg_weight = 0.0
    for sig in signals:
        w = sig.weight.combined * sig.impact_score
        if sig.sentiment_value > 0:
            pos_weight += w
        elif sig.sentiment_value < 0:
            neg_weight += w

    total = pos_weight + neg_weight
    if total == 0.0:
        return 0.0

    minority = min(pos_weight, neg_weight)
    return round(minority / total, 4)


# ---------------------------------------------------------------------------
# Rank evidence (supporting vs opposing)
# ---------------------------------------------------------------------------


def rank_evidence(
    signals: list[WeightedSignal],
    max_refs: int = MAX_EVIDENCE_REFS,
) -> tuple[list[str], list[str]]:
    """Return top supporting and opposing document IDs ranked by composite score.

    Delegates to the evidence ranking module which considers multiple
    factors (weight, impact, recency, confidence) rather than raw weight alone.

    Supporting = positive sentiment, Opposing = negative sentiment.
    Neutral/mixed signals are excluded from evidence lists.
    """
    config = EvidenceRankConfig(max_refs=max_refs)
    return _rank_evidence_composite(signals, config)


# ---------------------------------------------------------------------------
# Extract dominant catalysts and material risks
# ---------------------------------------------------------------------------


def extract_catalysts_and_risks(
    impacts: list[ImpactRow],
    signals: list[WeightedSignal],
) -> tuple[list[str], list[str]]:
    """Return dominant catalyst types and material risks weighted by signal strength.

    Catalysts are ranked by cumulative weight. Risks are deduplicated and
    ordered by the weight of the signal that surfaced them.
    """
    catalyst_weights: dict[str, float] = {}
    risk_entries: list[tuple[float, str]] = []

    # Build a lookup from document_id to combined weight
    weight_by_doc = {s.document_id: s.weight.combined * s.impact_score for s in signals}

    for imp in impacts:
        w = weight_by_doc.get(imp.document_id, 0.0)
        if w <= 0.0:
            continue
        catalyst_weights[imp.catalyst_type] = catalyst_weights.get(imp.catalyst_type, 0.0) + w
        for risk in imp.risks:
            risk_entries.append((w, risk))

    # Top catalysts by cumulative weight
    sorted_catalysts = sorted(catalyst_weights.items(), key=lambda x: x[1], reverse=True)
    catalysts = [cat for cat, _ in sorted_catalysts[:5]]

    # Deduplicated risks ordered by weight
    seen_risks: set[str] = set()
    risks: list[str] = []
    risk_entries.sort(key=lambda x: x[0], reverse=True)
    for _, risk_text in risk_entries:
        normalized = risk_text.strip().lower()
        if normalized not in seen_risks:
            seen_risks.add(normalized)
            risks.append(risk_text.strip())
        if len(risks) >= 5:
            break

    return catalysts, risks



# ---------------------------------------------------------------------------
# Compute trend confidence
# ---------------------------------------------------------------------------


def compute_trend_confidence(
    signals: list[WeightedSignal],
    contradiction_score: float,
) -> float:
    """Derive an overall confidence for the trend summary.

    Confidence is based on:
    - Number of UNIQUE source documents (not raw signal count)
    - Average extraction confidence of contributing signals
    - Signal agreement (what fraction point the same direction),
      dampened by sample size so that 1-2 signals agreeing doesn't
      inflate confidence the same way 10+ signals agreeing does
    - Contradiction penalty (high contradiction lowers confidence)

    Returns a value in [0, 1].
    """
    if not signals:
        return 0.0

    active = [s for s in signals if s.weight.combined > 0]
    if not active:
        return 0.0

    # Count unique source documents — competitive signals from the same doc
    # shouldn't inflate confidence
    unique_sources = len({s.document_id for s in active if s.document_id})
    count_factor = min(unique_sources / 15.0, 0.8)  # Cap at 0.8, needs 15 unique docs

    # Average extraction confidence
    avg_conf = sum(s.weight.credibility for s in active) / len(active)

    # Signal agreement: what fraction of signals agree on direction
    bullish = sum(1 for s in active if s.sentiment_value > 0)
    bearish = sum(1 for s in active if s.sentiment_value < 0)
    total = bullish + bearish
    if total > 0:
        agreement = max(bullish, bearish) / total
    else:
        agreement = 0.5

    # Dampen agreement by sample size: 1-2 signals agreeing is far less
    # meaningful than 7+ signals agreeing.  Uses log2(n+1)/log2(8) so the
    # dampener saturates at 1.0 around n=7 unique sources.
    agreement_dampener = min(1.0, math.log2(unique_sources + 1) / math.log2(8))
    agreement *= agreement_dampener

    # Contradiction penalty
    contradiction_penalty = contradiction_score * 0.4

    confidence = (0.3 * count_factor + 0.3 * avg_conf + 0.4 * agreement) - contradiction_penalty
    return round(max(0.0, min(1.0, confidence)), 4)


# ---------------------------------------------------------------------------
# Assemble a TrendSummary from components
# ---------------------------------------------------------------------------


@dataclass
class AssembledTrend:
    """A trend summary paired with its detailed evidence rankings."""

    summary: TrendSummary
    supporting_evidence: list[RankedEvidence]
    opposing_evidence: list[RankedEvidence]


def assemble_trend_summary(
    ticker: str,
    window: str,
    signals: list[WeightedSignal],
    impacts: list[ImpactRow],
    market_ctx: Any | None = None,
    max_evidence: int = MAX_EVIDENCE_REFS,
    reference_time: datetime | None = None,
    *,
    probabilistic: bool = False,
    regime: RegimeClassification | None = None,
) -> TrendSummary:
    """Build a complete TrendSummary from weighted signals and impact records."""
    result = assemble_trend_with_evidence(
        ticker, window, signals, impacts, market_ctx, max_evidence, reference_time,
        probabilistic=probabilistic,
        regime=regime,
    )
    return result.summary


def assemble_trend_with_evidence(
    ticker: str,
    window: str,
    signals: list[WeightedSignal],
    impacts: list[ImpactRow],
    market_ctx: Any | None = None,
    max_evidence: int = MAX_EVIDENCE_REFS,
    reference_time: datetime | None = None,
    *,
    probabilistic: bool = False,
    regime: RegimeClassification | None = None,
) -> AssembledTrend:
    """Build a TrendSummary and return detailed evidence rankings for persistence.

    When ``probabilistic`` is True:
    - Computes Bayesian posterior from merged signals
    - Uses Bayesian confidence formula for trend confidence
    - Uses entropy-based direction classification
    - Applies regime-adjusted thresholds
    - Populates probabilistic TrendSummary fields
    - Stores probabilistic outputs in market_context JSONB

    When ``probabilistic`` is False:
    - Preserves exact current heuristic behavior (no changes)

    Requirements: 1.1, 1.2, 8.1–8.5, 9.1–9.6, 7.8, 16.4, 16.5
    """
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)

    avg_sentiment = weighted_sentiment_average(signals)

    # Run full contradiction detection (Requirement 6.4)
    catalyst_entries = [
        CatalystEntry(document_id=imp.document_id, catalyst_type=imp.catalyst_type)
        for imp in impacts
    ]
    contradiction_result = detect_contradictions(
        signals, catalyst_entries, probabilistic=probabilistic,
    )
    contradiction = contradiction_result.score

    if not probabilistic:
        # --- Heuristic mode: preserve exact current behavior ---
        direction = derive_trend_direction(avg_sentiment, contradiction)
        confidence = compute_trend_confidence(signals, contradiction)

        # Get detailed evidence rankings for persistence
        ev_config = EvidenceRankConfig(max_refs=max_evidence)
        supporting_ranked, opposing_ranked = rank_evidence_detailed(signals, ev_config)

        supporting = list(dict.fromkeys(r.document_id for r in supporting_ranked))
        opposing = list(dict.fromkeys(r.document_id for r in opposing_ranked))

        catalysts, risks = extract_catalysts_and_risks(impacts, signals)

        # Trend strength: absolute value of weighted sentiment, clamped to [0, 1]
        strength = round(min(abs(avg_sentiment), 1.0), 4)

        summary = TrendSummary(
            entity_type="company",
            entity_id=ticker,
            window=TrendWindow(window),
            trend_direction=direction,
            trend_strength=strength,
            confidence=confidence,
            top_supporting_evidence=supporting,
            top_opposing_evidence=opposing,
            dominant_catalysts=catalysts,
            material_risks=risks,
            contradiction_score=contradiction,
            disagreement_details=contradiction_result.details,
            market_context=market_ctx,
            generated_at=reference_time,
        )

        return AssembledTrend(
            summary=summary,
            supporting_evidence=supporting_ranked,
            opposing_evidence=opposing_ranked,
        )

    # --- Probabilistic mode (Req 8.1–8.5, 9.1–9.6) ---

    # Default to uncertainty regime when not provided (Req 7.9)
    if regime is None:
        regime = RegimeClassification(
            regime=MarketRegime.UNCERTAINTY,
            trend_indicator=0.0,
            volatility_ratio=1.0,
            bullish_threshold=0.15,
            bearish_threshold=-0.15,
            contradiction_penalty_multiplier=0.6,
        )

    # Compute Bayesian posterior from merged signals (Req 1.1, 1.2)
    posterior: BayesianPosterior = compute_bayesian_posterior(signals)

    # --- Bayesian confidence formula (Req 8.1–8.4) ---
    # confidence = 0.5 × C_bayesian + 0.25 × F_count + 0.25 × C_avg_credibility - P_contradiction
    active = [s for s in signals if s.weight.combined > 0]
    unique_sources = len({s.document_id for s in active if s.document_id}) if active else 0
    f_count = min(unique_sources / 15.0, 0.8)

    avg_credibility = (
        sum(s.weight.credibility for s in active) / len(active) if active else 0.0
    )

    # Contradiction penalty uses regime-adjusted multiplier (Req 7.7)
    contradiction_penalty = contradiction * regime.contradiction_penalty_multiplier

    confidence = (
        0.5 * posterior.bayesian_confidence
        + 0.25 * f_count
        + 0.25 * avg_credibility
        - contradiction_penalty
    )
    confidence = round(max(0.0, min(1.0, confidence)), 4)

    # --- Entropy-based direction (Req 9.1–9.5) ---
    # Fixed P_bull thresholds for direction: 0.65 / 0.35
    if posterior.entropy > 0.9:
        direction = TrendDirection.MIXED
    elif posterior.p_bull > 0.65:
        direction = TrendDirection.BULLISH
    elif posterior.p_bull < 0.35:
        direction = TrendDirection.BEARISH
    else:
        direction = TrendDirection.NEUTRAL

    # Get detailed evidence rankings for persistence
    ev_config = EvidenceRankConfig(max_refs=max_evidence)
    supporting_ranked, opposing_ranked = rank_evidence_detailed(signals, ev_config)

    supporting = list(dict.fromkeys(r.document_id for r in supporting_ranked))
    opposing = list(dict.fromkeys(r.document_id for r in opposing_ranked))

    catalysts, risks = extract_catalysts_and_risks(impacts, signals)

    # Trend strength: absolute value of weighted sentiment, clamped to [0, 1]
    strength = round(min(abs(avg_sentiment), 1.0), 4)

    # Build probabilistic JSONB data for market_context storage
    probabilistic_data = {
        "p_bull": round(posterior.p_bull, 6),
        "alpha": round(posterior.alpha, 4),
        "beta": round(posterior.beta, 4),
        "log_likelihood": round(posterior.log_likelihood, 6),
        "bayesian_confidence": round(posterior.bayesian_confidence, 6),
        "entropy": round(posterior.entropy, 6),
        "regime": regime.regime.value,
        "regime_volatility_ratio": round(regime.volatility_ratio, 4),
        "pipeline_mode": "probabilistic",
        "contradiction_entropy": round(contradiction, 4),
    }

    # Enrich market_context with probabilistic outputs
    if market_ctx is not None and hasattr(market_ctx, "model_dump"):
        enriched_ctx_data = market_ctx.model_dump()
        enriched_ctx_data["probabilistic"] = probabilistic_data
        enriched_market_ctx = enriched_ctx_data
    elif isinstance(market_ctx, dict):
        enriched_market_ctx = {**market_ctx, "probabilistic": probabilistic_data}
    else:
        enriched_market_ctx = {"probabilistic": probabilistic_data}

    summary = TrendSummary(
        entity_type="company",
        entity_id=ticker,
        window=TrendWindow(window),
        trend_direction=direction,
        trend_strength=strength,
        confidence=confidence,
        top_supporting_evidence=supporting,
        top_opposing_evidence=opposing,
        dominant_catalysts=catalysts,
        material_risks=risks,
        contradiction_score=contradiction,
        disagreement_details=contradiction_result.details,
        market_context=enriched_market_ctx,
        generated_at=reference_time,
        # Probabilistic fields (Req 9.6, 16.1)
        p_bull=round(posterior.p_bull, 6),
        alpha=round(posterior.alpha, 4),
        beta_param=round(posterior.beta, 4),
        bayesian_confidence=round(posterior.bayesian_confidence, 6),
        entropy=round(posterior.entropy, 6),
        regime=regime.regime.value,
        pipeline_mode="probabilistic",
    )

    return AssembledTrend(
        summary=summary,
        supporting_evidence=supporting_ranked,
        opposing_evidence=opposing_ranked,
    )


# ---------------------------------------------------------------------------
# Persist trend summary to PostgreSQL
# ---------------------------------------------------------------------------

_UPSERT_TREND = """
INSERT INTO trend_windows (
    entity_type, entity_id, "window", trend_direction, trend_strength,
    confidence, top_supporting_evidence, top_opposing_evidence,
    dominant_catalysts, material_risks, contradiction_score,
    disagreement_details, market_context, generated_at
) VALUES (
    $1, $2, $3, $4, $5,
    $6, $7::jsonb, $8::jsonb,
    $9::jsonb, $10::jsonb, $11,
    $12::jsonb, $13::jsonb, $14
)
ON CONFLICT (entity_type, entity_id, "window") DO UPDATE SET
    trend_direction = EXCLUDED.trend_direction,
    trend_strength = EXCLUDED.trend_strength,
    confidence = EXCLUDED.confidence,
    top_supporting_evidence = EXCLUDED.top_supporting_evidence,
    top_opposing_evidence = EXCLUDED.top_opposing_evidence,
    dominant_catalysts = EXCLUDED.dominant_catalysts,
    material_risks = EXCLUDED.material_risks,
    contradiction_score = EXCLUDED.contradiction_score,
    disagreement_details = EXCLUDED.disagreement_details,
    market_context = EXCLUDED.market_context,
    generated_at = EXCLUDED.generated_at
RETURNING id
"""


_INSERT_TREND_HISTORY = """
INSERT INTO trend_history (
    entity_type, entity_id, "window", trend_direction,
    trend_strength, confidence, contradiction_score,
    dominant_catalysts, material_risks, generated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10)
"""


async def persist_trend_summary(
    pool: asyncpg.Pool,
    summary: TrendSummary,
) -> str:
    """Insert a trend summary row and return its UUID.

    Also appends a snapshot to trend_history for time-series charting.
    """
    row = await pool.fetchrow(
        _UPSERT_TREND,
        summary.entity_type,
        summary.entity_id,
        summary.window.value,
        summary.trend_direction.value,
        summary.trend_strength,
        summary.confidence,
        json.dumps(summary.top_supporting_evidence),
        json.dumps(summary.top_opposing_evidence),
        json.dumps(summary.dominant_catalysts),
        json.dumps(summary.material_risks),
        summary.contradiction_score,
        json.dumps([d.model_dump() for d in summary.disagreement_details]),
        json.dumps(
            summary.market_context.model_dump()
            if hasattr(summary.market_context, "model_dump")
            else (summary.market_context if summary.market_context else {}),
            default=str,
        ),
        summary.generated_at,
    )
    trend_id = str(row["id"])

    # Append to trend_history for time-series charts
    try:
        await pool.execute(
            _INSERT_TREND_HISTORY,
            summary.entity_type,
            summary.entity_id,
            summary.window.value,
            summary.trend_direction.value,
            summary.trend_strength,
            summary.confidence,
            summary.contradiction_score,
            json.dumps(summary.dominant_catalysts),
            json.dumps(summary.material_risks),
            summary.generated_at,
        )
    except Exception:
        # Don't fail the main upsert if history insert fails (table may not exist yet)
        logger.debug("Could not insert trend history for %s/%s", summary.entity_id, summary.window.value, exc_info=True)

    return trend_id


# ---------------------------------------------------------------------------
# Persist evidence mappings to trend_evidence table
# ---------------------------------------------------------------------------

_INSERT_EVIDENCE = """
INSERT INTO trend_evidence (
    trend_window_id, document_id, evidence_type,
    rank_score, weight_component, impact_component,
    recency_component, confidence_component, sentiment_value
) VALUES (
    $1, $2::uuid, $3,
    $4, $5, $6,
    $7, $8, $9
)
"""


def _is_valid_uuid(val: str) -> bool:
    """Check if a string is a valid UUID (pattern signal IDs are not)."""
    try:
        _uuid.UUID(val)
        return True
    except (ValueError, AttributeError):
        return False


async def persist_trend_evidence(
    pool: asyncpg.Pool,
    trend_window_id: str,
    supporting: list[RankedEvidence],
    opposing: list[RankedEvidence],
) -> int:
    """Insert evidence mapping rows for a trend window. Returns count inserted.

    Deletes any existing evidence for this trend window first to prevent
    duplicate accumulation across aggregation cycles.
    """
    rows: list[tuple[str, str, str, float, float, float, float, float, float]] = []
    for ev in supporting:
        # Skip non-UUID document IDs (e.g. pattern signal synthetic IDs)
        if not _is_valid_uuid(ev.document_id):
            continue
        rows.append((
            trend_window_id, ev.document_id, "supporting",
            ev.rank_score, ev.weight_component, ev.impact_component,
            ev.recency_component, ev.confidence_component, ev.sentiment_value,
        ))
    for ev in opposing:
        if not _is_valid_uuid(ev.document_id):
            continue
        rows.append((
            trend_window_id, ev.document_id, "opposing",
            ev.rank_score, ev.weight_component, ev.impact_component,
            ev.recency_component, ev.confidence_component, ev.sentiment_value,
        ))

    # Clear stale evidence before inserting fresh rows
    await pool.execute(
        "DELETE FROM trend_evidence WHERE trend_window_id = $1",
        trend_window_id,
    )

    if not rows:
        return 0

    await pool.executemany(_INSERT_EVIDENCE, rows)
    return len(rows)


# ---------------------------------------------------------------------------
# Build MacroEventInfo objects for projection computation
# ---------------------------------------------------------------------------

_MACRO_EVENT_INFO_QUERY = """
SELECT
    mir.event_id,
    mir.macro_impact_score,
    mir.impact_direction,
    mir.confidence,
    ge.estimated_duration,
    ge.severity,
    d.published_at AS event_published_at
FROM macro_impact_records mir
JOIN global_events ge ON ge.id = mir.event_id
JOIN documents d ON d.id = ge.source_document_id
WHERE mir.ticker = $1
  AND mir.computed_at >= $2
  AND mir.computed_at <= $3
ORDER BY mir.computed_at DESC
"""


async def _build_macro_event_infos(
    pool: asyncpg.Pool,
    ticker: str,
    window_start: datetime,
    reference_time: datetime,
) -> list[MacroEventInfo]:
    """Fetch macro impact records and build MacroEventInfo objects for projection."""
    rows = await pool.fetch(
        _MACRO_EVENT_INFO_QUERY, ticker, window_start, reference_time,
    )
    infos: list[MacroEventInfo] = []
    for row in rows:
        published_at = row["event_published_at"]
        age_hours = 0.0
        if published_at:
            age_hours = max(
                (reference_time - published_at).total_seconds() / 3600.0, 0.0,
            )
        infos.append(
            MacroEventInfo(
                event_id=str(row["event_id"]),
                macro_impact_score=float(row["macro_impact_score"] or 0.0),
                impact_direction=row["impact_direction"] or "neutral",
                confidence=float(row["confidence"] or 0.5),
                estimated_duration=row["estimated_duration"] or "short_term",
                severity=row["severity"] or "low",
                event_age_hours=age_hours,
            )
        )
    return infos


# ---------------------------------------------------------------------------
# Regime detection helper (Req 7.1, 7.2, 7.3, 7.8, 7.9)
# ---------------------------------------------------------------------------

_CLOSING_PRICES_QUERY = """
SELECT close
FROM market_data_daily
WHERE ticker = $1
ORDER BY bar_date DESC
LIMIT 120
"""

_DAILY_RETURNS_QUERY = """
SELECT (close - LAG(close) OVER (ORDER BY bar_date)) / NULLIF(LAG(close) OVER (ORDER BY bar_date), 0) AS daily_return
FROM market_data_daily
WHERE ticker = $1
ORDER BY bar_date DESC
LIMIT 120
"""

_DAILY_VOLUMES_QUERY = """
SELECT volume
FROM market_data_daily
WHERE ticker = $1
ORDER BY bar_date DESC
LIMIT 30
"""

# Default uncertainty regime used when market data is unavailable
_DEFAULT_UNCERTAINTY_REGIME = RegimeClassification(
    regime=MarketRegime.UNCERTAINTY,
    trend_indicator=0.0,
    volatility_ratio=1.0,
    bullish_threshold=0.15,
    bearish_threshold=-0.15,
    contradiction_penalty_multiplier=0.6,
)


async def _classify_ticker_regime(
    pool: asyncpg.Pool,
    ticker: str,
) -> RegimeClassification:
    """Classify market regime for a ticker from historical price data.

    Fetches closing prices and daily returns, then delegates to
    ``classify_regime``.  Falls back to the uncertainty regime when
    market data is unavailable or insufficient.

    Requirements: 7.1, 7.2, 7.3, 7.8, 7.9
    """
    try:
        price_rows = await pool.fetch(_CLOSING_PRICES_QUERY, ticker)
        if not price_rows:
            logger.info(
                "No market data for %s — defaulting to uncertainty regime",
                ticker,
            )
            return _DEFAULT_UNCERTAINTY_REGIME

        # Prices come in DESC order; reverse to chronological
        closing_prices = [float(r["close"]) for r in reversed(price_rows) if r["close"] is not None]

        return_rows = await pool.fetch(_DAILY_RETURNS_QUERY, ticker)
        # Returns come in DESC order; reverse to chronological, skip NULLs
        returns = [
            float(r["daily_return"])
            for r in reversed(return_rows)
            if r["daily_return"] is not None
        ]

        if not closing_prices or not returns:
            logger.info(
                "Insufficient market data for %s — defaulting to uncertainty regime",
                ticker,
            )
            return _DEFAULT_UNCERTAINTY_REGIME

        return classify_regime(closing_prices, returns)

    except Exception:
        logger.warning(
            "Failed to classify regime for %s — defaulting to uncertainty regime",
            ticker,
            exc_info=True,
        )
        return _DEFAULT_UNCERTAINTY_REGIME


async def _fetch_ticker_market_data(
    pool: asyncpg.Pool,
    ticker: str,
) -> tuple[list[float] | None, list[float] | None]:
    """Fetch recent daily returns and volumes for regime multiplier scoring.

    Returns (returns, volumes) where each is a chronological list or None
    if data is unavailable.  Used by the probabilistic scoring pipeline
    to compute regime multiplier M_regime in ``compute_signal_weight``.
    """
    try:
        return_rows = await pool.fetch(_DAILY_RETURNS_QUERY, ticker)
        returns = [
            float(r["daily_return"])
            for r in reversed(return_rows)
            if r["daily_return"] is not None
        ] if return_rows else None

        volume_rows = await pool.fetch(_DAILY_VOLUMES_QUERY, ticker)
        volumes = [
            float(r["volume"])
            for r in reversed(volume_rows)
            if r["volume"] is not None
        ] if volume_rows else None

        return returns or None, volumes or None
    except Exception:
        logger.warning(
            "Failed to fetch market data for %s scoring — "
            "regime multiplier will default to 1.0",
            ticker,
            exc_info=True,
        )
        return None, None


# ---------------------------------------------------------------------------
# Main aggregation entry point for a single ticker + window
# ---------------------------------------------------------------------------


async def aggregate_company_window(
    pool: asyncpg.Pool,
    ticker: str,
    window: str,
    reference_time: datetime | None = None,
    config: AggregationConfig | None = None,
    *,
    probabilistic: bool = False,
    regime: RegimeClassification | None = None,
    source_accuracy_map: dict[str, float] | None = None,
    ticker_returns: list[float] | None = None,
    ticker_volumes: list[float] | None = None,
) -> TrendSummary:
    """Compute and persist a trend summary for one ticker and one window.

    Steps:
    1. Determine the time range for the window.
    2. Fetch document impact records from PostgreSQL.
    3. Fetch market context for the ticker.
    4. Build weighted signals using the scoring module.
    5. Check macro toggle and fetch/merge macro signals if enabled.
    6. Check competitive toggle and fetch/merge pattern/competitive signals if enabled.
    7. Assemble the TrendSummary (probabilistic or heuristic).
    8. Persist to trend_windows table.

    When ``probabilistic`` is True, the scoring config is set to
    probabilistic mode, source accuracy factors are passed to signal
    scoring, and macro integration uses the conditional modifier.

    Returns the assembled TrendSummary.
    """
    cfg = config or AggregationConfig()
    scoring_cfg = cfg.effective_scoring()

    # When probabilistic mode is active, create a scoring config with
    # probabilistic=True so all downstream scoring uses the new formulas.
    if probabilistic and not scoring_cfg.probabilistic:
        scoring_cfg = ScoringConfig(
            half_life_hours=scoring_cfg.half_life_hours,
            min_recency_weight=scoring_cfg.min_recency_weight,
            credibility_floor=scoring_cfg.credibility_floor,
            credibility_ceiling=scoring_cfg.credibility_ceiling,
            credibility_exponent=scoring_cfg.credibility_exponent,
            novelty_bonus_max=scoring_cfg.novelty_bonus_max,
            confidence_floor=scoring_cfg.confidence_floor,
            volatility_recency_boost_threshold=scoring_cfg.volatility_recency_boost_threshold,
            volatility_recency_boost_max=scoring_cfg.volatility_recency_boost_max,
            volume_surge_threshold_pct=scoring_cfg.volume_surge_threshold_pct,
            volume_surge_boost=scoring_cfg.volume_surge_boost,
            probabilistic=True,
            sigmoid_steepness=scoring_cfg.sigmoid_steepness,
            sigmoid_midpoint=scoring_cfg.sigmoid_midpoint,
            info_gain_lambda=scoring_cfg.info_gain_lambda,
            info_gain_max=scoring_cfg.info_gain_max,
            default_base_rate=scoring_cfg.default_base_rate,
            adaptive_decay_impact_scale=scoring_cfg.adaptive_decay_impact_scale,
            adaptive_decay_surprise_scale=scoring_cfg.adaptive_decay_surprise_scale,
            adaptive_decay_market_scale=scoring_cfg.adaptive_decay_market_scale,
            regime_return_weight=scoring_cfg.regime_return_weight,
            regime_volume_weight=scoring_cfg.regime_volume_weight,
            regime_multiplier_max=scoring_cfg.regime_multiplier_max,
        )

    if reference_time is None:
        reference_time = datetime.now(timezone.utc)

    _agg_start = time.monotonic()
    duration = WINDOW_DURATIONS.get(window, timedelta(days=7))
    window_start = reference_time - duration

    # 1. Fetch impact records
    impacts = await fetch_impact_records(pool, ticker, window_start, reference_time)

    # 2. Fetch market context
    market_ctx = await fetch_market_context(pool, ticker, window, reference_time)

    # 2a. Normalize impact scores using 7-day z-score per ticker
    if impacts:
        raw_scores = [imp.impact_score for imp in impacts]
        normalized_scores = await normalize_impact_scores(pool, ticker, raw_scores)
        for imp, norm_score in zip(impacts, normalized_scores):
            imp.impact_score = norm_score

    # 3. Build weighted signals — pass source accuracy and market data
    #    when in probabilistic mode (Req 4.1–4.3, 6.1–6.5)
    signals = build_weighted_signals(
        impacts, reference_time, window, market_ctx, scoring_cfg,
        source_accuracy_map=source_accuracy_map if probabilistic else None,
        returns=ticker_returns if probabilistic else None,
        volumes=ticker_volumes if probabilistic else None,
    )

    # 4. Check macro toggle and merge macro signals
    # (Requirement 11.2, 11.3, 11.4): Toggle state is read from the DB on
    # every aggregation cycle. When disabled, macro signals are skipped but
    # ingestion/classification continue independently — so when re-enabled,
    # the most recent classifications (including those ingested while disabled)
    # are immediately available for impact computation.
    macro_enabled = cfg.macro_enabled
    db_toggle = await fetch_macro_enabled(pool)
    if db_toggle is not None:
        macro_enabled = db_toggle

    macro_modifier = 1.0
    if macro_enabled:
        macro_impacts = await fetch_macro_impact_records(
            pool, ticker, window_start, reference_time,
        )
        if macro_impacts:
            macro_signals = build_macro_weighted_signals(
                macro_impacts,
                reference_time,
                window,
                macro_signal_weight=cfg.macro_signal_weight,
                config=scoring_cfg,
                returns=ticker_returns if probabilistic else None,
                volumes=ticker_volumes if probabilistic else None,
            )

            if probabilistic:
                # Probabilistic mode: use conditional macro modifier (Req 11.1–11.5)
                company_direction = derive_trend_direction(
                    weighted_sentiment_average(signals),
                ).value
                signals, macro_modifier = integrate_macro_signals(
                    company_signals=signals,
                    macro_signals=macro_signals,
                    company_direction=company_direction,
                    macro_impacts=macro_impacts,
                    ticker=ticker,
                    probabilistic=True,
                    macro_signal_weight=cfg.macro_signal_weight,
                )
            else:
                # Heuristic mode: simple additive merge (current behavior)
                signals = signals + macro_signals

            logger.info(
                "Merged %d macro signals for %s/%s (modifier=%.4f)",
                len(macro_signals), ticker, window, macro_modifier,
            )

    # 5. Check competitive toggle and merge pattern/competitive signals
    # (Requirements 5.1-5.6): Same toggle pattern as macro layer. When
    # disabled, pattern mining remains queryable but aggregation skips
    # competitive signals — no degradation of existing behavior.
    competitive_enabled = cfg.competitive_enabled
    db_competitive_toggle = await fetch_competitive_enabled(pool)
    if db_competitive_toggle is not None:
        competitive_enabled = db_competitive_toggle

    if competitive_enabled:
        try:
            # Get unique catalyst types from the impact records
            catalyst_types = {imp.catalyst_type for imp in impacts}

            # Query self-company historical patterns for each catalyst type
            all_patterns = []
            for cat_type in catalyst_types:
                patterns = await find_self_patterns(pool, ticker, cat_type)
                all_patterns.extend(patterns)

            # Fetch competitive signals targeting this ticker
            comp_signals = await fetch_competitive_signals(
                pool, ticker, window_start, reference_time,
            )

            # Convert to WeightedSignal objects
            if all_patterns or comp_signals:
                pattern_weighted = build_pattern_weighted_signals(
                    patterns=all_patterns,
                    competitive_signals=comp_signals,
                    reference_time=reference_time,
                    window=window,
                )
                signals = signals + pattern_weighted
                logger.info(
                    "Merged %d pattern/competitive signals for %s/%s "
                    "(patterns=%d, competitive=%d)",
                    len(pattern_weighted), ticker, window,
                    len(all_patterns), len(comp_signals),
                )
        except Exception:
            logger.exception(
                "Failed to fetch pattern/competitive signals for %s/%s — "
                "continuing with company+macro signals only",
                ticker, window,
            )

    # 6. Assemble trend summary with evidence details
    assembled = assemble_trend_with_evidence(
        ticker=ticker,
        window=window,
        signals=signals,
        impacts=impacts,
        market_ctx=market_ctx if market_ctx.has_data else None,
        max_evidence=cfg.max_evidence,
        reference_time=reference_time,
        probabilistic=probabilistic,
        regime=regime,
    )
    summary = assembled.summary

    # 6b. Enrich probabilistic JSONB with macro modifier (Req 16.2)
    if probabilistic and macro_modifier != 1.0:
        ctx = summary.market_context
        if isinstance(ctx, dict) and "probabilistic" in ctx:
            ctx["probabilistic"]["macro_modifier"] = round(macro_modifier, 4)

    # 7. Persist trend window
    trend_id = await persist_trend_summary(pool, summary)

    # 8. Persist evidence mappings
    evidence_count = await persist_trend_evidence(
        pool, trend_id,
        assembled.supporting_evidence,
        assembled.opposing_evidence,
    )

    logger.info(
        "Persisted trend %s for %s/%s: direction=%s strength=%.3f confidence=%.3f signals=%d evidence=%d",
        trend_id, ticker, window, summary.trend_direction.value,
        summary.trend_strength, summary.confidence, len(signals), evidence_count,
    )

    # 9. Compute and persist trend projection
    try:
        macro_event_infos: list[MacroEventInfo] = []
        if macro_enabled:
            macro_event_infos = await _build_macro_event_infos(
                pool, ticker, window_start, reference_time,
            )

        projection = compute_projection(
            summary=summary,
            macro_events=macro_event_infos if macro_event_infos else None,
            macro_enabled=macro_enabled,
            upcoming_catalysts=summary.dominant_catalysts[:3] if summary.dominant_catalysts else None,
        )
        await persist_trend_projection(pool, trend_id, projection)
        logger.info(
            "Persisted projection for %s/%s: direction=%s strength=%.3f confidence=%.3f diverges=%s",
            ticker, window, projection.projected_direction,
            projection.projected_strength, projection.projected_confidence,
            projection.diverges_from_current,
        )
    except Exception:
        logger.exception(
            "Failed to compute/persist projection for trend %s (%s/%s) — continuing",
            trend_id, ticker, window,
        )

    # Prometheus metrics
    AGGREGATION_WINDOWS_COMPUTED.labels(window=window).inc()
    AGGREGATION_SIGNALS_PROCESSED.labels(window=window).inc(len(signals))
    AGGREGATION_CONTRADICTION_SCORE.observe(summary.contradiction_score)
    AGGREGATION_DURATION.labels(window=window).observe(time.monotonic() - _agg_start)

    return summary


# ---------------------------------------------------------------------------
# Aggregate all windows for a single ticker
# ---------------------------------------------------------------------------


async def aggregate_company(
    pool: asyncpg.Pool,
    ticker: str,
    reference_time: datetime | None = None,
    config: AggregationConfig | None = None,
) -> list[TrendSummary]:
    """Compute trend summaries for all configured windows for a ticker."""
    cfg = config or AggregationConfig()
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)

    # --- v3 engine feature flag (Requirements 19.1–19.6) ---
    # Read v3_engine_enabled once per aggregation cycle. When True, route
    # to the v3 calibrated evidence pipeline. Default to heuristic on any
    # read failure (fail-safe).
    v3_enabled = await _read_v3_flag(pool)

    if v3_enabled:
        # Attempt the v3 pipeline with try/except fallback (Req 19.4)
        try:
            summaries = await _aggregate_company_v3(
                pool, ticker, reference_time, cfg,
            )
            logger.info(
                "Aggregation cycle for %s completed via v3 pipeline",
                ticker,
            )
            return summaries
        except Exception:
            logger.error(
                "v3 pipeline failed for %s, falling back to heuristic",
                ticker,
                exc_info=True,
            )
            # Fall through to heuristic pipeline below

    # --- Heuristic pipeline (existing behavior) ---
    return await _aggregate_company_heuristic(pool, ticker, reference_time, cfg)


async def _aggregate_company_v3(
    pool: asyncpg.Pool,
    ticker: str,
    reference_time: datetime,
    config: AggregationConfig,
) -> list[TrendSummary]:
    """Run the v3 calibrated evidence pipeline for all windows.

    Routes each window through _run_v3_pipeline and annotates output
    metadata with pipeline_mode="v3".

    Requirements: 19.2, 19.5
    """
    cfg = config
    summaries: list[TrendSummary] = []
    for window in cfg.effective_windows():
        summary = await _run_v3_pipeline(
            pool, ticker, window, reference_time, cfg,
        )
        # Annotate output with pipeline_mode (Req 19.5)
        _annotate_pipeline_mode(summary, "v3")
        summaries.append(summary)
    return summaries


def _annotate_pipeline_mode(summary: TrendSummary, mode: str) -> None:
    """Store pipeline_mode in the TrendSummary market_context JSONB.

    Requirements: 19.5
    """
    ctx = summary.market_context
    if isinstance(ctx, dict):
        ctx["pipeline_mode"] = mode
    elif ctx is not None and hasattr(ctx, "model_dump"):
        ctx_dict = ctx.model_dump()
        ctx_dict["pipeline_mode"] = mode
        summary.market_context = ctx_dict
    else:
        summary.market_context = {"pipeline_mode": mode}


async def _aggregate_company_heuristic(
    pool: asyncpg.Pool,
    ticker: str,
    reference_time: datetime,
    config: AggregationConfig,
) -> list[TrendSummary]:
    """Run the existing heuristic/probabilistic pipeline for all windows.

    This preserves the exact existing behavior. The pipeline_mode field
    is set to "heuristic" in output metadata (Req 19.5).
    """
    cfg = config

    # Read probabilistic scoring flag once per cycle (Requirement 16.7).
    # Mid-cycle changes take effect on the next cycle.
    probabilistic = await fetch_probabilistic_scoring_enabled(pool)
    pipeline_mode = "probabilistic" if probabilistic else "heuristic"

    # --- Quality gate evaluation (Req 11.2, 11.3) ---
    # Evaluate model quality gate at the start of each aggregation cycle.
    # When the gate fails, all recommendations are forced to paper mode.
    # Gate evaluation failure defaults to paper-only (fail-safe).
    quality_gate_passed = False
    try:
        gate_result: QualityGateResult = await evaluate_quality_gate(pool)
        quality_gate_passed = gate_result.passed
        logger.info(
            "Quality gate for %s cycle: %s — %s",
            ticker,
            "PASS" if gate_result.passed else "FAIL",
            gate_result.reason,
        )
    except Exception:
        logger.exception(
            "Quality gate evaluation failed for %s cycle — "
            "defaulting to paper-only mode (fail-safe)",
            ticker,
        )
        quality_gate_passed = False

    logger.info(
        "Aggregation cycle for %s: pipeline_mode=%s quality_gate=%s",
        ticker,
        pipeline_mode,
        "passed" if quality_gate_passed else "failed",
    )

    # --- Regime detection (Req 7.1, 7.2, 7.3, 7.8, 7.9) ---
    # Classify market regime for this ticker using closing prices and returns.
    # Default to uncertainty regime when market data is unavailable.
    regime: RegimeClassification | None = None
    ticker_returns: list[float] | None = None
    ticker_volumes: list[float] | None = None
    source_accuracy_map: dict[str, float] | None = None

    if probabilistic:
        regime = await _classify_ticker_regime(pool, ticker)
        logger.info(
            "Regime for %s: %s (trend_indicator=%.1f, vol_ratio=%.2f, "
            "bullish_threshold=%.2f, contradiction_mult=%.1f)",
            ticker,
            regime.regime.value,
            regime.trend_indicator,
            regime.volatility_ratio,
            regime.bullish_threshold,
            regime.contradiction_penalty_multiplier,
        )

        # Fetch market data (returns/volumes) for regime multiplier in scoring
        # (Req 6.1–6.5). Fetched once per cycle and reused across all windows.
        ticker_returns, ticker_volumes = await _fetch_ticker_market_data(pool, ticker)

        # Batch-fetch source accuracy for all sources in the signal set
        # (Req 4.1–4.3). Fetched once per cycle; individual signals look up
        # their factor from this map. DB errors default to empty map (factor 1.0).
        try:
            # Fetch all source IDs from the longest window to cover all signals
            longest_window = max(
                cfg.effective_windows(),
                key=lambda w: WINDOW_DURATIONS.get(w, timedelta(days=7)),
            )
            longest_duration = WINDOW_DURATIONS.get(longest_window, timedelta(days=90))
            window_start = reference_time - longest_duration
            all_impacts = await fetch_impact_records(pool, ticker, window_start, reference_time)
            source_ids = list({imp.document_id for imp in all_impacts})
            if source_ids:
                sa_records = await fetch_source_accuracy(pool, source_ids)
                source_accuracy_map = {
                    sid: sa.accuracy_factor for sid, sa in sa_records.items()
                }
                logger.info(
                    "Fetched source accuracy for %s: %d/%d sources have records",
                    ticker, len(sa_records), len(source_ids),
                )
        except Exception:
            logger.warning(
                "Failed to fetch source accuracy for %s — defaulting to neutral factor",
                ticker,
                exc_info=True,
            )
            source_accuracy_map = None

    summaries: list[TrendSummary] = []
    for window in cfg.effective_windows():
        summary = await aggregate_company_window(
            pool, ticker, window, reference_time, cfg,
            probabilistic=probabilistic,
            regime=regime,
            source_accuracy_map=source_accuracy_map,
            ticker_returns=ticker_returns,
            ticker_volumes=ticker_volumes,
        )

        # When quality gate fails, annotate the trend summary so the
        # recommendation engine forces paper mode (Req 11.2, 11.3).
        if not quality_gate_passed:
            ctx = summary.market_context
            if isinstance(ctx, dict):
                ctx["quality_gate_passed"] = False
            elif ctx is not None and hasattr(ctx, "model_dump"):
                ctx_dict = ctx.model_dump()
                ctx_dict["quality_gate_passed"] = False
                summary.market_context = ctx_dict
            else:
                summary.market_context = {"quality_gate_passed": False}

        # Annotate output with pipeline_mode (Req 19.5)
        _annotate_pipeline_mode(summary, "heuristic")

        summaries.append(summary)

    return summaries
