import operator as op
from collections import Counter, defaultdict

from brake.colors import Color, colorize
from brake.exceptions import CyclicGraph, DuplicatedTarget, InvalidDependency, MultipleDefaultTargets
from brake.model import Target
from brake.parser import TargetVisitor, grammar


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)
        return cls(targets=TargetVisitor().visit(tree))

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

    def validate(self) -> list[Exception]:
        """Return a list of validation errors when the target file is invalid"""
        errors, defaults = [], []
        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(defaults)
            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 defaults"
                )
            )
        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
            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
