"""
Dependency analysis system for wave-based parallel execution.

This module provides the DependencyGraph class that builds task dependency
graphs and groups independent tasks into parallel execution waves using
NetworkX for graph operations.

Requirements: EXEC-01 (wave-based execution), EXEC-03 (dependency detection)
"""

from typing import List, Set, Dict, Optional
from dataclasses import dataclass, field

import networkx as nx


@dataclass
class WaveResult:
    """Result of wave execution for tracking progress.

    Attributes:
        wave_id: Sequential wave number (0-indexed)
        task_ids: List of task IDs in this wave
        success: Whether all tasks in this wave succeeded
    """

    wave_id: int
    task_ids: List[str]
    success: bool = True


class DependencyGraph:
    """Task dependency graph with cycle detection and wave grouping.

    This class uses NetworkX DiGraph internally to manage task dependencies
    and provides methods for:
    - Adding tasks with their dependencies
    - Detecting circular dependencies
    - Grouping tasks into parallel execution waves

    The wave grouping algorithm uses topological generations to determine
    which tasks can run in parallel (same wave) vs. which must wait for
    prerequisites (different waves).

    Example:
        ```python
        from gsd_rlm.execution.dependency import DependencyGraph

        graph = DependencyGraph()
        graph.add_task("A")
        graph.add_task("B")
        graph.add_task("C", depends_on=["A", "B"])

        waves = graph.get_waves()
        # waves = [["A", "B"], ["C"]]
        # A and B can run in parallel, C must wait for both
        ```

    Attributes:
        _graph: Internal NetworkX DiGraph storing task dependencies
    """

    def __init__(self):
        """Initialize an empty dependency graph."""
        self._graph: nx.DiGraph = nx.DiGraph()

    def add_task(self, task_id: str, depends_on: Optional[List[str]] = None) -> None:
        """Add a task with optional dependencies.

        Args:
            task_id: Unique identifier for the task
            depends_on: List of task IDs that must complete before this task
                       (empty or None means no dependencies)

        Raises:
            ValueError: If task_id is empty
        """
        if not task_id:
            raise ValueError("task_id cannot be empty")

        # Add the task node
        self._graph.add_node(task_id)

        # Add dependency edges (prerequisite -> dependent)
        if depends_on:
            for prereq in depends_on:
                # Ensure prerequisite exists in graph
                if prereq not in self._graph:
                    self._graph.add_node(prereq)
                self._graph.add_edge(prereq, task_id)

    def add_tasks(self, tasks: Dict[str, List[str]]) -> None:
        """Add multiple tasks with their dependencies.

        This is a convenience method for bulk task addition.

        Args:
            tasks: Dictionary mapping task_id -> list of dependency task_ids

        Example:
            ```python
            graph.add_tasks({
                "A": [],
                "B": [],
                "C": ["A", "B"],
                "D": ["C"]
            })
            ```
        """
        for task_id, deps in tasks.items():
            self.add_task(task_id, deps if deps else None)

    def get_waves(self) -> List[List[str]]:
        """Get tasks grouped into parallel execution waves.

        Uses NetworkX topological_generations() to determine which tasks
        can run concurrently. Tasks in the same wave have no dependencies
        on each other and can be executed in parallel.

        Returns:
            List of waves, where each wave is a list of task IDs.
            Tasks in wave[i] can run in parallel.
            Tasks in wave[i+1] depend on at least one task in wave[i].

        Raises:
            ValueError: If the graph contains cycles (detected during
                       topological sort)
        """
        if not self._graph.nodes:
            return []

        try:
            # nx.topological_generations yields generators of nodes
            # where each generation represents tasks that can run in parallel
            generations = nx.topological_generations(self._graph)
            return [list(gen) for gen in generations]
        except nx.NetworkXUnfeasible as e:
            # Graph has cycles - raise with cycle information
            cycles = self.detect_cycles()
            cycle_str = " -> ".join(cycles[0]) if cycles else "unknown"
            raise ValueError(f"Circular dependency detected: {cycle_str}") from e

    def get_dependencies(self, task_id: str) -> Set[str]:
        """Get prerequisite tasks for a given task.

        Args:
            task_id: The task to get dependencies for

        Returns:
            Set of task IDs that must complete before this task

        Raises:
            KeyError: If task_id is not in the graph
        """
        if task_id not in self._graph:
            raise KeyError(f"Task '{task_id}' not found in graph")

        # Predecessors are tasks this task depends on
        return set(self._graph.predecessors(task_id))

    def get_dependents(self, task_id: str) -> Set[str]:
        """Get tasks that depend on a given task.

        Args:
            task_id: The task to get dependents for

        Returns:
            Set of task IDs that depend on this task

        Raises:
            KeyError: If task_id is not in the graph
        """
        if task_id not in self._graph:
            raise KeyError(f"Task '{task_id}' not found in graph")

        # Successors are tasks that depend on this task
        return set(self._graph.successors(task_id))

    def detect_cycles(self) -> List[List[str]]:
        """Detect and return all cycles in the dependency graph.

        Uses NetworkX simple_cycles to find all elementary cycles.

        Returns:
            List of cycles, where each cycle is a list of task IDs
            forming a circular dependency. Empty list if no cycles.
        """
        try:
            # nx.simple_cycles returns all elementary cycles
            cycles = list(nx.simple_cycles(self._graph))
            return cycles
        except nx.NetworkXNotImplemented:
            # For undirected graphs - shouldn't happen with DiGraph
            return []

    def validate(self) -> bool:
        """Validate the graph for execution.

        Checks for:
        - Circular dependencies

        Returns:
            True if the graph is valid (no cycles)

        Raises:
            ValueError: If circular dependencies are found, with details
                       about the cycles
        """
        cycles = self.detect_cycles()
        if cycles:
            # Format cycles for error message
            cycle_descriptions = []
            for cycle in cycles:
                cycle_descriptions.append(" -> ".join(cycle + [cycle[0]]))

            raise ValueError(
                f"Circular dependencies detected:\n"
                + "\n".join(f"  - {c}" for c in cycle_descriptions)
            )

        return True

    def get_task_count(self) -> int:
        """Get the number of tasks in the graph.

        Returns:
            Number of unique tasks
        """
        return self._graph.number_of_nodes()

    def get_dependency_count(self) -> int:
        """Get the number of dependency edges in the graph.

        Returns:
            Number of dependency relationships
        """
        return self._graph.number_of_edges()

    def has_task(self, task_id: str) -> bool:
        """Check if a task exists in the graph.

        Args:
            task_id: Task ID to check

        Returns:
            True if task exists in the graph
        """
        return task_id in self._graph

    def get_all_tasks(self) -> Set[str]:
        """Get all task IDs in the graph.

        Returns:
            Set of all task IDs
        """
        return set(self._graph.nodes())

    def clear(self) -> None:
        """Remove all tasks and dependencies from the graph."""
        self._graph.clear()

    def get_wave_count(self) -> int:
        """Get the number of waves needed to execute all tasks.

        Returns:
            Number of waves (0 if graph is empty)

        Raises:
            ValueError: If graph contains cycles
        """
        waves = self.get_waves()
        return len(waves)

    def get_tasks_in_wave(self, wave_index: int) -> List[str]:
        """Get tasks in a specific wave.

        Args:
            wave_index: Zero-indexed wave number

        Returns:
            List of task IDs in the specified wave

        Raises:
            ValueError: If graph contains cycles or wave_index is out of range
            IndexError: If wave_index is invalid
        """
        waves = self.get_waves()
        if wave_index < 0 or wave_index >= len(waves):
            raise IndexError(
                f"Wave index {wave_index} out of range (0-{len(waves) - 1})"
            )
        return waves[wave_index]
