import json
import logging
from dataclasses import MISSING, dataclass, fields
from typing import Callable

from .dumpers import asdict
from .lookups import Env, lookup_exact, clean
from ..abstractions import AbstractEnvWizard
from ..bases import AbstractEnvMeta
from ..bases_meta import BaseEnvWizardMeta, EnvMeta
from ..class_helper import (call_meta_initializer_if_needed, get_meta,
                            field_to_env_var, dataclass_field_to_json_field)
from ..decorators import cached_class_property
from ..enums import LetterCase
from ..environ.loaders import EnvLoader
from ..errors import ExtraData, MissingVars, ParseError, type_name
from ..loader_selection import get_loader
from ..log import enable_library_debug_logging
from ..models import Extras, JSONField
from ..type_def import ExplicitNull, JSONObject, dataclass_transform
from ..utils.function_builder import FunctionBuilder


_to_dataclass = dataclass(init=False)


@dataclass_transform(kw_only_default=True)
class EnvWizard(AbstractEnvWizard):
    """
    *Environment Wizard*

    A mixin class for parsing and managing environment variables in Python.

    ``EnvWizard`` makes it easy to map environment variables to Python attributes,
    handle defaults, and optionally load values from `.env` files.

    Quick Example::

        import os
        from pathlib import Path

        class MyConfig(EnvWizard):
            my_var: str
            my_optional_var: int = 42

        # Set environment variables
        os.environ["MY_VAR"] = "hello"

        # Load configuration from the environment
        config = MyConfig()
        print(config.my_var)  # Output: "hello"
        print(config.my_optional_var)  # Output: 42

        # Specify configuration explicitly
        config = MyConfig(my_var='world')
        print(config.my_var)  # Output: "world"
        print(config.my_optional_var)  # Output: 42

    Example with ``.env`` file::

        class MyConfigWithEnvFile(EnvWizard):
            class _(EnvWizard.Meta):
                env_file = True  # Defaults to loading from `.env`

            my_var: str
            my_optional_var: int = 42

        # Create an `.env` file in the current directory:
        # MY_VAR=world
        config = MyConfigWithEnvFile()
        print(config.my_var)  # Output: "world"
        print(config.my_optional_var)  # Output: 42

    Key Features:
        - Automatically maps environment variables to dataclass fields.
        - Supports default values for fields if environment variables are not set.
        - Optionally loads environment variables from `.env` files.
        - Supports prefixes for environment variables using ``_env_prefix`` or ``Meta.env_prefix``.
        - Supports loading secrets from directories using ``_secrets_dir`` or ``Meta.secrets_dir``.
        - Dynamic reloading with ``_reload`` to handle updated environment values.

    Initialization Options:
        The ``__init__`` method accepts additional parameters for flexibility:

        - ``_env_file`` (optional):
            Overrides the ``Meta.env_file`` value dynamically. Can be a file path,
            a sequence of file paths, or ``True`` to use the default `.env` file.
        - ``_reload`` (optional):
            Forces a reload of environment variables to bypass caching. Defaults to ``False``.
        - ``_env_prefix`` (optional):
            Dynamically overrides ``Meta.env_prefix``, applying a prefix to all environment
            variables. Defaults to ``None``.
        - ``_secrets_dir`` (optional):
            Overrides the ``Meta.secrets_dir`` value dynamically. Can be a directory path
            or a sequence of paths pointing to directories containing secret files.

    Meta Settings:
        These class-level attributes can be configured in a nested ``Meta`` class:

        - ``env_file``:
            The path(s) to `.env` files to load. If set to ``True``, defaults to `.env`.
        - ``env_prefix``:
            A prefix applied to all environment variables. Defaults to ``None``.
        - ``secrets_dir``:
            A path or sequence of paths to directories containing secret files. Defaults to ``None``.

    Attributes:
        Defined dynamically based on the dataclass fields in the derived class.
    """
    __slots__ = ()

    class Meta(BaseEnvWizardMeta):
        """
        Inner meta class that can be extended by sub-classes for additional
        customization with the environment load process.
        """
        __slots__ = ()

        # Class attribute to enable detection of the class type.
        __is_inner_meta__ = True

        def __init_subclass__(cls):
            # Set the `__init_subclass__` method here, so we can ensure it
            # doesn't run for the `EnvWizard.Meta` class.
            return cls._init_subclass()

    # noinspection PyMethodParameters,PyUnresolvedReferences
    @cached_class_property
    def __fields__(cls: type['E']):
        cls_fields = {}
        field_to_var = field_to_env_var(cls)

        for field in fields(cls):
            name = field.name
            cls_fields[name] = field

            if isinstance(field, JSONField):
                if not field.json.dump:
                    field_to_json_key = dataclass_field_to_json_field(cls)
                    field_to_json_key[name] = ExplicitNull

                keys = field.json.keys
                if keys:
                    # minor optimization: convert a one-element tuple of `str` to `str`
                    field_to_var[name] = keys[0] if len(keys) == 1 else keys

        return cls_fields

    to_dict = asdict

    def to_json(self, *,
                encoder = json.dumps,
                **encoder_kwargs):
        """
        Converts the `EnvWizard` subclass to a JSON `string` representation.
        """
        return encoder(asdict(self), **encoder_kwargs)

    def __init_subclass__(cls, *, reload_env=False, debug=False,
                          key_transform=LetterCase.NONE):

        if reload_env:  # reload cached var names from `os.environ` as needed.
            Env.reload()

        # apply the `@dataclass(init=False)` decorator to `cls`.
        _to_dataclass(cls)

        # set `key_transform_with_dump` for the class's Meta
        meta = EnvMeta(key_transform_with_dump=key_transform)

        if debug:
            # minimum logging level for logs by this library
            lvl = logging.DEBUG if isinstance(debug, bool) else debug
            # enable library logging
            enable_library_debug_logging(lvl)
            # set `debug_enabled` flag for the class's Meta
            meta.debug_enabled = lvl

        # Bind child class to DumpMeta with no key transformation.
        meta.bind_to(cls)

        # Calls the Meta initializer when inner :class:`Meta` is sub-classed.
        call_meta_initializer_if_needed(cls)

        # create and set methods such as `__init__()`.
        cls._create_methods()

    @classmethod
    def _create_methods(cls):
        """
        Generates methods such as the ``__init__()`` constructor method
        and ``dict()`` for the :class:`EnvWizard` subclass, vis-à-vis
        how the ``dataclasses`` module does it, with a few noticeable
        differences.
        """
        meta = get_meta(cls, base_cls=AbstractEnvMeta)
        cls_loader = get_loader(cls, base_cls=EnvLoader)

        # A cached mapping of each dataclass field name to its environment
        # variable name; useful so we don't need to do a case transformation
        # (via regex) each time.
        field_to_var = field_to_env_var(cls)

        # The function to case-transform and lookup variables defined in the
        # environment.
        get_env: 'Callable[[str], str | None]' = meta.key_lookup_with_load

        # noinspection PyArgumentList
        extras = Extras(config=None)

        cls_fields = cls.__fields__
        field_names = frozenset(cls_fields)

        _meta_env_file = meta.env_file

        _locals = {'Env': Env,
                   'ParseError': ParseError,
                   'field_names': field_names,
                   'get_env': get_env,
                   'lookup_exact': lookup_exact}

        _globals = {'MissingVars': MissingVars,
                    'add': _add_missing_var,
                    'cls': cls,
                    'fields_ordered': cls_fields.keys(),
                    'handle_err': _handle_parse_error,
                    'MISSING': MISSING,
                    }

        if meta.secrets_dir is None:
            _secrets_dir_value = 'None'
        else:
            _locals['_secrets_dir_value'] = meta.secrets_dir
            _secrets_dir_value = '_secrets_dir_value'

        # parameters to the `__init__()` method.
        init_params = ['self',
                       '_env_file=None',
                       '_reload=False',
                       f'_env_prefix={meta.env_prefix!r}',
                       f'_secrets_dir={_secrets_dir_value}',
        ]

        fn_gen = FunctionBuilder()

        with fn_gen.function('__init__', init_params, None, _locals):

            # reload cached var names from `os.environ` as needed.
            with fn_gen.if_('_reload'):
                fn_gen.add_line('Env.reload()')
            with fn_gen.else_():
                fn_gen.add_line('Env.load_environ()')

            with fn_gen.if_('_secrets_dir'):
                fn_gen.add_line('Env.update_with_secret_values(_secrets_dir)')

            # update environment with values in the "dot env" files as needed.
            if _meta_env_file:
                fn = fn_gen.elif_
                _globals['_dotenv_values'] = Env.dotenv_values(_meta_env_file)
                with fn_gen.if_('_env_file is None'):
                    fn_gen.add_line('Env.update_with_dotenv(dotenv_values=_dotenv_values)')
            else:
                fn = fn_gen.if_
            with fn('_env_file'):
                fn_gen.add_line('Env.update_with_dotenv(_env_file)')

            # iterate over the dataclass fields and (attempt to) resolve
            # each one.
            fn_gen.add_line('_vars = []')

            if field_names:

                with fn_gen.try_():

                    for name, f in cls_fields.items():
                        type_field = f'_tp_{name}'
                        tp = _globals[type_field] = f.type

                        init_params.append(f'{name}:{type_field}=MISSING')

                        # retrieve value (if it exists) for the environment variable

                        env_var = var_name = field_to_var.get(name)
                        if env_var:
                            part = f'({name} := lookup_exact(_var_name))'
                        else:
                            var_name = name
                            part = f'({name} := get_env(_var_name))'

                        fn_gen.add_line(f'_name={name!r}; _env_var={env_var!r}; _var_name=f"{{_env_prefix}}{var_name}" if _env_prefix else {var_name!r}')

                        with fn_gen.if_(f'{name} is not MISSING or {part} is not MISSING'):
                            parser_name = f'_parser_{name}'
                            _globals[parser_name] = getattr(p := cls_loader.get_parser_for_annotation(
                                tp, cls, extras), '__call__', p)
                            fn_gen.add_line(f'self.{name} = {parser_name}({name})')
                        # this `else` block means that a value was not received for the
                        # field, either via keyword arguments or Environment.
                        with fn_gen.else_():
                            # check if the field defines a `default` or `default_factory`
                            # value; note this is similar to how `dataclasses` does it.
                            default_name = f'_dflt_{name}'
                            if f.default is not MISSING:
                                _globals[default_name] = f.default
                                fn_gen.add_line(f'self.{name} = {default_name}')
                            elif f.default_factory is not MISSING:
                                _globals[default_name] = f.default_factory
                                fn_gen.add_line(f'self.{name} = {default_name}()')
                            else:
                                fn_gen.add_line(f'add(_vars, _name, _env_prefix, _env_var, {type_field})')

                with fn_gen.except_(ParseError, 'e'):
                    fn_gen.add_line('handle_err(e, cls, _name, _env_prefix, _env_var)')

            # check for any required fields with missing values
            with fn_gen.if_('_vars'):
                fn_gen.add_line('raise MissingVars(cls, _vars) from None')

            # if keyword arguments are passed in, confirm that all there
            # aren't any "extra" keyword arguments
            # if _extra is not Extra.IGNORE:
                # with fn_gen.if_('has_kwargs'):
                #     # get a list of keyword arguments that don't map to any fields
                #     fn_gen.add_line('extra_kwargs = set(init_kwargs) - field_names')
                #     with fn_gen.if_('extra_kwargs'):
                #         # the default behavior is "DENY", so an error will be raised here.
                #         if _extra is None or _extra is Extra.DENY:
                #             _globals['ExtraData'] = ExtraData
                #             fn_gen.add_line('raise ExtraData(cls, extra_kwargs, list(fields_ordered)) from None')
                #         else:  # Extra.ALLOW
                #             # else, if we want to "ALLOW" extra keyword arguments, we need to
                #             # store those attributes in the instance.
                #             with fn_gen.for_('attr in extra_kwargs'):
                #                 fn_gen.add_line('setattr(self, attr, init_kwargs[attr])')

        with fn_gen.function('dict', ['self'], JSONObject, _locals):
            parts = ','.join([f'{name!r}:self.{name}' for name, f in cls.__fields__.items()])
            fn_gen.add_line(f'return {{{parts}}}')

        functions = fn_gen.create_functions(_globals)

        # set the `__init__()` method.
        cls.__init__ = functions['__init__']
        # set the `dict()` method.
        cls.dict = functions['dict']


def _add_missing_var(missing_vars, name, env_prefix, var_name, tp):

    var_name = _get_var_name(name, env_prefix, var_name)
    tn = type_name(tp)
    # noinspection PyBroadException
    try:
        suggested = tp()
    except Exception:
        suggested = None

    missing_vars.append((name, var_name, tn, suggested))


def _handle_parse_error(e, cls, name, env_prefix, var_name):

    # We run into a parsing error while loading the field
    # value; Add additional info on the Exception object
    # before re-raising it.
    e.class_name = cls
    e.field_name = name
    e.kwargs['env_variable'] = _get_var_name(name, env_prefix, var_name)

    raise


def _get_var_name(name, env_prefix, var_name):

    if var_name is None:
        env_var = f'{env_prefix}{name}' if env_prefix else name
        var_name = Env.cleaned_to_env.get(clean(env_var), env_var)

    elif env_prefix:
        var_name = f'{env_prefix}{var_name}'

    return var_name
