"""Agent core for Hikaflow debugging.

Creates and configures the debug agent with Claude Agent SDK or pydantic-ai fallback.
"""

from __future__ import annotations

import json
import os
import subprocess
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional

import logging
import re

from rich.console import Console

console = Console()
logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Codebase context injection
# ---------------------------------------------------------------------------

# Cached per-process — project doesn't change mid-session
_codebase_context_cache: Dict[str, str] = {}


def _detect_frameworks(root: Path) -> list[str]:
    """Detect frameworks from project dependency files."""
    frameworks = []

    # Python frameworks
    for cfg_file in ("pyproject.toml", "requirements.txt", "setup.py"):
        path = root / cfg_file
        if path.exists():
            try:
                content = path.read_text(encoding="utf-8", errors="replace")[:8000]
                for name, label in [
                    ("fastapi", "FastAPI"), ("django", "Django"), ("flask", "Flask"),
                    ("starlette", "Starlette"), ("celery", "Celery"),
                    ("sqlalchemy", "SQLAlchemy"), ("pydantic", "Pydantic"),
                    ("langchain", "LangChain"), ("langgraph", "LangGraph"),
                ]:
                    if name in content.lower():
                        frameworks.append(label)
            except Exception:
                pass
            break

    # JS/TS frameworks
    pkg = root / "package.json"
    if pkg.exists():
        try:
            content = pkg.read_text(encoding="utf-8", errors="replace")[:8000]
            for name, label in [
                ("next", "Next.js"), ("react", "React"), ("vue", "Vue"),
                ("express", "Express"), ("svelte", "Svelte"),
                ("tailwindcss", "Tailwind CSS"), ("prisma", "Prisma"),
            ]:
                if f'"{name}"' in content:
                    frameworks.append(label)
        except Exception:
            pass

    return list(dict.fromkeys(frameworks))[:8]  # dedupe, cap at 8


def _shallow_tree(root: Path, max_depth: int = 2, max_entries: int = 30) -> str:
    """Build a shallow directory tree (directories only)."""
    lines = []
    _SKIP = {".git", "node_modules", "__pycache__", ".next", ".venv", "venv",
             ".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
             ".eggs", ".tox", "htmlcov", ".hypothesis"}

    def _walk(path: Path, depth: int, prefix: str):
        if depth > max_depth or len(lines) >= max_entries:
            return
        try:
            entries = sorted(
                [e for e in path.iterdir() if e.is_dir() and e.name not in _SKIP and not e.name.startswith(".")],
                key=lambda e: e.name,
            )
        except PermissionError:
            return
        for i, entry in enumerate(entries):
            if len(lines) >= max_entries:
                return
            connector = "└── " if i == len(entries) - 1 else "├── "
            lines.append(f"{prefix}{connector}{entry.name}/")
            extension = "    " if i == len(entries) - 1 else "│   "
            _walk(entry, depth + 1, prefix + extension)

    _walk(root, 0, "")
    return "\n".join(lines) if lines else "(flat project)"


def _recent_commits(root: Path, limit: int = 5) -> str:
    """Get recent git commits (one-line format)."""
    try:
        result = subprocess.run(
            ["git", "log", f"--oneline", f"-{limit}"],
            capture_output=True, text=True, timeout=5, cwd=str(root),
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()
    except Exception:
        pass
    return ""


def _read_excerpt(path: Path, max_lines: int = 10) -> str:
    """Read the first N lines of a file."""
    if not path.exists():
        return ""
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[:max_lines]
        return "\n".join(lines)
    except Exception:
        return ""


def build_codebase_context(project_path: str) -> str:
    """Scan project and return a context string for system prompt (~500-1000 tokens).

    Result is cached per project path for the session.
    """
    if project_path in _codebase_context_cache:
        return _codebase_context_cache[project_path]

    root = Path(project_path).resolve()
    ctx_parts = []

    # 1. Project identity
    ctx_parts.append(f"Project: {root.name}")

    # 2. Project type detection
    markers = {
        "pyproject.toml": "Python",
        "package.json": "Node.js/TypeScript",
        "Cargo.toml": "Rust",
        "go.mod": "Go",
        "pom.xml": "Java/Maven",
        "Gemfile": "Ruby",
    }
    for file, lang in markers.items():
        if (root / file).exists():
            ctx_parts.append(f"Type: {lang}")
            break

    # 3. Framework detection
    frameworks = _detect_frameworks(root)
    if frameworks:
        ctx_parts.append(f"Frameworks: {', '.join(frameworks)}")

    # 4. Directory structure
    tree = _shallow_tree(root, max_depth=2, max_entries=30)
    ctx_parts.append(f"Structure:\n{tree}")

    # 5. Recent git commits
    commits = _recent_commits(root, limit=5)
    if commits:
        ctx_parts.append(f"Recent commits:\n{commits}")

    # 6. README excerpt
    readme = _read_excerpt(root / "README.md", max_lines=10)
    if readme:
        ctx_parts.append(f"README excerpt:\n{readme}")

    result = "\n".join(ctx_parts)
    _codebase_context_cache[project_path] = result
    return result

# --- Model constants ---
# The orchestrator (smart) handles planning and delegation.
# Workers (cheap) handle focused file reviews in parallel.
def _default_orchestrator() -> str:
    """Resolve orchestrator model: env var → credentials → Claude default."""
    env = os.getenv("HIKAFLOW_ORCHESTRATOR_MODEL")
    if env:
        return env
    try:
        import json
        from pathlib import Path
        creds = json.loads((Path.home() / ".hikaflow" / "credentials.json").read_text())
        if creds.get("openrouter_api_key"):
            return "qwen/qwen3-235b-a22b"
    except Exception:
        pass
    return "claude-sonnet-4-6"

def _default_worker() -> str:
    """Resolve worker model: env var → credentials → Claude default.

    Workers handle focused file reviews. With deterministic routing the
    orchestrator does 0 LLM work, so we can afford the stronger model
    for workers to get real findings.
    """
    env = os.getenv("HIKAFLOW_WORKER_MODEL")
    if env:
        return env
    try:
        import json
        from pathlib import Path
        creds = json.loads((Path.home() / ".hikaflow" / "credentials.json").read_text())
        if creds.get("openrouter_api_key"):
            # Gemini 2.5 Flash: fastest (1.8s), finds real bugs, cheap ($0.15/M).
            # Qwen 30b was too dumb, Qwen 235b timed out (>120s).
            return "google/gemini-2.5-flash"
    except Exception:
        pass
    return "claude-haiku-4-5-20251001"

ORCHESTRATOR_MODEL = _default_orchestrator()
WORKER_MODEL = _default_worker()

# --- Agent backend ---
# "claude-sdk" = Claude Agent SDK (default, recommended)
# "langgraph" = LangGraph backend (controlled agentic loop, requires [langgraph] extra)
# "pydantic-ai" = legacy pydantic-ai backend (requires [pydantic-ai-legacy] extra)
AGENT_BACKEND = os.getenv("HIKAFLOW_AGENT_BACKEND", "langgraph")

# --- Per-mode turn budgets ---
# These control how many LLM calls the LangGraph backend allows before
# forcing synthesis. The Claude SDK path ignores these (it uses max_turns).
# Increased from v1 based on effectiveness audit: real debugging flows
# need more headroom than happy-path estimates.
_DEFAULT_TURN_BUDGETS = {
    "scan": 5,     # Tight budget: read target, analyze, report
    "debug": 18,   # Was 12 — realistic debugging needs 11-18 turns
    "fix": 20,     # Was 15 — candidate exploration + self-verification
    "chat": 5,     # Was 3 — some conversations need a few file reads
}


def _load_turn_budgets() -> Dict[str, int]:
    """Load turn budgets with env var overrides.

    Env vars: HIKAFLOW_BUDGET_SCAN, HIKAFLOW_BUDGET_DEBUG, etc.
    """
    budgets = _DEFAULT_TURN_BUDGETS.copy()
    for mode in budgets:
        env_val = os.environ.get(f"HIKAFLOW_BUDGET_{mode.upper()}")
        if env_val:
            try:
                budgets[mode] = max(1, int(env_val))
            except ValueError:
                pass
    return budgets


MODE_TURN_BUDGETS: Dict[str, int] = _load_turn_budgets()

# --- Per-mode synthesis nudges ---
# Injected as the final user message when the turn budget is exhausted.
# Includes the expected output format so the model knows what to emit.
MODE_SYNTHESIS_NUDGES: Dict[str, str] = {
    "scan": (
        "Turn budget exhausted. Emit your final report NOW as a markdown table:\n\n"
        "| Severity | File:Line | Issue | Proposed Fix |\n"
        "|----------|-----------|-------|--------------|\n\n"
        "IMPORTANT: Only include findings with status='open' (verified). "
        "Exclude any 'draft' findings — they are unverified hypotheses and must NOT appear in the report. "
        "If a finding was not confirmed by running code, do not report it.\n\n"
        "Include: Files scanned count, verified findings count.\n"
        "Do not call any more tools."
    ),
    "debug": (
        "Turn budget exhausted. Emit your conclusion NOW:\n"
        "- **Root cause:** 1-2 sentences\n"
        "- **Evidence:** file:line references\n"
        "- **Proposed fix:** concrete code change\n"
        "- **Confidence:** HIGH/MEDIUM/LOW\n"
        "- **Coverage:** what you checked and what you didn't\n\n"
        "IMPORTANT: Only include verified conclusions. If you saved draft findings "
        "but did not verify them with code, exclude them from this report.\n"
        "Do not call any more tools."
    ),
    "fix": (
        "Turn budget exhausted. Report your fix status NOW:\n"
        "- **Fix applied:** yes/no\n"
        "- **PR:** link if created\n"
        "- **Validation:** pass/fail\n"
        "Do not call any more tools."
    ),
    "chat": "Please wrap up your response concisely.",
}


def _resolve_agent_backend() -> str:
    """Resolve backend at call time.

    In tests, default to pydantic-ai for backward-compatible contract checks
    unless the backend is explicitly set via env.
    """
    explicit = os.getenv("HIKAFLOW_AGENT_BACKEND")
    if explicit:
        if explicit not in ("claude-sdk", "langgraph", "pydantic-ai"):
            logger.warning("Unknown HIKAFLOW_AGENT_BACKEND=%r, defaulting to claude-sdk", explicit)
            return "claude-sdk"
        return explicit
    if os.getenv("PYTEST_CURRENT_TEST"):
        return "pydantic-ai"
    return AGENT_BACKEND

# --- Input guardrails ---

# Patterns that indicate potential jailbreak or unsafe prompt injection attempts.
_INPUT_GUARDRAIL_PATTERNS = [
    re.compile(r"ignore\s+(all\s+)?previous\s+instructions", re.IGNORECASE),
    re.compile(r"ignore\s+(all\s+)?prior\s+instructions", re.IGNORECASE),
    re.compile(r"disregard\s+(all\s+)?(previous|prior|above)\s+instructions", re.IGNORECASE),
    re.compile(r"you\s+are\s+now\s+(?:a|an)\s+(?:different|new)", re.IGNORECASE),
    re.compile(r"forget\s+(all\s+)?(previous|prior|your)\s+instructions", re.IGNORECASE),
    re.compile(r"override\s+system\s+prompt", re.IGNORECASE),
    re.compile(r"new\s+system\s+prompt", re.IGNORECASE),
    re.compile(r"system\s*:\s*you\s+are", re.IGNORECASE),
]


def _validate_input(prompt: str) -> Optional[str]:
    """Validate user input against known jailbreak/injection patterns.

    Returns an error message if the prompt is rejected, or None if it passes.
    """
    for pattern in _INPUT_GUARDRAIL_PATTERNS:
        if pattern.search(prompt):
            logger.warning("Input guardrail triggered: potential prompt injection detected")
            return (
                "Your input was flagged by our safety filter. "
                "Please rephrase your request without instruction-override language."
            )
    return None


@dataclass
class HikaflowDeps:
    """Dependencies injected into agent tools.

    These are available via RunContext in tool functions.
    """

    api_base: str
    token: str
    project_path: str
    environment: Optional[str] = None
    github_token: Optional[str] = field(default_factory=lambda: os.getenv("GITHUB_TOKEN"))
    model_id: str = ""
    worker_model: str = WORKER_MODEL
    orchestrator_model: str = ORCHESTRATOR_MODEL
    # Tool call dedup cache (per-session). Key = "tool_name:sorted_params_json"
    _tool_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
    # Active skill content injected per-turn by set_active_skill()
    _active_skill_content: Optional[str] = field(default=None, repr=False)
    # MemGPT-style working context blocks (set by load_blocks_from_disk)
    _memory_blocks: Optional[Any] = field(default=None, repr=False)
    # Context pressure flag — when True, system prompt warns agent to save findings
    _context_pressure: bool = field(default=False, repr=False)
    # Per-skill tool call budget tracking
    _skill_turns_used: int = field(default=0, repr=False)
    _skill_budget: int = field(default=50, repr=False)
    _active_skill_name: Optional[str] = field(default=None, repr=False)

    def get_cached_tool_result(self, tool_name: str, params: Dict[str, Any]) -> Optional[Any]:
        """Return cached result for a tool call, or None if not cached."""
        key = f"{tool_name}:{json.dumps(params, sort_keys=True, default=str)}"
        return self._tool_cache.get(key)

    def cache_tool_result(self, tool_name: str, params: Dict[str, Any], result: Any) -> None:
        """Cache a tool result. Evicts oldest if cache exceeds 200 entries."""
        key = f"{tool_name}:{json.dumps(params, sort_keys=True, default=str)}"
        if len(self._tool_cache) >= 200:
            oldest = next(iter(self._tool_cache))
            self._tool_cache.pop(oldest, None)
        self._tool_cache[key] = result


_compat_checked = False


def _check_pydantic_ai_compat() -> None:
    """Verify pydantic-ai version is compatible with the installed openai SDK.

    pydantic-ai 1.30.0 passes ``prompt_cache_retention`` to
    ``openai.chat.completions.create()`` which only exists in openai SDK >=2.8.
    Version 1.30.1 reverted this.  Guard against the broken combo so users get
    a clear message instead of an opaque TypeError at runtime.

    Result is cached so the check only runs once per process.
    """
    global _compat_checked
    if _compat_checked:
        return
    try:
        from importlib.metadata import version as pkg_version
        pai = pkg_version("pydantic-ai")
        oai = pkg_version("openai")
    except Exception:
        _compat_checked = True
        return  # can't check — skip

    if pai.startswith("1.30.0") and oai.startswith("1."):
        raise RuntimeError(
            f"Incompatible dependency versions: pydantic-ai {pai} requires "
            f"openai SDK >=2.8, but openai {oai} is installed. "
            "Upgrade with: pip install 'pydantic-ai>=1.30.1'"
        )
    _compat_checked = True


def _build_model(
    model_str: str,
    api_base: Optional[str] = None,
    token: Optional[str] = None,
) -> Any:
    """Build a pydantic-ai model, proxying OpenAI models through Hikaflow."""
    _check_pydantic_ai_compat()

    if model_str.startswith("openai:") and api_base and token:
        try:
            from pydantic_ai.providers.openai import OpenAIProvider
            from pydantic_ai.models.openai import OpenAIModel
            proxy_url = f"{api_base}/v1/openai"
            return OpenAIModel(
                model_str.split(":", 1)[1],
                provider=OpenAIProvider(base_url=proxy_url, api_key=token),
            )
        except ImportError:
            return model_str

    # OpenRouter models (e.g., "qwen/qwen3-30b-a3b") — use OpenAI-compatible provider
    if "/" in model_str and not model_str.startswith(("openai:", "anthropic:", "claude")):
        try:
            from pydantic_ai.providers.openai import OpenAIProvider
            from pydantic_ai.models.openai import OpenAIModel
            from autodebug.llm_config import get_llm_config
            config = get_llm_config()
            if config.provider == "openrouter" and config.api_key:
                return OpenAIModel(
                    model_str,
                    provider=OpenAIProvider(
                        base_url="https://openrouter.ai/api/v1",
                        api_key=config.api_key,
                    ),
                )
        except (ImportError, ValueError):
            pass

    return model_str


_SCAN_TRIGGERS = frozenset({
    "scan", "find bugs", "find issues", "check for", "review codebase",
    "security audit", "look for problems", "check the code", "audit",
    "find errors", "look for bugs", "find vulnerabilities",
})
_DEBUG_TRIGGERS = frozenset({
    "debug", "why is", "failing", "broken", "error", "investigate",
    "root cause", "stack trace", "traceback", "what's wrong",
})
_FIX_TRIGGERS = frozenset({
    "fix this", "fix the", "patch", "heal", "apply fix", "create pr",
    "ship it", "open a pr", "generate fix", "fix #",
})


def detect_mode(user_message: str) -> str:
    """Detect orchestrator mode from user message.

    Returns one of: "scan", "debug", "fix", "chat".
    Fix is checked first — "fix this bug" should be fix mode, not debug.
    """
    if not user_message:
        return "chat"
    lower = user_message.lower()
    if any(t in lower for t in _FIX_TRIGGERS):
        return "fix"
    if any(t in lower for t in _SCAN_TRIGGERS):
        return "scan"
    if any(t in lower for t in _DEBUG_TRIGGERS):
        return "debug"
    return "chat"


def build_system_prompt(
    deps: Optional[HikaflowDeps] = None,
    *,
    user_message: str = "",
) -> str:
    """Build the full system prompt including dynamic context sections.

    For Claude SDK: called before each client.query() to inject per-turn context.
    For pydantic-ai: used as the static prompt; dynamic parts injected via @agent.system_prompt.

    Args:
        deps: Agent dependencies with memory, skills, etc.
        user_message: First user message, used for mode detection.
            Empty string falls back to debug mode for backward compat.
    """
    from autodebug.agent.system_prompt import (
        HIKAFLOW_BASE_PROMPT,
        HIKAFLOW_SYSTEM_PROMPT,
        MODE_PROMPTS,
    )

    try:
        from autodebug.agent.skills import build_skill_menu
        skill_menu = build_skill_menu()
    except Exception:
        skill_menu = ""

    # Detect mode and build composite prompt
    if user_message:
        mode = detect_mode(user_message)
        mode_prompt = MODE_PROMPTS.get(mode, MODE_PROMPTS["debug"])
        prompt = HIKAFLOW_BASE_PROMPT.replace("{{skill_menu}}", skill_menu) + mode_prompt
    else:
        # Backward compat: no message → use default composite (base + debug)
        prompt = HIKAFLOW_SYSTEM_PROMPT.format(skill_menu=skill_menu)

    if deps is None:
        return prompt

    # Append dynamic context (replaces pydantic-ai @agent.system_prompt callables)
    sections = []

    # Codebase context (auto-scan project structure, frameworks, recent commits)
    try:
        codebase_ctx = build_codebase_context(deps.project_path)
        if codebase_ctx:
            sections.append(f"[Codebase Context]\n{codebase_ctx}")
    except Exception:
        pass

    # Hierarchical memory (global → project → directory MEMORY.md files)
    try:
        from autodebug.agent.hik_memory import discover_memory, render_memory_for_prompt
        memories = discover_memory(deps.project_path)
        if memories:
            mem_text = render_memory_for_prompt(memories)
            if mem_text:
                sections.append(mem_text)
    except Exception:
        pass

    # Memory blocks (MemGPT-style working context)
    if deps._memory_blocks is not None:
        try:
            sections.append(deps._memory_blocks.render_for_prompt())
        except Exception:
            pass

    # Recent reflections
    try:
        from autodebug.agent.reflections import load_recent_reflections, render_reflections_for_prompt
        reflections = load_recent_reflections(deps.project_path, limit=3)
        if reflections:
            sections.append(render_reflections_for_prompt(reflections))
    except Exception:
        pass

    # Learned skills
    try:
        from autodebug.agent.skill_library import load_top_skills, render_skills_for_prompt
        skills = load_top_skills(deps.project_path, limit=5)
        if skills:
            sections.append(render_skills_for_prompt(skills))
    except Exception:
        pass

    # Active skill
    if deps._active_skill_content:
        sections.append(deps._active_skill_content)

    # Budget / pressure warnings
    warnings = []
    if deps._context_pressure:
        try:
            from autodebug.agent.context import render_pressure_warning
            warnings.append(render_pressure_warning())
        except Exception:
            pass
    budget = deps._skill_budget
    used = deps._skill_turns_used
    if budget > 0 and used > 0:
        remaining = max(0, budget - used)
        if remaining == 0:
            warnings.append(
                "**BUDGET EXHAUSTED.** Summarize your findings now and "
                "update session_state with what you found. Do not make more tool calls."
            )
        elif used >= int(budget * 0.8):
            warnings.append(
                f"**Budget warning:** {remaining} tool turns remaining for this task. "
                f"Start summarizing your findings and update session_state."
            )
    if warnings:
        sections.append("\n\n".join(warnings))

    if sections:
        prompt += "\n\n" + "\n\n".join(s for s in sections if s)

    return prompt


def create_agent(
    model: Optional[str] = None,
    api_base: Optional[str] = None,
    token: Optional[str] = None,
) -> Any:
    """Create Hikaflow debug agent.

    Uses Claude Agent SDK by default. Falls back to pydantic-ai if
    HIKAFLOW_AGENT_BACKEND=pydantic-ai is set.

    For Claude SDK: returns a ClaudeAgentOptions object (client created in REPL loop).
    For pydantic-ai: returns a pydantic_ai.Agent instance.
    """
    backend = _resolve_agent_backend()
    default_model = ORCHESTRATOR_MODEL if backend in ("claude-sdk", "langgraph") else "openai:gpt-5"
    model = model or os.getenv("HIKAFLOW_MODEL", default_model)

    if backend == "claude-sdk":
        return _create_claude_sdk_agent(model, api_base, token)
    if backend == "langgraph":
        # LangGraph backend doesn't create an agent object — graph is built in the REPL.
        # Return a dict with model info so _run_repl_loop can use it.
        return {"backend": "langgraph", "model": model}
    return _create_pydantic_ai_agent(model, api_base, token)


def _create_claude_sdk_agent(
    model: str,
    api_base: Optional[str] = None,
    token: Optional[str] = None,
) -> Any:
    """Create agent configuration using Claude Agent SDK.

    Returns ClaudeAgentOptions (not a client — the client is async and must
    be created inside the REPL loop).
    """
    from claude_agent_sdk import ClaudeAgentOptions
    from claude_agent_sdk.types import AgentDefinition

    system_prompt = build_system_prompt()

    # Collect MCP tool names for allowed_tools
    try:
        from autodebug.agent.sdk_bridge import get_tool_names
        mcp_tool_names = [f"mcp__hikaflow__{n}" for n in get_tool_names()]
    except Exception:
        mcp_tool_names = []

    options = ClaudeAgentOptions(
        model=model,
        system_prompt=system_prompt,
        allowed_tools=[
            # Built-in SDK tools (same as Claude Code)
            "Read", "Write", "Edit", "Bash", "Glob", "Grep",
            "Task", "WebSearch", "WebFetch",
            # Custom Hikaflow MCP tools
            *mcp_tool_names,
        ],
        # --- Cost controls (same mechanisms Claude Code uses) ---
        max_turns=15,
        max_budget_usd=1.00,
        # Adaptive thinking: model decides when to think deeply vs quickly.
        # Saves tokens on simple queries, thinks hard on complex ones.
        # SDK expects a concrete tagged union payload, not an empty TypedDict ctor.
        thinking={"type": "adaptive"},
        # Sub-agents use Haiku (cheap) instead of the main model
        agents={
            "task": AgentDefinition(
                description="Sub-agent for parallel research tasks",
                prompt="",
                tools=None,
                model="haiku",
            ),
        },
        permission_mode="bypassPermissions",
        include_partial_messages=True,
        cwd=os.getcwd(),
    )

    return options


def _create_pydantic_ai_agent(
    model: str,
    api_base: Optional[str] = None,
    token: Optional[str] = None,
) -> Any:
    """Legacy: create agent using pydantic-ai backend."""
    try:
        from pydantic_ai import Agent
    except ImportError:
        raise ImportError(
            "pydantic-ai is required for the legacy agent backend. "
            "Install with: pip install 'autodebug[pydantic-ai-legacy]'"
        )

    from pydantic_ai import RunContext
    from autodebug.agent.tools import register_hikaflow_tools, smart_tool_filter

    if model and ":" not in model:
        raise ValueError(
            f"Invalid model format: '{model}'. "
            f"Use 'provider:model-name' (e.g., 'anthropic:claude-sonnet-4-5-20250929')"
        )

    system_prompt = build_system_prompt()

    # Build model — proxy OpenAI through backend, direct for others
    resolved_model = _build_model(model, api_base=api_base, token=token)

    agent = Agent(
        resolved_model,
        deps_type=HikaflowDeps,
        system_prompt=system_prompt,
        retries=2,
        prepare_tools=smart_tool_filter,
    )

    register_hikaflow_tools(agent)

    # Dynamic system prompt injectors (pydantic-ai specific)
    @agent.system_prompt
    def _inject_memory_blocks(ctx: RunContext[HikaflowDeps]) -> str:
        if ctx.deps._memory_blocks is not None:
            try:
                return ctx.deps._memory_blocks.render_for_prompt()
            except Exception:
                pass
        return ""

    @agent.system_prompt
    def _inject_reflections(ctx: RunContext[HikaflowDeps]) -> str:
        try:
            from autodebug.agent.reflections import load_recent_reflections, render_reflections_for_prompt
            reflections = load_recent_reflections(ctx.deps.project_path, limit=3)
            return render_reflections_for_prompt(reflections) if reflections else ""
        except Exception:
            return ""

    @agent.system_prompt
    def _inject_dynamic_skills(ctx: RunContext[HikaflowDeps]) -> str:
        try:
            from autodebug.agent.skill_library import load_top_skills, render_skills_for_prompt
            skills = load_top_skills(ctx.deps.project_path, limit=5)
            return render_skills_for_prompt(skills) if skills else ""
        except Exception:
            return ""

    @agent.system_prompt
    def _inject_skill(ctx: RunContext[HikaflowDeps]) -> str:
        return ctx.deps._active_skill_content or ""

    @agent.system_prompt
    def _inject_pressure_warning(ctx: RunContext[HikaflowDeps]) -> str:
        warnings = []
        if ctx.deps._context_pressure:
            try:
                from autodebug.agent.context import render_pressure_warning
                warnings.append(render_pressure_warning())
            except Exception:
                pass
        budget = ctx.deps._skill_budget
        used = ctx.deps._skill_turns_used
        if budget > 0 and used > 0:
            remaining = max(0, budget - used)
            if remaining == 0:
                warnings.append(
                    "**BUDGET EXHAUSTED.** Summarize your findings now and "
                    "update session_state with what you found. Do not make more tool calls."
                )
            elif used >= int(budget * 0.8):
                warnings.append(
                    f"**Budget warning:** {remaining} tool turns remaining for this task. "
                    f"Start summarizing your findings and update session_state."
                )
        return "\n\n".join(warnings)

    # Filesystem tools fallback
    try:
        from pydantic_ai_filesystem_sandbox import FileSystemToolset
        file_toolset = FileSystemToolset(root_dir=os.getcwd(), sandbox=True)
        if hasattr(agent, "toolsets"):
            agent.toolsets.append(file_toolset)
    except ImportError:
        from autodebug.agent.tools import register_file_tools
        register_file_tools(agent)

    return agent


def set_active_skill(deps: HikaflowDeps, user_message: str) -> Optional[str]:
    """Detect intent from user message and load the matching skill into deps.

    Sets deps._active_skill_content so the @agent.system_prompt callable
    injects it on the next run_stream() call.

    Returns the skill name if one was loaded, or None.
    """
    try:
        from autodebug.agent.skills import detect_skill, load_skill_content
    except ImportError:
        return None

    skill_name = detect_skill(user_message)
    if skill_name:
        content = load_skill_content(skill_name)
        deps._active_skill_content = content
        # Reset budget tracking when a new skill is activated
        if skill_name != deps._active_skill_name:
            deps._active_skill_name = skill_name
            deps._skill_turns_used = 0
            try:
                from autodebug.agent.skills import SKILL_BUDGETS
                deps._skill_budget = SKILL_BUDGETS.get(skill_name, 50)
            except ImportError:
                deps._skill_budget = 50
    else:
        deps._active_skill_content = None
        deps._active_skill_name = None
        deps._skill_turns_used = 0
        deps._skill_budget = 0
    return skill_name


async def run_agent(
    prompt: str,
    model: Optional[str] = None,
    api_base: Optional[str] = None,
    token: Optional[str] = None,
    project_path: Optional[str] = None,
    environment: Optional[str] = None,
    stream: bool = True,
) -> AsyncIterator[str]:
    """Run the debug agent with streaming output.

    Args:
        prompt: The user's request/question.
        model: Model to use (default: from env or gpt-5).
        api_base: Hikaflow API base URL.
        token: Authentication token.
        project_path: Project root directory.
        environment: Environment filter (production/staging/etc).
        stream: Whether to stream output.

    Yields:
        Text chunks from the agent's response.

    Example:
        >>> async for chunk in run_agent("What errors do I have?"):
        ...     print(chunk, end="")
    """
    from pydantic_ai import UsageLimits

    from autodebug.auth import load_config

    # Load configuration
    config = load_config()

    # Resolve model
    resolved_model = model or os.getenv("HIKAFLOW_MODEL", ORCHESTRATOR_MODEL)

    # Create dependencies
    deps = HikaflowDeps(
        api_base=api_base or config.get("api_base", "https://get.hikaflow.com"),
        token=token or config.get("token", ""),
        project_path=project_path or os.getcwd(),
        environment=environment,
        model_id=resolved_model,
    )

    # Verify we have a token
    if not deps.token:
        yield "Error: Not authenticated. Run 'hikaflow login' first.\n"
        return

    # Input guardrails: length limit and jailbreak patterns
    _MAX_PROMPT_CHARS = 50_000
    if len(prompt) > _MAX_PROMPT_CHARS:
        yield f"Error: Input too long ({len(prompt):,} chars). Maximum is {_MAX_PROMPT_CHARS:,}.\n"
        return
    guardrail_error = _validate_input(prompt)
    if guardrail_error:
        yield guardrail_error + "\n"
        return

    # Load memory blocks for this session
    try:
        from autodebug.agent.memory import load_blocks_from_disk
        deps._memory_blocks = load_blocks_from_disk(deps.project_path)
    except Exception:
        pass

    # Create and run agent — proxy OpenAI through backend
    agent = create_agent(resolved_model, api_base=deps.api_base, token=deps.token)

    # Load skill based on user intent
    set_active_skill(deps, prompt)

    usage_limits = UsageLimits(request_limit=50, tool_calls_limit=50)

    if stream:
        async with agent.run_stream(prompt, deps=deps, usage_limits=usage_limits) as response:
            async for chunk in response.stream_text():
                yield chunk
    else:
        result = await agent.run(prompt, deps=deps, usage_limits=usage_limits)
        yield result.data


def _fetch_remote_models() -> list[dict[str, str]] | None:
    """Fetch the model list from the API server.

    Returns None on any failure so the caller can fall back to the local list.
    """
    import requests
    from autodebug.config import get_api_base

    try:
        resp = requests.get(f"{get_api_base()}/v1/cli/models", timeout=3)
        if resp.status_code == 200:
            return resp.json()
    except Exception:
        pass
    return None


# Local fallback — only used when the server is unreachable.
_FALLBACK_MODELS: list[dict[str, str]] = [
    {"id": "anthropic:claude-sonnet-4-6", "name": "Claude Sonnet 4.6 (default)", "provider": "Anthropic"},
    {"id": "anthropic:claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5", "provider": "Anthropic"},
    {"id": "anthropic:claude-opus-4-6", "name": "Claude Opus 4.6", "provider": "Anthropic"},
    {"id": "anthropic:claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "provider": "Anthropic"},
    {"id": "openai:gpt-5", "name": "GPT-5", "provider": "OpenAI"},
    {"id": "openai:gpt-5-mini", "name": "GPT-5 Mini", "provider": "OpenAI"},
    {"id": "openai:gpt-5-nano", "name": "GPT-5 Nano", "provider": "OpenAI"},
    {"id": "ollama:llama3.2", "name": "Llama 3.2", "provider": "Ollama (Local)"},
    {"id": "ollama:codellama", "name": "Code Llama", "provider": "Ollama (Local)"},
    {"id": "ollama:mistral", "name": "Mistral", "provider": "Ollama (Local)"},
]


def get_supported_models() -> list[dict[str, str]]:
    """Get list of supported model configurations.

    Fetches from the API server first (so model updates only need a deploy).
    Falls back to a local list if the server is unreachable.
    """
    return _fetch_remote_models() or _FALLBACK_MODELS


# --- Server-driven agent configuration ---

_FALLBACK_AGENT_CONFIG: dict = {
    "tools": {
        "hidden_tools": ["list_directory", "scan_flaky_tests"],
        "one_shot_tools": [],
    },
    "semgrep": {
        "config": "p/default",
        "deep_config": ["p/default", "p/typescript", "p/react"],
        "max_target_bytes": 500000,
        "timeout_seconds": 120,
        "executor_timeout_seconds": 180,
        "exclude": [
            "node_modules", ".git", ".next", "__pycache__",
            "*.min.js", "*.lock", "*.map",
            "web/package-lock.json", ".hypothesis",
        ],
        "excluded_rules": [
            "dict-key-access-without-check",
            "catch-and-pass",
            "catch-and-return-none",
            "async-function-without-await",
        ],
        "target_roots": [
            "api", "worker", "autodebug", "web", "mcp_server", "hikaflow",
            "scripts", "ide",
        ],
    },
    "models": {
        "orchestrator": ORCHESTRATOR_MODEL,
        "worker": WORKER_MODEL,
    },
    "version": 2,
}

_agent_config_cache: dict | None = None


def _fetch_agent_config() -> dict | None:
    """Fetch agent config from the API server.

    Returns None on any failure so the caller can fall back to the local config.
    Cached for the process lifetime (one fetch per CLI session).
    """
    global _agent_config_cache
    if _agent_config_cache is not None:
        return _agent_config_cache

    import requests
    from autodebug.config import get_api_base

    try:
        resp = requests.get(f"{get_api_base()}/v1/cli/agent-config", timeout=3)
        if resp.status_code == 200:
            _agent_config_cache = resp.json()
            return _agent_config_cache
    except Exception:
        pass
    return None


def get_agent_config() -> dict:
    """Get agent configuration (remote or local fallback).

    Returns tool filtering rules, Semgrep settings, and model defaults.
    Fetches from server first so changes deploy instantly to all users.
    """
    return _fetch_agent_config() or _FALLBACK_AGENT_CONFIG
