#!/usr/bin/env python3
"""List imports defined in Python source files."""

import ast
import contextlib
import importlib
import re
import sys
from pathlib import Path
from typing import Dict, List

import tomli
from libcli.basecli import BaseCLI

import srcs


class Analyzer(ast.NodeVisitor):
    """Collect import statements from Python source."""

    def __init__(self) -> None:
        """Initialize import stats."""

        self.stats: Dict[str, List[str]] = {"import": [], "from": []}

    def visit_Import(self, node: ast.Import) -> None:  # noqa: PLC103
        """Visit import statement."""

        for alias in node.names:
            self.stats["import"].append(alias.name.split(".", 1)[0])
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:  # noqa: PLC103
        """Visit from-import statement."""

        if node.module:
            self.stats["import"].append(node.module.split(".", 1)[0])
        # for alias in node.names:
        #     self.stats["from"].append(alias.name)
        self.generic_visit(node)

    # def report(self) -> None:
    #     print(self.stats)

    def modnames(self) -> List[str]:
        """Return sorted unique module names."""

        return sorted(set(self.stats["import"]))


class Module:  # noqa: PLR903
    """Classify a module as stdlib or local."""

    def __init__(self, modname: str) -> None:
        """Initialize module classification."""

        self.modname: str = modname
        self.modtype: str = "local"

        for mname in self.modname, "_" + self.modname:
            if mname in sys.builtin_module_names:
                self.modtype = "builtin"
                return

        try:
            module = importlib.import_module(self.modname)
            assert module.__file__ is not None
            if (
                module.__file__.startswith("/usr/lib/python")
                or "/.asdf/installs/python/" in module.__file__
            ):
                self.modtype = "stdlib"
        except ModuleNotFoundError:
            self.modtype = "local"


class Dependencies:
    """Track project dependencies from pyproject.toml."""

    def __init__(self, path: Path) -> None:
        """Load dependencies from `path` (`pyproject.toml`)."""

        # Parse package names from rules.
        # "-e file:///home/russel/dev/libcurses#egg=libcurses",
        self._pat1 = re.compile("#egg=(.+)$")
        # "argcomplete>=1.12.3",
        self._pat2 = re.compile(r"^([\w\._-]+)", re.IGNORECASE)
        # (_pat1, _pat2... order matters)

        self._deps: Dict[str, str] = {}  # k=modname, v=("prod"|"dev")

        toml = tomli.loads(path.read_text(encoding="utf-8"))

        rules = None
        with contextlib.suppress(KeyError):
            rules = toml["project"]["dependencies"]
        if rules:
            self._load_deps("prod", rules)

        rules = None
        with contextlib.suppress(KeyError):
            rules = toml["tool"]["pdm"]["dev-dependencies"]["dev"]
        if rules:
            self._load_deps("dev", rules)

    def defines(self, modname: str) -> bool:
        """Return True if modname is a declared dependency."""

        return modname in self._deps

    @property
    def prod_deps(self) -> List[str]:
        """Return list of production dependency names."""

        return [k for k, rule_type in self._deps.items() if rule_type == "prod"]

    def _load_deps(self, rule_type: str, rules: List[str]) -> None:
        for rule in rules:
            # Parse package name from rule.
            if not ((m := self._pat1.search(rule)) or (m := self._pat2.search(rule))):
                print(f"Can't parse {rule!r}")
                continue
            dep = m.group(1)
            # if self.options.verbose:
            #     print(f"match {dep!r}")
            self._deps[dep] = rule_type

        # if self.options.verbose:
        #     print("deps", deps)


class LsiCLI(BaseCLI):
    """Command line interface."""

    modules: Dict[str, Module] = {}  # cache by modname

    def init_parser(self) -> None:
        """Initialize argument parser."""

        self.parser = self.ArgumentParser(
            description="List imports defined in python source `FILE`s.",
        )

    def add_arguments(self) -> None:
        """Add arguments to parser."""

        arg = self.parser.add_argument(
            "--check-deps",
            action="store_true",
            help="check imports against dependencies file",
        )
        self.add_default_to_help(arg)

        arg = self.parser.add_argument(
            "--deps",
            type=Path,
            default=Path("./pyproject.toml"),
            help="dependencies file",
        )
        self.add_default_to_help(arg)

        self.parser.add_argument(
            "FILE",
            nargs="*",
            type=Path,
            help=str(
                "python source file(s) to analyze. "
                "When no `FILE` arguments are given, "
                "files found by the `srcs` module are analyzed"
            ),
        )

    def main(self) -> None:
        """Command line interface entry point (method)."""

        files = self.options.FILE if len(self.options.FILE) else srcs.srcs()
        modules = self._get_modules_from_files(files)

        if self.options.check_deps:
            if not self.options.deps.exists():
                self.parser.error(f"Can't find dependencies file `{self.options.deps}`")
            self._check_deps(modules)
        else:
            self._report(modules)

    # public
    # -------------------------------------------------------------------------------
    # private

    def _get_modules_from_files(self, files: List[str]) -> List[Module]:
        modules_dict: Dict[str, Module] = {}
        for file in files:
            for module in self._get_modules_from_file(Path(file)):
                modules_dict[module.modname] = module

        sorted_modules = sorted(modules_dict.values(), key=lambda x: x.modname)

        if self.options.verbose:
            self.debug("-" * 80)

        return sorted_modules

    def _get_modules_from_file(self, path: Path) -> List[Module]:
        if self.options.verbose:
            self.debug(f" {path} ".center(80, "-"))

        if not path.is_file():
            self.error(f"Not a file `{path}`")
            return []

        modules_dict: Dict[str, Module] = {}
        with open(path, "r", encoding="utf-8") as source:
            try:
                tree = ast.parse(source.read())
            except SyntaxError as err:
                self.error(f"Can't parse `{path}`; {err}")
                return []
            analyzer = Analyzer()
            analyzer.visit(tree)
            for module in analyzer.modnames():
                if module not in modules_dict:
                    modules_dict[module] = self._get_module(module)

        sorted_modules = sorted(modules_dict.values(), key=lambda x: x.modname)

        if self.options.verbose:
            for mod in sorted_modules:
                self.debug(str(mod.__dict__))

        return sorted_modules

    def _get_module(self, modname: str) -> Module:
        if (module := self.modules.get(modname)) is None:
            module = self.modules[modname] = Module(modname)
        return module

    def _check_deps(self, modules: List[Module]) -> None:
        """Check `pyprojects.toml` for modules that should? be listed as dependencies."""

        deps = Dependencies(self.options.deps)

        local_modnames = [
            x.modname for x in modules if "." not in x.modname and x.modtype == "local"
        ]

        for modname in local_modnames:
            if deps.defines(modname):
                self.info(f"import has dependency: `{modname}`.")
            else:
                self.error(f"import `{modname}` missing dependency in `{self.options.deps}`.")
                # print(
                #     f"dep-check: ERROR: import `{modname}`",
                #     f"missing dependency in `{self.options.deps}`.",
                # )

        for dep in deps.prod_deps:
            module = self._get_module(dep)
            if module.modtype != "local":
                self.debug(f"skipping {module.__dict__}")
                continue
            if module.modname in local_modnames:
                self.info(f"dependency has import: `{dep}`.")
            else:
                self.error(f"no import for dependency `{dep}`.")

    @staticmethod
    def _report(modules: List[Module]) -> None:
        for module in modules:
            print(module.__dict__)


def main() -> None:
    """CLI entry point."""
    LsiCLI().main()


if __name__ == "__main__":
    main()
