import asyncio
import uuid
from datetime import datetime
from pdb import run
from typing import Any, Dict, List, Optional, TypedDict, Union

from loguru import logger
from pydantic import BaseModel

from ..database.db_manager import DatabaseManager
from ..datamodel.db import EvalCriteriaDB, EvalRunDB, EvalTaskDB
from ..datamodel.eval import EvalJudgeCriteria, EvalRunResult, EvalRunStatus, EvalScore, EvalTask
from .judges import BaseEvalJudge
from .runners import BaseEvalRunner


class DimensionScore(TypedDict):
    score: Optional[float]
    reason: Optional[str]


class RunEntry(TypedDict):
    id: str
    name: str
    task_name: str
    runner_type: str
    overall_score: Optional[float]
    scores: List[Optional[float]]
    reasons: Optional[List[Optional[str]]]


class TabulatedResults(TypedDict):
    dimensions: List[str]
    runs: List[RunEntry]


class EvalOrchestrator:
    """
    Orchestrator for evaluation runs.

    This class manages the lifecycle of evaluation tasks, criteria, and runs.
    It can operate with or without a database manager for persistence.
    """

    def __init__(self, db_manager: Optional[DatabaseManager] = None):
        """
        Initialize the orchestrator.

        Args:
            db_manager: Optional database manager for persistence.
                        If None, data is stored in memory only.
        """
        self._db_manager = db_manager

        # In-memory storage (used when db_manager is None)
        self._tasks: Dict[str, EvalTask] = {}
        self._criteria: Dict[str, EvalJudgeCriteria] = {}
        self._runs: Dict[str, Dict[str, Any]] = {}

        # Active runs tracking
        self._active_runs: Dict[str, asyncio.Task] = {}

    # ----- Task Management -----

    async def create_task(self, task: EvalTask) -> str:
        """
        Create a new evaluation task.

        Args:
            task: The evaluation task to create

        Returns:
            Task ID
        """
        if not task.task_id:
            task.task_id = str(uuid.uuid4())

        if self._db_manager:
            # Store in database
            task_db = EvalTaskDB(name=task.name, description=task.description, config=task)
            response = self._db_manager.upsert(task_db)
            if not response.status:
                logger.error(f"Failed to store task: {response.message}")
                raise RuntimeError(f"Failed to store task: {response.message}")
            task_id = str(response.data.get("id")) if response.data else str(task.task_id)
        else:
            # Store in memory
            task_id = str(task.task_id)
            self._tasks[task_id] = task

        return task_id

    async def get_task(self, task_id: str) -> Optional[EvalTask]:
        """
        Retrieve an evaluation task by ID.

        Args:
            task_id: The ID of the task to retrieve

        Returns:
            The task if found, None otherwise
        """
        if self._db_manager:
            # Retrieve from database
            response = self._db_manager.get(EvalTaskDB, filters={"id": int(task_id) if task_id.isdigit() else task_id})

            if response.status and response.data and len(response.data) > 0:
                task_data = response.data[0]
                return (
                    task_data.get("config")
                    if isinstance(task_data.get("config"), EvalTask)
                    else EvalTask.model_validate(task_data.get("config"))
                )
        else:
            # Retrieve from memory
            return self._tasks.get(task_id)

        return None

    async def list_tasks(self) -> List[EvalTask]:
        """
        List all available evaluation tasks.

        Returns:
            List of evaluation tasks
        """
        if self._db_manager:
            # Retrieve from database
            response = self._db_manager.get(EvalTaskDB)

            tasks = []
            if response.status and response.data:
                for task_data in response.data:
                    config = task_data.get("config")
                    if config:
                        if isinstance(config, EvalTask):
                            tasks.append(config)
                        else:
                            tasks.append(EvalTask.model_validate(config))
            return tasks
        else:
            # Retrieve from memory
            return list(self._tasks.values())

    # ----- Criteria Management -----

    async def create_criteria(self, criteria: EvalJudgeCriteria) -> str:
        """
        Create new evaluation criteria.

        Args:
            criteria: The evaluation criteria to create

        Returns:
            Criteria ID
        """
        criteria_id = str(uuid.uuid4())

        if self._db_manager:
            # Store in database
            criteria_db = EvalCriteriaDB(name=criteria.dimension, description=criteria.prompt, config=criteria)
            response = self._db_manager.upsert(criteria_db)
            if not response.status:
                logger.error(f"Failed to store criteria: {response.message}")
                raise RuntimeError(f"Failed to store criteria: {response.message}")
            criteria_id = str(response.data.get("id")) if response.data else criteria_id
        else:
            # Store in memory
            self._criteria[criteria_id] = criteria

        return criteria_id

    async def get_criteria(self, criteria_id: str) -> Optional[EvalJudgeCriteria]:
        """
        Retrieve evaluation criteria by ID.

        Args:
            criteria_id: The ID of the criteria to retrieve

        Returns:
            The criteria if found, None otherwise
        """
        if self._db_manager:
            # Retrieve from database
            response = self._db_manager.get(
                EvalCriteriaDB, filters={"id": int(criteria_id) if criteria_id.isdigit() else criteria_id}
            )

            if response.status and response.data and len(response.data) > 0:
                criteria_data = response.data[0]
                return (
                    criteria_data.get("config")
                    if isinstance(criteria_data.get("config"), EvalJudgeCriteria)
                    else EvalJudgeCriteria.model_validate(criteria_data.get("config"))
                )
        else:
            # Retrieve from memory
            return self._criteria.get(criteria_id)

        return None

    async def list_criteria(self) -> List[EvalJudgeCriteria]:
        """
        List all available evaluation criteria.

        Returns:
            List of evaluation criteria
        """
        if self._db_manager:
            # Retrieve from database
            response = self._db_manager.get(EvalCriteriaDB)

            criteria_list = []
            if response.status and response.data:
                for criteria_data in response.data:
                    config = criteria_data.get("config")
                    if config:
                        if isinstance(config, EvalJudgeCriteria):
                            criteria_list.append(config)
                        else:
                            criteria_list.append(EvalJudgeCriteria.model_validate(config))
            return criteria_list
        else:
            # Retrieve from memory
            return list(self._criteria.values())

    # ----- Run Management -----

    async def create_run(
        self,
        task: Union[str, EvalTask],
        runner: BaseEvalRunner,
        judge: BaseEvalJudge,
        criteria: List[Union[str, EvalJudgeCriteria]],
        name: str = "",
        description: str = "",
    ) -> str:
        """
        Create a new evaluation run configuration.

        Args:
            task: The task to evaluate (ID or task object)
            runner: The runner to use for evaluation
            judge: The judge to use for evaluation
            criteria: List of criteria to use for evaluation (IDs or criteria objects)
            name: Name for the run
            description: Description for the run

        Returns:
            Run ID
        """
        # Resolve task
        task_obj = None
        if isinstance(task, str):
            task_obj = await self.get_task(task)
            if not task_obj:
                raise ValueError(f"Task not found: {task}")
        else:
            task_obj = task

        # Resolve criteria
        criteria_objs = []
        for criterion in criteria:
            if isinstance(criterion, str):
                criterion_obj = await self.get_criteria(criterion)
                if not criterion_obj:
                    raise ValueError(f"Criteria not found: {criterion}")
                criteria_objs.append(criterion_obj)
            else:
                criteria_objs.append(criterion)

        # Generate run ID
        run_id = str(uuid.uuid4())

        # Create run configuration
        runner_config = runner.dump_component() if hasattr(runner, "dump_component") else runner._to_config()
        judge_config = judge.dump_component() if hasattr(judge, "dump_component") else judge._to_config()

        if self._db_manager:
            # Store in database
            run_db = EvalRunDB(
                name=name or f"Run {run_id}",
                description=description,
                task_id=int(task) if isinstance(task, str) and task.isdigit() else None,
                runner_config=runner_config.model_dump(),
                judge_config=judge_config.model_dump(),
                criteria_configs=criteria_objs,
                status=EvalRunStatus.PENDING,
            )
            response = self._db_manager.upsert(run_db)
            if not response.status:
                logger.error(f"Failed to store run: {response.message}")
                raise RuntimeError(f"Failed to store run: {response.message}")
            run_id = str(response.data.get("id")) if response.data else run_id
        else:
            # Store in memory
            self._runs[run_id] = {
                "task": task_obj,
                "runner_config": runner_config,
                "judge_config": judge_config,
                "criteria_configs": [c.model_dump() for c in criteria_objs],
                "status": EvalRunStatus.PENDING,
                "created_at": datetime.now(),
                "run_result": None,
                "score_result": None,
                "name": name or f"Run {run_id}",
                "description": description,
            }

        return run_id

    async def start_run(self, run_id: str) -> None:
        """
        Start an evaluation run.

        Args:
            run_id: The ID of the run to start
        """
        # Check if run is already active
        if run_id in self._active_runs:
            logger.warning(f"Run {run_id} is already active")
            return

        # Start the run asynchronously
        run_task = asyncio.create_task(self._execute_run(run_id))
        self._active_runs[run_id] = run_task

        # Update run status
        await self._update_run_status(run_id, EvalRunStatus.RUNNING)

    async def _execute_run(self, run_id: str) -> None:
        """
        Execute an evaluation run.

        Args:
            run_id: The ID of the run to execute
        """
        try:
            # Get run configuration
            run_config = await self._get_run_config(run_id)
            if not run_config:
                raise ValueError(f"Run not found: {run_id}")

            # Get task
            task = run_config.get("task")
            if not task:
                raise ValueError(f"Task not found for run: {run_id}")

            # Initialize runner
            runner_config = run_config.get("runner_config")
            runner = BaseEvalRunner.load_component(runner_config) if runner_config else None

            # Initialize judge
            judge_config = run_config.get("judge_config")
            judge = BaseEvalJudge.load_component(judge_config) if judge_config else None

            if not runner or not judge:
                raise ValueError(f"Runner or judge not found for run: {run_id}")

            # Initialize criteria
            criteria_configs = run_config.get("criteria_configs")
            criteria = []
            if criteria_configs:
                criteria = [
                    EvalJudgeCriteria.model_validate(c) if not isinstance(c, EvalJudgeCriteria) else c
                    for c in criteria_configs
                ]

            # Execute runner
            logger.info(f"Starting runner for run {run_id}")
            start_time = datetime.now()
            run_result = await runner.run(task)

            # Update run result
            await self._update_run_result(run_id, run_result)

            if not run_result.status:
                logger.error(f"Runner failed for run {run_id}: {run_result.error}")
                await self._update_run_status(run_id, EvalRunStatus.FAILED)
                return

            # Execute judge
            logger.info(f"Starting judge for run {run_id}")
            score_result = await judge.judge(task, run_result, criteria)

            # Update score result
            await self._update_score_result(run_id, score_result)

            # Update run status
            end_time = datetime.now()
            await self._update_run_completed(run_id, start_time, end_time)

            logger.info(f"Run {run_id} completed successfully")

        except Exception as e:
            logger.exception(f"Error executing run {run_id}: {str(e)}")
            await self._update_run_error(run_id, str(e))
        finally:
            # Remove from active runs
            if run_id in self._active_runs:
                del self._active_runs[run_id]

    async def get_run_status(self, run_id: str) -> Optional[EvalRunStatus]:
        """
        Get the status of an evaluation run.

        Args:
            run_id: The ID of the run

        Returns:
            The run status if found, None otherwise
        """
        run_config = await self._get_run_config(run_id)
        return run_config.get("status") if run_config else None

    async def get_run_result(self, run_id: str) -> Optional[EvalRunResult]:
        """
        Get the result of an evaluation run.

        Args:
            run_id: The ID of the run

        Returns:
            The run result if found, None otherwise
        """
        run_config = await self._get_run_config(run_id)
        if not run_config:
            return None

        run_result = run_config.get("run_result")
        if not run_result:
            return None

        return run_result if isinstance(run_result, EvalRunResult) else EvalRunResult.model_validate(run_result)

    async def get_run_score(self, run_id: str) -> Optional[EvalScore]:
        """
        Get the score of an evaluation run.

        Args:
            run_id: The ID of the run

        Returns:
            The run score if found, None otherwise
        """
        run_config = await self._get_run_config(run_id)
        if not run_config:
            return None

        score_result = run_config.get("score_result")
        if not score_result:
            return None

        return score_result if isinstance(score_result, EvalScore) else EvalScore.model_validate(score_result)

    async def list_runs(self) -> List[Dict[str, Any]]:
        """
        List all available evaluation runs.

        Returns:
            List of run configurations
        """
        if self._db_manager:
            # Retrieve from database
            response = self._db_manager.get(EvalRunDB)

            runs = []
            if response.status and response.data:
                for run_data in response.data:
                    runs.append(
                        {
                            "id": run_data.get("id"),
                            "name": run_data.get("name"),
                            "status": run_data.get("status"),
                            "created_at": run_data.get("created_at"),
                            "updated_at": run_data.get("updated_at"),
                        }
                    )
            return runs
        else:
            # Retrieve from memory
            return [
                {
                    "id": run_id,
                    "name": run_config.get("name"),
                    "status": run_config.get("status"),
                    "created_at": run_config.get("created_at"),
                    "updated_at": run_config.get("updated_at", run_config.get("created_at")),
                }
                for run_id, run_config in self._runs.items()
            ]

    async def cancel_run(self, run_id: str) -> bool:
        """
        Cancel an active evaluation run.

        Args:
            run_id: The ID of the run to cancel

        Returns:
            True if the run was cancelled, False otherwise
        """
        # Check if run is active
        if run_id not in self._active_runs:
            logger.warning(f"Run {run_id} is not active")
            return False

        # Cancel the run task
        try:
            self._active_runs[run_id].cancel()
            await self._update_run_status(run_id, EvalRunStatus.CANCELED)
            del self._active_runs[run_id]
            return True
        except Exception as e:
            logger.error(f"Failed to cancel run {run_id}: {str(e)}")
            return False

    # ----- Helper Methods -----

    async def _get_run_config(self, run_id: str) -> Optional[Dict[str, Any]]:
        """
        Get the configuration of an evaluation run.

        Args:
            run_id: The ID of the run

        Returns:
            The run configuration if found, None otherwise
        """
        if self._db_manager:
            # Retrieve from database
            response = self._db_manager.get(EvalRunDB, filters={"id": int(run_id) if run_id.isdigit() else run_id})

            if response.status and response.data and len(response.data) > 0:
                run_data = response.data[0]

                # Get task
                task = None
                if run_data.get("task_id"):
                    task_response = self._db_manager.get(EvalTaskDB, filters={"id": run_data.get("task_id")})
                    if task_response.status and task_response.data and len(task_response.data) > 0:
                        task_data = task_response.data[0]
                        task = (
                            task_data.get("config")
                            if isinstance(task_data.get("config"), EvalTask)
                            else EvalTask.model_validate(task_data.get("config"))
                        )

                return {
                    "task": task,
                    "runner_config": run_data.get("runner_config"),
                    "judge_config": run_data.get("judge_config"),
                    "criteria_configs": run_data.get("criteria_configs"),
                    "status": run_data.get("status"),
                    "run_result": run_data.get("run_result"),
                    "score_result": run_data.get("score_result"),
                    "name": run_data.get("name"),
                    "description": run_data.get("description"),
                    "created_at": run_data.get("created_at"),
                    "updated_at": run_data.get("updated_at"),
                }
        else:
            # Retrieve from memory
            return self._runs.get(run_id)

        return None

    async def _update_run_status(self, run_id: str, status: EvalRunStatus) -> None:
        """
        Update the status of an evaluation run.

        Args:
            run_id: The ID of the run
            status: The new status
        """
        if self._db_manager:
            # Update in database
            response = self._db_manager.get(EvalRunDB, filters={"id": int(run_id) if run_id.isdigit() else run_id})

            if response.status and response.data and len(response.data) > 0:
                run_data = response.data[0]
                run_db = EvalRunDB.model_validate(run_data)
                run_db.status = status
                run_db.updated_at = datetime.now()
                self._db_manager.upsert(run_db)
        else:
            # Update in memory
            if run_id in self._runs:
                self._runs[run_id]["status"] = status
                self._runs[run_id]["updated_at"] = datetime.now()

    async def _update_run_result(self, run_id: str, run_result: EvalRunResult) -> None:
        """
        Update the result of an evaluation run.

        Args:
            run_id: The ID of the run
            run_result: The run result
        """
        if self._db_manager:
            # Update in database
            response = self._db_manager.get(EvalRunDB, filters={"id": int(run_id) if run_id.isdigit() else run_id})

            if response.status and response.data and len(response.data) > 0:
                run_data = response.data[0]
                run_db = EvalRunDB.model_validate(run_data)
                run_db.run_result = run_result
                run_db.updated_at = datetime.now()
                self._db_manager.upsert(run_db)
        else:
            # Update in memory
            if run_id in self._runs:
                self._runs[run_id]["run_result"] = run_result
                self._runs[run_id]["updated_at"] = datetime.now()

    async def _update_score_result(self, run_id: str, score_result: EvalScore) -> None:
        """
        Update the score of an evaluation run.

        Args:
            run_id: The ID of the run
            score_result: The score result
        """
        if self._db_manager:
            # Update in database
            response = self._db_manager.get(EvalRunDB, filters={"id": int(run_id) if run_id.isdigit() else run_id})

            if response.status and response.data and len(response.data) > 0:
                run_data = response.data[0]
                run_db = EvalRunDB.model_validate(run_data)
                run_db.score_result = score_result
                run_db.updated_at = datetime.now()
                self._db_manager.upsert(run_db)
        else:
            # Update in memory
            if run_id in self._runs:
                self._runs[run_id]["score_result"] = score_result
                self._runs[run_id]["updated_at"] = datetime.now()

    async def _update_run_completed(self, run_id: str, start_time: datetime, end_time: datetime) -> None:
        """
        Update a run as completed.

        Args:
            run_id: The ID of the run
            start_time: The start time
            end_time: The end time
        """
        if self._db_manager:
            # Update in database
            response = self._db_manager.get(EvalRunDB, filters={"id": int(run_id) if run_id.isdigit() else run_id})

            if response.status and response.data and len(response.data) > 0:
                run_data = response.data[0]
                run_db = EvalRunDB.model_validate(run_data)
                run_db.status = EvalRunStatus.COMPLETED
                run_db.start_time = start_time
                run_db.end_time = end_time
                run_db.updated_at = datetime.now()
                self._db_manager.upsert(run_db)
        else:
            # Update in memory
            if run_id in self._runs:
                self._runs[run_id]["status"] = EvalRunStatus.COMPLETED
                self._runs[run_id]["start_time"] = start_time
                self._runs[run_id]["end_time"] = end_time
                self._runs[run_id]["updated_at"] = datetime.now()

    async def _update_run_error(self, run_id: str, error_message: str) -> None:
        """
        Update a run with an error.

        Args:
            run_id: The ID of the run
            error_message: The error message
        """
        if self._db_manager:
            # Update in database
            response = self._db_manager.get(EvalRunDB, filters={"id": int(run_id) if run_id.isdigit() else run_id})

            if response.status and response.data and len(response.data) > 0:
                run_data = response.data[0]
                run_db = EvalRunDB.model_validate(run_data)
                run_db.status = EvalRunStatus.FAILED
                run_db.error_message = error_message
                run_db.end_time = datetime.now()
                run_db.updated_at = datetime.now()
                self._db_manager.upsert(run_db)
        else:
            # Update in memory
            if run_id in self._runs:
                self._runs[run_id]["status"] = EvalRunStatus.FAILED
                self._runs[run_id]["error_message"] = error_message
                self._runs[run_id]["end_time"] = datetime.now()
                self._runs[run_id]["updated_at"] = datetime.now()

    async def tabulate_results(self, run_ids: List[str], include_reasons: bool = False) -> TabulatedResults:
        """
        Generate a tabular representation of evaluation results across runs.

        This method collects scores across different runs and organizes them by
        dimension, making it easy to create visualizations like radar charts.

        Args:
            run_ids: List of run IDs to include in the tabulation
            include_reasons: Whether to include scoring reasons in the output

        Returns:
            A dictionary with structured data suitable for visualization
        """
        result: TabulatedResults = {"dimensions": [], "runs": []}

        # Parallelize fetching of run configs and scores
        fetch_tasks = []
        for run_id in run_ids:
            fetch_tasks.append(self._get_run_config(run_id))
            fetch_tasks.append(self.get_run_score(run_id))

        # Wait for all fetches to complete
        fetch_results = await asyncio.gather(*fetch_tasks)

        # Process fetched data
        dimensions_set = set()
        run_data = {}

        for i in range(0, len(fetch_results), 2):
            run_id = run_ids[i // 2]
            run_config = fetch_results[i]
            score = fetch_results[i + 1]

            # Store run data for later processing
            run_data[run_id] = (run_config, score)

            # Collect dimensions
            if score and score.dimension_scores:
                for dim_score in score.dimension_scores:
                    dimensions_set.add(dim_score.dimension)

        # Convert dimensions to sorted list
        result["dimensions"] = sorted(list(dimensions_set))

        # Process each run's data
        for run_id, (run_config, score) in run_data.items():
            if not run_config or not score:
                continue

            # Determine runner type
            runner_type = "unknown"
            if run_config.get("runner_config"):
                runner_config = run_config.get("runner_config")
                if runner_config is not None and "provider" in runner_config:
                    if "ModelEvalRunner" in runner_config["provider"]:
                        runner_type = "model"
                    elif "TeamEvalRunner" in runner_config["provider"]:
                        runner_type = "team"

            # Get task name
            task = run_config.get("task")
            task_name = task.name if task else "Unknown Task"

            # Create run entry
            run_entry: RunEntry = {
                "id": run_id,
                "name": run_config.get("name", f"Run {run_id}"),
                "task_name": task_name,
                "runner_type": runner_type,
                "overall_score": score.overall_score,
                "scores": [],
                "reasons": [] if include_reasons else None,
            }

            # Build dimension lookup map for O(1) access
            dim_map = {ds.dimension: ds for ds in score.dimension_scores}

            # Populate scores aligned with dimensions
            for dim in result["dimensions"]:
                dim_score = dim_map.get(dim)
                if dim_score:
                    run_entry["scores"].append(dim_score.score)
                    if include_reasons:
                        run_entry["reasons"].append(dim_score.reason)  # type: ignore
                else:
                    run_entry["scores"].append(None)
                    if include_reasons:
                        run_entry["reasons"].append(None)  # type: ignore

            result["runs"].append(run_entry)

        return result
