#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import os
import re
import xml.etree.ElementTree as ET
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set, Tuple

ANDROID_NS = "{http://schemas.android.com/apk/res/android}"
TOOLS_NS = "{http://schemas.android.com/tools}"

RETROFIT_ANNOTATIONS = {
    "GET",
    "POST",
    "PUT",
    "DELETE",
    "PATCH",
    "HEAD",
    "OPTIONS",
    "HTTP",
    "Multipart",
    "FormUrlEncoded",
    "Streaming",
}
DAO_ANNOTATIONS = {"Query", "Insert", "Update", "Delete", "Upsert", "RawQuery", "Transaction"}
SKIP_DIR_NAMES = {".git", ".gradle", ".idea", "build", "out", "node_modules"}
INTERACTIVE_VIEW_TYPES = {
    "Button",
    "ImageButton",
    "TextView",
    "ImageView",
    "FloatingActionButton",
    "MaterialButton",
    "Chip",
    "CheckBox",
    "Switch",
    "SwitchCompat",
    "RadioButton",
    "LinearLayout",
    "RelativeLayout",
    "ConstraintLayout",
    "FrameLayout",
    "CardView",
    "MaterialCardView",
    "RecyclerView",
    "ListView",
    "GridView",
    "SwipeRefreshLayout",
    "NavigationView",
    "Toolbar",
}
LIFECYCLE_ENTRY_METHODS = {
    "onCreate",
    "onStart",
    "onResume",
    "onViewCreated",
    "onCreateView",
    "onActivityCreated",
}
CUSTOM_ENTRY_NAME_RE = re.compile(
    r"^(init(?:Data|View|Views|State)?|load(?:Data|Items|Content)?|fetch\w+|query\w+|refresh\w+|setup(?:Observers|Observer|Data|Ui)?|observe\w*|request\w+|populate\w+|bind\w+)$",
    re.IGNORECASE,
)
SKIP_CALL_NAMES = {
    "if",
    "for",
    "while",
    "when",
    "switch",
    "catch",
    "return",
    "super",
    "this",
    "fun",
    "class",
    "interface",
    "object",
    "try",
    "println",
    "print",
    "listOf",
    "mutableListOf",
    "mapOf",
    "lazy",
    "apply",
    "also",
    "let",
    "run",
    "with",
    "checkNotNull",
    "requireNotNull",
    "setOnClickListener",
    "setOnLongClickListener",
    "setOnItemClickListener",
    "setOnMenuItemClickListener",
    "setOnNavigationItemSelectedListener",
    "setNavigationItemSelectedListener",
    "setOnRefreshListener",
}


@dataclass
class Location:
    file: str
    line: Optional[int] = None
    snippet: Optional[str] = None


@dataclass
class LayoutComponent:
    component_id: str
    view_type: str
    label: Optional[str]
    source: Location
    depth: int
    order: int
    resource_kind: str = "view"
    on_click: Optional[str] = None
    clickable: bool = False
    layout_name: Optional[str] = None


@dataclass
class MenuItemInfo:
    menu_name: str
    item_id: str
    title: Optional[str]
    source: Location
    order: int


@dataclass
class ClassInfo:
    name: str
    qualified_name: str
    file_path: str
    package_name: str
    line: int
    end_line: int
    kind: str
    annotations: List[str]
    super_types: List[str]
    imports: List[str]
    body: str
    fields: Dict[str, str] = field(default_factory=dict)


@dataclass
class MethodInfo:
    name: str
    owner_name: str
    qualified_owner: str
    owner_kind: str
    receiver_type: Optional[str]
    file_path: str
    line: int
    end_line: int
    body_start_line: int
    signature: str
    body: str
    annotations: List[str]
    package_name: str
    imports: List[str]
    super_types: List[str] = field(default_factory=list)
    fields: Dict[str, str] = field(default_factory=dict)
    class_kind: str = "class"


@dataclass
class CallSite:
    name: str
    qualifier: Optional[str]
    line: int
    raw: str


class AndroidUIApiAnalyzer:
    def __init__(self, project_root: str, max_depth: int = 4) -> None:
        self.project_root = os.path.abspath(project_root)
        self.max_depth = max(1, max_depth)
        self.file_cache: Dict[str, str] = {}
        self.manifest_path: Optional[str] = None
        self.manifest_data: Dict[str, object] = {}
        self.class_map: Dict[str, List[ClassInfo]] = defaultdict(list)
        self.class_by_qualified: Dict[str, ClassInfo] = {}
        self.methods: List[MethodInfo] = []
        self.methods_by_name: Dict[str, List[MethodInfo]] = defaultdict(list)
        self.methods_by_owner: Dict[str, Dict[str, List[MethodInfo]]] = defaultdict(lambda: defaultdict(list))
        self.extension_methods: Dict[str, List[MethodInfo]] = defaultdict(list)
        self.layout_files: Dict[str, str] = {}
        self.menu_files: Dict[str, str] = {}
        self.navigation_files: Dict[str, str] = {}
        self.navigation_destinations: Dict[str, str] = {}
        self.string_table: Dict[str, str] = {}
        self.menu_items_by_id: Dict[str, List[MenuItemInfo]] = defaultdict(list)
        self.resource_component_cache: Dict[str, Tuple[List[LayoutComponent], List[str]]] = {}

    def analyze(self, target_activity: Optional[str] = None) -> Dict[str, object]:
        self.discover_files()
        self.parse_strings()
        self.parse_manifest()
        self.parse_navigation_resources()
        self.parse_menu_resources()
        self.parse_sources()

        activities_from_manifest = self.manifest_data.get("activities", []) or []
        source_inferred_activities = self.infer_activities_from_source()
        activity_entries = self.merge_activity_entries(activities_from_manifest, source_inferred_activities)

        analyses = []
        for activity_entry in activity_entries:
            activity_name = activity_entry["name"]
            simple_name = simple_name_of(activity_name)
            if target_activity and target_activity not in {activity_name, simple_name}:
                continue
            analyses.append(self.build_activity_analysis(activity_entry))

        return {
            "schema_version": "1.0.0",
            "generated_at": datetime.now(timezone.utc).isoformat(),
            "analyzed_activity": target_activity,
            "project": {
                "root_path": self.project_root.replace("\\", "/"),
                "name": self.manifest_data.get("application_label") or os.path.basename(self.project_root),
                "package_name": self.manifest_data.get("package_name"),
                "manifest_path": self.rel(self.manifest_path) if self.manifest_path else None,
            },
            "activities": analyses,
        }

    def discover_files(self) -> None:
        for root, dirs, files in os.walk(self.project_root):
            dirs[:] = [directory for directory in dirs if directory not in SKIP_DIR_NAMES]
            for file_name in files:
                path = os.path.join(root, file_name)
                if file_name == "AndroidManifest.xml":
                    if self.manifest_path is None and path.replace("\\", "/").endswith("src/main/AndroidManifest.xml"):
                        self.manifest_path = path
                    elif self.manifest_path is None:
                        self.manifest_path = path
                elif file_name.endswith((".kt", ".java")):
                    continue
                elif file_name.endswith(".xml") and "/res/layout" in path.replace("\\", "/"):
                    self.layout_files[os.path.splitext(file_name)[0]] = path
                elif file_name.endswith(".xml") and "/res/menu" in path.replace("\\", "/"):
                    self.menu_files[os.path.splitext(file_name)[0]] = path
                elif file_name.endswith(".xml") and "/res/navigation" in path.replace("\\", "/"):
                    self.navigation_files[os.path.splitext(file_name)[0]] = path

    def parse_manifest(self) -> None:
        if not self.manifest_path:
            self.manifest_data = {"activities": []}
            return
        tree = ET.parse(self.manifest_path)
        root = tree.getroot()
        package_name = root.attrib.get("package")
        application = root.find("application")
        application_label = None
        activities: List[Dict[str, object]] = []
        if application is not None:
            label_attr = application.attrib.get(f"{ANDROID_NS}label")
            application_label = self.resolve_resource_string(label_attr)
            for activity_tag in application.findall("activity") + application.findall("activity-alias"):
                name = activity_tag.attrib.get(f"{ANDROID_NS}name")
                if not name:
                    continue
                qualified_name = qualify_android_name(name, package_name)
                intent_filters = []
                for intent_filter in activity_tag.findall("intent-filter"):
                    actions = [item.attrib.get(f"{ANDROID_NS}name") for item in intent_filter.findall("action")]
                    categories = [item.attrib.get(f"{ANDROID_NS}name") for item in intent_filter.findall("category")]
                    data_values = [item.attrib for item in intent_filter.findall("data")]
                    intent_filters.append(
                        {
                            "actions": [value for value in actions if value],
                            "categories": [value for value in categories if value],
                            "data": data_values,
                        }
                    )
                activities.append(
                    {
                        "name": qualified_name,
                        "simple_name": simple_name_of(qualified_name),
                        "source": "manifest",
                        "manifest": {
                            "exported": activity_tag.attrib.get(f"{ANDROID_NS}exported"),
                            "theme": activity_tag.attrib.get(f"{ANDROID_NS}theme"),
                            "parent_activity": qualify_android_name(
                                activity_tag.attrib.get(f"{ANDROID_NS}parentActivityName", ""),
                                package_name,
                            )
                            if activity_tag.attrib.get(f"{ANDROID_NS}parentActivityName")
                            else None,
                            "launch_mode": activity_tag.attrib.get(f"{ANDROID_NS}launchMode"),
                            "intent_filters": intent_filters,
                            "location": {"file": self.rel(self.manifest_path), "line": find_line_for_text(self.read_text(self.manifest_path), name)},
                        },
                    }
                )
        self.manifest_data = {
            "package_name": package_name,
            "application_label": application_label,
            "activities": activities,
        }

    def parse_strings(self) -> None:
        for root, dirs, files in os.walk(self.project_root):
            dirs[:] = [directory for directory in dirs if directory not in SKIP_DIR_NAMES]
            normalized_root = root.replace("\\", "/")
            if "/res/values" not in normalized_root:
                continue
            for file_name in files:
                if not file_name.endswith(".xml"):
                    continue
                path = os.path.join(root, file_name)
                try:
                    tree = ET.parse(path)
                except ET.ParseError:
                    continue
                xml_root = tree.getroot()
                for element in xml_root.findall("string"):
                    name = element.attrib.get("name")
                    if name and name not in self.string_table:
                        self.string_table[name] = "".join(element.itertext()).strip()

    def parse_navigation_resources(self) -> None:
        for file_name, path in self.navigation_files.items():
            try:
                tree = ET.parse(path)
            except ET.ParseError:
                continue
            root = tree.getroot()
            for element in root.iter():
                destination_id = element.attrib.get(f"{ANDROID_NS}id")
                destination_name = element.attrib.get(f"{ANDROID_NS}name") or element.attrib.get(f"{TOOLS_NS}layout")
                if destination_id:
                    cleaned = clean_resource_id(destination_id)
                    if destination_name:
                        self.navigation_destinations[cleaned] = simple_name_of(destination_name)
                    else:
                        self.navigation_destinations[cleaned] = simple_name_of(cleaned)

    def parse_menu_resources(self) -> None:
        for menu_name, path in self.menu_files.items():
            try:
                tree = ET.parse(path)
            except ET.ParseError:
                continue
            root = tree.getroot()
            order = 0
            for item in root.iter():
                if simple_xml_tag(item.tag) != "item":
                    continue
                item_id = item.attrib.get(f"{ANDROID_NS}id")
                if not item_id:
                    continue
                order += 1
                title = self.resolve_resource_string(item.attrib.get(f"{ANDROID_NS}title"))
                info = MenuItemInfo(
                    menu_name=menu_name,
                    item_id=clean_resource_id(item_id),
                    title=title,
                    source=Location(self.rel(path), find_line_for_text(self.read_text(path), item_id)),
                    order=order,
                )
                self.menu_items_by_id[info.item_id].append(info)

    def parse_sources(self) -> None:
        source_files: List[str] = []
        for root, dirs, files in os.walk(self.project_root):
            dirs[:] = [directory for directory in dirs if directory not in SKIP_DIR_NAMES]
            for file_name in files:
                if file_name.endswith((".kt", ".java")):
                    source_files.append(os.path.join(root, file_name))

        all_classes: List[ClassInfo] = []
        all_methods: List[MethodInfo] = []
        for path in source_files:
            classes, methods = self.parse_source_file(path)
            all_classes.extend(classes)
            all_methods.extend(methods)

        for class_info in all_classes:
            self.class_map[class_info.name].append(class_info)
            self.class_by_qualified[class_info.qualified_name] = class_info
        for method in all_methods:
            self.methods.append(method)
            self.methods_by_name[method.name].append(method)
            owner_key = method.qualified_owner or method.owner_name
            self.methods_by_owner[owner_key][method.name].append(method)
            if method.owner_kind == "extension" and method.receiver_type:
                self.extension_methods[method.receiver_type].append(method)

    def parse_source_file(self, path: str) -> Tuple[List[ClassInfo], List[MethodInfo]]:
        text = self.read_text(path)
        package_name = extract_package_name(text)
        imports = extract_imports(text)
        classes = self.extract_classes(text, path, package_name, imports)
        methods = self.extract_methods(text, path, package_name, imports, classes)
        return classes, methods

    def extract_classes(self, text: str, path: str, package_name: str, imports: List[str]) -> List[ClassInfo]:
        classes: List[ClassInfo] = []
        class_pattern = re.compile(
            r"(?m)^[ \t]*(?:@[\w.]+(?:\([^\n]*\))?[ \t]*\n[ \t]*)*(?:public|private|protected|internal|open|abstract|sealed|data|enum|final|static|inner|\s)*\b(class|interface|object|enum)\s+([A-Za-z_][A-Za-z0-9_]*)\s*([^\n{]*)"
        )
        for match in class_pattern.finditer(text):
            kind = match.group(1)
            name = match.group(2)
            suffix = match.group(3) or ""
            brace_index = text.find("{", match.end())
            if brace_index == -1:
                continue
            end_index = find_matching_brace(text, brace_index)
            if end_index == -1:
                continue
            start_line = line_number_for_offset(text, match.start())
            end_line = line_number_for_offset(text, end_index)
            annotations = extract_leading_annotations(text, match.start())
            super_types = parse_super_types_from_class_suffix(suffix)
            body = text[brace_index + 1 : end_index]
            header_segment = text[match.start() : brace_index]
            fields = parse_fields(header_segment, body)
            qualified_name = qualify_with_package(name, package_name)
            classes.append(
                ClassInfo(
                    name=name,
                    qualified_name=qualified_name,
                    file_path=path,
                    package_name=package_name,
                    line=start_line,
                    end_line=end_line,
                    kind=kind,
                    annotations=annotations,
                    super_types=super_types,
                    imports=imports,
                    body=body,
                    fields=fields,
                )
            )
        return classes

    def extract_methods(
        self,
        text: str,
        path: str,
        package_name: str,
        imports: List[str],
        classes: List[ClassInfo],
    ) -> List[MethodInfo]:
        methods: List[MethodInfo] = []
        class_ranges = sorted(classes, key=lambda item: (item.line, item.end_line))

        kotlin_pattern = re.compile(
            r"(?m)^[ \t]*(?:@[\w.]+(?:\([^\n]*\))?[ \t]*\n[ \t]*)*(?:public|private|protected|internal|override|open|abstract|suspend|tailrec|operator|infix|inline|external|final|\s)*fun\s+(?:<[^>]+>\s*)?(?:(\w+)\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\("
        )
        java_pattern = re.compile(
            r"(?m)^[ \t]*(?:@[\w.]+(?:\([^\n]*\))?[ \t]*\n[ \t]*)*(?:public|private|protected|static|final|synchronized|abstract|native|override|\s)+([A-Za-z_][A-Za-z0-9_<>,\[\]? ]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^;\n]*\)\s*(?:throws [^{\n]+)?\{"
        )

        for match in kotlin_pattern.finditer(text):
            receiver_type = match.group(1)
            name = match.group(2)
            owner = find_enclosing_class(class_ranges, line_number_for_offset(text, match.start()))
            signature, body, start_line, end_line, body_start_line = extract_kotlin_callable(text, match.start(), match.end())
            if not signature:
                continue
            annotations = extract_leading_annotations(text, match.start())
            owner_name, qualified_owner, owner_kind, super_types, fields, class_kind = self.method_owner_context(owner, receiver_type)
            methods.append(
                MethodInfo(
                    name=name,
                    owner_name=owner_name,
                    qualified_owner=qualified_owner,
                    owner_kind=owner_kind,
                    receiver_type=receiver_type,
                    file_path=path,
                    line=start_line,
                    end_line=end_line,
                    body_start_line=body_start_line,
                    signature=signature,
                    body=body,
                    annotations=annotations,
                    package_name=package_name,
                    imports=imports,
                    super_types=super_types,
                    fields=fields,
                    class_kind=class_kind,
                )
            )

        for match in java_pattern.finditer(text):
            name = match.group(2)
            owner = find_enclosing_class(class_ranges, line_number_for_offset(text, match.start()))
            signature, body, start_line, end_line, body_start_line = extract_java_callable(text, match.start())
            if not signature:
                continue
            annotations = extract_leading_annotations(text, match.start())
            owner_name, qualified_owner, owner_kind, super_types, fields, class_kind = self.method_owner_context(owner, None)
            methods.append(
                MethodInfo(
                    name=name,
                    owner_name=owner_name,
                    qualified_owner=qualified_owner,
                    owner_kind=owner_kind,
                    receiver_type=None,
                    file_path=path,
                    line=start_line,
                    end_line=end_line,
                    body_start_line=body_start_line,
                    signature=signature,
                    body=body,
                    annotations=annotations,
                    package_name=package_name,
                    imports=imports,
                    super_types=super_types,
                    fields=fields,
                    class_kind=class_kind,
                )
            )

        return deduplicate_methods(methods)

    def method_owner_context(self, owner: Optional[ClassInfo], receiver_type: Optional[str]) -> Tuple[str, str, str, List[str], Dict[str, str], str]:
        if owner is not None:
            return (
                owner.name,
                owner.qualified_name,
                "class",
                owner.super_types,
                owner.fields,
                owner.kind,
            )
        if receiver_type:
            return (receiver_type, receiver_type, "extension", [], {}, "extension")
        return ("<top-level>", "<top-level>", "top_level", [], {}, "top_level")

    def infer_activities_from_source(self) -> List[Dict[str, object]]:
        activities = []
        seen: Set[str] = set()
        for candidates in self.class_map.values():
            for class_info in candidates:
                if any("Activity" in value for value in class_info.super_types) or class_info.name.endswith("Activity"):
                    if class_info.qualified_name in seen:
                        continue
                    seen.add(class_info.qualified_name)
                    activities.append(
                        {
                            "name": class_info.qualified_name,
                            "simple_name": class_info.name,
                            "source": "source_inference",
                            "manifest": None,
                        }
                    )
        return activities

    def merge_activity_entries(self, *activity_groups: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
        merged: List[Dict[str, object]] = []
        seen_names: Set[str] = set()
        seen_simple_names: Set[str] = set()
        for group in activity_groups:
            for activity_entry in group:
                activity_name = str(activity_entry.get("name") or "")
                simple_name = str(activity_entry.get("simple_name") or simple_name_of(activity_name))
                if activity_name and activity_name in seen_names:
                    continue
                if simple_name and simple_name in seen_simple_names:
                    continue
                merged.append(activity_entry)
                if activity_name:
                    seen_names.add(activity_name)
                if simple_name:
                    seen_simple_names.add(simple_name)
        return merged

    def build_activity_analysis(self, activity_entry: Dict[str, object]) -> Dict[str, object]:
        activity_name = str(activity_entry["name"])
        class_info = self.resolve_class(activity_name)
        surface = self.get_owner_surface(class_info)
        layouts = self.discover_layouts_for_surface(surface)
        layout_components, fragment_names = self.collect_layout_components(layouts)
        menus = self.discover_menus_for_surface(surface)
        menu_items = self.collect_menu_items(menus)

        ui_components = self.collect_ui_components(
            activity_name=simple_name_of(activity_name),
            owner_path=simple_name_of(activity_name),
            surface=surface,
            layout_components=layout_components,
            menu_items=menu_items,
        )

        fragment_analyses = []
        for fragment_name in sorted(set(fragment_names + self.discover_fragments_in_surface(surface))):
            fragment_class = self.resolve_class(fragment_name)
            if fragment_class is None:
                continue
            fragment_surface = self.get_owner_surface(fragment_class)
            fragment_layouts = self.discover_layouts_for_surface(fragment_surface)
            fragment_components, nested_fragments = self.collect_layout_components(fragment_layouts)
            fragment_ui = self.collect_ui_components(
                activity_name=simple_name_of(activity_name),
                owner_path=f"{simple_name_of(activity_name)} > {fragment_class.name}",
                surface=fragment_surface,
                layout_components=fragment_components,
                menu_items=self.collect_menu_items(self.discover_menus_for_surface(fragment_surface)),
            )
            fragment_entries = self.collect_page_data_entries(fragment_surface, f"{simple_name_of(activity_name)} > {fragment_class.name}")
            fragment_analyses.append(
                {
                    "fragment_name": fragment_class.name,
                    "source_file": self.rel(fragment_class.file_path),
                    "layouts": fragment_layouts,
                    "nested_fragment_refs": nested_fragments,
                    "ui_components": fragment_ui,
                    "page_data_loading_entries": fragment_entries,
                }
            )

        adapter_components = self.collect_adapter_components(simple_name_of(activity_name), surface)
        ui_components.extend(adapter_components)
        ui_components.sort(key=lambda item: (item.get("sort_order", 10_000), item.get("component_id") or "", item.get("source_location", {}).get("line") or 0))

        page_entries = self.collect_page_data_entries(surface, simple_name_of(activity_name))
        functional_evidence = self.build_functional_evidence(activity_entry, class_info, layouts, menus, fragment_names, page_entries, ui_components)

        return {
            "activity_name": simple_name_of(activity_name),
            "qualified_activity_name": activity_name,
            "source_file": self.rel(class_info.file_path) if class_info else None,
            "manifest": activity_entry.get("manifest"),
            "layouts": layouts,
            "menus": menus,
            "hosted_fragments": sorted(set(fragment_names)),
            "functional_evidence": functional_evidence,
            "ui_components": ui_components,
            "page_data_loading_entries": page_entries,
            "fragment_analyses": fragment_analyses,
        }

    def build_functional_evidence(
        self,
        activity_entry: Dict[str, object],
        class_info: Optional[ClassInfo],
        layouts: List[Dict[str, object]],
        menus: List[Dict[str, object]],
        fragment_names: List[str],
        page_entries: List[Dict[str, object]],
        ui_components: List[Dict[str, object]],
    ) -> List[Dict[str, object]]:
        evidence: List[Dict[str, object]] = []
        manifest = activity_entry.get("manifest") or {}
        if manifest:
            for intent_filter in manifest.get("intent_filters", []):
                for action in intent_filter.get("actions", []):
                    evidence.append(
                        {
                            "kind": "manifest_intent_action",
                            "value": action,
                            "source": manifest.get("location"),
                        }
                    )
        for layout in layouts:
            evidence.append(
                {
                    "kind": "layout",
                    "value": layout["layout_name"],
                    "source": layout["evidence"],
                }
            )
        for menu in menus:
            evidence.append(
                {
                    "kind": "menu",
                    "value": menu["menu_name"],
                    "source": menu["evidence"],
                }
            )
        for fragment_name in sorted(set(fragment_names)):
            evidence.append(
                {
                    "kind": "fragment_ref",
                    "value": fragment_name,
                    "source": {"file": self.rel(class_info.file_path), "line": class_info.line} if class_info else None,
                }
            )
        for entry in page_entries:
            evidence.append(
                {
                    "kind": "page_entry",
                    "value": entry["entry_name"],
                    "source": entry["source_location"],
                }
            )
        evidence.append(
            {
                "kind": "interactive_component_count",
                "value": len(ui_components),
                "source": {"file": self.rel(class_info.file_path), "line": class_info.line} if class_info else None,
            }
        )
        return evidence

    def get_owner_surface(self, class_info: Optional[ClassInfo]) -> Dict[str, object]:
        if class_info is None:
            return {"root_class": None, "classes": [], "methods": [], "extensions": []}
        classes = []
        visited: Set[str] = set()
        queue = [class_info]
        while queue:
            current = queue.pop(0)
            if current.qualified_name in visited:
                continue
            visited.add(current.qualified_name)
            classes.append(current)
            for super_type in current.super_types:
                super_class = self.resolve_class(super_type)
                if super_class is not None and super_class.qualified_name not in visited:
                    queue.append(super_class)

        methods: List[MethodInfo] = []
        extensions: List[MethodInfo] = []
        for class_item in classes:
            owner_key = class_item.qualified_name
            for method_group in self.methods_by_owner.get(owner_key, {}).values():
                methods.extend(method_group)
            for receiver_name in {class_item.name, class_item.qualified_name}:
                extensions.extend(self.extension_methods.get(receiver_name, []))
        deduped_methods = deduplicate_methods(methods + extensions)
        return {
            "root_class": class_info,
            "classes": classes,
            "methods": deduped_methods,
            "extensions": extensions,
        }

    def discover_layouts_for_surface(self, surface: Dict[str, object]) -> List[Dict[str, object]]:
        layouts: List[Dict[str, object]] = []
        seen: Set[Tuple[str, str]] = set()
        for class_info in surface.get("classes", []):
            file_text = self.read_text(class_info.file_path)
            for match in re.finditer(r"setContentView\s*\(\s*R\.layout\.([A-Za-z0-9_]+)", file_text):
                layout_name = match.group(1)
                if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
                    seen.add((layout_name, class_info.file_path))
                    layouts.append(
                        {
                            "layout_name": layout_name,
                            "layout_file": self.rel(self.layout_files[layout_name]),
                            "binding_type": "setContentView",
                            "evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
                        }
                    )
            for match in re.finditer(r"DataBindingUtil\.setContentView(?:<[^>]+>)?\s*\([^,]+,\s*R\.layout\.([A-Za-z0-9_]+)", file_text):
                layout_name = match.group(1)
                if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
                    seen.add((layout_name, class_info.file_path))
                    layouts.append(
                        {
                            "layout_name": layout_name,
                            "layout_file": self.rel(self.layout_files[layout_name]),
                            "binding_type": "data_binding",
                            "evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
                        }
                    )
            for match in re.finditer(r"([A-Za-z0-9_]+Binding)\.inflate\s*\(", file_text):
                layout_name = binding_class_to_layout(match.group(1))
                if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
                    seen.add((layout_name, class_info.file_path))
                    layouts.append(
                        {
                            "layout_name": layout_name,
                            "layout_file": self.rel(self.layout_files[layout_name]),
                            "binding_type": "view_binding",
                            "evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
                        }
                    )
            for match in re.finditer(r"inflate\s*\(\s*R\.layout\.([A-Za-z0-9_]+)\s*,", file_text):
                layout_name = match.group(1)
                if layout_name in self.layout_files and (layout_name, class_info.file_path) not in seen:
                    seen.add((layout_name, class_info.file_path))
                    layouts.append(
                        {
                            "layout_name": layout_name,
                            "layout_file": self.rel(self.layout_files[layout_name]),
                            "binding_type": "layout_inflater",
                            "evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
                        }
                    )
        return layouts

    def discover_menus_for_surface(self, surface: Dict[str, object]) -> List[Dict[str, object]]:
        menus: List[Dict[str, object]] = []
        seen: Set[Tuple[str, str]] = set()
        for class_info in surface.get("classes", []):
            file_text = self.read_text(class_info.file_path)
            for match in re.finditer(r"R\.menu\.([A-Za-z0-9_]+)", file_text):
                menu_name = match.group(1)
                if menu_name not in self.menu_files:
                    continue
                if (menu_name, class_info.file_path) in seen:
                    continue
                seen.add((menu_name, class_info.file_path))
                menus.append(
                    {
                        "menu_name": menu_name,
                        "menu_file": self.rel(self.menu_files[menu_name]),
                        "evidence": {"file": self.rel(class_info.file_path), "line": line_number_for_offset(file_text, match.start())},
                    }
                )
        return menus

    def discover_fragments_in_surface(self, surface: Dict[str, object]) -> List[str]:
        fragments: List[str] = []
        fragment_pattern = re.compile(r"(?:replace|add|show)\s*\([^\n]*?([A-Za-z_][A-Za-z0-9_]*Fragment)\s*\(")
        for class_info in surface.get("classes", []):
            text = self.read_text(class_info.file_path)
            fragments.extend(match.group(1) for match in fragment_pattern.finditer(text))
        return fragments

    def collect_layout_components(self, layouts: Sequence[Dict[str, object]]) -> Tuple[Dict[str, LayoutComponent], List[str]]:
        component_map: Dict[str, LayoutComponent] = {}
        fragment_names: List[str] = []
        for layout in layouts:
            layout_name = layout["layout_name"]
            components, fragments = self.parse_layout_recursive(layout_name)
            fragment_names.extend(fragments)
            for component in components:
                if component.component_id not in component_map:
                    component_map[component.component_id] = component
        return component_map, fragment_names

    def parse_layout_recursive(self, layout_name: str, visited: Optional[Set[str]] = None) -> Tuple[List[LayoutComponent], List[str]]:
        if visited is None:
            visited = set()
        if layout_name in visited:
            return [], []
        if layout_name in self.resource_component_cache:
            return self.resource_component_cache[layout_name]
        visited.add(layout_name)
        path = self.layout_files.get(layout_name)
        if not path:
            return [], []
        try:
            tree = ET.parse(path)
        except ET.ParseError:
            return [], []
        root = tree.getroot()
        components: List[LayoutComponent] = []
        fragments: List[str] = []
        order = 0
        file_text = self.read_text(path)

        def walk(element: ET.Element, depth: int) -> None:
            nonlocal order
            tag = simple_xml_tag(element.tag)
            if tag == "include":
                layout_ref = element.attrib.get("layout") or element.attrib.get(f"{ANDROID_NS}layout")
                if layout_ref and layout_ref.startswith("@layout/"):
                    nested_name = layout_ref.split("/", 1)[1]
                    nested_components, nested_fragments = self.parse_layout_recursive(nested_name, visited)
                    components.extend(nested_components)
                    fragments.extend(nested_fragments)
                return

            component_id_attr = element.attrib.get(f"{ANDROID_NS}id")
            component_id = clean_resource_id(component_id_attr) if component_id_attr else None
            label = self.resolve_first_label(element.attrib)
            on_click = element.attrib.get(f"{ANDROID_NS}onClick")
            clickable = element.attrib.get(f"{ANDROID_NS}clickable") == "true"
            if component_id:
                order += 1
                components.append(
                    LayoutComponent(
                        component_id=component_id,
                        view_type=tag,
                        label=label,
                        source=Location(self.rel(path), find_line_for_text(file_text, component_id_attr or component_id)),
                        depth=depth,
                        order=order,
                        on_click=on_click,
                        clickable=clickable or tag in INTERACTIVE_VIEW_TYPES,
                        layout_name=layout_name,
                    )
                )
            if tag == "fragment":
                fragment_name = element.attrib.get(f"{ANDROID_NS}name") or element.attrib.get("class")
                if fragment_name:
                    fragments.append(simple_name_of(fragment_name))
            for child in list(element):
                walk(child, depth + 1)

        walk(root, 0)
        self.resource_component_cache[layout_name] = (components, fragments)
        return components, fragments

    def collect_menu_items(self, menus: Sequence[Dict[str, object]]) -> Dict[str, MenuItemInfo]:
        menu_map: Dict[str, MenuItemInfo] = {}
        for menu in menus:
            for item_id, infos in self.menu_items_by_id.items():
                for info in infos:
                    if info.menu_name == menu["menu_name"] and item_id not in menu_map:
                        menu_map[item_id] = info
        return menu_map

    def collect_ui_components(
        self,
        activity_name: str,
        owner_path: str,
        surface: Dict[str, object],
        layout_components: Dict[str, LayoutComponent],
        menu_items: Dict[str, MenuItemInfo],
    ) -> List[Dict[str, object]]:
        items: List[Dict[str, object]] = []
        seen_keys: Set[Tuple[str, str, int]] = set()
        known_ids = set(layout_components.keys()) | set(menu_items.keys())

        for component in layout_components.values():
            if component.on_click:
                handler_method = self.resolve_method_in_surface(surface, component.on_click)
                if handler_method:
                    item = self.build_ui_component_from_handler(
                        activity_name=activity_name,
                        owner_path=owner_path,
                        component_id=component.component_id,
                        view_type=component.view_type,
                        label=component.label,
                        interaction_type="click",
                        handler_method=handler_method,
                        handler_body=handler_method.body,
                        source_location={"file": self.rel(handler_method.file_path), "line": handler_method.line},
                        layout_component=component,
                    )
                    key = (item["component_id"], item["interaction_type"], item["source_location"].get("line") or 0)
                    if key not in seen_keys:
                        seen_keys.add(key)
                        items.append(item)

        for method in surface.get("methods", []):
            extracted = self.extract_listener_events_from_method(method, known_ids, layout_components, menu_items)
            for event in extracted:
                item = self.build_ui_component_from_handler(
                    activity_name=activity_name,
                    owner_path=owner_path,
                    component_id=event["component_id"],
                    view_type=event["view_type"],
                    label=event.get("label"),
                    interaction_type=event["interaction_type"],
                    handler_method=event["handler_method"],
                    handler_body=event["handler_body"],
                    source_location=event["source_location"],
                    layout_component=layout_components.get(event["component_id"]),
                    sort_order=event.get("sort_order"),
                    resource_kind=event.get("resource_kind", "view"),
                )
                key = (item["component_id"], item["interaction_type"], item["source_location"].get("line") or 0)
                if key not in seen_keys:
                    seen_keys.add(key)
                    items.append(item)

        return items

    def build_ui_component_from_handler(
        self,
        activity_name: str,
        owner_path: str,
        component_id: str,
        view_type: str,
        label: Optional[str],
        interaction_type: str,
        handler_method: MethodInfo,
        handler_body: str,
        source_location: Dict[str, object],
        layout_component: Optional[LayoutComponent] = None,
        sort_order: Optional[int] = None,
        resource_kind: str = "view",
    ) -> Dict[str, object]:
        navigation = self.detect_navigation_targets(handler_body, activity_name)
        api_dependencies = self.trace_from_body(handler_method, handler_body, source_location.get("line") or handler_method.line)
        return {
            "activity": owner_path,
            "component_id": component_id,
            "view_type": view_type,
            "component_label": label,
            "resource_kind": resource_kind,
            "source_location": source_location,
            "interaction_type": interaction_type,
            "handler_entry": {
                "owner": handler_method.owner_name,
                "method_name": handler_method.name,
                "file": self.rel(handler_method.file_path),
                "line": handler_method.line,
            },
            "navigation_target": navigation,
            "api_dependencies": api_dependencies,
            "evidence": self.build_component_evidence(layout_component, handler_method),
            "sort_order": sort_order or (layout_component.order if layout_component else 50_000),
        }

    def build_component_evidence(self, layout_component: Optional[LayoutComponent], handler_method: MethodInfo) -> List[Dict[str, object]]:
        evidence = [
            {
                "kind": "handler_method",
                "source": {"file": self.rel(handler_method.file_path), "line": handler_method.line},
            }
        ]
        if layout_component is not None:
            evidence.append(
                {
                    "kind": "layout_component",
                    "source": {"file": layout_component.source.file, "line": layout_component.source.line},
                }
            )
        return evidence

    def collect_adapter_components(self, activity_name: str, surface: Dict[str, object]) -> List[Dict[str, object]]:
        root_classes: List[ClassInfo] = surface.get("classes", [])
        if not root_classes:
            return []
        root_texts = {item.file_path: self.read_text(item.file_path) for item in root_classes}
        referenced_adapter_names: Set[str] = set()
        for text in root_texts.values():
            for match in re.finditer(r"([A-Za-z_][A-Za-z0-9_]*Adapter)\s*\(", text):
                referenced_adapter_names.add(match.group(1))
            for match in re.finditer(r"new\s+([A-Za-z_][A-Za-z0-9_]*Adapter)\s*\(", text):
                referenced_adapter_names.add(match.group(1))

        items: List[Dict[str, object]] = []
        for adapter_name in sorted(referenced_adapter_names):
            adapter_class = self.resolve_class(adapter_name)
            if adapter_class is None:
                continue
            adapter_surface = self.get_owner_surface(adapter_class)
            adapter_layouts = self.discover_layouts_for_surface(adapter_surface)
            adapter_components, _ = self.collect_layout_components(adapter_layouts)
            known_ids = set(adapter_components.keys())
            for method in adapter_surface.get("methods", []):
                events = self.extract_listener_events_from_method(method, known_ids, adapter_components, {})
                for event in events:
                    component_id = event["component_id"]
                    if component_id == "<unknown>":
                        component_id = f"{snake_case(adapter_name)}_item_click"
                    item = self.build_ui_component_from_handler(
                        activity_name=activity_name,
                        owner_path=f"{activity_name} > {adapter_name}",
                        component_id=component_id,
                        view_type=event.get("view_type") or f"{adapter_name} Item",
                        label=event.get("label"),
                        interaction_type=event["interaction_type"],
                        handler_method=event["handler_method"],
                        handler_body=event["handler_body"],
                        source_location=event["source_location"],
                        layout_component=adapter_components.get(component_id),
                        sort_order=90_000 + (event.get("sort_order") or 0),
                        resource_kind="adapter_item",
                    )
                    items.append(item)
        return items

    def collect_page_data_entries(self, surface: Dict[str, object], owner_path: str) -> List[Dict[str, object]]:
        entries: List[Dict[str, object]] = []
        seen: Set[Tuple[str, int]] = set()
        for method in surface.get("methods", []):
            if not self.is_page_data_entry_method(method):
                continue
            dependencies = self.trace_method_dependencies(method, visited=set(), depth=0, chain=[])
            if not dependencies:
                continue
            key = (method.name, method.line)
            if key in seen:
                continue
            seen.add(key)
            entries.append(
                {
                    "activity": owner_path,
                    "entry_name": method.name,
                    "entry_type": self.classify_entry_type(method),
                    "purpose": humanize_entry_name(method.name),
                    "source_location": {"file": self.rel(method.file_path), "line": method.line},
                    "api_dependencies": dependencies,
                    "evidence": [
                        {
                            "kind": "entry_method",
                            "source": {"file": self.rel(method.file_path), "line": method.line},
                        }
                    ],
                }
            )
        entries.sort(key=lambda item: (item["source_location"].get("line") or 0, item["entry_name"]))
        return entries

    def is_page_data_entry_method(self, method: MethodInfo) -> bool:
        if method.name in LIFECYCLE_ENTRY_METHODS:
            return True
        return bool(CUSTOM_ENTRY_NAME_RE.match(method.name))

    def classify_entry_type(self, method: MethodInfo) -> str:
        if method.name in LIFECYCLE_ENTRY_METHODS:
            return "lifecycle"
        if method.name.lower().startswith("observe") or method.name.lower().startswith("setupobserver"):
            return "observer_setup"
        if method.name.lower().startswith("init"):
            return "initialization"
        if method.name.lower().startswith(("load", "fetch", "query", "refresh", "request")):
            return "data_loading"
        return "custom_entry"

    def extract_listener_events_from_method(
        self,
        method: MethodInfo,
        known_ids: Set[str],
        layout_components: Dict[str, LayoutComponent],
        menu_items: Dict[str, MenuItemInfo],
    ) -> List[Dict[str, object]]:
        body = method.body
        events: List[Dict[str, object]] = []

        listener_patterns = [
            ("setOnClickListener", "click"),
            ("setOnLongClickListener", "long_click"),
            ("setOnItemClickListener", "item_click"),
            ("setOnMenuItemClickListener", "menu_click"),
            ("setOnNavigationItemSelectedListener", "navigation_select"),
            ("setNavigationItemSelectedListener", "navigation_select"),
            ("setOnRefreshListener", "refresh"),
        ]

        for listener_name, interaction_type in listener_patterns:
            pattern = re.compile(rf"(?P<target>[A-Za-z0-9_\.<>?()]+)\s*\.\s*{listener_name}\s*(?P<tail>\{{|\(|::|this)")
            for match in pattern.finditer(body):
                event = self.listener_event_from_match(method, match, interaction_type, known_ids, layout_components, menu_items)
                if event:
                    events.append(event)

        explicit_this_click = re.compile(r"(?P<target>[A-Za-z0-9_\.<>?()]+)\s*\.\s*setOnClickListener\s*\(\s*this\s*\)")
        for match in explicit_this_click.finditer(body):
            component_id = self.resolve_component_id(match.group("target"), known_ids)
            handler_method = self.resolve_method_for_name(method, "onClick")
            if component_id and handler_method:
                events.append(
                    {
                        "component_id": component_id,
                        "view_type": layout_components.get(component_id).view_type if component_id in layout_components else "View",
                        "label": layout_components.get(component_id).label if component_id in layout_components else None,
                        "interaction_type": "click",
                        "handler_method": handler_method,
                        "handler_body": handler_method.body,
                        "source_location": {"file": self.rel(method.file_path), "line": method.body_start_line + count_newlines(body[: match.start()])},
                        "sort_order": layout_components.get(component_id).order if component_id in layout_components else 50_000,
                    }
                )

        if method.name in {"onOptionsItemSelected", "onNavigationItemSelected"}:
            events.extend(self.extract_branch_events(method, layout_components, menu_items))

        return events

    def listener_event_from_match(
        self,
        method: MethodInfo,
        match: re.Match[str],
        interaction_type: str,
        known_ids: Set[str],
        layout_components: Dict[str, LayoutComponent],
        menu_items: Dict[str, MenuItemInfo],
    ) -> Optional[Dict[str, object]]:
        target_expr = match.group("target")
        tail = match.group("tail")
        component_id = self.resolve_component_id(target_expr, known_ids)
        if not component_id:
            if target_expr.endswith("itemView") or target_expr.endswith("binding.root"):
                component_id = "<unknown>"
            else:
                return None

        event_line = method.body_start_line + count_newlines(method.body[: match.start()])
        handler_method = method
        handler_body = method.body
        if tail == "::":
            reference_match = re.match(r"::\s*([A-Za-z_][A-Za-z0-9_]*)", method.body[match.end() :].lstrip())
            if reference_match:
                referenced = self.resolve_method_for_name(method, reference_match.group(1))
                if referenced:
                    handler_method = referenced
                    handler_body = referenced.body
        elif tail == "{":
            brace_pos = method.body.find("{", match.end() - 1)
            if brace_pos != -1:
                end = find_matching_brace(method.body, brace_pos)
                if end != -1:
                    handler_body = method.body[brace_pos + 1 : end]
        elif tail == "(":
            following = method.body[match.end() - 1 :]
            anon_pos = following.find("{")
            if anon_pos != -1:
                absolute = match.end() - 1 + anon_pos
                end = find_matching_brace(method.body, absolute)
                if end != -1:
                    anon_body = method.body[absolute + 1 : end]
                    nested = self.extract_anonymous_callback_body(anon_body)
                    if nested:
                        handler_body = nested
        elif tail == "this":
            resolved = self.resolve_method_for_name(method, "onClick")
            if resolved:
                handler_method = resolved
                handler_body = resolved.body

        component = layout_components.get(component_id)
        menu = menu_items.get(component_id)
        view_type = component.view_type if component else ("MenuItem" if menu else "View")
        label = component.label if component else (menu.title if menu else None)
        sort_order = component.order if component else (80_000 + (menu.order if menu else 0))
        resource_kind = "menu_item" if menu else "view"
        return {
            "component_id": component_id,
            "view_type": view_type,
            "label": label,
            "interaction_type": interaction_type,
            "handler_method": handler_method,
            "handler_body": handler_body,
            "source_location": {"file": self.rel(method.file_path), "line": event_line},
            "sort_order": sort_order,
            "resource_kind": resource_kind,
        }

    def extract_branch_events(
        self,
        method: MethodInfo,
        layout_components: Dict[str, LayoutComponent],
        menu_items: Dict[str, MenuItemInfo],
    ) -> List[Dict[str, object]]:
        body = method.body
        branch_regexes = [re.compile(r"R\.id\.([A-Za-z0-9_]+)\s*->"), re.compile(r"case\s+R\.id\.([A-Za-z0-9_]+)\s*:")]
        matches: List[Tuple[str, int, int]] = []
        for regex in branch_regexes:
            for match in regex.finditer(body):
                matches.append((match.group(1), match.start(), match.end()))
        matches.sort(key=lambda item: item[1])

        events: List[Dict[str, object]] = []
        for index, (component_id, start, _) in enumerate(matches):
            next_start = matches[index + 1][1] if index + 1 < len(matches) else len(body)
            snippet = body[start:next_start]
            component = layout_components.get(component_id)
            menu = menu_items.get(component_id)
            events.append(
                {
                    "component_id": component_id,
                    "view_type": component.view_type if component else "MenuItem",
                    "label": component.label if component else (menu.title if menu else None),
                    "interaction_type": "menu_select",
                    "handler_method": method,
                    "handler_body": snippet,
                    "source_location": {"file": self.rel(method.file_path), "line": method.body_start_line + count_newlines(body[:start])},
                    "sort_order": component.order if component else (80_000 + (menu.order if menu else index)),
                    "resource_kind": "menu_item",
                }
            )
        return events

    def resolve_component_id(self, target_expr: str, known_ids: Set[str]) -> Optional[str]:
        id_match = re.search(r"R\.id\.([A-Za-z0-9_]+)", target_expr)
        if id_match:
            return id_match.group(1)
        if "binding." in target_expr:
            prop = target_expr.split("binding.", 1)[1].split(".", 1)[0]
            return resolve_view_token(prop, known_ids)
        token = target_expr.split(".")[-1].split("?")[-1]
        return resolve_view_token(token, known_ids)

    def extract_anonymous_callback_body(self, anon_body: str) -> Optional[str]:
        for name in ("onClick", "onLongClick", "onMenuItemClick", "onRefresh"):
            matcher = re.search(rf"{name}\s*\([^)]*\)\s*\{{", anon_body)
            if not matcher:
                continue
            brace_pos = anon_body.find("{", matcher.start())
            if brace_pos == -1:
                continue
            end = find_matching_brace(anon_body, brace_pos)
            if end == -1:
                continue
            return anon_body[brace_pos + 1 : end]
        return None

    def detect_navigation_targets(self, body: str, activity_name: str) -> Dict[str, object]:
        targets: List[Tuple[str, str]] = []
        for match in re.finditer(r"Intent\s*\([^\n]*?([A-Za-z_][A-Za-z0-9_]*Activity)::class\.java", body):
            targets.append(("activity", match.group(1)))
        for match in re.finditer(r"new\s+Intent\s*\([^\n]*?,\s*([A-Za-z_][A-Za-z0-9_]*Activity)\.class", body):
            targets.append(("activity", match.group(1)))
        for match in re.finditer(r"navigate\s*\(\s*R\.id\.([A-Za-z0-9_]+)", body):
            destination_id = match.group(1)
            targets.append(("activity", self.navigation_destinations.get(destination_id, destination_id)))
        for match in re.finditer(r"(?:replace|add|show)\s*\([^\n]*?([A-Za-z_][A-Za-z0-9_]*Fragment)\s*\(", body):
            fragment_name = match.group(1)
            targets.append(("fragment", f"{fragment_name} (Fragment)"))
        if re.search(r"\bfinish\s*\(", body):
            targets.append(("back", "[Back]"))
        if re.search(r"AlertDialog\.Builder|DialogFragment|BottomSheetDialogFragment|\.show\s*\(", body):
            targets.append(("dialog", f"{activity_name} (self)"))
        if re.search(r"Intent\.ACTION_VIEW|Intent\.ACTION_SEND|Intent\.createChooser|Uri\.parse\s*\(", body):
            targets.append(("external", "N/A (external)"))
        if re.search(r"startService\s*\(|bindService\s*\(|startForegroundService\s*\(", body):
            targets.append(("self", f"{activity_name} (self)"))
        if not targets and re.search(r"submitList\s*\(|notifyDataSetChanged\s*\(|setVisibility\s*\(|isVisible\s*=|setText\s*\(|adapter\s*=|refresh\s*\(", body):
            targets.append(("self", f"{activity_name} (self)"))

        deduped: List[Tuple[str, str]] = []
        seen = set()
        for item in targets:
            if item not in seen:
                seen.add(item)
                deduped.append(item)
        if not deduped:
            return {"target_type": "unknown", "target": "N/A", "candidates": []}
        target_type = deduped[0][0] if len({kind for kind, _ in deduped}) == 1 else "mixed"
        return {
            "target_type": target_type,
            "target": " / ".join(value for _, value in deduped),
            "candidates": [{"type": kind, "target": value} for kind, value in deduped],
        }

    def trace_from_body(self, method: MethodInfo, body: str, body_line: int) -> List[Dict[str, object]]:
        dependencies = self.detect_system_api_dependencies(body, method, body_line, chain=[])
        additional = self.trace_calls_in_body(method, body, body_line, depth=0, visited=set(), chain=[])
        return deduplicate_dependency_dicts(dependencies + additional)

    def trace_method_dependencies(
        self,
        method: MethodInfo,
        visited: Set[Tuple[str, int, str]],
        depth: int,
        chain: List[Dict[str, object]],
    ) -> List[Dict[str, object]]:
        key = (method.file_path, method.line, method.name)
        if key in visited or depth > self.max_depth:
            return []
        visited = set(visited)
        visited.add(key)
        dependencies = self.detect_system_api_dependencies(method.body, method, method.body_start_line, chain)
        dependencies.extend(self.trace_calls_in_body(method, method.body, method.body_start_line, depth, visited, chain))
        return deduplicate_dependency_dicts(dependencies)

    def trace_calls_in_body(
        self,
        method: MethodInfo,
        body: str,
        body_line: int,
        depth: int,
        visited: Set[Tuple[str, int, str]],
        chain: List[Dict[str, object]],
    ) -> List[Dict[str, object]]:
        if depth >= self.max_depth:
            return []
        dependencies: List[Dict[str, object]] = []
        for call in extract_call_sites(body, body_line):
            resolved = self.resolve_call(method, call)
            if resolved is None:
                continue
            step = {
                "from": f"{method.owner_name}.{method.name}",
                "to": f"{resolved.owner_name}.{resolved.name}",
                "call": call.raw,
                "source": {"file": self.rel(method.file_path), "line": call.line},
            }
            nested = self.trace_method_dependencies(resolved, visited, depth + 1, chain + [step])
            include_current = self.should_include_method_dependency(resolved, nested)
            if include_current:
                dependencies.append(self.method_dependency_record(resolved, chain + [step]))
            dependencies.extend(nested)
        return dependencies

    def resolve_call(self, current_method: MethodInfo, call: CallSite) -> Optional[MethodInfo]:
        candidates: List[MethodInfo] = []
        if call.qualifier:
            qualifier_tokens = [part for part in re.split(r"[?.]", call.qualifier) if part]
            qualifier = qualifier_tokens[-1] if qualifier_tokens else call.qualifier
            if qualifier in {"this", "super"}:
                candidates.extend(self.lookup_owner_methods(current_method, call.name, include_extensions=True))
            elif qualifier in current_method.fields:
                type_name = current_method.fields[qualifier]
                candidates.extend(self.lookup_methods_by_type(type_name, call.name))
            elif qualifier_tokens and qualifier_tokens[0] in current_method.fields:
                type_name = current_method.fields[qualifier_tokens[0]]
                candidates.extend(self.lookup_methods_by_type(type_name, call.name))
            elif qualifier and qualifier[:1].isupper():
                candidates.extend(self.lookup_methods_by_type(qualifier, call.name))
        else:
            candidates.extend(self.lookup_owner_methods(current_method, call.name, include_extensions=True))

        unique = unique_method(candidates)
        if unique:
            return unique

        interesting = [method for method in self.methods_by_name.get(call.name, []) if self.classify_method_dependency_type(method) != "custom_method"]
        if len(interesting) == 1:
            return interesting[0]
        if len(self.methods_by_name.get(call.name, [])) == 1:
            return self.methods_by_name[call.name][0]
        return None

    def lookup_owner_methods(self, method: MethodInfo, method_name: str, include_extensions: bool = False) -> List[MethodInfo]:
        results: List[MethodInfo] = []
        owner_names = [method.qualified_owner, method.owner_name]
        for owner_name in owner_names:
            results.extend(self.methods_by_owner.get(owner_name, {}).get(method_name, []))
        for super_type in method.super_types:
            super_class = self.resolve_class(super_type)
            if super_class:
                results.extend(self.methods_by_owner.get(super_class.qualified_name, {}).get(method_name, []))
                results.extend(self.methods_by_owner.get(super_class.name, {}).get(method_name, []))
            if include_extensions:
                results.extend([item for item in self.extension_methods.get(super_type, []) if item.name == method_name])
                if super_class:
                    results.extend([item for item in self.extension_methods.get(super_class.qualified_name, []) if item.name == method_name])
        if include_extensions:
            for receiver in {method.owner_name, method.qualified_owner}:
                results.extend([item for item in self.extension_methods.get(receiver, []) if item.name == method_name])
        return deduplicate_methods(results)

    def lookup_methods_by_type(self, type_name: str, method_name: str) -> List[MethodInfo]:
        results: List[MethodInfo] = []
        resolved_class = self.resolve_class(type_name)
        if resolved_class:
            results.extend(self.methods_by_owner.get(resolved_class.qualified_name, {}).get(method_name, []))
            results.extend(self.methods_by_owner.get(resolved_class.name, {}).get(method_name, []))
            results.extend([item for item in self.extension_methods.get(resolved_class.name, []) if item.name == method_name])
        else:
            results.extend(self.methods_by_owner.get(type_name, {}).get(method_name, []))
        return deduplicate_methods(results)

    def should_include_method_dependency(self, method: MethodInfo, nested: List[Dict[str, object]]) -> bool:
        category = self.classify_method_dependency_type(method)
        if category != "custom_method":
            return True
        if method.name in LIFECYCLE_ENTRY_METHODS | {"onClick", "onOptionsItemSelected", "onNavigationItemSelected"}:
            return False
        if nested:
            return False
        return True

    def classify_method_dependency_type(self, method: MethodInfo) -> str:
        annotation_names = {annotation.split("(", 1)[0].lstrip("@").split(".")[-1] for annotation in method.annotations}
        owner_class = self.resolve_class(method.owner_name) or self.resolve_class(method.qualified_owner)
        owner_annotations = set(owner_class.annotations if owner_class else [])
        owner_simple = method.owner_name
        if annotation_names & RETROFIT_ANNOTATIONS:
            return "network_api"
        if annotation_names & DAO_ANNOTATIONS or "@Dao" in owner_annotations or owner_simple.endswith("Dao"):
            return "dao_method"
        if owner_simple.endswith(("Repository", "Repo")):
            return "repository_method"
        if owner_simple.endswith(("UseCase", "Interactor")):
            return "usecase_method"
        return "custom_method"

    def method_dependency_record(self, method: MethodInfo, chain: List[Dict[str, object]]) -> Dict[str, object]:
        return {
            "api_type": self.classify_method_dependency_type(method),
            "declaration": compact_signature(method.signature, method.owner_name),
            "owner": method.owner_name,
            "source": {"file": self.rel(method.file_path), "line": method.line},
            "evidence_chain": chain,
        }

    def detect_system_api_dependencies(
        self,
        body: str,
        method: MethodInfo,
        body_line: int,
        chain: List[Dict[str, object]],
    ) -> List[Dict[str, object]]:
        dependencies: List[Dict[str, object]] = []
        patterns = [
            (re.compile(r"\bcontentResolver\.(query|insert|update|delete|openInputStream|openOutputStream)\s*\("), "content_resolver", lambda m: f"android.content.ContentResolver.{m.group(1)}(...)"),
            (re.compile(r"\bMediaStore\b"), "mediastore_query", lambda _m: "android.provider.MediaStore"),
            (re.compile(r"\bregisterForActivityResult\s*\("), "system_intent", lambda _m: "androidx.activity.result.ActivityResultCaller.registerForActivityResult(...)"),
            (re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*Launcher\.launch\s*\("), "system_intent", lambda _m: "androidx.activity.result.ActivityResultLauncher.launch(...)"),
            (re.compile(r"\bstartActivity(?:ForResult)?\s*\("), "system_intent", lambda _m: "android.app.Activity.startActivity(...)"),
            (re.compile(r"\bstartService\s*\("), "service_call", lambda _m: "android.content.Context.startService(...)"),
            (re.compile(r"\bbindService\s*\("), "service_call", lambda _m: "android.content.Context.bindService(...)"),
            (re.compile(r"\bstartForegroundService\s*\("), "service_call", lambda _m: "android.content.Context.startForegroundService(...)"),
            (re.compile(r"\bIntent\s*\("), "system_intent", lambda _m: "android.content.Intent(...)"),
        ]
        for regex, api_type, declaration_builder in patterns:
            for match in regex.finditer(body):
                dependencies.append(
                    {
                        "api_type": api_type,
                        "declaration": declaration_builder(match),
                        "owner": "android",
                        "source": {"file": self.rel(method.file_path), "line": body_line + count_newlines(body[: match.start()])},
                        "evidence_chain": chain,
                    }
                )
        return deduplicate_dependency_dicts(dependencies)

    def resolve_method_in_surface(self, surface: Dict[str, object], method_name: str) -> Optional[MethodInfo]:
        candidates = [method for method in surface.get("methods", []) if method.name == method_name]
        return unique_method(candidates) or (candidates[0] if candidates else None)

    def resolve_method_for_name(self, method: MethodInfo, name: str) -> Optional[MethodInfo]:
        candidates = self.lookup_owner_methods(method, name, include_extensions=True)
        return unique_method(candidates) or (candidates[0] if candidates else None)

    def resolve_class(self, class_name: Optional[str]) -> Optional[ClassInfo]:
        if not class_name:
            return None
        normalized = class_name.replace("$", ".")
        if normalized in self.class_by_qualified:
            return self.class_by_qualified[normalized]
        simple = simple_name_of(normalized)
        candidates = self.class_map.get(simple, [])
        if len(candidates) == 1:
            return candidates[0]
        package_name = self.manifest_data.get("package_name")
        if package_name:
            qualified = qualify_android_name(normalized, str(package_name))
            if qualified in self.class_by_qualified:
                return self.class_by_qualified[qualified]
        return candidates[0] if candidates else None

    def resolve_first_label(self, attributes: Dict[str, str]) -> Optional[str]:
        for key in (f"{ANDROID_NS}text", f"{ANDROID_NS}contentDescription", f"{ANDROID_NS}hint", f"{ANDROID_NS}title"):
            if key in attributes:
                return self.resolve_resource_string(attributes.get(key))
        return None

    def resolve_resource_string(self, value: Optional[str]) -> Optional[str]:
        if not value:
            return value
        if value.startswith("@string/"):
            return self.string_table.get(value.split("/", 1)[1], value)
        return value

    def read_text(self, path: str) -> str:
        if path not in self.file_cache:
            try:
                with open(path, "r", encoding="utf-8", errors="ignore") as handle:
                    self.file_cache[path] = handle.read()
            except FileNotFoundError:
                self.file_cache[path] = ""
        return self.file_cache[path]

    def rel(self, path: Optional[str]) -> Optional[str]:
        if not path:
            return path
        return Path(os.path.relpath(path, self.project_root)).as_posix()


def extract_package_name(text: str) -> str:
    match = re.search(r"(?m)^\s*package\s+([A-Za-z0-9_.]+)", text)
    return match.group(1) if match else ""


def extract_imports(text: str) -> List[str]:
    return re.findall(r"(?m)^\s*import\s+([A-Za-z0-9_.*]+)", text)


def parse_super_types_from_class_suffix(suffix: str) -> List[str]:
    suffix = suffix.strip()
    if not suffix:
        return []
    result: List[str] = []
    if ":" in suffix:
        after_colon = suffix.split(":", 1)[1]
        for item in after_colon.split(","):
            cleaned = clean_type_name(item)
            if cleaned:
                result.append(cleaned)
    else:
        extends_match = re.search(r"extends\s+([A-Za-z0-9_.<>]+)", suffix)
        if extends_match:
            result.append(clean_type_name(extends_match.group(1)))
        implements_match = re.search(r"implements\s+([A-Za-z0-9_., <>]+)", suffix)
        if implements_match:
            result.extend(clean_type_name(part) for part in implements_match.group(1).split(",") if clean_type_name(part))
    return [value for value in result if value]


def parse_fields(header: str, body: str) -> Dict[str, str]:
    fields: Dict[str, str] = {}
    for match in re.finditer(r"(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z0-9_.,<>? ]+)", header):
        fields[match.group(1)] = clean_type_name(match.group(2))
    for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public|internal)?\s*(?:lateinit\s+)?(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z0-9_.,<>? ]+)", body):
        fields[match.group(1)] = clean_type_name(match.group(2))
    for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public|internal)?\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*([A-Z][A-Za-z0-9_<>]*)\s*\(", body):
        fields[match.group(1)] = clean_type_name(match.group(2))
    for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public|internal)?\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s+by\s+[A-Za-z0-9_]+<([A-Za-z_][A-Za-z0-9_]*)>", body):
        fields[match.group(1)] = clean_type_name(match.group(2))
    for match in re.finditer(r"(?m)^[ \t]*(?:private|protected|public)\s+(?:final\s+)?([A-Z][A-Za-z0-9_<>?,. ]+)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|;)", body):
        fields[match.group(2)] = clean_type_name(match.group(1))
    return fields


def extract_leading_annotations(text: str, start_index: int) -> List[str]:
    lines = text[:start_index].splitlines()
    annotations: List[str] = []
    for line in reversed(lines[-6:]):
        stripped = line.strip()
        if not stripped:
            if annotations:
                break
            continue
        if stripped.startswith("@"):
            annotations.insert(0, stripped)
            continue
        break
    return annotations


def extract_kotlin_callable(text: str, start_index: int, after_name_index: int) -> Tuple[str, str, int, int, int]:
    line_start = text.rfind("\n", 0, start_index) + 1
    signature_start_line = line_number_for_offset(text, start_index)
    search_limit = min(len(text), after_name_index + 3000)
    segment = text[after_name_index:search_limit]
    body_offset = None
    body_mode = None
    for relative_index, char in enumerate(segment):
        if char == "{":
            body_offset = after_name_index + relative_index
            body_mode = "block"
            break
        if char == "=":
            body_offset = after_name_index + relative_index
            body_mode = "expression"
            break
    if body_offset is None:
        signature_end = text.find("\n", line_start)
        signature = text[line_start:signature_end if signature_end != -1 else len(text)].strip()
        return signature, "", signature_start_line, signature_start_line, signature_start_line
    if body_mode == "block":
        end = find_matching_brace(text, body_offset)
        if end == -1:
            return "", "", 0, 0, 0
        signature = text[line_start:body_offset].strip()
        body = text[body_offset + 1 : end]
        return (
            compact_whitespace(signature),
            body,
            signature_start_line,
            line_number_for_offset(text, end),
            line_number_for_offset(text, body_offset + 1),
        )
    line_end = text.find("\n", body_offset)
    if line_end == -1:
        line_end = len(text)
    signature = text[line_start:body_offset].strip()
    body = text[body_offset + 1 : line_end].strip()
    return compact_whitespace(signature), body, signature_start_line, line_number_for_offset(text, line_end), signature_start_line


def extract_java_callable(text: str, start_index: int) -> Tuple[str, str, int, int, int]:
    line_start = text.rfind("\n", 0, start_index) + 1
    brace_pos = text.find("{", start_index)
    if brace_pos == -1:
        return "", "", 0, 0, 0
    end = find_matching_brace(text, brace_pos)
    if end == -1:
        return "", "", 0, 0, 0
    signature = text[line_start:brace_pos].strip()
    body = text[brace_pos + 1 : end]
    start_line = line_number_for_offset(text, start_index)
    return compact_whitespace(signature), body, start_line, line_number_for_offset(text, end), line_number_for_offset(text, brace_pos + 1)


def find_enclosing_class(classes: Sequence[ClassInfo], line: int) -> Optional[ClassInfo]:
    candidates = [item for item in classes if item.line <= line <= item.end_line]
    if not candidates:
        return None
    return sorted(candidates, key=lambda item: (item.end_line - item.line))[0]


def compact_signature(signature: str, owner_name: str) -> str:
    signature = compact_whitespace(signature)
    if signature.startswith("fun") or signature.startswith("public") or signature.startswith("private") or signature.startswith("protected"):
        return signature
    return f"{owner_name}.{signature}"


def compact_whitespace(value: str) -> str:
    return re.sub(r"\s+", " ", value).strip()


def line_number_for_offset(text: str, offset: int) -> int:
    return text.count("\n", 0, max(offset, 0)) + 1


def count_newlines(text: str) -> int:
    return text.count("\n")


def find_matching_brace(text: str, open_index: int) -> int:
    depth = 0
    in_single = False
    in_double = False
    in_line_comment = False
    in_block_comment = False
    escape = False
    for index in range(open_index, len(text)):
        char = text[index]
        nxt = text[index + 1] if index + 1 < len(text) else ""
        if in_line_comment:
            if char == "\n":
                in_line_comment = False
            continue
        if in_block_comment:
            if char == "*" and nxt == "/":
                in_block_comment = False
            continue
        if in_single:
            if char == "'" and not escape:
                in_single = False
            escape = char == "\\" and not escape
            continue
        if in_double:
            if char == '"' and not escape:
                in_double = False
            escape = char == "\\" and not escape
            continue
        if char == "/" and nxt == "/":
            in_line_comment = True
            continue
        if char == "/" and nxt == "*":
            in_block_comment = True
            continue
        if char == "'":
            in_single = True
            continue
        if char == '"':
            in_double = True
            continue
        if char == "{":
            depth += 1
        elif char == "}":
            depth -= 1
            if depth == 0:
                return index
    return -1


def extract_call_sites(body: str, body_line: int) -> List[CallSite]:
    calls: List[CallSite] = []
    occupied: List[Tuple[int, int]] = []
    qualified_pattern = re.compile(r"(?P<qualifier>[A-Za-z_][A-Za-z0-9_?.]*)\s*\.\s*(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(")
    unqualified_pattern = re.compile(r"(?<![\w.])(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\(")

    for match in qualified_pattern.finditer(body):
        name = match.group("name")
        if name in SKIP_CALL_NAMES:
            continue
        calls.append(
            CallSite(
                name=name,
                qualifier=match.group("qualifier"),
                line=body_line + count_newlines(body[: match.start()]),
                raw=compact_whitespace(match.group(0)),
            )
        )
        occupied.append((match.start(), match.end()))

    for match in unqualified_pattern.finditer(body):
        name = match.group("name")
        if name in SKIP_CALL_NAMES or name[:1].isupper():
            continue
        if any(start <= match.start() < end for start, end in occupied):
            continue
        calls.append(
            CallSite(
                name=name,
                qualifier=None,
                line=body_line + count_newlines(body[: match.start()]),
                raw=compact_whitespace(match.group(0)),
            )
        )
    return calls


def unique_method(methods: Sequence[MethodInfo]) -> Optional[MethodInfo]:
    methods = deduplicate_methods(methods)
    return methods[0] if len(methods) == 1 else None


def deduplicate_methods(methods: Sequence[MethodInfo]) -> List[MethodInfo]:
    result: List[MethodInfo] = []
    seen: Set[Tuple[str, int, str, str]] = set()
    for method in methods:
        key = (method.file_path, method.line, method.owner_name, method.name)
        if key in seen:
            continue
        seen.add(key)
        result.append(method)
    return result


def deduplicate_dependency_dicts(items: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
    result: List[Dict[str, object]] = []
    seen: Set[Tuple[str, str, str, int]] = set()
    for item in items:
        source = item.get("source") or {}
        key = (
            str(item.get("api_type")),
            str(item.get("declaration")),
            str(source.get("file")),
            int(source.get("line") or 0),
        )
        if key in seen:
            continue
        seen.add(key)
        result.append(item)
    return result


def find_line_for_text(text: str, needle: Optional[str]) -> Optional[int]:
    if not needle:
        return None
    position = text.find(needle)
    if position == -1:
        return None
    return line_number_for_offset(text, position)


def clean_type_name(value: str) -> str:
    value = value.strip().rstrip("?")
    if not value:
        return value
    value = value.split("<", 1)[0]
    value = value.split("(", 1)[0]
    value = value.split(" ", 1)[0]
    return simple_name_of(value)


def simple_name_of(value: str) -> str:
    return value.split(".")[-1].replace("$", ".").split(".")[-1]


def qualify_with_package(name: str, package_name: str) -> str:
    return f"{package_name}.{name}" if package_name else name


def qualify_android_name(name: str, package_name: Optional[str]) -> str:
    if not name:
        return name
    if name.startswith(".") and package_name:
        return f"{package_name}{name}"
    if "." not in name and package_name:
        return f"{package_name}.{name}"
    return name


def clean_resource_id(value: str) -> str:
    return value.replace("@+id/", "").replace("@id/", "")


def simple_xml_tag(tag: str) -> str:
    if "}" in tag:
        return tag.split("}", 1)[1]
    return tag.split(".")[-1]


def binding_class_to_layout(binding_name: str) -> str:
    return snake_case(binding_name.removesuffix("Binding"))


def snake_case(value: str) -> str:
    first = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", value)
    second = re.sub("([a-z0-9])([A-Z])", r"\1_\2", first)
    return second.replace("__", "_").lower()


def resolve_view_token(token: str, known_ids: Set[str]) -> Optional[str]:
    candidates = [token, snake_case(token)]
    for candidate in candidates:
        if candidate in known_ids:
            return candidate
    return None


def humanize_entry_name(name: str) -> str:
    lowered = name.lower()
    if lowered == "oncreate":
        return "Initialize the screen and trigger first-load work"
    if lowered == "onstart":
        return "Refresh data when the screen becomes visible"
    if lowered == "onresume":
        return "Resume page state and refresh visible data"
    if lowered.startswith("setupobserver") or lowered.startswith("observe"):
        return "Attach observers that can trigger UI data updates"
    if lowered.startswith("init"):
        return "Initialize page state required for first render"
    if lowered.startswith(("load", "fetch", "query", "refresh", "request")):
        return "Load or refresh data for the page"
    return f"Execute {name} for page rendering"


def main(argv: Optional[Sequence[str]] = None) -> int:
    parser = argparse.ArgumentParser(description="Analyze Android UI navigation and API dependencies.")
    parser.add_argument("--project", required=True, help="Android project root path")
    parser.add_argument("--output", required=True, help="Output JSON path")
    parser.add_argument("--activity", help="Analyze only one Activity by simple or qualified name")
    parser.add_argument("--max-depth", type=int, default=4, help="Maximum static call-trace depth (default: 4)")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
    args = parser.parse_args(argv)

    analyzer = AndroidUIApiAnalyzer(args.project, max_depth=args.max_depth)
    result = analyzer.analyze(target_activity=args.activity)

    output_path = os.path.abspath(args.output)
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as handle:
        json.dump(result, handle, indent=2 if args.pretty else None, ensure_ascii=False)
        handle.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())