"""Bridge between Hikaflow tools and Claude Agent SDK.

Wraps existing tool functions (designed for pydantic-ai RunContext) as
Claude Agent SDK MCP tools via create_sdk_mcp_server.

Usage:
    from autodebug.agent.sdk_bridge import create_hikaflow_mcp_server
    server = create_hikaflow_mcp_server(deps)
"""

from __future__ import annotations

import inspect
import json
import logging
import os
import re
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict, Optional

logger = logging.getLogger(__name__)

# Optional OpenTelemetry integration for MCP compact adapter observability.
try:
    from opentelemetry import trace as _otel_trace
    _mcp_tracer = _otel_trace.get_tracer("hikaflow.mcp.compact")
except ImportError:
    _mcp_tracer = None  # type: ignore[assignment]


class _MockContext:
    """Minimal stand-in for pydantic_ai.RunContext[HikaflowDeps].

    Existing tool functions only access ctx.deps — this provides that.
    """

    __slots__ = ("deps",)

    def __init__(self, deps: Any):
        self.deps = deps


def _extract_tool_schema(func) -> Dict[str, Any]:
    """Extract JSON Schema-style input schema from a tool function's signature.

    Skips the first parameter (ctx) and converts Python type annotations
    to a simple JSON Schema dict suitable for the @tool decorator.
    """
    sig = inspect.signature(func)
    params = list(sig.parameters.values())

    # Skip 'ctx' (first param) and 'self' if present
    tool_params = [p for p in params if p.name not in ("ctx", "self")]

    properties = {}
    required = []

    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
    }

    # String-based type map for __future__.annotations (annotations are strings)
    _str_type_map = {
        "str": "string",
        "int": "integer",
        "float": "number",
        "bool": "boolean",
    }

    for param in tool_params:
        annotation = param.annotation
        if annotation is inspect.Parameter.empty:
            annotation = str  # default to string

        ann_str = str(annotation)

        # Handle Optional[X]
        origin = getattr(annotation, "__origin__", None)
        args = getattr(annotation, "__args__", ())
        is_optional = False
        if ann_str.startswith("typing.Optional") or ann_str.startswith("Optional[") or (args and type(None) in args):
            is_optional = True
            # Extract the actual type from Optional[X]
            if args:
                actual_types = [a for a in args if a is not type(None)]
                annotation = actual_types[0] if actual_types else str
            else:
                # String annotation: extract inner type from "Optional[int]"
                inner = ann_str.split("[", 1)[-1].rstrip("]").strip()
                annotation = inner  # will be matched by _str_type_map below
            ann_str = str(annotation)

        # Handle list[X]
        if origin is list or ann_str.startswith("list") or ann_str.startswith("List"):
            prop = {"type": "array", "items": {"type": "string"}}
        elif annotation in type_map:
            prop = {"type": type_map[annotation]}
        elif ann_str in _str_type_map:
            prop = {"type": _str_type_map[ann_str]}
        else:
            prop = {"type": "string"}

        # Add default if present
        if param.default is not inspect.Parameter.empty:
            prop["default"] = param.default
        elif not is_optional:
            required.append(param.name)

        properties[param.name] = prop

    schema = {
        "type": "object",
        "properties": properties,
    }
    if required:
        schema["required"] = required

    return schema


# ── Tool registry ──────────────────────────────────────────────────────
# Populated by register_hikaflow_tools_dual() which mirrors the existing
# register_hikaflow_tools() but also records metadata for MCP bridging.

_TOOL_REGISTRY: list[tuple[str, Any, str, Dict[str, Any]]] = []
# Each entry: (name, func, description, schema)

_COMPACT_PROFILE_TOOLS = frozenset(
    {
        "incident_overview",
        "failure_similarity_search",
        "review_files",
        "run_tests",
        "update_findings",
        "knowledge_memory",  # unified dispatcher for memory, reflections, skills
    }
)

# Tools moved to admin-only profile (available via HIKAFLOW_TOOL_PROFILE=admin):
# - codebase_scan (superseded by scan_for_bugs builtin)
# - generate_fix_candidates, validate_fix_candidate, create_fix_pr (stubs)
# - quarantine_control (operational, not debugging)
# - memory_blocks, reflections, skills_manager (subsumed by knowledge_memory)
# - project_health_snapshot (backend-dependent, informational)

_LEGACY_ALIAS_TARGETS: dict[str, str] = {
    "fetch_production_errors": "incident_overview",
    "get_error_details": "incident_overview",
    "view_io_transcript": "incident_overview",
    "transcript": "incident_overview",
    "sessions": "incident_overview",
    "search_similar_fixes": "failure_similarity_search",
    "patterns": "failure_similarity_search",
    "generate_fix_hypothesis": "generate_fix_candidates",
    "heal_flaky_test": "generate_fix_candidates",
    "replay_error": "validate_fix_candidate",
    "validate_fix": "validate_fix_candidate",
    "generate_tests": "validate_fix_candidate",
    "contract_drift": "validate_fix_candidate",
    "performance_baselines": "validate_fix_candidate",
    "mutation_testing": "validate_fix_candidate",
    "create_pull_request": "create_fix_pr",
    "git_commit": "create_fix_pr",
    "git_status": "create_fix_pr",
    "apply_patch": "create_fix_pr",
    "quarantine": "quarantine_control",
    "scan_codebase": "codebase_scan",
    "scan_flaky_tests": "project_health_snapshot",
    "detect_flaky_patterns": "project_health_snapshot",
    "dashboard_stats": "project_health_snapshot",
    "scan_feedback": "project_health_snapshot",
    "self_heal": "project_health_snapshot",
    "project_settings": "project_health_snapshot",
    "dora_metrics": "project_health_snapshot",
    "memory_insert": "knowledge_memory",
    "memory_replace": "knowledge_memory",
    "memory_rethink": "knowledge_memory",
    "save_memory": "knowledge_memory",
    "search_reflections": "knowledge_memory",
    "save_reflection": "knowledge_memory",
    "create_skill": "knowledge_memory",
    "search_skills": "knowledge_memory",
    "use_skill": "knowledge_memory",
    "record_skill_outcome": "knowledge_memory",
    "load_skill": "knowledge_memory",
}

_COMPACT_SCHEMA_OVERRIDES: dict[str, dict[str, Any]] = {
    "review_files": {
        "type": "object",
        "properties": {
            "paths": {"type": "array", "items": {"type": "string"}},
            "focus": {"type": "string", "default": "general"},
            "context_hint": {"type": "string", "default": ""},
            "worker_modes": {"type": "array", "items": {"type": "string"}, "default": []},
            "max_files_per_turn": {"type": "integer", "default": 15, "minimum": 1, "maximum": 15},
            "max_workers_per_file": {"type": "integer", "default": 3, "minimum": 1, "maximum": 3},
            "max_total_worker_invocations": {"type": "integer", "default": 30, "minimum": 1, "maximum": 45},
        },
        "required": ["paths"],
    },
}

_PHASE2_CONTRACT_DIR = Path(__file__).resolve().parents[2] / "phase2" / "contracts"

try:
    from mcp_server.adapters import ADAPTER_SPECS as _ADAPTER_SPECS
except Exception:
    _ADAPTER_SPECS = {}


def _env_flag_enabled(name: str, default: bool = True) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def _mcp_migration_enabled(tool_name: str) -> bool:
    """Feature-flag gate for per-tool MCP migration path.

    Defaults to enabled to preserve current behavior, while allowing instant
    rollback via ENABLE_MCP_<TOOL>=0 flags.
    """
    spec = _ADAPTER_SPECS.get(tool_name)
    env_name = spec.feature_flag if spec else None
    if not env_name:
        return True
    return _env_flag_enabled(env_name, default=True)


@lru_cache(maxsize=1)
def _load_phase2_request_schema_overrides() -> dict[str, dict[str, Any]]:
    """Load frozen request schemas from Phase 2 contracts.

    Only contracts with status=frozen are applied. Draft contracts are ignored.
    """
    overrides: dict[str, dict[str, Any]] = {}
    contract_dir = Path(os.getenv("HIKAFLOW_PHASE2_CONTRACT_DIR", str(_PHASE2_CONTRACT_DIR)))
    if not contract_dir.exists():
        return overrides

    for tool_name in _COMPACT_PROFILE_TOOLS:
        spec = _ADAPTER_SPECS.get(tool_name)
        path = Path(spec.contract_path) if spec is not None else (contract_dir / f"{tool_name}.json")
        if not path.exists():
            continue
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except Exception as exc:
            logger.warning("Failed reading contract schema %s: %s", path, exc)
            continue

        if str(payload.get("status", "")).strip().lower() != "frozen":
            continue
        request_schema = payload.get("request_schema")
        if not isinstance(request_schema, dict):
            logger.warning("Frozen contract %s missing request_schema object", path)
            continue
        if request_schema.get("type") != "object":
            logger.warning("Frozen contract %s request_schema.type must be object", path)
            continue
        properties = request_schema.get("properties", {})
        if not isinstance(properties, dict) or not properties:
            logger.warning("Frozen contract %s request_schema.properties missing/empty", path)
            continue
        overrides[tool_name] = request_schema
    return overrides


def _resolve_compact_schema(tool_name: str, fallback_schema: Dict[str, Any]) -> Dict[str, Any]:
    if not _mcp_migration_enabled(tool_name):
        return _COMPACT_SCHEMA_OVERRIDES.get(tool_name, fallback_schema)
    phase2_schema = _load_phase2_request_schema_overrides().get(tool_name)
    if phase2_schema:
        return phase2_schema
    return _COMPACT_SCHEMA_OVERRIDES.get(tool_name, fallback_schema)


def _status_from_text(text: str) -> str:
    low = (text or "").strip().lower()
    if not low:
        return "error"  # Empty output means tool failed silently
    if low.startswith("error") or low.startswith("traceback"):
        return "error"
    if "timeouterror" in low or "timed out" in low:
        return "error"
    # Only flag "timeout" in pytest summary line (e.g., "1 timeout") — not config lines
    # Use [ \t] instead of \s to avoid matching across newlines
    if re.search(r"\b\d+[ \t]+timeout\b", low):
        return "partial"
    if re.search(r"timeout[ \t]+after[ \t]+\d+", low):
        return "partial"
    return "ok"


def _compact_envelope(
    status: str,
    summary: str,
    data: Optional[dict[str, Any]] = None,
    next_actions: Optional[list[str]] = None,
    confidence: float = 1.0,
) -> str:
    # Sanitize raw_output in data to prevent prompt injection
    if data and "raw_output" in data:
        from autodebug.agent.tools import _sanitize_tool_output
        data["raw_output"] = _sanitize_tool_output(str(data["raw_output"]), "compact_adapter")
    payload: dict[str, Any] = {
        "status": status if status in {"ok", "partial", "error"} else "error",
        "summary": (summary or "No summary.").strip()[:240],
        "data": data or {},
        "confidence": round(max(0.0, min(1.0, float(confidence))), 2),
    }
    if next_actions is not None:
        payload["next_actions"] = [str(x)[:120] for x in next_actions[:8]]
    return json.dumps(payload, ensure_ascii=True)


def _parse_run_tests_output(raw: str, path: str) -> dict[str, Any]:
    text = raw or ""
    status = _status_from_text(text)
    pass_count = 0
    fail_count = 0
    m_pass = re.search(r"(\d+)\s+passed", text, re.IGNORECASE)
    m_fail = re.search(r"(\d+)\s+failed", text, re.IGNORECASE)
    if m_pass:
        pass_count = int(m_pass.group(1))
    if m_fail:
        fail_count = int(m_fail.group(1))
    duration = None
    m_dur = re.search(r"\bin\s+([0-9]+(?:\.[0-9]+)?s)\b", text)
    if m_dur:
        duration = m_dur.group(1)
    # Only capture tests on FAILED/ERROR lines — not PASSED ones
    failed_tests = sorted(set(
        re.findall(r"([A-Za-z0-9_./-]+(?:::[A-Za-z0-9_./\[\]-]+)+)\s+(?:FAILED|ERROR)", text)
    ))[:50]
    exit_code = 0 if status == "ok" and fail_count == 0 else (1 if status != "error" else None)
    if status == "error":
        summary = "Test execution failed."
    elif fail_count > 0:
        summary = f"Tests completed with failures ({fail_count} failed)."
    else:
        summary = "Tests completed successfully."
    return {
        "status": status,
        "summary": summary,
        "data": {
            "command": f"pytest {path}",
            "exit_code": exit_code,
            "pass_count": pass_count,
            "fail_count": fail_count,
            "failed_tests": failed_tests,
            "duration": duration,
            "truncated": "[truncated" in text.lower(),
            "raw_output": text[:12000],
        },
        "next_actions": ["Re-run with a narrower path or pattern for faster iteration."] if status != "ok" else [],
        "confidence": 0.9 if status == "ok" else 0.5 if status == "partial" else 0.15,
    }


_REVIEW_FINDING_RE = re.compile(
    r"^\s*-\s+Line\s+(\d+)\s+\[(CRITICAL|HIGH|MEDIUM|LOW)\|([^|]+)\|([0-9.]+)\]"
    r"(?:\[([\w-]+)\])?:\s*(.+)$"
)


def _parse_review_files_output(raw: str, paths: list[str]) -> dict[str, Any]:
    text = raw or ""
    status = _status_from_text(text)
    severity_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
    file_entries: list[dict[str, Any]] = []
    findings: list[dict[str, Any]] = []
    current_file = None
    current_findings = 0
    worker_timeouts: list[str] = []
    error_notes: list[str] = []
    for line in text.splitlines():
        if line.startswith("### "):
            if current_file is not None:
                file_entries.append({"path": current_file, "finding_count": current_findings})
            current_file = line.replace("### ", "").split(" (reviewed", 1)[0].strip()
            current_findings = 0
            continue
        sev_match = re.search(r"\[(CRITICAL|HIGH|MEDIUM|LOW)\|", line)
        if sev_match:
            sev = sev_match.group(1)
            severity_counts[sev] += 1
            current_findings += 1
            # Extract structured finding detail
            fm = _REVIEW_FINDING_RE.match(line)
            if fm and current_file:
                try:
                    score = float(fm.group(4))
                except ValueError:
                    score = 0.0
                findings.append({
                    "file": current_file,
                    "line": int(fm.group(1)),
                    "severity": fm.group(2),
                    "confidence": fm.group(3),
                    "score": score,
                    "worker": fm.group(5) or "",
                    "message": fm.group(6).strip(),
                })
        if "TIMEOUT" in line:
            worker_timeouts.append(line.strip())
        if line.strip().lower().startswith("error"):
            error_notes.append(line.strip()[:240])
    if current_file is not None:
        file_entries.append({"path": current_file, "finding_count": current_findings})
    semgrep_overlap = "+Semgrep" in text
    # Extract pending_reviews from budget-limit section
    pending_reviews: list[dict[str, str]] = []
    _in_pending = False
    for line in text.splitlines():
        if "Budget limit reached" in line:
            _in_pending = True
            continue
        if _in_pending and line.strip().startswith("- `"):
            _pr_match = re.match(r"- `([^`]+)`", line.strip())
            if _pr_match:
                pending_reviews.append({"path": _pr_match.group(1)})
        elif _in_pending and not line.strip().startswith("-") and not line.strip().startswith("..."):
            _in_pending = False
    if worker_timeouts and status == "ok":
        status = "partial"
    summary = "File review completed."
    if pending_reviews:
        summary = f"File review completed. {len(pending_reviews)} file(s) deferred due to budget."
    if status == "partial":
        summary = "File review completed with degraded worker coverage."
    if status == "error":
        summary = "File review failed."
    return {
        "status": status,
        "summary": summary,
        "data": {
            "files": file_entries or [{"path": p, "finding_count": 0} for p in paths[:15]],
            "findings": findings,
            "severity_counts": severity_counts,
            "confidence_summary": {
                "high_confidence_ratio": None,
                "note": "Confidence is derived from worker outputs and may vary by mode.",
            },
            "semgrep_overlap": semgrep_overlap,
            "worker_timeouts": worker_timeouts,
            "error_notes": error_notes,
            "pending_reviews": pending_reviews,
            "raw_output": text[:12000],
        },
        "next_actions": ["Review high severity findings first."] if status != "error" else ["Re-run with fewer files or focus=general."],
        "confidence": 0.75 if status == "ok" else 0.5 if status == "partial" else 0.2,
    }


def _parse_update_findings_output(raw: str, action: str, project_path: str) -> dict[str, Any]:
    text = raw or ""
    status = _status_from_text(text)
    updated_count = 0 if status == "error" else (1 if action in {"add", "update", "remove"} else 0)
    findings: list[dict[str, Any]] | None = None
    if action == "list" and status != "error":
        findings = []
    summary = "Findings updated." if action != "list" else "Findings listed."
    if status == "error":
        summary = "Findings operation failed."
    return {
        "status": status,
        "summary": summary,
        "data": {
            "updated_count": updated_count,
            "store_path": os.path.join(project_path, ".hikaflow", "findings.json"),
            "findings": findings,
            "errors": [text[:240]] if status == "error" else [],
            "raw_output": text[:8000],
        },
        "next_actions": ["Use action=list to verify findings state."],
        "confidence": 0.95 if status == "ok" else 0.4 if status == "partial" else 0.1,
    }


def _build_compact_adapter(tool_name: str, func):
    """Create compact contract wrappers for legacy tools kept in compact profile."""
    if tool_name == "run_tests":
        async def _wrapped(
            ctx,
            path: str = "tests/",
            pattern: Optional[str] = None,
            maxfail: int = 10,
            quick: bool = False,
            verbose: bool = False,
        ) -> str:
            raw = await func(
                ctx,
                path=path,
                pattern=pattern,
                maxfail=maxfail,
                quick=quick,
                verbose=verbose,
            )
            parsed = _parse_run_tests_output(raw, path)
            return _compact_envelope(
                parsed["status"], parsed["summary"], parsed["data"], parsed["next_actions"], parsed["confidence"]
            )
        return _wrapped

    if tool_name == "review_files":
        async def _wrapped(
            ctx,
            paths: list[str],
            focus: str = "general",
            context_hint: str = "",
            worker_modes: Optional[list[str]] = None,
            max_files_per_turn: int = 15,
            max_workers_per_file: int = 3,
            max_total_worker_invocations: int = 30,
        ) -> str:
            focus_text = focus
            if context_hint:
                focus_text = f"{focus}. Context: {context_hint}"
            if worker_modes:
                focus_text = f"{focus_text}. Preferred worker modes: {', '.join(worker_modes[:3])}"
            raw = await func(
                ctx, paths=paths, focus=focus_text,
                max_files_per_turn=max_files_per_turn,
                max_workers_per_file=max_workers_per_file,
                max_total_worker_invocations=max_total_worker_invocations,
            )
            parsed = _parse_review_files_output(raw, paths)
            return _compact_envelope(
                parsed["status"], parsed["summary"], parsed["data"], parsed["next_actions"], parsed["confidence"]
            )
        return _wrapped

    if tool_name == "update_findings":
        async def _wrapped(
            ctx,
            action: str = "list",
            issue_id: str = "",
            file: str = "",
            line: int = 0,
            severity: str = "MEDIUM",
            description: str = "",
            status: str = "open",
        ) -> str:
            raw = await func(
                ctx,
                action=action,
                issue_id=issue_id,
                file=file,
                line=line,
                severity=severity,
                description=description,
                status=status,
            )
            parsed = _parse_update_findings_output(raw, action, getattr(ctx.deps, "project_path", ""))
            return _compact_envelope(
                parsed["status"], parsed["summary"], parsed["data"], parsed["next_actions"], parsed["confidence"]
            )
        return _wrapped

    return func


def _resolve_tool_profile(profile: Optional[str] = None) -> str:
    """Resolve tool profile from explicit arg or environment."""
    raw = (profile or os.getenv("HIKAFLOW_TOOL_PROFILE", "compact")).strip().lower()
    compact_aliases = {"compact", "default", "minimal", "lite", "prod"}
    admin_aliases = {"admin", "all", "full", "extended", "legacy"}
    if raw in admin_aliases:
        return "admin"
    if raw in compact_aliases:
        return "compact"
    logger.warning("Unknown HIKAFLOW_TOOL_PROFILE=%r, defaulting to compact", raw)
    return "compact"


def _filter_registry_entries(
    entries: list[tuple[str, Any, str, Dict[str, Any]]], profile: str
) -> list[tuple[str, Any, str, Dict[str, Any]]]:
    """Filter registered tools based on resolved profile."""
    if profile == "admin":
        return list(entries)
    return [entry for entry in entries if entry[0] in _COMPACT_PROFILE_TOOLS]


def _with_compact_adapters(
    entries: list[tuple[str, Any, str, Dict[str, Any]]],
) -> list[tuple[str, Any, str, Dict[str, Any]]]:
    adapted: list[tuple[str, Any, str, Dict[str, Any]]] = []
    for name, func, description, schema in entries:
        wrapped_fn = _build_compact_adapter(name, func) if _mcp_migration_enabled(name) else func
        wrapped_schema = _resolve_compact_schema(name, schema)
        adapted.append((name, wrapped_fn, description, wrapped_schema))
    return adapted


def _with_legacy_aliases(
    entries: list[tuple[str, Any, str, Dict[str, Any]]],
) -> list[tuple[str, Any, str, Dict[str, Any]]]:
    if os.getenv("HIKAFLOW_ENABLE_LEGACY_ALIASES", "0").strip().lower() not in {"1", "true", "yes", "on"}:
        return entries
    by_name = {name: (func, description, schema) for name, func, description, schema in entries}
    out = list(entries)
    for alias, target in _LEGACY_ALIAS_TARGETS.items():
        if alias in by_name:
            continue
        tgt = by_name.get(target)
        if not tgt:
            continue
        func, _, schema = tgt

        async def _alias_wrapper(ctx, _fn=func, _alias=alias, _target=target, **kwargs):
            logger.warning("Legacy alias tool used: %s -> %s", _alias, _target)
            try:
                from api.metrics import inc as _metrics_inc
            except Exception:
                _metrics_inc = None
            if _metrics_inc is not None:
                try:
                    _metrics_inc("tool.legacy_alias.used")
                    _metrics_inc(f"tool.legacy_alias.used.{_alias}")
                except Exception:
                    pass
            return await _fn(ctx, **kwargs)

        out.append((alias, _alias_wrapper, f"Compatibility alias for {target}", schema))
    return out


def get_tool_names(profile: Optional[str] = None) -> list[str]:
    """Return list of registered tool names (for allowed_tools config)."""
    resolved = _resolve_tool_profile(profile)
    entries = _filter_registry_entries(_TOOL_REGISTRY, resolved)
    if resolved == "compact":
        entries = _with_legacy_aliases(entries)
    return [name for name, _, _, _ in entries]


def create_hikaflow_mcp_server(deps: Any, profile: Optional[str] = None):
    """Create an in-process MCP server with all Hikaflow tools.

    Args:
        deps: HikaflowDeps instance — passed to tools via _MockContext.

    Returns:
        An SDK MCP server ready to pass to ClaudeAgentOptions.mcp_servers.
    """
    from claude_agent_sdk import create_sdk_mcp_server
    from claude_agent_sdk import tool as sdk_tool

    ctx = _MockContext(deps)
    mcp_tools = []

    resolved = _resolve_tool_profile(profile)
    entries = _filter_registry_entries(_TOOL_REGISTRY, resolved)
    if resolved == "compact":
        # NOTE: _TOOL_REGISTRY entries are already wrapped by populate_registry_from_agent.
        # Do NOT re-apply _with_compact_adapters here — that causes double-wrapping
        # which corrupts run_tests/review_files/update_findings output.
        entries = _with_legacy_aliases(entries)

    import inspect as _inspect  # noqa: E402 - hoisted from loop

    for name, func, description, schema in entries:
        # Create wrapper that bridges SDK args → existing tool function
        # The SDK MCP server passes args as a dict. We need to:
        # 1. Ensure args is a plain dict (not a Pydantic model)
        # 2. Strip any 'ctx'/'self' keys that shouldn't be kwargs
        # 3. Only pass kwargs that match the function's parameters
        _sig_params = list(_inspect.signature(func).parameters.values())
        _accepts_var_kwargs = any(p.kind == _inspect.Parameter.VAR_KEYWORD for p in _sig_params)
        _valid_params = {
            p.name
            for p in _sig_params
            if p.name not in ("ctx", "self") and p.kind != _inspect.Parameter.VAR_KEYWORD
        }

        @sdk_tool(name, description, schema)
        async def _wrapped(
            args: Dict[str, Any],
            _fn=func,
            _ctx=ctx,
            _params=frozenset(_valid_params),
            _accepts_kwargs=_accepts_var_kwargs,
            _name=name,
            _profile=resolved,
        ):
            import time as _time
            _start = _time.monotonic()
            _result_status = "ok"
            # Start OTel span BEFORE execution so it wraps the work
            _span = None
            if _mcp_tracer is not None:
                _span = _mcp_tracer.start_span(f"mcp.tool.{_name}")
                _span.set_attribute("tool.name", _name)
                _span.set_attribute("tool.profile", _profile)
                _span.set_attribute("tool.mcp_migration", str(_mcp_migration_enabled(_name)))
            try:
                # Normalize args to a plain dict
                if hasattr(args, "model_dump"):
                    call_args = args.model_dump()
                elif hasattr(args, "__dict__") and not isinstance(args, dict):
                    call_args = {k: v for k, v in args.__dict__.items() if not k.startswith("_")}
                elif isinstance(args, dict):
                    call_args = dict(args)
                else:
                    call_args = {}

                # Only pass kwargs that the function actually accepts
                if _accepts_kwargs:
                    filtered = dict(call_args)
                else:
                    extra = sorted(k for k in call_args if k not in _params)
                    if extra:
                        _result_status = "error"
                        return {
                            "content": [{
                                "type": "text",
                                "text": f"Error: Unknown arguments for {_name}: {', '.join(extra)}",
                            }]
                        }
                    filtered = {k: v for k, v in call_args.items() if k in _params}
                result = await _fn(_ctx, **filtered)
                result_text = str(result)
                if '"status": "error"' in result_text or result_text.startswith("Error"):
                    _result_status = "error"
                elif '"status": "partial"' in result_text:
                    _result_status = "partial"
                return {"content": [{"type": "text", "text": result_text}]}
            except Exception as e:
                _result_status = "error"
                logger.warning("Tool %s error: %s", _name, e)
                return {"content": [{"type": "text", "text": f"Error: {e}"}]}
            finally:
                _duration_ms = (_time.monotonic() - _start) * 1000
                if _span is not None:
                    _span.set_attribute("tool.duration_ms", round(_duration_ms, 1))
                    _span.set_attribute("tool.result_status", _result_status)
                    _span.end()

        mcp_tools.append(_wrapped)

    logger.info(
        "Creating Hikaflow MCP server with profile=%s tools=%d",
        resolved,
        len(entries),
    )
    return create_sdk_mcp_server(
        name="hikaflow",
        version="2.1.1",
        tools=mcp_tools,
    )


def register_tool(name: str, description: str, schema: Optional[Dict[str, Any]] = None):
    """Decorator to register a tool function in the bridge registry.

    Can be used alongside @agent.tool for dual-mode support:

        @register_tool("my_tool", "Does something")
        @agent.tool
        async def my_tool(ctx, param: str) -> str:
            ...
    """
    def decorator(func):
        _schema = schema or _extract_tool_schema(func)
        _TOOL_REGISTRY.append((name, func, description, _schema))
        return func
    return decorator


def populate_registry_from_agent(agent: Any, profile: Optional[str] = None):
    """Extract tool metadata from an already-configured pydantic-ai Agent.

    Call this AFTER register_hikaflow_tools(agent) to populate the bridge
    registry from the agent's registered tools. This avoids modifying the
    existing tool registration code.
    """
    _TOOL_REGISTRY.clear()

    # pydantic-ai stores tools in agent._function_tools (dict of name -> Tool)
    tools_dict = getattr(agent, "_function_tools", {})
    if not tools_dict:
        # Try alternate attribute names across pydantic-ai versions
        for attr in ("_tools", "tools", "_registered_tools"):
            tools_dict = getattr(agent, attr, {})
            if tools_dict:
                break

    discovered: list[tuple[str, Any, str, Dict[str, Any]]] = []

    for tool_name, tool_obj in tools_dict.items():
        # Extract the actual function
        func = getattr(tool_obj, "function", None) or getattr(tool_obj, "_function", None)
        if func is None:
            continue
        if inspect.ismethod(func):
            func = func.__func__

        # Extract description from docstring
        description = (func.__doc__ or "").split("\n")[0].strip() or tool_name

        # Extract schema
        schema = _extract_tool_schema(func)

        discovered.append((tool_name, func, description, schema))

    resolved = _resolve_tool_profile(profile)
    selected = _filter_registry_entries(discovered, resolved)
    if resolved == "compact":
        selected = _with_compact_adapters(selected)
        selected = _with_legacy_aliases(selected)
    _TOOL_REGISTRY.extend(selected)
    logger.info(
        "Populated SDK bridge registry with %d tools (profile=%s, discovered=%d)",
        len(_TOOL_REGISTRY),
        resolved,
        len(discovered),
    )
