"""
Bindings to integrate plux into setuptools build processes.
"""

import json
import logging
import os
import re
import shutil
import sys
import typing as t
from pathlib import Path

import setuptools
from setuptools.command.egg_info import egg_info

from plux.build import config
from plux.build.config import EntrypointBuildMode
from plux.build.project import Project

try:
    from setuptools.command.editable_wheel import editable_wheel
except ImportError:
    # this means we're probably working on an old setuptools version, perhaps because we are on an older
    # Python version.
    class editable_wheel:
        def run(self):
            raise NotImplementedError("Compatibility with editable wheels requires Python 3.10")

        def _ensure_dist_info(self, *args, **kwargs):
            pass


from setuptools.command.egg_info import InfoCommon, write_entries

from plux.core.entrypoint import EntryPointDict, discover_entry_points
from plux.runtime.metadata import entry_points_from_metadata_path
from plux.build.discovery import PluginFromPackageFinder, PackageFinder, Filter, MatchAllFilter
from plux.build.index import PluginIndexBuilder

LOG = logging.getLogger(__name__)


# SETUPTOOLS HOOKS
# ================
# The following classes and methods are a way for plux to hook into
# the setuptools build process either indirectly or programmatically.


class plugins(InfoCommon, setuptools.Command):
    """
    Setuptools command that discovers plugins and writes them into the egg_info directory to a ``plux.json`` file.

    TODO: This only exists for compatibility with older ``setup.py`` workflows. It was meant as a frontend for
      setuptools (to be called via ``setup.py plugins``. The modern way to build is either ``pip install -e .`` or
      ``python -m build``, so the command is basically obsolete. We should remove it with a future release, and
      instead rely on either the plux CLI frontend, or a transparent instrumentation of the build backend. It does help
      *slightly* with the integration with setuptools, since we can call ``dist.run_command('plugins')``
      programmatically, and then the ``egg_info`` command in a chain. However, we already have
      ``patch_egg_info_command`` where we could implement this logic. More background can be found here:
      https://github.com/pypa/setuptools/discussions/4223.
    """

    description = "Discover plux plugins and store them in .egg_info"

    user_options: t.ClassVar[list[tuple[str, str, str]]] = [
        (
            "exclude=",
            "e",
            "a sequence of paths to exclude; '*' can be used as a wildcard in the names. 'foo.*' will exclude all subpackages of 'foo' (but not 'foo' itself).",
        ),
        (
            "include=",
            "i",
            "a sequence of paths to include; If it's specified, only the named items will be included. If it's not "
            "specified, all found items in the path will be included. 'include' can contain shell style wildcard "
            "patterns just like 'exclude'",
        ),
        # TODO: add more, this should mirror the entrypoints or discover cli
    ]

    egg_info: str

    def initialize_options(self) -> None:
        self.plux_json_path = None
        self.exclude = None
        self.include = None
        self.plux_config = None

    def finalize_options(self) -> None:
        self.plux_json_path = get_plux_json_path(self.distribution)
        self.ensure_string_list("exclude")
        self.ensure_string_list("include")

        # we merge the configuration from the CLI arguments with the configuration read from the `pyproject.toml`
        # [tool.plux] section
        self.plux_config = read_plux_configuration(self.distribution)
        self.plux_config = self.plux_config.merge(
            exclude=self.exclude,
            include=self.include,
        )

    def run(self) -> None:
        # TODO: should be reconciled with Project.build_entrypoints()
        index_builder = create_plugin_index_builder(self.plux_config, self.distribution)
        self.debug_print(f"writing discovered plugins into {self.plux_json_path}")
        self.mkpath(os.path.dirname(self.plux_json_path))
        with open(self.plux_json_path, "w") as fp:
            ep = index_builder.write(fp)

        update_entrypoints(self.distribution, ep)


def patch_editable_wheel_command():
    """
    This patch creates a way for plux to maintain a link between the entry_points.txt contained in source
    distribution's egg-info, and the installed dist-info directory created by the editable_wheel command.
    This is so we can resolve entry points re-generated by ``python -m plux plugins`` from the source code,
    while using editable installs via ``pip install -e .`` into the venv.

    For instance, when you use an editable install, the path will look something like::

        ~/my-project/my_project/
        ~/my-project/my_project.egg-info/
        ~/my-project/.venv/
        ~/my-project/.venv/lib/python3.11/site-packages/my_project-0.0.1.dev0.dist-info

    The .egg-info directory will correctly contain an entry-points.txt, but not the generated dist-info
    directory in site-packages. This is a problem under certain circumstances because importlib may not
    find the entry points. The reasons why dist-info doesn't contain the entry_points.txt are because of
    how pip works. We could make sure that we correctly create an entry_points.txt during the building
    process, but that would ultimately not help, as we would have to ``pip install -e .`` again every time
    the plugins change. So a dynamic approach is better, which the linking facilitates. During runtime,
    simply follow the link left behind in ``entry_points_editable.txt`` to the real ``entry_points.txt`` in
    the .egg-info directory.

    TODO: this is a hacky patch that relies on setuptools internals. It would be better to find a clean way
      to completely overwrite the ``editable_wheel`` command, perhaps via the command class resolution
      mechanism. i looked into that for an hour or so and concluded that this is an acceptable workaround for
      now.
    """
    _ensure_dist_info_orig = editable_wheel._ensure_dist_info

    def _ensure_dist_info(self):
        _ensure_dist_info_orig(self)
        # then we can create a link to the original file
        # this is the egg info dir from the distribution source (the pip install -e target)

        # create what is basically an application-layer symlink to the original entry points
        target = Path(self.dist_info_dir, "entry_points_editable.txt")
        target.write_text(os.path.join(find_egg_info_dir(), "entry_points.txt"))

    editable_wheel._ensure_dist_info = _ensure_dist_info


# patch will be applied implicitly through the entry point that loads the ``plugins`` command.
patch_editable_wheel_command()


def patch_egg_info_command():
    """
    This patch fixes the build process when pip builds a wheel from a source distribution. A source distribution built
    with plux will already contain an `.egg_info` directory, but during the build with pip, a new .egg-info
    directory is created from scratch from the python distribution configuration files (like setup.cfg or
    pyproject.toml). This is a problem, as building a wheel involves creating a .dist-info dir, which is populated from
    this new .egg-info directory. The .egg-info shipped with the source distribution is completely ignored during this
    process, including the `plux.json`, leading to a wheel that does not correctly contain the `entry_points.txt`.

    This patch hooks into this procedure and makes sure when this new .egg-info directory is created, we first locate
    the one shipped with the source distribution, locate the ``plux.json`` file, and copy it into the new build context.
    """
    _run_orig = egg_info.run

    def _run(self):
        LOG.debug("Running egg_info command patch from plux")

        cfg = read_plux_configuration(self.distribution)
        if cfg.entrypoint_build_mode == EntrypointBuildMode.MANUAL:
            LOG.debug("Entrypoint build mode is manual, skipping egg_info patch")
            return _run_orig(self)

        # the working directory may be something like `/tmp/pip-req-build-bwekzpi_` where the source distribution
        # was copied into. this happens when you `pip install <dist>` where <dist> is
        # some source distribution.
        should_read, meta_dir = _should_read_existing_egg_info()

        # if the .egg_info from the source distribution contains the plux file, we prepare the new .egg-info by copying
        # it there. everything else should be handled implicitly by the command
        if should_read:
            LOG.debug("Locating plux.json from local build context %s", meta_dir)
            plux_json = os.path.join(meta_dir, "plux.json")
            if os.path.exists(plux_json):
                self.mkpath(self.egg_info)  # this is what egg_info.run() does but it's idempotent
                if not os.path.exists(os.path.join(self.egg_info, "plux.json")):
                    LOG.debug("copying %s into temporary %s", plux_json, self.egg_info)
                    shutil.copy(plux_json, self.egg_info)

        return _run_orig(self)

    egg_info.run = _run


patch_egg_info_command()


def load_plux_entrypoints(cmd, file_name, file_path):
    """
    This method is called indirectly by setuptools through the ``egg_info.writers`` plugin. It is used as the main hook
    to generate entry points from the index file that plux generates. The plux distribution defines the following
    setuptools entrypoint::

        [project.entry-points."egg_info.writers"]
        # this is actually not a writer, it's a reader :-)
        "plux.json" = "plux.build.setuptools:load_plux_entrypoints"

    It works in the following way: when setuptools builds the egg_info directory to the additional files that are
    defined as egg_info.writers plugins. This is our hook into the build process of setuptools. The trick here is that
    we have already previously built the ``plux.json``, and we use this hook to read the file, and use it to write the
    ``entry_points.txt`` file.
    """
    if not os.path.exists(file_path):
        return

    cfg = read_plux_configuration(cmd.distribution)
    if cfg.entrypoint_build_mode == EntrypointBuildMode.MANUAL:
        return

    cmd.debug_print(f"extend entrypoints with plux plugins from {file_path}")
    with open(file_path, "r") as fd:
        ep = json.load(fd)

    update_entrypoints(cmd.distribution, ep)

    # this is kind of a hack, but we cannot rely on load_plux_entrypoints being called before the regular
    # entry_points.txt egg_info.writers plugin to write the updated entry points
    ep_file = "entry_points.txt"
    ep_path = os.path.join(os.path.dirname(file_path), ep_file)
    write_entries(cmd, ep_file, ep_path)


def find_plugins(where=".", exclude=(), include=("*",)) -> EntryPointDict:
    """
    Utility for setup.py that collects all plugins from the specified path, and creates a dictionary for
    entry_points.

    For example:

    setup(
        entry_points=find_plugins()
    )
    """

    return discover_entry_points(PackagePathPluginFinder(where=where, exclude=exclude, include=include))


def load_entry_points(where=".", exclude=(), include=("*",), merge: EntryPointDict = None) -> EntryPointDict:
    """
    Finds plugins and builds and entry point map. This is to be used in a setup.py in the setup call:

    setup(
        entry_points=load_entry_points(exclude=("tests", "tests.*"), merge={
            "my.static.entrypoint": [
                "foo=bar"
            ]
        })
        ...
    )

    This is a hack for installing from source distributions. When running pip install on a source
    distribution, the egg_info directory is always re-built, even though it comes with the source
    distribution package data. This also means the entry points are resolved, and in extent, `find_plugins`
    is called, which is problematic at this point, because find_plugins will scan the code, and that will
    fail if requirements aren't yet installed, which they aren't when running pip install. However,
    since source distributions package the .egg-info directory, we can read the entry points from there
    instead, acting as sort of a cache.

    TODO: I'm not sure this is used or needed anymore, as we're moving away from ``setup.py`` files for which this
      api was designed. Though the idea of merging manually defined entry points with those discovered by plux is
      still valid and maybe useful in some cases.

    :param where: the file path to look for plugins (default, the current working dir)
    :param exclude: the shell style wildcard patterns to exclude
    :param include: the shell style wildcard patterns to include
    :param merge: a map of entry points that are always added
    """
    should_read, meta_dir = _should_read_existing_egg_info()
    if should_read:
        print("reading entry points from existing meta dir", meta_dir)
        eps = entry_points_from_egg_info(meta_dir)
    else:
        print("resolving plugin entry points")
        eps = find_plugins(where, exclude, include)

    if merge:
        # TODO: merge the dicts instead of overwriting (will involve de-duplicating entry points)
        eps.update(merge)

    return eps


# UTILITIES
# =========
# The remaining methods are utilities


class SetuptoolsProject(Project):
    distribution: setuptools.Distribution

    def __init__(self, workdir: str = None):
        super().__init__(workdir)

        self.distribution = get_distribution_from_workdir(str(self.workdir))

    def find_entry_point_file(self) -> Path:
        if egg_info_dir := find_egg_info_dir():
            return Path(egg_info_dir, "entry_points.txt")
        raise FileNotFoundError("No .egg-info directory found. Have you run `python -m plux entrypoints`?")

    def find_plux_index_file(self) -> Path:
        if self.config.entrypoint_build_mode == EntrypointBuildMode.MANUAL:
            return self.workdir / self.config.entrypoint_static_file

        return Path(get_plux_json_path(self.distribution))

    def create_plugin_index_builder(self) -> PluginIndexBuilder:
        return create_plugin_index_builder(self.config, self.distribution)

    def create_package_finder(self) -> PackageFinder:
        exclude = [_path_to_module(item) for item in self.config.exclude]
        include = [_path_to_module(item) for item in self.config.include]
        return DistributionPackageFinder(self.distribution, exclude=exclude, include=include)

    def build_entrypoints(self):
        dist = self.distribution

        dist.command_options["plugins"] = {
            "exclude": ("command line", ",".join(self.config.exclude) or None),
            "include": ("command line", ",".join(self.config.include) or None),
        }
        dist.run_command("plugins")

        print(f"building {dist.get_name().replace('-', '_')}.egg-info...")
        dist.run_command("egg_info")

        print("discovered plugins:")
        # print discovered plux plugins
        with open(get_plux_json_path(dist)) as fd:
            plux_json = json.load(fd)
            json.dump(plux_json, sys.stdout, indent=2)


def get_plux_json_path(distribution: setuptools.Distribution) -> str:
    """
    Returns the full path of ``plux.json`` file for the given distribution. The file is located within the .egg-info
    directory that contains the built metadata of the distribution.
    """
    dirs = distribution.package_dir
    egg_base = (dirs or {}).get("", os.curdir)
    egg_info_dir = _to_filename(_safe_name(distribution.get_name())) + ".egg-info"
    egg_info_dir = os.path.join(egg_base, egg_info_dir)
    return os.path.join(egg_info_dir, "plux.json")


def read_plux_configuration(distribution: setuptools.Distribution) -> config.PluxConfiguration:
    """
    Try reading the ``[tool.plux]`` section of the distribution's ``pyproject.toml`` file and parse it using our
    config parser. Note this method will use the distribution's ``package_dir`` to try and resolve the path first,
    so if you have a project with a namespace package, it may not work as expected if you call it from a CLI process.

    :param distribution: The distribution containing the pyproject.toml file.
    :return: The parsed configuration object
    """
    dirs = distribution.package_dir
    pyproject_base = (dirs or {}).get("", os.curdir)
    return config.read_plux_config_from_workdir(pyproject_base)


def update_entrypoints(distribution: setuptools.Distribution, ep: EntryPointDict):
    """
    Updates ``distribution.entry_points`` with the given entry point dict. Currently, it simply overwrites sections
    within the entrypoints.
    """
    if distribution.entry_points is None:
        distribution.entry_points = {}

    # TODO: merge entry point groups
    distribution.entry_points.update(ep)


def create_plugin_index_builder(
    cfg: config.PluxConfiguration,
    distribution: setuptools.Distribution,
) -> PluginIndexBuilder:
    """
    Creates a PluginIndexBuilder instance for discovering plugins from a setuptools distribution. It uses a
    ``DistributionPackageFinder`` to resolve the packages to scan for plugins from the given distribution (instead of
    the path given in the PluxConfiguration). However, it respects excludes given in the PluxConfiguration.
    """
    exclude = [_path_to_module(item) for item in cfg.exclude]
    include = [_path_to_module(item) for item in cfg.include]
    plugin_finder = PluginFromPackageFinder(
        DistributionPackageFinder(distribution, exclude=exclude, include=include)
    )
    return PluginIndexBuilder(plugin_finder)


def entry_points_from_egg_info(egg_info_dir: str) -> EntryPointDict:
    """
    Reads the entry_points.txt from a distribution meta dir (e.g., the .egg-info directory).
    """
    return entry_points_from_metadata_path(egg_info_dir)


def _should_read_existing_egg_info() -> tuple[bool, str | None]:
    # we want to read the .egg-info dir only if it exists, and if we are creating the egg_info or
    # installing it with pip install -e (which calls 'setup.py develop')

    if not (_is_pip_build_context() or _is_local_build_context()):
        return False, None

    egg_info_dir = find_egg_info_dir()
    if not egg_info_dir:
        return False, None

    if not os.path.isfile(os.path.join(egg_info_dir, "entry_points.txt")):
        return False, None

    return True, egg_info_dir


def _is_pip_build_context():
    # when pip builds packages or wheels from source distributions, it creates a temporary directory with a
    # marker file that we can use to determine whether we are in such a build context.
    for f in os.listdir(os.getcwd()):
        if f == "pip-delete-this-directory.txt":
            return True

    return False


def _is_local_build_context():
    # when installing with `pip install -e` pip also tries to create a "modern-metadata" (dist-info)
    # directory.
    if "dist_info" in sys.argv:
        try:
            i = sys.argv.index("--egg-base")
        except ValueError:
            try:
                # this code path is for building wheels from source distributions via pip
                i = sys.argv.index("--output-dir")
            except ValueError:
                return False

        if "pip-modern-metadata" in sys.argv[i + 1]:
            return True

    # it's unfortunately not really distinguishable whether or not a user calls `python setup.py develop`
    # in the project, or calls `pip install -e ..` to install the project from somewhere else.
    if len(sys.argv) > 1 and sys.argv[1] in ["egg_info", "develop"]:
        return True

    return False


def find_egg_info_dir() -> str | None:
    """
    Heuristic to find the .egg-info dir of the current build context.
    """
    workdir = os.getcwd()
    distribution = get_distribution_from_workdir(workdir)
    dirs = distribution.package_dir
    egg_base = (dirs or {}).get("", workdir)
    if not egg_base:
        return None
    egg_info_dir = _to_filename(_safe_name(distribution.get_name())) + ".egg-info"
    candidate = os.path.join(workdir, egg_base, egg_info_dir)
    if os.path.exists(candidate):
        return candidate
    return None


def get_distribution_from_workdir(workdir: str) -> setuptools.Distribution:
    """
    Reads from the current workdir the available project configs and parses them to create a
    ``setuptools.Distribution`` object, which can later be used to invoke distutils commands.

    :param workdir: the workdir containing the project files
    :return: a distribution object
    """
    config_files = ["pyproject.toml", "setup.cfg"]
    config_files = [os.path.join(workdir, file) for file in config_files]
    config_files = [file for file in config_files if os.path.exists(file)]

    if not config_files:
        raise ValueError(f"no distribution config files found in {workdir}")

    dist = setuptools.Distribution()
    dist.parse_config_files(config_files)
    if os.path.exists(os.path.join(workdir, "setup.py")):
        # use setup.py script if available
        dist.script_name = os.path.join(workdir, "setup.py")
    else:
        # else use a config file (seems to work regardless)
        dist.script_name = config_files[0]

    # note: the property Distribution.script_name is added to `SOURCES.txt` during the `sdist` command. the path
    # must be a relative path. see https://github.com/localstack/plux/issues/23
    dist.script_name = os.path.relpath(dist.script_name, workdir)

    return dist


def _safe_name(name):
    """Convert an arbitrary string to a standard distribution name. Copied from pkg_resources.

    Any runs of non-alphanumeric/. characters are replaced with a single '-'.
    """
    return re.sub("[^A-Za-z0-9.]+", "-", name)


def _to_filename(name):
    """Convert a project or version name to its filename-escaped form. Copied from pkg_resources.

    Any '-' characters are currently replaced with '_'.
    """
    return name.replace("-", "_")


def _path_to_module(path):
    """
    Convert a path to a Python module to its module representation
    Example: plux/core/test -> plux.core.test
    """
    return ".".join(Path(path).with_suffix("").parts)


class DistributionPackageFinder(PackageFinder):
    """
    PackageFinder that returns the packages found in the distribution. The Distribution will already have a
    list of resolved packages depending on the setup config. For example, if a ``pyproject.toml`` is used,
    then the ``[tool.setuptools.package.find]`` config will be interpreted, resolved, and then
    ``distribution.packages`` will contain the resolved packages. This already contains namespace packages
    correctly if configured.
    You can additionally pass a sequence of values to the ``exclude`` parameters to provide a list of Unix shell style
    patterns that will be matched against the Python packages to exclude them from the resolved packages.
    Wildcards are allowed in the patterns with '*'. 'foo.*' will exclude all subpackages of 'foo' (but not 'foo'
    itself). You can also pass ``include`` parameters, as specified in the ``PluxConfiguration``.
    """

    def __init__(
        self,
        distribution: setuptools.Distribution,
        exclude: t.Iterable[str] | None = None,
        include: t.Iterable[str] | None = None,
    ):
        self.distribution = distribution
        self.exclude = Filter(exclude or [])
        self.include = Filter(include) if include else MatchAllFilter()

    def find_packages(self) -> t.Iterable[str]:
        if self.distribution.packages is None:
            raise ValueError(
                "No packages found in setuptools distribution. Is your project configured correctly?"
            )
        return self.filter_packages(self.distribution.packages)

    @property
    def path(self) -> str:
        if not self.distribution.package_dir:
            where = "."
        else:
            if self.distribution.package_dir[""]:
                where = self.distribution.package_dir[""]
            else:
                LOG.warning("plux doesn't know how to resolve multiple package_dir directories")
                where = "."
        return where

    def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]:
        return [item for item in packages if not self.exclude(item) and self.include(item)]


class SetuptoolsPackageFinder(PackageFinder):
    """
    Uses setuptools internals to resolve packages.
    """

    def __init__(self, where=".", exclude=(), include=("*",), namespace=True) -> None:
        self.where = where
        self.exclude = exclude
        self.include = include
        self.namespace = namespace

    def find_packages(self) -> t.Iterable[str]:
        if self.namespace:
            return setuptools.find_namespace_packages(self.where, self.exclude, self.include)
        else:
            return setuptools.find_packages(self.where, self.exclude, self.include)

    @property
    def path(self) -> str:
        return self.where


class PackagePathPluginFinder(PluginFromPackageFinder):
    """
    Uses setuptools and pkgutil to find and import modules within a given path and then uses a
    ModuleScanningPluginFinder to resolve the available plugins. The constructor has the same signature as
    setuptools.find_packages(where, exclude, include).
    """

    def __init__(self, where=".", exclude=(), include=("*",), namespace=True) -> None:
        super().__init__(SetuptoolsPackageFinder(where, exclude, include, namespace=namespace))
