"""Utilities parsing and analyzing Python code."""

from __future__ import annotations

import tokenize
from importlib import import_module
from os import path
from typing import TYPE_CHECKING, Any

from sphinx.errors import PycodeError
from sphinx.pycode.parser import Parser

if TYPE_CHECKING:
    from inspect import Signature


class ModuleAnalyzer:
    annotations: dict[tuple[str, str], str]
    attr_docs: dict[tuple[str, str], list[str]]
    finals: list[str]
    overloads: dict[str, list[Signature]]
    tagorder: dict[str, int]
    tags: dict[str, tuple[str, int, int]]

    # cache for analyzer objects -- caches both by module and file name
    cache: dict[tuple[str, str], Any] = {}

    @staticmethod
    def get_module_source(modname: str) -> tuple[str | None, str | None]:
        """Try to find the source code for a module.

        Returns ('filename', 'source'). One of it can be None if
        no filename or source found
        """
        try:
            mod = import_module(modname)
        except Exception as err:
            raise PycodeError('error importing %r' % modname, err) from err
        loader = getattr(mod, '__loader__', None)
        filename = getattr(mod, '__file__', None)
        if loader and getattr(loader, 'get_source', None):
            # prefer Native loader, as it respects #coding directive
            try:
                source = loader.get_source(modname)
                if source:
                    # no exception and not None - it must be module source
                    return filename, source
            except ImportError:
                pass  # Try other "source-mining" methods
        if filename is None and loader and getattr(loader, 'get_filename', None):
            # have loader, but no filename
            try:
                filename = loader.get_filename(modname)
            except ImportError as err:
                raise PycodeError('error getting filename for %r' % modname, err) from err
        if filename is None:
            # all methods for getting filename failed, so raise...
            raise PycodeError('no source found for module %r' % modname)
        filename = path.normpath(path.abspath(filename))
        if filename.lower().endswith(('.pyo', '.pyc')):
            filename = filename[:-1]
            if not path.isfile(filename) and path.isfile(filename + 'w'):
                filename += 'w'
        elif not filename.lower().endswith(('.py', '.pyw')):
            raise PycodeError('source is not a .py file: %r' % filename)

        if not path.isfile(filename):
            raise PycodeError('source file is not present: %r' % filename)
        return filename, None

    @classmethod
    def for_string(
        cls: type[ModuleAnalyzer], string: str, modname: str, srcname: str = '<string>',
    ) -> ModuleAnalyzer:
        return cls(string, modname, srcname)

    @classmethod
    def for_file(cls: type[ModuleAnalyzer], filename: str, modname: str) -> ModuleAnalyzer:
        if ('file', filename) in cls.cache:
            return cls.cache['file', filename]
        try:
            with tokenize.open(filename) as f:
                string = f.read()
            obj = cls(string, modname, filename)
            cls.cache['file', filename] = obj
        except Exception as err:
            raise PycodeError('error opening %r' % filename, err) from err
        return obj

    @classmethod
    def for_module(cls: type[ModuleAnalyzer], modname: str) -> ModuleAnalyzer:
        if ('module', modname) in cls.cache:
            entry = cls.cache['module', modname]
            if isinstance(entry, PycodeError):
                raise entry
            return entry

        try:
            filename, source = cls.get_module_source(modname)
            if source is not None:
                obj = cls.for_string(source, modname, filename or '<string>')
            elif filename is not None:
                obj = cls.for_file(filename, modname)
        except PycodeError as err:
            cls.cache['module', modname] = err
            raise
        cls.cache['module', modname] = obj
        return obj

    def __init__(self, source: str, modname: str, srcname: str) -> None:
        self.modname = modname  # name of the module
        self.srcname = srcname  # name of the source file

        # cache the source code as well
        self.code = source

        self._analyzed = False

    def analyze(self) -> None:
        """Analyze the source code."""
        if self._analyzed:
            return

        try:
            parser = Parser(self.code)
            parser.parse()

            self.attr_docs = {}
            for (scope, comment) in parser.comments.items():
                if comment:
                    self.attr_docs[scope] = [*comment.splitlines(), '']
                else:
                    self.attr_docs[scope] = ['']

            self.annotations = parser.annotations
            self.finals = parser.finals
            self.overloads = parser.overloads
            self.tags = parser.definitions
            self.tagorder = parser.deforders
            self._analyzed = True
        except Exception as exc:
            msg = f'parsing {self.srcname!r} failed: {exc!r}'
            raise PycodeError(msg) from exc

    def find_attr_docs(self) -> dict[tuple[str, str], list[str]]:
        """Find class and module-level attributes and their documentation."""
        self.analyze()
        return self.attr_docs

    def find_tags(self) -> dict[str, tuple[str, int, int]]:
        """Find class, function and method definitions and their location."""
        self.analyze()
        return self.tags
