"""Human-readable hints for ``skip_reason`` codes recorded in the task ledger.

Mugi's background loops (heartbeat, idle tick, curiosity, active-memory,
/btw side questions) log ``status="skipped"`` rows with short machine codes
like ``active_hours`` or ``thread_hot_recent_user``.  The operator-facing
Diagnostics pane should show those skips as **plain language** so nobody has
to open the Python source to ask "why didn't automation run?".

The catalog is intentionally:

- **Static** — no user data, no secrets; safe to ship inside the doctor
  report and any future MCP tool.
- **Non-alarming** — skipped is normal behaviour, not a failure.
- **Defensive** — unknown codes fall back to a generic hint; known dynamic
  shapes (e.g. ``mesh_thread:<prefix>``, ``per-thread cooldown (60s)``)
  are collapsed to a single helpful entry via prefix / regex buckets.
"""

from __future__ import annotations

import re
from typing import TypedDict


class SkipReasonHint(TypedDict):
    code: str
    title: str
    hint: str


# Known exact codes emitted in-tree (audited across mugi_runner, mugi_backend,
# utils.agent_plane, utils.idle_tick, utils.active_memory, utils.btw,
# app_http_routes).  Keep titles short (fits on one ledger row) and hints to
# one or two sentences of actionable context.
_EXACT: dict[str, dict[str, str]] = {
    # Heartbeat / curiosity pre-flight gates.
    "active_hours": {
        "title": "Quiet hours",
        "hint": "Automation is outside the configured active-hours window. Adjust activeHoursV1 in preferences to change when the agent runs.",
    },
    "no_work": {
        "title": "Nothing new to do",
        "hint": "Signals and cache were unchanged since the last cycle, so the heartbeat skipped. New signals or a HEARTBEAT.md task will wake it.",
    },
    "no_tasks_due": {
        "title": "No tasks due",
        "hint": "HEARTBEAT.md has a tasks block but nothing is due right now. Add or un-defer a task to run the next cycle.",
    },
    "skip_gate": {
        "title": "Skip gate",
        "hint": "Heartbeat skip gate fired without a specific reason code. Enable skip_gate_debug in the heartbeat config to log details.",
    },
    # Idle tick gates (utils/idle_tick.py + mugi_backend.py).
    "disabled_by_user": {
        "title": "Disabled in preferences",
        "hint": "Idle-in-chat ticks are off. Toggle them in Preferences → Advanced to re-enable.",
    },
    "disabled_agent_plane": {
        "title": "Agent plane disabled",
        "hint": "The agent plane is off in the TOML config; idle ticks are suppressed until it is re-enabled.",
    },
    "no_thread": {
        "title": "No active thread",
        "hint": "Idle tick fired without a current chat thread. This is expected at startup and between sessions.",
    },
    "thread_running": {
        "title": "Thread already running",
        "hint": "The target thread is mid-turn. The next tick will try again.",
    },
    "agent_busy": {
        "title": "Agent busy",
        "hint": "Another agent turn is already running. The next tick will retry.",
    },
    "pending_attachments": {
        "title": "Attachments pending",
        "hint": "The user has attached files that have not been sent yet. The tick will retry once they are consumed or cleared.",
    },
    "thread_hot_recent_user": {
        "title": "Recent user activity",
        "hint": "The user messaged the thread recently; idle tick skips until the thread cools down (configured by idle_tick_hot_thread_minutes).",
    },
    "pending_mesh_task_wait": {
        "title": "Waiting on peer",
        "hint": "A mesh task is still waiting for a peer reply. Idle tick resumes once the peer responds or the task times out.",
    },
    "claim_held": {
        "title": "Another idle tick is running",
        "hint": "A sibling process holds the idle-tick claim. Only one may run at a time; the next tick will try again.",
    },
    # /btw side questions (utils/btw.py).
    "empty_question": {
        "title": "Empty /btw question",
        "hint": "The side-question command was invoked without text. Retry with a question, e.g. `/btw what is MLX?`.",
    },
    "cancelled": {
        "title": "Cancelled",
        "hint": "The operation was cancelled before it finished.",
    },
    "cancelled_by_next_btw": {
        "title": "Superseded by newer /btw",
        "hint": "A newer /btw replaced this one. Only the latest runs.",
    },
    # Active-memory pre-reply extractor (utils/active_memory.py).
    "subagent_mode": {
        "title": "Subagent context",
        "hint": "Active memory does not run from inside subagent turns by design.",
    },
    "heartbeat": {
        "title": "Heartbeat context",
        "hint": "Active memory skips background heartbeats; they already preload their own context.",
    },
    "no_thread_id": {
        "title": "No thread id",
        "hint": "Active memory needs a thread id to run. This usually means the request path is not a user chat turn.",
    },
    "btw_thread": {
        "title": "Ephemeral /btw thread",
        "hint": "Active memory never runs on ephemeral /btw threads.",
    },
    "disabled": {
        "title": "Disabled",
        "hint": "This subsystem is turned off in configuration.",
    },
    "no_user_text": {
        "title": "No user text",
        "hint": "The most recent message had no user text to analyse, so no extraction ran.",
    },
    "no_memory_md": {
        "title": "No memory hub",
        "hint": "The workspace has no MEMORY.md hub yet (and no memory/shards/ content), so there is nothing to surface.",
    },
    "utility_unavailable_or_empty": {
        "title": "Utility model unavailable",
        "hint": "The lightweight utility model did not return a result. Check the utility model configuration or fall back to the main LLM.",
    },
    "empty_result": {
        "title": "No relevant memory",
        "hint": "The extractor ran but did not find anything worth surfacing for this turn.",
    },
    # Neko / shared-browser automation codes (templates/plugin_examples/neko).
    "cdp_unreachable": {
        "title": "Shared browser unreachable",
        "hint": "The agent could not reach Chromium via CDP. Check that mugi-neko is running (docker ps | grep mugi-neko) and that neko_cdp_url matches the published debug port.",
    },
    "no_page_target": {
        "title": "No open page tab",
        "hint": "Chromium reported no page-type target. The user may have closed every tab — call neko_navigate with an https:// URL to open one.",
    },
    "wait_timeout:url_equals": {
        "title": "Wait timeout (URL)",
        "hint": "neko_wait_for did not see the expected URL within the timeout. Confirm the exact URL (trailing slash, fragment) or use url_prefix for looser matching.",
    },
    "wait_timeout:url_prefix": {
        "title": "Wait timeout (URL prefix)",
        "hint": "neko_wait_for did not see a URL that starts with the given prefix within the timeout. The page may have redirected through a login or consent flow that blocks automated navigation.",
    },
    "wait_timeout:text": {
        "title": "Wait timeout (text)",
        "hint": "neko_wait_for did not find the target text on the page within the timeout. The substring must appear in document.body.innerText; dynamic content rendered in a shadow root or iframe is not observed.",
    },
    "policy_blocked": {
        "title": "Policy blocked",
        "hint": "The tool was denied by tool_policy. Read the full [Policy blocked] message — common causes are plan mode, shell disabled, or unattended-external contexts (webhooks/cron) needing MUGI_ALLOW_UNATTENDED_BROWSER_AUTOMATION=1 for shared-browser automation.",
    },
}


# Prefix / regex families — used when the runtime composes reasons dynamically.
# Ordered list; first match wins.
_FAMILIES: list[tuple[re.Pattern[str], dict[str, str]]] = [
    (
        re.compile(r"^mesh_thread:"),
        {
            "title": "Mesh / peer thread",
            "hint": "Active memory never runs in mesh, group, or peer threads so private memory cannot leak to other participants.",
        },
    ),
    (
        re.compile(r"^per-thread cooldown"),
        {
            "title": "Per-thread cooldown",
            "hint": "Idle tick is rate-limited per thread. The cooldown window is configurable via idle_tick_cooldown_seconds.",
        },
    ),
    (
        re.compile(r"^daily idle tick limit"),
        {
            "title": "Daily idle-tick cap",
            "hint": "Idle ticks hit today's per-day cap. The counter resets at local midnight; raise idle_tick_max_per_day to allow more.",
        },
    ),
    (
        re.compile(r"^wait_timeout:selector="),
        {
            "title": "Wait timeout (selector)",
            "hint": "neko_wait_for did not find the CSS selector within the timeout. Open neko_page_visible_text to confirm the expected content rendered; selectors do not cross shadow roots or iframes.",
        },
    ),
]


_GENERIC_FALLBACK: dict[str, str] = {
    "title": "Skipped",
    "hint": "This run was skipped on purpose. Open the ledger row or logs if you need the exact cause.",
}


def expand_skip_reason(reason: str | None) -> SkipReasonHint:
    """Return a ``SkipReasonHint`` for *reason*.

    Always returns a populated dict, even for unknown or empty codes, so
    UI callers can render a hint without branching on ``None``.
    """
    code = (reason or "").strip()
    if not code:
        return SkipReasonHint(code="", **_GENERIC_FALLBACK)

    exact = _EXACT.get(code)
    if exact is not None:
        return SkipReasonHint(code=code, title=exact["title"], hint=exact["hint"])

    for pattern, entry in _FAMILIES:
        if pattern.search(code):
            return SkipReasonHint(code=code, title=entry["title"], hint=entry["hint"])

    return SkipReasonHint(code=code, **_GENERIC_FALLBACK)


def full_catalog() -> dict[str, dict[str, str]]:
    """Return a client-friendly catalog dict keyed by exact code.

    Only exact codes are shipped verbatim; prefix families are represented by
    a single placeholder entry so the client can render sensible hints for
    runtime-shaped reasons without us flattening every possible variant.
    """
    out: dict[str, dict[str, str]] = {
        code: {"title": entry["title"], "hint": entry["hint"]}
        for code, entry in _EXACT.items()
    }
    for pattern, entry in _FAMILIES:
        sample = pattern.pattern.lstrip("^").rstrip("$")
        out.setdefault(sample, {"title": entry["title"], "hint": entry["hint"]})
    out["__fallback__"] = dict(_GENERIC_FALLBACK)
    return out
