#!/usr/bin/env python

import os
import configparser
from pathlib import Path
from typing import Dict, Optional, List, Callable, Any
import sys
import logging

from click import Command, Context, MultiCommand, CommandCollection
from .utils import WorkspaceEntryPointMetadata


if sys.version_info < (3, 8):
    from importlib_metadata import entry_points
else:
    from importlib.metadata import entry_points

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib


logger = logging.getLogger(__name__)


ENTRY_POINT_GROUP = 'lab_partner.cli_plugins'


workspace = os.environ['LAB_WORKSPACE']
workspace_data = os.path.join(workspace, 'data')


class WorkspaceCommands(MultiCommand):
    def __init__(self, workspace_path, name: Optional[str] = None, invoke_without_command: bool = False,
                 no_args_is_help: Optional[bool] = None, subcommand_metavar: Optional[str] = None,
                 chain: bool = False, result_callback: Optional[Callable[..., Any]] = None,
                 **attrs: Any) -> None:
        """
        Constructor. Adds workspace_path to superclass

        :param workspace_path: absolute path in the filesystem to the workspace
        :param name:
        :param invoke_without_command:
        :param no_args_is_help:
        :param subcommand_metavar:
        :param chain:
        :param result_callback:
        :param attrs:
        """
        super().__init__(name, invoke_without_command, no_args_is_help, subcommand_metavar, chain, result_callback,
                         **attrs)
        self.workspace_path = workspace_path

    def list_commands(self, ctx: Context) -> List[str]:
        """
        Generates list of subcommand names by scanning the workspace for cli_plugins entry point

        :param ctx: current click context
        :return: list of subcommand names
        """
        found_cli_plugins = self._cli_plugin_search(self.workspace_path)
        plugin_names = []
        if found_cli_plugins:
            plugin_names = list(found_cli_plugins.keys())
            plugin_names.sort()
        return plugin_names

    def get_command(self, ctx: Context, project_name: str) -> Optional[Command]:
        """
        Builds a set of commands for a specific project and returns a click Command

        :param ctx: current click context
        :param project_name: name of the scanned project with CLI an entry point
        :return: Compiled click Command or None if there was an error
        """
        found_cli_plugins = self._cli_plugin_search(self.workspace_path)
        if project_name not in found_cli_plugins:
            return None

        plugin_metadata = found_cli_plugins[project_name]
        plugin_script_path = self._absolute_script_path(plugin_metadata.project_path, plugin_metadata.script_name)
        if not plugin_script_path:
            logger.error(
                f'Error: project "{project_name}" supplies a CLI plugin but script "{plugin_metadata.script_name}" could not be loaded')
            return None
        ctx.meta.update(plugin_metadata.__dict__)
        return self.compile_command(plugin_script_path, plugin_metadata.function_name)

    @staticmethod
    def compile_command(plugin_script_path, function_name) -> Optional[Command]:
        """
        Compiles click Command from the specified script path. This allows discovered workspace
        commands to be used on the fly

        :param plugin_script_path: absolute path to the script file to compile
        :param function_name: name of the function to pull the compiled command out of scope
        :return: Compiled click Command or None if there was an error
        """
        with open(plugin_script_path) as f:
            code = compile(f.read(), plugin_script_path, mode='exec')
            try:
                ns = {}
                eval(code, ns, ns)
                return ns[function_name]
            except Exception:
                logger.exception(f'Error compiling {plugin_script_path}')
                return None

    @staticmethod
    def _cli_plugin_search(workspace_path: str) -> Dict[str, WorkspaceEntryPointMetadata]:
        """
        Searches the workspace looking for pyproject.toml files with a cli.cli_plugins
        entry point

        :param workspace_path: path of the workspace to search
        :return: a dictionary of each entry point found
        """
        cli_plugins = {}
        for project_name in os.listdir(workspace_path):
            project_path = os.path.join(workspace_path, project_name)
            project_data_path = os.path.join(project_path, 'data')

            project_toml_path = os.path.join(project_path, 'pyproject.toml')
            if not os.path.exists(project_toml_path):
                continue

            with open(project_toml_path, "rb") as f:
                project_toml = tomllib.load(f)
                if 'project' not in project_toml \
                        or 'entry-points' not in project_toml['project'] \
                        or ENTRY_POINT_GROUP not in project_toml['project']['entry-points']:
                    logger.info(f'the project {project_name} does not have entrypoint')
                    continue

                if 'version' not in project_toml['project']:
                    logger.warning(f'The "version" attribute is required in pyproject.toml, skipping project {project_name}')
                    continue

                project_version = project_toml['project']['version']
                found_entry_points = project_toml['project']['entry-points'][ENTRY_POINT_GROUP]
                for entry_point_name, entry_point_value in found_entry_points.items():
                    entry_point_metadata = WorkspaceEntryPointMetadata.build(entry_point_name, entry_point_value, project_name, project_version, project_path, project_data_path)
                    cli_plugins[entry_point_metadata.name] = entry_point_metadata
        return cli_plugins

    @staticmethod
    def _absolute_script_path(project_path: str, script_name: str) -> Optional[str]:
        """
        Walks the filesystem below the project path to resolve the absolute path of
        the script specified in the entry point

        :param project_path: absolute path to the project
        :param script_name: name of the script somewhere in the project path
        :return: absolute path of the project
        """
        script_filename = f'{script_name}.py'
        exclude = {'.git', '.idea', '.ipynb_checkpoints', '__pycache__', '.pytest_cache', 'data'}
        for root, dirs, files in os.walk(project_path):
            dirs[:] = [d for d in dirs if d not in exclude]
            absolute_script_path = os.path.join(root, script_filename)
            if script_filename in files and os.path.exists(absolute_script_path):
                return absolute_script_path


class CliPluginCommands(MultiCommand):
    """
    Searches installed python packages for CLI entry points
    and loads the specified functions as click Commands
    """

    def list_commands(self, ctx: Context) -> List[str]:
        """
        Generates list of subcommand names from installed packages for cli_plugins entry point

        :param ctx: current click context
        :return: list of subcommand names
        """
        plugin_names = []
        eps = entry_points().get(ENTRY_POINT_GROUP, [])
        for e in eps:
            plugin_names.append(e.name)
        return plugin_names

    def get_command(self, ctx: Context, plugin_name: str) -> Optional[Command]:
        """
        Builds a set of commands for an installed package with a CLI entry point
        and returns a click Command

        :param ctx: current click context
        :param plugin_name: name of the cli plugin in the installed package
        :return: Compiled click Command or None if there was an error
        """
        eps = entry_points().get(ENTRY_POINT_GROUP, [])
        for e in eps:
            if e.name == plugin_name:
                return e.load()


plugin_commands = CliPluginCommands()
workspace_commands = WorkspaceCommands(workspace_path=workspace)
cli = CommandCollection(sources=[workspace_commands, plugin_commands])


if __name__ == '__main__':
    cli()
