from __future__ import annotations

import contextlib
import inspect
import re
import textwrap
import warnings
from importlib.metadata import PackageNotFoundError, metadata
from typing import TYPE_CHECKING

from scinexus.composable import is_app, is_app_composable
from scinexus.data_store import set_summary_display

import cogent3
from cogent3._plugin import get_app_manager


def _summary_to_table(data, *, name):
    """Convert scinexus summary dicts to cogent3 Table objects.

    Registered with scinexus.data_store.set_summary_display so that
    scinexus data store summary properties return cogent3 Tables.
    """
    from cogent3.core.table import Table

    if isinstance(data, dict):
        rows = [[k, v] for k, v in data.items() if k != "title"]
        return Table(
            header=["Condition", "Value"],
            data=rows,
            title=data.get("title", name),
            index_name="Condition",
        )

    if isinstance(data, list):
        if not data:
            return Table(header=[], data=[], title=name)
        header = list(data[0].keys())
        rows = [list(row.values()) for row in data]
        return Table(header=header, data=rows, title=name)

    return data


set_summary_display(_summary_to_table)


def __getattr__(name):
    if name == "open_data_store":
        from .io import open_data_store

        globals()["open_data_store"] = open_data_store
        return open_data_store
    msg = f"module {__name__!r} has no attribute {name!r}"
    raise AttributeError(msg)


if TYPE_CHECKING:  # pragma: no cover
    from stevedore.extension import Extension

    from cogent3.core.table import Table


def _parse_license_name(classifier: str) -> str:
    """Extract just the license name from a trove classifier"""
    match = re.search(r"(.+?)(?:\s+License)?$", classifier.rsplit("::", maxsplit=1)[-1])
    return match[1].strip() if match else classifier


def _get_licenses(package_name: str) -> str:
    try:
        pkg_meta = metadata(package_name)
    except PackageNotFoundError:
        return ""

    if license_expr := pkg_meta.get("License-Expression"):
        return license_expr

    classifiers = pkg_meta.get_all("Classifier") or []
    license_classifiers = [c for c in classifiers if c.startswith("License ::")]
    # there can be multiple trove license classifiers
    license_names = {_parse_license_name(c) for c in license_classifiers}
    return ", ".join(license_names)


def _get_extension_attr(extension: Extension) -> list[str]:
    """
    This function returns app details for display.

    Notes
    -----
    This function also loads the module the app is in.
    """

    obj = extension.plugin

    if not is_app(obj):
        warnings.warn(
            f"{obj!r} from {obj.__module__!r} is not a valid cogent3 app, skipping",
            stacklevel=2,
        )

    _types = _make_types(obj)
    package = extension.module_name.split(".")[0]
    return [
        package,
        extension.name,
        is_app_composable(obj),
        _doc_summary(obj.__doc__ or ""),
        ", ".join(sorted(_types["_data_types"])),
        ", ".join(sorted(_types["_return_types"])),
        _get_licenses(package) or "Unknown, check package",
    ]


def _make_types(app: type) -> dict:
    """returns type hints for the input and output"""
    from cogent3.app.typing import get_type_display_names

    _types = {"_data_types": [], "_return_types": []}
    input_type = getattr(app, "_input_type", None)
    return_type = getattr(app, "_return_type", None)
    if input_type is not None:
        _types["_data_types"] = sorted(get_type_display_names(input_type))
    if return_type is not None:
        _types["_return_types"] = sorted(get_type_display_names(return_type))
    return _types


def available_apps(name_filter: str | None = None) -> Table:
    """
    returns Table listing the available apps
    """

    rows = []

    extensions = get_app_manager()

    for extension in extensions:
        if name_filter and name_filter not in extension.name:
            continue

        with contextlib.suppress(AttributeError):
            # probably a local scope issue in testing!
            rows.append(_get_extension_attr(extension))

    header = [
        "package",
        "name",
        "composable",
        "doc",
        "input type",
        "output type",
        "licenses",
    ]
    return cogent3.make_table(header=header, data=rows)


_get_param = re.compile('(?<=").+(?=")')
_type_hint = re.compile(r":.+?=\s*")


def _make_signature(app: type) -> str:
    from scinexus.misc import get_object_provenance

    if app is None:
        msg = "app cannot be None"
        raise ValueError(msg)

    # if app is an instance, get the underlying class
    if not inspect.isclass(app):
        app = app.__class__

    init_sig = inspect.signature(app.__init__)
    app_name = app.__name__
    params = [f"{app_name!r}"]
    empty_default = inspect._empty
    for k, v in init_sig.parameters.items():
        if k == "self":
            continue

        txt = repr(v).replace("\n", " ")
        # clean up text when callable() used  as a type hint
        txt = txt.replace("<built-in function callable>", "callable")

        val = _get_param.findall(txt)[0]
        val = _type_hint.sub("=", val)
        if v.default is not empty_default and callable(v.default):
            val = val.split("=", maxsplit=1)
            if hasattr(v.default, "app_type"):
                val[-1] = f" {v.default!s}"
            else:
                val[-1] = f" {get_object_provenance(v.default)}"
            val = "=".join(val)

        params.append(val.replace("\n", " "))

    sig_prefix = f"{app_name}_app = get_app"
    sig_prefix_length = len(sig_prefix)

    if len(", ".join(params)) + sig_prefix_length <= 68:  # plus 2 parentheses makes 70
        params = ", ".join(params)
        return f"{sig_prefix}({params})"

    indent = " " * 4
    params = ",\n".join([f"{indent}{p}" for p in params])

    return f"{sig_prefix}(\n{params},\n)"


def _doc_summary(doc: str) -> str:
    """return first para of docstring"""
    result = []
    for line in doc.splitlines():
        if line := line.strip():
            result.append(line)
        else:
            break
    return " ".join(result)


def _get_app_matching_name(name: str) -> type:
    """name can include module name"""

    if "." in name:
        modname, name = name.rsplit(".", maxsplit=1)
    else:
        modname = None

    extensions_matching = [
        extension for extension in get_app_manager() if extension.name == name
    ]
    if not extensions_matching:
        msg = f"App {name!r} not found. Please check for typos."
        raise ValueError(msg)

    if modname:
        for extension in extensions_matching:
            if extension.module_name.endswith(modname):
                return extension.plugin
        msg = f"App {name!r} not found. Please check for typos."
        raise ValueError(msg)

    if len(extensions_matching) == 1:
        return extensions_matching[0].plugin
    msg = f"Too many apps matching name {name!r},\n{available_apps().filtered(lambda x: name == x, columns='name')}"
    raise NameError(
        msg,
    )


def get_app(_app_name: str, *args, **kwargs):
    """returns app instance, use app_help() to display arguments

    Raises
    ------
    NameError when multiple apps have the same name. In that case use a
    qualified class name.
    """
    return _get_app_matching_name(_app_name)(*args, **kwargs)


def _make_head(text: str) -> list[str]:
    """makes a restructured text formatted header"""
    return [text, "-" * len(text)]


def _clean_params_docs(text: str) -> str:
    """remove unnecessary indentation"""
    text = text.splitlines(keepends=False)
    prefix = re.compile(r"^\s{8}")  # expected indentation of constructor doc
    doc = []
    for line in text:
        line = prefix.sub("", line)
        doc.append(line)

    # Remove empty lines at the beginning and end of docstrings
    if not doc[0]:
        doc.pop(0)
    if not doc[-1]:
        doc.pop()

    return "\n".join(doc)


def _clean_overview(text: str) -> str:
    text = text.split()
    return "\n".join(textwrap.wrap(" ".join(text), break_long_words=False))


def _make_apphelp_docstring(app):
    docs = []
    app_doc = app.__doc__ or ""
    if app_doc.strip():
        docs.extend([*_make_head("Overview"), _clean_overview(app_doc), ""])

    docs.extend([*_make_head("Options for making the app"), _make_signature(app)])

    init_doc = app.__init__.__doc__ or ""
    if init_doc.strip():
        docs.extend(["", _clean_params_docs(init_doc)])

    types = _make_types(app)
    docs.extend(["", *_make_head("Input type"), ", ".join(types["_data_types"])])
    docs.extend(["", *_make_head("Output type"), ", ".join(types["_return_types"])])

    return "\n".join(docs)


def app_help(name: str) -> None:
    """displays help for the named app

    Parameters
    ----------
    name
        app name, e.g. 'min_length', or can include module information,
        e.g. 'cogent3.app.sample.min_length' or 'sample.min_length'. Use the
        latter (qualified class name) style when there are multiple matches
        to name.
    """
    app = _get_app_matching_name(name)
    print(_make_apphelp_docstring(app))  # noqa
