import logging
import time
from typing import Any, Dict, List, Optional, Tuple, Type

from plotly.graph_objects import Figure

from .processor import Processor


class AggregatorProcessor(Processor):
    """The Aggregator processor is used to combine several processors into a single 
    processing step. Best practice is to place any chain of processors within an aggregator
    when each step in the chain runs of the same data and can be run within the same thread. 
    This minimises the overhead of copying data between threads. 
    """

    name: str = "aggregator"

    def __init__(self,
                 processors: List[Type[Processor]],
                 processor_args: Optional[Dict[str, Dict[str, Any]]] = None,
                 additional_reqs: Optional[List[str]] = None,
                 render_default: bool = False,
                 log_level: int = logging.INFO
                 ):
        """Create AggregatorProcessor.

        Args:
            processors (List[Type[Processor]]): List of processors to be run together, in desired execution
                order.
            processor_args (Optional[Dict[str, Dict[str, Any]]], optional): Any initialisation
                parameters to be passed to the processors. Can also use a special 'render'
                argument to control whether each processor output gets drawin if the render
                step is used. Defaults to None.
            additional_reqs (Optional[List[str]], optional): Any additional requirements that
                should be added to the requirements list. Can be used to force ingest of a field
                that is modified within the AggregatorProcessor. Defaults to None.
            render_default: Whether each processor renders by default (when global rendering is enabled).
            log_level (int, optional): Log level. Defaults to logging.INFO.
        """
        super().__init__(AggregatorProcessor.name, log_level=log_level)

        self.processors: List[Processor] = []
        self.render_processors: Dict[str, bool] = {
            processor.name: render_default for processor in processors}

        for processor in processors:
            
            if processor_args and processor.name in processor_args:
                if '_render' in processor_args[processor.name]:
                    self.render_processors[processor.name] = processor_args[processor.name]['_render']
                args = {k:v for k,v in processor_args[processor.name].items() if k != '_render'}
                
                if len(args) > 0:
                    self.processors.append(
                        processor(log_level=log_level, **args))  # type:ignore
                else:
                    self.processors.append(
                        processor(log_level=log_level))  # type:ignore
            else:
                self.processors.append(
                    processor(log_level=log_level))  # type:ignore

        self.additional_reqs = additional_reqs if additional_reqs else []

    def initialise(self):
        """Run initialise for all child processors"""
        for processor in self.processors:
            processor.initialise()

    def requirements(self) -> Tuple[List[str], List[str]]:
        """Generates a smart superset of fields required by the child procesors.

        Note that if a field is generated by an earlier processor if won't be added to the requirements.
        If you want to manually require this field (e.g. as it's initially generated by a non-aggregated 
        processor) you can add it via additional_reqs argument in class instantiation.

        Returns:
            Tuple[List[str], List[str]]: Required and optional fields
        """
        reqs = set()
        opt_reqs = set()
        gens = set()
        for processor in self.processors:
            proc_reqs = processor.requirements()
            for req in proc_reqs[0]:
                if req not in gens:
                    reqs.add(req)
            for req in proc_reqs[1]:
                if req not in gens:
                    opt_reqs.add(req)

            gens |= set(processor.generates())

        to_remove: List[str] = []
        for req in opt_reqs:
            if req in reqs:
                to_remove.append(req)
        for req in to_remove:
            opt_reqs.remove(req)

        return (list(reqs) + self.additional_reqs, list(opt_reqs))

    def generates(self) -> List[str]:
        """Returns the superset of fields generated by the child processors

        Returns:
            List[str]
        """
        gens = set()
        for processor in self.processors:
            gens |= set(processor.generates())
        return list(gens)

    def _process(self, data: Any) -> Any:
        for processor in self.processors:
            self.logger.debug(
                "----------------------- Running %s --------------------", {type(processor).__name__})
            start = time.perf_counter()
            processor._process(data)  # pylint: disable=protected-access
            data['performance'][self.name][processor.name] = [
                round(time.perf_counter() - start, 3)]
        return data

    def add_generated_items_to_fig(self, page_number: int, fig: Figure, data: Dict[str, Any]):
        for processor in self.processors:
            if self.render_processors[processor.name]:
                processor.add_generated_items_to_fig(page_number, fig, data)
