"""Sector and market-level rollup aggregation.

Aggregates company-level trend summaries into sector and market-level
summaries, enabling top-down views of sentiment and risk across the
portfolio.

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

import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone

import asyncpg

from services.shared.schemas import (
    DisagreementDetail,
    TrendDirection,
    TrendSummary,
    TrendWindow,
)

logger = logging.getLogger(__name__)


@dataclass
class CompanyTrendRow:
    """A company-level trend summary fetched from the DB for rollup."""

    entity_id: str  # ticker
    sector: str
    window: str
    trend_direction: str
    trend_strength: float
    confidence: float
    contradiction_score: float
    dominant_catalysts: list[str]
    material_risks: list[str]
    top_supporting_evidence: list[str]
    top_opposing_evidence: list[str]


@dataclass
class SectorMacroImpact:
    """Aggregated macro impact data for a single sector.

    Used to incorporate macro signals into sector and market rollups.
    Requirements: 6.1, 6.2, 6.3
    """

    sector: str
    total_impact: float  # sum of macro_impact_score across companies in sector
    avg_impact: float  # average macro_impact_score
    company_count: int  # number of companies affected
    net_direction: float  # weighted direction: +1 positive, -1 negative, 0 mixed
    event_ids: list[str] = field(default_factory=list)  # contributing event IDs


# Threshold for disproportionate sector impact (Requirement 6.3)
SECTOR_CONCENTRATION_THRESHOLD = 0.60


# ---------------------------------------------------------------------------
# Fetch sector-level macro impact aggregates
# ---------------------------------------------------------------------------

_SECTOR_MACRO_IMPACT_QUERY = """
SELECT
    c.sector,
    mir.event_id,
    mir.macro_impact_score,
    mir.impact_direction
FROM macro_impact_records mir
JOIN companies c ON c.id = mir.company_id AND c.active = TRUE
WHERE mir.computed_at >= $1
  AND mir.computed_at <= $2
ORDER BY c.sector, mir.macro_impact_score DESC
"""


async def fetch_sector_macro_impacts(
    pool: asyncpg.Pool,
    window_start: datetime,
    window_end: datetime,
) -> dict[str, SectorMacroImpact]:
    """Fetch macro impact records aggregated by sector for a time range.

    Returns a mapping of sector name to SectorMacroImpact.
    """
    rows = await pool.fetch(_SECTOR_MACRO_IMPACT_QUERY, window_start, window_end)

    # Accumulate per-sector
    sector_data: dict[str, dict] = {}
    direction_map = {"positive": 1.0, "negative": -1.0, "mixed": 0.0, "neutral": 0.0}

    for row in rows:
        sector = str(row["sector"]) if row["sector"] else "Unknown"
        score = float(row["macro_impact_score"] or 0.0)
        direction = row["impact_direction"] or "neutral"
        event_id = str(row["event_id"])

        if sector not in sector_data:
            sector_data[sector] = {
                "total": 0.0,
                "count": 0,
                "dir_sum": 0.0,
                "dir_count": 0,
                "event_ids": set(),
            }

        d = sector_data[sector]
        d["total"] += score
        d["count"] += 1
        dir_val = direction_map.get(direction, 0.0)
        if dir_val != 0.0:
            d["dir_sum"] += dir_val
            d["dir_count"] += 1
        d["event_ids"].add(event_id)

    result: dict[str, SectorMacroImpact] = {}
    for sector, d in sector_data.items():
        count = d["count"]
        avg = d["total"] / count if count > 0 else 0.0
        net_dir = d["dir_sum"] / d["dir_count"] if d["dir_count"] > 0 else 0.0
        result[sector] = SectorMacroImpact(
            sector=sector,
            total_impact=d["total"],
            avg_impact=avg,
            company_count=count,
            net_direction=net_dir,
            event_ids=sorted(d["event_ids"]),
        )

    return result


# ---------------------------------------------------------------------------
# Sector macro concentration helper (Requirement 6.3)
# ---------------------------------------------------------------------------


def compute_sector_macro_concentration(
    sector_impacts: dict[str, SectorMacroImpact],
) -> list[tuple[str, float]]:
    """Compute the fraction of total macro impact concentrated in each sector.

    Returns a list of (sector, fraction) tuples sorted by fraction descending.
    Sectors with fraction > SECTOR_CONCENTRATION_THRESHOLD are considered
    disproportionately affected.
    """
    total = sum(si.total_impact for si in sector_impacts.values())
    if total <= 0.0:
        return []

    fractions = [
        (sector, si.total_impact / total)
        for sector, si in sector_impacts.items()
    ]
    fractions.sort(key=lambda x: x[1], reverse=True)
    return fractions


# ---------------------------------------------------------------------------
# Fetch latest company trends for a given window
# ---------------------------------------------------------------------------

_LATEST_COMPANY_TRENDS_QUERY = """
SELECT DISTINCT ON (tw.entity_id)
    tw.entity_id,
    c.sector,
    tw.window,
    tw.trend_direction,
    tw.trend_strength,
    tw.confidence,
    tw.contradiction_score,
    tw.dominant_catalysts,
    tw.material_risks,
    tw.top_supporting_evidence,
    tw.top_opposing_evidence
FROM trend_windows tw
JOIN companies c ON c.ticker = tw.entity_id AND c.active = TRUE
WHERE tw.entity_type = 'company'
  AND tw.window = $1
  AND tw.generated_at >= $2
ORDER BY tw.entity_id, tw.generated_at DESC
"""


def _parse_jsonb_list(val: object) -> list[str]:
    """Safely parse a JSONB column that should be a list of strings."""
    if isinstance(val, list):
        return [str(v) for v in val]
    if isinstance(val, str):
        parsed = json.loads(val)
        if isinstance(parsed, list):
            return [str(v) for v in parsed]
    return []


def _parse_company_trend_row(row: object) -> CompanyTrendRow:
    """Convert an asyncpg Record to a CompanyTrendRow."""
    # asyncpg Records support dict() but aren't typed; use getattr-style access
    get = getattr(row, "__getitem__", None)
    if get is None:
        raise TypeError(f"Expected a mapping-like row, got {type(row)}")

    def _str(key: str, default: str = "") -> str:
        val = get(key)
        return str(val) if val is not None else default

    def _float(key: str) -> float:
        val = get(key)
        return float(val) if val is not None else 0.0

    return CompanyTrendRow(
        entity_id=_str("entity_id"),
        sector=_str("sector", "Unknown") or "Unknown",
        window=_str("window"),
        trend_direction=_str("trend_direction"),
        trend_strength=_float("trend_strength"),
        confidence=_float("confidence"),
        contradiction_score=_float("contradiction_score"),
        dominant_catalysts=_parse_jsonb_list(get("dominant_catalysts")),
        material_risks=_parse_jsonb_list(get("material_risks")),
        top_supporting_evidence=_parse_jsonb_list(get("top_supporting_evidence")),
        top_opposing_evidence=_parse_jsonb_list(get("top_opposing_evidence")),
    )


async def fetch_latest_company_trends(
    pool: asyncpg.Pool,
    window: str,
    since: datetime,
) -> list[CompanyTrendRow]:
    """Fetch the most recent company-level trend for each ticker in a window."""
    rows = await pool.fetch(_LATEST_COMPANY_TRENDS_QUERY, window, since)
    return [_parse_company_trend_row(r) for r in rows]


# ---------------------------------------------------------------------------
# Pure rollup logic
# ---------------------------------------------------------------------------

# Direction mapping for numeric aggregation
_DIRECTION_VALUES = {
    TrendDirection.BULLISH.value: 1.0,
    TrendDirection.BEARISH.value: -1.0,
    TrendDirection.MIXED.value: 0.0,
    TrendDirection.NEUTRAL.value: 0.0,
}

BULLISH_THRESHOLD = 0.15
BEARISH_THRESHOLD = -0.15


def rollup_trends(
    trends: list[CompanyTrendRow],
    entity_type: str,
    entity_id: str,
    window: str,
    reference_time: datetime,
    macro_impacts: dict[str, SectorMacroImpact] | None = None,
) -> TrendSummary:
    """Aggregate a list of company-level trends into a single rollup summary.

    Each company trend is weighted by its confidence to produce a
    confidence-weighted average of direction, strength, and contradiction.

    When macro_impacts is provided:
    - For sector rollups: incorporates the sector's macro signal into
      strength and confidence, weighted by constituent company exposure.
    - For market rollups: aggregates macro signals across all sectors and
      surfaces disproportionately affected sectors (>60% concentration)
      in material_risks or dominant_catalysts.

    When macro_impacts is None or empty, produces identical output to
    the original company-only rollup.
    """
    if not trends:
        return TrendSummary(
            entity_type=entity_type,
            entity_id=entity_id,
            window=TrendWindow(window),
            trend_direction=TrendDirection.NEUTRAL,
            trend_strength=0.0,
            confidence=0.0,
            generated_at=reference_time,
        )

    total_weight = 0.0
    weighted_direction = 0.0
    weighted_strength = 0.0
    weighted_contradiction = 0.0
    catalyst_weights: dict[str, float] = {}
    risk_set: dict[str, float] = {}
    all_supporting: list[str] = []
    all_opposing: list[str] = []

    for t in trends:
        w = t.confidence
        total_weight += w
        dir_val = _DIRECTION_VALUES.get(t.trend_direction, 0.0)
        weighted_direction += w * dir_val
        weighted_strength += w * t.trend_strength
        weighted_contradiction += w * t.contradiction_score

        for cat in t.dominant_catalysts:
            catalyst_weights[cat] = catalyst_weights.get(cat, 0.0) + w

        for risk in t.material_risks:
            norm = risk.strip().lower()
            if norm not in risk_set:
                risk_set[norm] = w
            else:
                risk_set[norm] = max(risk_set[norm], w)

        all_supporting.extend(t.top_supporting_evidence)
        all_opposing.extend(t.top_opposing_evidence)

    if total_weight == 0.0:
        return TrendSummary(
            entity_type=entity_type,
            entity_id=entity_id,
            window=TrendWindow(window),
            trend_direction=TrendDirection.NEUTRAL,
            trend_strength=0.0,
            confidence=0.0,
            generated_at=reference_time,
        )

    avg_direction = weighted_direction / total_weight
    avg_strength = weighted_strength / total_weight
    avg_contradiction = weighted_contradiction / total_weight
    avg_confidence = total_weight / len(trends)

    # --- Incorporate macro impact signals when available ---
    macro_strength_adj = 0.0
    macro_confidence_adj = 0.0
    macro_catalysts: list[str] = []
    macro_risks: list[str] = []

    if macro_impacts:
        if entity_type == "sector":
            # Sector rollup: incorporate this sector's macro signal
            sector_macro = macro_impacts.get(entity_id)
            if sector_macro and sector_macro.total_impact > 0:
                # Weight macro contribution by avg impact and company breadth
                breadth = min(sector_macro.company_count / max(len(trends), 1), 1.0)
                macro_strength_adj = sector_macro.avg_impact * breadth * 0.3
                macro_confidence_adj = sector_macro.avg_impact * breadth * 0.1
                # Nudge direction based on macro net direction
                avg_direction += sector_macro.net_direction * macro_strength_adj * 0.5

        elif entity_type == "market":
            # Market rollup: aggregate macro signals across all sectors
            total_macro = sum(si.total_impact for si in macro_impacts.values())
            if total_macro > 0:
                total_companies = sum(si.company_count for si in macro_impacts.values())
                breadth = min(total_companies / max(len(trends), 1), 1.0)
                avg_macro = total_macro / max(len(macro_impacts), 1)
                macro_strength_adj = avg_macro * breadth * 0.3
                macro_confidence_adj = avg_macro * breadth * 0.1

                # Aggregate net direction across sectors
                dir_sum = sum(
                    si.net_direction * si.total_impact
                    for si in macro_impacts.values()
                )
                net_dir = dir_sum / total_macro if total_macro > 0 else 0.0
                avg_direction += net_dir * macro_strength_adj * 0.5

                # Surface disproportionately affected sectors (Requirement 6.3)
                concentration = compute_sector_macro_concentration(macro_impacts)
                for sector, fraction in concentration:
                    if fraction > SECTOR_CONCENTRATION_THRESHOLD:
                        si = macro_impacts[sector]
                        label = f"Macro: {sector} ({fraction:.0%} of macro impact)"
                        if si.net_direction < 0:
                            macro_risks.append(label)
                        else:
                            macro_catalysts.append(label)

    # Apply macro adjustments to strength and confidence
    adj_strength = avg_strength + macro_strength_adj
    adj_confidence = avg_confidence + macro_confidence_adj

    # Derive direction
    direction = _derive_rollup_direction(avg_direction, avg_contradiction)

    # Top catalysts (macro catalysts prepended when present)
    sorted_catalysts = sorted(catalyst_weights.items(), key=lambda x: x[1], reverse=True)
    catalysts = macro_catalysts + [c for c, _ in sorted_catalysts[:5]]
    catalysts = catalysts[:5]

    # Top risks (macro risks prepended when present, deduplicated)
    sorted_risks = sorted(risk_set.items(), key=lambda x: x[1], reverse=True)
    base_risks = [r for r, _ in sorted_risks[:5]]
    risks = macro_risks + base_risks
    risks = risks[:5]

    # Disagreement details
    disagreement = _build_rollup_disagreement(trends, entity_id)

    return TrendSummary(
        entity_type=entity_type,
        entity_id=entity_id,
        window=TrendWindow(window),
        trend_direction=direction,
        trend_strength=round(min(abs(adj_strength), 1.0), 4),
        confidence=round(max(0.0, min(adj_confidence, 1.0)), 4),
        top_supporting_evidence=list(dict.fromkeys(all_supporting))[:10],
        top_opposing_evidence=list(dict.fromkeys(all_opposing))[:10],
        dominant_catalysts=catalysts,
        material_risks=risks,
        contradiction_score=round(max(0.0, min(avg_contradiction, 1.0)), 4),
        disagreement_details=disagreement,
        generated_at=reference_time,
    )


def _derive_rollup_direction(
    avg_direction: float,
    avg_contradiction: float,
) -> TrendDirection:
    """Map averaged direction value to a TrendDirection."""
    if avg_contradiction > 0.10 and abs(avg_direction) < 0.3:
        return TrendDirection.MIXED
    if avg_direction >= BULLISH_THRESHOLD:
        return TrendDirection.BULLISH
    if avg_direction <= BEARISH_THRESHOLD:
        return TrendDirection.BEARISH
    return TrendDirection.NEUTRAL


def _build_rollup_disagreement(
    trends: list[CompanyTrendRow],
    entity_id: str,
) -> list[DisagreementDetail]:
    """Build disagreement details showing which companies are bullish vs bearish."""
    bullish_ids: list[str] = []
    bearish_ids: list[str] = []
    bullish_weight = 0.0
    bearish_weight = 0.0

    for t in trends:
        if t.trend_direction == TrendDirection.BULLISH.value:
            bullish_ids.append(t.entity_id)
            bullish_weight += t.confidence
        elif t.trend_direction == TrendDirection.BEARISH.value:
            bearish_ids.append(t.entity_id)
            bearish_weight += t.confidence

    if not bullish_ids or not bearish_ids:
        return []

    return [
        DisagreementDetail(
            dimension="company_direction",
            positive_doc_ids=bullish_ids,
            negative_doc_ids=bearish_ids,
            positive_weight=round(bullish_weight, 4),
            negative_weight=round(bearish_weight, 4),
            description=(
                f"{entity_id}: {len(bullish_ids)} bullish vs "
                f"{len(bearish_ids)} bearish companies"
            ),
        )
    ]


# ---------------------------------------------------------------------------
# Persist rollup (reuses the same trend_windows table)
# ---------------------------------------------------------------------------

_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
)
RETURNING id
"""


async def persist_rollup(
    pool: asyncpg.Pool,
    summary: TrendSummary,
) -> str:
    """Insert a rollup trend summary and return its UUID."""
    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.generated_at,
    )
    return str(row["id"])  # type: ignore[index]


# ---------------------------------------------------------------------------
# High-level rollup entry points
# ---------------------------------------------------------------------------


async def aggregate_sector(
    pool: asyncpg.Pool,
    sector: str,
    window: str,
    reference_time: datetime | None = None,
    since: datetime | None = None,
    macro_impacts: dict[str, SectorMacroImpact] | None = None,
) -> TrendSummary:
    """Compute and persist a sector-level rollup for one window.

    Fetches the latest company trends, filters to the given sector,
    and rolls them up into a single sector summary. When macro_impacts
    is provided, incorporates macro signals weighted by constituent
    company exposure.
    """
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)
    if since is None:
        since = reference_time - _window_lookback(window)

    all_trends = await fetch_latest_company_trends(pool, window, since)
    sector_trends = [t for t in all_trends if t.sector == sector]

    # Fetch macro impacts if not provided
    if macro_impacts is None:
        macro_impacts = await fetch_sector_macro_impacts(pool, since, reference_time)

    summary = rollup_trends(
        sector_trends, "sector", sector, window, reference_time,
        macro_impacts=macro_impacts,
    )

    if sector_trends:
        rollup_id = await persist_rollup(pool, summary)
        logger.info(
            "Persisted sector rollup %s for %s/%s: direction=%s strength=%.3f companies=%d",
            rollup_id, sector, window, summary.trend_direction.value,
            summary.trend_strength, len(sector_trends),
        )

    return summary


async def aggregate_market(
    pool: asyncpg.Pool,
    window: str,
    reference_time: datetime | None = None,
    since: datetime | None = None,
    macro_impacts: dict[str, SectorMacroImpact] | None = None,
) -> TrendSummary:
    """Compute and persist a market-wide rollup for one window.

    Aggregates all company trends regardless of sector. When macro_impacts
    is provided, aggregates macro signals across all sectors and surfaces
    disproportionately affected sectors in material_risks or dominant_catalysts.
    """
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)
    if since is None:
        since = reference_time - _window_lookback(window)

    all_trends = await fetch_latest_company_trends(pool, window, since)

    # Fetch macro impacts if not provided
    if macro_impacts is None:
        macro_impacts = await fetch_sector_macro_impacts(pool, since, reference_time)

    summary = rollup_trends(
        all_trends, "market", "all", window, reference_time,
        macro_impacts=macro_impacts,
    )

    if all_trends:
        rollup_id = await persist_rollup(pool, summary)
        logger.info(
            "Persisted market rollup %s for %s: direction=%s strength=%.3f companies=%d",
            rollup_id, window, summary.trend_direction.value,
            summary.trend_strength, len(all_trends),
        )

    return summary


async def aggregate_all_sectors(
    pool: asyncpg.Pool,
    window: str,
    reference_time: datetime | None = None,
    since: datetime | None = None,
    macro_impacts: dict[str, SectorMacroImpact] | None = None,
) -> list[TrendSummary]:
    """Compute sector rollups for every sector that has company trends."""
    if reference_time is None:
        reference_time = datetime.now(timezone.utc)
    if since is None:
        since = reference_time - _window_lookback(window)

    all_trends = await fetch_latest_company_trends(pool, window, since)

    # Fetch macro impacts once for all sectors if not provided
    if macro_impacts is None:
        macro_impacts = await fetch_sector_macro_impacts(pool, since, reference_time)

    # Group by sector
    sectors: dict[str, list[CompanyTrendRow]] = {}
    for t in all_trends:
        sectors.setdefault(t.sector, []).append(t)

    summaries: list[TrendSummary] = []
    for sector, trends in sectors.items():
        summary = rollup_trends(
            trends, "sector", sector, window, reference_time,
            macro_impacts=macro_impacts,
        )
        if trends:
            _id = await persist_rollup(pool, summary)
        summaries.append(summary)

    return summaries


def _window_lookback(window: str) -> timedelta:
    """Return a reasonable lookback for finding recent company trends."""
    mapping = {
        TrendWindow.INTRADAY.value: timedelta(hours=24),
        TrendWindow.ONE_DAY.value: timedelta(days=2),
        TrendWindow.SEVEN_DAY.value: timedelta(days=8),
        TrendWindow.THIRTY_DAY.value: timedelta(days=35),
        TrendWindow.NINETY_DAY.value: timedelta(days=95),
    }
    return mapping.get(window, timedelta(days=8))
