import functools
import json
from typing import Any, Callable

from .. import regex
from .. import instruction_recognition
from ..static import StaticVar
from ..config import Config

# next commands field indexes
NEXT_COMMAND_ID = 0
DEPENDENT_ON_DATA = 1
REQUIRED_VALUE = 2

@functools.total_ordering
class CommandNode:
    def __init__(self, data: list, commands: dict[str, dict], 
            command_name_to_func: dict[str, Callable]):
        self.id = data[0]
        self.command_name = data[1]

        assert self.command_name in commands, f"Command '{self.command_name}' does not exist."
        assert self.command_name in command_name_to_func, f"Command '{self.command_name}' does not have a function declaration."

        self.command = command_name_to_func[self.command_name]
        self.arguments = data[2]

        self.next_commands: list[list[int | str | Any]] = data[3]
        assert all(
            map(
                lambda next_command:
                    (type(next_command[0]) is int and 
                     type(next_command[1]) in [str, type(None)]),
                self.next_commands,
            )
        ), f"Next commands field must match data types: list[list[int, str, Any]].\n{self.next_commands}"

        self.data_generated = None

    def __eq__(self, other):
        return self.id == other.id

    def __lt__(self, other):
        return self.id < other.id

    def __str__(self):
        return f"CommandNode(id={self.id}, command={self.command})"

    def execute_command(self, config: Config, graph, arguments: dict[str, Any]):
        print(f"\n\nRunning '{self.command_name}' command with id {self.id}...")
        if config.verbosity >= 2:
            print(f"Using arguments: {arguments}")
        self.data_generated = self.command(config, graph, **arguments)
    
    def get_next_commands_to_execute(self) -> list[int]:
        next_commands_to_execute = []
        for next_command in self.next_commands:
            next_command_id = next_command[NEXT_COMMAND_ID]
            dependent_on_data = next_command[DEPENDENT_ON_DATA]
            required_value = next_command[REQUIRED_VALUE]

            if dependent_on_data is None: # doesn't matter the result of current node
                next_commands_to_execute.append(next_command_id)
            else: # next command execution depends on the result of current node
                if self.data_generated[dependent_on_data] == required_value:
                    next_commands_to_execute.append(next_command_id)
        return next_commands_to_execute

class Graph:
    def __init__(self, raw_commands_data: StaticVar, commands: dict[str, dict], 
            command_name_to_func: dict[str, Callable]):
        self.set_start_data(raw_commands_data, commands, command_name_to_func)
        self.initialize()

    def set_start_data(self, raw_commands_data: StaticVar, commands: dict[str, dict], 
            command_name_to_func: dict[str, Callable]):
        self.raw_commands_data = raw_commands_data
        self.commands = commands
        self.command_name_to_func = command_name_to_func

    def build_graph(self, raw_commands_data: StaticVar, commands: dict[str, dict], 
            command_name_to_func: dict[str, Callable]):
        self.set_start_data(raw_commands_data, commands, command_name_to_func)
        self.commands_data, self.data_references = generate_graph_build_data(raw_commands_data.val)
        self.build_nodes()

    def build_nodes(self):
        for command_data in self.commands_data:
            node = CommandNode(command_data, self.commands, self.command_name_to_func)
            if node.id in self.reached_nodes_ids:
                # recover results of the node
                reached_node = self.nodes[node.id]
                node.data_generated = reached_node.data_generated
            self.nodes[node.id] = node

    def initialize(self):
        self.reached_nodes_ids: list[int] = []
        self.nodes: dict[str, CommandNode] = {}
        self.build_graph(self.raw_commands_data, self.commands, self.command_name_to_func)
    
    def inject_node_data(self, node_id: int, data_generated_by_node: dict[str, Any]):
        # inject data generated by the node to the data string
        new_raw_commands_data = StaticVar(regex.inject_node_data(
            self.raw_commands_data.val, node_id, data_generated_by_node,
        ))

        # rebuild graph
        self.build_graph(new_raw_commands_data, self.commands, self.command_name_to_func)
        
    def execute_node(self, node_id: int, config: Config) -> list[int]:
        node = self.nodes[node_id]
    
        node.execute_command(config, self, node.arguments)
        self.reached_nodes_ids.append(node.id)

        if node.id in self.data_references:
            self.inject_node_data(node.id, node.data_generated)
        
        next_commands_to_execute = node.get_next_commands_to_execute()
        return next_commands_to_execute

    def print_graph(self, explain_graph: bool):
        print("\n\n--- Commands graph ---")

        print("\n~~ Graph ~~")
        for node in self.nodes.values():
            print(f"\n{node.id}. {node.command_name}")
            
            if node.next_commands:
                print(f"\tCommands executed by this node:")
                for next_command_id, dependent_on_data, required_value in node.next_commands:
                    next_node = self.nodes[next_command_id]
                    print(f"\t{next_node.id}. {next_node.command_name}")
                    if dependent_on_data is not None:
                        print(f"\t\tIf '{dependent_on_data}' generated data has value: {required_value}.")

        if explain_graph:
            print("\n~~ Explanation ~~")
            explanation = instruction_recognition.explain_graph_in_natural_language(self.raw_commands_data.val, self.commands)
            print(explanation)

        print("\n--- -------------- ---\n")

    def execute_commands(self, config: Config):
        self.initialize()
        if config.verbosity >= 1:
            self.print_graph(config.explain_graph)

        first_node_id = sorted(self.nodes.keys())[0]
        next_commands_to_execute = self.execute_node(first_node_id, config)
        new_next_commands_to_execute = []
        while True:
            new_next_commands_to_execute.clear()
            for next_command_id in next_commands_to_execute:
                new_next_commands_to_execute.extend(self.execute_node(next_command_id, config))
            next_commands_to_execute = new_next_commands_to_execute.copy()

            if not next_commands_to_execute:
                break

def generate_graph_build_data(raw_commands_data):
    """
    Parses a commands data string to a JSON to create the graph data

    Args:
        raw_commands_data (str): A string containing raw commands data that has not yet been converted to a JSON.

    Returns:
        a tuple: Containing:
        
            commands_data: data of the commands (to create a graph).

            data_references: data references (data that will be injected).
    """
    data_references = regex.find_data_references_indices(raw_commands_data)
    commands_data = regex.nullify_all_data_references(raw_commands_data)

    try:
        commands_data = json.loads(commands_data, strict=False)
    except Exception as e:
        print(f"!!! Can't decode raw commands data to JSON: {raw_commands_data}")
        raise e

    return commands_data, data_references