"""Repository for monitoring CRUD operations.

All database reads and writes for traces, spans, and detail records.
"""

from __future__ import annotations

import json
from datetime import datetime
from typing import Any

try:
    import asyncpg
except ImportError as e:
    raise ImportError(
        "asyncpg is required for monitoring. "
        "Install it with: pip install fluxibly[monitoring]"
    ) from e

from fluxibly.monitoring.db.models import (
    AgentCallDetailRecord,
    LLMCallDetailRecord,
    SpanRecord,
    ToolCallDetailRecord,
    TraceRecord,
)


def _json_dumps(obj: Any) -> str | None:
    """Serialize to JSON string for JSONB columns."""
    if obj is None:
        return None
    return json.dumps(obj, default=str)


def _row_to_dict(row: asyncpg.Record) -> dict[str, Any]:
    """Convert an asyncpg Record to a dict.

    - UUID values are cast to strings.
    - JSONB columns returned as raw JSON strings by asyncpg are
      deserialized to Python objects (dicts/lists).
    """
    import uuid

    d: dict[str, Any] = {}
    for key, value in dict(row).items():
        if isinstance(value, uuid.UUID):
            d[key] = str(value)
        elif isinstance(value, str) and _is_json_value(value):
            try:
                d[key] = json.loads(value)
            except (json.JSONDecodeError, TypeError):
                d[key] = value
        else:
            d[key] = value
    return d


def _is_json_value(s: str) -> bool:
    """Quick check if a string looks like a JSON object or array."""
    stripped = s.strip()
    return (stripped.startswith("{") and stripped.endswith("}")) or (
        stripped.startswith("[") and stripped.endswith("]")
    )


class MonitoringRepository:
    """CRUD operations for monitoring data."""

    def __init__(self, pool: asyncpg.Pool) -> None:
        self.pool = pool

    # ══════════════════════════════════════════════════════════════
    # Traces
    # ══════════════════════════════════════════════════════════════

    async def insert_trace(self, trace: TraceRecord) -> None:
        """Insert a new trace record."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO monitoring.traces (
                    trace_id, root_span_id, service_name, environment, tags,
                    entry_input, final_output, total_duration_ms, total_cost,
                    total_input_tokens, total_output_tokens, total_tokens,
                    span_count, status, error_message, created_at, updated_at
                ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
                """,
                trace.trace_id,
                trace.root_span_id,
                trace.service_name,
                trace.environment,
                _json_dumps(trace.tags),
                trace.entry_input,
                trace.final_output,
                trace.total_duration_ms,
                trace.total_cost,
                trace.total_input_tokens,
                trace.total_output_tokens,
                trace.total_tokens,
                trace.span_count,
                trace.status,
                trace.error_message,
                trace.created_at,
                trace.updated_at,
            )

    async def update_trace(self, trace_id: str, **fields: Any) -> None:
        """Update specific fields on a trace."""
        if not fields:
            return
        set_clauses = []
        values: list[Any] = []
        for i, (key, value) in enumerate(fields.items(), start=1):
            if key == "tags" and isinstance(value, dict):
                value = _json_dumps(value)
            set_clauses.append(f"{key} = ${i}")
            values.append(value)

        idx = len(values) + 1
        set_clauses.append(f"updated_at = ${idx}")
        values.append(datetime.now())
        values.append(trace_id)

        sql = f"UPDATE monitoring.traces SET {', '.join(set_clauses)} WHERE trace_id = ${idx + 1}"
        async with self.pool.acquire() as conn:
            await conn.execute(sql, *values)

    async def aggregate_trace_totals(self, trace_id: str) -> dict[str, Any]:
        """Aggregate token/cost/span totals from spans and LLM details for a trace."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                """
                SELECT
                    COUNT(DISTINCT s.span_id) AS span_count,
                    COALESCE(SUM(l.input_tokens), 0) AS total_input_tokens,
                    COALESCE(SUM(l.output_tokens), 0) AS total_output_tokens,
                    COALESCE(SUM(l.total_tokens), 0) AS total_tokens,
                    COALESCE(SUM(l.total_cost), 0) AS total_cost
                FROM monitoring.spans s
                LEFT JOIN monitoring.llm_call_details l ON s.span_id = l.span_id
                WHERE s.trace_id = $1
                """,
                trace_id,
            )
        return _row_to_dict(row) if row else {}

    async def get_trace(self, trace_id: str) -> dict[str, Any] | None:
        """Get a single trace by ID."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                "SELECT * FROM monitoring.traces WHERE trace_id = $1", trace_id
            )
            return _row_to_dict(row) if row else None

    async def list_traces(
        self,
        page: int = 1,
        per_page: int = 50,
        status: str | None = None,
        service_name: str | None = None,
        environment: str | None = None,
        from_ts: datetime | None = None,
        to_ts: datetime | None = None,
        search: str | None = None,
        model: str | None = None,
        agent_name: str | None = None,
        sort: str = "created_at",
        order: str = "desc",
    ) -> tuple[list[dict[str, Any]], int]:
        """List traces with filtering and pagination.

        Returns:
            Tuple of (trace_list, total_count).
        """
        conditions: list[str] = []
        params: list[Any] = []
        idx = 1

        if status:
            conditions.append(f"t.status = ${idx}")
            params.append(status)
            idx += 1
        if service_name:
            conditions.append(f"t.service_name = ${idx}")
            params.append(service_name)
            idx += 1
        if environment:
            conditions.append(f"t.environment = ${idx}")
            params.append(environment)
            idx += 1
        if from_ts:
            conditions.append(f"t.created_at >= ${idx}")
            params.append(from_ts)
            idx += 1
        if to_ts:
            conditions.append(f"t.created_at <= ${idx}")
            params.append(to_ts)
            idx += 1
        if search:
            conditions.append(
                f"(t.entry_input ILIKE ${idx} OR t.final_output ILIKE ${idx})"
            )
            params.append(f"%{search}%")
            idx += 1
        if model:
            conditions.append(
                f"EXISTS (SELECT 1 FROM monitoring.spans s WHERE s.trace_id = t.trace_id AND s.model = ${idx})"
            )
            params.append(model)
            idx += 1
        if agent_name:
            conditions.append(
                f"EXISTS (SELECT 1 FROM monitoring.spans s WHERE s.trace_id = t.trace_id AND s.component_name = ${idx})"
            )
            params.append(agent_name)
            idx += 1

        where_clause = (
            f"WHERE {' AND '.join(conditions)}" if conditions else ""
        )

        allowed_sorts = {
            "created_at",
            "total_duration_ms",
            "total_cost",
            "total_tokens",
        }
        sort_col = sort if sort in allowed_sorts else "created_at"
        sort_order = "ASC" if order.lower() == "asc" else "DESC"

        # Count
        count_sql = f"SELECT COUNT(*) FROM monitoring.traces t {where_clause}"
        # Data
        offset = (page - 1) * per_page
        data_sql = f"""
            SELECT t.* FROM monitoring.traces t {where_clause}
            ORDER BY t.{sort_col} {sort_order}
            LIMIT ${idx} OFFSET ${idx + 1}
        """
        params.extend([per_page, offset])

        async with self.pool.acquire() as conn:
            async with conn.transaction(
                isolation="repeatable_read", readonly=True
            ):
                total = await conn.fetchval(count_sql, *params[: idx - 1])
                rows = await conn.fetch(data_sql, *params)

        return [_row_to_dict(r) for r in rows], total or 0

    # ══════════════════════════════════════════════════════════════
    # Spans
    # ══════════════════════════════════════════════════════════════

    async def insert_span(self, span: SpanRecord) -> None:
        """Insert a new span record."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO monitoring.spans (
                    span_id, trace_id, parent_span_id, span_type,
                    component_class, component_name, model, provider,
                    sequence_number, started_at, ended_at, duration_ms,
                    status, error_message, depth
                ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
                """,
                span.span_id,
                span.trace_id,
                span.parent_span_id,
                span.span_type,
                span.component_class,
                span.component_name,
                span.model,
                span.provider,
                span.sequence_number,
                span.started_at,
                span.ended_at,
                span.duration_ms,
                span.status,
                span.error_message,
                span.depth,
            )

    async def update_span(self, span_id: str, **fields: Any) -> None:
        """Update specific fields on a span."""
        if not fields:
            return
        set_clauses = []
        values: list[Any] = []
        for i, (key, value) in enumerate(fields.items(), start=1):
            set_clauses.append(f"{key} = ${i}")
            values.append(value)
        values.append(span_id)
        sql = f"UPDATE monitoring.spans SET {', '.join(set_clauses)} WHERE span_id = ${len(values)}"
        async with self.pool.acquire() as conn:
            await conn.execute(sql, *values)

    async def get_spans_for_trace(self, trace_id: str) -> list[dict[str, Any]]:
        """Get all spans for a trace, ordered by depth and sequence."""
        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT * FROM monitoring.spans
                WHERE trace_id = $1
                ORDER BY depth, sequence_number, started_at
                """,
                trace_id,
            )
        return [_row_to_dict(r) for r in rows]

    async def get_span(self, span_id: str) -> dict[str, Any] | None:
        """Get a single span."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                "SELECT * FROM monitoring.spans WHERE span_id = $1", span_id
            )
            return _row_to_dict(row) if row else None

    # ══════════════════════════════════════════════════════════════
    # LLM Call Details
    # ══════════════════════════════════════════════════════════════

    async def insert_llm_detail(self, detail: LLMCallDetailRecord) -> None:
        """Insert LLM call details."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO monitoring.llm_call_details (
                    span_id, model, provider, api_type,
                    input_messages, input_message_count, system_prompt,
                    output_text, output_content, reasoning_text, stop_reason,
                    input_tokens, output_tokens, reasoning_tokens, cached_tokens, total_tokens,
                    input_cost, output_cost, reasoning_cost, cached_cost, total_cost,
                    temperature, max_output_tokens, top_p,
                    reasoning_enabled, reasoning_effort, streaming,
                    response_id, config_snapshot, raw_response,
                    tool_names
                ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31)
                """,
                detail.span_id,
                detail.model,
                detail.provider,
                detail.api_type,
                _json_dumps(detail.input_messages),
                detail.input_message_count,
                detail.system_prompt,
                detail.output_text,
                _json_dumps(detail.output_content),
                detail.reasoning_text,
                detail.stop_reason,
                detail.input_tokens,
                detail.output_tokens,
                detail.reasoning_tokens,
                detail.cached_tokens,
                detail.total_tokens,
                detail.input_cost,
                detail.output_cost,
                detail.reasoning_cost,
                detail.cached_cost,
                detail.total_cost,
                detail.temperature,
                detail.max_output_tokens,
                detail.top_p,
                detail.reasoning_enabled,
                detail.reasoning_effort,
                detail.streaming,
                detail.response_id,
                _json_dumps(detail.config_snapshot),
                _json_dumps(detail.raw_response),
                _json_dumps(detail.tool_names),
            )

    async def get_llm_detail(self, span_id: str) -> dict[str, Any] | None:
        """Get LLM call details for a span."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                "SELECT * FROM monitoring.llm_call_details WHERE span_id = $1",
                span_id,
            )
            return _row_to_dict(row) if row else None

    # ══════════════════════════════════════════════════════════════
    # Agent Call Details
    # ══════════════════════════════════════════════════════════════

    async def insert_agent_detail(self, detail: AgentCallDetailRecord) -> None:
        """Insert agent call details."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO monitoring.agent_call_details (
                    span_id, agent_class, agent_name,
                    system_prompt, user_prompt_template,
                    input_messages, input_message_count, output_text,
                    output_content, context,
                    tool_count, tool_names, sub_agent_count, iteration_count, total_llm_calls,
                    config_snapshot, tool_interactions
                ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
                """,
                detail.span_id,
                detail.agent_class,
                detail.agent_name,
                detail.system_prompt,
                detail.user_prompt_template,
                _json_dumps(detail.input_messages),
                detail.input_message_count,
                detail.output_text,
                _json_dumps(detail.output_content),
                _json_dumps(detail.context),
                detail.tool_count,
                _json_dumps(detail.tool_names),
                detail.sub_agent_count,
                detail.iteration_count,
                detail.total_llm_calls,
                _json_dumps(detail.config_snapshot),
                _json_dumps(detail.tool_interactions),
            )

    async def get_agent_detail(self, span_id: str) -> dict[str, Any] | None:
        """Get agent call details for a span."""
        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                "SELECT * FROM monitoring.agent_call_details WHERE span_id = $1",
                span_id,
            )
            return _row_to_dict(row) if row else None

    # ══════════════════════════════════════════════════════════════
    # Tool Call Details
    # ══════════════════════════════════════════════════════════════

    async def insert_tool_detail(self, detail: ToolCallDetailRecord) -> None:
        """Insert tool call details."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO monitoring.tool_call_details (
                    id, span_id, tool_call_id, tool_name, tool_type,
                    arguments, result_content, is_error,
                    started_at, ended_at, duration_ms,
                    iteration_number, sequence_in_batch
                ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
                """,
                detail.id,
                detail.span_id,
                detail.tool_call_id,
                detail.tool_name,
                detail.tool_type,
                _json_dumps(detail.arguments),
                detail.result_content,
                detail.is_error,
                detail.started_at,
                detail.ended_at,
                detail.duration_ms,
                detail.iteration_number,
                detail.sequence_in_batch,
            )

    async def get_tool_details(self, span_id: str) -> list[dict[str, Any]]:
        """Get all tool call details for a span."""
        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT * FROM monitoring.tool_call_details
                WHERE span_id = $1
                ORDER BY iteration_number, sequence_in_batch
                """,
                span_id,
            )
        return [_row_to_dict(r) for r in rows]

    # ══════════════════════════════════════════════════════════════
    # Analytics Queries
    # ══════════════════════════════════════════════════════════════

    async def get_overview_stats(
        self,
        from_ts: datetime | None = None,
        to_ts: datetime | None = None,
        service_name: str | None = None,
    ) -> dict[str, Any]:
        """Get high-level overview stats for the dashboard."""
        conditions: list[str] = []
        params: list[Any] = []
        idx = 1

        if from_ts:
            conditions.append(f"created_at >= ${idx}")
            params.append(from_ts)
            idx += 1
        if to_ts:
            conditions.append(f"created_at <= ${idx}")
            params.append(to_ts)
            idx += 1
        if service_name:
            conditions.append(f"service_name = ${idx}")
            params.append(service_name)
            idx += 1

        where = f"WHERE {' AND '.join(conditions)}" if conditions else ""

        async with self.pool.acquire() as conn:
            row = await conn.fetchrow(
                f"""
                SELECT
                    COUNT(*) AS total_requests,
                    COALESCE(SUM(total_cost), 0) AS total_cost,
                    COALESCE(SUM(total_input_tokens), 0) AS total_input_tokens,
                    COALESCE(SUM(total_output_tokens), 0) AS total_output_tokens,
                    COALESCE(SUM(total_tokens), 0) AS total_tokens,
                    COALESCE(AVG(total_duration_ms), 0) AS avg_duration_ms,
                    COUNT(*) FILTER (WHERE status = 'failed') AS error_count
                FROM monitoring.traces {where}
                """,
                *params,
            )
        return _row_to_dict(row) if row else {}

    async def get_daily_usage(
        self,
        days: int = 30,
    ) -> list[dict[str, Any]]:
        """Get daily usage from traces for the last N days.

        Returns one row per day with request_count, total_cost, total_tokens.
        """
        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT
                    date_trunc('day', created_at) AS day,
                    COUNT(*) AS request_count,
                    COALESCE(SUM(total_cost), 0) AS total_cost,
                    COALESCE(SUM(total_input_tokens), 0) AS total_input_tokens,
                    COALESCE(SUM(total_output_tokens), 0) AS total_output_tokens,
                    COALESCE(SUM(total_tokens), 0) AS total_tokens
                FROM monitoring.traces
                WHERE created_at >= NOW() - ($1 || ' days')::INTERVAL
                GROUP BY day
                ORDER BY day
                """,
                str(days),
            )
        return [_row_to_dict(r) for r in rows]

    async def get_usage_trend(
        self,
        group_by: str = "hour",
        component_type: str | None = None,
        component_class: str | None = None,
        model: str | None = None,
        from_ts: datetime | None = None,
        to_ts: datetime | None = None,
        service_name: str | None = None,
    ) -> list[dict[str, Any]]:
        """Get usage trend data from the hourly rollup table."""
        interval_map = {
            "hour": "hour",
            "day": "day",
            "week": "week",
            "month": "month",
        }
        trunc = interval_map.get(group_by, "hour")

        conditions: list[str] = []
        params: list[Any] = []
        idx = 1

        if component_type:
            conditions.append(f"component_type = ${idx}")
            params.append(component_type)
            idx += 1
        if component_class:
            conditions.append(f"component_class = ${idx}")
            params.append(component_class)
            idx += 1
        if model:
            conditions.append(f"model = ${idx}")
            params.append(model)
            idx += 1
        if from_ts:
            conditions.append(f"hour_bucket >= ${idx}")
            params.append(from_ts)
            idx += 1
        if to_ts:
            conditions.append(f"hour_bucket <= ${idx}")
            params.append(to_ts)
            idx += 1
        if service_name:
            conditions.append(f"service_name = ${idx}")
            params.append(service_name)
            idx += 1

        where = f"WHERE {' AND '.join(conditions)}" if conditions else ""

        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                f"""
                SELECT
                    date_trunc('{trunc}', hour_bucket) AS period,
                    SUM(request_count) AS request_count,
                    SUM(total_input_tokens) AS total_input_tokens,
                    SUM(total_output_tokens) AS total_output_tokens,
                    SUM(total_tokens) AS total_tokens,
                    SUM(total_cost) AS total_cost,
                    AVG(avg_duration_ms) AS avg_duration_ms,
                    SUM(error_count) AS error_count
                FROM monitoring.usage_hourly_rollup {where}
                GROUP BY period
                ORDER BY period
                """,
                *params,
            )
        return [_row_to_dict(r) for r in rows]

    async def get_model_breakdown(
        self,
        from_ts: datetime | None = None,
        to_ts: datetime | None = None,
        service_name: str | None = None,
    ) -> list[dict[str, Any]]:
        """Get per-model usage breakdown."""
        conditions: list[str] = ["s.span_type = 'llm'", "s.model IS NOT NULL"]
        params: list[Any] = []
        idx = 1

        if from_ts:
            conditions.append(f"s.started_at >= ${idx}")
            params.append(from_ts)
            idx += 1
        if to_ts:
            conditions.append(f"s.started_at <= ${idx}")
            params.append(to_ts)
            idx += 1
        if service_name:
            conditions.append(f"t.service_name = ${idx}")
            params.append(service_name)
            idx += 1

        where = f"WHERE {' AND '.join(conditions)}"

        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                f"""
                SELECT
                    s.model,
                    s.provider,
                    COUNT(*) AS request_count,
                    COALESCE(SUM(l.input_tokens), 0) AS total_input_tokens,
                    COALESCE(SUM(l.output_tokens), 0) AS total_output_tokens,
                    COALESCE(SUM(l.cached_tokens), 0) AS total_cached_tokens,
                    COALESCE(SUM(l.total_tokens), 0) AS total_tokens,
                    COALESCE(SUM(l.input_cost), 0) AS total_input_cost,
                    COALESCE(SUM(l.output_cost), 0) AS total_output_cost,
                    COALESCE(SUM(l.cached_cost), 0) AS total_cached_cost,
                    COALESCE(SUM(l.total_cost), 0) AS total_cost,
                    COALESCE(AVG(s.duration_ms), 0) AS avg_duration_ms
                FROM monitoring.spans s
                JOIN monitoring.traces t ON s.trace_id = t.trace_id
                LEFT JOIN monitoring.llm_call_details l ON s.span_id = l.span_id
                {where}
                GROUP BY s.model, s.provider
                ORDER BY total_cost DESC
                """,
                *params,
            )
        return [_row_to_dict(r) for r in rows]

    async def get_agent_breakdown(
        self,
        from_ts: datetime | None = None,
        to_ts: datetime | None = None,
        group_by: str = "class",
        service_name: str | None = None,
    ) -> list[dict[str, Any]]:
        """Get per-agent usage breakdown."""
        conditions: list[str] = ["s.span_type = 'agent'"]
        params: list[Any] = []
        idx = 1

        if from_ts:
            conditions.append(f"s.started_at >= ${idx}")
            params.append(from_ts)
            idx += 1
        if to_ts:
            conditions.append(f"s.started_at <= ${idx}")
            params.append(to_ts)
            idx += 1
        if service_name:
            conditions.append(f"t.service_name = ${idx}")
            params.append(service_name)
            idx += 1

        where = f"WHERE {' AND '.join(conditions)}"
        group_col = (
            "s.component_class" if group_by == "class" else "s.component_name"
        )

        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                f"""
                SELECT
                    {group_col} AS agent,
                    s.component_class,
                    s.component_name,
                    COUNT(DISTINCT s.span_id) AS request_count,
                    COALESCE(SUM(l.input_tokens), 0) AS total_input_tokens,
                    COALESCE(SUM(l.output_tokens), 0) AS total_output_tokens,
                    COALESCE(SUM(l.cached_tokens), 0) AS total_cached_tokens,
                    COALESCE(SUM(l.total_tokens), 0) AS total_tokens,
                    COALESCE(SUM(l.total_cost), 0) AS total_cost,
                    COALESCE(AVG(s.duration_ms), 0) AS avg_duration_ms,
                    COALESCE(AVG(a.iteration_count), 0) AS avg_iterations,
                    COUNT(DISTINCT s.span_id) FILTER (WHERE s.status = 'failed') AS error_count
                FROM monitoring.spans s
                JOIN monitoring.traces t ON s.trace_id = t.trace_id
                LEFT JOIN monitoring.agent_call_details a ON s.span_id = a.span_id
                LEFT JOIN monitoring.spans child_s
                    ON child_s.parent_span_id = s.span_id AND child_s.span_type = 'llm'
                LEFT JOIN monitoring.llm_call_details l ON child_s.span_id = l.span_id
                {where}
                GROUP BY s.component_class, s.component_name
                ORDER BY total_cost DESC
                """,
                *params,
            )
        return [_row_to_dict(r) for r in rows]

    # ══════════════════════════════════════════════════════════════
    # Chain / Tree View
    # ══════════════════════════════════════════════════════════════

    async def get_trace_chain(self, trace_id: str) -> list[dict[str, Any]]:
        """Get the full span tree for a trace with ALL detail data joined.

        Returns spans ordered for tree rendering (depth-first),
        with full LLM and Agent details for expandable views.
        """
        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT
                    s.*,
                    -- LLM details (full)
                    l.model AS llm_model,
                    l.provider AS llm_provider,
                    l.api_type AS llm_api_type,
                    l.input_messages AS llm_input_messages,
                    l.input_message_count AS llm_input_message_count,
                    l.system_prompt AS llm_system_prompt,
                    l.output_text AS llm_output_text,
                    l.output_content AS llm_output_content,
                    l.reasoning_text AS llm_reasoning_text,
                    l.stop_reason,
                    l.input_tokens, l.output_tokens,
                    l.reasoning_tokens, l.cached_tokens, l.total_tokens,
                    l.input_cost, l.output_cost,
                    l.reasoning_cost, l.cached_cost, l.total_cost,
                    l.temperature AS llm_temperature,
                    l.max_output_tokens AS llm_max_output_tokens,
                    l.top_p AS llm_top_p,
                    l.reasoning_enabled AS llm_reasoning_enabled,
                    l.reasoning_effort AS llm_reasoning_effort,
                    l.streaming AS llm_streaming,
                    l.response_id AS llm_response_id,
                    l.config_snapshot AS llm_config_snapshot,
                    l.tool_names AS llm_tool_names,
                    -- Agent details (full)
                    a.agent_name AS agent_detail_name,
                    a.agent_class AS agent_detail_class,
                    a.system_prompt AS agent_system_prompt,
                    a.user_prompt_template AS agent_user_prompt_template,
                    a.input_messages AS agent_input_messages,
                    a.input_message_count AS agent_input_message_count,
                    a.output_text AS agent_output_text,
                    a.output_content AS agent_output_content,
                    a.context AS agent_context,
                    a.tool_count AS agent_tool_count,
                    a.sub_agent_count AS agent_sub_agent_count,
                    a.iteration_count,
                    a.total_llm_calls AS agent_total_llm_calls,
                    a.tool_names AS agent_tool_names,
                    a.config_snapshot AS agent_config_snapshot,
                    a.tool_interactions AS agent_tool_interactions
                FROM monitoring.spans s
                LEFT JOIN monitoring.llm_call_details l ON s.span_id = l.span_id
                LEFT JOIN monitoring.agent_call_details a ON s.span_id = a.span_id
                WHERE s.trace_id = $1
                ORDER BY s.depth, s.sequence_number, s.started_at
                """,
                trace_id,
            )
        return [_row_to_dict(r) for r in rows]

    async def get_tool_calls_for_trace(
        self, trace_id: str
    ) -> dict[str, list[dict[str, Any]]]:
        """Get all tool calls for a trace, grouped by span_id."""
        async with self.pool.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT tc.*
                FROM monitoring.tool_call_details tc
                JOIN monitoring.spans s ON tc.span_id = s.span_id
                WHERE s.trace_id = $1
                ORDER BY tc.iteration_number, tc.sequence_in_batch
                """,
                trace_id,
            )
        result: dict[str, list[dict[str, Any]]] = {}
        for r in rows:
            d = _row_to_dict(r)
            sid = d["span_id"]
            if sid not in result:
                result[sid] = []
            result[sid].append(d)
        return result

    # ══════════════════════════════════════════════════════════════
    # Rollup
    # ══════════════════════════════════════════════════════════════

    async def run_hourly_rollup(self, hour: datetime) -> None:
        """Run hourly aggregation for a specific hour bucket."""
        async with self.pool.acquire() as conn:
            await conn.execute(
                """
                INSERT INTO monitoring.usage_hourly_rollup (
                    hour_bucket, component_type, component_class, component_name,
                    model, provider, service_name, environment,
                    request_count, total_input_tokens, total_output_tokens,
                    total_reasoning_tokens, total_tokens, total_cost,
                    avg_duration_ms, min_duration_ms, max_duration_ms, error_count
                )
                SELECT
                    date_trunc('hour', s.started_at) AS hour_bucket,
                    s.span_type, s.component_class, s.component_name,
                    s.model, s.provider, t.service_name, t.environment,
                    COUNT(*),
                    COALESCE(SUM(l.input_tokens), 0),
                    COALESCE(SUM(l.output_tokens), 0),
                    COALESCE(SUM(l.reasoning_tokens), 0),
                    COALESCE(SUM(l.total_tokens), 0),
                    COALESCE(SUM(l.total_cost), 0),
                    AVG(s.duration_ms), MIN(s.duration_ms), MAX(s.duration_ms),
                    COUNT(*) FILTER (WHERE s.status = 'failed')
                FROM monitoring.spans s
                JOIN monitoring.traces t ON s.trace_id = t.trace_id
                LEFT JOIN monitoring.llm_call_details l ON s.span_id = l.span_id
                WHERE s.started_at >= $1
                  AND s.started_at < $1 + INTERVAL '1 hour'
                GROUP BY 1,2,3,4,5,6,7,8
                ON CONFLICT (hour_bucket, component_type, component_class,
                             component_name, model, service_name, environment)
                DO UPDATE SET
                    request_count = EXCLUDED.request_count,
                    total_input_tokens = EXCLUDED.total_input_tokens,
                    total_output_tokens = EXCLUDED.total_output_tokens,
                    total_reasoning_tokens = EXCLUDED.total_reasoning_tokens,
                    total_tokens = EXCLUDED.total_tokens,
                    total_cost = EXCLUDED.total_cost,
                    avg_duration_ms = EXCLUDED.avg_duration_ms,
                    min_duration_ms = EXCLUDED.min_duration_ms,
                    max_duration_ms = EXCLUDED.max_duration_ms,
                    error_count = EXCLUDED.error_count
                """,
                hour,
            )

    # ══════════════════════════════════════════════════════════════
    # Retention
    # ══════════════════════════════════════════════════════════════

    async def cleanup_old_data(self, retention_days: int) -> int:
        """Delete traces older than retention_days. Returns deleted count."""
        if retention_days <= 0:
            return 0
        async with self.pool.acquire() as conn:
            result = await conn.execute(
                """
                DELETE FROM monitoring.traces
                WHERE created_at < NOW() - ($1 || ' days')::INTERVAL
                  AND status != 'in_progress'
                """,
                str(retention_days),
            )
            # result is like "DELETE 42"
            parts = result.split()
            return int(parts[1]) if len(parts) > 1 else 0
