# 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 json
import logging
import os
import threading
import time
import uuid
from typing import Any, Callable, Dict, Optional

from .analytics import get_analytics_store
from .event_emitter import EventEmitter, ExecutionEvent
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__)


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 thread-local
    try:
        _tc = getattr(_thr4h.current_thread(), "_familiar_triage_ctx", None)
        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()
            _thr4h.current_thread()._familiar_triage_ctx = 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 ""
            )
        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:
                # 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

                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)
            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 execute_tool_secure(
        self,
        tool_name: str,
        tool_input: Dict,
        session: SecureSession,
        context: Dict,
        trace: Optional[Trace] = None,
    ) -> str:
        """Execute a tool with security checks."""
        agent = self._agent

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

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

        user_context = context.get("user_context")
        if user_context and HAS_USER_MANAGEMENT:
            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",
            }

            is_write_operation = tool_name.lower() in write_tools

            if is_write_operation 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"⚠️ Permission denied. Your role ({user_context.user.role.value}) is read-only."
                )

        # Check 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"⚠️ Permission denied. Missing: {missing_str}. Build trust to unlock."

        # RBAC enforcement — check skill-level permissions from rbac.json
        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)

            # Default-deny: if RBAC is configured (has any assignments) but this
            # user has no assignment, apply the "readonly" role as a safe default.
            if user_assignment:
                role_name = user_assignment.get("role", "")
            elif assignments:
                # RBAC active but user unassigned → default to readonly
                role_name = "readonly"
                logger.info(
                    "RBAC: user %s has no role assignment, defaulting to 'readonly'",
                    session.user_id,
                )
            else:
                # RBAC not configured (no assignments at all) → skip enforcement
                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"⚠️ Permission denied. Role '{role_name}' cannot use "
                        f"{skill_name}:{tool_name}."
                    )
        except ImportError:
            # RBAC module not available — check if rbac.json exists.
            # If it does, RBAC was configured but the module is broken → deny.
            try:
                from familiar.core.paths import get_data_dir

                _rbac_path = get_data_dir() / "rbac.json"
                if _rbac_path.exists():
                    logger.warning(
                        "RBAC module failed to import but rbac.json exists — "
                        "denying tool call as safety measure"
                    )
                    session.add_audit(
                        "tool_blocked_rbac_unavailable",
                        {"tool": tool_name, "reason": "RBAC module import failed"},
                    )
                    return (
                        "⚠️ Permission check unavailable. Tool blocked for safety. "
                        "Contact your administrator."
                    )
                else:
                    logger.debug("RBAC module not installed (no rbac.json) — skipping RBAC")
            except Exception:
                # Can't even check the path — log and allow (defense in depth
                # via capability checks still applies)
                logger.warning("RBAC module import failed and path check failed")

        # Guardrails: config-level blocklist + approval-required list.
        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"⚠️ {guardrail_reason}"

        if requires_approval:
            session.add_audit(
                "tool_blocked_needs_approval",
                {
                    "tool": tool_name,
                    "reason": guardrail_reason,
                },
            )
            return (
                f"⚠️ '{tool_name}' requires administrator approval and cannot run automatically. "
                f"Please contact your admin or use a manual workflow."
            )

        # Check if confirmation needed
        if needs_confirm:
            session.add_audit("tool_requires_confirmation", {"tool": tool_name})
            logger.debug(
                f"Tool {tool_name}: capability-level confirm noted; skill-level gate handles UI"
            )

        # 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_result_holder: list = []
        _tool_exc_holder: list = []
        _done_event = threading.Event()

        def _run_tool():
            # Clear stale triage context from parent thread (H7)
            try:
                threading.current_thread()._familiar_triage_ctx = None
            except Exception:
                pass
            try:
                _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)
        elif _tool_exc_holder:
            raise _tool_exc_holder[0]
        else:
            result = _tool_result_holder[0] if _tool_result_holder else None

        if trace and finished and not _tool_exc_holder:
            try:
                with trace.span(f"tool:{tool_name}") as span:
                    span.metadata["tool_input"] = tool_input
            except Exception:
                pass

        _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)
            return f"{SENTINEL_PREFIX}{token}"

        # 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("Error"))
        )
        if _is_error:
            _tool_error = result[:200]
        try:
            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", "")),
            )
        except Exception as _m_err:
            logger.debug(f"Metrics record_tool_execution skipped: {_m_err}")

        # Analytics: record tool call
        _skill_name = ""
        try:
            _skill_name = self.get_skill_for_tool(tool_name) or ""
            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 ""),
                },
            )
        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}")

        return result

    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

        # 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 capability_allowed, _extract_skills(capability_allowed)

        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 routing.schemas, _extract_skills(routing.schemas)
        except Exception as e:
            logger.warning(f"ToolRouter failed, using full capability set: {e}")

        return capability_allowed, _extract_skills(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
