from __future__ import annotations
import logging
import networkx as nx
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple, TYPE_CHECKING
from . import task as task_
from . import util


if TYPE_CHECKING:
    from .actions import Action
    from .contexts import Context
    from .task import Task


LOGGER = logging.getLogger(__name__)


class Manager:
    """
    Task manager that captures the relationship between tasks, targets, and dependencies.
    """
    _INSTANCE: Optional[Manager] = None

    def __init__(self, contexts: Optional[List["Context"]] = None) -> None:
        self.contexts: List["Context"] = contexts or []
        self.tasks: Dict[str, "Task"] = {}

    def __enter__(self) -> Manager:
        if Manager._INSTANCE:
            raise ValueError("another manager is already active")
        Manager._INSTANCE = self
        return self

    def __exit__(self, *_) -> None:
        if Manager._INSTANCE is not self:
            raise RuntimeError("exiting failed: unexpected manager")
        Manager._INSTANCE = None

    @staticmethod
    def get_instance() -> Manager:
        """
        Get the currently activate task manager.
        """
        if not Manager._INSTANCE:
            raise ValueError("no manager is active")
        return Manager._INSTANCE

    def create_task(self, name: str, **kwargs):
        """
        Create a task. See :func:`.create_task` for details.
        """
        try:
            if name in self.tasks:
                raise ValueError(f"task with name '{name}' already exists")
            task = task_.Task(name, **kwargs)
            for context in reversed(self.contexts):
                task = context.apply(task)
                if task is None:
                    raise ValueError(f"{context} did not return a task")
            self.tasks[name] = task
            return task
        except Exception as ex:
            filename, lineno = util.get_location()
            LOGGER.error("failed to create task with name '%s' at %s:%d: %s", name, filename,
                         lineno, ex)
            raise

    def resolve_dependencies(self) -> nx.DiGraph:
        """
        Resolve dependencies between tasks.

        Returns:
            Mapping from each task to the tasks it depends on.
        """
        # Run over all the targets and dependencies to explore connections between tasks.
        task_by_target: Dict[Path, "Task"] = {}
        tasks_by_file_dependency: Dict[Path, List["Task"]] = {}
        dependencies: Dict["Task", Set["Task"]] = {}
        for task in self.tasks.values():
            if task.task_dependencies:
                dependencies[task] = set(task.task_dependencies)
            for path in task.targets:
                path = path.resolve()
                if (other := task_by_target.get(path)):
                    raise ValueError(f"tasks {task} and {other} both have target {path}")
                task_by_target[path] = task
            for path in task.dependencies:
                path = path.resolve()
                tasks_by_file_dependency.setdefault(path, set()).add(task)

        # Build a directed graph of dependencies based on files produced and consumed by tasks.
        for file_dependency, dependent_tasks in tasks_by_file_dependency.items():
            # This is the task that's going to generate the file we're after.
            if task := task_by_target.get(file_dependency):
                # For each of the dependent tasks, add the target task as a dependency.
                for dependent_task in dependent_tasks:
                    dependencies.setdefault(dependent_task, set()).add(task)
            elif not file_dependency.is_file():
                raise FileNotFoundError(
                    f"file {file_dependency} required by tasks {dependent_tasks} does not exist "
                    "nor is there a task to create it"
                )

        graph = nx.DiGraph()
        graph.add_nodes_from(self.tasks.values())
        graph.add_edges_from((task, dep) for task, deps in dependencies.items() for dep in deps)

        try:
            cycle = nx.find_cycle(graph)
            raise util.CookError(f"dependency graph contains a cycle: {cycle}")
        except nx.NetworkXNoCycle:
            pass

        return graph


def create_task(name: str, *, action: Optional["Action"] = None,
                targets: Optional[List["Path"]] = None, dependencies: Optional[List["Path"]] = None,
                task_dependencies: Optional[List["Task"]] = None,
                location: Optional[Tuple[str, int]] = None) -> "Task":
    """
    Create a new task.

    Args:
        name: Name of the new task.
        action: Action to execute.
        targets: Paths for files to be generated.
        dependencies: Paths to files on which this task depends.
        task_dependencies: Tasks which the new task explicitly depends on.
        location: Location at which the task was defined as a tuple `(filename, lineno)`.

    Returns:
        New task.

    Raises:
        ValueError: If a task with the same name already exists.
    """
    return Manager.get_instance().create_task(
        name, action=action, targets=targets, dependencies=dependencies, location=location,
        task_dependencies=task_dependencies,
    )
