from collections import namedtuple
import logging
from typing import List, Iterable, Tuple

from django.db import models
from django.db.utils import IntegrityError

from bravado.exception import HTTPNotFound

from . import __title__
from .helpers import EveEntityNameResolver
from .providers import esi
from .utils import chunks, LoggerAddTag, make_logger_prefix


logger = LoggerAddTag(logging.getLogger(__name__), __title__)

FakeResponse = namedtuple("FakeResponse", ["status_code"])


class EveUniverseBaseModelManager(models.Manager):
    def _defaults_from_esi_obj(self, eve_data_obj: dict) -> dict:
        """compiles defaults from an esi data object for update/creating the model"""
        defaults = dict()
        for field_name, mapping in self.model._esi_mapping().items():
            if not mapping.is_pk:
                if not isinstance(mapping.esi_name, tuple):
                    if mapping.esi_name in eve_data_obj:
                        esi_value = eve_data_obj[mapping.esi_name]
                    else:
                        esi_value = None
                else:
                    if (
                        mapping.esi_name[0] in eve_data_obj
                        and mapping.esi_name[1] in eve_data_obj[mapping.esi_name[0]]
                    ):
                        esi_value = eve_data_obj[mapping.esi_name[0]][
                            mapping.esi_name[1]
                        ]
                    else:
                        esi_value = None

                if esi_value is not None:
                    if mapping.is_fk:
                        ParentClass = mapping.related_model
                        try:
                            value = ParentClass.objects.get(id=esi_value)
                        except ParentClass.DoesNotExist:
                            if mapping.create_related and hasattr(
                                ParentClass.objects, "update_or_create_esi"
                            ):
                                value, _ = ParentClass.objects.update_or_create_esi(
                                    id=esi_value,
                                    include_children=False,
                                    wait_for_children=True,
                                )
                            else:
                                value = None

                    else:
                        if mapping.is_charfield and esi_value is None:
                            value = ""
                        else:
                            value = esi_value

                    defaults[field_name] = value

        return defaults


class EveUniverseEntityModelManager(EveUniverseBaseModelManager):
    def get_or_create_esi(
        self,
        *,
        id: int,
        include_children: bool = False,
        wait_for_children: bool = True,
    ) -> Tuple[models.Model, bool]:
        """gets or creates an eve universe object.
        
        The object is automatically fetched from ESI if it does not exist (blocking).
        Will always get/create parent objects.
        
        Args:
            id: Eve Online ID of object
            include_children: when needed to updated/created if child objects should be updated/created as well (if any)
            wait_for_children: when true child objects will be updated/created blocking (if any), else async
        
        Returns:
            A tuple consisting of the requested object and a created flag                
        """
        try:
            obj = self.get(id=id)
            created = False
        except self.model.DoesNotExist:
            obj, created = self.update_or_create_esi(
                id=id,
                include_children=include_children,
                wait_for_children=wait_for_children,
            )

        return obj, created

    def update_or_create_esi(
        self,
        *,
        id: int,
        include_children: bool = False,
        wait_for_children: bool = True,
    ) -> Tuple[models.Model, bool]:
        """updates or creates an Eve universe object by fetching it from ESI (blocking).
        Will always get/create parent objects

        Args:
            id: Eve Online ID of object
            include_children: if child objects should be updated/created as well (if any)
            wait_for_children: when true child objects will be updated/created blocking (if any), else async

        Returns:
            A tuple consisting of the requested object and a created flag
        """
        add_prefix = make_logger_prefix("%s(id=%s)" % (self.model.__name__, id))
        try:
            eve_data_obj = self._transform_esi_response_for_list_endpoints(
                id, self._fetch_from_esi(id)
            )
            if eve_data_obj:
                defaults = self._defaults_from_esi_obj(eve_data_obj)
                obj, created = self.update_or_create(id=id, defaults=defaults)
                inline_objects = self.model._inline_objects()
                if inline_objects:
                    self._update_or_create_inline_objects(
                        parent_eve_data_obj=eve_data_obj,
                        parent_obj=obj,
                        inline_objects=inline_objects,
                    )
                if eve_data_obj and include_children:
                    self._update_or_create_children(
                        parent_eve_data_obj=eve_data_obj,
                        include_children=include_children,
                        wait_for_children=wait_for_children,
                    )
            else:
                raise HTTPNotFound(
                    FakeResponse(status_code=404),
                    message=f"{self.model.__name__} object with id {id} not found",
                )

        except Exception as ex:
            logger.warn(
                add_prefix("Failed to update or create: %s" % ex), exc_info=True,
            )
            raise ex

        return obj, created

    def _fetch_from_esi(self, id: int = None) -> object:
        """make request to ESI and return response data. 
        Can handle raw ESI response from both list and normal endpoints.
        """
        if id and not self.model._is_list_only_endpoint():
            args = {self.model._esi_pk(): id}
        else:
            args = dict()
        category, method = self.model._esi_path_object()
        esi_data = getattr(getattr(esi.client, category), method,)(**args).results()
        return esi_data

    def _transform_esi_response_for_list_endpoints(self, id: int, esi_data) -> object:
        """Transforms raw ESI response from list endpoints if this is one
        else just passes the ESI response through
        """
        if not self.model._is_list_only_endpoint():
            return esi_data

        else:
            esi_pk = self.model._esi_pk()
            for row in esi_data:
                if esi_pk in row and row[esi_pk] == id:
                    return row

            raise HTTPNotFound(
                FakeResponse(status_code=404),
                message=f"{self.model.__name__} object with id {id} not found",
            )

    def _update_or_create_inline_objects(
        self,
        *,
        parent_eve_data_obj: dict,
        parent_obj: models.Model,
        inline_objects: dict,
    ) -> None:
        """updates_or_creates eve objects that are returns "inline" from ESI
        for the parent eve objects as defined for this parent model (if any)
        """
        if not parent_eve_data_obj or not parent_obj:
            raise ValueError(
                "%s: Tried to create inline object from empty parent object"
                % self.model.__name__,
            )

        for inline_field, model_name in inline_objects.items():
            if (
                inline_field in parent_eve_data_obj
                and parent_eve_data_obj[inline_field]
            ):
                InlineModel = self.model.get_model_class(model_name)
                esi_mapping = InlineModel._esi_mapping()
                parent_fk = None
                other_pk = None
                ParentClass2 = None
                for field_name, mapping in esi_mapping.items():
                    if mapping.is_pk:
                        if mapping.is_parent_fk:
                            parent_fk = field_name
                        else:
                            other_pk = (field_name, mapping)
                            ParentClass2 = mapping.related_model

                if not parent_fk or not other_pk:
                    raise ValueError(
                        "ESI Mapping for %s not valid: %s, %s"
                        % (model_name, parent_fk, other_pk,)
                    )

                for eve_data_obj in parent_eve_data_obj[inline_field]:
                    args = {parent_fk: parent_obj}
                    esi_value = eve_data_obj[other_pk[1].esi_name]
                    if other_pk[1].is_fk:
                        try:
                            value = ParentClass2.objects.get(id=esi_value)
                        except ParentClass2.DoesNotExist:
                            if hasattr(ParentClass2.objects, "update_or_create_esi"):
                                (value, _,) = ParentClass2.objects.get_or_create_esi(
                                    id=esi_value
                                )
                            else:
                                value = None
                    else:
                        value = esi_value

                    args[other_pk[0]] = value
                    args["defaults"] = InlineModel.objects._defaults_from_esi_obj(
                        eve_data_obj,
                    )
                    InlineModel.objects.update_or_create(**args)

    def _update_or_create_children(
        self,
        *,
        parent_eve_data_obj: dict,
        include_children: bool,
        wait_for_children: bool,
    ) -> None:
        """updates or creates child objects as defined for this parent model (if any)"""
        from .tasks import update_or_create_eve_object

        if not parent_eve_data_obj:
            raise ValueError(
                "%s: Tried to create children from empty parent object"
                % self.model.__name__,
            )

        for key, child_class in self.model._children().items():
            if key in parent_eve_data_obj and parent_eve_data_obj[key]:
                for id in parent_eve_data_obj[key]:
                    if wait_for_children:
                        ChildClass = self.model.get_model_class(child_class)
                        ChildClass.objects.update_or_create_esi(
                            id=id,
                            include_children=include_children,
                            wait_for_children=wait_for_children,
                        )

                    else:
                        update_or_create_eve_object.delay(
                            child_class,
                            id,
                            include_children=include_children,
                            wait_for_children=wait_for_children,
                        )

    def update_or_create_all_esi(
        self, *, include_children: bool = False, wait_for_children: bool = True,
    ) -> None:
        """updates or creates all objects of this class from ESI.
        
        Loading all objects can take a long time. Use with care!
        
        Args:
            include_children: if child objects should be updated/created as well (if any)
            wait_for_children: when false all objects will be loaded async, else blocking
        """
        from .tasks import update_or_create_eve_object

        add_prefix = make_logger_prefix(f"{self.model.__name__}")
        if self.model._is_list_only_endpoint():
            try:
                esi_pk = self.model._esi_pk()
                for eve_data_obj in self._fetch_from_esi():
                    args = {"id": eve_data_obj[esi_pk]}
                    args["defaults"] = self._defaults_from_esi_obj(eve_data_obj)
                    self.update_or_create(**args)

            except Exception as ex:
                logger.warn(
                    add_prefix("Failed to update or create: %s" % ex), exc_info=True,
                )
                raise ex
        else:
            if self.model._has_esi_path_list():
                category, method = self.model._esi_path_list()
                ids = getattr(getattr(esi.client, category), method,)().results()
                for id in ids:
                    if wait_for_children:
                        self.update_or_create_esi(
                            id=id,
                            include_children=include_children,
                            wait_for_children=wait_for_children,
                        )
                    else:
                        update_or_create_eve_object.delay(
                            model_name=self.model.__name__,
                            entity_id=id,
                            include_children=include_children,
                            wait_for_children=wait_for_children,
                        )
            else:
                raise TypeError(
                    f"ESI does not provide a list endpoint for {self.model.__name__}"
                )

    def bulk_get_or_create_esi(
        self,
        *,
        ids: List[int],
        include_children: bool = False,
        wait_for_children: bool = True,
    ) -> models.QuerySet:
        """Gets or creates objects in bulk.
        
        Nonexisting objects will be fetched from ESI (blocking). 
        Will always get/create parent objects.
        
        Args:
            ids: List of valid IDs of Eve objects
            include_children: when needed to updated/created if child objects should be updated/created as well (if any)
            wait_for_children: when true child objects will be updated/created blocking (if any), else async
        
        Returns:
            Queryset with all requested eve objects
        """
        ids = set(ids)
        existing_ids = set(self.filter(id__in=ids).values_list("id", flat=True))
        for id in ids.difference(existing_ids):
            self.update_or_create_esi(
                id=id,
                include_children=include_children,
                wait_for_children=wait_for_children,
            )

        return self.filter(id__in=ids)


class EvePlanetManager(EveUniverseEntityModelManager):
    def _fetch_from_esi(self, id) -> object:
        from .models import EveSolarSystem

        esi_data = super()._fetch_from_esi(id)
        # no need to proceed if all children have been disabled
        if not self.model._children():
            return esi_data

        if "system_id" not in esi_data:
            raise ValueError("system_id not found in moon response - data error")

        system_id = esi_data["system_id"]
        solar_system_data = EveSolarSystem.objects._fetch_from_esi(system_id)
        if "planets" not in solar_system_data:
            raise ValueError("planets not found in solar system response - data error")

        for planet in solar_system_data["planets"]:
            if planet["planet_id"] == id:
                if "moons" in planet:
                    esi_data["moons"] = planet["moons"]

                if "asteroid_belts" in planet:
                    esi_data["asteroid_belts"] = planet["asteroid_belts"]

                return esi_data

        raise ValueError(
            f"Failed to find moon {id} in solar system response for {system_id} "
            f"- data error"
        )


class EvePlanetChildrenManager(EveUniverseEntityModelManager):
    def __init__(self, property_name: str) -> None:
        super().__init__()
        self._my_property_name = property_name

    def _fetch_from_esi(self, id: int) -> object:
        from .models import EveSolarSystem

        esi_data = super()._fetch_from_esi(id)
        if "system_id" not in esi_data:
            raise ValueError("system_id not found in moon response - data error")

        system_id = esi_data["system_id"]
        solar_system_data = EveSolarSystem.objects._fetch_from_esi(system_id)
        if "planets" not in solar_system_data:
            raise ValueError("planets not found in solar system response - data error")

        for planet in solar_system_data["planets"]:
            if (
                self._my_property_name in planet
                and id in planet[self._my_property_name]
            ):
                esi_data["planet_id"] = planet["planet_id"]
                return esi_data

        raise ValueError(
            f"Failed to find moon {id} in solar system response for {system_id} "
            f"- data error"
        )


class EveStargateManager(EveUniverseEntityModelManager):
    """For special handling of relations"""

    def update_or_create_esi(
        self,
        id: int,
        *,
        include_children: bool = False,
        wait_for_children: bool = True,
    ) -> Tuple[models.Model, bool]:
        """updates or creates an EveStargate object by fetching it from ESI (blocking).
        Will always get/create parent objects

        Args:
            id: Eve Online ID of object
            include_children: (no effect)
            wait_for_children: (no effect)

        Returns:
            A tuple consisting of the requested object and a created flag
        """
        obj, created = super().update_or_create_esi(
            id=id,
            include_children=include_children,
            wait_for_children=wait_for_children,
        )
        if obj:
            if obj.destination_eve_stargate is not None:
                obj.destination_eve_stargate.destination_eve_stargate = obj

                if obj.eve_solar_system is not None:
                    obj.destination_eve_stargate.destination_eve_solar_system = (
                        obj.eve_solar_system
                    )
                obj.destination_eve_stargate.save()

        return obj, created


class EveStationManager(EveUniverseEntityModelManager):
    """For special handling of station services"""

    def _update_or_create_inline_objects(
        self,
        *,
        parent_eve_data_obj: dict,
        parent_obj: models.Model,
        inline_objects: dict,
    ) -> None:
        """updates_or_creates station service objects for EveStations"""
        from .models import EveStationService

        if "services" in parent_eve_data_obj:
            services = list()
            for service_name in parent_eve_data_obj["services"]:
                service, _ = EveStationService.objects.get_or_create(name=service_name)
                services.append(service)

            if services:
                parent_obj.services.add(*services)


class EveEntityQuerySet(models.QuerySet):
    """Custom queryset for EveEntity"""

    MAX_DEPTH = 5

    def update_from_esi(self) -> int:
        """Updates all Eve entity objects in this queryset from ESI"""
        ids = list(self.values_list("id", flat=True))
        if not ids:
            return 0
        else:
            logger.info("Updating %d entities from ESI", len(ids))
            resolved_counter = 0
            for chunk_ids in chunks(ids, 1000):
                logger.debug(
                    "Trying to resolve the following IDs from ESI:\n%s", chunk_ids
                )
                resolved_counter = self._resolve_entities_from_esi(chunk_ids)
            return resolved_counter

    def _resolve_entities_from_esi(self, ids: list, depth: int = 1):
        resolved_counter = 0
        try:
            items = esi.client.Universe.post_universe_names(ids=ids).results()
        except HTTPNotFound:
            # if API fails to resolve all IDs, we divide and conquer,
            # trying to resolve each half of the ids seperately
            if len(ids) > 1 and depth < self.MAX_DEPTH:
                resolved_counter += self._resolve_entities_from_esi(ids[::2], depth + 1)
                resolved_counter += self._resolve_entities_from_esi(
                    ids[1::2], depth + 1
                )
            else:
                logger.warning("Failed to resolve invalid IDs: %s", ids)
        else:
            resolved_counter += len(items)
            for item in items:
                try:
                    self.update_or_create(
                        id=item["id"],
                        defaults={"name": item["name"], "category": item["category"]},
                    )
                except IntegrityError:
                    pass

        return resolved_counter


class EveEntityManager(EveUniverseEntityModelManager):
    """Custom manager for EveEntity"""

    def get_queryset(self) -> models.QuerySet:
        return EveEntityQuerySet(self.model, using=self._db)

    def update_or_create_esi(
        self,
        *,
        id: int,
        include_children: bool = False,
        wait_for_children: bool = True,
    ) -> Tuple[models.Model, bool]:
        """updates or creates an EveEntity object by fetching it from ESI (blocking).
        
        Args:
            id: Eve Online ID of object
            include_children: (no effect)
            wait_for_children: (no effect)

        Returns:
            A tuple consisting of the requested object and a created flag
        """
        obj, created = self.update_or_create(id=id)
        self.filter(id=id).update_from_esi()
        obj.refresh_from_db()
        return obj, created

    def bulk_create_esi(self, ids: Iterable[int]) -> int:
        """bulk create multiple entities from ESI.
        
        Args:
            ids: List of valid EveEntity IDs

        Returns:
            Count of updated entities
        """
        ids = set(ids)
        existing_ids = set(self.filter(id__in=ids).values_list("id", flat=True))
        new_ids = ids.difference(existing_ids)
        if new_ids:
            objects = [self.model(id=id) for id in new_ids]
            self.bulk_create(objects, ignore_conflicts=True)
            return self.filter(id__in=ids).update_from_esi()
        else:
            return 0

    def update_or_create_all_esi(
        self, *, include_children: bool = False, wait_for_children: bool = True,
    ) -> None:
        """not implemented - do not use"""
        raise NotImplementedError()

    def bulk_update_new_esi(self) -> int:
        """updates all unresolved EveEntity objects in the database from ESI.
        
        Returns:
            Count of updated entities.
        """
        return self.filter(name="").update_from_esi()

    def bulk_update_all_esi(self):
        """Updates all EveEntity objects in the database from ESI.
        
        Returns:
            Count of updated entities.
        """
        return self.all().update_from_esi()

    def resolve_name(self, id: int) -> str:
        """return the name for the given Eve entity ID 
        or an empty string if ID is not valid
        """
        if id is not None:
            obj, _ = self.get_or_create_esi(id=id)
            if obj:
                return obj.name

        return ""

    def bulk_resolve_names(self, ids: Iterable[int]) -> EveEntityNameResolver:
        """returns a map of IDs to names in a resolver object for given IDs
        
        Args:
            ids: List of valid EveEntity IDs

        Returns:
            EveEntityNameResolver object helpful for quick resolving a large amount
            of IDs
        """
        ids = set(ids)
        self.bulk_create_esi(ids)
        return EveEntityNameResolver(
            {
                row[0]: row[1]
                for row in self.filter(id__in=ids).values_list("id", "name")
            }
        )
