# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
ToolExecutor — extracted from agent.py (PR 1).

Handles all tool execution logic:
- Batch tool execution with streaming events
- Single tool execution with RBAC, sandboxing, retry, metrics
- Capability + routing filter
- Error classification and formatting
- Tool event callbacks
"""

import contextvars
import json
import logging
import os
import threading
import time
import uuid
from typing import Any, Callable, Dict, Optional

# Shared context variable for triage payload propagation.
# Using contextvars (not thread-local) ensures correct isolation in thread
# pools where threads are reused across requests.
_triage_ctx_var: contextvars.ContextVar[dict | None] = contextvars.ContextVar(
    "_familiar_triage_ctx", default=None
)

from .analytics import get_analytics_store
from .event_emitter import EventEmitter, ExecutionEvent
from .result import Result, Ok, Err
from .observability import Trace
from .providers import StreamEvent, StreamEventType
from .secrets_detection import mask_secrets
from .security import (
    SecureSession,
    TrustLevel,
    check_skill_access,
)

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# OSINT Authorization Gate (Constitution Article 8)
# Extracted to osint_gate.py — re-exported here for backward compatibility.
# ---------------------------------------------------------------------------
from .osint_gate import (
    _PRIVATE_CIDR_PREFIXES,
    OSINT_GATED_TOOLS,
    _check_osint_authorization,
    _is_private_target,
)


def _capture_context_from_result(
    tool_name: str,
    tool_input: dict,
    result,
    session,
    sessions_manager,
) -> None:
    """
    Phase-4 shared helper: write session_context keys from a tool result.
    Called from both _execute_tool_secure (normal path) and channel confirm
    handlers (Phase-2 re-execution path) so context capture fires regardless
    of execution path.
    """
    import datetime as _dt4h
    import threading as _thr4h

    if result is None or not isinstance(result, str):
        return

    # 1. Triage context (contextvars — safe across thread-pool reuse)
    try:
        _tc = _triage_ctx_var.get()
        if _tc and isinstance(_tc, dict) and _tc.get("emails"):
            session.session_context["triage_emails"] = _tc["emails"]
            session.session_context["triage_at"] = _dt4h.datetime.now(
                _dt4h.timezone.utc
            ).isoformat()
            _triage_ctx_var.set(None)
    except Exception:
        logger.debug("Failed to store triage context in session")

    # 2. last_doc_id from drive_create_doc
    if tool_name == "drive_create_doc" and "ID: " in result:
        try:
            for _ln in result.splitlines():
                if _ln.strip().startswith("ID:"):
                    _did = _ln.split("ID: ", 1)[1].strip()
                    if _did:
                        session.session_context["last_doc_id"] = _did
                        session.session_context["last_doc_title"] = (
                            tool_input.get("title", "") if tool_input else ""
                        )
                    break
        except Exception:
            logger.debug("Failed to extract doc ID from drive_create_doc result")

    # 3. last_event_title from create_event
    if tool_name == "create_event" and result.startswith("\u2705"):
        try:
            session.session_context["last_event_title"] = (
                tool_input.get("title", "") if tool_input else ""
            )
            # 3b. RSVP reply hint from email-to-calendar bridge
            _email_idx = (tool_input or {}).get("_email_index")
            _has_rsvp = (tool_input or {}).get("_email_has_rsvp", False)
            if _email_idx and _has_rsvp:
                session.session_context["pending_rsvp_reply"] = {
                    "email_index": _email_idx,
                    "event_title": (tool_input or {}).get("title", ""),
                }
        except Exception:
            logger.debug("Failed to extract event title from create_event result")

    if sessions_manager is not None:
        try:
            sessions_manager.save_session(session)
        except Exception:
            logger.warning("Failed to save session after tool execution", exc_info=True)


class ToolExecutor:
    """Handles tool execution, RBAC checks, retry, metrics, and event callbacks."""

    # Error indicators in tool results
    _ERROR_PREFIXES = ("⚠️", "Error:", "error:", "ERROR:", "Failed:", "FAILED:")
    _ERROR_SUBSTRINGS = (
        "Permission denied",
        "not found",
        "does not exist",
        "invalid",
        "connection refused",
        "timed out",
        "timeout",
        "rate limit",
        "exceeds limit",
        "Missing required",
    )

    def __init__(self, agent_ref):
        self._agent = agent_ref
        self._tool_event_callbacks: Dict[str, Any] = {}
        self._tool_event_callbacks_lock = threading.Lock()

    def set_tool_event_callback(
        self,
        user_id: str,
        callback: "Callable[[ExecutionEvent], None]",
    ):
        """
        Register a callback to receive ExecutionEvent objects for a user's
        tool executions. Thread-safe: callbacks dict is protected by a lock.
        """
        with self._tool_event_callbacks_lock:
            if callback is None:
                self._tool_event_callbacks.pop(str(user_id), None)
            else:
                self._tool_event_callbacks[str(user_id)] = callback

    def clear_tool_event_callback(self, user_id: str):
        """Remove the tool event callback for a user. No-op if not set."""
        self.set_tool_event_callback(user_id, None)

    def get_callback(self, user_id: str):
        """Get the tool event callback for a user, or None."""
        return self._tool_event_callbacks.get(str(user_id))

    def execute_tools(
        self,
        tool_calls,
        session,
        tool_context: dict,
        retry_tracker,
        trace,
        stream: bool,
        emit_tool_events: bool,
    ):
        """
        Execute a list of tool calls, applying self-correction.

        In stream mode, yields TOOL_START / TOOL_END events.
        In non-stream mode, returns list of tool result dicts directly.

        Returns (tool_results, events) where events is a list of
        StreamEvent objects (empty in non-stream mode).
        """
        tool_results = []
        events = []

        emitter: EventEmitter = tool_context.get("emitter") or EventEmitter(execution_id="fallback")

        for tc in tool_calls:
            logger.info(f"Tool: {tc.name}({json.dumps(tc.input)[:100]}...)")

            tool_id = str(uuid.uuid4())[:8]
            _tool_start = time.monotonic()

            emitter.tool_start(tc.name, tc.input, tool_id=tool_id)

            if stream and emit_tool_events:
                events.append(
                    StreamEvent(
                        type=StreamEventType.TOOL_START,
                        tool_name=tc.name,
                        tool_input=tc.input,
                    )
                )

            # Self-correction: respect retry budget
            if not retry_tracker.should_retry(tc.name) and retry_tracker.get_attempts(tc.name) > 0:
                result = retry_tracker.format_give_up(tc.name, "Max retries exhausted")
                logger.warning(
                    f"Tool {tc.name} exhausted after {retry_tracker.get_attempts(tc.name)} attempts"
                )
                elapsed_ms = (time.monotonic() - _tool_start) * 1000
                emitter.tool_error(
                    tc.name, error="Max retries exhausted", elapsed_ms=elapsed_ms, tool_id=tool_id
                )
            else:
                # ── Credential pre-flight ────────────────────────────────
                # Check if the tool's required credentials are valid before
                # executing.  Short-circuits with a structured error if not.
                try:
                    from familiar.core.credential_health import credential_preflight

                    _cred_ok, _cred_msg = credential_preflight(tc.name)
                    if not _cred_ok:
                        result = _cred_msg
                        elapsed_ms = (time.monotonic() - _tool_start) * 1000
                        emitter.tool_error(
                            tc.name,
                            error="credential_preflight",
                            elapsed_ms=elapsed_ms,
                            tool_id=tool_id,
                        )
                        try:
                            from familiar.core.credential_audit import (
                                CredentialEventType,
                                log_credential_event,
                            )

                            log_credential_event(
                                tc.name,
                                CredentialEventType.PREFLIGHT_BLOCKED,
                                details=_cred_msg[:200],
                                source="tool_executor",
                            )
                        except Exception as _audit_err:
                            logger.debug("Credential audit log skipped: %s", _audit_err)
                        # Skip execution — jump to result collection
                        tool_results.append(
                            {
                                "type": "tool_result",
                                "tool_use_id": tc.id,
                                "content": result,
                                "tool_name": tc.name,
                                "tool_input": tc.input,
                            }
                        )
                        if stream and emit_tool_events:
                            events.append(
                                StreamEvent(
                                    type=StreamEventType.TOOL_END,
                                    tool_name=tc.name,
                                    tool_result=result[:500] if result else None,
                                )
                            )
                        continue
                except Exception as _cpf_err:
                    logger.debug("Credential preflight skipped: %s", _cpf_err)

                # Route through agent delegate so monkey-patches in tests work
                result = self._agent._execute_tool_secure(
                    tc.name, tc.input, session, tool_context, trace
                )

                elapsed_ms = (time.monotonic() - _tool_start) * 1000

                # ── Credential retry on auth failure ─────────────────────
                # If the tool returned an auth error, try to refresh the
                # credential and retry once (Android AccountManager pattern).
                if retry_tracker.is_error(result):
                    try:
                        from familiar.core.credential_health import credential_retry_on_auth_failure

                        _should_retry, _retry_msg = credential_retry_on_auth_failure(
                            tc.name, result
                        )
                        if _should_retry:
                            logger.info("Retrying %s after credential refresh", tc.name)
                            try:
                                from familiar.core.credential_audit import (
                                    CredentialEventType,
                                    log_credential_event,
                                )

                                log_credential_event(
                                    tc.name,
                                    CredentialEventType.RETRY_REFRESH,
                                    details="Auth failure recovered, retrying tool",
                                    source="tool_executor",
                                )
                            except Exception as _audit_err:
                                logger.debug("Credential retry audit skipped: %s", _audit_err)
                            result = self._agent._execute_tool_secure(
                                tc.name, tc.input, session, tool_context, trace
                            )
                            elapsed_ms = (time.monotonic() - _tool_start) * 1000
                        elif _retry_msg:
                            # Refresh failed — replace result with structured message
                            result = _retry_msg
                    except Exception as _cra_err:
                        logger.debug("Credential retry skipped: %s", _cra_err)

                if retry_tracker.is_error(result):
                    error_class = retry_tracker.record_failure(tc.name, result)
                    emitter.tool_retry(
                        tc.name,
                        attempt=retry_tracker.get_attempts(tc.name),
                        reason=result,
                        tool_id=tool_id,
                    )
                    if retry_tracker.should_retry(tc.name):
                        _plan_step = None
                        _pt = tool_context.get("_plan_tracker") or getattr(
                            self._agent, "_current_plan_tracker", None
                        )
                        if _pt:
                            _cs = _pt.get_current_step()
                            if _cs:
                                _plan_step = _cs.description
                        result = retry_tracker.format_error_feedback(
                            tc.name, result, error_class, plan_step=_plan_step
                        )
                    else:
                        result = retry_tracker.format_give_up(tc.name, result, error_class)
                        emitter.tool_error(
                            tc.name, error=result, elapsed_ms=elapsed_ms, tool_id=tool_id
                        )
                    session.add_audit(
                        "tool_error",
                        {
                            "tool": tc.name,
                            "error_class": error_class.value,
                            "attempt": retry_tracker.get_attempts(tc.name),
                        },
                    )
                    # Backoff for transient errors (SERVICE_DOWN, RATE_LIMIT)
                    _delay = retry_tracker.get_retry_delay(tc.name)
                    if _delay > 0:
                        threading.Event().wait(timeout=_delay)
                else:
                    # Success — emit TOOL_COMPLETE and reset retry tracker
                    emitter.tool_complete(
                        tc.name,
                        result=result[:200] if result else None,
                        elapsed_ms=elapsed_ms,
                        tool_id=tool_id,
                    )
                    if hasattr(retry_tracker, "reset"):
                        retry_tracker.reset(tc.name)
                    else:
                        retry_tracker._attempts.pop(tc.name, None)
                        retry_tracker._error_classes.pop(tc.name, None)

                    # Progressive: track skill usage
                    try:
                        from familiar.core.progressive import on_tool_executed

                        tool_skill = self.get_skill_for_tool(tc.name)
                        if tool_skill:
                            on_tool_executed(tc.name, tool_skill)
                    except (ImportError, Exception) as e:
                        logger.debug("Optional dependency not available: %s", e)

                    # Knowledge capture: record successful installations
                    if tc.name in (
                        "install_package",
                        "setup_binary",
                        "run_setup_script",
                    ):
                        try:
                            from familiar.skills.host_setup.knowledge import (
                                capture_install_knowledge,
                            )

                            capture_install_knowledge(tc.name, tc.input, result)
                        except Exception as _kc_err:
                            logger.debug("Install knowledge capture skipped: %s", _kc_err)
            if stream and emit_tool_events:
                events.append(
                    StreamEvent(
                        type=StreamEventType.TOOL_END,
                        tool_name=tc.name,
                        tool_result=result[:500] if result else None,
                    )
                )

            # Sentinel: skill wants confirmation — short-circuit the tool loop.
            from familiar.core.confirmations import (
                CALENDAR_MENU_PREFIX,
                EMAIL_MENU_PREFIX,
                SENTINEL_PREFIX,
            )

            if isinstance(result, str) and result.startswith(SENTINEL_PREFIX):
                return [
                    {"type": "confirmation_pending", "sentinel": result, "tool_use_id": tc.id}
                ], events

            if isinstance(result, str) and result.startswith(EMAIL_MENU_PREFIX):
                return [
                    {"type": "email_menu_pending", "sentinel": result, "tool_use_id": tc.id}
                ], events

            if isinstance(result, str) and result.startswith(CALENDAR_MENU_PREFIX):
                return [
                    {"type": "calendar_menu_pending", "sentinel": result, "tool_use_id": tc.id}
                ], events

            tool_results.append(
                {
                    "type": "tool_result",
                    "tool_use_id": tc.id,
                    "content": result,
                    "tool_name": tc.name,
                    "tool_input": tc.input,
                }
            )

        return tool_results, events

    def _check_tool_permissions(
        self,
        tool_name: str,
        tool_input: Dict,
        session: SecureSession,
        context: Dict,
    ) -> Optional[str]:
        """Run all permission checks before tool execution.

        Returns an error message string if blocked, or None if allowed.
        Checks: user role → capability → RBAC → guardrails → OSINT gate.
        """
        agent = self._agent

        # Phase 1: user_context write permission
        try:
            from .users import UserContext as _UC  # noqa: F811

            _has_um = _UC is not None
        except ImportError:
            _has_um = False

        user_context = context.get("user_context")
        if user_context and _has_um:
            write_tools = {
                "write_file",
                "create_file",
                "delete_file",
                "move_file",
                "send_email",
                "send_message",
                "send_sms",
                "create_task",
                "update_task",
                "delete_task",
                "create_event",
                "update_event",
                "delete_event",
                "add_memory",
                "update_memory",
                "delete_memory",
                "execute_command",
                "run_shell",
            }
            if tool_name.lower() in write_tools and not user_context.can_write:
                session.add_audit(
                    "tool_blocked_readonly",
                    {
                        "tool": tool_name,
                        "user": user_context.user.email,
                        "role": user_context.user.role.value,
                    },
                )
                return f"\u26a0\ufe0f Permission denied. Your role ({user_context.user.role.value}) is read-only."

        # Phase 2: capability (trust-level security)
        allowed, missing, needs_confirm = check_skill_access(tool_name, session)
        if not allowed:
            missing_str = ", ".join(c.value for c in missing)
            session.add_audit(
                "tool_blocked", {"tool": tool_name, "missing_capabilities": missing_str}
            )
            return f"\u26a0\ufe0f Permission denied. Missing: {missing_str}. Build trust to unlock."

        # Phase 3: RBAC (skill-level permissions)
        try:
            from familiar.skills.rbac.skill import _check_perm as _rbac_check
            from familiar.skills.rbac.skill import _get_role_permissions as _rbac_perms
            from familiar.skills.rbac.skill import _load_data as _rbac_load

            rbac_data = _rbac_load()
            assignments = rbac_data.get("assignments", {})
            user_assignment = assignments.get(session.user_id)
            if user_assignment:
                role_name = user_assignment.get("role", "")
            elif assignments:
                role_name = "readonly"
                logger.info("RBAC: user %s unassigned, defaulting to 'readonly'", session.user_id)
            else:
                role_name = ""

            if role_name:
                user_perms = _rbac_perms(role_name)
                skill_name = self.get_skill_for_tool(tool_name) or ""
                if not _rbac_check(user_perms, skill_name, tool_name):
                    session.add_audit(
                        "tool_blocked_rbac",
                        {"tool": tool_name, "role": role_name, "skill": skill_name},
                    )
                    return (
                        f"\u26a0\ufe0f Permission denied. Role '{role_name}' cannot use "
                        f"{skill_name}:{tool_name}."
                    )
        except ImportError:
            try:
                from familiar.core.paths import get_data_dir

                if (get_data_dir() / "rbac.json").exists():
                    logger.warning("RBAC module import failed but rbac.json exists — denying")
                    session.add_audit(
                        "tool_blocked_rbac_unavailable",
                        {"tool": tool_name, "reason": "RBAC module import failed"},
                    )
                    return "\u26a0\ufe0f Permission check unavailable. Tool blocked for safety."
                else:
                    logger.debug("RBAC not installed (no rbac.json) — skipping")
            except Exception:
                logger.warning("RBAC module import failed and path check failed")

        # Phase 4: guardrails (blocklist + approval-required)
        guardrail_allowed, guardrail_reason, requires_approval = agent.guardrails.check_tool_call(
            tool_name, tool_input, tool_registry=None
        )
        if not guardrail_allowed:
            session.add_audit(
                "tool_blocked_guardrail", {"tool": tool_name, "reason": guardrail_reason}
            )
            return f"\u26a0\ufe0f {guardrail_reason}"
        if requires_approval:
            session.add_audit(
                "tool_blocked_needs_approval", {"tool": tool_name, "reason": guardrail_reason}
            )
            return (
                f"\u26a0\ufe0f '{tool_name}' requires administrator approval. "
                f"Contact your admin or use a manual workflow."
            )

        # Phase 5: confirmation note (no block, just audit)
        if needs_confirm:
            session.add_audit("tool_requires_confirmation", {"tool": tool_name})
            logger.debug(f"Tool {tool_name}: capability-level confirm noted")

        # Phase 6: OSINT gate (Constitution Article 8)
        _osint_check = _check_osint_authorization(tool_name, tool_input, session)
        if _osint_check:
            session.add_audit(
                "tool_blocked_osint_unauthorized", {"tool": tool_name, "reason": _osint_check}
            )
            return _osint_check

        return None  # All checks passed

    def execute_tool_secure(
        self,
        tool_name: str,
        tool_input: Dict,
        session: SecureSession,
        context: Dict,
        trace: Optional[Trace] = None,
    ) -> Result[str]:
        """Execute a tool with security checks. Returns Result[str]."""
        # Macro intercept — deterministic workflows bypass the RBAC tool model.
        # Macros are already job-class scoped and contain no privileged operations.
        try:
            from familiar.core.macros import get_macro_registry
            _macro_reg = get_macro_registry()
            if _macro_reg.get(tool_name):
                _macro_result = _macro_reg.execute(tool_name, tool_input, context=context)
                if _macro_result.success:
                    return Ok(json.dumps(_macro_result.to_dict()), tool=tool_name)
                return Err(_macro_result.error, code="macro_failed", tool=tool_name)
        except Exception as _macro_err:
            logger.warning("Macro intercept failed for %s: %s", tool_name, _macro_err)

        # Permission checks (user role → capability → RBAC → guardrails → OSINT)
        perm_error = self._check_tool_permissions(tool_name, tool_input, session, context)
        if perm_error is not None:
            return Err(perm_error, code="permission_denied", tool=tool_name)

        user_context = context.get("user_context")

        # Execute tool with span tracking
        session.add_audit(
            "tool_executed",
            {
                "tool": tool_name,
                "input_preview": mask_secrets(str(tool_input)[:200]),
                "user": user_context.user.email if user_context else None,
            },
        )

        _tool_start = time.time()
        _tool_error: Optional[str] = None
        _tool_span = None
        try:
            _parent_span = context.get("_trace_span")
            if _parent_span:
                from familiar.core.observability import get_tracer

                _tool_span = get_tracer().create_span(
                    f"tool.{tool_name}",
                    parent=_parent_span,
                    attributes={"tool.name": tool_name},
                )
        except Exception as _span_err:
            logger.debug("Tool span creation skipped: %s", _span_err)

        _tool_result_holder: list = []
        _tool_exc_holder: list = []
        _done_event = threading.Event()

        def _run_tool():
            # Clear stale triage context inherited from parent context (H7)
            _triage_ctx_var.set(None)
            try:
                # Retry transient failures (rate limits, temporary network errors)
                # using RetryPolicy from enhancements.py — 2 attempts, exponential backoff
                try:
                    from familiar.core.enhancements import RetryPolicy, RetryStrategy

                    _retry = RetryPolicy(
                        max_attempts=2,
                        strategy=RetryStrategy.EXPONENTIAL,
                        base_delay=1.0,
                        retryable_exceptions=(TimeoutError, ConnectionError, OSError),
                    )
                    _tool_result_holder.append(
                        _retry.execute(agent.tools.execute, tool_name, tool_input, context=context)
                    )
                except ImportError:
                    # Fallback: no retry
                    _tool_result_holder.append(
                        agent.tools.execute(tool_name, tool_input, context=context)
                    )
            except Exception as exc:
                _tool_exc_holder.append(exc)
            finally:
                _done_event.set()

        _tool_timeout = int(os.environ.get("FAMILIAR_TOOL_TIMEOUT", 60))
        worker = threading.Thread(target=_run_tool, daemon=True)
        worker.start()
        finished = _done_event.wait(timeout=_tool_timeout)

        if not finished:
            result = f"Error: Tool '{tool_name}' timed out after {_tool_timeout} seconds."
            logger.warning(result)
            if _tool_span:
                try:
                    from familiar.core.observability import SpanStatus as _SS

                    _tool_span.set_status(_SS.ERROR, "timeout")
                    _tool_span.end()
                    _tool_span = None
                except Exception as _span_err:
                    logger.debug("Tool timeout span finalization failed: %s", _span_err)
        elif _tool_exc_holder:
            # Record failed tool metric before re-raising
            _exc_duration = (time.time() - _tool_start) * 1000
            try:
                agent.metrics.record_tool_execution(
                    tool_name=tool_name,
                    duration_ms=_exc_duration,
                    success=False,
                    error_message=str(_tool_exc_holder[0])[:200],
                    input_size=len(str(tool_input)),
                    output_size=0,
                    user_id=str(context.get("user_id", "")),
                )
            except Exception as _m_err:
                logger.debug("Failed tool metric skipped: %s", _m_err)
            if _tool_span:
                try:
                    from familiar.core.observability import SpanStatus as _SS

                    _tool_span.set_status(_SS.ERROR, str(_tool_exc_holder[0])[:100])
                    _tool_span.end()
                    _tool_span = None
                except Exception as _span_err:
                    logger.debug("Tool error span finalization failed: %s", _span_err)
            raise _tool_exc_holder[0]
        else:
            result = _tool_result_holder[0] if _tool_result_holder else None

        # Legacy trace path removed — tool spans handled above via _tool_span

        _tool_duration_ms = (time.time() - _tool_start) * 1000

        # Inline confirmation intercept
        if hasattr(result, "is_confirmation_required") and result.is_confirmation_required():
            import datetime as _dt
            import secrets as _sec

            from familiar.core.confirmations import SENTINEL_PREFIX

            token = _sec.token_hex(8)
            now = _dt.datetime.now(_dt.timezone.utc)
            session.pending_confirmations[token] = {
                "tool_name": result.tool_name,
                "tool_input": result.tool_input,
                "preview": result.preview,
                "risk": result.risk,
                "created_at": now.isoformat(),
                "expires_at": (now + _dt.timedelta(seconds=300)).isoformat(),
            }
            session.add_audit(
                "confirmation_pending",
                {
                    "tool": result.tool_name,
                    "token": token,
                    "risk": result.risk,
                },
            )
            agent.sessions.save_session(session)
            # Record tool metric for confirmation-path tools
            try:
                agent.metrics.record_tool_execution(
                    tool_name=tool_name,
                    duration_ms=_tool_duration_ms,
                    success=True,
                    input_size=len(str(tool_input)),
                    output_size=0,
                    user_id=str(context.get("user_id", "")),
                )
            except Exception:
                logger.debug("Metrics record skipped for confirmation tool")
            return Ok(f"{SENTINEL_PREFIX}{token}", tool=tool_name, confirmation=True)

        # Phase 4: Session context capture (shared helper)
        _capture_context_from_result(tool_name, tool_input, result, session, agent.sessions)
        _is_error = (
            result
            and isinstance(result, str)
            and (result.startswith("⚠️") or result.startswith("❌") or result.startswith("Error"))
        )
        if _is_error:
            _tool_error = result[:200]
        try:
            # Look up tool definition for category enrichment
            _tool_cat = ""
            _tool_tags: list = []
            _tool_skill = ""
            try:
                _tool_def = agent.tools.get(tool_name) if hasattr(agent, "tools") else None
                if _tool_def:
                    _tool_cat = getattr(_tool_def, "category", "") or ""
                    _tool_tags = list(getattr(_tool_def, "tags", []) or [])
                    _tool_skill = getattr(_tool_def, "skill", "") or ""
                elif hasattr(agent, "_tool_registry"):
                    _reg_entry = agent._tool_registry.get(tool_name)
                    if _reg_entry:
                        _tool_cat = getattr(_reg_entry, "category", "") or ""
                        _tool_tags = list(getattr(_reg_entry, "tags", []) or [])
            except Exception:
                pass
            agent.metrics.record_tool_execution(
                tool_name=tool_name,
                duration_ms=_tool_duration_ms,
                success=not _is_error,
                error_message=_tool_error,
                input_size=len(str(tool_input)),
                output_size=len(result) if result else 0,
                user_id=str(context.get("user_id", "")),
                category=_tool_cat,
                tags=_tool_tags,
                skill=_tool_skill,
            )
        except Exception as _m_err:
            logger.debug(f"Metrics record_tool_execution skipped: {_m_err}")

        # Close tool span
        if _tool_span:
            try:
                from familiar.core.observability import SpanStatus as _SS

                _tool_span.set_attribute("tool.duration_ms", _tool_duration_ms)
                _tool_span.set_attribute("tool.success", not _is_error)
                if _is_error:
                    _tool_span.set_status(_SS.ERROR, (_tool_error or "")[:100])
                else:
                    _tool_span.set_status(_SS.OK)
                _tool_span.end()
            except Exception as _span_err:
                logger.debug("Tool span finalization failed: %s", _span_err)

        # Analytics: record tool call
        _skill_name = ""
        try:
            _skill_name = self.get_skill_for_tool(tool_name) or ""
            _trace_span = context.get("_trace_span")
            _req_id = None
            if _trace_span and hasattr(_trace_span, "context"):
                _req_id = _trace_span.context.span_id
            get_analytics_store().record_tool_call(
                user_id=str(context.get("user_id", "")),
                channel=str(context.get("channel", "")),
                skill=_skill_name,
                action=tool_name,
                latency_ms=int(_tool_duration_ms),
                success=not _is_error,
                error_message=_tool_error,
                metadata={
                    "tool_input": tool_input,
                    "tool_result_preview": (result[:500] if result else ""),
                    "request_id": _req_id,
                },
            )
        except Exception as _analytics_err:
            logger.debug(f"Analytics record_tool_call skipped: {_analytics_err}")

        # Bus event spine
        try:
            _bus_payload = {
                "tool": tool_name,
                "skill": _skill_name,
                "user_id": str(uid) if (uid := context.get("user_id")) else "",
                "success": not _is_error,
                "result_preview": result[:200] if result else "",
                "input_preview": mask_secrets(str(tool_input)[:200]),
            }
            agent.bus.publish("tool.executed", _bus_payload, source_skill=_skill_name)
            agent.bus.publish(f"tool.{tool_name}", _bus_payload, source_skill=_skill_name)
        except Exception as _bus_err:
            logger.debug(f"Bus publish skipped after tool execution: {_bus_err}")

        # Wrap in Result for structured error handling
        if _is_error:
            return Err(
                result,
                tool=tool_name,
                duration_ms=round(_tool_duration_ms, 1),
            )
        return Ok(
            result,
            tool=tool_name,
            duration_ms=round(_tool_duration_ms, 1),
        )

    def get_allowed_tools(self, session: SecureSession, message: str = "") -> tuple:
        """Filter tools based on session capabilities, then route by message relevance.

        Two-stage pipeline:
          1. Capability gate  — hard filter by trust level / session capabilities.
          2. Relevance routing — soft filter via ToolRouter, trims to <=15 most
             relevant schemas so the LLM receives a focused context per message.

        Returns:
            (schemas, routed_skill_names)
        """
        agent = self._agent
        from .security import SKILL_DIRECTORY_CAPABILITIES

        all_schemas = agent.tools.get_schemas()
        capability_allowed = []

        tool_to_skill = dict(agent.tool_router._tool_to_skill)
        if hasattr(agent, "skills") and agent.skills:
            for skill_name, skill in agent.skills.skills.items():
                for tool in skill.tools:
                    tool_to_skill.setdefault(tool.name, skill_name)

        # Stage 1: capability gate
        for schema in all_schemas:
            tool_name = schema.get("name", "")

            skill_name = tool_to_skill.get(tool_name)
            if skill_name and skill_name in SKILL_DIRECTORY_CAPABILITIES:
                required = SKILL_DIRECTORY_CAPABILITIES[skill_name]
                missing = {cap for cap in required if not session.has_capability(cap)}
                if missing:
                    logger.debug(
                        f"Tool {tool_name} blocked (skill={skill_name}): missing {missing}"
                    )
                    continue
                capability_allowed.append(schema)
                continue

            allowed_access, missing, _ = check_skill_access(tool_name, session)
            if allowed_access:
                if session.trust_level == TrustLevel.STRANGER and not skill_name:
                    logger.debug(f"Tool {tool_name} blocked for STRANGER (unmapped)")
                    continue
                capability_allowed.append(schema)
            else:
                logger.debug(
                    f"Tool {tool_name} blocked for user {session.user_id}: missing {missing}"
                )

        def _extract_skills(schemas):
            """Extract skill names from a list of tool schemas."""
            names = {tool_to_skill.get(s.get("name", ""), "") for s in schemas}
            names.discard("")
            return names

        def _append_macros(schemas):
            """Append macro schemas for the current job class. Returns (schemas, skills)."""
            try:
                from familiar.core.macros import get_macro_registry
                _job_class = getattr(getattr(agent.config, "agent", None), "job_class", "") or ""
                _macros = get_macro_registry().list_for_job_class(_job_class)
                if _macros:
                    _macro_schemas = [m.to_tool_schema() for m in _macros]
                    schemas = list(schemas) + _macro_schemas
                    logger.debug("Appended %d macro schemas for job_class=%r", len(_macros), _job_class)
            except Exception as _merr:
                logger.debug("Macro schema injection skipped: %s", _merr)
            return schemas, _extract_skills(schemas)

        # Stage 2: relevance routing (skip for small sets or no message context)
        if not message or len(capability_allowed) <= agent.tool_router.config.fallback_count:
            return _append_macros(capability_allowed)

        # Fast path: purely conversational messages need zero tools.
        # This dramatically reduces context size for local models on CPU.
        _msg_lower = message.strip().lower().rstrip("!?.,")
        _CHAT_ONLY = {
            "good morning",
            "good evening",
            "good night",
            "good afternoon",
            "hello",
            "hi",
            "hey",
            "howdy",
            "sup",
            "yo",
            "thanks",
            "thank you",
            "thx",
            "ty",
            "bye",
            "goodbye",
            "see you",
            "later",
            "gotta go",
            "how are you",
            "whats up",
            "what's up",
            "lol",
            "haha",
            "nice",
            "cool",
            "ok",
            "okay",
            "sure",
            "yes",
            "no",
            "yep",
            "nope",
            "yeah",
            "nah",
        }
        if _msg_lower in _CHAT_ONLY:
            logger.debug("Chat-only message detected — skipping tools: %r", message[:50])
            return [], set()

        try:
            routing = agent.tool_router.route(
                message=message,
                allowed_schemas=capability_allowed,
            )
            if not routing.fallback and len(routing.schemas) < len(capability_allowed):
                logger.debug(
                    f"ToolRouter: {len(capability_allowed)} -> {len(routing.schemas)} tools "
                    f"(conf={routing.confidence:.2f}, {routing.route_time_ms:.1f}ms)"
                )
                return _append_macros(routing.schemas)
        except Exception as e:
            logger.warning(f"ToolRouter failed, using full capability set: {e}")

        return _append_macros(capability_allowed)

    def get_skill_for_tool(self, tool_name: str) -> Optional[str]:
        """Look up which skill a tool belongs to. Used for progressive tracking."""
        agent = self._agent
        if hasattr(agent, "skills") and agent.skills:
            for skill_name, skill in agent.skills.skills.items():
                for tool in skill.tools:
                    if tool.name == tool_name:
                        return skill_name
        return None

    # ── Error helpers ──────────────────────────────────────────────

    def _is_tool_error(self, result: str) -> bool:
        """Detect whether a tool result is an error."""
        if not result:
            return False
        r = result.strip()
        if any(r.startswith(p) for p in self._ERROR_PREFIXES):
            return True
        r_lower = r.lower()
        if any(s.lower() in r_lower for s in self._ERROR_SUBSTRINGS):
            return True
        return False
