"""Gateway-side clarify primitive (blocking event-based queue). The agent runs on a worker
thread while the event loop handles the user's reply, so a pending clarify is stored
module-level (same shape as ``tools.approval``) and the agent thread blocks on an ``Event``
until an adapter button callback or the gateway text-intercept resolves it, or the timeout
fires. Adapters render inline buttons (an "Other" row flips the entry into text-capture
mode) or a numbered-list text fallback."""

from __future__ import annotations
import json
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional

logger = logging.getLogger(__name__)


@dataclass
class _ClarifyEntry:
    """One pending clarify request inside a gateway session."""
    clarify_id: str
    session_key: str
    question: str
    choices: Optional[List[str]]
    multi_select: bool = False
    event: threading.Event = field(default_factory=threading.Event)
    response: Optional[str] = None
    awaiting_text: bool = False  # set when user picked "Other" or clarify is open-ended


_lock = threading.RLock()
_entries: Dict[str, _ClarifyEntry] = {}  # clarify_id -> entry (button callbacks)
_session_index: Dict[str, List[str]] = {}  # session_key -> [clarify_id] FIFO (text intercept, cleanup)
# Per-session notify callbacks (gateway -> adapter bridge); mirrors tools.approval. Tests clear it.
_notify_cbs: Dict[str, Callable[[_ClarifyEntry], None]] = {}

# Outcomes for typed clarify replies. Gateway cancels the pending prompt on
# free prose (deadlock break) but keeps it armed for a retryable bad selection.
TEXT_RESOLVED = "resolved"
TEXT_REJECTED_PROSE = "rejected_prose"
TEXT_REJECTED_SELECTION = "rejected_selection"
TEXT_NO_PENDING = "no_pending"
SKIP_WORD = "skip"
SKIPPED = "\x00skipped"
CANCELLED = "\x00cancelled"


def register(clarify_id: str, session_key: str, question: str, choices: Optional[List[str]],
             multi_select: bool = False) -> _ClarifyEntry:
    """Register a pending clarify request; caller then blocks on ``wait_for_response``.
    Open-ended (no choices) entries start in text mode: the next message IS the response."""
    entry = _ClarifyEntry(clarify_id, session_key, question, list(choices) if choices else None,
                          bool(multi_select) and bool(choices), awaiting_text=not bool(choices))
    with _lock:
        _entries[clarify_id] = entry
        _session_index.setdefault(session_key, []).append(clarify_id)
    return entry


def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]:
    """Block until the entry resolves or ``timeout`` (``<= 0`` = unlimited) elapses; None on
    timeout/unknown id. Polls in 1s slices so the inactivity heartbeat keeps firing (a
    single long ``Event.wait`` would let the gateway watchdog kill a live prompt)."""
    with _lock:
        entry = _entries.get(clarify_id)
    if entry is None:
        return None
    try:
        from tools.environments.base import touch_activity_if_due
    except Exception:  # pragma: no cover - optional
        touch_activity_if_due = None
    deadline = None if timeout is None or float(timeout) <= 0.0 else time.monotonic() + float(timeout)
    activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()}
    while True:
        remaining = 1.0 if deadline is None else deadline - time.monotonic()
        if remaining <= 0 or entry.event.wait(timeout=min(1.0, remaining)):
            break
        # Periodic activity touch so the gateway's inactivity timeout doesn't kill the agent during long
        # code execution (#10807).
        if touch_activity_if_due is not None:
            touch_activity_if_due(activity_state, "waiting for user clarify response")
    with _lock:
        _entries.pop(clarify_id, None)  # regardless of outcome
        ids = _session_index.get(entry.session_key) or []
        if clarify_id in ids:
            ids.remove(clarify_id)
            if not ids:
                _session_index.pop(entry.session_key, None)
    return entry.response


def resolve_gateway_clarify(clarify_id: str, response: str) -> bool:
    """Unblock the waiter on ``clarify_id``; False if already resolved/expired/unknown."""
    with _lock:
        entry = _entries.get(clarify_id)
        if entry is None or entry.event.is_set():
            return False
        entry.response = str(response) if response is not None else ""
        entry.event.set()
        return True


def get_pending_for_session(session_key: str, *, include_choice_prompts: bool = False) -> Optional[_ClarifyEntry]:
    """Oldest pending entry awaiting free text (open-ended, or after "Other");
    ``include_choice_prompts=True`` returns the oldest unresolved entry of any kind (user
    typed at an active choice prompt: resolve it rather than queue a follow-up turn)."""
    with _lock:
        for cid in _session_index.get(session_key) or []:
            entry = _entries.get(cid)
            if entry is not None and (include_choice_prompts or entry.awaiting_text):
                return entry
        return None


def _match_label(text: str, choices: List[str]) -> Optional[str]:
    """Stripped choice text matching ``text`` case-insensitively, ignoring the '(Recommended)'
    suffix the first choice carries by the time it reaches adapters; None if no match."""
    from tools.clarify_tool import strip_recommended
    wanted = strip_recommended(text).casefold()
    for choice in choices:
        if strip_recommended(str(choice)).casefold() == wanted:
            return str(choice).strip()
    return None


def _split_tokens(text: str) -> Optional[List[str]]:
    """Comma-separated tokens, or space-separated all-numeric tokens ("1 3"); else None."""
    if "," in text:
        return [t.strip() for t in text.split(",") if t.strip()]
    parts = text.split()
    return parts if len(parts) > 1 and all(p.isdigit() for p in parts) else None


def _is_int(text: str) -> bool:
    try:
        int(text)
        return True
    except ValueError:
        return False


def _selection_attempt_tokens(text: str, choices: Optional[List[str]] = None) -> Optional[List[str]]:
    """Tokens when ``text`` looks like a typed selection (bare int, comma list,
    all-numeric space list); None for free prose so the gateway can release the
    clarify. Comma-list labels may span up to the longest choice's word count."""
    stripped = str(text).strip()
    if not stripped:
        return None
    tokens = _split_tokens(stripped)
    if tokens is None:
        digits = stripped[1:] if stripped.startswith("-") else stripped
        return [stripped] if digits.isdigit() or _is_int(stripped) else None
    if "," not in stripped or not tokens:
        return tokens or None
    max_words = max(1, max((len(str(c).split()) for c in choices or []), default=1))
    return tokens if all(t.isdigit() or len(t.split()) <= max_words for t in tokens) else None


def _coerce_text_response(entry: _ClarifyEntry, response: str) -> Optional[str]:
    """Accepted value for a typed reply, or None on any rejection."""
    return _coerce_text_response_detailed(entry, response)[0]


def _coerce_text_response_detailed(entry: _ClarifyEntry, response: str) -> tuple[Optional[str], Optional[str]]:
    """Map a typed reply to ``(value, None)`` or ``(None, reason)``: ``"invalid_selection"``
    (selection-shaped but out of range/unrecognised — keep the clarify armed for a retry) or
    ``"prose"`` (free text on a native choice prompt — the gateway may cancel and route normally
    so a redirect-to-steer path cannot deadlock behind the waiting tool). Open-ended entries and
    ``awaiting_text`` accept any text; numeric picks and exact labels always resolve; multi-select
    returns a JSON array string (decoded tool-side); one bad token rejects the whole reply."""
    text = str(response).strip()
    is_skip = text.casefold() == SKIP_WORD
    if not entry.choices:
        return (SKIPPED if is_skip else text), None
    if entry.multi_select:
        coerced = _coerce_multi_select_text(entry, text)
        selection_shaped = _selection_attempt_tokens(text, entry.choices) is not None
    else:
        # Out-of-range / non-canonical integer is a failed selection, not prose.
        selection_shaped = _is_int(text)
        idx = int(text) - 1 if selection_shaped else -1
        coerced = entry.choices[idx] if 0 <= idx < len(entry.choices) else _match_label(text, entry.choices)
    if coerced is not None:
        return coerced, None
    if is_skip:
        return SKIPPED, None
    if entry.awaiting_text:
        return text, None
    return None, "invalid_selection" if selection_shaped else "prose"


def _coerce_multi_select_text(entry: _ClarifyEntry, text: str) -> Optional[str]:
    """Parse "1,3" / "1 3" / "staging, prod" into a JSON array of choice labels;
    None when any token is out of range or unrecognised (reject the whole reply)."""
    choices, selected = entry.choices or [], []
    if not text:
        return None
    tokens = _split_tokens(text)
    for token in [text] if tokens is None else tokens:
        if token.isdigit():
            idx = int(token) - 1
            label = str(choices[idx]).strip() if 0 <= idx < len(choices) else None
        else:
            label = _match_label(token, choices)
        if label is None:
            return None
        if label not in selected:
            selected.append(label)
    return json.dumps(selected, ensure_ascii=False) if selected else None


def attempt_text_response_for_session(session_key: str, response: str) -> str:
    """Try to resolve the oldest pending clarify from typed text; returns a TEXT_* outcome."""
    entry = get_pending_for_session(session_key, include_choice_prompts=True)
    if entry is None:
        return TEXT_NO_PENDING
    coerced, reason = _coerce_text_response_detailed(entry, response)
    if coerced is None:
        return TEXT_REJECTED_SELECTION if reason == "invalid_selection" else TEXT_REJECTED_PROSE
    if resolve_gateway_clarify(entry.clarify_id, coerced):
        return TEXT_RESOLVED
    return TEXT_NO_PENDING  # lost a race with a button/callback resolution — no work left


def resolve_text_response_for_session(session_key: str, response: str) -> bool:
    """True only when the typed reply was accepted and the waiter unblocked."""
    return attempt_text_response_for_session(session_key, response) == TEXT_RESOLVED


def mark_awaiting_text(clarify_id: str) -> bool:
    """Flip an entry into text-capture mode (user picked 'Other'); False if unknown."""
    with _lock:
        entry = _entries.get(clarify_id)
        if entry is not None:
            entry.awaiting_text = True
        return entry is not None


def has_pending(session_key: str) -> bool:
    """True when this session has at least one pending clarify entry."""
    with _lock:
        return any(_entries.get(cid) is not None for cid in _session_index.get(session_key) or [])


def clear_session(session_key: str) -> int:
    """Drop every pending clarify for a session (``/new``, shutdown, cached-agent eviction) so
    blocked agent threads don't outlive it; returns how many were cancelled. First-writer-wins: an already-set entry was answered for
    real, so it is dropped but its response preserved. The loop stays inside the lock so a button
    callback cannot slip between pop and check; entries go regardless of state so a cleared
    session is never resurrected by late callbacks."""
    with _lock:
        cancelled = 0
        for entry in (_entries.pop(cid, None) for cid in list(_session_index.pop(session_key, []) or [])):
            if entry is None or entry.event.is_set():
                continue
            entry.response = CANCELLED
            entry.event.set()
            cancelled += 1
    return cancelled


def resolve_clarify_timeout(config: dict) -> int:
    """Clarify timeout (seconds): legacy ``clarify.timeout`` if explicitly set, else
    ``agent.clarify_timeout``, else 3600 — the single source of truth for every surface
    (gateway, CLI, TUI). ``<= 0`` is kept verbatim (unlimited); non-numeric -> 3600."""
    raw = (config.get("clarify") or {}).get("timeout")
    if raw is None:
        raw = (config.get("agent") or {}).get("clarify_timeout", 3600)
    try:
        return int(raw)
    except (TypeError, ValueError):
        return 3600


def get_clarify_timeout() -> int:
    """Clarify timeout from config.yaml; 0/negative = unlimited. Default 3600: long enough
    that a user who stepped away still finds a live entry when they tap, short enough that
    an abandoned prompt eventually unblocks the agent thread instead of pinning the guard.

    The old 600s default evicted the entry mid-think, so a late tap landed on a dead entry and the agent
    hung on ``running: clarify`` (#32762).
    """
    try:
        from hermes_cli.config import load_config
        return resolve_clarify_timeout(load_config() or {})
    except Exception:
        return 3600
