"""Aggregate per-target dispatch results into an ``EventLog.details`` summary.

Every dispatch site (``event_dispatch``, ``watcher``, ``deferred_dispatch``,
``scheduled_dispatch``) calls :func:`NotificationDispatcher.dispatch` and
gets back a ``list[dict]`` — one entry per target. Each entry has at minimum
``success: bool`` and (on failure) ``error: str``. Telegram media-group
sends additionally include ``delivered_count``, ``skipped_count``,
``failed_count``, ``errors`` and ``failed_at_chunk`` so a partial delivery
is observable from the result.

Historically the dashboard only saw the per-row ``status`` derived at
EventLog insert time — partial failures (one target out of three failed,
two assets out of ten dropped) showed up as a generic success/failure and
the operator had to read stderr to find the cause. This module collapses
the per-target dicts into a small ``dispatch_summary`` block that's merged
into ``EventLog.details`` after the dispatch completes, so the same
information surfaces in the UI without re-reading logs.
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any

from sqlalchemy.orm.attributes import flag_modified
from sqlmodel.ext.asyncio.session import AsyncSession

from ..database.models import EventLog

_LOGGER = logging.getLogger(__name__)

# Bound the error list we stash on the row. A pathological dispatch (50
# targets, 50 media items each, all failing) would otherwise bloat the
# row past anything useful — and the dashboard renders a fixed-height
# strip anyway. Excess entries are summarized as ``errors_truncated``.
_MAX_ERRORS = 20
_MAX_MEDIA_ERRORS = 20
# Cap error message length to avoid pathological payloads in the row.
_MAX_ERROR_MSG_LEN = 500
# Distinct sentinel so an operator scanning the dashboard can tell our
# clipping apart from a literal ``…`` that often appears in upstream API
# error text (Telegram does this in some Bad Request messages).
_TRUNCATION_MARKER = "…[truncated]"


def _trim(value: Any) -> Any:
    """Truncate string values to keep the persisted summary bounded."""
    if isinstance(value, str) and len(value) > _MAX_ERROR_MSG_LEN:
        return value[:_MAX_ERROR_MSG_LEN] + _TRUNCATION_MARKER
    return value


def summarize_dispatch_results(
    results: list[dict[str, Any]],
) -> dict[str, Any]:
    """Aggregate per-target dispatch results into a compact summary dict.

    The shape is intentionally narrow so it round-trips cleanly through
    SQLite JSON storage and stays cheap to render in the dashboard.

    Returns a dict with keys:

    * ``targets_attempted`` / ``targets_succeeded`` / ``targets_failed``
      — counts across the results list.
    * ``errors`` — per-target failure entries
      (``[{index, error}, ...]``), capped at ``_MAX_ERRORS``.
    * ``media`` — present only when at least one result reports media
      counts. ``{delivered, skipped, failed}``.
    * ``media_errors`` — per-item / per-chunk failure entries from the
      Telegram media-group fallback, capped at ``_MAX_MEDIA_ERRORS``.
    * ``errors_truncated`` / ``media_errors_truncated`` — count of dropped
      entries when the corresponding cap was hit. Present only when > 0.

    Input shape: each entry is what ``NotificationDispatcher._aggregate_results``
    returns for one target — ``{success, receivers, successes, failures,
    results: [per-receiver, ...], errors?, error?}``. Media counts live
    on each per-receiver dict under ``media_delivered_count`` /
    ``media_skipped_count`` / ``media_failed_count`` /  ``media_errors``,
    so the walk drills one level deeper than the obvious top-level reads.
    For backward compat with simpler call sites that pass a single leaf
    dict (the Telegram media-group result directly), the leaf shape is
    accepted as a fallback when ``results`` is absent.
    """
    if not results:
        # Empty results = nothing to summarize. Returning ``{}`` lets the
        # callers' ``if summary`` / ``if results`` guards keep the row
        # clean rather than stamping a misleading zero-counts block.
        return {}

    succeeded = 0
    failed = 0
    errors: list[dict[str, Any]] = []
    media_delivered = 0
    media_skipped = 0
    media_failed = 0
    media_errors: list[dict[str, Any]] = []
    has_media_counts = False
    errors_dropped = 0
    media_errors_dropped = 0

    for index, result in enumerate(results):
        if result.get("success"):
            succeeded += 1
        else:
            failed += 1
            if len(errors) < _MAX_ERRORS:
                errors.append({
                    "index": index,
                    "error": _trim(result.get("error", "unknown")),
                })
            else:
                errors_dropped += 1

        # Per-receiver detail is bundled under ``results`` by the
        # dispatcher's ``_aggregate_results``. Walk it when present; fall
        # back to reading the leaf shape directly so older callers and
        # direct-test fixtures keep working.
        per_receiver = result.get("results")
        leaves: list[dict[str, Any]]
        if isinstance(per_receiver, list):
            leaves = [r for r in per_receiver if isinstance(r, dict)]
        else:
            leaves = [result]

        for receiver_index, leaf in enumerate(leaves):
            # The dispatcher's Telegram path renames the media counters
            # to ``media_*`` to disambiguate them from the surrounding
            # text-message result. Accept both names so a future provider
            # that surfaces top-level counts (single-shot text+media)
            # also gets picked up.
            d = leaf.get("media_delivered_count")
            if d is None:
                d = leaf.get("delivered_count")
            s = leaf.get("media_skipped_count")
            if s is None:
                s = leaf.get("skipped_count")
            f = leaf.get("media_failed_count")
            if f is None:
                f = leaf.get("failed_count")
            if d is not None or s is not None or f is not None:
                has_media_counts = True
                media_delivered += int(d or 0)
                media_skipped += int(s or 0)
                media_failed += int(f or 0)

            sub_errors = leaf.get("media_errors") or leaf.get("errors") or []
            for sub in sub_errors:
                if not isinstance(sub, dict):
                    # ``_aggregate_results`` populates a string list at
                    # the target level; only dict entries carry structured
                    # per-chunk / per-item detail worth keeping here.
                    continue
                if len(media_errors) >= _MAX_MEDIA_ERRORS:
                    media_errors_dropped += 1
                    continue
                entry: dict[str, Any] = {"target_index": index}
                # Only stamp the receiver index when we actually drilled
                # into a multi-receiver target — single-leaf fallbacks
                # leave the key off so the existing one-target tests
                # stay shape-compatible.
                if len(leaves) > 1 or isinstance(per_receiver, list):
                    entry["receiver_index"] = receiver_index
                entry.update({k: _trim(v) for k, v in sub.items()})
                media_errors.append(entry)

    summary: dict[str, Any] = {
        "targets_attempted": len(results),
        "targets_succeeded": succeeded,
        "targets_failed": failed,
    }
    if errors:
        summary["errors"] = errors
    if errors_dropped:
        summary["errors_truncated"] = errors_dropped
    if has_media_counts:
        summary["media"] = {
            "delivered": media_delivered,
            "skipped": media_skipped,
            "failed": media_failed,
        }
    if media_errors:
        summary["media_errors"] = media_errors
    if media_errors_dropped:
        summary["media_errors_truncated"] = media_errors_dropped
    return summary


def attach_summary_in_place(
    row: EventLog, results: list[dict[str, Any]],
) -> None:
    """Merge a dispatch summary into ``row.details`` before its session commits.

    Use when the EventLog row is still attached to a session that has not
    yet committed — the caller's session.commit() carries the update.
    """
    summary = summarize_dispatch_results(results)
    if not summary:
        return
    details = dict(row.details or {})
    # Don't overwrite a summary that a caller / previous pass already
    # set explicitly — that's the same "caller wins" rule the correlation
    # enricher follows in ``log_context.py``.
    if "dispatch_summary" in details:
        return
    details["dispatch_summary"] = summary
    row.details = details
    # Identity-changing reassignment above is enough for SQLAlchemy to mark
    # the column dirty. ``flag_modified`` is belt-and-suspenders against a
    # future refactor that switches this to in-place mutation.
    flag_modified(row, "details")


async def record_dispatch_summary_async(
    session: AsyncSession,
    event_log_id: int | None,
    results: list[dict[str, Any]],
) -> None:
    """Best-effort update of an already-committed ``EventLog`` row.

    Used by call sites where the row was committed in an earlier
    transaction (the polling watcher commits its EventLog rows before
    invoking the dispatcher, so we need a follow-up update).

    Best-effort: a DB hiccup here must never abort the wider dispatch
    flow — the row keeps its prior status / details and the operator
    can still trace via stderr (via the ``dispatch_id`` correlation
    written at insert time).
    """
    if event_log_id is None or not results:
        return
    summary = summarize_dispatch_results(results)
    if not summary:
        return
    try:
        row = await session.get(EventLog, event_log_id)
        if row is None:
            return
        details = dict(row.details or {})
        if "dispatch_summary" in details:
            return
        details["dispatch_summary"] = summary
        row.details = details
        flag_modified(row, "details")
        session.add(row)
        await session.commit()
    except asyncio.CancelledError:
        # Cancellation must propagate so APScheduler can drain shutdown.
        # Swallowing it here would pin the task and leave the row in an
        # indeterminate state.
        raise
    except Exception:  # noqa: BLE001
        _LOGGER.exception(
            "Failed to record dispatch_summary on event_log %s", event_log_id,
        )
