"""CrewAI adapter -- global before/after hook integration."""

from __future__ import annotations

import asyncio
import logging
import uuid
from collections.abc import Callable
from dataclasses import asdict
from typing import TYPE_CHECKING, Any

from edictum.audit import AuditAction, AuditEvent
from edictum.envelope import Principal, create_envelope
from edictum.findings import Finding, PostCallResult, build_findings
from edictum.pipeline import GovernancePipeline
from edictum.session import Session

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from edictum import Edictum


class CrewAIAdapter:
    """Translate Edictum pipeline decisions into CrewAI hook format.

    The adapter does NOT contain governance logic -- that lives in
    GovernancePipeline. The adapter only:
    1. Creates envelopes from CrewAI hook context
    2. Manages pending state (envelope + span) between before/after hooks
    3. Translates PreDecision/PostDecision into CrewAI hook responses
    4. Handles observe mode (deny -> allow conversion)

    CrewAI is sequential, so a single-pending slot correlates before/after.
    """

    def __init__(
        self,
        guard: Edictum,
        session_id: str | None = None,
        principal: Principal | None = None,
    ):
        self._guard = guard
        self._pipeline = GovernancePipeline(guard)
        self._session_id = session_id or str(uuid.uuid4())
        self._session = Session(self._session_id, guard.backend)
        self._call_index = 0
        self._pending_envelope: Any | None = None
        self._pending_span: Any | None = None
        self._principal = principal

    @property
    def session_id(self) -> str:
        return self._session_id

    @staticmethod
    def _normalize_tool_name(name: str) -> str:
        """Normalize CrewAI tool names to match contract tool names.

        Lowercases and replaces spaces, hyphens, and other non-alphanumeric
        separators with underscores, then collapses consecutive underscores.

        Examples:
            "Search Documents" -> "search_documents"
            "Read-Database"    -> "read_database"
            "already_snake"       -> "already_snake"
        """
        import re

        return re.sub(r"_+", "_", re.sub(r"[\s\-]+", "_", name.lower())).strip("_")

    def register(
        self,
        on_postcondition_warn: Callable[[Any, list[Finding]], Any] | None = None,
    ) -> None:
        """Register global before/after tool-call hooks with CrewAI.

        Uses CrewAI's ``register_*_hook()`` functions instead of decorators
        to avoid ``setattr`` failures on bound methods.

        Args:
            on_postcondition_warn: Optional callback invoked when postconditions
                detect issues. Receives (original_result, findings) and is called
                for side effects (CrewAI controls the tool result flow).

        Imports CrewAI hook registration lazily to avoid hard dependency.
        The underlying handlers are stored as _before_hook/_after_hook for
        direct test access without requiring the CrewAI framework.
        """
        self._on_postcondition_warn = on_postcondition_warn

        from crewai.hooks.tool_hooks import (
            register_after_tool_call_hook,
            register_before_tool_call_hook,
        )

        adapter = self

        def _run_async(coro):
            """Bridge async coroutine to sync CrewAI hook context.

            CrewAI calls hooks synchronously, but Edictum's guard is async.
            When no event loop is running, ``asyncio.run()`` is used directly.
            When called from within an active loop (e.g. nested in an async
            framework), a worker thread runs a fresh loop to avoid blocking
            the caller's loop.  This means Edictum state accessed inside the
            coroutine must be thread-safe; the adapter's own state is guarded
            by CrewAI's sequential-execution model (one tool at a time).
            """
            try:
                loop = asyncio.get_running_loop()
            except RuntimeError:
                loop = None

            if loop is not None and loop.is_running():
                import concurrent.futures

                with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
                    future = pool.submit(asyncio.run, coro)
                    return future.result()
            return asyncio.run(coro)

        def before_hook(context):
            original_name = context.tool_name
            context.tool_name = adapter._normalize_tool_name(original_name)
            result = _run_async(adapter._before_hook(context))
            context.tool_name = original_name
            return result

        def after_hook(context):
            original_name = context.tool_name
            context.tool_name = adapter._normalize_tool_name(original_name)
            post_result = _run_async(adapter._after_hook(context))
            context.tool_name = original_name

            # CrewAI's after hook can return a string to replace the tool result
            if post_result is not None and not post_result.postconditions_passed:
                on_warn = getattr(adapter, "_on_postcondition_warn", None)
                if on_warn:
                    try:
                        redacted = on_warn(getattr(context, "tool_result", None), post_result.findings)
                        if isinstance(redacted, str):
                            return redacted
                    except Exception:
                        logger.exception("on_postcondition_warn callback raised in after_hook")
            return None  # keep original result

        register_before_tool_call_hook(before_hook)
        register_after_tool_call_hook(after_hook)

    async def _before_hook(self, context: Any) -> bool | None:
        """Handle a before-tool-call event from CrewAI.

        Returns None to allow, False to deny.
        """
        tool_name: str = context.tool_name
        tool_input: dict = context.tool_input
        call_id = str(uuid.uuid4())

        # Create envelope
        envelope = create_envelope(
            tool_name=tool_name,
            tool_input=tool_input,
            run_id=self._session_id,
            call_index=self._call_index,
            tool_use_id=call_id,
            environment=self._guard.environment,
            registry=self._guard.tool_registry,
            principal=self._principal,
        )
        self._call_index += 1

        # Increment attempts BEFORE governance
        await self._session.increment_attempts()

        # Start OTel span
        span = self._guard.telemetry.start_tool_span(envelope)

        # Run pipeline
        decision = await self._pipeline.pre_execute(envelope, self._session)

        # Handle observe mode: convert deny to allow with warning
        if self._guard.mode == "observe" and decision.action == "deny":
            await self._emit_audit_pre(envelope, decision, audit_action=AuditAction.CALL_WOULD_DENY)
            span.set_attribute("governance.action", "would_deny")
            span.set_attribute("governance.would_deny_reason", decision.reason)
            self._pending_envelope = envelope
            self._pending_span = span
            return None  # allow through

        # Handle deny
        if decision.action == "deny":
            await self._emit_audit_pre(envelope, decision)
            self._guard.telemetry.record_denial(envelope, decision.reason)
            span.set_attribute("governance.action", "denied")
            span.end()
            self._pending_envelope = None
            self._pending_span = None
            return self._deny(decision.reason)

        # Handle per-contract observed denials
        if decision.observed:
            for cr in decision.contracts_evaluated:
                if cr.get("observed") and not cr.get("passed"):
                    await self._guard.audit_sink.emit(
                        AuditEvent(
                            action=AuditAction.CALL_WOULD_DENY,
                            run_id=envelope.run_id,
                            call_id=envelope.call_id,
                            call_index=envelope.call_index,
                            tool_name=envelope.tool_name,
                            tool_args=self._guard.redaction.redact_args(envelope.args),
                            side_effect=envelope.side_effect.value,
                            environment=envelope.environment,
                            principal=asdict(envelope.principal) if envelope.principal else None,
                            decision_source="precondition",
                            decision_name=cr["name"],
                            reason=cr["message"],
                            mode="observe",
                            policy_version=self._guard.policy_version,
                            policy_error=decision.policy_error,
                        )
                    )

        # Handle allow
        await self._emit_audit_pre(envelope, decision)
        span.set_attribute("governance.action", "allowed")
        self._pending_envelope = envelope
        self._pending_span = span
        return None

    async def _after_hook(self, context: Any) -> PostCallResult | None:
        """Handle an after-tool-call event from CrewAI. Returns PostCallResult."""
        # Use single-pending slot (sequential execution model)
        envelope = self._pending_envelope
        span = self._pending_span

        if envelope is None or span is None:
            return None

        # Clear pending state
        self._pending_envelope = None
        self._pending_span = None

        # Derive tool_success from context
        tool_result = getattr(context, "tool_result", None)
        tool_success = self._check_tool_success(tool_result)

        # Run pipeline
        post_decision = await self._pipeline.post_execute(envelope, tool_result, tool_success)

        effective_response = (
            post_decision.redacted_response if post_decision.redacted_response is not None else tool_result
        )

        # Record in session
        await self._session.record_execution(envelope.tool_name, success=tool_success)

        # Emit audit
        action = AuditAction.CALL_EXECUTED if tool_success else AuditAction.CALL_FAILED
        await self._guard.audit_sink.emit(
            AuditEvent(
                action=action,
                run_id=envelope.run_id,
                call_id=envelope.call_id,
                call_index=envelope.call_index,
                tool_name=envelope.tool_name,
                tool_args=self._guard.redaction.redact_args(envelope.args),
                side_effect=envelope.side_effect.value,
                environment=envelope.environment,
                principal=asdict(envelope.principal) if envelope.principal else None,
                tool_success=tool_success,
                postconditions_passed=post_decision.postconditions_passed,
                contracts_evaluated=post_decision.contracts_evaluated,
                session_attempt_count=await self._session.attempt_count(),
                session_execution_count=await self._session.execution_count(),
                mode=self._guard.mode,
                policy_version=self._guard.policy_version,
                policy_error=post_decision.policy_error,
            )
        )

        # End span
        span.set_attribute("governance.tool_success", tool_success)
        span.set_attribute("governance.postconditions_passed", post_decision.postconditions_passed)
        span.end()

        # Build findings
        findings = build_findings(post_decision)
        post_result = PostCallResult(
            result=effective_response,
            postconditions_passed=post_decision.postconditions_passed,
            findings=findings,
        )

        # Call callback for side effects
        on_warn = getattr(self, "_on_postcondition_warn", None)
        if not post_result.postconditions_passed and on_warn:
            try:
                on_warn(post_result.result, post_result.findings)
            except Exception:
                logger.exception("on_postcondition_warn callback raised")

        return post_result

    async def _emit_audit_pre(self, envelope: Any, decision: Any, audit_action: AuditAction | None = None) -> None:
        if audit_action is None:
            audit_action = AuditAction.CALL_DENIED if decision.action == "deny" else AuditAction.CALL_ALLOWED

        await self._guard.audit_sink.emit(
            AuditEvent(
                action=audit_action,
                run_id=envelope.run_id,
                call_id=envelope.call_id,
                call_index=envelope.call_index,
                tool_name=envelope.tool_name,
                tool_args=self._guard.redaction.redact_args(envelope.args),
                side_effect=envelope.side_effect.value,
                environment=envelope.environment,
                principal=asdict(envelope.principal) if envelope.principal else None,
                decision_source=decision.decision_source,
                decision_name=decision.decision_name,
                reason=decision.reason,
                hooks_evaluated=decision.hooks_evaluated,
                contracts_evaluated=decision.contracts_evaluated,
                session_attempt_count=await self._session.attempt_count(),
                session_execution_count=await self._session.execution_count(),
                mode=self._guard.mode,
                policy_version=self._guard.policy_version,
                policy_error=decision.policy_error,
            )
        )

    def _check_tool_success(self, tool_result: Any) -> bool:
        if tool_result is None:
            return True
        if isinstance(tool_result, dict):
            if tool_result.get("is_error"):
                return False
        if isinstance(tool_result, str):
            if tool_result.startswith("Error:") or tool_result.startswith("fatal:"):
                return False
        return True

    @staticmethod
    def _deny(reason: str) -> bool:
        """Return CrewAI's deny signal (False)."""
        return False
