"""Skill loader and executor for LLM providers (Azure OpenAI, OpenAI, Ollama)."""

from __future__ import annotations

import importlib.util
import json
import os
import subprocess
import sys
import time
from collections.abc import Callable, Generator
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from logging_config import get_logger
from openai import AzureOpenAI, OpenAI

if TYPE_CHECKING:
    from agent_logger import AgentLogger

log = get_logger(__name__)


@dataclass
class SkillParameter:
    """Represents a skill parameter definition."""

    name: str
    param_type: str
    description: str
    required: bool = False
    default: Any = None


@dataclass
class Skill:
    """Represents a loaded skill."""

    name: str
    description: str
    version: str
    prompt_template: str
    parameters: list[SkillParameter] = field(default_factory=list)
    path: Path | None = None
    handler: Callable[..., str] | None = None
    category: str | None = None

    @property
    def has_handler(self) -> bool:
        """Whether this skill has a local Python handler."""
        return self.handler is not None

    def render_prompt(self, **kwargs: Any) -> str:
        """Render the prompt template with the given parameters."""
        prompt = self.prompt_template
        for param in self.parameters:
            key = param.name
            value = kwargs.get(key, param.default)
            if param.required and value is None:
                raise ValueError(f"Missing required parameter: {key}")
            prompt = prompt.replace(f"{{{key}}}", str(value) if value is not None else "")
        return prompt


_SUBPROCESS_TIMEOUT = 30  # seconds


def _make_subprocess_handler(command: list[str], skill_path: Path) -> Callable[..., str]:
    """Return a callable that runs a subprocess handler.

    The handler receives kwargs as a JSON object on stdin and returns
    the process's stdout. Non-zero exit codes produce an error string.
    """

    def handler(**kwargs: Any) -> str:
        try:
            result = subprocess.run(
                command,
                input=json.dumps(kwargs),
                capture_output=True,
                text=True,
                cwd=str(skill_path),
                timeout=_SUBPROCESS_TIMEOUT,
            )
        except subprocess.TimeoutExpired:
            return f"ERROR: Handler timed out after {_SUBPROCESS_TIMEOUT}s"

        if result.returncode != 0:
            stderr = result.stderr.strip()
            return f"ERROR: Process exited with code {result.returncode}: {stderr}"

        return result.stdout.strip()

    return handler


class SkillLoader:
    """Loads skills from a directory."""

    def __init__(self, skills_dir: str | Path) -> None:
        self.skills_dir = Path(skills_dir)
        self._skills: dict[str, Skill] = {}
        self._log = log.bind(component="loader", skills_dir=str(skills_dir))

    def load_all(self) -> dict[str, Skill]:
        """Load all skills from the skills directory."""
        if not self.skills_dir.exists():
            raise FileNotFoundError(f"Skills directory not found: {self.skills_dir}")

        self._log.debug("scanning_skills_directory")

        for skill_path in self.skills_dir.iterdir():
            if skill_path.is_dir():
                skill = self._load_skill(skill_path)
                if skill:
                    self._skills[skill.name] = skill
                    self._log.debug(
                        "skill_loaded",
                        skill=skill.name,
                        version=skill.version,
                        mode="handler" if skill.has_handler else "prompt-only",
                        params=[p.name for p in skill.parameters],
                    )

        handler_count = sum(1 for s in self._skills.values() if s.has_handler)
        self._log.debug(
            "skills_loaded_complete",
            total=len(self._skills),
            with_handlers=handler_count,
            prompt_only=len(self._skills) - handler_count,
        )
        return self._skills

    def _load_skill(self, skill_path: Path) -> Skill | None:
        """Load a single skill from a directory."""
        skill_json = skill_path / "skill.json"
        if not skill_json.exists():
            return None

        with open(skill_json) as f:
            config = json.load(f)

        prompt_template = ""
        prompt_txt = skill_path / "prompt.txt"
        if prompt_txt.exists():
            with open(prompt_txt) as f:
                prompt_template = f.read()

        parameters = []
        for name, param_config in config.get("parameters", {}).items():
            parameters.append(
                SkillParameter(
                    name=name,
                    param_type=param_config.get("type", "string"),
                    description=param_config.get("description", ""),
                    required=param_config.get("required", False),
                    default=param_config.get("default"),
                )
            )

        handler = self._load_handler(skill_path, config["name"], config)

        return Skill(
            name=config["name"],
            description=config.get("description", ""),
            version=config.get("version", "1.0.0"),
            prompt_template=prompt_template,
            parameters=parameters,
            path=skill_path,
            handler=handler,
            category=config.get("category"),
        )

    def _load_handler(
        self,
        skill_path: Path,
        skill_name: str,
        config: dict[str, Any],
    ) -> Callable[..., str] | None:
        """Load a handler for a skill."""
        skill_log = self._log.bind(skill=skill_name)

        # 1) Python handler (existing fast path)
        py_handler = self._load_python_handler(skill_path, skill_name)
        if py_handler is not None:
            return py_handler

        # 2) Explicit command in skill.json
        handler_config = config.get("handler")
        if isinstance(handler_config, dict):
            command = handler_config.get("command")
            if isinstance(command, list) and command:
                skill_log.debug("subprocess_handler_configured", command=command)
                return _make_subprocess_handler(command, skill_path)

        # 3) Auto-detect by file extension
        return self._detect_subprocess_handler(skill_path, skill_name)

    def _load_python_handler(self, skill_path: Path, skill_name: str) -> Callable[..., str] | None:
        """Import handler.py and return its handle() function."""
        handler_file = skill_path / "handler.py"
        if not handler_file.exists():
            return None

        skill_log = self._log.bind(skill=skill_name)

        try:
            module_name = f"_skill_handler_{skill_name}"
            spec = importlib.util.spec_from_file_location(module_name, handler_file)
            if spec is None or spec.loader is None:
                return None

            module = importlib.util.module_from_spec(spec)
            sys.modules[module_name] = module
            spec.loader.exec_module(module)

            handler_fn = getattr(module, "handle", None)
            if handler_fn is None or not callable(handler_fn):
                skill_log.warning("handler_missing_function", file=str(handler_file))
                return None

            skill_log.debug("python_handler_loaded")
            return handler_fn
        except Exception as exc:
            skill_log.warning("handler_load_failed", error=str(exc), exc_info=True)
            return None

    def _detect_subprocess_handler(self, skill_path: Path, skill_name: str) -> Callable[..., str] | None:
        """Auto-detect non-Python handler files by extension."""
        skill_log = self._log.bind(skill=skill_name)

        detections: list[tuple[str, list[str]]] = [
            ("handler.ts", ["npx", "tsx", "handler.ts"]),
            ("handler.js", ["node", "handler.js"]),
        ]

        for filename, command in detections:
            if (skill_path / filename).exists():
                skill_log.debug("handler_autodetected", type=filename)
                return _make_subprocess_handler(command, skill_path)

        # Executable binary with no extension (compiled Go/Rust)
        executable = skill_path / "handler"
        if executable.exists() and os.access(executable, os.X_OK):
            skill_log.debug("handler_autodetected", type="executable")
            return _make_subprocess_handler(["./handler"], skill_path)

        return None

    def get_skill(self, name: str) -> Skill | None:
        """Get a loaded skill by name."""
        return self._skills.get(name)

    def list_skills(self) -> list[str]:
        """List all loaded skill names."""
        return list(self._skills.keys())


def build_skill_guidance(skills: dict[str, Skill]) -> str:
    """Build a compact skill index for the system prompt.

    Uses progressive disclosure: only skill names and descriptions are
    included in the system prompt.  Full prompt_templates are injected
    on-demand when a tool is actually called (see SkillExecutor).
    """
    lines: list[str] = []
    for skill in skills.values():
        if skill.has_handler:
            lines.append(f"- **{skill.name}**: {skill.description}")
    if not lines:
        return ""
    return "Available tools:\n" + "\n".join(lines)


_TYPE_MAP = {
    "string": "string",
    "integer": "integer",
    "number": "number",
    "boolean": "boolean",
}


def to_openai_tool(skill: Skill) -> dict[str, Any]:
    """Convert a Skill into an OpenAI tool definition dict."""
    properties: dict[str, Any] = {}
    required: list[str] = []

    for param in skill.parameters:
        prop: dict[str, Any] = {
            "type": _TYPE_MAP.get(param.param_type, "string"),
            "description": param.description,
        }
        if param.default is not None:
            prop["default"] = param.default
        properties[param.name] = prop
        if param.required:
            required.append(param.name)

    return {
        "type": "function",
        "function": {
            "name": skill.name,
            "description": skill.description,
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


def _extract_render_ui_components(arguments_json: str) -> list[dict[str, Any]]:
    """Extract normalized render_ui components from tool arguments JSON."""
    try:
        args = json.loads(arguments_json)
    except json.JSONDecodeError:
        return []
    if not isinstance(args, dict):
        return []

    components = args.get("components")
    if isinstance(components, str):
        try:
            components = json.loads(components)
        except json.JSONDecodeError:
            return []
    if isinstance(components, dict):
        components = [components]
    if not isinstance(components, list):
        return []
    return [c for c in components if isinstance(c, dict)]


def is_render_ui_flow_chart_call(arguments_json: str) -> bool:
    """Return True if render_ui args contain at least one flow_chart component."""
    components = _extract_render_ui_components(arguments_json)
    return any(c.get("type") == "flow_chart" for c in components)


def has_render_ui_flow_chart_payload(result: str) -> bool:
    """Return True if a tool result is a render_ui payload with flow_chart."""
    try:
        payload = json.loads(result)
    except (json.JSONDecodeError, TypeError):
        return False
    if not isinstance(payload, dict):
        return False
    if payload.get("render_ui") is not True:
        return False
    components = payload.get("components")
    if isinstance(components, dict):
        components = [components]
    if not isinstance(components, list):
        return False
    return any(isinstance(c, dict) and c.get("type") == "flow_chart" for c in components)


class SkillExecutor:
    """Executes skills using an OpenAI-compatible LLM client."""

    def __init__(
        self,
        client: OpenAI | AzureOpenAI,
        deployment: str,
        system_prompt: str = "You are a helpful assistant. Use the available tools to help the user.",
        agent_logger: AgentLogger | None = None,
    ) -> None:
        self.client = client
        self.deployment = deployment
        self.system_prompt = system_prompt
        self.agent_logger = agent_logger
        self._log = log.bind(component="executor", deployment=deployment)
        self._current_session_id: str | None = None

    def execute(
        self,
        skill: Skill,
        temperature: float = 1.0,
        **kwargs: Any,
    ) -> str:
        """Execute a skill directly via prompt template (legacy mode)."""
        skill_log = self._log.bind(skill=skill.name, mode="prompt-only")
        user_prompt = skill.render_prompt(**kwargs)

        skill_log.info("executing", params=kwargs)

        create_kwargs: dict[str, Any] = {
            "model": self.deployment,
            "messages": [
                {"role": "system", "content": self.system_prompt},
                {"role": "user", "content": user_prompt},
            ],
        }
        if temperature != 1.0:
            create_kwargs["temperature"] = temperature
        response = self.client.chat.completions.create(**create_kwargs)

        content = response.choices[0].message.content
        result = content if content else ""

        skill_log.info(
            "completed",
            result_length=len(result),
            result_preview=result[:100] + "..." if len(result) > 100 else result,
        )
        return result

    def _execute_tool_call(
        self,
        tool_call: Any,
        handler_skills: dict[str, Skill],
    ) -> tuple[str, int]:
        """Execute a single tool call and return (result, duration_ms)."""
        fn_name = tool_call.function.name
        call_log = self._log.bind(tool=fn_name, call_id=tool_call.id)

        try:
            fn_args = json.loads(tool_call.function.arguments)
        except json.JSONDecodeError as exc:
            call_log.error("invalid_arguments_json", error=str(exc))
            error_msg = f"ERROR: Invalid arguments JSON: {exc}"
            if self.agent_logger:
                self.agent_logger.log_tool_call(
                    session_id=self._current_session_id,
                    tool_name=fn_name,
                    tool_args={},
                    duration_ms=0,
                    error=str(exc),
                )
            return error_msg, 0

        skill = handler_skills.get(fn_name)
        if skill is None or skill.handler is None:
            call_log.warning("unknown_tool_called")
            error_msg = f"ERROR: Unknown tool '{fn_name}'"
            if self.agent_logger:
                self.agent_logger.log_tool_call(
                    session_id=self._current_session_id,
                    tool_name=fn_name,
                    tool_args=fn_args,
                    duration_ms=0,
                    error="Unknown tool",
                )
            return error_msg, 0

        call_log.info("invoking_handler", args=fn_args)

        start_time = time.perf_counter()
        try:
            result = skill.handler(**fn_args)
            duration_ms = int((time.perf_counter() - start_time) * 1000)

            # Log successful tool call
            if self.agent_logger:
                self.agent_logger.log_tool_call(
                    session_id=self._current_session_id,
                    tool_name=fn_name,
                    tool_args=fn_args,
                    duration_ms=duration_ms,
                    result_preview=result,
                )
        except Exception as exc:
            duration_ms = int((time.perf_counter() - start_time) * 1000)
            call_log.error(
                "handler_exception",
                error_type=type(exc).__name__,
                error=str(exc),
                exc_info=True,
            )
            error_msg = f"ERROR: {type(exc).__name__}: {exc}"

            # Log failed tool call
            if self.agent_logger:
                self.agent_logger.log_tool_call(
                    session_id=self._current_session_id,
                    tool_name=fn_name,
                    tool_args=fn_args,
                    duration_ms=duration_ms,
                    error=str(exc),
                )
            return error_msg, duration_ms

        call_log.info(
            "handler_completed",
            result_length=len(result),
            result_preview=result[:100] + "..." if len(result) > 100 else result,
        )

        # Hard cap: prevent any single tool result from flooding the context window
        _MAX_TOOL_RESULT = 30_000
        if len(result) > _MAX_TOOL_RESULT:
            call_log.warning(
                "tool_result_truncated",
                original_length=len(result),
                cap=_MAX_TOOL_RESULT,
            )
            result = (
                result[:_MAX_TOOL_RESULT]
                + f"\n\n[Result truncated — original {len(result):,} chars, capped to {_MAX_TOOL_RESULT:,}]"
            )

        return result, duration_ms

    def run(
        self,
        user_message: str,
        skills: dict[str, Skill],
        messages: list[dict[str, Any]] | None = None,
        temperature: float = 1.0,
        max_turns: int = 10,
        session_id: str | None = None,
        context_injection: str | None = None,
        user_content: str | list[dict[str, Any]] | None = None,
        attachment_meta: list[dict[str, Any]] | None = None,
        stream: bool = False,
    ) -> str | Generator[str, None, str]:
        """Run a conversation turn with tool-calling support.

        Args:
            user_message: The user's input message.
            skills: Dict of skill_name -> Skill (only those with handlers are
                registered as tools).
            messages: Optional external message list. If provided, the executor
                appends to it (for session persistence). If None, a fresh
                stateless list is created.
            temperature: LLM temperature.
            max_turns: Safety limit on tool-call round trips.
            session_id: Optional session ID for logging.
            context_injection: Optional context to inject for this turn only
                (e.g., domain knowledge when query is relevant). This is added
                as a system message before the user message and NOT persisted
                to the session.
            user_content: Optional multimodal content for the user message.
                When provided (e.g. a list with text + image_url parts), this
                is used as the ``content`` field sent to the LLM instead of the
                plain ``user_message`` string.  The stored/persisted message
                still uses the plain text so that base64 data is not saved.
            attachment_meta: Optional list of attachment metadata dicts to
                store on the persisted user message (``_attachments`` key).
            stream: If True, returns a generator that yields text chunks as
                they arrive from the LLM. The generator's return value is the
                full response text.

        Returns:
            The final assistant text response (str), or a generator of text
            chunks if stream=True.
        """
        if stream:
            return self._run_stream(
                user_message,
                skills,
                messages,
                temperature,
                max_turns,
                session_id,
                context_injection,
                user_content,
                attachment_meta,
            )
        return self._run_sync(
            user_message,
            skills,
            messages,
            temperature,
            max_turns,
            session_id,
            context_injection,
            user_content,
            attachment_meta,
        )

    def _run_sync(
        self,
        user_message: str,
        skills: dict[str, Skill],
        messages: list[dict[str, Any]] | None = None,
        temperature: float = 1.0,
        max_turns: int = 10,
        session_id: str | None = None,
        context_injection: str | None = None,
        user_content: str | list[dict[str, Any]] | None = None,
        attachment_meta: list[dict[str, Any]] | None = None,
    ) -> str:
        self._current_session_id = session_id
        run_log = self._log.bind(max_turns=max_turns, session_id=session_id)

        tools: list[dict[str, Any]] = []
        handler_skills: dict[str, Skill] = {}
        for name, skill in skills.items():
            if skill.has_handler:
                tools.append(to_openai_tool(skill))
                handler_skills[name] = skill

        # Use external messages list or create a fresh one (backwards-compat)
        if messages is None:
            messages = [{"role": "system", "content": self.system_prompt}]

        # Append user message (this is persisted — plain text only, no base64)
        user_msg: dict[str, Any] = {"role": "user", "content": user_message}
        if attachment_meta:
            user_msg["_attachments"] = attachment_meta
        messages.append(user_msg)

        # Determine content to send to the LLM (may be multimodal)
        _llm_user_content = user_content if user_content is not None else user_message

        # Log user message
        if self.agent_logger:
            self.agent_logger.log_message(
                session_id=session_id,
                role="user",
                content_preview=user_message,
            )

        # Prepare context injection if provided (not persisted to session)
        # This will be prepended to messages for API calls only
        has_context_injection = bool(context_injection)
        if has_context_injection:
            run_log.debug("context_injection_prepared", content_length=len(context_injection))

        run_log.info(
            "conversation_started",
            user_message_length=len(user_message),
            available_tools=[t["function"]["name"] for t in tools],
            message_history_length=len(messages),
            context_injected=has_context_injection,
        )

        # Ephemeral skill guidance to inject on the next LLM call
        # (progressive disclosure: loaded only after a tool is called)
        _pending_skill_guidance: str | None = None

        for turn in range(max_turns):
            turn_log = run_log.bind(turn=turn + 1)
            turn_log.debug("sending_request")

            api_messages = self._build_api_messages(
                messages,
                turn,
                _llm_user_content,
                user_message,
                has_context_injection,
                context_injection,
                _pending_skill_guidance,
            )
            _pending_skill_guidance = None

            create_kwargs: dict[str, Any] = {
                "model": self.deployment,
                "messages": api_messages,
            }
            if temperature != 1.0:
                create_kwargs["temperature"] = temperature
            if tools:
                create_kwargs["tools"] = tools

            # Time the LLM request
            start_time = time.perf_counter()
            response = self.client.chat.completions.create(**create_kwargs)
            duration_ms = int((time.perf_counter() - start_time) * 1000)

            assistant_msg = response.choices[0].message

            # Extract token usage from response
            tokens_input = None
            tokens_output = None
            if response.usage:
                tokens_input = response.usage.prompt_tokens
                tokens_output = response.usage.completion_tokens

            # Log LLM request
            if self.agent_logger:
                self.agent_logger.log_llm_request(
                    session_id=session_id,
                    duration_ms=duration_ms,
                    tokens_input=tokens_input,
                    tokens_output=tokens_output,
                    response_preview=assistant_msg.content,
                    metadata={
                        "turn": turn + 1,
                        "has_tool_calls": bool(assistant_msg.tool_calls),
                        "model": self.deployment,
                    },
                )

            if not assistant_msg.tool_calls:
                final_text = assistant_msg.content or ""
                # Append assistant response to messages for persistence
                messages.append(
                    {
                        "role": "assistant",
                        "content": final_text,
                        "_metadata": {"model": self.deployment},
                    }
                )

                # Log assistant message
                if self.agent_logger:
                    self.agent_logger.log_message(
                        session_id=session_id,
                        role="assistant",
                        content_preview=final_text,
                    )

                turn_log.info(
                    "conversation_completed",
                    response_length=len(final_text),
                )
                return final_text

            tool_names = [tc.function.name for tc in assistant_msg.tool_calls]
            turn_log.info(
                "tool_calls_requested",
                count=len(assistant_msg.tool_calls),
                tools=tool_names,
            )

            assistant_dict = assistant_msg.model_dump()
            assistant_dict["_metadata"] = {"model": self.deployment}
            messages.append(assistant_dict)

            # Collect skill guidance for tools called this turn
            # (progressive disclosure: inject prompt_template only when needed)
            _turn_guidance_parts: list[str] = []
            for _tn in tool_names:
                _sk = handler_skills.get(_tn)
                if _sk and _sk.prompt_template.strip():
                    _turn_guidance_parts.append(f"## Guidance for {_tn}\n{_sk.prompt_template.strip()}")

            flow_chart_already_rendered = False
            for tool_call in assistant_msg.tool_calls:
                fn_name = tool_call.function.name
                should_skip_render_ui = (
                    fn_name == "render_ui"
                    and flow_chart_already_rendered
                    and is_render_ui_flow_chart_call(tool_call.function.arguments)
                )

                if should_skip_render_ui:
                    result = json.dumps(
                        {
                            "ok": True,
                            "skipped": True,
                            "reason": (
                                "Skipped duplicate render_ui flow_chart because "
                                "flow_chart_builder already returned a render_ui payload."
                            ),
                        }
                    )
                    tool_duration_ms = 0
                else:
                    result, tool_duration_ms = self._execute_tool_call(tool_call, handler_skills)
                    if fn_name == "flow_chart_builder" and has_render_ui_flow_chart_payload(str(result)):
                        flow_chart_already_rendered = True

                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tool_call.id,
                        "name": fn_name,
                        "content": str(result),
                        "_metadata": {
                            "tool_name": fn_name,
                            "duration_ms": tool_duration_ms,
                        },
                    }
                )

            # Stage skill guidance for injection on the next LLM call
            if _turn_guidance_parts:
                _pending_skill_guidance = "\n\n".join(_turn_guidance_parts)

        run_log.warning("max_turns_exceeded")
        if self.agent_logger:
            self.agent_logger.log_error(
                session_id=session_id,
                error="Maximum conversation turns exceeded",
                context=f"max_turns={max_turns}",
            )
        return "ERROR: Maximum conversation turns exceeded."

    def _run_stream(
        self,
        user_message: str,
        skills: dict[str, Skill],
        messages: list[dict[str, Any]] | None = None,
        temperature: float = 1.0,
        max_turns: int = 10,
        session_id: str | None = None,
        context_injection: str | None = None,
        user_content: str | list[dict[str, Any]] | None = None,
        attachment_meta: list[dict[str, Any]] | None = None,
    ) -> Generator[str, None, str]:
        """Streaming variant of _run_sync. Yields text chunks; returns full text."""
        self._current_session_id = session_id
        run_log = self._log.bind(max_turns=max_turns, session_id=session_id)

        tools: list[dict[str, Any]] = []
        handler_skills: dict[str, Skill] = {}
        for name, skill in skills.items():
            if skill.has_handler:
                tools.append(to_openai_tool(skill))
                handler_skills[name] = skill

        if messages is None:
            messages = [{"role": "system", "content": self.system_prompt}]

        user_msg: dict[str, Any] = {"role": "user", "content": user_message}
        if attachment_meta:
            user_msg["_attachments"] = attachment_meta
        messages.append(user_msg)

        _llm_user_content = user_content if user_content is not None else user_message

        if self.agent_logger:
            self.agent_logger.log_message(
                session_id=session_id,
                role="user",
                content_preview=user_message,
            )

        has_context_injection = bool(context_injection)
        _pending_skill_guidance: str | None = None

        run_log.info(
            "conversation_started",
            user_message_length=len(user_message),
            available_tools=[t["function"]["name"] for t in tools],
            message_history_length=len(messages),
            context_injected=has_context_injection,
        )

        for turn in range(max_turns):
            turn_log = run_log.bind(turn=turn + 1)
            turn_log.debug("sending_request")

            api_messages = self._build_api_messages(
                messages,
                turn,
                _llm_user_content,
                user_message,
                has_context_injection,
                context_injection,
                _pending_skill_guidance,
            )
            _pending_skill_guidance = None

            create_kwargs: dict[str, Any] = {
                "model": self.deployment,
                "messages": api_messages,
                "stream": True,
            }
            if temperature != 1.0:
                create_kwargs["temperature"] = temperature
            if tools:
                create_kwargs["tools"] = tools

            start_time = time.perf_counter()
            stream_resp = self.client.chat.completions.create(**create_kwargs)

            # Accumulate streamed response
            collected_content: list[str] = []
            collected_tool_calls: dict[int, dict[str, Any]] = {}

            for chunk in stream_resp:
                if not chunk.choices:
                    continue
                delta = chunk.choices[0].delta

                # Text content
                if delta.content:
                    collected_content.append(delta.content)
                    yield delta.content

                # Tool calls (accumulated across chunks)
                if delta.tool_calls:
                    for tc_delta in delta.tool_calls:
                        idx = tc_delta.index
                        if idx not in collected_tool_calls:
                            collected_tool_calls[idx] = {
                                "id": tc_delta.id or "",
                                "function": {"name": "", "arguments": ""},
                                "type": "function",
                            }
                        entry = collected_tool_calls[idx]
                        if tc_delta.id:
                            entry["id"] = tc_delta.id
                        if tc_delta.function:
                            if tc_delta.function.name:
                                entry["function"]["name"] += tc_delta.function.name
                            if tc_delta.function.arguments:
                                entry["function"]["arguments"] += tc_delta.function.arguments

            duration_ms = int((time.perf_counter() - start_time) * 1000)
            final_text = "".join(collected_content)

            # Log
            if self.agent_logger:
                self.agent_logger.log_llm_request(
                    session_id=session_id,
                    duration_ms=duration_ms,
                    tokens_input=None,
                    tokens_output=None,
                    response_preview=final_text or None,
                    metadata={
                        "turn": turn + 1,
                        "has_tool_calls": bool(collected_tool_calls),
                        "model": self.deployment,
                        "streamed": True,
                    },
                )

            # No tool calls — we're done
            if not collected_tool_calls:
                messages.append(
                    {
                        "role": "assistant",
                        "content": final_text,
                        "_metadata": {"model": self.deployment},
                    }
                )
                if self.agent_logger:
                    self.agent_logger.log_message(
                        session_id=session_id,
                        role="assistant",
                        content_preview=final_text,
                    )
                turn_log.info("conversation_completed", response_length=len(final_text))
                return final_text

            # Has tool calls — process them (non-streaming)
            sorted_tool_calls = [collected_tool_calls[i] for i in sorted(collected_tool_calls)]
            tool_names = [tc["function"]["name"] for tc in sorted_tool_calls]
            turn_log.info("tool_calls_requested", count=len(sorted_tool_calls), tools=tool_names)

            # Build assistant message with tool_calls
            assistant_dict: dict[str, Any] = {
                "role": "assistant",
                "content": final_text or None,
                "tool_calls": sorted_tool_calls,
                "_metadata": {"model": self.deployment},
            }
            messages.append(assistant_dict)

            # Collect skill guidance
            _turn_guidance_parts: list[str] = []
            for _tn in tool_names:
                _sk = handler_skills.get(_tn)
                if _sk and _sk.prompt_template.strip():
                    _turn_guidance_parts.append(f"## Guidance for {_tn}\n{_sk.prompt_template.strip()}")

            # Execute tool calls
            flow_chart_already_rendered = False
            for tc in sorted_tool_calls:
                fn_name = tc["function"]["name"]

                # Create a simple object to pass to _execute_tool_call
                class _ToolCall:
                    def __init__(self, d: dict[str, Any]) -> None:
                        self.id = d["id"]
                        self.function = type(
                            "F",
                            (),
                            {
                                "name": d["function"]["name"],
                                "arguments": d["function"]["arguments"],
                            },
                        )()

                tool_call_obj = _ToolCall(tc)

                should_skip_render_ui = (
                    fn_name == "render_ui"
                    and flow_chart_already_rendered
                    and is_render_ui_flow_chart_call(tc["function"]["arguments"])
                )

                if should_skip_render_ui:
                    result = json.dumps(
                        {
                            "ok": True,
                            "skipped": True,
                            "reason": "Skipped duplicate render_ui flow_chart.",
                        }
                    )
                    tool_duration_ms = 0
                else:
                    result, tool_duration_ms = self._execute_tool_call(tool_call_obj, handler_skills)
                    if fn_name == "flow_chart_builder" and has_render_ui_flow_chart_payload(str(result)):
                        flow_chart_already_rendered = True

                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": tc["id"],
                        "name": fn_name,
                        "content": str(result),
                        "_metadata": {"tool_name": fn_name, "duration_ms": tool_duration_ms},
                    }
                )

            if _turn_guidance_parts:
                _pending_skill_guidance = "\n\n".join(_turn_guidance_parts)

        run_log.warning("max_turns_exceeded")
        if self.agent_logger:
            self.agent_logger.log_error(
                session_id=session_id,
                error="Maximum conversation turns exceeded",
                context=f"max_turns={max_turns}",
            )
        return "ERROR: Maximum conversation turns exceeded."

    @staticmethod
    def _build_api_messages(
        messages: list[dict[str, Any]],
        turn: int,
        llm_user_content: str | list[dict[str, Any]],
        user_message: str,
        has_context_injection: bool,
        context_injection: str | None,
        pending_skill_guidance: str | None,
    ) -> list[dict[str, Any]]:
        """Build the api_messages list for an LLM call (shared by sync/stream)."""
        api_messages = messages.copy()

        if turn == 0 and llm_user_content is not user_message:
            for i in range(len(api_messages) - 1, -1, -1):
                if api_messages[i].get("role") == "user":
                    api_messages[i] = {k: v for k, v in api_messages[i].items() if k != "_attachments"}
                    api_messages[i]["content"] = llm_user_content
                    break

        api_messages = [{k: v for k, v in m.items() if not k.startswith("_")} for m in api_messages]

        if turn == 0 and has_context_injection and context_injection:
            api_messages.insert(1, {"role": "system", "content": context_injection})

        if pending_skill_guidance:
            api_messages.append({"role": "system", "content": pending_skill_guidance})

        return api_messages
