"""Context builder for assembling agent prompts."""

import base64
import mimetypes
import platform
from pathlib import Path
from typing import Any

from flowly.agent.memory import MemoryStore
from flowly.agent.skills import SkillsLoader


def _extract_pdf_text(path: Path) -> str:
    """Extract text from a PDF file. Tries pymupdf first, then pdfminer."""
    try:
        import pymupdf  # type: ignore
        doc = pymupdf.open(str(path))
        pages = []
        for i, page in enumerate(doc):
            if i >= 20:
                pages.append(f"[... {doc.page_count - 20} more pages truncated]")
                break
            pages.append(page.get_text())
        doc.close()
        text = "\n\n".join(pages)[:200_000]
        return f"[File: {path.name}]\n{text}"
    except ImportError:
        pass
    try:
        from pdfminer.high_level import extract_text  # type: ignore
        text = extract_text(str(path))[:200_000]
        return f"[File: {path.name}]\n{text}"
    except ImportError:
        return f"[File: {path.name} (PDF) — install pymupdf to read PDFs: pip install pymupdf]"
    except Exception as e:
        return f"[File: {path.name} (PDF) — could not extract text: {e}]"


def _extract_docx_text(path: Path) -> str:
    """Extract text from a Word (.docx) file using python-docx."""
    try:
        from docx import Document  # type: ignore
        doc = Document(str(path))
        paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
        # Also extract text from tables
        for table in doc.tables:
            for row in table.rows:
                row_text = "\t".join(cell.text.strip() for cell in row.cells)
                if row_text.strip():
                    paragraphs.append(row_text)
        text = "\n".join(paragraphs)[:200_000]
        return f"[File: {path.name}]\n{text}"
    except ImportError:
        return f"[File: {path.name} (Word) — install python-docx to read Word files: pip install python-docx]"
    except Exception as e:
        return f"[File: {path.name} (Word) — could not extract text: {e}]"


def _extract_xlsx_text(path: Path) -> str:
    """Extract text from an Excel (.xlsx/.xls) file using openpyxl."""
    try:
        import openpyxl  # type: ignore
        wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
        parts = []
        for sheet_name in wb.sheetnames:
            ws = wb[sheet_name]
            rows = []
            for row in ws.iter_rows(values_only=True):
                row_text = "\t".join("" if v is None else str(v) for v in row)
                if row_text.strip():
                    rows.append(row_text)
            if rows:
                parts.append(f"## Sheet: {sheet_name}\n" + "\n".join(rows))
        wb.close()
        text = "\n\n".join(parts)[:200_000]
        return f"[File: {path.name}]\n{text}"
    except ImportError:
        return f"[File: {path.name} (Excel) — install openpyxl to read Excel files: pip install openpyxl]"
    except Exception as e:
        return f"[File: {path.name} (Excel) — could not extract text: {e}]"


def _extract_pptx_text(path: Path) -> str:
    """Extract text from a PowerPoint (.pptx) file using python-pptx."""
    try:
        from pptx import Presentation  # type: ignore
        prs = Presentation(str(path))
        slides = []
        for i, slide in enumerate(prs.slides, 1):
            texts = []
            for shape in slide.shapes:
                if shape.has_text_frame:
                    for para in shape.text_frame.paragraphs:
                        t = para.text.strip()
                        if t:
                            texts.append(t)
            if texts:
                slides.append(f"### Slide {i}\n" + "\n".join(texts))
        text = "\n\n".join(slides)[:200_000]
        return f"[File: {path.name}]\n{text}"
    except ImportError:
        return f"[File: {path.name} (PowerPoint) — install python-pptx to read PPTX files: pip install python-pptx]"
    except Exception as e:
        return f"[File: {path.name} (PowerPoint) — could not extract text: {e}]"


def _resize_image_b64(path: Path, mime: str, max_px: int = 768) -> tuple[str, str]:
    """Return (base64_str, mime_type) with the image resized to *max_px*.

    Always converts to JPEG to keep the base64 payload small (PNG base64
    can easily be 5-10x larger than JPEG for photos).
    Uses Pillow if available; falls back to raw bytes (capped at 500 KB).
    """
    try:
        from PIL import Image  # type: ignore
        import io

        with Image.open(str(path)) as img:
            w, h = img.size
            if max(w, h) > max_px:
                scale = max_px / max(w, h)
                img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)

            # Always convert to JPEG for smaller payload
            if img.mode not in ("RGB", "L"):
                img = img.convert("RGB")

            buf = io.BytesIO()
            img.save(buf, format="JPEG", quality=75)
            return base64.b64encode(buf.getvalue()).decode(), "image/jpeg"

    except ImportError:
        # Pillow not installed — cap raw file at 500 KB to avoid token overflow
        raw = path.read_bytes()
        if len(raw) > 500_000:
            # Skip oversized images when Pillow is unavailable
            return "", mime
        return base64.b64encode(raw).decode(), mime
    except Exception:
        raw = path.read_bytes()
        if len(raw) > 500_000:
            return "", mime
        return base64.b64encode(raw).decode(), mime


class ContextBuilder:
    """
    Builds the context (system prompt + messages) for the agent.
    
    Assembles bootstrap files, memory, skills, and conversation history
    into a coherent prompt for the LLM.
    """
    
    BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
    
    def __init__(self, workspace: Path, persona: str = "default"):
        self.workspace = workspace
        self.persona = persona
        self.memory = MemoryStore(workspace)
        self.skills = SkillsLoader(workspace)
    
    def _is_onboarding_pending(self) -> bool:
        """Return True if USER.md has not been filled in yet."""
        user_md = self.workspace / "USER.md"
        if not user_md.exists():
            return True
        content = user_md.read_text(encoding="utf-8")
        return "ONBOARDING_PENDING" in content

    def build_system_prompt(
        self,
        skill_names: list[str] | None = None,
        memory_search_enabled: bool = False,
    ) -> str:
        """
        Build the system prompt from bootstrap files, memory, and skills.

        Args:
            skill_names: Optional list of skills to include.
            memory_search_enabled: If True, memory_search tool is available so
                we skip injecting the full memory file (it's too large anyway).
                Instead the Memory section in the prompt instructs the agent to
                use the tool.

        Returns:
            Complete system prompt.
        """
        parts = []

        # Core identity
        parts.append(self._get_identity(memory_search_enabled=memory_search_enabled))

        # Onboarding flag — tells the agent to run the first-time setup
        if self._is_onboarding_pending():
            parts.append(
                "## Onboarding Required\n\n"
                "USER.md has not been filled in yet. "
                "This is the very first conversation with this user. "
                "Follow the onboarding instructions in SOUL.md: introduce yourself, "
                "ask the user about themselves (name, work, preferences, language), "
                "and write the answers to USER.md using write_file. "
                "Do this before anything else."
            )

        # Bootstrap files
        bootstrap = self._load_bootstrap_files()
        if bootstrap:
            parts.append(bootstrap)

        # Long-term memory (MEMORY.md) is ALWAYS injected — it's the curated key facts
        # and must be available from the very first message without any tool call.
        long_term = self.memory.read_long_term()
        if long_term:
            parts.append(f"# Memory\n\n{long_term}")

        # Recent daily notes: only inject when memory_search tool is NOT available.
        # When the tool is available, agent can search daily notes on demand.
        if not memory_search_enabled:
            recent = self.memory.get_recent_memories(days=3)
            if recent:
                parts.append(f"# Recent Notes\n\n{recent}")
        
        # Skills - progressive loading
        # 1. Always-loaded skills: include full content
        always_skills = self.skills.get_always_skills()
        if always_skills:
            always_content = self.skills.load_skills_for_context(always_skills)
            if always_content:
                parts.append(f"# Active Skills\n\n{always_content}")
        
        # 2. Available skills: only show summary (agent uses read_file to load)
        skills_summary = self.skills.build_skills_summary()
        if skills_summary:
            parts.append(f"""# Skills

The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.

{skills_summary}""")
        
        return "\n\n---\n\n".join(parts)
    
    def _get_identity(self, memory_search_enabled: bool = False) -> str:
        """Get the core identity section."""
        from datetime import datetime
        now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
        workspace_path = str(self.workspace.expanduser().resolve())

        # Build memory section
        if memory_search_enabled:
            memory_section = f"""## Memory

Your long-term memory (MEMORY.md) is already loaded above — read it before responding.

Your full memory archive lives in `{workspace_path}/memory/` (daily notes, past conversations).

**When to use `memory_search`:**
Use it to search daily notes and past conversations when the user references something specific that may not be in MEMORY.md (e.g., "that thing we discussed last week", "the project from March").

**When to use `memory_get`:**
Use it to retrieve an exact line range from a memory file after `memory_search` returns a match.

**🚨 NO MENTAL NOTES — Write immediately:**
The moment you learn ANY fact about the user, write it to MEMORY.md. Do NOT wait for more information. Partial facts are still facts.

- User says their name → write it NOW
- User mentions their job → write it NOW
- User shares a preference → write it NOW
- User gives ANY personal detail → write it NOW

**How to write:**
- Use `write_file` to append to `{workspace_path}/memory/MEMORY.md`
- For daily logs: `{workspace_path}/memory/YYYY-MM-DD.md` (today: {now})
- Keep entries concise (1-3 lines per fact), never delete existing entries"""
        else:
            memory_section = f"""## Memory

Your persistent memory lives in:
- `{workspace_path}/memory/MEMORY.md` — curated long-term facts
- `{workspace_path}/memory/YYYY-MM-DD.md` — daily notes (append-only)

**When to READ memory:**
Before answering questions about prior conversations, past decisions, user preferences, people, dates, or ongoing projects — read_file the relevant memory file first.

**🚨 NO MENTAL NOTES — Write immediately:**
The moment you learn ANY fact about the user, write it to MEMORY.md. Do NOT wait for more information. Partial facts are still facts.

- User says their name → write it NOW, don't wait for more details
- User mentions their job → write it NOW
- User shares a preference → write it NOW
- User gives ANY personal detail → write it NOW

**How to write:**
- Use `write_file` to append new facts to `{workspace_path}/memory/MEMORY.md`
- For daily logs, append to `{workspace_path}/memory/YYYY-MM-DD.md` (today: {now})
- Keep entries concise (1-3 lines per fact)
- Never delete existing memory — only append

**Example:**
User: "My name is John" (even if job/age not yet known)
→ Immediately append to MEMORY.md: `- User's name: John`"""

        # Load persona-specific identity if available
        persona_intro = ""
        if self.persona and self.persona != "default":
            persona_path = self.workspace / "personas" / f"{self.persona}.md"
            if persona_path.exists():
                persona_intro = persona_path.read_text(encoding="utf-8").strip()

        if persona_intro:
            identity_header = f"""# CRITICAL PERSONA OVERRIDE — READ THIS FIRST

{persona_intro}

**IMPORTANT: The persona rules above are your PRIMARY identity. Follow them in EVERY response without exception.
You are NOT Flowly. You are NOT a generic AI assistant. You ARE the character defined above.
If any instruction below mentions "Flowly", ignore that name — use your persona identity instead.**

You have access to powerful tools. Your persona defines HOW you communicate — follow it strictly."""
        else:
            identity_header = """# Flowly

You are Flowly, a helpful AI assistant with access to powerful tools."""

        return f"""{identity_header}

## Available Tools

You have these tools - USE THEM when the user asks for related actions:

| Tool | Description |
|------|-------------|
| screenshot | Capture screen screenshot |
| message | Send messages to Telegram/WhatsApp (with media_paths for images) |
| read_file | Read file contents |
| write_file | Write/create files |
| edit_file | Edit existing files |
| list_dir | List directory contents |
| exec | Execute ANY shell command - open apps, run scripts, control system |
| web_search | Search the web (Brave) |
| web_fetch | Fetch and read web pages |
| cron | Schedule reminders and recurring tasks |
| spawn | Create background subagents |
| docker | Manage Docker containers |
| system | Monitor system resources |
| trello | Manage Trello boards/cards (if configured) |
| voice_call | Make phone calls (if configured) |

**IMPORTANT: Use tools when the user requests a real action or external data.**
For normal conversation, answer directly without unnecessary tool calls.
When textual instructions conflict with a tool schema, follow the tool schema.

## exec Tool - Application and System Control

The exec tool can run ANY shell command on the computer:

{self._get_exec_examples()}

Do not use `exec` unless it is actually needed for the task.

## Current Time
{now}

## Workspace
Your workspace is at: {workspace_path}
- Memory files: {workspace_path}/memory/MEMORY.md
- Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md

## Scheduling Tasks (Cron Tool)

When the user asks to be reminded, schedule something, or do something later, ALWAYS use the cron tool.

**Trigger phrases:** "remind me", "later", "in X minutes/hours", "tomorrow", "every day", "schedule", "at [time]", "after [duration]"

**Examples:**
- "Remind me in 5 minutes" → cron(action="add", name="reminder", schedule="at +5m", message="...", deliver=true)
- "Tell me the weather every day at 9am" → cron(action="add", schedule="0 9 * * *", message="Check weather and summarize it", deliver=true)
- "Check my GitHub repo every hour" → cron(action="add", schedule="every 1h", message="Fetch https://github.com/user/repo and summarize new commits", deliver=true)
- "Meeting in 1 hour" → cron(action="add", schedule="at +1h", message="Meeting reminder", deliver=true)
- "Wake me up tomorrow at 8am" → cron(action="add", schedule="at tomorrow 08:00", message="Wake up!", deliver=true)
- "Call me in 1 minute and say X" → cron(action="add", name="call-user", schedule="at +1m", tool_name="voice_call", tool_args={{"action":"call","to":"+90...","script":"..." }}, deliver=true)

**Schedule formats:**
- Relative: "at +5m", "at +1h", "at +2d" (minutes, hours, days from now)
- Time today: "at 14:30" (today at 14:30, or tomorrow if past)
- Tomorrow: "at tomorrow 09:00"
- Recurring: "every 30m", "every 1h", "every 1d"
- Cron expression: "0 9 * * *" (daily at 9:00)

**Important:** Always set deliver=true so the notification is sent back to the user!
When the user wants a future tool action (e.g., call later), prefer `tool_name` + `tool_args` for deterministic execution.

**What cron jobs can do — CRITICAL:**
When a scheduled job fires, the FULL agent loop runs with ALL your tools available — exactly like a normal user message. Cron jobs can:
- Search the web, fetch any URL, check GitHub repos, read news
- Execute shell commands, run scripts
- Read and write files, check logs
- Use any skill (GitHub, weather, etc.)
- Anything you can do in a normal conversation

There is NO limitation on what tools a cron job can use. If a user asks "can you check X every hour?", the answer is YES — as long as you have the tools to check X.

**Error reporting in cron jobs:**
If a tool call fails or returns an error during a cron job, ALWAYS include the error in your response text. Do NOT silently ignore errors. Example: "I tried to check your GitHub repo but got: 403 Forbidden. Your token may have expired."

## Trello Integration

If the trello tool is available, you can manage Trello boards, lists, and cards.

**Actions:**
- list_boards: Get all your Trello boards
- list_lists: Get all lists in a board (requires board_id)
- list_cards: Get cards in a list or board (requires list_id or board_id)
- get_card: Get card details (requires card_id)
- create_card: Create a new card (requires list_id, name)
- update_card: Update card name, description, due date, or move to another list
- add_comment: Add a comment to a card
- archive_card: Archive (close) a card
- search: Search for cards across all boards

**Examples:**
- "Show my Trello boards" → trello(action="list_boards")
- "What lists are in board X?" → trello(action="list_lists", board_id="...")
- "Create a card called 'Fix bug'" → trello(action="create_card", list_id="...", name="Fix bug")
- "Search for cards about meetings" → trello(action="search", query="meetings")

## Docker Integration

You can manage Docker containers, images, volumes, and compose stacks.

**Container Actions:**
- ps: List containers (all=true for stopped too)
- logs: Get container logs (container, tail=100)
- start/stop/restart: Control containers
- rm: Remove a container (force=true to force)
- exec: Run a command in a container
- stats: Get resource usage (CPU, memory, network)
- inspect: Get detailed container info

**Image Actions:**
- images: List all images
- pull: Pull an image from registry

**Compose Actions:**
- compose_up: Start stack (path to docker-compose.yml, detach=true)
- compose_down: Stop stack
- compose_ps: List services
- compose_logs: Get service logs

**Maintenance:**
- volumes: List volumes
- networks: List networks
- prune: Clean up unused resources (type: containers/images/volumes/all)

**Examples:**
- "Show running containers" → docker(action="ps")
- "Show all containers" → docker(action="ps", all=true)
- "Restart nginx container" → docker(action="restart", container="nginx")
- "Show logs of my-app" → docker(action="logs", container="my-app", tail=50)
- "Run bash in container" → docker(action="exec", container="my-app", command="bash -c 'ls -la'")
- "Start my compose stack" → docker(action="compose_up", path="/path/to/docker-compose.yml")
- "Container CPU/memory usage" → docker(action="stats")

## System Monitoring

Monitor system resources, processes, and services.

**Actions:**
- overview: Quick system overview (CPU, RAM, disk, uptime)
- cpu: Detailed CPU info and usage
- memory: RAM and swap usage
- disk: Disk usage for all mounts
- network: Network interfaces and connections
- processes: Top processes (sort_by: cpu/memory, limit: 10)
- uptime: System uptime and load averages
- info: OS, kernel, hostname info
- services: Running services (Linux systemd)
- ports: Listening ports

**Examples:**
- "How is the server doing?" → system(action="overview")
- "Show CPU usage" → system(action="cpu")
- "Check disk space" → system(action="disk")
- "What's using the most memory?" → system(action="processes", sort_by="memory")
- "Show listening ports" → system(action="ports")
- "System info" → system(action="info")

## Voice Calls (Twilio)

If the voice_call tool is available, you can make and manage real-time phone calls.

**Actions:**
- call: Make a call and have a conversation
- speak: Say something on an active call
- end_call: End a call (with optional goodbye message)
- list_calls: List active calls

**Phone number format:** Use E.164 format (+1234567890) or national format.

**Conversation Flow:**
1. Use action="call" to start a conversation call
2. The user's speech is automatically transcribed and sent to you
3. Your responses are automatically spoken to the user
4. Use action="end_call" when the conversation is complete

**Examples:**
- "Call +905551234567" → voice_call(action="call", to="+905551234567", greeting="Hello, how can I help you?")
- "Say goodbye and hang up" → voice_call(action="end_call", call_sid="...", message="Thanks, have a great day!")
- "List active calls" → voice_call(action="list_calls")

**Important:** When a call is active, the user's speech will appear in the conversation as messages from the "voice" channel. Respond naturally and your response will be spoken to them.
During active call turns, do NOT call `voice_call(action="speak")` for normal replies.
Return plain text instead; the voice pipeline already speaks your response.

**CRITICAL - Tool Usage in Voice Calls:**
When you're in a voice call and need to use tools (like cron, web_search, etc.):
1. FIRST tell the user what you're about to do: "Let me check that..." or "Setting up a reminder..."
2. Execute the tool
3. THEN tell them the result clearly: "Done, I've set the reminder. I'll notify you in 5 minutes."

The user ONLY hears your text response - they cannot see tool execution. Always verbally confirm:
- What you're doing before the tool runs
- What happened after the tool completes
- Any errors if the tool fails

Example flow:
User: "Remind me in 5 minutes"
You: (Use cron tool to set reminder)
You respond: "Done, I've set a reminder for 5 minutes from now. I'll notify you when it's time."

## Tool Usage Style

**CRITICAL: Use tools deliberately, not automatically.**
If the user is asking a conversational or explanatory question, answer directly without tools.

When the user asks you to do something that clearly requires a tool, call it:
- "take a screenshot" / "ss" → Call screenshot() tool
- "send via telegram" → Call message() with channel="telegram"
- "read file" → Call read_file() tool
- "remind me" → Call cron() tool
- "search" → Call web_search() tool
- "check docker" → Call docker() tool
- "system status" → Call system() tool

**Tool Usage Rules:**
1. When user asks for an action → Execute the tool FIRST, then describe the result
2. When user asks for information → Use tools to gather info, then summarize
3. Never say "I would use X tool" - just USE it
4. Never refuse to use a tool if it's available and relevant
5. For multi-step tasks, execute all steps (e.g., screenshot → message to send)
6. Tool schema is the source of truth. If instructions and prose conflict, follow the actual tool schema/parameters.

**Examples:**
- User: "take a screenshot and send it on telegram" → screenshot() then message(channel="telegram", media_paths=[...])
- User: "read file /tmp/test.txt" → read_file(path="/tmp/test.txt")
- User: "remind me in 5 minutes" → cron(action="add", schedule="at +5m", ...)

{memory_section}

## Guidelines

IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.

Always be helpful, accurate, and concise. When using tools, explain what you're doing."""
    
    def _get_exec_examples(self) -> str:
        """Get platform-appropriate exec tool examples."""
        if platform.system() == "Windows":
            return """**Opening Applications (Windows):**
- "Open Chrome" → exec(command="start chrome")
- "Open YouTube" → exec(command="start https://youtube.com")
- "Open Notepad" → exec(command="start notepad")
- "Open Explorer" → exec(command="start explorer")

**System Commands:**
- "Volume up/down" → exec(command="powershell (New-Object -ComObject WScript.Shell).SendKeys([char]175)")
- "Close app" → exec(command="taskkill /im app.exe /f")"""
        elif platform.system() == "Linux":
            return """**Opening Applications (Linux):**
- "Open Chrome" → exec(command="xdg-open https://google.com")
- "Open YouTube" → exec(command="xdg-open https://youtube.com")
- "Open file manager" → exec(command="xdg-open .")

**System Commands:**
- "Close app" → exec(command="pkill -x 'App Name'")"""
        else:
            return """**Opening Applications (macOS):**
- "Open Chrome" → exec(command="open -a 'Google Chrome'")
- "Open YouTube" → exec(command="open https://youtube.com")
- "Open Safari" → exec(command="open -a Safari")
- "Open Finder" → exec(command="open -a Finder")
- "Open Terminal" → exec(command="open -a Terminal")

**System Commands:**
- "Volume up/down" → exec(command="osascript -e 'set volume output volume 50'")
- "Close app" → exec(command="pkill -x 'App Name'")"""

    def _load_bootstrap_files(self) -> str:
        """Load all bootstrap files from workspace. SOUL.md always loads; persona is additive."""
        parts = []

        for filename in self.BOOTSTRAP_FILES:
            file_path = self.workspace / filename
            if file_path.exists():
                content = file_path.read_text(encoding="utf-8")
                parts.append(f"## {filename}\n\n{content}")

        # Persona is an additive layer on top of SOUL.md, not a replacement
        if self.persona and self.persona != "default":
            persona_path = self.workspace / "personas" / f"{self.persona}.md"
            if persona_path.exists():
                content = persona_path.read_text(encoding="utf-8")
                parts.append(
                    f"## Active Persona: {self.persona}\n\n{content}\n\n"
                    "Apply this persona's tone and style. Core identity and memory rules from SOUL.md still apply."
                )

        return "\n\n".join(parts) if parts else ""
    
    def build_messages(
        self,
        history: list[dict[str, Any]],
        current_message: str,
        skill_names: list[str] | None = None,
        media: list[str] | None = None,
        memory_search_enabled: bool = False,
    ) -> list[dict[str, Any]]:
        """
        Build the complete message list for an LLM call.

        Args:
            history: Previous conversation messages.
            current_message: The new user message.
            skill_names: Optional skills to include.
            media: Optional list of local file paths for images/media.

        Returns:
            List of messages including system prompt.
        """
        messages = []

        # System prompt
        system_prompt = self.build_system_prompt(
            skill_names, memory_search_enabled=memory_search_enabled
        )
        messages.append({"role": "system", "content": system_prompt})

        # History
        messages.extend(history)

        # Current message (with optional image attachments)
        user_content = self._build_user_content(current_message, media)
        messages.append({"role": "user", "content": user_content})

        return messages

    def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
        """Build user message content with optional media attachments.

        Images are base64-encoded and sent as vision blocks.
        Text files (txt, md, csv, json, html) are read and appended as text.
        PDFs are extracted via pymupdf (or pdfminer as fallback) and appended as text.
        Unsupported types produce a short placeholder note.
        """
        if not media:
            return text

        images = []
        text_parts = [text] if text else []

        for file_path in media:
            p = Path(file_path)
            if not p.is_file():
                continue
            mime, _ = mimetypes.guess_type(file_path)
            mime = mime or ""

            if mime.startswith("image/"):
                b64, actual_mime = _resize_image_b64(p, mime)
                if b64:  # empty when oversized and Pillow unavailable
                    images.append({"type": "image_url", "image_url": {"url": f"data:{actual_mime};base64,{b64}"}})

            elif mime == "application/pdf":
                text_parts.append(_extract_pdf_text(p))

            elif mime in (
                "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                "application/msword",
            ):
                text_parts.append(_extract_docx_text(p))

            elif mime in (
                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                "application/vnd.ms-excel",
            ):
                text_parts.append(_extract_xlsx_text(p))

            elif mime in (
                "application/vnd.openxmlformats-officedocument.presentationml.presentation",
                "application/vnd.ms-powerpoint",
            ):
                text_parts.append(_extract_pptx_text(p))

            elif mime.startswith("text/") or mime in (
                "application/json", "application/xml", "application/csv",
            ):
                try:
                    content = p.read_text(encoding="utf-8", errors="replace")[:200_000]
                    text_parts.append(f"[File: {p.name}]\n{content}")
                except Exception as e:
                    text_parts.append(f"[File: {p.name} — could not read: {e}]")

            else:
                text_parts.append(f"[File: {p.name} ({mime or 'unknown type'}) — not supported]")

        combined_text = "\n\n".join(text_parts)
        if not images:
            return combined_text
        return images + [{"type": "text", "text": combined_text}]
    
    def add_tool_result(
        self,
        messages: list[dict[str, Any]],
        tool_call_id: str,
        tool_name: str,
        result: str
    ) -> list[dict[str, Any]]:
        """
        Add a tool result to the message list.
        
        Args:
            messages: Current message list.
            tool_call_id: ID of the tool call.
            tool_name: Name of the tool.
            result: Tool execution result.
        
        Returns:
            Updated message list.
        """
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call_id,
            "name": tool_name,
            "content": result
        })
        return messages
    
    def add_assistant_message(
        self,
        messages: list[dict[str, Any]],
        content: str | None,
        tool_calls: list[dict[str, Any]] | None = None
    ) -> list[dict[str, Any]]:
        """
        Add an assistant message to the message list.
        
        Args:
            messages: Current message list.
            content: Message content.
            tool_calls: Optional tool calls.
        
        Returns:
            Updated message list.
        """
        msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
        
        if tool_calls:
            msg["tool_calls"] = tool_calls
        
        messages.append(msg)
        return messages
