from __future__ import annotations

import ast
import os
from pathlib import Path
import re
from typing import Any

try:
    import tomllib
except ModuleNotFoundError:
    import tomli as tomllib  # type: ignore[no-redef]


_SKIP_DIRS = frozenset({
    "__pycache__", ".venv", "venv", ".git", "node_modules",
    "test", "tests", "dist", "build", ".tox", ".mypy_cache",
    ".pytest_cache", "egg-info",
})

_MAX_DEPTH = 5

_FRAMEWORK_DEPS: list[tuple[str, str]] = [
    ("fastapi", "fastapi"),
    ("flask", "flask"),
    ("django", "django"),
    ("starlette", "starlette"),
]

_PROVIDER_DEPS: list[tuple[str, str]] = [
    ("openai", "openai"),
    ("anthropic", "anthropic"),
    ("google-generativeai", "gemini"),
    ("google-genai", "gemini"),
    ("mistralai", "mistral"),
    ("cohere", "cohere"),
]

_BEDROCK_DEPS = frozenset({"boto3", "botocore"})

_AGENT_FRAMEWORK_DEPS: list[tuple[str, str]] = [
    ("langchain", "langchain"),
    ("langchain-core", "langchain"),
    ("llama-index", "llama-index"),
    ("openai-agents", "openai-agents"),
    ("crewai", "crewai"),
    ("pydantic-ai", "pydantic-ai"),
    ("pydantic-ai-slim", "pydantic-ai"),
]

_MESSAGE_QUEUE_DEPS = frozenset({
    "celery", "redis", "rq", "pika", "aio-pika",
    "aiokafka", "confluent-kafka", "google-cloud-pubsub",
})

# Tightened: require LLM context — async for ... in client.chat/messages, or stream=True near model/messages
_STREAMING_RE = re.compile(
    r"async\s+for\s+\w+\s+in\s+\w+\.(?:chat|messages|responses)|stream\s*=\s*True",
)

_LANGGRAPH_DEPS = frozenset({"langgraph", "langgraph-core"})

_ASSISTANTS_API_RE = re.compile(r"\.beta\.(?:threads|assistants)\.")

_MULTI_AGENT_CODE_RE = re.compile(r"\.child\s*\(|\.run_as\s*\(")

_ROUTE_HANDLER_RE = re.compile(
    r"@(?:app|router)\.\s*(?:get|post|put|patch|delete|route)\s*\("
    r"|def\s+(?:get|post|put|patch|delete)\s*\("
    r"|@api_view\s*\(",
    re.IGNORECASE,
)


# ---------------------------------------------------------------------------
# AST analysis (standalone, mirrors _LLMCallVisitor from server.py)
# ---------------------------------------------------------------------------

_LLM_METHOD_CHAINS: dict[tuple[str, ...], tuple[str, str]] = {
    ("chat", "completions", "create"): ("openai", "chat.completions.create"),
    ("chat", "completions", "parse"): ("openai", "chat.completions.parse"),
    ("responses", "create"): ("openai", "responses.create"),
    ("messages", "create"): ("anthropic", "messages.create"),
    ("generate_content",): ("gemini", "generate_content"),
    ("invoke_model",): ("bedrock", "invoke_model"),
    ("converse",): ("bedrock", "converse"),
    ("invoke_model_with_response_stream",): ("bedrock", "invoke_model_with_response_stream"),
    ("converse_stream",): ("bedrock", "converse_stream"),
    ("chat", "complete"): ("mistral", "chat.complete"),
    ("get_chat_completions",): ("azure-openai", "get_chat_completions"),
}

_SINGLE_METHOD_PROVIDER_HINTS: dict[str, tuple[str, ...]] = {
    "gemini": ("gemini", "google", "generative", "genai"),
    "bedrock": ("bedrock", "boto3", "botocore", "runtime"),
    "azure-openai": ("azure", "openai"),
}

_PROVIDER_CONSTRUCTOR_NAMES = frozenset({
    "OpenAI", "Anthropic", "Gemini", "AzureOpenAI", "Bedrock", "Mistral",
    "GoogleGenerativeAI", "GoogleGenAI", "CohereClient",
})


class _LLMCallVisitor(ast.NodeVisitor):
    """AST visitor that finds LLM provider method calls."""

    def __init__(
        self,
        has_patch: bool,
        wrapped_clients: set[str],
        provider_signals: set[str],
        source_lines: list[str],
    ) -> None:
        self.call_sites: list[dict[str, object]] = []
        self._has_patch = has_patch
        self._wrapped_clients = wrapped_clients
        self._provider_signals = provider_signals
        self._source_lines = source_lines
        self._function_stack: list[str] = []

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._function_stack.append(node.name)
        self.generic_visit(node)
        self._function_stack.pop()

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._function_stack.append(node.name)
        self.generic_visit(node)
        self._function_stack.pop()

    def _extract_chain_and_receiver(self, node: ast.expr) -> tuple[list[str], str | None]:
        parts: list[str] = []
        while isinstance(node, ast.Attribute):
            parts.append(node.attr)
            node = node.value
        parts.reverse()
        receiver = node.id if isinstance(node, ast.Name) else None
        return parts, receiver

    def _single_method_has_provider_signal(
        self,
        provider: str,
        chain: list[str],
        receiver: str | None,
    ) -> bool:
        if receiver is not None and receiver in self._wrapped_clients:
            return True
        if provider in self._provider_signals:
            return True
        hints = _SINGLE_METHOD_PROVIDER_HINTS.get(provider, ())
        chain_prefix = chain[:-1]
        for token in chain_prefix:
            lowered = token.lower()
            if any(hint in lowered for hint in hints):
                return True
        return False

    def _extract_code_context(self, line_num: int, radius: int = 4) -> str:
        idx = line_num - 1
        start = max(0, idx - radius)
        end = min(len(self._source_lines) - 1, idx + radius)
        result: list[str] = []
        for i in range(start, end + 1):
            ln = i + 1
            marker = ">>>" if ln == line_num else "   "
            result.append(f"{marker} {ln}: {self._source_lines[i]}")
        return "\n".join(result)

    def visit_Call(self, node: ast.Call) -> None:
        if isinstance(node.func, ast.Attribute):
            chain, receiver = self._extract_chain_and_receiver(node.func)
            for method_chain, (provider, api) in _LLM_METHOD_CHAINS.items():
                if len(chain) >= len(method_chain) and tuple(chain[-len(method_chain):]) == method_chain:
                    if len(method_chain) == 1 and not self._single_method_has_provider_signal(provider, chain, receiver):
                        continue
                    effective_provider = provider
                    effective_api = api
                    if provider == "anthropic" and any(p in ("beta", "threads") for p in chain):
                        effective_provider = "openai-assistants"
                        effective_api = "beta.threads.messages.create"
                    instrumented = self._has_patch or (receiver is not None and receiver in self._wrapped_clients)
                    containing_fn = self._function_stack[-1] if self._function_stack else None
                    self.call_sites.append({
                        "line": node.lineno,
                        "provider": effective_provider,
                        "api": effective_api,
                        "instrumented": instrumented,
                        "containing_function": containing_fn,
                        "code_context": self._extract_code_context(node.lineno),
                    })
                    break
        self.generic_visit(node)


def _analyze_file(source: str) -> dict[str, Any]:
    """Analyze Python source for LLM call sites and instrumentation state.

    Standalone version of the server.py analysis — avoids circular imports.
    """
    has_patch = bool(re.search(r'\bpatch\s*\(\s*amplitude\s*=', source, re.DOTALL))
    has_session_context = bool(
        re.search(r"\.session\s*\(", source)
        or re.search(r"\bsession\.run\s*\(", source)
        or "AmplitudeAIMiddleware" in source
    )

    wrapped_client_re = re.compile(
        r'(\w+)\s*=\s*(?:OpenAI|Anthropic|Gemini|AzureOpenAI|Bedrock|Mistral)\s*\(.*?amplitude\s*='
    )
    wrapped_clients = {m.group(1) for m in wrapped_client_re.finditer(source)}
    wrap_re = re.compile(r'(\w+)\s*=\s*(?:\w+\.)*wrap\s*\(.*?amplitude\s*=')
    wrapped_clients |= {m.group(1) for m in wrap_re.finditer(source)}

    has_amplitude_import = bool(
        re.search(r'from\s+amplitude_ai\s+import', source)
        or re.search(r'import\s+amplitude_ai', source)
        or re.search(r'\bAmplitudeAI\b', source)
    )

    provider_signals: set[str] = set()
    if re.search(r'\bopenai\b|from\s+openai\s+import|import\s+openai', source, re.IGNORECASE):
        provider_signals.add("openai")
    if re.search(r'\banthropic\b|from\s+anthropic\s+import|import\s+anthropic', source, re.IGNORECASE):
        provider_signals.add("anthropic")
    if re.search(r'google\.generativeai|google\.genai|\bGemini\b', source, re.IGNORECASE):
        provider_signals.add("gemini")
    if re.search(r'\bboto3\b|\bbotocore\b|\bBedrock\b', source, re.IGNORECASE):
        provider_signals.add("bedrock")
    if re.search(r'\bmistral\b|\bmistralai\b|\bMistral\b', source, re.IGNORECASE):
        provider_signals.add("mistral")

    call_sites: list[dict[str, object]] = []

    source_lines = source.splitlines()
    tool_definitions: list[str] = []
    function_definitions: list[str] = []

    try:
        tree = ast.parse(source)
        for node in ast.walk(tree):
            if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
                cfunc = node.value.func
                is_patch_call = (isinstance(cfunc, ast.Name) and cfunc.id == "patch") or (
                    isinstance(cfunc, ast.Attribute) and cfunc.attr == "patch"
                )
                if is_patch_call and any(kw.arg == "amplitude" for kw in node.value.keywords):
                    has_patch = True
            if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
                func = node.value.func
                is_wrap = (isinstance(func, ast.Name) and func.id == "wrap") or (
                    isinstance(func, ast.Attribute) and func.attr == "wrap"
                )
                if is_wrap and any(kw.arg == "amplitude" for kw in node.value.keywords):
                    for target in node.targets:
                        if isinstance(target, ast.Name):
                            wrapped_clients.add(target.id)
                    continue

                func_name = None
                if isinstance(func, ast.Name):
                    func_name = func.id
                elif isinstance(func, ast.Attribute):
                    func_name = func.attr
                if func_name in _PROVIDER_CONSTRUCTOR_NAMES:
                    if any(kw.arg == "amplitude" for kw in node.value.keywords):
                        for target in node.targets:
                            if isinstance(target, ast.Name):
                                wrapped_clients.add(target.id)

            # Collect function definitions
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                if node.name not in function_definitions:
                    function_definitions.append(node.name)

        # Collect tool definitions: look for dicts with "name" keys that are
        # string literals (common in OpenAI function calling schemas)
        for node in ast.walk(tree):
            if isinstance(node, ast.Dict):
                for key, val in zip(node.keys, node.values):
                    if (
                        isinstance(key, ast.Constant) and key.value == "name"
                        and isinstance(val, ast.Constant) and isinstance(val.value, str)
                    ):
                        name = val.value
                        if name not in tool_definitions:
                            tool_definitions.append(name)

        visitor = _LLMCallVisitor(has_patch, wrapped_clients, provider_signals, source_lines)
        visitor.visit(tree)
        call_sites = visitor.call_sites
    except SyntaxError:
        return {
            "call_sites": [],
            "has_patch": has_patch,
            "has_amplitude_import": has_amplitude_import,
            "has_session_context": has_session_context,
            "has_wrappers": bool(wrapped_clients),
            "tool_definitions": [],
            "function_definitions": [],
        }

    return {
        "call_sites": call_sites,
        "has_patch": has_patch,
        "has_amplitude_import": has_amplitude_import,
        "has_session_context": has_session_context,
        "has_wrappers": bool(wrapped_clients),
        "tool_definitions": tool_definitions,
        "function_definitions": function_definitions,
    }


# ---------------------------------------------------------------------------
# Dependency parsing helpers
# ---------------------------------------------------------------------------

def _normalize_dep_name(raw: str) -> str:
    """Extract the bare package name from a dependency specifier."""
    name = re.split(r"[>=<!\[;~^@\s]", raw.strip(), maxsplit=1)[0]
    return name.lower().replace("_", "-").strip()


def _parse_pyproject_toml(root: Path) -> tuple[str | None, set[str]]:
    path = root / "pyproject.toml"
    if not path.is_file():
        return None, set()
    try:
        data = tomllib.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None, set()

    project = data.get("project", {})
    name = project.get("name") if isinstance(project, dict) else None

    deps: set[str] = set()
    for dep in project.get("dependencies", []) if isinstance(project, dict) else []:
        if isinstance(dep, str):
            deps.add(_normalize_dep_name(dep))

    optional = project.get("optional-dependencies", {}) if isinstance(project, dict) else {}
    if isinstance(optional, dict):
        for group_deps in optional.values():
            if isinstance(group_deps, list):
                for dep in group_deps:
                    if isinstance(dep, str):
                        deps.add(_normalize_dep_name(dep))

    return name, deps


def _parse_requirements_txt(root: Path) -> set[str]:
    path = root / "requirements.txt"
    if not path.is_file():
        return set()
    deps: set[str] = set()
    try:
        for line in path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if not line or line.startswith("#") or line.startswith("-"):
                continue
            deps.add(_normalize_dep_name(line))
    except Exception:
        pass
    return deps


def _parse_setup_files(root: Path) -> tuple[str | None, set[str]]:
    deps: set[str] = set()
    name: str | None = None
    for filename in ("setup.py", "setup.cfg"):
        path = root / filename
        if not path.is_file():
            continue
        try:
            content = path.read_text(encoding="utf-8")
        except Exception:
            continue
        name_match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', content)
        if name_match and name is None:
            name = name_match.group(1)
        install_match = re.search(r'install_requires\s*=\s*\[(.*?)\]', content, re.DOTALL)
        if install_match:
            for dep_str in re.findall(r'["\']([^"\']+)["\']', install_match.group(1)):
                deps.add(_normalize_dep_name(dep_str))
    return name, deps


def _detect_package_manager(root: Path) -> str:
    if (root / "uv.lock").exists():
        return "uv"
    if (root / "poetry.lock").exists():
        return "poetry"
    if (root / "Pipfile.lock").exists():
        return "pipenv"
    return "pip"


# ---------------------------------------------------------------------------
# Agent inference helpers
# ---------------------------------------------------------------------------

def _infer_agent_id(file_path: str) -> str:
    stem = Path(file_path).stem
    if stem in ("__init__", "__main__", "main", "app", "server"):
        parent = Path(file_path).parent.name
        if parent and parent != ".":
            stem = parent
    cleaned = re.sub(r"[_\s]+", "-", stem).strip("-").lower()
    return cleaned or "default"


def _extract_docstring(source: str) -> str | None:
    """Extract the module-level docstring, if any."""
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return None
    return ast.get_docstring(tree)


def _is_route_handler(source: str) -> bool:
    return bool(_ROUTE_HANDLER_RE.search(source))


# ---------------------------------------------------------------------------
# File walker
# ---------------------------------------------------------------------------

def _collect_python_files(root: Path) -> list[Path]:
    """Walk root up to _MAX_DEPTH levels, collecting .py files while skipping noise."""
    result: list[Path] = []
    root_str = str(root)

    for dirpath, dirnames, filenames in os.walk(root):
        rel = os.path.relpath(dirpath, root_str)
        depth = 0 if rel == "." else rel.count(os.sep) + 1
        if depth >= _MAX_DEPTH:
            dirnames.clear()
            continue

        dirnames[:] = [
            d for d in dirnames
            if d not in _SKIP_DIRS and not d.startswith(".")
            and not d.endswith(".egg-info")
        ]

        for fname in filenames:
            if fname.endswith(".py"):
                result.append(Path(dirpath) / fname)

    return result


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

def scan_project(root_path: str) -> dict[str, Any]:
    """Scan a Python project to detect LLM usage, providers, and instrumentation state."""
    root = Path(root_path).resolve()
    if not root.is_dir():
        return {"error": f"Not a directory: {root_path}"}

    # --- dependency parsing ---
    project_name, deps = _parse_pyproject_toml(root)
    setup_name, setup_deps = _parse_setup_files(root)
    deps |= _parse_requirements_txt(root)
    deps |= setup_deps
    if project_name is None:
        project_name = setup_name

    # --- environment detection ---
    framework: str | None = None
    for dep_name, fw_label in _FRAMEWORK_DEPS:
        if dep_name in deps:
            framework = fw_label
            break

    providers: list[str] = []
    seen_providers: set[str] = set()
    for dep_name, prov_label in _PROVIDER_DEPS:
        if dep_name in deps and prov_label not in seen_providers:
            seen_providers.add(prov_label)
            providers.append(prov_label)
    if _BEDROCK_DEPS & deps:
        if "bedrock" not in seen_providers:
            providers.append("bedrock")
            seen_providers.add("bedrock")

    agent_frameworks: list[str] = []
    seen_afs: set[str] = set()
    for dep_name, af_label in _AGENT_FRAMEWORK_DEPS:
        if dep_name in deps and af_label not in seen_afs:
            seen_afs.add(af_label)
            agent_frameworks.append(af_label)

    package_manager = _detect_package_manager(root)

    has_amplitude_ai_dep = "amplitude-ai" in deps

    # --- ecosystem detection ---
    has_langgraph = bool(_LANGGRAPH_DEPS & deps)

    # --- message queue deps (cross-service signal) ---
    message_queue_deps = sorted(_MESSAGE_QUEUE_DEPS & deps)

    # --- frontend detection (Python projects rarely have frontend deps, check for package.json) ---
    has_frontend_deps = (root / "package.json").is_file()

    # --- walk source files ---
    py_files = _collect_python_files(root)

    agents: list[dict[str, Any]] = []
    total_call_sites = 0
    instrumented_call_sites = 0
    uninstrumented_call_sites = 0
    global_has_patch = False
    global_has_session_context = False
    has_streaming = False
    has_assistants_api = False
    has_multi_agent_code_patterns = False

    # Per-provider tracking: which providers have wrappers globally
    global_wrapped_providers: set[str] = set()

    for py_path in py_files:
        try:
            source = py_path.read_text(encoding="utf-8", errors="replace")
        except Exception:
            continue

        if _ASSISTANTS_API_RE.search(source):
            has_assistants_api = True
        if _MULTI_AGENT_CODE_RE.search(source):
            has_multi_agent_code_patterns = True

        result = _analyze_file(source)
        call_sites = result.get("call_sites", [])

        # Only flag streaming if file has LLM call sites
        if _STREAMING_RE.search(source) and call_sites:
            has_streaming = True

        # Track wrapped providers from this file
        wrapped_providers_in_file: set[str] = set()
        if result.get("has_wrappers"):
            for cs in call_sites:
                provider = cs.get("provider", "")
                if provider:
                    wrapped_providers_in_file.add(provider)
            # Also check for wrap() calls — attribute to all providers in this file
            if re.search(r"\.wrap\s*\(", source) and result.get("has_amplitude_import"):
                for cs in call_sites:
                    provider = cs.get("provider", "")
                    if provider:
                        wrapped_providers_in_file.add(provider)
        global_wrapped_providers.update(wrapped_providers_in_file)

        if not call_sites:
            if result.get("has_patch"):
                global_has_patch = True
            if result.get("has_session_context"):
                global_has_session_context = True
            continue

        rel_path = str(py_path.relative_to(root))
        n_instrumented = sum(1 for cs in call_sites if cs.get("instrumented"))
        n_total = len(call_sites)

        total_call_sites += n_total
        instrumented_call_sites += n_instrumented
        uninstrumented_call_sites += n_total - n_instrumented

        if result.get("has_patch"):
            global_has_patch = True
        if result.get("has_session_context"):
            global_has_session_context = True

        docstring = _extract_docstring(source)
        description: str | None = None
        if docstring:
            first_line = docstring.strip().split("\n")[0].strip()
            if len(first_line) <= 120:
                description = first_line

        agents.append({
            "inferred_id": _infer_agent_id(rel_path),
            "file": rel_path,
            "call_sites": n_total,
            "is_instrumented": n_instrumented == n_total,
            "is_route_handler": _is_route_handler(source),
            "inferred_description": description,
            "call_site_details": call_sites,
            "tool_definitions": result.get("tool_definitions", []),
            "function_definitions": result.get("function_definitions", []),
        })

    global_has_wrappers = len(global_wrapped_providers) > 0

    # --- cross-file wrapper propagation (per-provider) ---
    # Only upgrade an agent's instrumentation status if ALL of its call-site
    # providers are wrapped globally.
    if global_has_wrappers and has_amplitude_ai_dep:
        for agent_entry in agents:
            if not agent_entry["is_instrumented"]:
                agent_providers = {cs.get("provider") for cs in agent_entry["call_site_details"]}
                all_covered = all(p in global_wrapped_providers for p in agent_providers if p)
                if all_covered:
                    agent_entry["is_instrumented"] = True
                    delta = agent_entry["call_sites"]
                    uninstrumented_call_sites -= delta
                    instrumented_call_sites += delta

    # --- multi-agent signals ---
    # Collect raw structural evidence for the AI skill to reason over.
    # is_multi_agent is conservative — only true when SDK delegation APIs are found.
    multi_agent_signals: list[str] = []

    files_with_calls = len(agents)
    if files_with_calls > 1:
        file_list = ", ".join(a["file"] for a in agents)
        multi_agent_signals.append(
            f"LLM call sites found in {files_with_calls} separate files: {file_list}"
        )

    if agent_frameworks:
        multi_agent_signals.append(
            f"Agent framework dependencies detected: {', '.join(agent_frameworks)}"
        )

    if len(providers) > 1:
        multi_agent_signals.append(
            f"Multiple LLM providers in use: {', '.join(providers)}"
        )

    # Check for tool definitions that overlap with LLM-calling function names
    for agent_entry in agents:
        tool_names = agent_entry.get("tool_definitions", [])
        call_fns = [
            cs.get("containing_function")
            for cs in agent_entry.get("call_site_details", [])
            if cs.get("containing_function")
        ]
        overlapping = [t for t in tool_names if t in call_fns]
        if overlapping:
            multi_agent_signals.append(
                f"File {agent_entry['file']}: tool definitions {overlapping} also contain LLM calls "
                "— possible delegation-as-tools pattern"
            )

    if has_multi_agent_code_patterns:
        multi_agent_signals.append(
            "Amplitude AI SDK delegation APIs detected: .child() or .run_as() — multi-agent confirmed"
        )

    # is_multi_agent is true only when SDK APIs are found; broader signals live in multi_agent_signals
    is_multi_agent = has_multi_agent_code_patterns

    # --- recommended tier ---
    # Always default to advanced for maximum instrumentation.
    # Lower tiers exist as fallbacks, not as recommended starting points.
    recommended_tier = "advanced"

    # --- contextual recommendations ---
    recommendations: list[str] = []
    if has_streaming:
        recommendations.append(
            "Streaming detected: keep sessions open until the stream is fully consumed. "
            "Use 'async with agent.session(...) as s:' wrapping the full streaming loop."
        )
    if message_queue_deps:
        recommendations.append(
            f"Message queue deps detected ({', '.join(message_queue_deps)}): "
            "enable propagate_context in the bootstrap file and use inject_context/extract_context "
            "to correlate events across services."
        )
    if has_frontend_deps:
        recommendations.append(
            "Frontend project detected alongside Python backend: pass browser_session_id and device_id "
            "from frontend request headers to agent.session() for session replay linking."
        )
    if has_assistants_api:
        recommendations.append(
            "OpenAI Assistants API detected (client.beta.threads/assistants). Provider wrappers do not "
            "auto-instrument the Assistants API. Use manual tracking with track_user_message/track_ai_message, "
            "or migrate to the OpenAI Agents SDK which supports AmplitudeTracingProcessor."
        )
    if has_langgraph:
        recommendations.append(
            "LangGraph detected. LLM calls within LangGraph are captured via the LangChain "
            "AmplitudeCallbackHandler, but graph orchestration events (node transitions, checkpoints, "
            "human-in-the-loop) are not yet instrumented."
        )

    has_pydantic_ai = "pydantic-ai" in seen_afs
    if has_pydantic_ai:
        recommendations.append(
            "pydantic-ai detected. LLM calls through pydantic-ai Agent.run() are not captured by "
            "provider-level wrappers since pydantic-ai manages the provider internally. Use the "
            "AmplitudeModelWrapper or patch() at bootstrap to intercept the underlying provider calls, "
            "and wrap Agent.run() calls in session context for session-level tracking."
        )

    if has_amplitude_ai_dep and global_has_wrappers and global_has_session_context:
        recommendations.append(
            "Project already has amplitude-ai instrumentation with wrappers and session context. "
            "No re-instrumentation needed. Consider upgrading contentMode tiers, adding scoring, "
            "or expanding multi-agent coverage if applicable."
        )

    return {
        "project_name": project_name,
        "runtime": "python",
        "language": "python",
        "framework": framework,
        "providers": providers,
        "agent_frameworks": agent_frameworks,
        "package_manager": package_manager,
        "existing_instrumentation": {
            "has_amplitude_ai": has_amplitude_ai_dep,
            "has_patch": global_has_patch,
            "has_wrappers": global_has_wrappers,
            "has_session_context": global_has_session_context,
        },
        "agents": agents,
        "total_call_sites": total_call_sites,
        "instrumented_call_sites": instrumented_call_sites,
        "uninstrumented_call_sites": uninstrumented_call_sites,
        "is_multi_agent": is_multi_agent,
        "multi_agent_signals": multi_agent_signals,
        "has_streaming": has_streaming,
        "has_assistants_api": has_assistants_api,
        "has_langgraph": has_langgraph,
        "message_queue_deps": message_queue_deps,
        "has_frontend_deps": has_frontend_deps,
        "recommendations": recommendations,
        "recommended_tier": recommended_tier,
    }
