"""Jedi-based test-to-function linking."""

from __future__ import annotations

import logging
import os
import re
import sys
from collections import defaultdict
from typing import TYPE_CHECKING

import attrs

from .models import (
    CodePosition,
    FunctionCalledInTest,
    TestsInFile,
    TestType,
)

if TYPE_CHECKING:
    from pathlib import Path

    from .._model import FunctionToOptimize

log = logging.getLogger(__name__)

PYTEST_PARAMETERIZED_TEST_NAME_REGEX = re.compile(
    r"[\[\]]",
)
UNITTEST_PARAMETERIZED_TEST_NAME_REGEX = re.compile(
    r"^test_\w+_\d+(?:_\w+)*",
)
UNITTEST_STRIP_NUMBERED_SUFFIX_REGEX = re.compile(
    r"_\d+(?:_\w+)*$",
)
FUNCTION_NAME_REGEX = re.compile(
    r"([^.]+)\.([a-zA-Z0-9_]+)$",
)


@attrs.frozen
class TestFunction:
    """A test function discovered within a test file."""

    __test__ = False

    function_name: str
    test_class: str | None
    parameters: str | None
    test_type: TestType


def module_name_from_file_path(
    file_path: Path,
    project_root_path: Path,
) -> str:
    """Convert a file path to a dotted module name."""
    relative_path = file_path.resolve().relative_to(
        project_root_path.resolve(),
    )
    return relative_path.with_suffix("").as_posix().replace("/", ".")


def add_test_entries(  # noqa: PLR0913
    function_to_test_map: dict[str, set[FunctionCalledInTest]],
    qualified_name: str,
    test_functions: list[TestFunction],
    test_file: Path,
    line_no: int,
    col_no: int,
) -> None:
    """Add *FunctionCalledInTest* entries for each test."""
    for test_func in test_functions:
        if test_func.parameters is not None:
            scope_test_function = (
                f"{test_func.function_name}[{test_func.parameters}]"
            )
        else:
            scope_test_function = test_func.function_name

        function_to_test_map.setdefault(qualified_name, set()).add(
            FunctionCalledInTest(
                tests_in_file=TestsInFile(
                    test_file=test_file,
                    test_class=test_func.test_class,
                    test_function=scope_test_function,
                    test_type=test_func.test_type,
                ),
                position=CodePosition(
                    line_no=line_no,
                    col_no=col_no,
                ),
            ),
        )


def process_test_files(  # noqa: C901, PLR0912, PLR0915
    file_to_test_map: dict[Path, list[TestsInFile]],
    project_root: Path,
    functions_to_optimize: (list[FunctionToOptimize] | None) = None,
) -> dict[str, set[FunctionCalledInTest]]:
    """Link test functions to the source functions they call.

    Uses Jedi to resolve references inside test files back to
    their definitions, building a mapping from qualified
    function names to the set of tests that call them.
    """
    import jedi  # type: ignore[import-untyped]  # noqa: PLC0415

    from .filtering import filter_test_files_by_imports  # noqa: PLC0415

    if functions_to_optimize:
        target_function_names = {
            func.qualified_name for func in functions_to_optimize
        }
        file_to_test_map = filter_test_files_by_imports(
            file_to_test_map, target_function_names
        )

    function_to_test_map: dict[str, set[FunctionCalledInTest]] = defaultdict(
        set
    )
    functions_to_optimize_by_name: dict[str, list[FunctionToOptimize]] = (
        defaultdict(list)
    )
    if functions_to_optimize:
        for func in functions_to_optimize:
            functions_to_optimize_by_name[func.function_name].append(func)

    # Resolve project root to avoid symlink mismatches (e.g.
    # /var/folders vs /private/var/folders on macOS).
    project_root = project_root.resolve()

    # Set up sys_path for Jedi to resolve imports correctly
    jedi_sys_path = list(sys.path)
    if str(project_root) not in jedi_sys_path:
        jedi_sys_path.insert(0, str(project_root))
    parent_path = project_root.parent
    if str(parent_path) not in jedi_sys_path:
        jedi_sys_path.insert(0, str(parent_path))

    jedi_project = jedi.Project(path=project_root, sys_path=jedi_sys_path)

    for test_file, functions in file_to_test_map.items():
        try:
            script = jedi.Script(path=test_file, project=jedi_project)
            test_functions: set[TestFunction] = set()

            all_names = script.get_names(all_scopes=True, references=True)
            all_names_top = script.get_names(all_scopes=True)

            top_level_functions = {
                name.name: name
                for name in all_names_top
                if name.type == "function"
            }

        except Exception:  # noqa: BLE001
            log.debug(
                "Failed to get jedi script for %s",
                test_file,
                exc_info=True,
            )
            continue

        for function in functions:
            if "[" in function.test_function:
                parts = PYTEST_PARAMETERIZED_TEST_NAME_REGEX.split(
                    function.test_function
                )
                function_name = parts[0]
                parameters = parts[1]
                if function_name in top_level_functions:
                    test_functions.add(
                        TestFunction(
                            function_name,
                            function.test_class,
                            parameters,
                            function.test_type,
                        ),
                    )
            elif function.test_function in top_level_functions:
                test_functions.add(
                    TestFunction(
                        function.test_function,
                        function.test_class,
                        None,
                        function.test_type,
                    ),
                )
            elif UNITTEST_PARAMETERIZED_TEST_NAME_REGEX.match(
                function.test_function
            ):
                base_name = UNITTEST_STRIP_NUMBERED_SUFFIX_REGEX.sub(
                    "", function.test_function
                )
                if base_name in top_level_functions:
                    test_functions.add(
                        TestFunction(
                            function_name=base_name,
                            test_class=(function.test_class),
                            parameters=(function.test_function),
                            test_type=(function.test_type),
                        ),
                    )

        test_functions_by_name: dict[str, list[TestFunction]] = defaultdict(
            list
        )
        for tf in test_functions:
            test_functions_by_name[tf.function_name].append(tf)

        test_function_names_set = set(
            test_functions_by_name.keys(),
        )
        relevant_names = []
        for name in all_names:
            if name.full_name is None:
                continue
            match = FUNCTION_NAME_REGEX.search(
                name.full_name,
            )
            if match and match.group(1) in test_function_names_set:
                relevant_names.append(
                    (name, match.group(1)),
                )

        for name, scope in relevant_names:
            try:
                definition = name.goto(
                    follow_imports=True,
                    follow_builtin_imports=False,
                )
            except Exception:  # noqa: BLE001
                log.debug(
                    "Jedi goto failed for %s",
                    name,
                    exc_info=True,
                )
                continue
            try:
                if not definition or definition[0].type != "function":
                    if functions_to_optimize_by_name and name.name:
                        for func_to_opt in functions_to_optimize_by_name.get(
                            name.name, []
                        ):
                            qname = (
                                module_name_from_file_path(
                                    func_to_opt.file_path,
                                    project_root,
                                )
                                + "."
                                + func_to_opt.qualified_name
                            )
                            add_test_entries(
                                function_to_test_map,
                                qname,
                                test_functions_by_name[scope],
                                test_file,
                                name.line,
                                name.column,
                            )
                    continue
                definition_obj = definition[0]
                def_path = str(
                    definition_obj.module_path,
                )

                project_root_str = str(project_root)
                if (
                    def_path.startswith(
                        project_root_str + os.sep,
                    )
                    and definition_obj.module_name != name.module_name
                    and definition_obj.full_name is not None
                ):
                    module_prefix = definition_obj.module_name + "."
                    full_name_no_mod = definition_obj.full_name.replace(
                        module_prefix, "", 1
                    )
                    qname = (
                        module_name_from_file_path(
                            definition_obj.module_path,
                            project_root,
                        )
                        + "."
                        + full_name_no_mod
                    )

                    add_test_entries(
                        function_to_test_map,
                        qname,
                        test_functions_by_name[scope],
                        test_file,
                        name.line,
                        name.column,
                    )
            except Exception:  # noqa: BLE001
                log.debug(
                    "Error processing definition for %s",
                    name,
                    exc_info=True,
                )
                continue

    return dict(function_to_test_map)
