from __future__ import annotations

import ast
import os
from collections.abc import Iterable
from configparser import ConfigParser
from dataclasses import asdict, dataclass, field, fields
from importlib.metadata import Distribution
from pathlib import Path
from typing import TYPE_CHECKING, Any, no_type_check

if TYPE_CHECKING:
    from importlib.metadata import _SimplePath


@dataclass
class Setup:
    """
    Abstraction of a Python project setup file.
    """

    name: str | None = None
    version: str | None = None
    install_requires: list[str] = field(default_factory=list)
    extras_require: dict[str, list[str]] = field(default_factory=dict)
    python_requires: str | None = None
    summary: str | None = None

    def update(self, other: Setup) -> None:
        for f in fields(self):
            other_field = getattr(other, f.name)
            if other_field:
                setattr(self, f.name, other_field)

    def as_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_directory(cls, dir: Path) -> Setup:
        return _SetupReader.read_from_directory(dir)

    def as_dist(self) -> Distribution:
        return SetupDistribution(self)


class _SetupReader:
    """
    Class that reads a setup.py file without executing it.
    """

    @classmethod
    def read_from_directory(cls, directory: Path) -> Setup:
        result = Setup()

        for filename, file_reader in [
            ("pyproject.toml", cls.read_pyproject_toml),
            ("setup.cfg", cls.read_setup_cfg),
            ("setup.py", cls.read_setup_py),
        ]:
            filepath = directory / filename
            if not filepath.exists():
                continue

            new_result = file_reader(filepath)
            result.update(new_result)

        return result

    @staticmethod
    def read_pyproject_toml(file: Path) -> Setup:
        from pdm import termui
        from pdm.exceptions import ProjectError
        from pdm.formats import MetaConvertError
        from pdm.project.project_file import PyProject

        try:
            metadata = PyProject(file, ui=termui.UI()).metadata
        except ProjectError:
            return Setup()
        except MetaConvertError as e:
            termui.logger.warning("Error parsing pyproject.toml, metadata may be incomplete. %s", e)
            metadata = e.data
        return Setup(
            name=metadata.get("name"),
            summary=metadata.get("description"),
            version=metadata.get("version"),
            install_requires=metadata.get("dependencies", []),
            extras_require=metadata.get("optional-dependencies", {}),
            python_requires=metadata.get("requires-python"),
        )

    @no_type_check
    @classmethod
    def read_setup_py(cls, file: Path) -> Setup:
        with file.open(encoding="utf-8") as f:
            content = f.read()

        body = ast.parse(content).body

        setup_call, body = cls._find_setup_call(body)
        if not setup_call:
            return Setup()

        return Setup(
            name=cls._find_single_string(setup_call, body, "name"),
            version=cls._find_single_string(setup_call, body, "version") or "0.0.0",
            install_requires=cls._find_install_requires(setup_call, body),
            extras_require=cls._find_extras_require(setup_call, body),
            python_requires=cls._find_single_string(setup_call, body, "python_requires"),
        )

    @staticmethod
    def read_setup_cfg(file: Path) -> Setup:
        parser = ConfigParser()

        parser.read(str(file))

        name = None
        version = "0.0.0"
        if parser.has_option("metadata", "name"):
            name = parser.get("metadata", "name")

        if parser.has_option("metadata", "version"):
            meta_version = parser.get("metadata", "version")
            if not meta_version.startswith("attr:"):
                version = meta_version

        install_requires = []
        extras_require: dict[str, list[str]] = {}
        python_requires = None
        if parser.has_section("options"):
            if parser.has_option("options", "install_requires"):
                for dep in parser.get("options", "install_requires").split("\n"):
                    dep = dep.strip()
                    if not dep:
                        continue

                    install_requires.append(dep)

            if parser.has_option("options", "python_requires"):
                python_requires = parser.get("options", "python_requires")

        if parser.has_section("options.extras_require"):
            for group in parser.options("options.extras_require"):
                extras_require[group] = []
                deps = parser.get("options.extras_require", group)
                for dep in deps.split("\n"):
                    dep = dep.strip()
                    if not dep:
                        continue

                    extras_require[group].append(dep)

        return Setup(
            name=name,
            version=version,
            install_requires=install_requires,
            extras_require=extras_require,
            python_requires=python_requires,
        )

    @classmethod
    def _find_setup_call(cls, elements: list[Any]) -> tuple[ast.Call | None, list[Any | None]]:
        funcdefs = []
        for i, element in enumerate(elements):
            if isinstance(element, ast.If) and i == len(elements) - 1:
                # Checking if the last element is an if statement
                # and if it is 'if __name__ == "__main__"' which
                # could contain the call to setup()
                test = element.test
                if not isinstance(test, ast.Compare):
                    continue

                left = test.left
                if not isinstance(left, ast.Name):
                    continue

                if left.id != "__name__":
                    continue

                setup_call, body = cls._find_sub_setup_call([element])
                if not setup_call:
                    continue

                return setup_call, body + elements
            if not isinstance(element, ast.Expr):
                if isinstance(element, ast.FunctionDef):
                    funcdefs.append(element)

                continue

            value = element.value
            if not isinstance(value, ast.Call):
                continue

            func = value.func
            if not (isinstance(func, ast.Name) and func.id == "setup") and not (
                isinstance(func, ast.Attribute)
                and isinstance(func.value, ast.Name)
                and func.value.id == "setuptools"
                and func.attr == "setup"
            ):
                continue

            return value, elements

        # Nothing, we inspect the function definitions
        return cls._find_sub_setup_call(funcdefs)

    @no_type_check
    @classmethod
    def _find_sub_setup_call(cls, elements: list[Any]) -> tuple[ast.Call | None, list[Any | None]]:
        for element in elements:
            if not isinstance(element, (ast.FunctionDef, ast.If)):
                continue

            setup_call = cls._find_setup_call(element.body)
            if setup_call != (None, None):
                setup_call, body = setup_call

                body = elements + body

                return setup_call, body

        return None, None

    @no_type_check
    @classmethod
    def _find_install_requires(cls, call: ast.Call, body: Iterable[Any]) -> list[str]:
        install_requires: list[str] = []
        value = cls._find_in_call(call, "install_requires")
        if value is None:
            # Trying to find in kwargs
            kwargs = cls._find_call_kwargs(call)

            if kwargs is None or not isinstance(kwargs, ast.Name):
                return install_requires

            variable = cls._find_variable_in_body(body, kwargs.id)
            if not isinstance(variable, (ast.Dict, ast.Call)):
                return install_requires

            if isinstance(variable, ast.Call):
                if not isinstance(variable.func, ast.Name):
                    return install_requires

                if variable.func.id != "dict":
                    return install_requires

                value = cls._find_in_call(variable, "install_requires")
            else:
                value = cls._find_in_dict(variable, "install_requires")

        if value is None:
            return install_requires

        if isinstance(value, ast.List):
            install_requires.extend(
                [el.value for el in value.elts if isinstance(el, ast.Constant) and isinstance(el.value, str)]
            )
        elif isinstance(value, ast.Name):
            variable = cls._find_variable_in_body(body, value.id)

            if variable is not None and isinstance(variable, ast.List):
                install_requires.extend(
                    [el.value for el in variable.elts if isinstance(el, ast.Constant) and isinstance(el.value, str)]
                )

        return install_requires

    @no_type_check
    @classmethod
    def _find_extras_require(cls, call: ast.Call, body: Iterable[Any]) -> dict[str, list[str]]:
        extras_require: dict[str, list[str]] = {}
        value = cls._find_in_call(call, "extras_require")
        if value is None:
            # Trying to find in kwargs
            kwargs = cls._find_call_kwargs(call)

            if kwargs is None or not isinstance(kwargs, ast.Name):
                return extras_require

            variable = cls._find_variable_in_body(body, kwargs.id)
            if not isinstance(variable, (ast.Dict, ast.Call)):
                return extras_require

            if isinstance(variable, ast.Call):
                if not isinstance(variable.func, ast.Name):
                    return extras_require

                if variable.func.id != "dict":
                    return extras_require

                value = cls._find_in_call(variable, "extras_require")
            else:
                value = cls._find_in_dict(variable, "extras_require")

        if value is None:
            return extras_require

        if isinstance(value, ast.Dict):
            for key, val in zip(value.keys, value.values, strict=True):
                if isinstance(val, ast.Name):
                    val = cls._find_variable_in_body(body, val.id)

                if isinstance(val, ast.List):
                    extras_require[key.value] = [
                        e.value for e in val.elts if isinstance(e, ast.Constant) and isinstance(e.value, str)
                    ]
        elif isinstance(value, ast.Name):
            variable = cls._find_variable_in_body(body, value.id)

            if variable is None or not isinstance(variable, ast.Dict):
                return extras_require

            for key, val in zip(variable.keys, variable.values, strict=True):
                if isinstance(val, ast.Name):
                    val = cls._find_variable_in_body(body, val.id)

                if isinstance(val, ast.List):
                    extras_require[key.value] = [
                        e.value for e in val.elts if isinstance(e, ast.Constant) and isinstance(e.value, str)
                    ]

        return extras_require

    @classmethod
    def _find_single_string(cls, call: ast.Call, body: list[Any], name: str) -> str | None:
        value = cls._find_in_call(call, name)
        if value is None:
            # Trying to find in kwargs
            kwargs = cls._find_call_kwargs(call)

            if kwargs is None or not isinstance(kwargs, ast.Name):
                return None

            variable = cls._find_variable_in_body(body, kwargs.id)
            if not isinstance(variable, (ast.Dict, ast.Call)):
                return None

            if isinstance(variable, ast.Call):
                if not isinstance(variable.func, ast.Name):
                    return None

                if variable.func.id != "dict":
                    return None

                value = cls._find_in_call(variable, name)
            else:
                value = cls._find_in_dict(variable, name)

        if value is None:
            return None

        if isinstance(value, ast.Constant) and isinstance(value.value, str):
            return value.value
        elif isinstance(value, ast.Name):
            variable = cls._find_variable_in_body(body, value.id)

            if variable is not None and isinstance(variable, ast.Constant) and isinstance(variable.value, str):
                return variable.value

        return None

    @staticmethod
    def _find_in_call(call: ast.Call, name: str) -> Any | None:
        for keyword in call.keywords:
            if keyword.arg == name:
                return keyword.value
        return None

    @staticmethod
    def _find_call_kwargs(call: ast.Call) -> Any | None:
        kwargs = None
        for keyword in call.keywords:
            if keyword.arg is None:
                kwargs = keyword.value

        return kwargs

    @staticmethod
    def _find_variable_in_body(body: Iterable[Any], name: str) -> Any | None:
        for elem in body:
            if not isinstance(elem, ast.Assign):
                continue

            for target in elem.targets:
                if not isinstance(target, ast.Name):
                    continue

                if target.id == name:
                    return elem.value
        return None

    @staticmethod
    def _find_in_dict(dict_: ast.Dict, name: str) -> Any | None:
        for key, val in zip(dict_.keys, dict_.values, strict=True):
            if isinstance(key, ast.Constant) and key.value == name:
                return val
        return None


class SetupDistribution(Distribution):
    def __init__(self, data: Setup) -> None:
        self._data = data

    def read_text(self, filename: str) -> str | None:
        return None

    def locate_file(self, path: str | os.PathLike[str]) -> _SimplePath:
        return Path()

    @property
    def metadata(self) -> dict[str, Any]:  # type: ignore[override]
        return {
            k: v
            for k, v in {
                "Name": self._data.name,
                "Version": self._data.version,
                "Summary": self._data.summary,
                "Requires-Python": self._data.python_requires,
            }.items()
            if v is not None
        }

    @property
    def requires(self) -> list[str] | None:
        from pdm.models.markers import get_marker
        from pdm.models.requirements import parse_requirement

        result = self._data.install_requires[:]
        for extra, reqs in self._data.extras_require.items():
            extra_marker = f"extra == '{extra}'"
            for req in reqs:
                parsed = parse_requirement(req)
                old_marker = str(parsed.marker) if parsed.marker else None
                if old_marker:
                    if " or " in old_marker:
                        new_marker = f"({old_marker}) and {extra_marker}"
                    else:
                        new_marker = f"{old_marker} and {extra_marker}"
                else:
                    new_marker = extra_marker
                parsed.marker = get_marker(new_marker)
                result.append(parsed.as_line())
        return result
