"""resourcelib - a semi-automated library for structured objects

This library aims to be both compatible with the mgr's object_format library
and be a base for a cleaner looking & easier working way to accomplish things
like the `ceph nfs export apply` command and the `ceph orch apply` command
but for smb. This only defines the tools to do semi-automated structuring/unstructuring
- serialization into JSON/YAML can be handled at higher levels but without
needing to get fancy in those libraries.

Quick Example:
>>> from . import resourcelib
>>> @resourcelib.component()
... class Rect:
...     x_pos: int
...     y_pos: int
...     width: int
...     height: int
>>> @resourcelib.resource('art')
... class Art:
...    name: str
...    rectangles: List[Rect]
>>> rdata = {
...     'resource_type': 'art',
...     'name': 'My Paintings',
...     'rectangles': [
...         {'x_pos': 5, 'y_pos': 0, 'width': 12, 'height': 9},
...         {'x_pos': 20, 'y_pos': 35, 'width': 10, 'height': 40},
...     ],
... }
>>> a = resourcelib.load(rdata)
>>> isinstance(a[0], Art)
True

That is, a dictionary containing a `resource_type` field and other data
fields can be easily converted into a more structured python object
based on dataclasses and a few decorators. This object can also be
converted back into a less-structured dict using the automatically
created `to_simplified` method.

One can also work directly with the Resource objects that are created
for each class. First, the special method `_customize_resource` with
the `customize` decorator can be used to alter the Resource immediately
after it is created. Alternatively, the property `_resource_config` is
added to every resourcelib component or resource.

Example:
>>> @resourcelib.resource('widget')
... class Widget:
...    name: str
...    description: str = ''
...    labels: Optional[List[str]] = None
...    @resourcelib.customize
...    def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource:
...        # set description field quiet property to true. this will hide
...        # empty strings from the unstructured dict
...        rc.description.quiet = True
...        return rc
>>> def do_something():
...     "Do something silly with the widget class resource."
...     w = Widget('default', 'A generic widget')
...     return Widget._resource_config.object_to_simplified(w)
>>> do_something()
{'resource_type': 'widget', 'name': 'default', 'description': 'A generic widget'}

Technically, in the _customize_resource method, you can manipulate the Resource
object deeply or even return a truly customized Resource subclass object
if you want to!

Multiple classes can share a single `resource_type` name if, and only if,
all resources are configured to have a condition. A condition is a function
that takes the raw dict and returns true/false indicating that the resource
and the dataclass it handles are appropriate for the data in question.
One can set the condition function using the `Resource.on_condition` method.
This can be invoked via the `_customize_resource` method.

The library doesn't check if the conditions are comprehensive. If you have
two classes mapped to resource_type "x" and neither condition returns true
the library will simply raise an exception.
"""
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    Hashable,
    Iterator,
    List,
    Optional,
    Tuple,
)

import dataclasses
import logging
import sys
from contextlib import contextmanager
from itertools import chain

from .proto import Self, Simplified

log = logging.getLogger(__name__)

_RESOURCE_TYPE = 'resource_type'
_RESOURCES = 'resources'
_DEBUG = False


if _DEBUG:

    def _xt(f: Callable) -> Callable:
        def _func(*args: Any, **kwargs: Any) -> Any:
            log.debug(f'\nCALL({f}): {args!r}, {kwargs!r}')
            try:
                result = f(*args, **kwargs)
                log.debug(f'\n RET({f}): result={result!r}')
                return result
            except Exception as err:
                log.debug(f'\n EXC({f}): {err!r}')
                raise

        return _func

else:

    def _xt(f: Callable) -> Callable:
        return f


if sys.version_info >= (3, 11):
    from typing import dataclass_transform
elif TYPE_CHECKING:
    from typing_extensions import dataclass_transform
else:
    # no-op decorator, placeholder for dataclass_transform
    def dataclass_transform(*args: Any, **kwargs: Any) -> Callable:
        def _phony(f: Callable) -> Callable:
            return f

        return _phony


# ---- Error Types ----


class ResourceTypeError(ValueError):
    """Generic error working with smb resource types."""

    pass


class MissingResourceTypeError(KeyError, ResourceTypeError):
    """Exception raised when converting from unstructured data and a required
    `resource_type` field is missing.
    """

    def __init__(self, data: Simplified) -> None:
        self.data = data

    def __str__(self) -> str:
        return 'source data is missing a resource_type field'


class InvalidResourceTypeError(ResourceTypeError):
    """Exception raised when an object can not be converted from unstructured
    data.
    """

    def __init__(self, *, expected: Any = None, actual: Any = None) -> None:
        self.expected = expected
        self.actual = actual

    def __str__(self) -> str:
        msg = f'invalid resource type value: {self.actual!r}'
        if self.expected:
            msg += f'; expected: {self.expected!r}'
        return msg


class MissingRequiredFieldError(KeyError, ResourceTypeError):
    """Exception raised when an object can not be converted from unstructured
    data due to a missing required field.
    """

    def __init__(self, key: str) -> None:
        self.key = key

    def __str__(self) -> str:
        return f'data object missing required field: {self.key}'


# ---- Internal Resource Types ----

# Sentinel object for unset/missing value conditions.
_unset = object()


def _unwrap_type(atype: Any) -> Tuple[Tuple, bool]:
    """Given an Optional[T] type return (T, True). Given a non-optional type
    return the  (T, false).
    """
    args = _get_args(atype)
    uargs: Tuple[Any, ...] = tuple(a for a in args if a is not type(None))
    return uargs, bool(args and len(args) != len(uargs))


def _get_args(atype: Any) -> Tuple:
    """Given a type object return the types args.
    Example: List[T] -> (T,), Union[A, B] -> (A, B).
    """
    return getattr(atype, '__args__', tuple())


def _get_origin(atype: Any) -> Any:
    """Given a type such as List[T] return the outer type (List)."""
    return getattr(atype, '__origin__', None)


@dataclasses.dataclass
class Field:
    """Metadata about a field (member) of a resource type class."""

    name: str
    field_type: Any
    default: Any = _unset
    quiet: bool = False  # customization value
    keep_none: bool = False  # customization value
    # wrapper_type can be used to customize the output type of a scalar value.
    # Typically this is used to assist YAML serialization.
    # IMPORTANT: make sure the type produces valid YAML and JSON, ideally as
    # subclass as a native type.
    wrapper_type: Optional[Callable] = None

    def optional(self) -> bool:
        """Return true if the type of the field is Optional."""
        _, optional = _unwrap_type(self.field_type)
        return optional

    def inner_type(self) -> Any:
        """For a field with an optional type (Optional[T]) return the
        nested type (T). Otherwise return the current field type.
        """
        args, optional = _unwrap_type(self.field_type)
        if optional:
            assert len(args) == 1
            return args[0]
        return self.field_type

    @_xt
    def takes(self, dest_type: Any) -> bool:
        """Returns true if the field's type is composed of a matching container
        type. It supports field types of the form `List[T]`, `Optional[List[T]]`,
        `Dict[T, V]`, `Optional[Dict[T, V]]`.

        For example a field with type `List[str]` returns true for
        `f.takes(list)`. Similarly, `Optional[List[str]]` returns true for
        `f.takes(list)`. A field `Dict[int, int]` returns true for `f.takes(dict)`,
        and so on.

        Ideally newer-style types like `list[T] | None` supported by later
        python versions should also work but this has not been tested.
        """
        otype = _get_origin(self.inner_type())
        if otype is None:
            return False

        if dest_type is list:
            dest_type = (list, List)
        elif dest_type is dict:
            dest_type = (dict, Dict)
        elif not isinstance(dest_type, tuple):
            dest_type = (dest_type,)
        return otype in dest_type

    def list_element_type(self) -> Any:
        """Assuming the field type is a list (List[T]) return the type
        of the list's elements (T).
        """
        args, optional = _unwrap_type(self.inner_type())
        assert not optional
        assert len(args) == 1
        return args[0]

    def dict_element_types(self) -> Tuple[Any, Any]:
        """Assuming the field type is a dict (List[KT, VT]) return the types
        of the dict's keys & values (KT, VT).
        """
        args, optional = _unwrap_type(self.inner_type())
        assert not optional
        assert len(args) == 2
        return args[0], args[1]

    @classmethod
    def create(cls, fld: dataclasses.Field) -> Self:
        """Contructor converting from a dataclasses.Field."""
        default = _unset
        if fld.default is not dataclasses.MISSING:
            default = fld.default
        return cls(
            name=fld.name,
            field_type=fld.type,
            default=default,
        )


class Resource:
    """Type converter that maintains metadata about a structured python object
    and can be used to convert to / from the structured object and unstructured
    (JSON/YAML-safe dict) data.
    """

    def __init__(self, cls: Any) -> None:
        self.resource_cls = cls
        self.fields: Dict[str, Field] = {}
        self._on_condition: Optional[Callable[..., bool]] = None
        self._on_construction_error: Optional[Callable[..., Exception]] = None

        for fld in dataclasses.fields(self.resource_cls):
            self.fields[fld.name] = Field.create(fld)

    @property
    def conditional(self) -> bool:
        """Return true if the resource is selectable based on a condition."""
        return self._on_condition is not None

    def on_condition(self, cond: Callable[..., bool]) -> None:
        """Set a condition function."""
        self._on_condition = cond

    def on_construction_error(self, cond: Callable[..., Exception]) -> None:
        """Set a function to handle/convert exceptions that occur while
        constructing objects from simplified data.
        """
        self._on_construction_error = cond

    def type_name(self) -> str:
        """Return the name of the type managed by this resource."""
        return self.resource_cls.__name__

    def __getattr__(self, name: str) -> Field:
        """Return a field metadata object for the type managed by this resource."""
        return self.fields[name]

    @_xt
    def object_from_simplified(self, data: Simplified) -> Any:
        """Given a dict-based unstructured data object return the structured
        object-based equivalent.
        """
        with self._structuring_error_hook(self.resource_cls, data):
            kw = {}
            for fld in self.fields.values():
                value = self._object_field_from_simplified(fld, data)
                if value is not _unset:
                    kw[fld.name] = value
            obj = self.resource_cls(**kw)
            validate = getattr(obj, 'validate', None)
            if validate:
                validate()
            return obj

    @contextmanager
    @_xt
    def _structuring_error_hook(
        self, resource_cls: Any, data: Simplified
    ) -> Iterator[None]:
        try:
            yield
        except Exception as err:
            if self._on_construction_error:
                raise self._on_construction_error(err, data) from err
            raise

    @_xt
    def _object_field_from_simplified(
        self, fld: Field, data: Simplified
    ) -> Any:
        if fld.name not in data and fld.default is not _unset:
            return _unset
        elif fld.name not in data:
            raise MissingRequiredFieldError(fld.name)

        value = data[fld.name]
        if value is None and fld.optional():
            return None
        inner_type = fld.inner_type()

        _rconfig = getattr(inner_type, '_resource_config', None)
        if _rconfig:
            return _rconfig.object_from_simplified(value)
        _fs = getattr(inner_type, 'from_simplified', None)
        if _fs:
            return _fs(value)

        if fld.takes(list):
            subtype = fld.list_element_type()
            return [
                self._object_sub_from_simplified(subtype, v) for v in value
            ]
        if fld.takes(dict):
            ktype, vtype = fld.dict_element_types()
            # keys must be simple types right now so we just
            # cast it directly
            return {
                ktype(k): self._object_sub_from_simplified(vtype, v)
                for k, v in value.items()
            }

        return inner_type(value)

    @_xt
    def _object_sub_from_simplified(
        self, subtype: Any, data: Simplified
    ) -> Any:
        _rconfig = getattr(subtype, '_resource_config', None)
        if _rconfig:
            return _rconfig.object_from_simplified(data)
        if _get_origin(subtype) in (list, List):
            return list(data)
        if _get_origin(subtype) in (dict, Dict):
            return dict(data)
        return subtype(data)

    @_xt
    def object_to_simplified(self, obj: Any) -> Simplified:
        """Given a python object tagged as a resource type return the
        unstructured data equivalent.
        """
        result: Simplified = {}
        rt = getattr(obj, _RESOURCE_TYPE, None)
        if rt is not None:
            result[_RESOURCE_TYPE] = rt
        for fld in self.fields.values():
            self._object_field_to_simplified(obj, fld, result)
        return result

    @_xt
    def _object_field_to_simplified(
        self, obj: Any, fld: Field, data: Simplified
    ) -> None:
        value = getattr(obj, fld.name)
        assert fld.optional() or value is not None
        if (value is None and not fld.keep_none) or (
            fld.quiet and value is not None and not value
        ):
            return

        _rconfig = getattr(fld.inner_type(), '_resource_config', None)
        if _rconfig:
            data[fld.name] = _rconfig.object_to_simplified(value)
            return
        _ts = getattr(fld.inner_type(), 'to_simplified', None)
        if _ts:
            data[fld.name] = _ts(value)
            return

        if isinstance(value, list):
            assert fld.takes(list)
            subtype = fld.list_element_type()
            data[fld.name] = [
                self._object_sub_to_simplified(subtype, v) for v in value
            ]
            return
        if isinstance(value, dict):
            assert fld.takes(dict)
            ktype, vtype = fld.dict_element_types()
            data[fld.name] = {
                ktype(k): self._object_sub_to_simplified(vtype, v)
                for k, v in value.items()
            }
            return

        if fld.wrapper_type:
            _out = fld.wrapper_type
        else:
            _out = lambda v: v  # noqa: E731
        if isinstance(value, str):
            data[fld.name] = _out(str(value))
            return
        if isinstance(value, (int, float)):
            data[fld.name] = _out(value)
            return
        raise ResourceTypeError(f'unexpected type for field {fld.name}')

    @_xt
    def _object_sub_to_simplified(self, subtype: Any, value: Any) -> Any:
        _rconfig = getattr(subtype, '_resource_config', None)
        if _rconfig:
            return _rconfig.object_to_simplified(value)
        if isinstance(value, dict):
            return value
        if isinstance(value, list):
            return value
        if isinstance(value, str):
            return str(value)
        if isinstance(value, (int, float)):
            return value
        raise ResourceTypeError(f'unexpected type: {type(value)}')

    def __repr__(self) -> str:
        return (
            f'{self.__class__.__name__}<'
            f'{self.type_name()}, conditional={self.conditional}>'
        )

    @classmethod
    def create(cls, resource_cls: Any) -> Self:
        """Constructor that creates a Resource for a given python class."""
        resource = cls(resource_cls)
        _customize = getattr(resource_cls, '_customize_resource', None)
        if _customize is not None:
            resource = _customize(resource)
            if not resource:
                raise ValueError(
                    '_customize_resource must return a valid resource object,'
                    f' not {resource!r}'
                )
        return resource


class Registry:
    """Registry to track resource objects."""

    def __init__(self) -> None:
        self.resources: Dict[str, List[Resource]] = {}
        self.types: Dict[Hashable, Resource] = {}

    def enable(self, cls: Any) -> Resource:
        """Given a python class create and record resource for it.  Return the
        new resource.
        """
        resource = Resource.create(cls)
        self.types[cls] = resource
        return resource

    def track(self, key: str, resource: Resource) -> None:
        """Given a resource-type-name and a resource object, save these items
        into the registry. A key may be repeated creating a "discriminating
        union" so long as the resource type provides a condition function to
        determine what resource to use.
        """
        if key not in self.resources:
            self.resources[key] = []
        self.resources[key].append(resource)
        if len(self.resources[key]) > 1:
            if any(not c.conditional for c in self.resources[key]):
                raise ResourceTypeError(
                    'multiplexed resources must be conditional'
                )

    def select(self, data: Simplified) -> Resource:
        """Given an unstructured data object use the resource_type field and
        any condition functions to return the resource object that matches.
        """
        try:
            rt = data[_RESOURCE_TYPE]
        except KeyError:
            raise MissingResourceTypeError(data)
        try:
            matches = self.resources[rt]
        except KeyError:
            raise InvalidResourceTypeError(actual=rt)
        if len(matches) == 1:
            return matches[0]
        for rc in matches:
            if not rc._on_condition:
                raise ResourceTypeError('resource not conditional')
            if rc._on_condition(data):
                return rc
        raise ResourceTypeError('no resource type conditions met')


class _ResourceType:
    """A read-only property of a class acting as a resource type. When accessed,
    returns the name of the resource type.
    """

    def __init__(self, name: str) -> None:
        self.name = name

    def __get__(self, obj: Any, objtype: Any = None) -> str:
        return self.name


class _ResourceConfig:
    """A read-only property of a class acting as a resource type that, when
    accessed, returns the resource object.
    """

    def __init__(self, resource: Resource) -> None:
        self.resource = resource

    def __get__(self, obj: Any, objtype: Any = None) -> Resource:
        return self.resource


# _REGISTRY is our internal registry object
_REGISTRY = Registry()


# ---- decorators ----


def _to_simplified(obj: Any) -> Simplified:
    rc = getattr(obj, '_resource_config')
    return rc.object_to_simplified(obj)


def _make_resource(cls: Any, resource_name: str = '') -> Any:
    cls = dataclasses.dataclass(cls)
    rconfig = _REGISTRY.enable(cls)
    cls._resource_config = _ResourceConfig(rconfig)
    if resource_name:
        cls.resource_type = _ResourceType(resource_name)
        _REGISTRY.track(resource_name, rconfig)
    if getattr(cls, 'to_simplified', None) is None:
        cls.to_simplified = _to_simplified
    return cls


@dataclass_transform()
def resource(resource_name: str) -> Any:
    """Class-decorator that establishes a new dataclass/resource type.
    These types can be converted from raw data using the `load` function.
    """
    assert resource_name

    def _decorator(cls: Any) -> Any:
        return _make_resource(cls, resource_name)

    return _decorator


@dataclass_transform()
def component() -> Any:
    """Class-decorator that establishes a new dataclass/resource type without a
    top-level name. These types may be manually converted from raw or embedded
    within a resource type.
    """

    def _decorator(cls: Any) -> Any:
        return _make_resource(cls)

    return _decorator


# TODO: make customize validate the function and not just a wrapper over
# staticmethod
customize = staticmethod


# --- resource load function ----


@_xt
def load(data: Simplified) -> List[Any]:
    """Given a simple unstructured data object (python dict/list) containing
    only other simple data types, use the `resource_type` metadata to
    convert the input to a list of structured and typed objects.

    The input may be:
     * a single item: {"resource_type": "foo", ...} which will return
       a single valued list
     * a python list containing dicts like the above
     * a dict containing the key "resources" that maps to a list containing dicts
       like the 1st item
    """
    # Given a bare list/iterator. Assume it contains loadable objects.
    if not isinstance(data, dict):
        assert not isinstance(data, (str, bytes))
        return list(chain.from_iterable(load(v) for v in data))
    # Given a "list object"
    if _RESOURCE_TYPE not in data and _RESOURCES in data:
        rl = data[_RESOURCES]
        if not isinstance(rl, list):
            raise TypeError('expected resources list')
        return list(chain.from_iterable(load(v) for v in rl))
    # anything else must be a "self describing" object with a resource_type
    # value
    resource = _REGISTRY.select(data)
    return [resource.object_from_simplified(data)]
