"""Reply guardrails — last-line safety net for agent chat replies.

Two detectors, both registered against the `before_agent_reply` hook:

1. Placeholder scrub — catches `[Your Name]`, `[Subject]`, `[Topic]`-style
   fabrication tokens that an LLM emits when it cannot ground a field.
   Replaces them with `⟨missing⟩` and logs a `fabrication_in_chat` activity
   event. If the reply contains too many placeholders, the entire reply is
   replaced with a refusal message.

2. Capability-hallucination detection — catches replies like "I'm currently
   unable to create drafts" when the agent actually has a `save_draft` tool
   in the registry. Writes a `reply_signal` decision with
   `contract_result="capability_hallucinated"`, which SkillSchool reads to
   propose skill tunings.

Rule 12: all strategy (patterns, phrases, keywords, thresholds) lives in
`defaults/skills/_meta/reply-guardrails/config/guardrails.yaml`. This module
is pure engine — it loads the YAML at startup and consults the tool registry
at runtime. Fails open: if anything is missing (YAML, registry, loggers),
the handlers are no-ops and the chain continues unchanged.
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from typing import Any

log = logging.getLogger(__name__)


# ── Fallback config (tagged deterministic) ────────────────────────
# arch:deterministic — fallback so first boot / corrupted YAML never
# crashes reply handling. The real config lives in
# defaults/skills/_meta/reply-guardrails/config/guardrails.yaml
_FALLBACK_CONFIG: dict[str, Any] = {
    "placeholders": {
        "enabled": True,
        "patterns": [
            r"\[(?:Your|your)\s+[A-Za-z]+\]",
            r"\[(?:specific|insert|enter|fill|type)[^\]]*\]",
            r"\[[A-Z][a-zA-Z]*\s+(?:name|email|address|title)\]",
        ],
        "stub_token": "⟨missing⟩",
        "max_placeholders_before_refusal": 4,
        "refusal_message": "⚠ I drafted a response but it contained too many unfilled fields.",
        "ignore_patterns": [r"\[\[[^\]]+\]\]", r"!\[[^\]]*\]\([^\)]+\)"],
    },
    "capabilities": {
        "enabled": True,
        "refusal_phrases": {
            "en": [
                "i am unable to", "i'm unable to", "i cannot create",
                "i can't create", "i don't have access", "i'm currently unable",
                "you'll have to", "you can copy", "paste it into", "manually",
            ],
        },
        "capability_keywords": {
            "save_draft": ["draft", "drafts folder", "save to drafts"],
            "send_email": ["send an email", "send email"],
            "read_inbox": ["your email", "your inbox"],
        },
        "context_window_chars": 300,
        "log_importance": "medium",
        "require_capability_match": True,
    },
}


# ── Config loader ─────────────────────────────────────────────────

@dataclass
class GuardrailConfig:
    """Compiled guardrail config. Patterns pre-compiled for hot-path speed."""

    placeholder_enabled: bool = True
    placeholder_patterns: list[re.Pattern] = field(default_factory=list)
    placeholder_ignore_patterns: list[re.Pattern] = field(default_factory=list)
    placeholder_stub: str = "⟨missing⟩"
    placeholder_max_before_refusal: int = 4
    placeholder_refusal_msg: str = ""

    capability_enabled: bool = True
    refusal_phrases: list[str] = field(default_factory=list)  # pre-lowercased
    capability_keywords: dict[str, list[str]] = field(default_factory=dict)
    capability_context_window: int = 300
    capability_log_importance: str = "medium"
    capability_require_match: bool = True

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> GuardrailConfig:
        """Build a compiled config from a raw YAML dict. Never raises."""
        cfg = cls()
        try:
            ph = data.get("placeholders", {}) or {}
            cfg.placeholder_enabled = bool(ph.get("enabled", True))
            cfg.placeholder_stub = str(ph.get("stub_token", "⟨missing⟩"))
            cfg.placeholder_max_before_refusal = int(
                ph.get("max_placeholders_before_refusal", 4)
            )
            cfg.placeholder_refusal_msg = str(
                ph.get("refusal_message", "") or ""
            ).strip()
            patterns = ph.get("patterns", []) or []
            cfg.placeholder_patterns = _compile_patterns(patterns)
            ignores = ph.get("ignore_patterns", []) or []
            cfg.placeholder_ignore_patterns = _compile_patterns(ignores)
        except Exception:
            log.debug("reply_guardrails: placeholder config parse failed", exc_info=True)

        try:
            cap = data.get("capabilities", {}) or {}
            cfg.capability_enabled = bool(cap.get("enabled", True))
            cfg.capability_context_window = int(cap.get("context_window_chars", 300))
            cfg.capability_log_importance = str(cap.get("log_importance", "medium"))
            cfg.capability_require_match = bool(cap.get("require_capability_match", True))

            phrases_by_lang = cap.get("refusal_phrases", {}) or {}
            flat: list[str] = []
            for _lang, phrases in phrases_by_lang.items():
                if isinstance(phrases, list):
                    flat.extend(str(p).lower() for p in phrases if p)
            cfg.refusal_phrases = flat

            keywords = cap.get("capability_keywords", {}) or {}
            clean: dict[str, list[str]] = {}
            for tool_name, kws in keywords.items():
                if isinstance(kws, list):
                    clean[str(tool_name)] = [str(k).lower() for k in kws if k]
            cfg.capability_keywords = clean
        except Exception:
            log.debug("reply_guardrails: capability config parse failed", exc_info=True)

        return cfg


def _compile_patterns(patterns: list) -> list[re.Pattern]:
    """Compile regex patterns, skipping any that fail to parse."""
    compiled: list[re.Pattern] = []
    for p in patterns:
        try:
            compiled.append(re.compile(str(p)))
        except re.error:
            log.debug("reply_guardrails: bad regex skipped: %r", p)
    return compiled


def load_config(skill_loader: Any | None = None) -> GuardrailConfig:
    """Load the compiled config from the `reply-guardrails` skill YAML.

    Falls back to `_FALLBACK_CONFIG` when the skill is missing, unreadable,
    or raises any exception during parsing.
    """
    if skill_loader is None:
        return GuardrailConfig.from_dict(_FALLBACK_CONFIG)

    # Preferred path: SkillLoader.load_skill_config_file() — the canonical
    # Rule 12 strategy-YAML loader. It resolves the skill's path relative to
    # the vault's skills_dir and parses the YAML for us. Before, this code
    # called a non-existent `get_skill_config_yaml` method and then fell
    # back to `skill_loader.skills_dir` which also doesn't exist (the
    # attribute is private `_skills_dir`) — every load path raised
    # AttributeError and reply_guardrails silently ran on fallback defaults.
    try:
        raw = skill_loader.load_skill_config_file(  # type: ignore[attr-defined]
            "reply-guardrails", "guardrails.yaml"
        )
        if isinstance(raw, dict) and raw:
            return GuardrailConfig.from_dict(raw)
    except AttributeError:
        pass
    except Exception:
        log.debug("reply_guardrails: config load failed", exc_info=True)

    return GuardrailConfig.from_dict(_FALLBACK_CONFIG)


# ── Placeholder scrubbing ─────────────────────────────────────────

@dataclass
class ScrubReport:
    """Result of a placeholder scrub pass."""

    modified: bool = False
    placeholder_count: int = 0
    replaced_tokens: list[str] = field(default_factory=list)
    clean_text: str = ""
    refused: bool = False  # True when the whole reply was replaced


def scrub_placeholders(text: str, cfg: GuardrailConfig) -> ScrubReport:
    """Replace placeholder tokens with the stub. Pure function — no I/O.

    Lines matching any `ignore_patterns` regex are left untouched.
    """
    report = ScrubReport(clean_text=text)
    if not text or not cfg.placeholder_enabled or not cfg.placeholder_patterns:
        return report

    out_lines: list[str] = []
    for line in text.split("\n"):
        # Skip lines that match an ignore pattern (wikilinks, code, md links)
        if any(p.search(line) for p in cfg.placeholder_ignore_patterns):
            out_lines.append(line)
            continue

        new_line = line
        for pat in cfg.placeholder_patterns:
            def _replace(m: re.Match) -> str:
                report.replaced_tokens.append(m.group(0))
                report.placeholder_count += 1
                return cfg.placeholder_stub
            new_line = pat.sub(_replace, new_line)

        if new_line != line:
            report.modified = True
        out_lines.append(new_line)

    report.clean_text = "\n".join(out_lines)

    # If too many placeholders, refuse the whole reply
    if (
        cfg.placeholder_max_before_refusal > 0
        and report.placeholder_count >= cfg.placeholder_max_before_refusal
        and cfg.placeholder_refusal_msg
    ):
        report.clean_text = cfg.placeholder_refusal_msg
        report.refused = True

    return report


# ── Capability-hallucination detection ────────────────────────────

@dataclass
class CapabilityReport:
    """Result of a capability-hallucination scan."""

    hallucinated: bool = False
    refusal_phrase: str = ""
    capability: str = ""           # e.g. "save_draft"
    capability_keyword: str = ""   # the user-space keyword that matched
    available_tools: list[str] = field(default_factory=list)


def detect_capability_hallucination(
    text: str,
    cfg: GuardrailConfig,
    tool_names: set[str],
) -> CapabilityReport:
    """Check if the reply refuses a capability the agent actually has.

    Pure function. Returns a report; emitting the decision is the
    caller's job so the engine stays sync + I/O-free here.
    """
    report = CapabilityReport()
    if not text or not cfg.capability_enabled or not cfg.refusal_phrases:
        return report

    text_lower = text.lower()

    # Find the first refusal phrase in the text
    refusal_idx = -1
    refusal_phrase = ""
    for phrase in cfg.refusal_phrases:
        idx = text_lower.find(phrase)
        if idx >= 0 and (refusal_idx < 0 or idx < refusal_idx):
            refusal_idx = idx
            refusal_phrase = phrase

    if refusal_idx < 0:
        return report

    # Scan the window around the refusal for a capability keyword
    window = cfg.capability_context_window
    start = max(0, refusal_idx - window)
    end = min(len(text_lower), refusal_idx + len(refusal_phrase) + window)
    neighborhood = text_lower[start:end]

    for tool_name, keywords in cfg.capability_keywords.items():
        # Only count as a hallucination if the agent actually has the tool.
        # Unless the strict flag is off — then any match is a signal.
        if cfg.capability_require_match and tool_name not in tool_names:
            continue
        for kw in keywords:
            if kw in neighborhood:
                report.hallucinated = True
                report.refusal_phrase = refusal_phrase
                report.capability = tool_name
                report.capability_keyword = kw
                report.available_tools = sorted(tool_names)
                return report

    return report


# ── Hook handlers ─────────────────────────────────────────────────
#
# These are registered against HookBus.BEFORE_AGENT_REPLY at server
# startup. They run on every chat reply (streaming + non-streaming,
# tool-path + fallback-path).

class ReplyGuardrails:
    """Collection of before_agent_reply handlers with the shared deps they need."""

    def __init__(
        self,
        cfg: GuardrailConfig,
        *,
        activity_logger: Any | None = None,
        decisions: Any | None = None,
        registry: Any | None = None,
    ) -> None:
        self._cfg = cfg
        self._activity = activity_logger
        self._decisions = decisions
        self._registry = registry

    def reload_config(self, cfg: GuardrailConfig) -> None:
        self._cfg = cfg

    def _tool_names(self) -> set[str]:
        """Snapshot of currently-registered tool names. Fail-open empty set."""
        if self._registry is None:
            return set()
        try:
            return {t["name"] for t in self._registry.list_tools() if t.get("name")}
        except Exception:
            log.debug("reply_guardrails: registry.list_tools failed", exc_info=True)
            return set()

    async def placeholder_scrub_handler(self, ctx: Any) -> None:
        """Scrub placeholder tokens from the reply. Never vetos."""
        try:
            report = scrub_placeholders(ctx.reply_text or "", self._cfg)
        except Exception:
            log.debug("reply_guardrails: placeholder scrub crashed", exc_info=True)
            return

        if not report.modified:
            return

        ctx.reply_text = report.clean_text

        if self._activity is not None:
            try:
                self._activity.log(
                    category="agent",
                    action="fabrication_in_chat",
                    summary=(
                        f"Scrubbed {report.placeholder_count} placeholder(s) from reply"
                        + (" (reply fully refused)" if report.refused else "")
                    ),
                    details={
                        "placeholder_count": report.placeholder_count,
                        "tokens": report.replaced_tokens[:20],
                        "refused": report.refused,
                        "conversation_id": getattr(ctx, "conversation_id", ""),
                    },
                    importance="high" if report.refused else "medium",
                )
            except Exception:
                log.debug("reply_guardrails: activity.log failed", exc_info=True)

    async def capability_hallucination_handler(self, ctx: Any) -> None:
        """Detect hallucinated capability refusals. Logs only, never mutates."""
        try:
            tool_names = self._tool_names()
            report = detect_capability_hallucination(
                ctx.reply_text or "", self._cfg, tool_names
            )
        except Exception:
            log.debug("reply_guardrails: capability detector crashed", exc_info=True)
            return

        if not report.hallucinated:
            return

        rationale = (
            f'Reply contained refusal "{report.refusal_phrase}" near '
            f'"{report.capability_keyword}", but the agent has the '
            f'{report.capability} tool registered. Likely a hallucinated '
            f'refusal — a skill gap, not a real capability limit.'
        )

        if self._decisions is not None:
            try:
                await self._decisions.record(
                    actor="main",
                    action_type="reply_signal",
                    action_target=report.capability,
                    contract_result="capability_hallucinated",
                    triggered_by="chat",
                    rationale=rationale,
                    details={
                        "refusal_phrase": report.refusal_phrase,
                        "capability_keyword": report.capability_keyword,
                        "available_tools": report.available_tools[:30],
                        "conversation_id": getattr(ctx, "conversation_id", ""),
                    },
                )
            except Exception:
                log.debug(
                    "reply_guardrails: decisions.record failed", exc_info=True
                )

        if self._activity is not None:
            try:
                self._activity.log(
                    category="agent",
                    action="capability_hallucinated",
                    summary=(
                        f"Agent refused capability '{report.capability}' "
                        f'it actually has (phrase: "{report.refusal_phrase}")'
                    ),
                    details={
                        "capability": report.capability,
                        "keyword": report.capability_keyword,
                        "phrase": report.refusal_phrase,
                        "conversation_id": getattr(ctx, "conversation_id", ""),
                    },
                    importance=self._cfg.capability_log_importance,
                )
            except Exception:
                log.debug("reply_guardrails: activity.log failed", exc_info=True)


def register_guardrail_handlers(
    hook_bus: Any,
    guardrails: ReplyGuardrails,
    *,
    plugin_name: str = "reply-guardrails",
) -> None:
    """Register both handlers on the hook bus. Call once at startup.

    Placeholder scrub runs first (priority 10) so the capability scan
    sees the scrubbed text and doesn't trigger on a refusal message
    generated by the scrub itself.
    """
    from agntspace.plugins.hooks import HookName

    hook_bus.register(
        HookName.BEFORE_AGENT_REPLY,
        guardrails.placeholder_scrub_handler,
        plugin_name=plugin_name,
        priority=10,
    )
    hook_bus.register(
        HookName.BEFORE_AGENT_REPLY,
        guardrails.capability_hallucination_handler,
        plugin_name=plugin_name,
        priority=20,
    )
