"""Run numpydoc validation on contents of a file."""

import ast
import configparser
import os
import re
import sys

try:
    import tomllib
except ImportError:
    import tomli as tomllib

from pathlib import Path
from typing import Any, Dict, List, Tuple, Union

from .. import docscrape, validate
from .utils import find_project_root


class AstValidator(validate.Validator):
    """
    Overrides the :class:`Validator` to work entirely with the AST.

    Parameters
    ----------
    ast_node : ast.AST
        The node under inspection.
    filename : os.PathLike
        The file where the node is defined.
    obj_name : str
        A name for the node to use in the listing of issues for the file as a whole.
    """

    def __init__(
        self,
        *,
        ast_node: ast.AST,
        filename: os.PathLike,
        obj_name: str,
        ancestry: list[ast.AST],
    ) -> None:
        self.node: ast.AST = ast_node
        self.raw_doc: str = ast.get_docstring(self.node, clean=False) or ""
        self.clean_doc: str = ast.get_docstring(self.node, clean=True)
        self.doc: docscrape.NumpyDocString = docscrape.NumpyDocString(self.raw_doc)

        self._source_file: os.PathLike = Path(filename).resolve()
        self._name: str = obj_name

        self.is_class: bool = isinstance(ast_node, ast.ClassDef)
        self.is_module: bool = isinstance(ast_node, ast.Module)

        self.ancestry: list[ast.AST] = ancestry

    @staticmethod
    def _load_obj(name):
        raise NotImplementedError("AstValidator does not support this method.")

    @property
    def name(self) -> str:
        return self._name

    @property
    def is_function_or_method(self) -> bool:
        return isinstance(self.node, ast.FunctionDef | ast.AsyncFunctionDef)

    @property
    def is_mod(self) -> bool:
        return self.is_module

    @property
    def is_generator_function(self) -> bool:
        if not self.is_function_or_method:
            return False
        for child in ast.iter_child_nodes(self.node):
            if isinstance(child, ast.Expr) and isinstance(child.value, ast.Yield):
                return True
        return False

    @property
    def type(self) -> str:
        if self.is_function_or_method:
            return "function"
        if self.is_class:
            return "type"
        if self.is_module:
            return "module"
        raise ValueError("Unknown type.")

    @property
    def source_file_name(self) -> str:
        return self._source_file

    @property
    def source_file_def_line(self) -> int:
        return self.node.lineno if not self.is_module else 1

    @property
    def signature_parameters(self) -> Tuple[str]:
        def extract_signature(node, parent):
            args_node = node.args
            params = []
            for arg_type in ["posonlyargs", "args", "vararg", "kwonlyargs", "kwarg"]:
                entries = getattr(args_node, arg_type)
                if arg_type in ["vararg", "kwarg"]:
                    if entries and arg_type == "vararg":
                        params.append(f"*{entries.arg}")
                    if entries and arg_type == "kwarg":
                        params.append(f"**{entries.arg}")
                else:
                    params.extend([arg.arg for arg in entries])
            params = tuple(params)
            if (
                params
                and params[0] in {"self", "cls"}
                and isinstance(parent, ast.ClassDef)
            ):
                return params[1:]
            return params

        params = tuple()
        if self.is_function_or_method:
            params = extract_signature(self.node, self.ancestry[-1])
        elif self.is_class:
            for child in self.node.body:
                if isinstance(child, ast.FunctionDef) and child.name == "__init__":
                    params = extract_signature(child, self.node)
        return params

    @property
    def method_source(self) -> str:
        with open(self.source_file_name) as file:
            source = ast.get_source_segment(file.read(), self.node)
        return source


class DocstringVisitor(ast.NodeVisitor):
    """
    Visits nodes in the AST from a given module and reporting numpydoc issues.

    Parameters
    ----------
    filepath : str
        The absolute or relative path to the file to inspect.
    config : dict
        Configuration options for reviewing flagged issues.
    """

    def __init__(
        self,
        filepath: str,
        config: dict,
    ) -> None:
        self.config: dict = config
        self.filepath: str = filepath
        self.module_name: str = Path(self.filepath).stem
        self.stack: list[ast.AST] = []
        self.findings: list = []

    @property
    def node_name(self) -> str:
        """
        Get the full name of the current node in the stack.

        Returns
        -------
        str
            The full name of the current node in the stack.
        """
        return ".".join(
            [getattr(node, "name", self.module_name) for node in self.stack]
        )

    def _ignore_issue(self, node: ast.AST, check: str) -> bool:
        """
        Check whether the issue should be ignored.

        Parameters
        ----------
        node : ast.AST
            The node under inspection.
        check : str
            The code for the check being evaluated.

        Return
        ------
        bool
            Whether the issue should be excluded from the report.
        """
        if check not in self.config["checks"]:
            return True

        if self.config["overrides"]:
            try:
                pattern = self.config["overrides"][check]
                if re.search(pattern, ast.get_docstring(node)) is not None:
                    return True
            except KeyError:
                pass

        return False

    def _get_numpydoc_issues(self, node: ast.AST) -> None:
        """
        Get numpydoc validation issues.

        Parameters
        ----------
        node : ast.AST
            The node under inspection.
        """
        name = self.node_name
        report = validate.validate(
            name,
            AstValidator,
            ast_node=node,
            filename=self.filepath,
            ancestry=self.stack[:-1],
        )
        self.findings.extend(
            [
                [f"{self.filepath}:{report['file_line']}", name, check, description]
                for check, description in report["errors"]
                if not self._ignore_issue(node, check)
            ]
        )

    def visit(self, node: ast.AST) -> None:
        """
        Visit a node in the AST and report on numpydoc validation issues.

        Parameters
        ----------
        node : ast.AST
            The node to visit.
        """
        if isinstance(
            node, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
        ):
            self.stack.append(node)

            if not (
                self.config["exclude"]
                and re.search(self.config["exclude"], self.node_name)
            ):
                self._get_numpydoc_issues(node)

            self.generic_visit(node)
            _ = self.stack.pop()


def parse_config(dir_path: os.PathLike = None) -> dict:
    """
    Parse config information from a pyproject.toml or setup.cfg file.

    This function looks in the provided directory path first for a
    pyproject.toml file. If it finds that, it won't look for a setup.cfg
    file.

    Parameters
    ----------
    dir_path : os.PathLike
        An absolute or relative path to a directory containing
        either a pyproject.toml file specifying a
        [tool.numpydoc_validation] section or a setup.cfg file
        specifying a [tool:numpydoc_validation] section.
        For example, ``~/my_project``. If not provided, the hook
        will try to find the project root directory.

    Returns
    -------
    dict
        Config options for the numpydoc validation hook.
    """
    options = {
        "checks": {"all"},
        "exclude": set(),
        "overrides": {},
        "exclude_files": set(),
    }
    dir_path = Path(dir_path).expanduser().resolve()

    toml_path = dir_path / "pyproject.toml"
    cfg_path = dir_path / "setup.cfg"

    def compile_regex(expressions):
        return (
            re.compile(r"|".join(exp for exp in expressions if exp))
            if expressions
            else None
        )

    def extract_check_overrides(options, config_items):
        for option, value in config_items:
            if option.startswith("override_"):
                _, check = option.split("_")
                if value:
                    options["overrides"][check.upper()] = compile_regex(value)

    if toml_path.is_file():
        with open(toml_path, "rb") as toml_file:
            pyproject_toml = tomllib.load(toml_file)
            config = pyproject_toml.get("tool", {}).get("numpydoc_validation", {})
            options["checks"] = set(config.get("checks", options["checks"]))

            global_exclusions = config.get("exclude", options["exclude"])
            options["exclude"] = set(
                global_exclusions
                if not isinstance(global_exclusions, str)
                else [global_exclusions]
            )

            file_exclusions = config.get("exclude_files", options["exclude_files"])
            options["exclude_files"] = set(
                file_exclusions
                if not isinstance(file_exclusions, str)
                else [file_exclusions]
            )

            extract_check_overrides(options, config.items())

    elif cfg_path.is_file():
        config = configparser.ConfigParser()
        config.read(cfg_path)
        numpydoc_validation_config_section = "tool:numpydoc_validation"
        try:
            try:
                options["checks"] = set(
                    config.get(numpydoc_validation_config_section, "checks")
                    .rstrip(",")
                    .split(",")
                    or options["checks"]
                )
            except configparser.NoOptionError:
                pass
            try:
                options["exclude"] = set(
                    config.get(numpydoc_validation_config_section, "exclude")
                    .rstrip(",")
                    .split(",")
                    or options["exclude"]
                )
            except configparser.NoOptionError:
                pass

            try:
                options["exclude_files"] = set(
                    config.get(numpydoc_validation_config_section, "exclude_files")
                    .rstrip(",")
                    .split(",")
                    or options["exclude_files"]
                )
            except configparser.NoOptionError:
                pass

            extract_check_overrides(
                options, config.items(numpydoc_validation_config_section)
            )

        except configparser.NoSectionError:
            pass

    options["checks"] = validate.get_validation_checks(options["checks"])
    options["exclude"] = compile_regex(options["exclude"])
    options["exclude_files"] = compile_regex(options["exclude_files"])
    return options


def process_file(filepath: os.PathLike, config: dict) -> "list[list[str]]":
    """
    Run numpydoc validation on a file.

    Parameters
    ----------
    filepath : path-like
        The absolute or relative path to the file to inspect.
    config : dict
        Configuration options for reviewing flagged issues.

    Returns
    -------
    list[list[str]]
        A list of [name, check, description] lists for flagged issues.
    """
    with open(filepath) as file:
        module_node = ast.parse(file.read(), filepath)

    docstring_visitor = DocstringVisitor(filepath=str(filepath), config=config)
    docstring_visitor.visit(module_node)

    return docstring_visitor.findings


def run_hook(
    files: List[str],
    *,
    config: Dict[str, Any] | None = None,
    ignore: List[str] | None = None,
) -> int:
    """
    Run the numpydoc validation hook.

    Parameters
    ----------
    files : list[str]
        The absolute or relative paths to the files to inspect.
    config : Union[dict[str, Any], None], optional
        Configuration options for reviewing flagged issues.
    ignore : Union[list[str], None], optional
        Checks to ignore in the results.

    Returns
    -------
    int
        The return status: 1 if issues were found, 0 otherwise.
    """
    project_root, _ = find_project_root(files)
    config_options = parse_config(config or project_root)
    config_options["checks"] -= set(ignore or [])
    exclude_re = config_options["exclude_files"]

    findings = False
    for file in files:
        if exclude_re and exclude_re.match(file):
            continue
        if file_issues := process_file(file, config_options):
            findings = True

            for line, obj, check, description in file_issues:
                print(f"\n{line}: {check} {description}", file=sys.stderr)

    return int(findings)
