from typing import *
from functools import partial
from dataclasses import dataclass
from django.db import models
from django.utils.translation import pgettext_lazy, pgettext
from pydantic import ValidationError
from collections import defaultdict

from wcd_settings.resolvers import (
    SettingsResolver, UserSettingsResolver, AppSettingsResolver,
)
from wcd_settings.registries import SettingsDTO
from wcd_settings.models import AppSettings, UserSettings


__all__ = (
    'SettingsSetFailure',
    'SettingsSetAction', 'AppSettingsSetAction', 'UserSettingsSetAction',
    'SettingsSetter',
    'app_instance_factory', 'user_instance_factory',
    'AppSettingsSetter', 'UserSettingsSetter',
    'make_app_setter', 'make_user_setter',
)

KeyType = Union[int, str]
PathType = List[KeyType]


@dataclass
class SettingsSetFailure:
    path: PathType
    message: str
    exception: Exception
    reason: Optional[str] = None


class SettingsSetAction(SettingsDTO):
    key: Hashable
    path: PathType
    value: Any


class AppSettingsSetAction(SettingsSetAction):
    key: str


class UserSettingsSetAction(SettingsSetAction):
    key: Tuple[int, str]


A = TypeVar('A', bound=SettingsSetAction)
M = TypeVar('M', bound=models.Model)
R = TypeVar('R', bound=SettingsResolver)


class PlainObjectInjector:
    def normalize_list_key(self, key):
        return key if isinstance(key, int) else int(key)

    def prepopulate_list(self, items: List[Any], key: int, default: Any = None):
        l = len(items)

        if key >= l:
            for _ in range(key - l + 1):
                items.append(default)

            return True

        return False

    def predict_type(self, key: KeyType):
        if isinstance(key, int):
            return []

        return {}

    def prepare_object(self, obj: Union[list, dict], key: KeyType) -> Union[
        Tuple[Dict, KeyType],
        Tuple[List, int],
    ]:
        if isinstance(obj, list):
            key = self.normalize_list_key(key)
            self.prepopulate_list(obj, key)

        elif isinstance(obj, dict):
            if key not in obj or obj[key] is None:
                obj[key] = None
        else:
            raise ValueError(pgettext(
                'wcd_settings:error',
                'Object in path has unsupported type to inject key.'
            ))

        return obj, key

    def inject(self, path: PathType, obj: Union[list, dict], value: Any):
        if len(path) == 0:
            return value

        root = obj

        for i, key in enumerate(path[:-1]):
            obj, key = self.prepare_object(obj, key)

            if obj[key] is None:
                obj[key] = self.predict_type(path[i + 1])

            obj = obj[key]

        obj, last = self.prepare_object(obj, path[-1])
        obj[last] = value

        return root


DEFAULT_OBJECT_INJECTOR = PlainObjectInjector()


class SettingsSetter(Generic[A, R, M]):
    object_injector: PlainObjectInjector
    resolver: R

    def __init__(
        self,
        resolver: R,
        instance_factory: Callable[[Hashable, SettingsDTO], M],
        object_injector: PlainObjectInjector = DEFAULT_OBJECT_INJECTOR,
    ):
        self.resolver = resolver
        self.instance_factory = instance_factory
        self.object_injector = object_injector

    def find_groups(self, dtos: List[A]) -> List[Tuple[
        Hashable, SettingsDTO, Optional[M], List[A],
    ]]:
        grouped = defaultdict(list)

        for dto in dtos:
            grouped[dto.key].append(dto)

        resolver = self.resolver
        resolver.collect(grouped.keys())

        return [
            (key, resolver.get(key), resolver.get_db_instance(key), dtos)
            for key, dtos in grouped.items()
        ]

    def apply(
        self,
        instance: Optional[M],
        settings: SettingsDTO,
        dtos: List[A],
        context: Optional[dict] = None,
    ) -> Tuple[Optional[M], List[Tuple[Optional[A], Exception]]]:
        if len(dtos) == 0:
            return instance, []

        result = settings.model_dump(mode='python')
        failures = []
        context = context if context is not None else {}
        context.setdefault('instance', instance)

        for dto in dtos:
            try:
                result = self.object_injector.inject(
                    dto.path, result, dto.value,
                )
            except ValidationError as e:
                failures.append((dto, e))
            except ValueError as e:
                failures.append((dto, e))
            except AssertionError as e:
                failures.append((dto, e))

        try:
            model_result = settings.__class__.model_validate(
                result, context=context,
            )
            instance.config = model_result.model_dump()
        except ValidationError as e:
            failures.append((None, e))

        return instance, failures

    def commit(
        self,
        to_create: List[Tuple[M, bool]],
        to_update: List[Tuple[M, bool]],
        partial: bool = False,
        context: Optional[dict] = None,
    ):
        creatables = [i for i, f in to_create if partial or f]
        updatables = [i for i, f in to_update if partial or f]

        if len(creatables) > 0:
            model = creatables[0].__class__
            model.objects.bulk_create(creatables)

        if len(updatables) > 0:
            model = updatables[0].__class__
            model.objects.bulk_update(updatables, ['config'])

        ready = creatables + updatables

        for i in ready:
            self.resolver.inject(i)

        return ready

    def set(self, dtos: List[A], context: Optional[dict] = None):
        groups = self.find_groups(dtos)
        failures: Dict[Hashable, List[SettingsSetFailure]] = defaultdict(list)
        to_create = []
        to_update = []

        for key, settings, instance, gdtos in groups:
            if settings is None:
                message = pgettext(
                    'wcd_settings:error', 'No settings found for {key}'
                ).format(key=key)

                failures[key] = [SettingsSetFailure(
                    [],
                    message=message,
                    exception=KeyError(message),
                    reason='unknown_key',
                )]
                continue

            change_group = to_update

            if instance is None:
                instance = self.instance_factory(key, settings)
                change_group = to_create

            instance, i_failures = self.apply(
                instance, settings, gdtos, context=context,
            )
            fully = len(i_failures) == 0

            for dto, e in i_failures:
                epath = dto.path if dto is not None else []

                if isinstance(e, ValidationError):
                    for err in e.errors():
                        failures[key].append(SettingsSetFailure(
                            epath + list(err['loc']),
                            err['msg'],
                            exception=e,
                            reason=err['type'],
                        ))
                else:
                    failures[key].append(SettingsSetFailure(
                        epath,
                        str(e),
                        exception=e,
                    ))

            change_group.append((instance, fully))

        return partial(self.commit, to_create, to_update, context=context), failures

    def save(
        self,
        key: Hashable,
        dto: SettingsDTO,
        context: Optional[dict] = None,
    ):
        results, failures = self.set(
            [SettingsSetAction(key=key, path=[], value=dto)],
            context=context,
        )

        if len(failures) > 0:
            raise failures[key][0].exception

        return results()[0]


def app_instance_factory(key: str, settings: SettingsDTO):
    return AppSettings(key=key)


def user_instance_factory(key: Tuple[int, str], settings: SettingsDTO):
    uid, settings_key = key
    return UserSettings(key=settings_key, user_id=uid)


AppSettingsSetter = SettingsSetter[AppSettingsSetAction, AppSettingsResolver, AppSettings]
UserSettingsSetter = SettingsSetter[UserSettingsSetAction, UserSettingsResolver, UserSettings]

def make_app_setter(resolver: AppSettingsResolver) -> AppSettingsSetter:
    return SettingsSetter(resolver, instance_factory=app_instance_factory)


def make_user_setter(resolver: UserSettingsResolver) -> UserSettingsSetter:
    return SettingsSetter(resolver, instance_factory=user_instance_factory)
