import json
from dataclasses import MISSING, Field
from datetime import date, datetime, time
from typing import Generic, Mapping, NewType, Any, TypedDict

from .constants import PY310_OR_ABOVE, PY314_OR_ABOVE
from .decorators import cached_property
from .type_def import T, DT, PyNotRequired
# noinspection PyProtectedMember
from .utils.dataclass_compat import _create_fn
from .utils.object_path import split_object_path
from .utils.type_conv import as_datetime, as_time, as_date


# Define a simple type (alias) for the `CatchAll` field
#
# The `type` statement is introduced in Python 3.12
# Ref: https://docs.python.org/3.12/reference/simple_stmts.html#type
#
# TODO: uncomment following usage of `type` statement
#   once we drop support for Python 3.9 - 3.11
# if PY312_OR_ABOVE:
#     type CatchAll = Mapping
CatchAll = NewType('CatchAll', Mapping)
# A date, time, datetime sub type, or None.
# DT_OR_NONE = Optional[DT]


class Extras(TypedDict):
    """
    "Extra" config that can be used in the load / dump process.
    """
    config: PyNotRequired['META']
    cls: type
    cls_name: str
    fn_gen: 'FunctionBuilder'
    locals: dict[str, Any]
    pattern: PyNotRequired['PatternedDT']


# noinspection PyShadowingBuiltins
def json_key(*keys: str, all=False, dump=True):
    return JSON(*keys, all=all, dump=dump)


# noinspection PyPep8Naming,PyShadowingBuiltins
def KeyPath(keys, all=True, dump=True):
    if isinstance(keys, str):
        keys = split_object_path(keys)

    return JSON(*keys, all=all, dump=dump, path=True)


# noinspection PyShadowingBuiltins
def json_field(keys, *,
               all=False, dump=True,
               default=MISSING,
               default_factory=MISSING,
               init=True, repr=True,
               hash=None, compare=True, metadata=None):

    if default is not MISSING and default_factory is not MISSING:
        raise ValueError('cannot specify both default and default_factory')

    return JSONField(keys, all, dump, default, default_factory, init, repr,
                     hash, compare, metadata)


env_field = json_field


class JSON:

    __slots__ = ('keys',
                 'all',
                 'dump',
                 'path')

    # noinspection PyShadowingBuiltins
    def __init__(self, *keys, all=False, dump=True, path=False):

        self.keys = (split_object_path(keys)
                     if path and isinstance(keys, str) else keys)
        self.all = all
        self.dump = dump
        self.path = path


class JSONField(Field):

    __slots__ = ('json', )

    # In Python 3.14, dataclasses adds a new parameter to the :class:`Field`
    # constructor: `doc`
    #
    # Ref: https://docs.python.org/3.14/library/dataclasses.html#dataclasses.field
    if PY314_OR_ABOVE:  # pragma: no cover
        # noinspection PyShadowingBuiltins
        def __init__(
            self,
            keys,
            all: bool,
            dump: bool,
            default,
            default_factory,
            init,
            repr,
            hash,
            compare,
            metadata,
            path: bool = False,
        ):

            super().__init__(
                default,
                default_factory,
                init,
                repr,
                hash,
                compare,
                metadata,
                False,
                None,
            )

            if isinstance(keys, str):
                keys = split_object_path(keys) if path else (keys,)
            elif keys is ...:
                keys = ()

            self.json = JSON(*keys, all=all, dump=dump, path=path)

    # In Python 3.10, dataclasses adds a new parameter to the :class:`Field`
    # constructor: `kw_only`
    #
    # Ref: https://docs.python.org/3.10/library/dataclasses.html#dataclasses.dataclass
    elif PY310_OR_ABOVE:  # pragma: no cover
        # noinspection PyShadowingBuiltins
        def __init__(self, keys, all: bool, dump: bool,
                     default, default_factory, init, repr, hash, compare,
                     metadata, path: bool = False):

            super().__init__(default, default_factory, init, repr, hash,
                             compare, metadata, False)

            if isinstance(keys, str):
                keys = split_object_path(keys) if path else (keys,)
            elif keys is ...:
                keys = ()

            self.json = JSON(*keys, all=all, dump=dump, path=path)

    else:  # pragma: no cover
        # noinspection PyArgumentList,PyShadowingBuiltins
        def __init__(self, keys, all: bool, dump: bool,
                     default, default_factory, init, repr, hash, compare,
                     metadata, path: bool = False):

            super().__init__(default, default_factory, init, repr, hash,
                             compare, metadata)

            if isinstance(keys, str):
                keys = split_object_path(keys) if path else (keys,)
            elif keys is ...:
                keys = ()

            self.json = JSON(*keys, all=all, dump=dump, path=path)


# noinspection PyPep8Naming
def Pattern(pattern):
    return PatternedDT(pattern)


class _PatternBase:
    __slots__ = ()

    def __class_getitem__(cls, pattern):
        return PatternedDT(pattern, cls.__base__)

    __getitem__ = __class_getitem__


class DatePattern(date, _PatternBase):
    __slots__ = ()


class TimePattern(time, _PatternBase):
    __slots__ = ()


class DateTimePattern(datetime, _PatternBase):
    __slots__ = ()


class PatternedDT(Generic[DT]):

    # `cls` is the date/time/datetime type or subclass.
    # `pattern` is the format string to pass in to `datetime.strptime`.
    __slots__ = ('cls',
                 'pattern')

    def __init__(self, pattern, cls = None):
        self.cls = cls
        self.pattern = pattern

    def get_transform_func(self):
        cls = self.cls

        # Parse with `fromisoformat` first, because its *much* faster than
        # `datetime.strptime` - see linked article above for more details.
        body_lines = [
            'dt = default_load_func(date_string, cls, raise_=False)',
            'if dt is not None:',
            '  return dt',
            'dt = datetime.strptime(date_string, pattern)',
        ]

        locals_ns = {'datetime': datetime,
                     'pattern': self.pattern,
                     'cls': cls}

        if cls is datetime:
            default_load_func = as_datetime
            body_lines.append('return dt')
        elif cls is date:
            default_load_func = as_date
            body_lines.append('return dt.date()')
        elif cls is time:
            default_load_func = as_time
            # temp fix for Python 3.11+, since `time.fromisoformat` is updated
            # to support more formats, such as "-" and "+" in strings.
            if '-' in self.pattern or '+' in self.pattern:
                body_lines = ['try:',
                              '  return datetime.strptime(date_string, pattern).time()',
                              'except (ValueError, TypeError):',
                              '  dt = default_load_func(date_string, cls, raise_=False)',
                              '  if dt is not None:',
                              '    return dt']
            else:
                body_lines.append('return dt.time()')
        elif issubclass(cls, datetime):
            default_load_func = as_datetime
            locals_ns['datetime'] = cls
            body_lines.append('return dt')
        elif issubclass(cls, date):
            default_load_func = as_date
            body_lines.append('return cls(dt.year, dt.month, dt.day)')
        elif issubclass(cls, time):
            default_load_func = as_time
            # temp fix for Python 3.11+, since `time.fromisoformat` is updated
            # to support more formats, such as "-" and "+" in strings.
            if '-' in self.pattern or '+' in self.pattern:
                body_lines = ['try:',
                              '  dt = datetime.strptime(date_string, pattern).time()',
                              'except (ValueError, TypeError):',
                              '  dt = default_load_func(date_string, cls, raise_=False)',
                              '  if dt is not None:',
                              '    return dt']

            body_lines.append('return cls(dt.hour, dt.minute, dt.second, '
                              'dt.microsecond, fold=dt.fold)')
        else:
            raise TypeError(f'Annotation for `Pattern` is of invalid type '
                            f'({cls}). Expected a type or subtype of: '
                            f'{DT.__constraints__}')

        locals_ns['default_load_func'] = default_load_func

        return _create_fn('pattern_to_dt',
                          ('date_string', ),
                          body_lines,
                          locals=locals_ns,
                          return_type=DT)

    def __repr__(self):
        repr_val = [f'{k}={getattr(self, k)!r}' for k in self.__slots__]
        return f'{self.__class__.__name__}({", ".join(repr_val)})'


class Container(list[T]):

    __slots__ = ('__dict__',
                 '__orig_class__')

    @cached_property
    def __model__(self):

        try:
            # noinspection PyUnresolvedReferences
            return self.__orig_class__.__args__[0]
        except AttributeError:
            cls_name = self.__class__.__qualname__
            msg = (f'A {cls_name} object needs to be instantiated with '
                   f'a generic type T.\n\n'
                   'Example:\n'
                   f'  my_list = {cls_name}[T](...)')

            raise TypeError(msg) from None

    def __str__(self):

        import pprint
        return pprint.pformat(self)

    def prettify(self, encoder = json.dumps,
                 ensure_ascii=False,
                 **encoder_kwargs):

        return self.to_json(
            indent=2,
            encoder=encoder,
            ensure_ascii=ensure_ascii,
            **encoder_kwargs
        )

    def to_json(self, encoder=json.dumps,
                **encoder_kwargs):

        from .loader_selection import asdict

        cls = self.__model__
        list_of_dict = [asdict(o, cls=cls) for o in self]

        return encoder(list_of_dict, **encoder_kwargs)

    def to_json_file(self, file, mode = 'w',
                     encoder=json.dump,
                     **encoder_kwargs):

        from .loader_selection import asdict

        cls = self.__model__
        list_of_dict = [asdict(o, cls=cls) for o in self]

        with open(file, mode) as out_file:
            encoder(list_of_dict, out_file, **encoder_kwargs)


# noinspection PyShadowingBuiltins
def path_field(keys, *,
               all=True, dump=True,
               default=MISSING,
               default_factory=MISSING,
               init=True, repr=True,
               hash=None, compare=True, metadata=None):

    if default is not MISSING and default_factory is not MISSING:
        raise ValueError('cannot specify both default and default_factory')

    return JSONField(keys, all, dump, default, default_factory, init, repr,
                    hash, compare, metadata, True)


if PY314_OR_ABOVE:

    def skip_if_field(
        condition,
        *,
        default=MISSING,
        default_factory=MISSING,
        init=True,
        repr=True,
        hash=None,
        compare=True,
        metadata=None,
        kw_only=MISSING,
        doc=None,
    ):

        if default is not MISSING and default_factory is not MISSING:
            raise ValueError("cannot specify both default and default_factory")

        if metadata is None:
            metadata = {}

        metadata["__skip_if__"] = condition

        return Field(
            default, default_factory, init, repr, hash, compare, metadata, kw_only, doc
        )


# In Python 3.10, dataclasses adds a new parameter to the :class:`Field`
# constructor: `kw_only`
#
# Ref: https://docs.python.org/3.10/library/dataclasses.html#dataclasses.dataclass
elif PY310_OR_ABOVE:  # pragma: no cover
    def skip_if_field(condition, *, default=MISSING, default_factory=MISSING, init=True, repr=True,
                      hash=None, compare=True, metadata=None, kw_only=MISSING):

        if default is not MISSING and default_factory is not MISSING:
            raise ValueError('cannot specify both default and default_factory')

        if metadata is None:
            metadata = {}

        metadata['__skip_if__'] = condition

        return Field(default, default_factory, init, repr, hash,
                     compare, metadata, kw_only)
else:  # pragma: no cover
    def skip_if_field(condition, *, default=MISSING, default_factory=MISSING, init=True, repr=True,
                      hash=None, compare=True, metadata=None):

        if default is not MISSING and default_factory is not MISSING:
            raise ValueError('cannot specify both default and default_factory')

        if metadata is None:
            metadata = {}

        metadata['__skip_if__'] = condition

        # noinspection PyArgumentList
        return Field(default, default_factory, init, repr, hash,
                     compare, metadata)


class Condition:

    __slots__ = (
        'op',
        'val',
        't_or_f',
        '_wrapped',
    )

    def __init__(self, operator, value):
        self.op = operator
        self.val = value
        self.t_or_f = operator in {'+', '!'}

    def __str__(self):
        return f"{self.op} {self.val!r}"

    def evaluate(self, other) -> bool:  # pragma: no cover
        # Optionally support runtime evaluation of the condition
        operators = {
            "==": lambda a, b: a == b,
            "!=": lambda a, b: a != b,
            "<": lambda a, b: a < b,
            "<=": lambda a, b: a <= b,
            ">": lambda a, b: a > b,
            ">=": lambda a, b: a >= b,
            "is": lambda a, b: a is b,
            "is not": lambda a, b: a is not b,
            "+": lambda a, _: True if a else False,
            "!": lambda a, _: not a,
        }
        return operators[self.op](other, self.val)


# Aliases for conditions

# noinspection PyPep8Naming
def EQ(value): return Condition("==", value)
# noinspection PyPep8Naming
def NE(value): return Condition("!=", value)
# noinspection PyPep8Naming
def LT(value): return Condition("<", value)
# noinspection PyPep8Naming
def LE(value): return Condition("<=", value)
# noinspection PyPep8Naming
def GT(value): return Condition(">", value)
# noinspection PyPep8Naming
def GE(value): return Condition(">=", value)
# noinspection PyPep8Naming
def IS(value): return Condition("is", value)
# noinspection PyPep8Naming
def IS_NOT(value): return Condition("is not", value)
# noinspection PyPep8Naming
def IS_TRUTHY(): return Condition("+", None)
# noinspection PyPep8Naming
def IS_FALSY(): return Condition("!", None)


# noinspection PyPep8Naming
def SkipIf(condition):
    """
    Mark a condition to be used as a skip directive during serialization.
    """
    condition._wrapped = True  # Set a marker attribute
    return condition


# Convenience alias, to skip serializing field if value is None
SkipIfNone = SkipIf(IS(None))


def finalize_skip_if(skip_if, operand_1, conditional):
    """
    Finalizes the skip condition by generating the appropriate string based on the condition.

    Args:
        skip_if (Condition): The condition to evaluate, containing truthiness and operation info.
        operand_1 (str): The primary operand for the condition (e.g., a variable or value).
        conditional (str): The conditional operator to use (e.g., '==', '!=').

    Returns:
        str: The resulting skip condition as a string.

    Example:
        >>> cond = Condition(t_or_f=True, op='+', val=None)
        >>> finalize_skip_if(cond, 'my_var', '==')
        'my_var'
    """
    if skip_if.t_or_f:
        return operand_1 if skip_if.op == '+' else f'not {operand_1}'

    return f'{operand_1} {conditional}'


def get_skip_if_condition(skip_if, _locals, operand_2=None, condition_i=None, condition_var='_skip_if_'):
    """
    Retrieves the skip condition based on the provided `Condition` object.

    Args:
        skip_if (Condition): The condition to evaluate.
        _locals (dict[str, Any]): A dictionary of local variables for condition evaluation.
        operand_2 (str): The secondary operand (e.g., a variable or value).
        condition_i (Condition): The condition var index.
        condition_var (str): The variable name to evaluate.

    Returns:
        Any: The result of the evaluated condition or a string representation for custom values.

    Example:
        >>> cond = Condition(t_or_f=False, op='==', val=10)
        >>> locals_dict = {}
        >>> get_skip_if_condition(cond, locals_dict, 'other_var')
        '== other_var'
    """
    # TODO: To avoid circular import
    from .class_helper import is_builtin

    if skip_if is None:
        return False

    if skip_if.t_or_f:  # Truthy or falsy condition, no operand
        return True

    if is_builtin(skip_if.val):
        return str(skip_if)

    # Update locals (as `val` is not a builtin)
    if operand_2 is None:
        operand_2 = f'{condition_var}{condition_i}'

    _locals[operand_2] = skip_if.val
    return f'{skip_if.op} {operand_2}'
