# copyright: sktime developers, BSD-3-Clause License (see LICENSE file)
"""Quick crafting methods to build an object from string and registry.

craft(spec)
    craft an object or estimator from string

deps(spec)
    retrieves all dependencies required to craft str, in PEP440 format

The ``craft`` function is a pair to ``str`` coercion, the two can be seen as
deserialization/serialization counterparts to each other.

That is,
spec = str(my_est)
new_est = craft(spec)

will have the same effect as new_est = spec.clone()
"""

__author__ = ["fkiraly"]

import re

from sktime.registry._lookup import all_estimators


def _extract_class_names(spec):
    """Get all maximal alphanumeric substrings that start with a capital letter.

    Parameters
    ----------
    spec : str (any)

    Returns
    -------
    cls_name_list : list of str
        list of all maximal alphanumeric substrings starting with a capital in ``spec``
    """
    pattern = r"\b([A-Z][A-Za-z0-9_]*)\b"
    cls_name_list = re.findall(pattern, spec)
    return cls_name_list


def craft(spec):
    """Instantiate an object from the specification string.

    Parameters
    ----------
    spec : str, sktime/skbase compatible object specification
        i.e., a string that executes to construct an object if all imports were present
        imports inferred are of any classes in the scope of ``all_estimators``
        option 1: a string that evaluates to an estimator
        option 2: a sequence of assignments in valid python code,
            with the object to be defined preceded by a "return"
            assignments can use names of classes as if all imports were present

    Returns
    -------
    obj : skbase BaseObject descendant, constructed from ``spec``
        this will have the property that ``spec == str(obj)`` (up to formatting)
    """
    register = dict(all_estimators())  # noqa: F841

    for x in _extract_class_names(spec):
        exec(f"{x} = register['{x}']")

    try:
        obj = eval(spec)
    except Exception:
        from textwrap import indent

        spec_fun = indent(spec, "    ")
        spec_fun = (
            """
def build_obj():
        """
            + spec_fun
        )
        exec(spec_fun, locals())
        obj = eval("build_obj()")

    return obj


def deps(spec):
    """Get PEP 440 dependency requirements for a craft spec.

    Parameters
    ----------
    spec : str, sktime/skbase compatible object specification
        i.e., a string that executes to construct an object if all imports were present
        imports inferred are of any classes in the scope of ``all_estimators``
        option 1: a string that evaluates to an estimator
        option 2: a sequence of assignments in valid python code,
            with the object to be defined preceded by a "return"
            assignments can use names of classes as if all imports were present

    Returns
    -------
    reqs : list of str
        each str is PEP 440 compatible requirement string for craft(spec)
        if spec has no requirements, return is [], the length 0 list
    """
    register = dict(all_estimators())

    dep_strs = []

    for x in _extract_class_names(spec):
        if x not in register.keys():
            raise RuntimeError(
                f"class {x} is required to build spec, but was not found "
                "in all_estimators scope"
            )
        cls = register[x]

        new_deps = cls.get_class_tag("python_dependencies")

        if isinstance(new_deps, list):
            dep_strs += new_deps
        elif isinstance(new_deps, str) and len(new_deps) > 0:
            dep_strs += [new_deps]

        reqs = list(set(dep_strs))

    return reqs


def imports(spec):
    """Get import code block for a craft spec.

    Parameters
    ----------
    spec : str, sktime/skbase compatible object specification
        i.e., a string that executes to construct an object if all imports were present
        imports inferred are of any classes in the scope of ``all_estimators``
        option 1: a string that evaluates to an estimator
        option 2: a sequence of assignments in valid python code,
            with the object to be defined preceded by a "return"
            assignments can use names of classes as if all imports were present

    Returns
    -------
    import_str : str
        python code consisting of all import statements required for spec
        imports cover object/estimator classes found as sub-strings of spec
    """
    register = dict(all_estimators())

    import_strs = []

    for x in _extract_class_names(spec):
        if x not in register.keys():
            raise RuntimeError(
                f"class {x} is required to build spec, but was not found "
                "in all_estimators scope"
            )
        cls = register[x]

        import_str = f"from {cls.__module__} import {x}"
        import_strs += [import_str]

    if len(import_strs) == 0:
        imports_str = ""
    else:
        imports_str = "\n".join(sorted(import_strs))

    return imports_str
