import logging
import os
from typing import Any

import libcst as cst

from py_agent.models.class_metadata import ClassMetadata
from py_agent.models.dependencies import DependenciesDTO
from py_agent.models.method_metadata import MethodMetadata
from py_agent.models.tested_code import ExtractedDataDTO, TestedCodeDTO
from py_agent.services.dependency_extractor.dependency_collector import DEPENDENCY_EXTRACTION_DEPTH
from py_agent.services.dependency_extractor.dependency_extractor import DependencyExtractor
from py_agent.services.focused_code_extractor import FocusedCodeExtractor
from py_agent.services.import_jedi_extractor import ImportJediExtractor
from py_agent.services.type_metadata_extractor import clear_resolved_types_cache
from py_agent.visitors.tested_code_visitor import TestedCodeVisitor

logger = logging.getLogger(__name__)


class TestedCodeExtractor:
    @staticmethod
    def _get_tested_code_by_function_name_shallow(
        absolute_file_path: str,
        relative_file_path: str,
        project_path: str,
        function_name: str,
        class_name: str | None,
    ) -> ExtractedDataDTO | None:
        focused_code_dto = FocusedCodeExtractor.get_focused_code_by_function_name(
            absolute_file_path, relative_file_path, project_path, function_name, class_name
        )

        if not focused_code_dto.focused_code:
            return None

        file_code = focused_code_dto.focused_code

        module = cst.parse_module(file_code)
        metadata_wrapper = cst.MetadataWrapper(module)

        visitor = TestedCodeVisitor(
            module=module,
            metadata_wrapper=metadata_wrapper,
            absolute_file_path=absolute_file_path,
            relative_file_path=relative_file_path,
            project_path=project_path,
            function_name=function_name,
            class_name=class_name,
        )
        metadata_wrapper.visit(visitor)

        if not visitor.extracted_data:
            return None

        constructor_metadata = visitor.get_constructor_metadata()

        class_metadata = (
            ClassMetadata(
                name=visitor.class_name,
                code=visitor.extracted_data.class_code or "",
                constructorMetadata=constructor_metadata,
                isAbstract=False,
            )
            if visitor.class_name and visitor.is_class_extracted
            else None
        )

        return ExtractedDataDTO(
            fileCode=file_code,
            functionCode=visitor.extracted_data.function_code,
            position=visitor.extracted_data.position,
            parameters=visitor.extracted_data.parameters,
            ctor_parameters=constructor_metadata.parameters if constructor_metadata else [],
            returnParam=visitor.extracted_data.return_parameter,
            classMetadata=class_metadata,
            external_function_calls=focused_code_dto.all_external_methods_list or {},
            enums=focused_code_dto.enums,
            isAsync=visitor.extracted_data.is_async,
            isStatic=visitor.extracted_data.is_static,
        )

    @staticmethod
    def _get_tested_code_call_tree(
        extracted_data: ExtractedDataDTO, project_path: str, absolute_file_path: str = ""
    ) -> list[tuple[Any, Any]]:
        external_calls: dict[str, Any] = {}

        for external_call in extracted_data.external_function_calls.values():
            if external_call.name in external_calls or not external_call.jedi_metadata.file_path:
                continue
            if external_call.jedi_metadata.class_name:
                continue
            # Skip calls to functions in the same file — already in "Full File Context"
            if absolute_file_path and os.path.normpath(
                str(external_call.jedi_metadata.file_path)
            ) == os.path.normpath(absolute_file_path):
                continue

            # Skip external calls whose file is outside the project (e.g. stdlib, site-packages)
            try:
                rel_path = str(
                    external_call.jedi_metadata.file_path.relative_to(project_path)
                    if hasattr(external_call.jedi_metadata.file_path, "relative_to")
                    else external_call.jedi_metadata.file_path
                )
            except ValueError:
                continue

            external_call_data = TestedCodeExtractor._get_tested_code_by_function_name_shallow(
                str(external_call.jedi_metadata.file_path),
                rel_path,
                project_path,
                external_call.jedi_metadata.name,
                external_call.jedi_metadata.class_name,
            )

            if not external_call_data:
                continue

            method_metadata = MethodMetadata(
                name=external_call.name,
                code=external_call_data.function_code,
                parameters=external_call_data.parameters,
                ctor_parameters=external_call_data.ctor_parameters,
                returnParam=external_call_data.return_parameter,
                decorators=[],
                isStatic=external_call_data.is_static,
                isAsync=external_call_data.is_async,
                signature=external_call.jedi_metadata.signatures[0]
                if external_call.jedi_metadata.signatures
                else "",
                method_class=external_call_data.class_metadata,
            )

            external_calls[external_call.name] = (external_call, method_metadata)

        return list(external_calls.values())

    @staticmethod
    def get_tested_code_by_function_name(
        absolute_file_path: str,
        relative_file_path: str,
        project_path: str,
        function_name: str,
        class_name: str | None,
        extraction_depth: int = DEPENDENCY_EXTRACTION_DEPTH,
    ) -> TestedCodeDTO:
        # Clear the resolved types cache for this fresh extraction
        clear_resolved_types_cache()

        extracted_data = TestedCodeExtractor._get_tested_code_by_function_name_shallow(
            absolute_file_path, relative_file_path, project_path, function_name, class_name
        )

        if not extracted_data:
            return TestedCodeDTO(
                filePath=absolute_file_path,
                functionName=function_name,
            )

        jedi_extractor = ImportJediExtractor(
            project_path=project_path, absolute_file_path=absolute_file_path
        )
        absolute_import_path = jedi_extractor.get_absolute_import_path(function_name, class_name)

        all_parameters = [*extracted_data.parameters, *extracted_data.ctor_parameters]
        if extracted_data.return_parameter:
            all_parameters.append(extracted_data.return_parameter)

        extracted_call_tree = TestedCodeExtractor._get_tested_code_call_tree(
            extracted_data, project_path, absolute_file_path
        )

        for _external_info, extracted_datas in extracted_call_tree:
            all_parameters.extend(extracted_datas.parameters)
            all_parameters.extend(extracted_datas.ctor_parameters)
            if extracted_datas.return_parameter:
                all_parameters.append(extracted_datas.return_parameter)

        try:
            relative_path = str(relative_file_path)
            code_dep, missing_dep = DependencyExtractor(
                relative_path, extraction_depth=extraction_depth
            ).extract(
                params=all_parameters,
                external_functions=extracted_call_tree,
                enums=extracted_data.enums,
            )
            deps = DependenciesDTO(codeDependencies=code_dep, missingDependencies=missing_dep)
        except Exception:
            logger.exception("Dependency extraction failed")
            deps = DependenciesDTO(codeDependencies=[], missingDependencies=[])

        return TestedCodeDTO(
            filePath=absolute_file_path,
            moduleName=absolute_import_path,
            functionName=function_name,
            extractedData=extracted_data,
            dependencies=deps,
        )
