"""
core exception analysis engine for raiseattention.

this module provides the main analysis logic for detecting unhandled
exceptions in python code, including transitive propagation tracking
through call chains with try-except handling detection.
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

from .ast_visitor import parse_file, parse_source
from .cache import (
    DependencyCache,
    FileAnalysis,
    FileCache,
    FunctionDict,
    SuppressDict,
    TryExceptDict,
)
from .config import Config
from .external_analyser import (
    CALLABLE_INVOKING_HOFS,
    KEY_CALLABLE_HOFS,
    ExternalAnalyser,
)
from .ignore_parser import IgnoreParseResult, parse_ignore_comments

if TYPE_CHECKING:
    from .env_detector import VenvInfo

logger = logging.getLogger(__name__)


@dataclass
class Diagnostic:
    """
    a diagnostic message for an unhandled exception.

    attributes:
        `file_path: Path`
            file where the issue was found
        `line: int`
            line number (1-indexed) where the call starts
        `column: int`
            column number (0-indexed)
        `message: str`
            human-readable diagnostic message
        `exception_types: list[str]`
            exception types that are unhandled
        `severity: str`
            'error', 'warning', or 'info'
        `end_line: int | None`
            line number where the call ends (for multi-line statements)
    """

    file_path: Path
    line: int
    column: int
    message: str
    exception_types: list[str] = field(default_factory=list)
    severity: str = "error"
    end_line: int | None = None


@dataclass
class AnalysisResult:
    """
    result of analysing a file or project.

    attributes:
        `diagnostics: list[Diagnostic]`
            all diagnostics found
        `files_analysed: list[Path]`
            files that were analysed
        `functions_found: int`
            total number of functions found
        `exceptions_tracked: int`
            total number of exceptions tracked
    """

    diagnostics: list[Diagnostic] = field(default_factory=list)
    files_analysed: list[Path] = field(default_factory=list)
    functions_found: int = 0
    exceptions_tracked: int = 0


class ExceptionAnalyser:
    """
    core exception analysis engine.

    analyses python code to detect unhandled exceptions, including
    transitive propagation through function calls with proper
    try-except handling detection. also analyses external modules
    (stdlib and third-party packages) for exception signatures.

    attributes:
        `config: Config`
            configuration settings
        `file_cache: FileCache`
            file-level analysis cache
        `dependency_cache: DependencyCache`
            external library exception cache
        `external_analyser: ExternalAnalyser`
            analyser for external modules (stdlib/third-party)
        `_file_analyses: dict[Path, FileAnalysis]`
            current analysis results by file
        `_exception_signatures: dict[str, list[str]]`
            computed exception signatures for functions
    """

    config: Config
    file_cache: FileCache
    dependency_cache: DependencyCache
    external_analyser: ExternalAnalyser

    def __init__(
        self,
        config: Config,
        venv_info: VenvInfo | None = None,
    ) -> None:
        """
        initialise the exception analyzer.

        arguments:
            `config: Config`
                configuration settings
            `venv_info: VenvInfo | None`
                virtual environment info for external module analysis
        """
        self.config = config
        self.file_cache = FileCache(config.cache)
        self.dependency_cache = DependencyCache(config.cache)
        self.external_analyser = ExternalAnalyser(
            venv_info,
            config.cache,
            warn_native=config.analysis.warn_native,
            ignore_include=config.analysis.ignore_include,
            ignore_exclude=config.analysis.ignore_exclude,
        )
        self._file_analyses: dict[Path, FileAnalysis] = {}
        self._exception_signatures: dict[str, list[str]] = {}

    def _get_ignore_exceptions(self) -> list[str]:
        """
        get the list of exception types to ignore.

        combines the top-level config.ignore_exceptions with any
        exceptions defined in analysis config for compatibility.

        returns: `list[str]`
            combined list of exception types to ignore
        """
        # get base list from config
        ignored = list(self.config.ignore_exceptions)

        # also check analysis config (for backward compatibility with tests)
        if hasattr(self.config.analysis, "ignore_exceptions"):
            analysis_ignored_raw: object = getattr(self.config.analysis, "ignore_exceptions", [])
            if isinstance(analysis_ignored_raw, list):
                # extend without duplicates
                for item in analysis_ignored_raw:  # pyright: ignore[reportUnknownVariableType]
                    if isinstance(item, str) and item not in ignored:
                        ignored.append(item)

        return ignored

    def analyse_file(self, file_path: str | Path) -> AnalysisResult:
        """
        analyse a single file for unhandled exceptions.

        arguments:
            `file_path: str | Path`
                path to the python file

        returns: `AnalysisResult`
            analysis results with diagnostics
        """
        file_path = Path(file_path).resolve()
        logger.debug("analysing file: %s", file_path)
        result = AnalysisResult()

        # check cache first
        if cached := self.file_cache.get(file_path):
            logger.debug("cache hit for file: %s", file_path)
            self._file_analyses[file_path] = cached
            result.files_analysed.append(file_path)
            result.functions_found = len(cached.functions)

            # recompute diagnostics from cached analysis
            diagnostics = self._compute_diagnostics(file_path, cached)
            result.diagnostics.extend(diagnostics)
            return result

        # parse and analyse
        try:
            source = file_path.read_text(encoding="utf-8")
            visitor = parse_file(file_path)
        except (SyntaxError, FileNotFoundError, OSError) as e:
            result.diagnostics.append(
                Diagnostic(
                    file_path=file_path,
                    line=1,
                    column=0,
                    message=f"failed to analyse file: {e}",
                    exception_types=[],
                    severity="error",
                )
            )
            return result

        # parse ignore comments from source
        ignore_result = parse_ignore_comments(source)

        # report invalid ignore directives
        for invalid in ignore_result.invalid:
            result.diagnostics.append(
                Diagnostic(
                    file_path=file_path,
                    line=invalid.line,
                    column=0,
                    message="invalid ignore comment: must include exception types in brackets (e.g., # raiseattention: ignore[ValueError])",
                    exception_types=[],
                    severity="warning",
                )
            )

        # create file analysis with new structure
        analysis = FileAnalysis(
            file_path=file_path,
            functions={
                name: {
                    "name": func.name,
                    "qualified_name": func.qualified_name,
                    "location": func.location,
                    "raises": [
                        {
                            "type": exc.exception_type,
                            "location": exc.location,
                            "is_re_raise": exc.is_re_raise,
                        }
                        for exc in func.raises
                    ],
                    "calls": [
                        {
                            "func_name": call.func_name,
                            "location": call.location,
                            "end_location": call.end_location,
                            "is_async": call.is_async,
                            "containing_try_blocks": call.containing_try_blocks,
                            "containing_suppress_blocks": call.containing_suppress_blocks,
                            "callable_args": call.callable_args,
                        }
                        for call in func.calls
                    ],
                    "docstring": func.docstring,
                    "is_async": func.is_async,
                    "decorators": func.decorators,
                }
                for name, func in visitor.functions.items()
            },
            imports=visitor.imports,
            timestamp=time.time(),
            try_except_blocks=[
                {
                    "location": try_info.location,
                    "end_location": try_info.end_location,
                    "handled_types": try_info.handled_types,
                    "has_bare_except": try_info.has_bare_except,
                    "has_except_exception": try_info.has_except_exception,
                    "reraises": try_info.reraises,
                }
                for try_info in visitor.try_except_blocks
            ],
            suppress_blocks=[
                {
                    "location": suppress_info.location,
                    "end_location": suppress_info.end_location,
                    "suppressed_types": suppress_info.suppressed_types,
                }
                for suppress_info in visitor.suppress_blocks
            ],
        )

        # store in cache and memory
        self.file_cache.store(file_path, analysis)
        self._file_analyses[file_path] = analysis

        result.files_analysed.append(file_path)
        result.functions_found = len(visitor.functions)
        result.exceptions_tracked = sum(len(func.raises) for func in visitor.functions.values())

        # compute diagnostics with ignore filtering
        diagnostics = self._compute_diagnostics(file_path, analysis, ignore_result)
        result.diagnostics.extend(diagnostics)

        return result

    def analyse_source(
        self,
        source: str,
        file_path: str | Path,
    ) -> AnalysisResult:
        """
        analyse source code directly for unhandled exceptions.

        this method is used by the lsp server to analyse unsaved changes
        without reading from disk.

        arguments:
            `source: str`
                python source code to analyse
            `file_path: str | Path`
                path to the file (for context and error reporting)

        returns: `AnalysisResult`
            analysis results with diagnostics
        """
        file_path = Path(file_path).resolve()
        logger.debug("analysing source for file: %s", file_path)
        result = AnalysisResult()

        # parse and analyse (skip cache since source may differ from disk)
        try:
            visitor = parse_source(source, file_path)
        except SyntaxError as e:
            result.diagnostics.append(
                Diagnostic(
                    file_path=file_path,
                    line=1,
                    column=0,
                    message=f"failed to analyse file: {e}",
                    exception_types=[],
                    severity="error",
                )
            )
            return result

        # parse ignore comments from source
        ignore_result = parse_ignore_comments(source)

        # report invalid ignore directives
        for invalid in ignore_result.invalid:
            result.diagnostics.append(
                Diagnostic(
                    file_path=file_path,
                    line=invalid.line,
                    column=0,
                    message="invalid ignore comment: must include exception types in brackets (e.g., # raiseattention: ignore[ValueError])",
                    exception_types=[],
                    severity="warning",
                )
            )

        # create file analysis with new structure
        analysis = FileAnalysis(
            file_path=file_path,
            functions={
                name: {
                    "name": func.name,
                    "qualified_name": func.qualified_name,
                    "location": func.location,
                    "raises": [
                        {
                            "type": exc.exception_type,
                            "location": exc.location,
                            "is_re_raise": exc.is_re_raise,
                            "message": exc.message,
                        }
                        for exc in func.raises
                    ],
                    "calls": [
                        {
                            "func_name": call.func_name,
                            "location": call.location,
                            "end_location": call.end_location,
                            "is_async": call.is_async,
                            "containing_try_blocks": call.containing_try_blocks,
                            "containing_suppress_blocks": call.containing_suppress_blocks,
                            "callable_args": call.callable_args,
                        }
                        for call in func.calls
                    ],
                    "docstring": func.docstring,
                    "is_async": func.is_async,
                }
                for name, func in visitor.functions.items()
            },
            imports=visitor.imports,
            timestamp=time.time(),
            try_except_blocks=[
                {
                    "location": try_info.location,
                    "end_location": try_info.end_location,
                    "handled_types": try_info.handled_types,
                    "has_bare_except": try_info.has_bare_except,
                    "has_except_exception": try_info.has_except_exception,
                    "reraises": try_info.reraises,
                }
                for try_info in visitor.try_except_blocks
            ],
            suppress_blocks=[
                {
                    "location": suppress_info.location,
                    "end_location": suppress_info.end_location,
                    "suppressed_types": suppress_info.suppressed_types,
                }
                for suppress_info in visitor.suppress_blocks
            ],
        )

        # store analysis
        self._file_analyses[file_path] = analysis
        result.files_analysed.append(file_path)
        result.functions_found = len(analysis.functions)

        # compute diagnostics
        diagnostics = self._compute_diagnostics(file_path, analysis, ignore_result)
        result.diagnostics.extend(diagnostics)

        # count tracked exceptions
        for func in visitor.functions.values():
            result.exceptions_tracked += len(func.raises)

        return result

    def analyse_project(self, project_root: str | Path | None = None) -> AnalysisResult:
        """
        analyse an entire project for unhandled exceptions.

        arguments:
            `project_root: str | Path | None`
                project root directory (default: from config)

        returns: `AnalysisResult`
            combined analysis results
        """
        if project_root is None:
            project_root = self.config.project_root

        project_path = Path(project_root).resolve()
        result = AnalysisResult()

        # find all python files
        python_files = self._find_python_files(project_path)

        # analyse each file
        for file_path in python_files:
            file_result = self.analyse_file(file_path)
            result.diagnostics.extend(file_result.diagnostics)
            result.files_analysed.extend(file_result.files_analysed)
            result.functions_found += file_result.functions_found
            result.exceptions_tracked += file_result.exceptions_tracked

        return result

    def get_function_signature(
        self,
        qualified_name: str,
        context_file: Path | None = None,
        _recursion_stack: set[str] | None = None,
    ) -> list[str]:
        """
        get the exception signature for a function.

        this computes the transitive exception signature, including
        exceptions from called functions. for functions not found in
        the local codebase, it looks up external modules (stdlib and
        third-party packages). exceptions in the ignore_exceptions
        config are filtered out.

        arguments:
            `qualified_name: str`
                fully qualified function name (or simple name if context_file provided)
            `context_file: Path | None`
                file path for resolving simple function names
            `_recursion_stack: set[str] | None`
                internal use for detecting circular calls

        returns: `list[str]`
            list of exception types the function may raise
        """
        logger.debug("computing signature for: %s", qualified_name)

        if qualified_name in self._exception_signatures:
            return self._exception_signatures[qualified_name]

        # initialize recursion stack (use a list to maintain shared state)
        if _recursion_stack is None:
            _recursion_stack = set()

        # find function in analyses
        func_info = None
        resolved_name = qualified_name
        imports: dict[str, str] = {}

        # first try exact match
        for _analysis_path, analysis in self._file_analyses.items():
            if qualified_name in analysis.functions:
                func_info = analysis.functions[qualified_name]
                resolved_name = qualified_name
                imports = analysis.imports
                break

        # if not found and context provided, try to resolve relative to context
        if func_info is None and context_file is not None and context_file in self._file_analyses:
            analysis = self._file_analyses[context_file]
            imports = analysis.imports
            # try with module prefix from the context file
            for name in analysis.functions:
                if name.endswith(f".{qualified_name}") or name == qualified_name:
                    func_info = analysis.functions[name]
                    resolved_name = name
                    break

        # if not found locally, try to resolve from external modules
        # if not found locally, try to resolve from external modules
        # (unless local_only mode is enabled)
        if func_info is None:
            if not self.config.analysis.local_only:
                external_exceptions = self._get_external_function_exceptions(
                    qualified_name, imports
                )
                if external_exceptions:
                    logger.debug(
                        "external function %s may raise: %s",
                        qualified_name,
                        external_exceptions,
                    )
                    # filter out ignored exceptions
                    result = [
                        exc
                        for exc in external_exceptions
                        if exc not in self._get_ignore_exceptions()
                    ]
                    self._exception_signatures[qualified_name] = result
                    return result
            return []

        # detect circular references using resolved name
        if resolved_name in _recursion_stack:
            return []

        # add to recursion stack (modifies the shared set in-place)
        _recursion_stack.add(resolved_name)

        # find the file this function belongs to (for resolving called function names)
        func_file_path = None
        for analysis_path, analysis in self._file_analyses.items():
            if resolved_name in analysis.functions:
                func_file_path = analysis_path
                imports = analysis.imports
                break

        # determine module name for qualifying local exceptions
        module_name = self._get_module_name_from_path(func_file_path)

        # collect directly raised exceptions
        exceptions: set[str] = set()
        for exc in func_info["raises"]:
            exc_type = exc["type"]
            is_re_raise = exc["is_re_raise"]
            if exc_type and not is_re_raise and exc_type not in self._get_ignore_exceptions():
                # qualify local exception types with module name
                qualified_exc = self._qualify_exception_type(exc_type, module_name)
                exceptions.add(qualified_exc)

        if exceptions:
            logger.debug("direct raises in '%s': %s", resolved_name, exceptions)

        # collect from called functions (transitive)
        for call in func_info["calls"]:
            called_func_name = call["func_name"]
            call_location = call["location"]
            callable_args = call["callable_args"]

            if called_func_name:
                logger.debug(
                    "call to '%s' at line %d",
                    called_func_name,
                    call_location[0],
                )
                called_exceptions = self.get_function_signature(
                    called_func_name, func_file_path, _recursion_stack
                )
                if called_exceptions:
                    logger.debug(
                        "called function '%s' may raise: %s",
                        called_func_name,
                        called_exceptions,
                    )
                # called exceptions are already filtered by their own signature computation
                exceptions.update(called_exceptions)

                # check if this is a higher-order function that invokes callable args
                if callable_args and self._is_callable_invoking_hof(called_func_name):
                    logger.debug(
                        "HOF '%s' invokes callable args: %s",
                        called_func_name,
                        callable_args,
                    )
                    for callable_arg in callable_args:
                        # skip lambdas - we can't easily track their exceptions
                        # (they're inline and would need separate AST analysis)
                        if callable_arg == "<lambda>":
                            logger.debug("skipping lambda in HOF call")
                            continue

                        # get the exception signature of the callable argument
                        callable_exceptions = self.get_function_signature(
                            callable_arg, func_file_path, _recursion_stack
                        )
                        if callable_exceptions:
                            logger.debug(
                                "callable arg '%s' passed to HOF '%s' may raise: %s",
                                callable_arg,
                                called_func_name,
                                callable_exceptions,
                            )
                            exceptions.update(callable_exceptions)

        # remove empty strings
        exceptions.discard("")

        # cache result
        result = list(exceptions)
        self._exception_signatures[resolved_name] = result

        logger.debug("final signature for '%s': %s", resolved_name, result)

        # remove from recursion stack to allow other calls
        _recursion_stack.discard(resolved_name)

        return result

    def _get_external_function_exceptions(
        self,
        func_name: str,
        imports: dict[str, str],
    ) -> list[str]:
        """
        get exception signature for a function from external modules.

        this method resolves the function name to an external module
        and returns its exception signature. it handles both direct
        module references (e.g., 'json.loads') and imported names
        (e.g., 'loads' when 'from json import loads' was used).

        arguments:
            `func_name: str`
                function name (may be dotted like 'json.loads')
            `imports: dict[str, str]`
                mapping of imported names to full paths

        returns: `list[str]`
            list of exception types, empty if not found
        """
        # try to resolve the module and function
        resolved = self.external_analyser.resolve_import_to_module(func_name, imports)
        if resolved is None:
            return []

        module_name, function_name = resolved

        # get the exceptions for this function
        return self.external_analyser.get_function_exceptions(module_name, function_name)

    def _is_callable_invoking_hof(self, func_name: str) -> bool:
        """
        check if a function is a known higher-order function that invokes its callable args.

        this method checks against registries of known HOFs like map, filter, sorted, etc.
        that invoke the callable arguments passed to them.

        arguments:
            `func_name: str`
                function name (may be simple like 'map' or qualified like 'functools.reduce')

        returns: `bool`
            true if the function is a known callable-invoking HOF
        """
        # check direct match in callable-invoking HOFs
        if func_name in CALLABLE_INVOKING_HOFS:
            return True

        # check key-callable HOFs (sorted, min, max, etc.)
        if func_name in KEY_CALLABLE_HOFS:
            return True

        # check for builtins that might be called without qualification
        # e.g., 'map' instead of 'builtins.map'
        builtin_hofs = {"map", "filter", "sorted", "min", "max", "reduce"}
        return func_name in builtin_hofs

    def invalidate_file(self, file_path: str | Path) -> None:
        """
        Invalidate cache for a file.

        arguments:
            `file_path: str | Path`
                path to the file
        """
        file_path = Path(file_path).resolve()
        self.file_cache.invalidate(file_path)

        if file_path in self._file_analyses:
            del self._file_analyses[file_path]

        # clear computed signatures (they may depend on this file)
        self._exception_signatures.clear()

    def clear_cache(self) -> None:
        """Clear all caches."""
        self.file_cache.clear()
        self._file_analyses.clear()
        self._exception_signatures.clear()

    def _compute_diagnostics(
        self,
        file_path: Path,
        analysis: FileAnalysis,
        ignore_result: IgnoreParseResult | None = None,
    ) -> list[Diagnostic]:
        """
                Compute diagnostics for a file analysis.

                this method analyses each function's calls and checks whether
        the exceptions from called functions are handled by try-except blocks
        at the call sites. it also considers exception hierarchies where
        catching a parent class handles child exceptions.

        arguments:
                    `file_path: Path`
                        path to the file
                    `analysis: FileAnalysis`
                        analysis results
                    `ignore_result: IgnoreParseResult | None`
                        parsed ignore comments for filtering diagnostics

                returns: `list[Diagnostic]`
                    list of diagnostics
        """
        diagnostics: list[Diagnostic] = []

        # get try-except blocks from analysis
        try_blocks = analysis.try_except_blocks
        suppress_blocks = analysis.suppress_blocks

        # first pass: compute function signatures and find unhandled exceptions at call sites
        # track which functions have unhandled exceptions escaping them
        func_unhandled_exceptions: dict[str, set[str]] = {}
        call_diagnostics: list[Diagnostic] = []

        for func_name, func_info in analysis.functions.items():
            func_location = func_info["location"]
            func_display_name = func_info["name"]
            # note: is_async is available but not used currently
            # _ = func_info["is_async"]

            # get full exception signature for this function (with file context)
            func_exceptions = self.get_function_signature(func_name, file_path)

            # filter out ignored exceptions
            func_exceptions = [
                exc for exc in func_exceptions if exc not in self._get_ignore_exceptions()
            ]

            # track unhandled exceptions for this function
            func_unhandled_exceptions[func_name] = set()

            # check each call in this function
            for call in func_info["calls"]:
                called_func_name = call["func_name"]
                call_location = call["location"]
                containing_tries = call["containing_try_blocks"]
                containing_suppress = call.get("containing_suppress_blocks", [])
                # note: is_async is available but not used currently
                # _ = call["is_async"]

                if not called_func_name:
                    continue

                # get the called function's exception signature (with file context)
                called_exceptions = self.get_function_signature(called_func_name, file_path)

                # filter out ignored exceptions
                called_exceptions = [
                    exc for exc in called_exceptions if exc not in self._get_ignore_exceptions()
                ]

                if not called_exceptions:
                    continue

                # check which exceptions are handled at this call site
                unhandled_exceptions = self._get_unhandled_exceptions(
                    called_exceptions,
                    containing_tries,
                    try_blocks,
                    containing_suppress,
                    suppress_blocks,
                )

                if unhandled_exceptions:
                    # report diagnostic at the call site
                    call_end_location = call.get("end_location")
                    call_diagnostics.append(
                        Diagnostic(
                            file_path=file_path,
                            line=call_location[0],
                            column=call_location[1],
                            end_line=call_end_location[0]
                            if call_end_location
                            else call_location[0],
                            message=(
                                f"call to '{called_func_name}' may raise "
                                f"unhandled exception(s): {', '.join(unhandled_exceptions)}"
                            ),
                            exception_types=unhandled_exceptions,
                            severity="error",
                        )
                    )
                    # track that these exceptions escape from the current function
                    func_unhandled_exceptions[func_name].update(unhandled_exceptions)

        diagnostics.extend(call_diagnostics)

        # filter diagnostics based on ignore directives and docstrings
        filtered_diagnostics: list[Diagnostic] = []
        for d in diagnostics:
            # check ignore directives - use end_line if available (for multi-line statements)
            ignore_line = d.end_line if d.end_line is not None else d.line
            if ignore_result is not None and any(
                ignore_result.should_ignore(ignore_line, exc) for exc in d.exception_types
            ):
                continue

            # check docstrings - filter out exceptions documented in parent docstring
            docstring_line = d.end_line if d.end_line is not None else d.line
            undocumented_exceptions = [
                exc
                for exc in d.exception_types
                if not self._is_exception_documented_in_docstring(docstring_line, exc, analysis)
            ]

            if not undocumented_exceptions:
                # all exceptions are documented in docstring, skip this diagnostic
                continue

            if len(undocumented_exceptions) != len(d.exception_types):
                # some exceptions are documented, update the diagnostic
                filtered_diagnostics.append(
                    Diagnostic(
                        file_path=d.file_path,
                        line=d.line,
                        column=d.column,
                        message=d.message.replace(
                            f": {', '.join(d.exception_types)}",
                            f": {', '.join(undocumented_exceptions)}",
                        ),
                        exception_types=undocumented_exceptions,
                        severity=d.severity,
                    )
                )
            else:
                # no exceptions are documented, keep original diagnostic
                filtered_diagnostics.append(d)

        diagnostics = filtered_diagnostics

        # second pass: check for undocumented exceptions in strict mode
        # only flag functions that have unhandled exceptions escaping them
        if self.config.analysis.strict_mode:
            for func_name, func_info in analysis.functions.items():
                func_location = func_info["location"]
                func_display_name = func_info["name"]

                # get this function's exceptions
                func_exceptions = self.get_function_signature(func_name, file_path)
                func_exceptions = [
                    exc for exc in func_exceptions if exc not in self._get_ignore_exceptions()
                ]

                if not func_exceptions:
                    continue

                # check if this function has unhandled exceptions escaping
                has_unhandled_escaping = len(func_unhandled_exceptions.get(func_name, set())) > 0

                # also check if function has no calls (exceptions might escape to external callers)
                has_no_calls = len(func_info["calls"]) == 0

                if has_unhandled_escaping or has_no_calls:
                    docstring = func_info["docstring"] or ""
                    undocumented = [exc for exc in func_exceptions if exc not in docstring]
                    if undocumented:
                        diagnostics.append(
                            Diagnostic(
                                file_path=file_path,
                                line=func_location[0],
                                column=func_location[1],
                                message=(
                                    f"function '{func_display_name}' may raise "
                                    f"undocumented exceptions: {', '.join(undocumented)}"
                                ),
                                exception_types=undocumented,
                                severity="warning",
                            )
                        )

        return diagnostics

    def _get_unhandled_exceptions(
        self,
        exception_types: list[str],
        containing_try_blocks: list[int],
        try_blocks: list[TryExceptDict],
        containing_suppress_blocks: list[int] | None = None,
        suppress_blocks: list[SuppressDict] | None = None,
    ) -> list[str]:
        """
        determine which exceptions are not handled by the given try-except blocks
        or contextlib.suppress contexts.

        this method checks if each exception type would be caught by any of the
        try-except blocks in scope, or suppressed by any contextlib.suppress
        context managers. it considers exception hierarchies where catching
        'Exception' will catch 'ValueError', etc.

        arguments:
            `exception_types: list[str]`
                list of exception types to check
            `containing_try_blocks: list[int]`
                indices of try-except blocks containing the call
            `try_blocks: list[TryExceptDict]`
                all try-except blocks in the file
            `containing_suppress_blocks: list[int] | None`
                indices of suppress blocks containing the call
            `suppress_blocks: list[SuppressDict] | None`
                all suppress blocks in the file

        returns: `list[str]`
            list of exception types that are NOT handled
        """
        unhandled: list[str] = []

        for exc_type in exception_types:
            is_handled = False

            # check each containing try-except block
            for try_index in containing_try_blocks:
                if try_index >= len(try_blocks):
                    continue

                try_block = try_blocks[try_index]
                handled_types = try_block["handled_types"]
                has_bare_except = try_block["has_bare_except"]
                reraises = try_block["reraises"]

                # bare except catches everything (unless it re-raises)
                if has_bare_except and not reraises:
                    is_handled = True
                    break

                # check if any handler catches this exception type
                for handled_type in handled_types:
                    if self._exception_is_caught(exc_type, handled_type):
                        is_handled = True
                        break

                if is_handled:
                    break

            # if not handled by try-except, check contextlib.suppress
            if not is_handled and containing_suppress_blocks and suppress_blocks:
                for suppress_index in containing_suppress_blocks:
                    if suppress_index >= len(suppress_blocks):
                        continue

                    suppress_block = suppress_blocks[suppress_index]
                    suppressed_types = suppress_block["suppressed_types"]

                    # check if this exception type is suppressed
                    for suppressed_type in suppressed_types:
                        if self._exception_is_caught(exc_type, suppressed_type):
                            is_handled = True
                            break

                    if is_handled:
                        break

            if not is_handled:
                unhandled.append(exc_type)

        return unhandled

    def _exception_is_caught(self, exception_type: str, handler_type: str) -> bool:
        """
        check if an exception type would be caught by a handler type.

        this considers exception hierarchies. for example:
        - ValueError is caught by Exception
        - ValidationError is caught by BusinessError (if ValidationError is a subclass)

        arguments:
            `exception_type: str`
                the exception type being raised
            `handler_type: str`
                the exception type in the except clause

        returns: `bool`
            true if the exception would be caught
        """
        # exact match
        if exception_type == handler_type:
            return True

        # check built-in exception hierarchy
        # we need to handle common cases where parent classes catch children
        return self._is_subclass_of(exception_type, handler_type)

    def _is_subclass_of(self, child_type: str, parent_type: str) -> bool:
        """
                check if one exception type is a subclass of another.

                this method uses knowledge of python's built-in exception hierarchy
        and common patterns. it attempts to resolve actual class relationships
        where possible.

        arguments:
                    `child_type: str`
                        the potential child exception type
                    `parent_type: str`
                        the potential parent exception type

                returns: `bool`
                    true if child_type is a subclass of parent_type
        """
        # handle multi-type except clauses (e.g., except (ValueError, TypeError))
        if "," in parent_type:
            parent_types = [t.strip() for t in parent_type.split(",")]
            return any(self._is_subclass_of(child_type, pt) for pt in parent_types)

        # PossibleNativeException is a sentinel for "some exception from native code"
        # it should be caught by except Exception, except BaseException, or bare except
        if child_type == "PossibleNativeException":
            return parent_type in ("Exception", "BaseException")

        # built-in exception hierarchy mapping
        # these are the most common parent classes
        exception_hierarchy = {
            "BaseException": [
                "SystemExit",
                "KeyboardInterrupt",
                "GeneratorExit",
                "Exception",
            ],
            "Exception": [
                "ArithmeticError",
                "LookupError",
                "AssertionError",
                "AttributeError",
                "BufferError",
                "EOFError",
                "FloatingPointError",
                "OSError",
                "ImportError",
                "ModuleNotFoundError",
                "IndexError",
                "KeyError",
                "MemoryError",
                "NameError",
                "UnboundLocalError",
                "OverflowError",
                "RecursionError",
                "ReferenceError",
                "RuntimeError",
                "NotImplementedError",
                "StopAsyncIteration",
                "StopIteration",
                "SyntaxError",
                "IndentationError",
                "TabError",
                "SystemError",
                "TypeError",
                "ValueError",
                "UnicodeError",
                "UnicodeDecodeError",
                "UnicodeEncodeError",
                "UnicodeTranslateError",
                "Warning",
                "BytesWarning",
                "DeprecationWarning",
                "FutureWarning",
                "ImportWarning",
                "PendingDeprecationWarning",
                "ResourceWarning",
                "RuntimeWarning",
                "SyntaxWarning",
                "UnicodeWarning",
                "UserWarning",
                "BlockingIOError",
                "ChildProcessError",
                "ConnectionError",
                "BrokenPipeError",
                "ConnectionAbortedError",
                "ConnectionRefusedError",
                "ConnectionResetError",
                "FileExistsError",
                "FileNotFoundError",
                "InterruptedError",
                "IsADirectoryError",
                "NotADirectoryError",
                "PermissionError",
                "ProcessLookupError",
                "TimeoutError",
                "ZeroDivisionError",
                "EnvironmentError",
                "IOError",
                "VMSError",
                "WindowsError",
                "ZeroDivisionError",
            ],
            "ArithmeticError": ["FloatingPointError", "OverflowError", "ZeroDivisionError"],
            "LookupError": ["IndexError", "KeyError"],
            "OSError": [
                "BlockingIOError",
                "ChildProcessError",
                "ConnectionError",
                "FileExistsError",
                "FileNotFoundError",
                "InterruptedError",
                "IsADirectoryError",
                "NotADirectoryError",
                "PermissionError",
                "ProcessLookupError",
                "TimeoutError",
                "EnvironmentError",
                "IOError",
                "VMSError",
                "WindowsError",
            ],
            "ConnectionError": [
                "BrokenPipeError",
                "ConnectionAbortedError",
                "ConnectionRefusedError",
                "ConnectionResetError",
            ],
            "ImportError": ["ModuleNotFoundError"],
            "NameError": ["UnboundLocalError"],
            "UnicodeError": [
                "UnicodeDecodeError",
                "UnicodeEncodeError",
                "UnicodeTranslateError",
            ],
            "Warning": [
                "BytesWarning",
                "DeprecationWarning",
                "FutureWarning",
                "ImportWarning",
                "PendingDeprecationWarning",
                "ResourceWarning",
                "RuntimeWarning",
                "SyntaxWarning",
                "UnicodeWarning",
                "UserWarning",
            ],
            "SyntaxError": ["IndentationError"],
            "IndentationError": ["TabError"],
        }

        # check if child is a known subclass of parent
        if parent_type in exception_hierarchy:
            if child_type in exception_hierarchy[parent_type]:
                return True
            # check transitive relationships
            for intermediate in exception_hierarchy[parent_type]:
                if self._is_subclass_of(child_type, intermediate):
                    return True

        # try to resolve using actual python classes if they're built-in
        try:
            child_class_raw: object = eval(child_type)  # noqa: S307  # pyright: ignore[reportAny]
            parent_class_raw: object = eval(parent_type)  # noqa: S307  # pyright: ignore[reportAny]
            if isinstance(child_class_raw, type) and isinstance(parent_class_raw, type):
                return issubclass(child_class_raw, parent_class_raw)
        except (NameError, TypeError, AttributeError):
            pass

        return False

    def _get_module_name_from_path(self, file_path: Path | None) -> str | None:
        """
        derive a python module name from a file path.

        attempts to determine the module name by looking for common
        project structures (e.g., src/ layout, package directories).

        arguments:
            `file_path: Path | None`
                path to the python file

        returns: `str | None`
            module name (e.g., 'tomlantic.tomlantic') or none if cannot be determined
        """
        if file_path is None:
            return None

        file_path = file_path.resolve()

        # try to find the project root by looking for common markers
        project_root = self.config.project_root.resolve()

        try:
            # get path relative to project root
            rel_path = file_path.relative_to(project_root)
        except ValueError:
            # file is not under project root
            return None

        # remove .py extension
        parts = list(rel_path.with_suffix("").parts)

        # handle src/ layout - remove 'src' prefix if present
        if parts and parts[0] == "src":
            parts = parts[1:]

        # join parts with dots to form module name
        if parts:
            module_name = ".".join(parts)
            # handle case where package and module have same name (e.g., tomlantic/tomlantic.py)
            # in this case, the exception is typically imported as tomlantic.ExceptionName
            # not tomlantic.tomlantic.ExceptionName
            # but if full_module_path is enabled, use the full path
            if (
                len(parts) == 2
                and parts[0] == parts[1]
                and not self.config.analysis.full_module_path
            ):
                return parts[0]
            return module_name

        return None

    def _qualify_exception_type(self, exc_type: str, module_name: str | None) -> str:
        """
        qualify an exception type with its module name if needed.

        arguments:
            `exc_type: str`
                the exception type name
            `module_name: str | None`
                the module name to use for qualification

        returns: `str`
            qualified exception type name
        """
        if not exc_type:
            return exc_type

        # get the top-level package name if we have a module name
        top_level_package = module_name.split(".")[0] if module_name else None

        # handle relative references like 'decoder.JSONDecodeError' in module 'json'
        # these should become 'json.JSONDecodeError' (using top-level package)
        if "." in exc_type:
            # extract the exception class name (last part after the dot)
            exc_class_name = exc_type.rsplit(".", 1)[-1]
            # check if this looks like a relative submodule reference
            # (first part is lowercase, suggesting a module name not a class)
            first_part = exc_type.split(".")[0]
            if first_part[0].islower() and top_level_package:
                # it's a relative reference like 'decoder.JSONDecodeError'
                # qualify with the top-level package
                return f"{top_level_package}.{exc_class_name}"
            # otherwise it's already fully qualified
            return exc_type

        # built-in exceptions don't need qualification
        builtin_exceptions = {
            "BaseException",
            "BaseExceptionGroup",
            "Exception",
            "ExceptionGroup",
            "GeneratorExit",
            "KeyboardInterrupt",
            "SystemExit",
            "ArithmeticError",
            "FloatingPointError",
            "OverflowError",
            "ZeroDivisionError",
            "AssertionError",
            "AttributeError",
            "BufferError",
            "EOFError",
            "ImportError",
            "ModuleNotFoundError",
            "LookupError",
            "IndexError",
            "KeyError",
            "MemoryError",
            "NameError",
            "UnboundLocalError",
            "OSError",
            "BlockingIOError",
            "ChildProcessError",
            "ConnectionError",
            "BrokenPipeError",
            "ConnectionAbortedError",
            "ConnectionRefusedError",
            "ConnectionResetError",
            "FileExistsError",
            "FileNotFoundError",
            "InterruptedError",
            "IsADirectoryError",
            "NotADirectoryError",
            "PermissionError",
            "ProcessLookupError",
            "TimeoutError",
            "ReferenceError",
            "RuntimeError",
            "NotImplementedError",
            "PythonFinalizationError",
            "RecursionError",
            "StopAsyncIteration",
            "StopIteration",
            "SyntaxError",
            "IndentationError",
            "TabError",
            "SystemError",
            "TypeError",
            "ValueError",
            "UnicodeError",
            "UnicodeDecodeError",
            "UnicodeEncodeError",
            "UnicodeTranslateError",
            "Warning",
            "BytesWarning",
            "DeprecationWarning",
            "EncodingWarning",
            "FutureWarning",
            "ImportWarning",
            "PendingDeprecationWarning",
            "ResourceWarning",
            "RuntimeWarning",
            "SyntaxWarning",
            "UnicodeWarning",
            "UserWarning",
            "EnvironmentError",
            "IOError",
            "VMSError",
            "WindowsError",
        }

        if exc_type in builtin_exceptions:
            return exc_type

        # qualify with module name if available
        if module_name:
            return f"{module_name}.{exc_type}"

        return exc_type

    def _find_python_files(self, project_path: Path) -> list[Path]:
        """
        find all python files in a project, respecting excludes.

        arguments:
            `project_path: Path`
                project root directory

        returns: `list[Path]`
            list of python file paths
        """
        import fnmatch

        python_files: list[Path] = []

        for py_file in project_path.rglob("*.py"):
            # check exclusions
            rel_path = py_file.relative_to(project_path)
            excluded = False

            for pattern in self.config.exclude:
                # convert glob pattern to match relative path
                if fnmatch.fnmatch(str(rel_path), pattern):
                    excluded = True
                    break
                if fnmatch.fnmatch(str(rel_path.as_posix()), pattern):
                    excluded = True
                    break

            if not excluded:
                python_files.append(py_file)

        return sorted(python_files)

    def _is_exception_documented_in_docstring(
        self,
        line: int,
        exception_type: str,
        analysis: FileAnalysis,
    ) -> bool:
        """
        check if an exception is documented in the closest parent function's docstring.

        looks for "raise" or "raises" followed by the exception class name
        in the docstring of the closest parent function or module.

        arguments:
            `line: int`
                line number where the exception is raised (1-indexed)
            `exception_type: str`
                the exception type (e.g., 'ValueError' or 'module.Exception')
            `analysis: FileAnalysis`
                analysis results containing function info

        returns: `bool`
            true if the exception is documented in the docstring
        """
        # extract exception class name (last part after dot)
        exc_name = exception_type.split(".")[-1]

        # find the closest parent function containing this line
        parent_func: FunctionDict | None = None
        for func_name, func_info in analysis.functions.items():
            func_location = func_info["location"]
            # skip module-level synthetic function
            if func_name.endswith(".<module>"):
                continue
            # check if this line falls within this function
            # (we assume functions start at their location and we don't have end info)
            if func_location[0] <= line:
                # if no parent yet, or this function starts later (closer to the line)
                if parent_func is None or func_location[0] > parent_func["location"][0]:  # type: ignore[index]
                    parent_func = func_info  # type: ignore[assignment]

        if parent_func is None:
            return False

        docstring = parent_func["docstring"]
        if not docstring:
            return False

        doc_lower = docstring.lower()

        # check for "raise" or "raises" in docstring
        has_raises_keyword = "raise" in doc_lower or "raises" in doc_lower

        if not has_raises_keyword:
            return False

        # check if exception name appears in docstring
        return exc_name in docstring
