"""Note usage telemetry — was a surfaced note ever actually pulled?

Two event streams, deliberately independent:

  - SURFACED: we put this note's title in front of the agent (auto-inject, or
    either arm of the write-path prior-art trigger).
  - PULLED: someone then opened it in full (get_snippet / get_note / the REST
    detail route).

The ratio between them is the signal. A snippet surfaced forty times and never
pulled is not neutral — it occupies the injection budget on every future turn
and dilutes the menu — so this is what makes dead weight visible and prunable.

Design notes (mirrors retrieval_telemetry, for the same reasons):
  - Writes are fire-and-forget. `record_surfaced` / `record_pulled` extract
    plain ints synchronously and schedule the insert as a background task, so
    telemetry never adds latency to — or can break — the surface it observes.
  - Every failure path is swallowed. Losing a usage row costs a data point;
    raising would cost the operator their retrieval.
  - Reads (`usage_for_notes`) are NOT fire-and-forget — a readout the caller
    awaits, aggregated in one round-trip for a whole page of snippets rather
    than per row.
"""
from __future__ import annotations

import asyncio
import logging

from sqlalchemy import func, select

from scribe.models import async_session
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent

logger = logging.getLogger(__name__)


async def _insert_events(rows: list[dict]) -> None:
    """Persist usage rows. Best-effort: all errors are swallowed."""
    try:
        async with async_session() as session:
            session.add_all([NoteUsageEvent(**row) for row in rows])
            await session.commit()
    except Exception:
        logger.debug("note usage telemetry write skipped", exc_info=True)


def _schedule(rows: list[dict]) -> None:
    if not rows:
        return
    try:
        asyncio.get_running_loop().create_task(_insert_events(rows))
    except RuntimeError:
        # No running loop (sync context outside the app) — skip rather than
        # block. Every app path runs on the loop.
        logger.debug("note usage telemetry skipped — no running event loop")


def record_surfaced(
    *, user_id: int | None, note_ids: list[int] | set[int], source: str
) -> None:
    """Fire-and-forget: record that these notes were shown to the agent.

    Takes the whole menu at once — one insert per surfacing event, not per note
    — because a menu is a single decision and its rows should land together.
    """
    try:
        rows = [
            {
                "user_id": user_id,
                "note_id": int(nid),
                "event": SURFACED,
                "source": source,
            }
            for nid in note_ids
        ]
    except Exception:
        logger.debug("note usage payload build failed", exc_info=True)
        return
    _schedule(rows)


def record_pulled(*, user_id: int | None, note_id: int, source: str) -> None:
    """Fire-and-forget: record that a note was opened in full."""
    try:
        rows = [
            {
                "user_id": user_id,
                "note_id": int(note_id),
                "event": PULLED,
                "source": source,
            }
        ]
    except Exception:
        logger.debug("note usage payload build failed", exc_info=True)
        return
    _schedule(rows)


def empty_usage() -> dict:
    """The zero readout — what a note with no recorded events looks like.

    Callers render this shape unconditionally, so a note predating the table
    reads as "never surfaced, never pulled" rather than as a missing key.
    """
    return {
        "surfaced_count": 0,
        "pull_count": 0,
        "last_surfaced_at": None,
        "last_pulled_at": None,
    }


async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
    """Aggregate usage for a set of notes: {note_id: {counts + timestamps}}.

    One GROUP BY for the whole page rather than a query per row — this feeds a
    list view, so the per-row shape would be N+1 by construction. Notes with no
    events are returned with `empty_usage()` so the caller never has to
    distinguish "no events" from "not in the result".
    """
    ids = [int(n) for n in note_ids]
    out: dict[int, dict] = {nid: empty_usage() for nid in ids}
    if not ids:
        return out

    try:
        async with async_session() as session:
            rows = (
                await session.execute(
                    select(
                        NoteUsageEvent.note_id,
                        NoteUsageEvent.event,
                        func.count().label("n"),
                        func.max(NoteUsageEvent.created_at).label("last_at"),
                    )
                    .where(NoteUsageEvent.note_id.in_(ids))
                    .group_by(NoteUsageEvent.note_id, NoteUsageEvent.event)
                )
            ).all()
    except Exception:
        # A telemetry readout must not be able to break the list it decorates.
        logger.debug("note usage readout failed", exc_info=True)
        return out

    for note_id, event, n, last_at in rows:
        slot = out.get(int(note_id))
        if slot is None:
            continue
        if event == SURFACED:
            slot["surfaced_count"] = int(n)
            slot["last_surfaced_at"] = last_at.isoformat() if last_at else None
        elif event == PULLED:
            slot["pull_count"] = int(n)
            slot["last_pulled_at"] = last_at.isoformat() if last_at else None
    return out
