"""
@package pmsco.config
infrastructure for configurable objects

@author Matthias Muntwiler

@copyright (c) 2021-23 by Paul Scherrer Institut @n
Licensed under the Apache License, Version 2.0 (the "License"); @n
  you may not use this file except in compliance with the License.
  You may obtain a copy of the License at
  http://www.apache.org/licenses/LICENSE-2.0
"""

import collections.abc
import inspect
import logging
import os
from pathlib import Path
from string import Template
from typing import Any, Callable, Dict, Generator, Iterable, List, Mapping, Optional, Sequence, Set, Tuple, Union

logger = logging.getLogger(__name__)

PathLike = Union[str, os.PathLike]
DataDict = Mapping[str, Union[str, int, float, Iterable, Mapping]]


def resolve_path(path: PathLike, dirs: Mapping[str, Any]):
    """
    Resolve a file path by replacing placeholders.

    Placeholders are enclosed in curly braces.
    Values for all possible placeholders are provided in a dictionary.

    @param path: str, Path or other path-like.
        Example: '${work}/test/testfile.dat'.
    @param dirs: Dictionary mapping placeholders to project paths.
        The paths can be str, Path or other path-like
        Example: {'work': '/home/user/work'}
    @return: pathlib.Path object
    """

    return Path(*(Template(p).substitute(dirs) for p in Path(path).parts))


class ConfigurableObject(object):
    """
    Parent class for objects that can be configured from a runfile

    The runfile is a JSON file that contains object data in a nested dictionary structure.

    In the dictionary structure the keys are property or attribute names of the object to be initialized.
    Keys starting with a non-alphabetic character (except for some special keys like __class__) are ignored.
    These can be used as comments, or they protect private attributes.

    The values can be numeric values, strings, lists or dictionaries.

    Simple values are simply assigned using setattr.
    This may call a property setter if defined.

    Lists are iterated. Each item is appended to the attribute.
    The attribute must implement an append method in this case.

    If an item is a dictionary and contains the special key '__class__',
    an object of that class is instantiated and recursively initialized with the dictionary elements.
    This requires that the class can be found in the module scope passed to the parser methods,
    and that the class inherits from this class.

    Cases that can't be covered easily using this mechanism
    should be implemented in a property setter.
    Value-checking should also be done in a property setter (or the append method in sequence-like objects).

    Attributes
    ----------

    project_symbols: Dictionary of symbols that should be used to resolve class and function names.
            This is usually the globals() dictionary of the project module.
    """

    def __init__(self):
        super().__init__()
        self.project_symbols: Optional[Mapping[str, Any]] = None

    def set_properties(self, symbols: Optional[Mapping[str, Any]],
                       data_dict: DataDict,
                       project: 'ConfigurableObject') -> None:

        """
        Set properties from dictionary.

        @param symbols: Dictionary of symbols that should be used to resolve class names.
            This is usually the globals() dictionary of the project module.
            Classes are resolved using the eval function.
        @param data_dict: Dictionary of properties to set.
            See the class description for details.
        @param project: Reference to the project object.
        @return: None
        """

        self.project_symbols = symbols
        for key in data_dict:
            if key[0].isalpha():
                self.set_property(symbols, key, data_dict[key], project)

    def set_property(self, symbols: Optional[Mapping[str, Any]],
                     key: str,
                     value: DataDict,
                     project: 'ConfigurableObject') -> None:

        """
        Set one property.

        @param symbols: Dictionary of symbols that should be used to resolve class names.
            This is usually the globals() dictionary of the project module.
            Classes are resolved using the eval function.
        @param key: Attribute name to set.
        @param value: New value of the attribute.
        @param project: Reference to the project object.
        @return: None
        """

        obj = self.parse_object(symbols, value, project)
        if hasattr(self, key):
            if obj is not None:
                if isinstance(obj, collections.abc.MutableSequence):
                    attr = getattr(self, key)
                    for item in obj:
                        attr.append(item)
                elif isinstance(obj, collections.abc.Mapping):
                    d = getattr(self, key)
                    if d is not None and isinstance(d, collections.abc.MutableMapping):
                        d.update(obj)
                    else:
                        setattr(self, key, obj)
                else:
                    setattr(self, key, obj)
            else:
                setattr(self, key, obj)
        else:
            logger.warning(f"class {self.__class__.__name__} does not have attribute {key}.")

    def parse_object(self, symbols: Optional[Mapping[str, Any]],
                     value: DataDict,
                     project: 'ConfigurableObject') -> object:

        if isinstance(value, collections.abc.MutableMapping) and "__class__" in value:
            cn = value["__class__"]
            try:
                c = eval(cn, symbols)
            except (AttributeError, KeyError, NameError, ValueError):
                logger.critical(f"can't resolve class name {cn}")
                raise
            s = inspect.signature(c)
            if 'project' in s.parameters:
                o = c(project=project)
            else:
                o = c()
            o.set_properties(symbols, value, project)
        elif isinstance(value, collections.abc.MutableSequence):
            o = [self.parse_object(symbols, i, project) for i in value]
        else:
            o = value
        return o
