from __future__ import annotations

import copy
from abc import ABC, abstractmethod
from typing import Any, Sequence, Optional, TYPE_CHECKING, TypeVar
from uuid import UUID

import pandas as pd

from ResSimpy.DataModelBaseClasses.DataObjectMixin import DataObjectMixin, DataObjectMixinDictType
from ResSimpy.Enums.UnitsEnum import UnitSystem
from ResSimpy.FileOperations.File import File
from ResSimpy.Nexus.DataModels.Network.NexusConstraint import NexusConstraint
from ResSimpy.Time.ISODateTime import ISODateTime
from ResSimpy.Utils.obj_to_table_string import get_column_headers_required

if TYPE_CHECKING:
    from ResSimpy.Nexus.NexusNetwork import Network

T = TypeVar('T', bound=DataObjectMixin)


class NetworkOperationsMixIn(ABC):
    """A mixin for network collections that forces the implementation of the same methods across all classes."""
    def __init__(self, parent_network: Network) -> None:
        """Initialises the NetworkOperationsMixIn class.

        Args:
            parent_network (Network): The parent network that the object is a part of.
        """
        self.__parent_network = parent_network

    @abstractmethod
    def get_all(self) -> Sequence[Any]:
        """Returns list of network operations."""
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def get_by_name(self, name: str) -> Optional[Any]:
        """Returns network object with provided name.

        Args:
            name(str): Name of network object.
        """
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def get_df(self) -> pd.DataFrame:
        """Creates dataframe representing network operations in a file."""
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def get_overview(self) -> str:
        """Returns an overview of the network operations."""
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def load(self, file: File, start_date: str, default_units: UnitSystem) -> None:
        """Loads file path using the default units.

        Args:
            file(File): Loads file path
            start_date(str): Starting date of the run.
            default_units(UnitSystem): Units used if not specified by the file.
        """
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def _add_to_memory(self, additional_objs: Optional[list[Any]]) -> None:
        """Adds an instance of objects to the current list.

        Args:
            additional_objs(Optional[list[Any]]): New objects to be added.
        """
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def remove(self, obj_to_remove: UUID | DataObjectMixinDictType) -> None:
        """Removes the UUID from the network.

        Args:
            obj_to_remove(UUID | DataObjectMixinDictType): UUID of the network to be removed
            from the dictionary.
        """
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def add(self, obj_to_add: DataObjectMixinDictType) -> None:
        """Adding a connection to a network taking a dictionary with properties for the new connection.

        Args:
            obj_to_add(DataObjectMixinDictType): dictionary taking all the new properties for the network.
        """
        raise NotImplementedError("Implement this in the derived class")

    @abstractmethod
    def modify(self, obj_to_modify: DataObjectMixinDictType,
               new_properties: DataObjectMixinDictType) -> None:
        """Modify existing object based on a matching dictionary of properties.

        Args:
            obj_to_modify(DataObjectMixinDictType): dictionary containing attributes to match
            in the existing object.
            new_properties(DataObjectMixinDictType): properties to switch to in the new object.
        """
        raise NotImplementedError("Implement this in the derived class")

    @property
    @abstractmethod
    def table_header(self) -> str:
        """Returns table header as a string."""
        raise NotImplementedError("Implement this in the derived class")

    @property
    @abstractmethod
    def table_footer(self) -> str:
        """Returns table footer as a string."""
        raise NotImplementedError("Implement this in the derived class")

    @property
    @abstractmethod
    def _network_element_name(self) -> str:
        raise NotImplementedError("Implement this in the derived class")

    @staticmethod
    def resolve_carried_over_attributes(objects_to_resolve: Sequence[T]) -> Sequence[T]:
        """Resolves carried over attributes from previous time steps to the future timesteps.

        Args:
            objects_to_resolve (Sequence[DataObjectMixin]): list of objects to resolve carried over attributes for.
            Must be of homogenous type.
        """
        resolved_objects: list[T] = []

        sorted_by_date, unique_names = NetworkOperationsMixIn.__sort_by_date_name_sim_order(objects_to_resolve)

        # resolve by name
        for name in unique_names:
            matching_names = [x for x in sorted_by_date if x.name == name]
            resolving_by_name = NetworkOperationsMixIn.resolve_same_named_objects(matching_names)
            resolved_objects.extend(resolving_by_name)

        return resolved_objects

    @staticmethod
    def __sort_by_date_name_sim_order(objects_to_resolve: Sequence[T]) -> tuple[Sequence[T], list[str | None]]:
        """Sorts objects by date and name.

        Args:
            objects_to_resolve (Sequence[DataObjectMixin]): list of objects to resolve carried over attributes for.
            Must be of homogenous type.
        """
        if (len(objects_to_resolve) > 0 and
                not all(isinstance(x, type(objects_to_resolve[0])) for x in objects_to_resolve)):
            raise ValueError("Objects to resolve must be of the same type.")
        # order by date and the order entered in the simulator.
        current_ordering = list(enumerate(objects_to_resolve))
        sorted_by_date_sim_ordering = sorted(current_ordering, key=lambda x: (x[1].iso_date, x[0]))
        sorted_by_date = [x[1] for x in sorted_by_date_sim_ordering]
        # split by the name of the object
        unique_names = list({x.name: x for x in sorted_by_date}.keys())
        return sorted_by_date, unique_names

    @staticmethod
    def resolve_same_named_objects(sorted_by_date: Sequence[T]) -> Sequence[T]:
        """Resolves a subset of objects by date.

        Args:
            sorted_by_date (Sequence[DataObjectMixin]): list of objects to resolve carried over attributes for.
            Must be of homogenous type.

        Returns:
            Sequence[DataObjectMixin]: list of resolved objects with carried over attributes.

        """
        resolved_objects: list[T] = []
        for unresolved_obj in sorted_by_date:
            # append the first
            if len(resolved_objects) == 0:
                resolved_objects.append(unresolved_obj)
                continue
            last_resolved_object = resolved_objects[-1]

            new_resolved_object = copy.deepcopy(unresolved_obj)

            for attr, value in last_resolved_object.__dict__.items():
                if value is not None and getattr(unresolved_obj, attr, None) is None:
                    setattr(new_resolved_object, attr, value)
            resolved_objects.append(new_resolved_object)
        return resolved_objects

    @staticmethod
    def resolve_same_named_objects_constraints(sorted_by_data: Sequence[NexusConstraint]) -> Sequence[NexusConstraint]:
        """Resolves a subset of objects by date and applies clears in the constraints.

        Args:
            sorted_by_data (Sequence[NexusConstraint]): Sequence of constraints to resolve in .
        """
        resolved_objects: list[NexusConstraint] = []
        for unresolved_obj in sorted_by_data:
            # append the first
            if len(resolved_objects) == 0:
                resolved_objects.append(unresolved_obj)
                continue
            last_resolved_object = resolved_objects[-1]
            # copy to prevent overriding previously resolved objects
            last_resolved_copy = copy.deepcopy(last_resolved_object)
            new_resolved_object = copy.deepcopy(unresolved_obj)

            # collect which attributes to clear:
            clear_constraints_dict = {}
            if unresolved_obj.clear_q:
                clear_constraints_dict.update(unresolved_obj.get_rate_constraints_map())
            if unresolved_obj.clear_p:
                clear_constraints_dict.update(unresolved_obj.get_pressure_constraints_map())
            if unresolved_obj.clear_limit:
                clear_constraints_dict.update(unresolved_obj.get_limit_constraints_map())
            if unresolved_obj.clear_alq:
                clear_constraints_dict.update(unresolved_obj.get_alq_constraints_map())
            if unresolved_obj.clear_all:
                clear_constraints_dict.update(unresolved_obj.get_rate_constraints_map())
                clear_constraints_dict.update(unresolved_obj.get_pressure_constraints_map())
                clear_constraints_dict.update(unresolved_obj.get_limit_constraints_map())
                clear_constraints_dict.update(unresolved_obj.get_alq_constraints_map())
            # Clear them by setting to None on the copied previously resolved object
            for (clear_attr, _) in clear_constraints_dict.values():
                setattr(last_resolved_copy, clear_attr, None)

            skip_attributes = ['id', 'date', 'name', 'iso_date', 'clear_q', 'clear_p', 'clear_limit', 'clear_alq',
                               'clear_all', 'convert_qmult_to_reservoir_barrels', 'active_node']
            for attr, value in last_resolved_copy.__dict__.items():
                if value is None or attr in skip_attributes:
                    continue
                if getattr(unresolved_obj, attr, None) is None:
                    setattr(new_resolved_object, attr, value)

            # resolve convert_qmult_to_reservoir_barrels
            if last_resolved_object.convert_qmult_to_reservoir_barrels:
                overriding_qmult_constraints = [unresolved_obj.max_surface_gas_rate,
                                                unresolved_obj.max_surface_water_rate,
                                                unresolved_obj.max_surface_oil_rate,
                                                ]
                if any(x for x in overriding_qmult_constraints if x is not None):
                    # clear any qmult constraints if any of the surface rate constraints for oil, gas, water are set
                    new_resolved_object.convert_qmult_to_reservoir_barrels = None
                    new_resolved_object.qmult_oil_rate = None
                    new_resolved_object.qmult_gas_rate = None
                    new_resolved_object.qmult_water_rate = None
                elif 'QALLRMAX' in clear_constraints_dict:
                    # if the object is cleared, set to None
                    new_resolved_object.convert_qmult_to_reservoir_barrels = None
                else:
                    new_resolved_object.convert_qmult_to_reservoir_barrels = True

            resolved_objects.append(new_resolved_object)
        return resolved_objects

    @property
    def resolved_network_objects(self) -> Sequence[DataObjectMixin]:
        """Returns the resolved network objects."""
        if not hasattr(self, '_resolved_network_objects'):
            self._resolved_network_objects = self.resolve_carried_over_attributes(self.get_all())
        return self._resolved_network_objects

    def to_string_for_date(self, date: ISODateTime) -> str:
        """Returns a string representation of the network object for the date."""
        printable_str = ''
        # get the required objects for the date
        network_objects_for_date: list[DataObjectMixin] = [x for x in self.resolved_network_objects if
                                                           x.iso_date == date]
        named_network_objects_for_date = {x.name: None for x in network_objects_for_date}
        # only pick the last object by name for the date
        network_objects_for_date = [[x for x in network_objects_for_date if x.name == y][-1]
                                    for y in named_network_objects_for_date.keys()]

        if not network_objects_for_date:
            return printable_str
        printable_str += f'{self.table_header}\n'
        # collect the required table column header:
        headers = get_column_headers_required(network_objects_for_date)
        printable_str += ' '.join(headers) + '\n'
        for network_object in network_objects_for_date:
            printable_str += f'{network_object.to_table_line(headers=headers)}'

        printable_str += f'{self.table_footer}\n'

        return printable_str
