# Copyright 2025 the original author or authors.
# <p>
# Licensed under the Moderne Source Available License (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# <p>
# https://docs.moderne.io/licensing/moderne-source-available-license
# <p>
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Python to JavaType mapping using ty-types for type inference.

This module provides type attribution for Python code by querying the ty-types
CLI for structured type descriptors. All node types for a file are fetched in
a single batch call, then looked up by byte offset. The type information is
mapped to OpenRewrite's JavaType model to enable Java recipes like FindMethods
to work on Python.
"""

from __future__ import annotations

import ast
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from ..java import JavaType


def compute_source_line_data(
        source: str,
) -> Tuple[List[str], List[int], Optional[Dict[int, List[int]]]]:
    """Split source into lines and compute byte offsets and byte-to-char mappings in one pass.

    Returns:
        source_lines: Lines with line endings stripped.
        line_byte_offsets: line_byte_offsets[i] is the byte offset of line i+1 (1-based).
        byte_to_char: Per-line byte-offset → char-offset mapping for non-ASCII lines only,
                      or None when the source is pure ASCII.
    """
    is_ascii = source.isascii()
    lines_with_endings = source.splitlines(True)
    source_lines: List[str] = []
    offsets: List[int] = [0]
    byte_to_char: Dict[int, List[int]] = {}

    for lineno, line in enumerate(lines_with_endings, start=1):
        # Strip line ending
        if line.endswith('\r\n'):
            source_lines.append(line[:-2])
        elif line.endswith(('\r', '\n')):
            source_lines.append(line[:-1])
        else:
            source_lines.append(line)

        if is_ascii:
            offsets.append(offsets[-1] + len(line))
        else:
            line_bytes = line.encode('utf-8')
            offsets.append(offsets[-1] + len(line_bytes))
            if len(line_bytes) != len(line):  # non-ASCII line — build byte→char index
                mapping: List[int] = []
                for char_idx, char in enumerate(line):
                    for _ in range(len(char.encode('utf-8'))):
                        mapping.append(char_idx)
                byte_to_char[lineno] = mapping

    return source_lines, offsets, (byte_to_char if byte_to_char else None)


# Shared Unknown singleton to avoid creating duplicate instances
_UNKNOWN = JavaType.Unknown()

# Mapping of Python builtin types to JavaType.Primitive
_PYTHON_PRIMITIVES: Dict[str, JavaType.Primitive] = {
    'str': JavaType.Primitive.String,
    'int': JavaType.Primitive.Int,
    'float': JavaType.Primitive.Double,
    'bool': JavaType.Primitive.Boolean,
    'None': JavaType.Primitive.None_,
    'NoneType': JavaType.Primitive.None_,
    'bytes': JavaType.Primitive.String,  # Close enough for matching
    'LiteralString': JavaType.Primitive.String,
}

# Reverse mapping from JavaType.Primitive to Python type name
_PRIMITIVE_TO_PYTHON: Dict[JavaType.Primitive, str] = {
    JavaType.Primitive.String: 'str',
    JavaType.Primitive.Int: 'int',
    JavaType.Primitive.Double: 'float',
    JavaType.Primitive.Boolean: 'bool',
    JavaType.Primitive.None_: 'None',
}


class PythonTypeMapping:
    """Maps Python types to JavaType for recipe matching.

    This class uses the ty-types CLI to infer types for Python code and
    converts them to JavaType objects that can be used by Java recipes
    like FindMethods, ChangeMethodName, etc.

    All types for a file are fetched in a single batch call during __init__,
    then individual nodes are looked up by byte offset.

    Usage:
        mapping = PythonTypeMapping(source, file_path="/path/to/file.py")
        method_type = mapping.method_invocation_type(call_node)
    """

    def __init__(self, source: str, file_path: Optional[str] = None, ty_client=None,
                 source_lines: Optional[List[str]] = None,
                 line_byte_offsets: Optional[List[int]] = None):
        """Initialize type mapping for a source file.

        Args:
            source: The Python source code.
            file_path: Optional file path for the source. If provided,
                      it will be used for ty-types queries.
            ty_client: Optional TyTypesClient instance. If provided and
                      already initialized, fetches types from ty-types.
            source_lines: Pre-computed list of lines (no endings). When provided
                         together with line_byte_offsets, avoids re-splitting source.
            line_byte_offsets: Pre-computed cumulative byte offsets per line.
        """
        self._source = source
        self._file_path = file_path
        self._temp_file: Optional[Path] = None
        self._type_cache: Dict[str, JavaType] = {}  # FQN -> JavaType (per-instance)

        # Use pre-computed values when available (e.g. supplied by ParserVisitor),
        # otherwise compute them here.
        if source_lines is not None and line_byte_offsets is not None:
            self._source_lines = source_lines
            self._line_byte_offsets = line_byte_offsets
        else:
            self._source_lines, self._line_byte_offsets, _ = compute_source_line_data(source)

        # Caches for byte offset and type ID lookups
        self._byte_offset_cache: Dict[Tuple[int, int], int] = {}
        self._lookup_cache: Dict[tuple, Optional[int]] = {}

        # ty-types data: populated by _build_index
        self._node_index: Dict[Tuple[int, int], Tuple[int, str]] = {}  # (start, end) -> (type_id, node_kind)
        self._node_index_by_start: Dict[int, List[Tuple[int, int, str]]] = {}  # start -> [(end, type_id, node_kind)]
        self._type_registry: Dict[int, Dict[str, Any]] = {}  # type_id -> TypeDescriptor
        self._call_signature_index: Dict[Tuple[int, int], Dict[str, Any]] = {}  # (start, end) -> callSignature
        self._type_id_cache: Dict[int, JavaType] = {}  # type_id -> resolved JavaType
        self._declaring_type_id_cache: Dict[int, JavaType.FullyQualified] = {}  # type_id -> resolved declaring type
        self._resolving_type_ids: set = set()  # type_ids currently being resolved (cycle detection)
        self._resolving_declaring_type_ids: set = set()
        self._cycle_placeholders: Dict[int, JavaType.Class] = {}  # placeholders created on cycle detection
        self._declaring_cycle_placeholders: Dict[int, JavaType.Class] = {}
        self._class_literal_index: Dict[str, int] = {}  # className -> classLiteral type_id

        # Fetch all types in one batch call
        if ty_client is not None:
            try:
                self._fetch_types(source, file_path, ty_client)
            except RuntimeError:
                pass

    def _fetch_types(self, source: str, file_path: Optional[str], client) -> None:
        """Fetch all types for this file from ty-types.

        The client must already be initialized with a project root.
        """
        if not client.is_available:
            return

        # Determine the actual file path on disk
        actual_file = self._ensure_file_on_disk(source, file_path)
        if actual_file is None:
            return

        # Fetch all types in one call
        result = client.get_types(actual_file)
        if result:
            self._build_index(result)

    def _ensure_file_on_disk(self, source: str, file_path: Optional[str]) -> Optional[str]:
        """Ensure the source is available as a file on disk for ty-types.

        Returns the absolute file path, or None if unavailable.
        When file_path is given but doesn't exist, writes source there.
        Callers are responsible for providing safe paths (e.g. within a temp directory).
        """
        if file_path:
            path = Path(file_path)
            if not path.is_absolute():
                path = path.resolve()
            if path.exists():
                return str(path)
            # File path given but doesn't exist — write source there.
            # The parent directory must already exist (caller should ensure this).
            try:
                path.write_text(source, encoding='utf-8')
                self._temp_file = path
                return str(path)
            except OSError:
                return None
        else:
            # No file path — create temp file
            try:
                fd, tmp_path = tempfile.mkstemp(suffix='.py')
                os.close(fd)
                Path(tmp_path).write_text(source, encoding='utf-8')
                self._temp_file = Path(tmp_path)
                return tmp_path
            except OSError:
                return None

    def _pos_to_byte_offset(self, lineno: int, col_offset: int) -> int:
        """Convert AST (lineno, col_offset) to an absolute byte offset.

        Python's ast uses 1-based lineno and 0-based character col_offset (Python 3.8+).
        ty-types uses absolute byte offsets (ruff convention).
        Results are cached since the same position is often queried multiple times.
        """
        key = (lineno, col_offset)
        cached = self._byte_offset_cache.get(key)
        if cached is not None:
            return cached
        line_start = self._line_byte_offsets[lineno - 1]
        line_text = self._source_lines[lineno - 1] if lineno <= len(self._source_lines) else ""
        byte_col = len(line_text[:col_offset].encode('utf-8'))
        result = line_start + byte_col
        self._byte_offset_cache[key] = result
        return result

    def _build_index(self, result: Dict[str, Any]) -> None:
        """Build the byte-offset lookup index from a getTypes response."""
        for node in result.get('nodes', []):
            type_id = node.get('typeId')
            if type_id is not None:
                start = node['start']
                end = node['end']
                kind = node['nodeKind']
                self._node_index[(start, end)] = (type_id, kind)
                if start not in self._node_index_by_start:
                    self._node_index_by_start[start] = []
                self._node_index_by_start[start].append((end, type_id, kind))

            # Index call signature data for ExprCall nodes
            call_sig = node.get('callSignature')
            if call_sig is not None:
                self._call_signature_index[(node['start'], node['end'])] = call_sig

        # Merge types into registry (keys are strings in JSON)
        for type_id_str, descriptor in result.get('types', {}).items():
            tid = int(type_id_str)
            self._type_registry[tid] = descriptor
            # Index classLiterals by className for kind inference
            if descriptor.get('kind') == 'classLiteral':
                cn = descriptor.get('className', '')
                if cn:
                    self._class_literal_index[cn] = tid

    def _lookup_type_id(self, node: ast.AST) -> Optional[int]:
        """Look up a node's type ID by converting AST position to byte offset.

        Results are cached by node position to avoid redundant byte-offset
        conversions when the same node is queried from multiple call sites.
        """
        if not hasattr(node, 'lineno') or node.lineno is None:
            return None

        end_lineno = getattr(node, 'end_lineno', None)
        end_col_offset = getattr(node, 'end_col_offset', None)
        cache_key = (node.lineno, node.col_offset, end_lineno, end_col_offset)
        if cache_key in self._lookup_cache:
            return self._lookup_cache[cache_key]

        start = self._pos_to_byte_offset(node.lineno, node.col_offset)
        end = self._pos_to_byte_offset(end_lineno, end_col_offset) if end_lineno is not None else None

        result = None
        if end is not None:
            match = self._node_index.get((start, end))
            if match:
                result = match[0]  # type_id

        if result is None:
            # Fuzzy match by start offset — prefer the entry whose end is closest
            entries = self._node_index_by_start.get(start, [])
            if entries:
                if end is not None:
                    best = min(entries, key=lambda e: abs(e[0] - end))
                    result = best[1]  # type_id
                else:
                    result = entries[0][1]  # type_id of first match

        self._lookup_cache[cache_key] = result
        return result

    def _resolve_type(self, type_id: int) -> Optional[JavaType]:
        """Resolve a type ID to a JavaType, maximizing object reuse.

        Caches resolved types so the same type_id always returns the same
        object. Breaks cyclic type references by creating a placeholder Class
        only when a cycle is actually detected — the placeholder is updated
        in-place once resolution completes.
        """
        if type_id in self._type_id_cache:
            return self._type_id_cache[type_id]

        # Cycle detected — create a placeholder that will be updated later
        if type_id in self._resolving_type_ids:
            if type_id not in self._cycle_placeholders:
                placeholder = JavaType.Class()
                placeholder._flags_bit_map = 0
                placeholder._kind = JavaType.FullyQualified.Kind.Class
                placeholder._fully_qualified_name = ''
                self._cycle_placeholders[type_id] = placeholder
            return self._cycle_placeholders[type_id]

        descriptor = self._type_registry.get(type_id)
        if not descriptor:
            return None

        self._resolving_type_ids.add(type_id)
        try:
            result = self._descriptor_to_java_type(descriptor)
        finally:
            self._resolving_type_ids.discard(type_id)

        if result is None:
            return None

        # If a cycle created a placeholder for this type_id, update it in-place
        if type_id in self._cycle_placeholders:
            placeholder = self._cycle_placeholders.pop(type_id)
            if isinstance(result, JavaType.Class):
                placeholder._fully_qualified_name = result.fully_qualified_name
                placeholder._kind = result._kind
                # Copy enriched fields so cycle placeholders retain supertypes/methods
                for attr in ('_supertype', '_methods', '_type_parameters', '_interfaces',
                             '_members', '_owning_class', '_annotations'):
                    val = getattr(result, attr, None)
                    if val is not None:
                        setattr(placeholder, attr, val)
            elif isinstance(result, JavaType.Parameterized):
                if hasattr(result._type, 'fully_qualified_name'):
                    placeholder._fully_qualified_name = result._type.fully_qualified_name
            self._type_id_cache[type_id] = placeholder
            return placeholder

        # No cycle — cache the actual result directly for maximum reuse.
        # For Class types this preserves the object from _create_class_type,
        # ensuring FQN-based deduplication across type_ids.
        self._type_id_cache[type_id] = result
        return result

    def _descriptor_to_java_type(self, descriptor: Dict[str, Any]) -> Optional[JavaType]:
        """Convert a ty-types TypeDescriptor to a JavaType."""
        kind = descriptor.get('kind')

        if kind == 'instance':
            class_name = descriptor.get('className', '')
            if class_name in _PYTHON_PRIMITIVES:
                return _PYTHON_PRIMITIVES[class_name]

            # Resolve base class: prefer classId (enriched with supertypes/methods)
            class_id = descriptor.get('classId')
            if class_id is None:
                # Look up classLiteral by className to get kind/supertypes/methods
                class_id = self._class_literal_index.get(class_name)

            if class_id is not None:
                base_class = self._resolve_type(class_id)
                if not isinstance(base_class, JavaType.Class):
                    base_class = self._create_class_type(class_name)
            else:
                module_name = descriptor.get('moduleName')
                if module_name and module_name != 'builtins':
                    base_class = self._create_class_type(f"{module_name}.{class_name}")
                else:
                    base_class = self._create_class_type(class_name)

            # If typeArgs present, wrap in Parameterized
            type_args = descriptor.get('typeArgs')
            if type_args:
                resolved_args = []
                for arg_id in type_args:
                    arg_type = self._resolve_type(arg_id)
                    if arg_type is not None:
                        resolved_args.append(arg_type)
                if resolved_args:
                    param = JavaType.Parameterized()
                    param._type = base_class
                    param._type_parameters = resolved_args
                    return param

            return base_class

        elif kind == 'intLiteral':
            return JavaType.Primitive.Int

        elif kind == 'boolLiteral':
            return JavaType.Primitive.Boolean

        elif kind in ('stringLiteral', 'literalString'):
            return JavaType.Primitive.String

        elif kind == 'bytesLiteral':
            return JavaType.Primitive.String

        elif kind == 'union':
            # Resolve all non-None members into a Union type.
            # For Optional[X] (= X | None) with a single real member, unwrap to just X.
            resolved_bounds = []
            for member_id in descriptor.get('members', []):
                member = self._type_registry.get(member_id)
                if member:
                    member_kind = member.get('kind')
                    # Skip None/NoneType members
                    if member_kind == 'instance' and member.get('className') in ('None', 'NoneType'):
                        continue
                    resolved = self._resolve_type(member_id)
                    if resolved is not None:
                        resolved_bounds.append(resolved)
            if not resolved_bounds:
                return _UNKNOWN
            if len(resolved_bounds) == 1:
                return resolved_bounds[0]
            return JavaType.Union(_bounds=resolved_bounds)

        elif kind == 'module':
            module_name = descriptor.get('moduleName', '')
            return self._create_class_type(module_name)

        elif kind in ('function', 'boundMethod'):
            # Use structured return type if available
            return_type_id = descriptor.get('returnType')
            if return_type_id is not None:
                result = self._resolve_type(return_type_id)
                if result is not None:
                    return result
            return _UNKNOWN

        elif kind == 'classLiteral':
            class_name = descriptor.get('className', '')
            class_type = self._create_class_type(class_name)

            # Infer Kind from supertypes before resolving them
            supertypes = descriptor.get('supertypes', [])
            for st_id in supertypes:
                st_desc = self._type_registry.get(st_id)
                if st_desc:
                    st_kind = st_desc.get('kind')
                    st_name = st_desc.get('className', '')
                    if st_kind == 'classLiteral' and st_name == 'Enum':
                        class_type._kind = JavaType.FullyQualified.Kind.Enum
                        break
                    elif st_kind == 'specialForm' and st_desc.get('name', '') == 'typing.Protocol':
                        class_type._kind = JavaType.FullyQualified.Kind.Interface
                        break

            # Populate supertypes: first → _supertype, rest → _interfaces
            if supertypes and getattr(class_type, '_supertype', None) is None:
                super_type = self._resolve_type(supertypes[0])
                if isinstance(super_type, JavaType.FullyQualified):
                    class_type._supertype = super_type

                if len(supertypes) > 1 and getattr(class_type, '_interfaces', None) is None:
                    interfaces = []
                    for st_id in supertypes[1:]:
                        iface = self._resolve_type(st_id)
                        if isinstance(iface, JavaType.FullyQualified):
                            interfaces.append(iface)
                    if interfaces:
                        class_type._interfaces = interfaces

            # Populate methods from function/boundMethod members
            members = descriptor.get('members', [])
            if members and getattr(class_type, '_methods', None) is None:
                methods = []
                for member in members:
                    member_type_id = member.get('typeId') if isinstance(member, dict) else member
                    if member_type_id is None:
                        continue
                    member_desc = self._type_registry.get(member_type_id)
                    if member_desc and member_desc.get('kind') in ('function', 'boundMethod'):
                        method = self._create_method_from_descriptor(member_desc, class_type)
                        if method:
                            methods.append(method)
                class_type._methods = methods if methods else None

            return class_type

        elif kind == 'typedDict':
            name = descriptor.get('name', '')
            if name:
                return self._create_class_type(name)
            return _UNKNOWN

        elif kind == 'subclassOf':
            base_id = descriptor.get('base')
            if base_id is not None:
                result = self._resolve_type(base_id)
                if result is not None:
                    return result
            return _UNKNOWN

        elif kind == 'newType':
            name = descriptor.get('name', '')
            if name:
                return self._create_class_type(name)
            return _UNKNOWN

        elif kind == 'intersection':
            for member_id in descriptor.get('positive', []):
                result = self._resolve_type(member_id)
                if result is not None:
                    return result
            return _UNKNOWN

        elif kind in ('dynamic', 'never'):
            return _UNKNOWN

        elif kind == 'enumLiteral':
            class_name = descriptor.get('className', '')
            class_type = self._create_class_type(class_name)
            class_type._kind = JavaType.FullyQualified.Kind.Enum
            return class_type

        elif kind == 'property':
            return _UNKNOWN

        else:
            return _UNKNOWN

    def close(self) -> None:
        """Clean up temporary files."""
        if self._temp_file and self._temp_file.exists():
            try:
                self._temp_file.unlink()
            except OSError:
                pass

    def type(self, node: ast.AST) -> Optional[JavaType]:
        """Get the expression type for an AST node.

        For call expressions this returns the return type of the call,
        NOT a JavaType.Method. Use method_invocation_type() when you
        need the full method signature.

        Args:
            node: The AST node to get the type for.

        Returns:
            The JavaType for the node, or None if the type cannot be determined.
        """
        if isinstance(node, ast.Constant):
            return self._constant_type(node)

        # Try to look up in ty-types index
        type_id = self._lookup_type_id(node)
        if type_id is not None:
            return self._resolve_type(type_id)

        return None

    def _constant_type(self, node: ast.Constant) -> Optional[JavaType]:
        """Get the type for a constant/literal node."""
        if isinstance(node.value, (str, bytes)):
            return JavaType.Primitive.String
        elif isinstance(node.value, bool):
            return JavaType.Primitive.Boolean
        elif isinstance(node.value, int):
            return JavaType.Primitive.Int
        elif isinstance(node.value, float):
            return JavaType.Primitive.Double
        elif node.value is None:
            return JavaType.Primitive.None_
        return None

    def _is_variable_descriptor(self, descriptor: Dict[str, Any]) -> bool:
        """Check if a type descriptor represents a variable (not a function, class, or module)."""
        kind = descriptor.get('kind')
        return kind not in ('function', 'boundMethod', 'module', 'classLiteral')

    def name_type_info(self, node: ast.Name) -> Tuple[Optional[JavaType], Optional[JavaType.Variable]]:
        """Get expression type and variable type for a name reference.

        Returns (expression_type, variable_field_type).
        """
        type_id = self._lookup_type_id(node)
        if type_id is None:
            return None, None

        expr_type = self._resolve_type(type_id)
        descriptor = self._type_registry.get(type_id)
        if descriptor and self._is_variable_descriptor(descriptor):
            return expr_type, JavaType.Variable(_name=node.id, _type=expr_type)
        return expr_type, None

    def attribute_type_info(self, node: ast.Attribute,
                            receiver_type: Optional[JavaType] = None
                            ) -> Tuple[Optional[JavaType], Optional[JavaType.Variable]]:
        """Get expression type and variable type for an attribute access.

        Returns (expression_type, variable_field_type).
        """
        type_id = self._lookup_type_id(node)
        if type_id is None:
            return None, None

        expr_type = self._resolve_type(type_id)
        descriptor = self._type_registry.get(type_id)
        if descriptor and self._is_variable_descriptor(descriptor):
            return expr_type, JavaType.Variable(_name=node.attr, _type=expr_type, _owner=receiver_type)
        return expr_type, None

    def method_invocation_type(self, node: ast.Call) -> Optional[JavaType.Method]:
        """Get the method type for a function/method call.

        This returns a complete JavaType.Method with:
        - name: The method name
        - declaringType: The class/module containing the method
        - parameterNames: The names of the method parameters
        - parameterTypes: The types of the method parameters

        Args:
            node: The ast.Call node representing the method invocation.

        Returns:
            A JavaType.Method with full type information, or None if
            the type cannot be determined.
        """
        # Extract method name
        method_name = self._extract_method_name(node)
        if not method_name:
            return None

        # Get declaring type
        declaring_type = self._get_declaring_type(node)

        # Get parameter names and types from method signature
        param_names, param_types = self._get_method_signature(node)

        # Get return type
        return_type = self._get_return_type(node)

        return JavaType.Method(
            _flags_bit_map=0,
            _declaring_type=declaring_type,
            _name=method_name,
            _return_type=return_type,
            _parameter_names=param_names if param_names else None,
            _parameter_types=param_types if param_types else None,
        )

    def _extract_method_name(self, node: ast.Call) -> Optional[str]:
        """Extract the method name from a Call node."""
        if isinstance(node.func, ast.Name):
            return node.func.id
        elif isinstance(node.func, ast.Attribute):
            return node.func.attr
        return None

    def _lookup_call_signature(self, node: ast.Call) -> Optional[Dict[str, Any]]:
        """Look up structured call signature data for a Call node."""
        if not hasattr(node, 'lineno') or node.lineno is None:
            return None

        start = self._pos_to_byte_offset(node.lineno, node.col_offset)
        if hasattr(node, 'end_lineno') and node.end_lineno is not None:
            end = self._pos_to_byte_offset(node.end_lineno, node.end_col_offset)
            return self._call_signature_index.get((start, end))
        return None

    def _resolve_param_type(self, param: Dict[str, Any]) -> JavaType:
        """Resolve a ParameterInfo's typeId to a JavaType."""
        type_id = param.get('typeId')
        if type_id is not None:
            result = self._resolve_type(type_id)
            if result is not None:
                return result
        return _UNKNOWN

    def _get_method_signature(self, node: ast.Call) -> Tuple[List[str], List[JavaType]]:
        """Get parameter names and types from the method signature.

        Uses structured call signature from ty-types when available,
        then falls back to function/method descriptor parameters,
        then to placeholder names.
        """
        # Try structured call signature from ty-types (most specific)
        sig = self._lookup_call_signature(node)
        if sig:
            params = sig.get('parameters', [])
            if params:
                names = [p['name'] for p in params
                         if p['name'] not in ('self', 'cls')]
                types = [self._resolve_param_type(p) for p in params
                         if p['name'] not in ('self', 'cls')]
                return names, types

        # Try function/method descriptor parameters
        func_type_id = self._lookup_func_type_id(node)
        if func_type_id is not None:
            descriptor = self._type_registry.get(func_type_id)
            if descriptor:
                params = descriptor.get('parameters', [])
                if params:
                    names = [p['name'] for p in params
                             if p['name'] not in ('self', 'cls')]
                    types = [self._resolve_param_type(p) for p in params
                             if p['name'] not in ('self', 'cls')]
                    return names, types

        # Fall back to placeholder names
        return self._generate_placeholder_names(node)

    def _lookup_func_type_id(self, node: ast.Call) -> Optional[int]:
        """Look up the type ID of the function/method being called."""
        if isinstance(node.func, ast.Attribute):
            return self._lookup_type_id(node.func)
        elif isinstance(node.func, ast.Name):
            return self._lookup_type_id(node.func)
        return None

    def _generate_placeholder_names(self, node: ast.Call) -> Tuple[List[str], List[JavaType]]:
        """Generate placeholder parameter names when signature parsing fails."""
        param_types = self._get_parameter_types(node) or []
        names = [f"arg{i}" for i in range(len(param_types))]
        return names, param_types

    def _get_declaring_type(self, node: ast.Call) -> Optional[JavaType.FullyQualified]:
        """Get the declaring type (class/module) for a method call."""
        if isinstance(node.func, ast.Attribute):
            receiver = node.func.value

            # For chained calls like "hello".upper().split(), the receiver is a Call
            if isinstance(receiver, ast.Call):
                return self._get_call_return_type(receiver)

            # Try to look up receiver type in ty-types index
            type_id = self._lookup_type_id(receiver)
            if type_id is not None:
                return self._resolve_declaring_type(type_id)

        elif isinstance(node.func, ast.Name):
            # For function calls, look up the function name
            type_id = self._lookup_type_id(node.func)
            if type_id is not None:
                descriptor = self._type_registry.get(type_id)
                if descriptor:
                    kind = descriptor.get('kind')
                    if kind == 'module':
                        return self._create_class_type(descriptor.get('moduleName', ''))

        return self._infer_declaring_type_from_ast(node)

    def _resolve_declaring_type(self, type_id: int) -> Optional[JavaType.FullyQualified]:
        """Resolve a type ID to a declaring type, maximizing object reuse.

        NOTE: The cycle-detection pattern here mirrors _resolve_type intentionally.
        They use separate caches and placeholder dicts because declaring types are
        resolved independently (often to a simpler Class without methods/members).
        """
        if type_id in self._declaring_type_id_cache:
            return self._declaring_type_id_cache[type_id]

        if type_id in self._resolving_declaring_type_ids:
            if type_id not in self._declaring_cycle_placeholders:
                placeholder = JavaType.Class()
                placeholder._flags_bit_map = 0
                placeholder._kind = JavaType.FullyQualified.Kind.Class
                placeholder._fully_qualified_name = ''
                self._declaring_cycle_placeholders[type_id] = placeholder
            return self._declaring_cycle_placeholders[type_id]

        descriptor = self._type_registry.get(type_id)
        if not descriptor:
            return None

        self._resolving_declaring_type_ids.add(type_id)
        try:
            result = self._declaring_type_from_descriptor(descriptor)
        finally:
            self._resolving_declaring_type_ids.discard(type_id)

        if result is None:
            return None

        if type_id in self._declaring_cycle_placeholders:
            placeholder = self._declaring_cycle_placeholders.pop(type_id)
            if isinstance(result, JavaType.Class):
                placeholder._fully_qualified_name = result.fully_qualified_name
                placeholder._kind = result._kind
            self._declaring_type_id_cache[type_id] = placeholder
            return placeholder

        self._declaring_type_id_cache[type_id] = result
        return result

    def _declaring_type_from_descriptor(self, descriptor: Dict[str, Any]) -> Optional[JavaType.FullyQualified]:
        """Extract a declaring type (class/module) from a TypeDescriptor."""
        kind = descriptor.get('kind')

        if kind == 'module':
            module_name = descriptor.get('moduleName', '')
            return self._create_class_type(module_name)

        elif kind == 'instance':
            class_name = descriptor.get('className', '')
            module_name = descriptor.get('moduleName')
            if module_name and module_name != 'builtins':
                return self._create_class_type(f"{module_name}.{class_name}")
            return self._create_class_type(class_name)

        elif kind == 'typedDict':
            name = descriptor.get('name', '')
            if name:
                return self._create_class_type(name)
            return None

        elif kind == 'subclassOf':
            base_id = descriptor.get('base')
            if base_id is not None:
                return self._resolve_declaring_type(base_id)
            return None

        elif kind == 'newType':
            name = descriptor.get('name', '')
            if name:
                return self._create_class_type(name)
            return None

        elif kind == 'intersection':
            for member_id in descriptor.get('positive', []):
                result = self._resolve_declaring_type(member_id)
                if result is not None:
                    return result
            return None

        elif kind in ('stringLiteral', 'literalString'):
            return self._create_class_type('str')

        elif kind == 'intLiteral':
            return self._create_class_type('int')

        elif kind == 'boolLiteral':
            return self._create_class_type('bool')

        elif kind == 'bytesLiteral':
            return self._create_class_type('bytes')

        elif kind == 'union':
            # Unwrap union: use first non-None member as declaring type
            for member_id in descriptor.get('members', []):
                member = self._type_registry.get(member_id)
                if member:
                    if member.get('kind') == 'instance' and member.get('className') in ('None', 'NoneType'):
                        continue
                    return self._resolve_declaring_type(member_id)

        elif kind == 'classLiteral':
            class_name = descriptor.get('className', '')
            return self._create_class_type(class_name)

        return None

    def _get_call_return_type(self, call_node: ast.Call) -> Optional[JavaType.FullyQualified]:
        """Get the return type of a function/method call as a class type.

        For chained calls like "hello".upper().split(), this returns the type
        that .upper() returns (str), which is then the declaring type for .split().
        """
        # The type of an ExprCall IS the return type
        type_id = self._lookup_type_id(call_node)
        if type_id is not None:
            java_type = self._resolve_type(type_id)
            if isinstance(java_type, JavaType.Class):
                return java_type
            if isinstance(java_type, JavaType.Parameterized):
                # For declaring type, unwrap to base class
                return java_type._type if isinstance(java_type._type, JavaType.FullyQualified) else java_type
            if isinstance(java_type, JavaType.Primitive):
                return self._create_class_type(
                    _PRIMITIVE_TO_PYTHON.get(java_type, java_type.name.lower())
                )
        return None

    def _infer_declaring_type_from_ast(self, node: ast.Call) -> Optional[JavaType.FullyQualified]:
        """Infer declaring type from AST when ty-types data is unavailable."""
        if isinstance(node.func, ast.Attribute):
            receiver = node.func.value

            # Handle Python builtin types from literals
            if isinstance(receiver, ast.Constant):
                if isinstance(receiver.value, str):
                    return self._create_class_type('str')
                elif isinstance(receiver.value, bytes):
                    return self._create_class_type('bytes')
                elif isinstance(receiver.value, (int, float, bool)):
                    type_name = type(receiver.value).__name__
                    return self._create_class_type(type_name)
            elif isinstance(receiver, ast.List):
                return self._create_class_type('list')
            elif isinstance(receiver, ast.Dict):
                return self._create_class_type('dict')
            elif isinstance(receiver, ast.Set):
                return self._create_class_type('set')
            elif isinstance(receiver, ast.Tuple):
                return self._create_class_type('tuple')

            # Try to build a fully qualified name from the attribute chain
            parts = []
            current = receiver
            while isinstance(current, ast.Attribute):
                parts.append(current.attr)
                current = current.value
            if isinstance(current, ast.Name):
                parts.append(current.id)

            # Only create a class type if we have an attribute chain (len > 1)
            if len(parts) > 1:
                parts.reverse()
                fqn = '.'.join(parts)
                return self._create_class_type(fqn)
        return None

    def _get_parameter_types(self, node: ast.Call) -> Optional[List[JavaType]]:
        """Get the types of actual arguments in the call."""
        if not node.args and not node.keywords:
            return []

        param_types: List[JavaType] = []

        for arg in node.args:
            arg_type = self.type(arg)
            if arg_type:
                param_types.append(arg_type)
            else:
                param_types.append(_UNKNOWN)

        # Handle keyword arguments as well
        for kw in node.keywords:
            kw_type = self.type(kw.value)
            if kw_type:
                param_types.append(kw_type)
            else:
                param_types.append(_UNKNOWN)

        return param_types if param_types else None

    def _get_return_type(self, node: ast.Call) -> Optional[JavaType]:
        """Get the return type of a method call.

        First tries the ExprCall node type (which IS the return type),
        then falls back to the function descriptor's returnType field.
        """
        # The type of an ExprCall node in ty-types IS the return type
        type_id = self._lookup_type_id(node)
        if type_id is not None:
            return self._resolve_type(type_id)

        # Fall back to function descriptor's structured returnType
        func_type_id = self._lookup_func_type_id(node)
        if func_type_id is not None:
            func_desc = self._type_registry.get(func_type_id)
            if func_desc:
                ret_id = func_desc.get('returnType')
                if ret_id is not None:
                    return self._resolve_type(ret_id)
        return None

    def _create_method_from_descriptor(self, descriptor: Dict[str, Any],
                                        declaring_type: JavaType.FullyQualified) -> Optional[JavaType.Method]:
        """Create a JavaType.Method from a ty-types function/boundMethod descriptor."""
        name = descriptor.get('name', '')
        if not name:
            return None

        # Resolve return type
        return_type = None
        return_type_id = descriptor.get('returnType')
        if return_type_id is not None:
            return_type = self._resolve_type(return_type_id)

        # Resolve parameters (skip self/cls)
        param_names = []
        param_types = []
        for param in descriptor.get('parameters', []):
            p_name = param.get('name', '')
            if p_name in ('self', 'cls'):
                continue
            param_names.append(p_name)
            p_type_id = param.get('typeId')
            if p_type_id is not None:
                p_type = self._resolve_type(p_type_id)
                param_types.append(p_type if p_type else _UNKNOWN)
            else:
                param_types.append(_UNKNOWN)

        return JavaType.Method(
            _flags_bit_map=0,
            _declaring_type=declaring_type,
            _name=name,
            _return_type=return_type,
            _parameter_names=param_names if param_names else None,
            _parameter_types=param_types if param_types else None,
        )

    def _create_class_type(self, fqn: str) -> JavaType.Class:
        """Create a JavaType.Class from a fully qualified name."""
        if fqn in self._type_cache:
            cached = self._type_cache[fqn]
            if isinstance(cached, JavaType.Class):
                return cached

        class_type = JavaType.Class()
        class_type._flags_bit_map = 0
        class_type._fully_qualified_name = fqn
        class_type._kind = JavaType.FullyQualified.Kind.Class

        self._type_cache[fqn] = class_type  # ty: ignore[invalid-assignment]
        return class_type

    def _get_node_text(self, node: ast.expr) -> str:
        """Get the source text for an AST node."""
        if node.end_lineno is not None and node.end_col_offset is not None:
            if node.lineno == node.end_lineno:
                line = self._source_lines[node.lineno - 1] if node.lineno <= len(self._source_lines) else ""
                return line[node.col_offset:node.end_col_offset]

        # Fallback: just return from col_offset to end of line
        if node.lineno <= len(self._source_lines):
            return self._source_lines[node.lineno - 1][node.col_offset:]
        return ""

    @staticmethod
    def module_to_fqn(module_path: str) -> str:
        """Convert a Python module path to a fully qualified name."""
        return module_path
