"""Persistent record of raw sources that could NOT be ingested.

A run prints unreadable/errored sources, then moves on — so once the console scrolls, there is no
lasting answer to "which files never made it into the wiki, and why?". This module keeps that answer
in a tiny JSON sidecar next to the manifest (``wiki/.citadel_failures.json``):

    {"raw/scan.tiff": {"reason": "unreadable", "detail": "no extractable text (binary/unsupported)"},
     "raw/huge.log":  {"reason": "error", "detail": "raw/huge.log: agent session failed", "model": "claude:sonnet"}}

``reason`` is a coarse category (``unreadable`` / ``error`` / ``timeout`` / ``duplicate``) for
at-a-glance triage; ``detail`` is the human-readable specifics; ``model`` (optional) is the backend
that was attempting it. When the source's content hash and stat were taken this run, the entry
additionally records ``sha256`` + the scan-cache stat fields (``size``/``mtime_ns``/``ctime_ns``/
``hashed_at_ns``, same shape as a manifest entry — see :mod:`citadel.manifest`), so an unchanged
stuck source (a dedup-dropped ``.pptx`` twin, an unreadable binary, a source whose session keeps
erroring) joins the stat quick check instead of being re-hashed on every later run. The file is
REGENERATED by ingest — a source that later succeeds (or is recognized as a move/duplicate) is
dropped — so it always reflects the CURRENT set of stuck sources. Like the manifest: plain JSON,
no DB. :func:`citadel.store.rebuild_indexes` renders it into ``wiki/sources/index.md`` so the
failures are visible in the wiki, not just this file.
"""

from __future__ import annotations

import json
import os

from . import config, manifest


# Coarse failure categories.
UNREADABLE = "unreadable"  # a binary/unsupported file with no extractable text (never fed to agent)
ERROR = "error"  # the agent session failed (bad edit, CLI error, validation, temp-write, …)
TIMEOUT = "timeout"  # the agent session exceeded CITADEL_LLM_TIMEOUT
DUPLICATE = "duplicate"  # skipped: same basename as another format that was ingested instead
CURATE = "curate"  # a `citadel curate` cluster session failed its gate; keyed by the PAGE rel_path
# with an additive integer ``attempts`` field: attempt-capped, never
# auto-retried once it reaches the cap, until an explicit `citadel curate --retry` rerun.


def load() -> dict[str, dict]:
    """json.loads(FAILURES_PATH) or {} if missing/empty/corrupt (mirrors :func:`manifest.load`)."""
    path = config.failures_path()
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, FileNotFoundError):
        return {}
    if not text.strip():
        return {}
    try:
        data = json.loads(text)
    except (ValueError, TypeError):
        return {}
    return data if isinstance(data, dict) else {}


def save(failures: dict[str, dict]) -> None:
    """Write ``failures`` to FAILURES_PATH (sorted, indented, trailing newline), or REMOVE the file
    when empty so a wiki with nothing stuck carries no stale failures sidecar."""
    path = config.failures_path()
    if not failures:
        try:
            path.unlink()
        except (OSError, FileNotFoundError):
            pass
        return
    config.robust_mkdir(path.parent)
    config.atomic_write_text(path, json.dumps(failures, sort_keys=True, indent=2) + "\n")


def reason_for(detail: str) -> str:
    """Categorize an agent-session error message: ``TIMEOUT`` when it mentions a timeout, else
    ``ERROR``. (Unreadable sources are recorded with :data:`UNREADABLE` directly.)"""
    low = (detail or "").lower()
    return TIMEOUT if ("timeout" in low or "timed out" in low) else ERROR


def record(
    failures: dict[str, dict],
    key: str,
    reason: str,
    detail: str,
    model: str | None = None,
    *,
    sha: str | None = None,
    st: os.stat_result | None = None,
) -> None:
    """Add/overwrite the failure for source ``key`` (mutates in place; caller saves). ``sha`` +
    ``st`` — the content hash and stat discovery already took for the file this run — add the
    manifest-shaped scan-cache fields so a later run's quick check can skip re-hashing the
    unchanged stuck source (see the module docstring); omitted for a failure with no readable
    file behind it (a vanished source's delete-cleanup, a repo session)."""
    entry: dict = {"reason": reason, "detail": detail}
    if model:
        entry["model"] = model
    if sha:
        entry["sha256"] = sha
    if st is not None:
        entry.update(manifest.stat_fields(st))
    failures[key] = entry


def clear(failures: dict[str, dict], key: str) -> None:
    """Drop any recorded failure for ``key`` — it ingested (or was recognized as a move) this run,
    so it is no longer stuck. No-op when absent (mutates in place; caller saves)."""
    failures.pop(key, None)
