from __future__ import annotations

import glob
import os
from pathlib import Path
from typing import Any, Callable, Generic, Iterable, Mapping, TypeVar, cast

from pdm.pep517._vendor.packaging.requirements import InvalidRequirement, Requirement
from pdm.pep517.exceptions import MetadataError, PDMWarning, ProjectError
from pdm.pep517.utils import (
    cd,
    expand_vars,
    find_packages_iter,
    merge_marker,
    safe_name,
    show_warning,
    to_filename,
)
from pdm.pep517.validator import validate_pep621
from pdm.pep517.version import DynamicVersion

T = TypeVar("T")


class MetaField(Generic[T]):
    def __init__(
        self, name: str, fget: Callable[[Metadata, Any], T] | None = None
    ) -> None:
        self.name = name
        self.fget = fget

    def __get__(self, instance: Metadata, owner: type[Metadata]) -> T | None:
        if instance is None:
            return self
        try:
            rv = instance.data[self.name]
        except KeyError:
            return None
        if self.fget is not None:
            rv = self.fget(instance, rv)
        return rv


class Metadata:
    """A class that holds all metadata that Python packaging requries."""

    DEFAULT_ENCODING = "utf-8"
    SUPPORTED_CONTENT_TYPES = ("text/markdown", "text/x-rst", "text/plain")

    def __init__(self, root: str | Path, pyproject: dict[str, Any]) -> None:
        self.root = Path(root).absolute()
        if "project" not in pyproject:
            raise ProjectError("No [project] config in pyproject.toml")
        self.data = pyproject["project"]
        self.config = Config(self.root, pyproject.get("tool", {}).get("pdm", {}))

    def validate(self, raising: bool = False) -> bool:
        return validate_pep621(self.data, raising)

    @property
    def name(self) -> str:
        if "name" not in self.data:
            raise MetadataError("name", "must be given in the project table")
        return self.data["name"]

    @property
    def version(self) -> str | None:
        static_version = self.data.get("version")
        if isinstance(static_version, str):
            return static_version
        if "version" in self.config.data and not (
            self.dynamic and "version" in self.dynamic
        ):
            raise MetadataError("version", "missing from 'dynamic' fields")
        dynamic_version = self.config.dynamic_version
        return (
            dynamic_version.evaluate_in_project(self.root) if dynamic_version else None
        )

    description: MetaField[str] = MetaField("description")

    def _get_readme_file(self, value: Mapping[str, str] | str) -> str:
        if isinstance(value, str):
            return value
        return value.get("file", "")

    def _get_readme_content(self, value: Mapping[str, str] | str) -> str:
        if isinstance(value, str):
            return Path(value).read_text(encoding=self.DEFAULT_ENCODING)
        if "file" in value and "text" in value:
            raise MetadataError(
                "readme",
                "readme table shouldn't specify both 'file' "
                "and 'text' at the same time",
            )
        if "text" in value:
            return value["text"]
        file_path = value.get("file", "")
        encoding = value.get("charset", self.DEFAULT_ENCODING)
        return Path(file_path).read_text(encoding=encoding)

    def _get_content_type(self, value: Mapping[str, str] | str) -> str:
        if isinstance(value, str):
            if value.lower().endswith(".md"):
                return "text/markdown"
            elif value.lower().endswith(".rst"):
                return "text/x-rst"
            return "text/plain"
        content_type = value.get("content-type")
        if not content_type:
            raise MetadataError(
                "readme", "'content-type' is missing in the readme table"
            )
        if content_type not in self.SUPPORTED_CONTENT_TYPES:
            raise MetadataError(
                "readme", f"Unsupported readme content-type: {content_type}"
            )
        return content_type

    readme: MetaField[str] = MetaField("readme", _get_readme_file)
    long_description: MetaField[str] = MetaField("readme", _get_readme_content)
    long_description_content_type: MetaField[str] = MetaField(
        "readme", _get_content_type
    )

    def _get_name(self, value: Iterable[Mapping[str, str]]) -> str:
        result = []
        for item in value:
            if "email" not in item and "name" in item:
                result.append(item["name"])
        return ",".join(result)

    def _get_email(self, value: Iterable[Mapping[str, str]]) -> str:
        result = []
        for item in value:
            if "email" not in item:
                continue
            email = (
                item["email"]
                if "name" not in item
                else "{name} <{email}>".format(**item)
            )
            result.append(email)
        return ",".join(result)

    author: MetaField[str] = MetaField("authors", _get_name)
    author_email: MetaField[str] = MetaField("authors", _get_email)
    maintainer: MetaField[str] = MetaField("maintainers", _get_name)
    maintainer_email: MetaField[str] = MetaField("maintainers", _get_email)

    @property
    def classifiers(self) -> list[str]:
        classifers = set(self.data.get("classifiers", []))

        if self.dynamic and "classifiers" in self.dynamic:
            show_warning(
                "`classifiers` no longer supports dynamic filling, "
                "please remove it from `dynamic` fields and manually "
                "supply all the classifiers",
                PDMWarning,
                stacklevel=2,
            )

        return sorted(classifers)

    keywords: MetaField[str] = MetaField("keywords")
    project_urls: MetaField[dict[str, str]] = MetaField("urls")

    @property
    def license_expression(self) -> str | None:
        if "license" in self.data:
            if isinstance(self.data["license"], str):
                return self.data["license"]
            # show_warning(
            #     "'license.text' subtable is deprecated in favor of string 'license' "
            #     "value",
            #     PDMWarning,
            #     stacklevel=2,
            # )
            if "text" in self.data["license"]:
                return self.data["license"]["text"]
        show_warning("'license' is missing", PDMWarning, stacklevel=2)
        return None

    @property
    def license_files(self) -> dict[str, list[str]]:
        subtable_files = None
        if (
            "license" in self.data
            and isinstance(self.data["license"], dict)
            and "files" in self.data["license"]
        ):
            subtable_files = self.data["license"]["files"]
        if "license-files" not in self.data:
            if subtable_files is not None:
                # show_warning(
                #     "'license.files' subtable is deprecated in favor of "
                #     "'license-files'",
                #     PDMWarning,
                #     stacklevel=2,
                # )
                return {"paths": [self.data["license"]["file"]]}
            return {
                "globs": [
                    "LICENSES/*",
                    "LICEN[CS]E*",
                    "COPYING*",
                    "NOTICE*",
                    "AUTHORS*",
                ]
            }
        if subtable_files is not None:
            raise MetadataError(
                "license-files",
                "Can't specify both 'license.files' and 'license-files' fields",
            )
        rv = self.data["license-files"]
        valid_keys = {"globs", "paths"} & set(rv)
        if len(valid_keys) == 2:
            raise MetadataError(
                "license-files", "Can't specify both 'paths' and 'globs'"
            )
        if not valid_keys:
            raise MetadataError("license-files", "Must specify 'paths' or 'globs'")
        return rv

    def _convert_dependencies(
        self, deps: list[str], field: str = "dependencies"
    ) -> list[str]:
        def convert_req(req: str) -> str | None:
            """Discard all non-PEP 440 requirements, e.g. editable VCS requirements."""

            if req.strip().startswith("-e"):
                return None
            try:
                r = Requirement(req)
            except InvalidRequirement as e:
                raise MetadataError(field, f"Invalid requirement {req!r}\n  {e}") from e
            if r.url and "${" in r.url:
                r.url = expand_vars(r.url, self.root.resolve().as_posix())
            return str(r)

        return list(filter(None, map(convert_req, deps)))

    def _convert_optional_dependencies(
        self, deps: Mapping[str, list[str]]
    ) -> dict[str, list[str]]:
        return {
            k: self._convert_dependencies(deps[k], "optional-dependencies")
            for k in deps
        }

    dependencies: MetaField[list[str]] = MetaField(
        "dependencies", _convert_dependencies
    )
    optional_dependencies: MetaField[dict[str, list[str]]] = MetaField(
        "optional-dependencies", _convert_optional_dependencies
    )
    dynamic: MetaField[list[str]] = MetaField("dynamic")

    @property
    def project_name(self) -> str | None:
        if self.name is None:
            return None
        return safe_name(self.name)

    @property
    def project_filename(self) -> str:
        if self.project_name is None:
            return "UNKNOWN"
        return to_filename(self.project_name)

    @property
    def requires_extra(self) -> dict[str, list[str]]:
        """For PKG-INFO metadata"""
        if not self.optional_dependencies:
            return {}
        result: dict[str, list[str]] = {}
        for name, reqs in self.optional_dependencies.items():
            current = result[name] = []
            for r in reqs:
                parsed = Requirement(r)
                merge_marker(parsed, f"extra == {name!r}")
                current.append(str(parsed))
        return result

    @property
    def requires_python(self) -> str:
        result = self.data.get("requires-python", "")
        return "" if result == "*" else result

    @property
    def entry_points(self) -> dict[str, list[str]]:
        result = {}
        settings = self.data
        if "scripts" in settings:
            result["console_scripts"] = [
                f"{key} = {value}" for key, value in settings["scripts"].items()
            ]
        if "gui-scripts" in settings:
            result["gui_scripts"] = [
                f"{key} = {value}" for key, value in settings["gui-scripts"].items()
            ]
        if "entry-points" in settings:
            for plugin, value in settings["entry-points"].items():
                if plugin in ("console_scripts", "gui_scripts"):
                    correct_key = (
                        "scripts" if plugin == "console_scripts" else "gui-scripts"
                    )
                    raise MetadataError(
                        "entry-points",
                        f"entry-points {plugin!r} should be defined in "
                        f"[project.{correct_key}]",
                    )
                result[plugin] = [f"{k} = {v}" for k, v in value.items()]
        return result

    def convert_package_paths(self) -> dict[str, list | dict]:
        """Return a {package_dir, packages, package_data, exclude_package_data} dict."""
        packages = []
        py_modules = []
        package_data = {"": ["*"]}
        exclude_package_data: dict[str, list[str]] = {}
        package_dir = self.config.package_dir
        includes = self.config.includes
        excludes = self.config.excludes

        with cd(self.root.as_posix()):
            src_dir = Path(package_dir or ".")
            if not includes:
                packages = list(
                    find_packages_iter(
                        package_dir or ".",
                        exclude=["tests", "tests.*"],
                        src=str(src_dir),
                    )
                )
                if not packages:
                    py_modules = [path.name[:-3] for path in src_dir.glob("*.py")]
            else:
                packages_set = set()
                includes = includes[:]
                for include in includes[:]:
                    if include.replace("\\", "/").endswith("/*"):
                        include = include[:-2]
                    if "*" not in include and os.path.isdir(include):
                        dir_name = include.rstrip("/\\")
                        temp = list(
                            find_packages_iter(dir_name, src=package_dir or ".")
                        )
                        if os.path.isfile(os.path.join(dir_name, "__init__.py")):
                            temp.insert(0, dir_name)
                        packages_set.update(temp)
                        includes.remove(include)
                packages[:] = list(packages_set)
                for include in includes:
                    for path in glob.glob(include, recursive=True):
                        if "/" not in path.lstrip("./") and path.endswith(".py"):
                            # Only include top level py modules
                            py_modules.append(path.lstrip("./")[:-3])
                    if include.endswith(".py"):
                        continue
                    for package in packages:
                        relpath = os.path.relpath(include, package)
                        if not relpath.startswith(".."):
                            package_data.setdefault(package, []).append(relpath)
                for exclude in excludes:
                    for package in packages:
                        relpath = os.path.relpath(exclude, package)
                        if not relpath.startswith(".."):
                            exclude_package_data.setdefault(package, []).append(relpath)
            if packages and py_modules:
                raise ProjectError(
                    "Can't specify packages and py_modules at the same time."
                )
        return {
            "package_dir": {"": package_dir} if package_dir else {},
            "packages": packages,
            "py_modules": py_modules,
            "package_data": package_data,
            "exclude_package_data": exclude_package_data,
        }


class Config:
    """The [tool.pdm] table"""

    def __init__(self, root: Path, data: dict[str, Any]) -> None:
        self.root = root
        self.data = data

    def _compatible_get(
        self, name: str, default: Any = None, old_name: str | None = None
    ) -> Any:
        if name in self.data.get("build", {}):
            return self.data["build"][name]
        old_name = old_name or name
        if old_name in self.data:
            show_warning(
                f"Field `{old_name}` is renamed to `{name}` under [tool.pdm.build] "
                "table, please update your pyproject.toml accordingly",
                PDMWarning,
                stacklevel=2,
            )
            return self.data[old_name]
        return default

    @property
    def includes(self) -> list[str]:
        return self._compatible_get("includes", [])

    @property
    def source_includes(self) -> list[str]:
        return self._compatible_get("source-includes", [])

    @property
    def excludes(self) -> list[str]:
        return self._compatible_get("excludes", [])

    @property
    def setup_script(self) -> str | None:
        build_table = self.data.get("build", {})
        if "setup-script" in build_table:
            return build_table["setup-script"]
        if isinstance(build_table, str):
            show_warning(
                "Field `build` is renamed to `setup-script` under [tool.pdm.build] "
                "table, please update your pyproject.toml accordingly",
                PDMWarning,
                stacklevel=2,
            )
            return build_table
        return None

    @property
    def run_setuptools(self) -> bool:
        build = self.data.get("build", {})
        return isinstance(build, str) or cast(dict, build).get("run-setuptools", False)

    @property
    def package_dir(self) -> str:
        """A directory that will be used to looking for packages."""
        default = "src" if self.root.joinpath("src").exists() else ""
        return self._compatible_get("package-dir", default)

    @property
    def is_purelib(self) -> bool:
        """If not explicitly set, the project is considered to be non-pure
        if `build` exists.
        """
        return self._compatible_get("is-purelib", not bool(self.run_setuptools))

    @property
    def editable_backend(self) -> str:
        """Currently only two backends are supported:
        - editables: Proxy modules via editables
        - path: the legacy .pth file method(default)
        """
        return self._compatible_get("editable-backend", "path")

    @property
    def dynamic_version(self) -> DynamicVersion | None:
        dynamic_version = self.data.get("version")
        if not dynamic_version:
            return None
        return DynamicVersion.from_toml(dynamic_version)
