import operator as op
from collections import Counter, defaultdict

from brake.colors import Color, colorize
from brake.exceptions import (
    BaseBrakeException,
    CyclicGraph,
    DuplicatedTarget,
    InvalidDependency,
    MultipleDefaultTargets,
)
from brake.model import Target
from brake.parser import TargetVisitor, grammar
from brake.variables import VariableResolver


class TargetGraph:
    """Core object representing the graph of targets to run"""

    def __init__(self, targets: list[Target]):
        self.targets = targets
        self.target_maps = {t.name: t for t in self.targets}

    def __getitem__(self, target_name: str):
        return self.target_maps[target_name]

    @classmethod
    def from_str(cls, target_str: str):
        """Parse the argument target graph string into a Graph object"""
        tree = grammar.parse(target_str)
        output = TargetVisitor().visit(tree)
        variable_resolver = VariableResolver(
            variables={var.name: var.value for var in output.variables}
        )
        for target in output.targets:
            target.commands = [variable_resolver.resolve(line) for line in target.commands]
        return cls(targets=output.targets)

    @property
    def default_target(self) -> Target | None:
        for target in self.targets:
            if target.default:
                return target
        return None

    def validate(self) -> list[BaseBrakeException]:
        """Return a list of validation errors when the target file is invalid"""
        errors: list[BaseBrakeException] = []
        defaults: list[str] = []

        target_names = Counter([target.name for target in self.targets])
        duplicated_targets = {
            target_name: count for target_name, count in target_names.items() if count > 1
        }
        for duplicated_target, count in duplicated_targets.items():
            errors.append(DuplicatedTarget(f"Target {duplicated_target} is defined {count} times"))

        for target in self.targets:
            if target.default:
                defaults.append(target.name)
            if target.type == "file" and not target.exists() and not target.commands:
                errors.append(
                    InvalidDependency(f"{target.name} has no build command and does not exist")
                )
            for dep in target.deps:
                try:
                    dep_task = self.target_maps[dep]
                except KeyError:
                    errors.append(InvalidDependency(f"{target.name} depends on unknown target {dep}"))
                    continue
                if dep == target.name:
                    errors.append(CyclicGraph(f"{target.name} depends on itself"))
                elif target.type == "file" and dep_task.type == "task":
                    errors.append(
                        InvalidDependency(
                            f"{target.name}->{dep_task.name}: a file target cannot depend on a task"
                        )
                    )
        if len(defaults) > 1:
            errors.append(
                MultipleDefaultTargets(
                    f"Multiple tasks ({', '.join(defaults)}) are defined as default"
                )
            )
        return errors

    def target_subgraph(self, root_target_name: str):
        """Return a set of target names that belong to the depdency subgraph of a given target.

        For example, if we had a graph like this:
            A
            B -> A
            C -> B
            D -> A
            D -> E
            E

        target_subgraph("A") returns {"A"}
        target_subgraph("B") returns {"A", "B"}
        target_subgraph("C") returns {"A", "B", "C"}
        target_subgraph("D") returns {"A", "E", "D"}
        target_subgraph("E") returns {"E"}

        This allows to us select the subset of targets to execute for a given
        target root.

        """
        needed = set()

        def visit(target_name: str):
            if target_name in needed:
                return  # pragma: no cover
            target = self.target_maps[target_name]

            if not target.deps and target.commands:
                needed.add(target_name)

            for dep in target.deps:
                dep_task = self.target_maps[dep]
                if dep_task.should_rebuild(target):
                    needed.add(target_name)
                    visit(dep)

        visit(root_target_name)
        return needed

    def target_subgraph_dependency_counters(self, root_target_name: str) -> dict[str, int]:
        """Returns a dependency counter for each target in the subgraph of argument root target.

        For example, if we had a graph like this:
            A
            B -> A
            C -> B
            D -> A
            D -> E
            E

        target_subgraph_dependency_counters("A") returns {"A": 0}
        -> when A is the root target, the subgraph is just {"A"}, which means
           0 dependencies on A itself.

        target_subgraph_dependency_counters("B") returns {"A": 0, "B": 1}
        -> when B is the root target, the subgraph is B -> A, which means
          - B has one dependency
          - A has no dependency

        target_subgraph_dependency_counters("C") returns {"A": 0, "B": 1, "C": 1}
        -> when B is the root target, the subgraph is C -> B -> A, which means
          - C has one dependency
          - B has one dependency
          - A has no dependency

        target_subgraph_dependency_counters("D") returns {"A": 0, "E": 0, "D": 2}
        -> when B is the root target, the subgraph is
            D -> A;
            D -> E;
        which means
          - D has 2 dependencies
          - A has no dependency
          - E has no dependency

        target_subgraph_dependency_counters("E") returns {"E": 0}
        -> same as A

        """
        remaining_deps = {}
        subgraph = self.target_subgraph(root_target_name)
        for subgraph_target_name in subgraph:
            subgraph_target = self.target_maps[subgraph_target_name]
            remaining_deps[subgraph_target_name] = sum(
                1 for d in subgraph_target.deps if d in subgraph
            )
        return remaining_deps

    def target_dependency_graph(self, target_name: str) -> dict[str, set[str]]:
        """Return a dependency set for each target in the subgraph of argument root target

        Note that this returns the dependency for all nodes in the subgraph to a given target,
        not the depdendencies a target has onto others.

        For example, if we had a graph like this:
            A
            B -> A
            C -> B
            D -> A
            D -> E
            E

        target_dependency_graph("A") == {"A": set()}
        target_dependency_graph("A") == {"A": {"B"}, "B": set()}
        -> A has a depoendency FROM B

        """
        target = self.target_maps[target_name]
        dependents = defaultdict(set)

        # build dependency info only for needed targets
        subgraph = self.target_subgraph(target_name)
        for subgraph_target_name in subgraph:
            subgraph_target = self.target_maps[subgraph_target_name]
            for dep in subgraph_target.deps:
                if dep in subgraph:
                    dependents[dep].add(subgraph_target_name)

        dependents[target.name] = set()
        for dep in target.deps:
            dependents[dep].add(target.name)

        return dict(dependents)

    def help(self) -> list[str]:
        """Return the sorted list of targets along with their description"""
        lines = []
        sorted_targets = sorted(self.targets, key=op.attrgetter("name"))
        longest_target_name = max([len(target.name) for target in sorted_targets])
        for target in sorted_targets:
            padded_target_name = f"{target.name:<{longest_target_name + 3}}"
            line = f"{colorize(padded_target_name, color=Color.YELLOW)}{target.description}"
            if target.default:
                line = f"{line} {colorize('[default]', color=Color.GREEN)}"
            lines.append(line)
        return lines

    def as_mermaid(self) -> list[str]:
        """Return the target graph formatted in mermaid syntax"""
        lines = ["graph LR"]
        for target in self.targets:
            target_node = f"{target.type}:{target.name}"
            if not target.deps:
                lines.append(target_node)
            else:
                for dep in target.deps:
                    dep_task = self.target_maps[dep]
                    lines.append(f"{target_node} --> {dep_task.type}:{dep_task.name}")
        return lines

    def as_dot(self):
        """Return the target graph formatted in dot syntax"""
        lines = ["digraph targets {"]
        for target in self.targets:
            target_node = f"{target.type}:{target.name}"
            if not target.deps:
                lines.append(f'    "{target_node}";')
            for dep in target.deps:
                dep_task = self.target_maps[dep]
                lines.append(f'    "{dep_task.type}:{dep_task.name}" -> "{target_node}";')
        lines.append("}")
        return lines
