"""Action Executor - Ejecuta las acciones del plan con retry."""

from typing import Dict, Any, List, Optional, Callable
import json
import asyncio
import inspect
from sonika_langchain_bot.planner.nodes.base_node import BaseNode


class ActionExecutor(BaseNode):
    """
    Ejecutor de acciones que procesa el plan generado por el Smart Orchestrator.
    
    Características:
    - Ejecuta acciones en orden de prioridad
    - Retry automático (hasta 3 intentos por tool)
    - Callbacks para monitoreo de ejecución
    - Manejo robusto de errores
    - Mapeo automático de parámetros
    """

    MAX_RETRIES = 3
    RETRY_DELAY_SECONDS = 0.5

    def __init__(
        self,
        tools: List[Any],
        on_tool_start: Optional[Callable[[str, str], None]] = None,
        on_tool_end: Optional[Callable[[str, str], None]] = None,
        on_tool_error: Optional[Callable[[str, str], None]] = None,
        logger=None
    ):
        super().__init__(logger)
        self.tools_list = tools
        self.tools = {t.name: t for t in tools}
        self.on_tool_start = on_tool_start
        self.on_tool_end = on_tool_end
        self.on_tool_error = on_tool_error
        self.tool_schemas = self._extract_tool_schemas()

    def _extract_tool_schemas(self) -> Dict[str, Dict[str, Any]]:
        """Extrae los schemas de las tools para conocer los parámetros exactos."""
        schemas = {}
        
        for tool in self.tools_list:
            tool_name = tool.name
            params = {}
            
            # Método 1: args_schema (para tools con Pydantic schema)
            if hasattr(tool, 'args_schema') and tool.args_schema:
                try:
                    schema = tool.args_schema.model_json_schema()
                    properties = schema.get('properties', {})
                    required = schema.get('required', [])
                    for param_name, param_info in properties.items():
                        params[param_name] = {
                            'type': param_info.get('type', 'string'),
                            'required': param_name in required
                        }
                except Exception:
                    pass
            
            # Método 2: get_input_schema (para tools de LangChain)
            if not params and hasattr(tool, 'get_input_schema'):
                try:
                    input_schema = tool.get_input_schema()
                    if hasattr(input_schema, 'model_json_schema'):
                        schema = input_schema.model_json_schema()
                        properties = schema.get('properties', {})
                        required = schema.get('required', [])
                        for param_name, param_info in properties.items():
                            # Ignorar parámetros internos de LangChain
                            if param_name in ('args', 'config', 'kwargs', 'run_manager'):
                                continue
                            params[param_name] = {
                                'type': param_info.get('type', 'string'),
                                'required': param_name in required
                            }
                except Exception:
                    pass
            
            # Método 3: Inspeccionar _run method (fallback)
            if not params and hasattr(tool, '_run'):
                try:
                    sig = inspect.signature(tool._run)
                    for param_name, param in sig.parameters.items():
                        # Ignorar self y parámetros internos
                        if param_name in ('self', 'args', 'kwargs', 'run_manager', 'config'):
                            continue
                        params[param_name] = {
                            'type': 'string',
                            'required': param.default == inspect.Parameter.empty
                        }
                except Exception:
                    pass
            
            # Método 4: Para tools creadas con @tool decorator, inspeccionar func
            if not params and hasattr(tool, 'func'):
                try:
                    sig = inspect.signature(tool.func)
                    for param_name, param in sig.parameters.items():
                        if param_name in ('self', 'args', 'kwargs', 'run_manager', 'config'):
                            continue
                        params[param_name] = {
                            'type': 'string',
                            'required': param.default == inspect.Parameter.empty
                        }
                except Exception:
                    pass
            
            schemas[tool_name] = {'parameters': params}
        
        return schemas

    def _resolve_tool_name(self, action: Dict[str, Any]) -> Optional[str]:
        """
        Resuelve el nombre de la tool a ejecutar basado en el tipo de acción.
        
        Mapeo de tipos de acción a tools:
        - save_contact -> create_or_update_contact
        - search_knowledge -> search_knowledge_documents
        - accept_policies -> accept_policies
        - execute_tool -> usa tool_name directamente
        """
        action_type = action.get("type", "")
        tool_name = action.get("tool_name")
        
        # Si ya tiene tool_name explícito, usarlo
        if tool_name and tool_name in self.tools:
            return tool_name
        
        # Mapeo de tipos a tools
        type_to_tool = {
            "save_contact": "create_or_update_contact",
            "search_knowledge": "search_knowledge_documents",
            "accept_policies": "accept_policies",
        }
        
        mapped_name = type_to_tool.get(action_type)
        if mapped_name and mapped_name in self.tools:
            return mapped_name
        
        # Si es execute_tool, buscar por tool_name
        if action_type == "execute_tool" and tool_name:
            return tool_name if tool_name in self.tools else None
        
        # Buscar por nombres alternativos comunes
        alternative_names = {
            "save_contact": ["SaveContact", "save_contact", "create_or_update_contact"],
            "search_knowledge": ["search_knowledge_documents", "SearchKnowledge"],
            "accept_policies": ["accept_policies", "AcceptPolicies"],
        }
        
        for alt_name in alternative_names.get(action_type, []):
            if alt_name in self.tools:
                return alt_name
        
        return None

    def _map_params_to_tool(self, tool_name: str, params: Dict[str, Any], action_type: str) -> Dict[str, Any]:
        """
        Mapea los parámetros genéricos a los parámetros reales de la tool.
        
        Esto resuelve el problema de que el LLM puede generar:
        - {"name": "Juan"} pero la tool espera {"nombre": "Juan"}
        - {"to": "email@test.com"} pero la tool espera {"to_email": "email@test.com"}
        """
        schema = self.tool_schemas.get(tool_name, {})
        expected_params = schema.get('parameters', {})
        expected_param_names = set(expected_params.keys())
        
        # Si no hay schema definido, pasar los parámetros tal cual
        if not expected_params:
            return params
        
        # Si los parámetros ya coinciden, retornarlos
        if set(params.keys()) <= expected_param_names:
            # Rellenar parámetros requeridos faltantes con strings vacíos
            mapped = dict(params)
            for param_name, param_info in expected_params.items():
                if param_name not in mapped and param_info.get('required', True):
                    mapped[param_name] = ''
            return mapped
        
        # Mapeo de nombres genéricos a posibles nombres reales
        param_mappings = {
            # Contacto
            'name': ['nombre', 'name', 'first_name', 'full_name', 'contact_name'],
            'nombre': ['nombre', 'name', 'first_name', 'full_name', 'contact_name'],
            'email': ['correo', 'email', 'email_address', 'contact_email'],
            'correo': ['correo', 'email', 'email_address', 'contact_email'],
            'phone': ['telefono', 'phone', 'phone_number', 'celular', 'cel'],
            'telefono': ['telefono', 'phone', 'phone_number', 'celular', 'cel'],
            # Email
            'to': ['to_email', 'to', 'recipient', 'destinatario'],
            'to_email': ['to_email', 'to', 'recipient', 'destinatario'],
            'subject': ['subject', 'asunto', 'titulo'],
            'message': ['message', 'body', 'content', 'mensaje', 'cuerpo', 'user_message'],
            'body': ['message', 'body', 'content', 'mensaje', 'cuerpo'],
            # Políticas
            'user_message': ['user_message', 'message', 'confirmation'],
            'confirmation': ['user_message', 'message', 'confirmation'],
            # Búsqueda
            'query': ['query', 'search_query', 'question', 'pregunta'],
        }
        
        mapped = {}
        used_expected = set()
        
        for param_key, param_value in params.items():
            # Buscar el nombre correcto en los parámetros esperados
            candidates = param_mappings.get(param_key, [param_key])
            
            matched = False
            for candidate in candidates:
                if candidate in expected_param_names and candidate not in used_expected:
                    mapped[candidate] = param_value
                    used_expected.add(candidate)
                    matched = True
                    break
            
            # Si no encontramos mapeo, intentar buscar por similitud
            if not matched:
                for expected_name in expected_param_names:
                    if expected_name not in used_expected:
                        # Verificar si el nombre esperado contiene parte del nombre del parámetro
                        if param_key.lower() in expected_name.lower() or expected_name.lower() in param_key.lower():
                            mapped[expected_name] = param_value
                            used_expected.add(expected_name)
                            matched = True
                            break
        
        # Rellenar parámetros requeridos faltantes
        for param_name, param_info in expected_params.items():
            if param_name not in mapped and param_info.get('required', True):
                # Intentar encontrar un valor apropiado de los params originales
                found_value = ''
                for orig_key, orig_value in params.items():
                    if orig_key not in [k for k, v in param_mappings.items() if param_name in v]:
                        continue
                    found_value = orig_value
                    break
                mapped[param_name] = found_value
        
        return mapped

    async def _execute_with_retry(
        self, 
        tool_name: str, 
        params: Dict[str, Any],
        action_type: str
    ) -> Dict[str, Any]:
        """
        Ejecuta una tool con retry automático.
        
        Returns:
            Dict con status, output, y attempts
        """
        tool = self.tools.get(tool_name)
        if not tool:
            return {
                "status": "failed",
                "output": f"Tool '{tool_name}' not found",
                "attempts": 0
            }
        
        last_error = None
        params_str = json.dumps(params) if isinstance(params, dict) else str(params)
        
        for attempt in range(1, self.MAX_RETRIES + 1):
            try:
                # Callback: inicio
                if self.on_tool_start:
                    try:
                        self.on_tool_start(tool_name, params_str)
                    except Exception:
                        pass
                
                # Ejecutar tool
                result = await tool.ainvoke(params)
                output = str(result)
                
                # Callback: éxito
                if self.on_tool_end:
                    try:
                        self.on_tool_end(tool_name, output)
                    except Exception:
                        pass
                
                return {
                    "status": "success",
                    "output": output,
                    "attempts": attempt
                }
                
            except Exception as e:
                last_error = str(e)
                self.logger.warning(f"Tool {tool_name} attempt {attempt} failed: {e}")
                
                # Callback: error
                if self.on_tool_error:
                    try:
                        self.on_tool_error(tool_name, f"Attempt {attempt}: {last_error}")
                    except Exception:
                        pass
                
                # Esperar antes de reintentar (excepto en el último intento)
                if attempt < self.MAX_RETRIES:
                    await asyncio.sleep(self.RETRY_DELAY_SECONDS)
        
        # Todos los intentos fallaron
        return {
            "status": "failed",
            "output": f"Failed after {self.MAX_RETRIES} attempts. Last error: {last_error}",
            "attempts": self.MAX_RETRIES
        }

    async def __call__(self, state: Dict[str, Any]) -> Dict[str, Any]:
        """
        Ejecuta todas las acciones del plan en orden de prioridad.
        """
        action_plan = state.get("action_plan")
        
        if not action_plan:
            return {
                "execution_results": [],
                "tools_executed": [],
                **self._add_log("No action plan to execute")
            }
        
        actions = action_plan.get("actions", [])
        
        if not actions:
            return {
                "execution_results": [],
                "tools_executed": [],
                **self._add_log("Action plan has no actions to execute")
            }
        
        # Ordenar por prioridad (menor número = mayor prioridad)
        sorted_actions = sorted(actions, key=lambda x: x.get("priority", 99))
        
        execution_results = []
        tools_executed = []
        
        for action in sorted_actions:
            action_type = action.get("type", "unknown")
            params = action.get("params", {})
            
            # Resolver nombre de tool
            tool_name = self._resolve_tool_name(action)
            
            if not tool_name:
                # Tool no encontrada, registrar y continuar
                result = {
                    "action_type": action_type,
                    "tool_name": action.get("tool_name", "unknown"),
                    "params": params,
                    "status": "skipped",
                    "output": f"Tool not found for action type '{action_type}'",
                    "attempts": 0
                }
                execution_results.append(result)
                self.logger.warning(f"Skipping action {action_type}: tool not found")
                continue
            
            # Mapear parámetros a los nombres correctos de la tool
            mapped_params = self._map_params_to_tool(tool_name, params, action_type)
            
            # Ejecutar con retry
            self.logger.info(f"Executing {tool_name} with params: {mapped_params}")
            exec_result = await self._execute_with_retry(tool_name, mapped_params, action_type)
            
            result = {
                "action_type": action_type,
                "tool_name": tool_name,
                "params": params,
                "status": exec_result["status"],
                "output": exec_result["output"],
                "attempts": exec_result["attempts"]
            }
            
            execution_results.append(result)
            
            # Agregar a tools_executed para compatibilidad
            tools_executed.append({
                "tool_name": tool_name,
                "args": json.dumps(params) if isinstance(params, dict) else str(params),
                "output": exec_result["output"],
                "status": exec_result["status"]
            })
            
            log_status = "✓" if exec_result["status"] == "success" else "✗"
            self._add_log(f"{log_status} {tool_name} ({exec_result['attempts']} attempts)")
        
        # Resumen de ejecución
        success_count = sum(1 for r in execution_results if r["status"] == "success")
        failed_count = sum(1 for r in execution_results if r["status"] == "failed")
        skipped_count = sum(1 for r in execution_results if r["status"] == "skipped")
        
        summary = f"Executed {len(execution_results)} actions: {success_count} success, {failed_count} failed, {skipped_count} skipped"
        
        return {
            "execution_results": execution_results,
            "tools_executed": tools_executed,
            **self._add_log(summary)
        }
