"""
Inbox watcher — monitors saved_prompts/inbox/ for new text files.

Lifecycle: inbox/ → intake/ (processing) → complete/ (done)

The user drops a .txt file in inbox/ with a paragraph update. Charles picks it
up, triages it, and delegates work to the appropriate agents who execute with
their tools. After delegation, Theo and Orion review for gaps and re-delegate.

**Kanban linking**: Files named ``task-{kanban_id}-*.txt`` are routed directly
to the agent assigned to that Kanban task, skipping Charles's triage. The agent
receives the file content as user-provided input for the task, and the task
status is updated automatically.
"""

from __future__ import annotations

import asyncio
import logging
import re
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from vastian.orchestrator import Orchestrator

logger = logging.getLogger(__name__)

# Regex to detect task-linked inbox files: task-{kanban_id}-description.txt
_TASK_LINK_RE = re.compile(r"^task-([a-zA-Z0-9]+)-", re.IGNORECASE)


class InboxWatcher:
    """
    Watches saved_prompts/inbox/ for new .txt files AND state/memory-inbox/
    for new JSON files (external context from Giga or other memory providers).

    All paths live under the vastian data root (VASTIAN_DATA_ROOT or data_root.txt).
    Processes prompt inbox through Charles → agent delegation → review loop.
    Processes memory inbox through InboxProcessor → raw context → Charles distribution.
    """

    def __init__(self, orchestrator: Orchestrator, data_root: Path) -> None:
        self.orchestrator = orchestrator
        self.inbox_dir = data_root / "saved_prompts" / "inbox"
        self.intake_dir = data_root / "saved_prompts" / "intake"
        self.complete_dir = data_root / "saved_prompts" / "complete"
        self._running = False
        self._started = False  # Guard against duplicate starts
        self._poll_interval = 10  # seconds
        self._processing: set[str] = set()  # Files currently being processed

        # Memory inbox tracking — remember which files we've already processed
        self._memory_inbox_root = data_root / "state" / "memory-inbox"
        self._memory_seen: set[str] = set()  # Tracks "{mind}/{kind}/{filename}"

        # Ensure directories exist
        self.inbox_dir.mkdir(parents=True, exist_ok=True)
        self.intake_dir.mkdir(parents=True, exist_ok=True)
        self.complete_dir.mkdir(parents=True, exist_ok=True)

    async def start(self) -> None:
        """Start the inbox polling loop. Safe to call multiple times — only runs once."""
        if self._started:
            return  # Already running
        self._started = True
        self._running = True
        logger.info("Inbox watcher started: %s", self.inbox_dir)

        # Snapshot existing memory inbox files so we only react to NEW ones
        self._memory_seen = self._snapshot_memory_inbox()

        while self._running:
            try:
                await self._scan_inbox()
            except Exception as e:
                logger.error("Inbox scan error: %s", e)
            try:
                await self._scan_memory_inbox()
            except Exception as e:
                logger.error("Memory inbox scan error: %s", e)
            await asyncio.sleep(self._poll_interval)

    def stop(self) -> None:
        self._running = False

    # ------------------------------------------------------------------
    # Memory Inbox scanning (state/memory-inbox/{mind}/*)
    # ------------------------------------------------------------------

    def _snapshot_memory_inbox(self) -> set[str]:
        """Snapshot all existing memory inbox JSON files so we only process new ones."""
        seen: set[str] = set()
        if not self._memory_inbox_root.exists():
            return seen
        for mind_dir in self._memory_inbox_root.iterdir():
            if not mind_dir.is_dir():
                continue
            for kind in ("plans", "neurons"):
                kind_dir = mind_dir / kind
                if kind_dir.exists():
                    for f in kind_dir.glob("*.json"):
                        seen.add(f"{mind_dir.name}/{kind}/{f.name}")
        return seen

    async def _scan_memory_inbox(self) -> None:
        """Check for new JSON files in state/memory-inbox/{mind}/ and process them."""
        if not self._memory_inbox_root.exists():
            return

        new_items: dict[str, list[str]] = {}  # mind -> list of new file keys

        for mind_dir in self._memory_inbox_root.iterdir():
            if not mind_dir.is_dir():
                continue
            mind_name = mind_dir.name
            for kind in ("plans", "neurons"):
                kind_dir = mind_dir / kind
                if not kind_dir.exists():
                    continue
                for f in kind_dir.glob("*.json"):
                    key = f"{mind_name}/{kind}/{f.name}"
                    if key not in self._memory_seen:
                        self._memory_seen.add(key)
                        new_items.setdefault(mind_name, []).append(key)

        # Process each mind with new items
        for mind_name, keys in new_items.items():
            logger.info(
                "Memory inbox: %d new items for mind '%s': %s",
                len(keys),
                mind_name,
                keys,
            )
            try:
                await self._process_memory_inbox(mind_name)
            except Exception as e:
                logger.error(
                    "Failed to process memory inbox for mind '%s': %s",
                    mind_name,
                    e,
                )

    async def _process_memory_inbox(self, mind: str) -> None:
        """Run the full memory inbox pipeline for a mind."""
        from vastian.tasks.memory_inbox import InboxProcessor

        processor = InboxProcessor(
            orchestrator=self.orchestrator,
            project_root=self._memory_inbox_root.parent.parent,  # state/ -> project root
        )
        result = await processor.pull_and_distribute(mind)
        logger.info("Memory inbox processed for '%s': %s", mind, result)

    # ------------------------------------------------------------------
    # Saved-prompts Inbox scanning (saved_prompts/inbox/*.txt)
    # ------------------------------------------------------------------

    async def _scan_inbox(self) -> None:
        """Check for new .txt files in inbox/ and process them."""
        try:
            txt_files = sorted(self.inbox_dir.glob("*.txt"), key=lambda f: f.stat().st_mtime)
        except OSError:
            return  # Directory may be unavailable momentarily
        for txt_file in txt_files:
            if txt_file.name in self._processing:
                continue  # Already being handled by this instance
            await self._process_inbox_file(txt_file)

    async def _process_inbox_file(self, file_path: Path) -> None:
        """Process a single inbox file through the full pipeline.

        Files named ``task-{kanban_id}-*.txt`` are routed directly to the
        Kanban task's assigned agent instead of going through Charles's triage.
        """
        filename = file_path.name

        # ── Race condition guard ──────────────────────────
        # Another process (worker or chat) may have already moved this file.
        if not file_path.exists():
            logger.debug("File already moved by another process, skipping: %s", filename)
            return
        self._processing.add(filename)

        try:
            content = file_path.read_text(encoding="utf-8").strip()
        except (OSError, FileNotFoundError):
            logger.debug("File disappeared before read, skipping: %s", filename)
            self._processing.discard(filename)
            return

        if not content or len(content) < 10:
            logger.warning("Inbox file too short, skipping: %s", filename)
            self._processing.discard(filename)
            return

        logger.info("Processing inbox file: %s (%d chars)", filename, len(content))

        # Move to intake/ (being processed) — atomic on same filesystem
        intake_path = self.intake_dir / filename
        try:
            shutil.move(str(file_path), str(intake_path))
        except (OSError, FileNotFoundError):
            logger.debug("File already moved during processing: %s", filename)
            self._processing.discard(filename)
            return

        tq = self.orchestrator.task_queue

        # ── Check for Kanban task link ────────────────────
        task_match = _TASK_LINK_RE.match(filename)
        if task_match and tq:
            kanban_id = task_match.group(1)
            kanban_tasks = tq.get_kanban_tasks()
            linked_task = next(
                (t for t in kanban_tasks if t["kanban_id"] == kanban_id),
                None,
            )
            if linked_task:
                logger.info(
                    "Inbox file linked to Kanban task %s: %s",
                    kanban_id,
                    linked_task["title"],
                )
                # For user-assigned tasks, route to the suggesting agent
                _exec_agent = (
                    linked_task["suggested_by"]
                    if linked_task.get("assigned_to") == "user"
                    else (linked_task.get("assigned_to") or linked_task["suggested_by"])
                )
                tq.add_notification(
                    _exec_agent,
                    f"User input received for task: {linked_task['title']}",
                    "info",
                )
                bg = tq.enqueue(
                    task_type="kanban_inbox",
                    agent_id=_exec_agent,
                    description=f"Kanban input: {linked_task['title'][:60]}",
                    payload={
                        "filename": filename,
                        "content": content,
                        "intake_path": str(intake_path),
                        "kanban_id": kanban_id,
                    },
                )
                asyncio.create_task(
                    self._execute_kanban_inbox(
                        bg.task_id,
                        filename,
                        content,
                        intake_path,
                        linked_task,
                    )
                )
                return  # Skip normal triage flow

        # ── Normal flow: Charles triages ──────────────────
        if tq:
            tq.add_notification("charles", f"Inbox pickup: {filename}", "info")

        if tq:
            task = tq.enqueue(
                task_type="inbox_processing",
                agent_id="charles",
                description=f"Inbox: {filename}",
                payload={
                    "filename": filename,
                    "content": content,
                    "intake_path": str(intake_path),
                },
            )
            asyncio.create_task(
                self._execute_inbox_task(task.task_id, filename, content, intake_path)
            )

    async def _execute_kanban_inbox(
        self,
        task_id: str,
        filename: str,
        content: str,
        intake_path: Path,
        kanban_task: dict,
    ) -> None:
        """Process an inbox file linked to a Kanban task.

        Routes the user's input directly to the assigned agent in the context
        of the Kanban task. Updates the task status through the pipeline.
        """
        tq = self.orchestrator.task_queue
        if tq:
            tq.mark_running(task_id)

        kanban_id = kanban_task["kanban_id"]
        # For user-assigned tasks, the executing agent is still the one who suggested it
        assigned_to = kanban_task.get("assigned_to") or kanban_task["suggested_by"]
        executing_agent_id = kanban_task["suggested_by"] if assigned_to == "user" else assigned_to
        agent = self.orchestrator.registry.get_agent(executing_agent_id)

        try:
            if agent is None:
                raise ValueError(f"Agent {executing_agent_id} not found")

            # If task is in 'suggested' status, auto-approve it since
            # the user is actively providing input
            if kanban_task.get("status") == "suggested" and tq:
                tq.approve_kanban_start(kanban_id)
                tq.start_kanban_task(kanban_id)
                logger.info("Auto-approved Kanban task %s (user provided input)", kanban_id)
            elif kanban_task.get("status") in ("approved",) and tq:
                tq.start_kanban_task(kanban_id)

            prompt = (
                f"The user has provided input for a Kanban task assigned to you.\n\n"
                f"KANBAN TASK: {kanban_task['title']}\n"
                f"DESCRIPTION: {kanban_task['description']}\n"
                f"CATEGORY: {kanban_task.get('category', 'general')}\n\n"
                f"USER INPUT (from inbox file {filename}):\n{content}\n\n"
                f"INSTRUCTIONS:\n"
                f"1. Use the user's input to complete this task\n"
                f"2. Persist your work to your owned documents using write_document\n"
                f"3. If you need more information, use request_user_input to ask\n"
                f"4. Provide a clear summary of what you did and any next steps"
            )

            if kanban_task.get("user_notes"):
                prompt += f"\n\nPRIOR FEEDBACK: {kanban_task['user_notes']}"

            result = await self.orchestrator._run_agent_with_tools(agent, prompt)

            # Complete the task (goes to pending_qa → manager reviews)
            if tq:
                tq.complete_kanban_task(kanban_id, result[:5000])
                tq.add_notification(
                    executing_agent_id,
                    f"Task completed with user input: {kanban_task['title']}",
                    "completed",
                )

            # Move inbox file to complete/
            timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
            complete_path = self.complete_dir / f"{timestamp}_{filename}"
            shutil.move(str(intake_path), str(complete_path))

            if tq:
                tq.mark_completed(task_id, f"Kanban input processed: {kanban_task['title']}")

        except Exception as e:
            logger.error("Kanban inbox processing failed for %s: %s", filename, e)
            if tq:
                tq.mark_failed(task_id, str(e))
                tq.add_notification(
                    assigned_to,
                    f"Failed to process input: {e}",
                    "failed",
                )
        finally:
            self._processing.discard(filename)

    async def _execute_inbox_task(
        self,
        task_id: str,
        filename: str,
        content: str,
        intake_path: Path,
    ) -> None:
        """Full inbox processing pipeline."""
        tq = self.orchestrator.task_queue
        if tq:
            tq.mark_running(task_id)

        try:
            # ── Step 1: Charles triages the update ───────────────
            triage_result = await self._charles_triage(task_id, content)

            # ── Step 2: Execute delegations with tools ───────────
            delegation_results = await self._execute_delegations(
                task_id,
                triage_result,
                content,
            )

            # ── Step 3: Theo + Orion review for gaps ─────────────
            await self._review_loop(task_id, content, delegation_results)

            # ── Step 3b: Charles learns patterns for user's digital clone ──
            await self._update_digital_clone(content, triage_result, delegation_results)

            # ── Step 4: Move to complete/ ────────────────────────
            timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
            complete_name = f"{timestamp}_{filename}"
            complete_path = self.complete_dir / complete_name
            if intake_path.exists():
                shutil.move(str(intake_path), str(complete_path))
            else:
                # File already moved or deleted (e.g. user moved it manually
                # or a race with a second inbox scan) — save content instead.
                logger.info("Inbox file %s already gone; saving content to complete/", filename)
                complete_path.write_text(content, encoding="utf-8")

            if tq:
                tq.mark_completed(
                    task_id, f"Processed {filename}: {len(delegation_results)} delegations"
                )
                tq.add_notification(
                    "charles",
                    f"Inbox complete: {filename} — {len(delegation_results)} tasks delegated",
                    "completed",
                )

        except Exception as e:
            logger.error("Inbox processing failed for %s: %s", filename, e)
            if tq:
                tq.mark_failed(task_id, str(e))
                tq.add_notification("charles", f"Inbox failed: {filename} — {e}", "failed")
        finally:
            self._processing.discard(filename)

    async def _charles_triage(self, task_id: str, content: str) -> list[dict]:
        """
        Charles reads the update and decides which agents should handle what.
        Returns a list of {agent_id, task_description} assignments.
        """
        llm = self.orchestrator._get_llm_for_agent("charles") or self.orchestrator.llm
        if llm is None:
            return []

        charles = self.orchestrator.registry.get_agent("charles")
        if charles is None:
            return []

        # Get the network roster for context
        roster = charles._network_roster if hasattr(charles, "_network_roster") else ""
        if not roster:
            # Build a minimal roster from the registry
            lines = []
            for cfg in self.orchestrator.registry.all_configs:
                docs = ", ".join(cfg.owned_documents[:3]) if cfg.owned_documents else "none"
                lines.append(f"- {cfg.agent_id}: {cfg.title} (docs: {docs})")
            roster = "\n".join(lines)

        triage_prompt = (
            f"You are Charles, the Executive Assistant. A new update was dropped in "
            f"the inbox. Read it and determine which agents should handle parts of it.\n\n"
            f"UPDATE:\n{content}\n\n"
            f"NETWORK ROSTER:\n{roster[:2000]}\n\n"
            f"RESPOND with a JSON array of assignments. Each assignment has:\n"
            f'  {{"agent_id": "...", "task_description": "Specific instructions for this agent"}}\n\n'
            f"Rules:\n"
            f"- Only assign to agents who own relevant documents\n"
            f"- Be specific in task_description — tell each agent exactly what to do\n"
            f"- Include write_document instructions for content that should be saved\n"
            f"- MEETING TRANSCRIPTS/NOTES → ALWAYS assign to maya FIRST with task: "
            f"'Use extract_signals tool on this meeting content to extract decisions, "
            f"action items, blockers, risks, and ideas. Signals auto-route to agents.'\n"
            f"- Therapy notes or 'Source: Zo Computer' with therapy → sage with task: "
            f"'Process therapy insights, update career-profile.md and growth-tracker.md'\n"
            f"- Career updates or 'Source: Zo Computer' with career → sage\n"
            f"- Financial updates → finley\n"
            f"- Goal/task updates → liam\n"
            f"- Strategy/roadmap updates → dominic\n"
            f"- Technical updates → henry\n"
            f"- Personal/reflection → adrian or victor\n"
            f"- Knowledge/research → elias\n"
            f"- Schedule/meeting updates → charles (yourself)\n\n"
            f"ONLY respond with the JSON array. No other text."
        )

        import json

        try:
            result = await llm.generate(
                system_prompt="You are Charles. Respond ONLY with a valid JSON array of assignments.",
                messages=[{"role": "user", "content": triage_prompt}],
                model=self.orchestrator._resolve_model_for_agent("charles"),
                temperature=0.3,
            )

            # Parse JSON from the response
            text = result.strip()
            # Handle markdown code blocks
            if "```" in text:
                text = text.split("```")[1]
                if text.startswith("json"):
                    text = text[4:]
                text = text.strip()

            assignments = json.loads(text)
            if isinstance(assignments, list):
                logger.info("Charles triaged inbox: %d assignments", len(assignments))
                if self.orchestrator.task_queue:
                    self.orchestrator.task_queue.add_notification(
                        "charles",
                        f"Triaged: {len(assignments)} assignments to agents",
                        "info",
                    )
                return assignments
        except (json.JSONDecodeError, Exception) as e:
            logger.warning("Charles triage parse error: %s", e)

        return []

    async def _execute_delegations(
        self,
        task_id: str,
        assignments: list[dict],
        original_content: str,
    ) -> list[dict]:
        """Execute all assignments in parallel, saving detailed reports."""
        tq = self.orchestrator.task_queue
        results: list[dict] = []

        async def _run_one(assignment: dict) -> dict:
            agent_id = assignment.get("agent_id", "")
            task_desc = assignment.get("task_description", "")
            agent = self.orchestrator.registry.get_agent(agent_id)

            entry: dict = {
                "agent_id": agent_id,
                "description": task_desc,
                "status": "failed",
                "full_result": "",
                "tools_used": [],
                "documents_written": [],
            }

            if agent is None:
                entry["full_result"] = f"Agent {agent_id} not found"
                return entry

            if tq:
                tq.add_notification(agent_id, f"Working on inbox task: {task_desc[:60]}", "running")

            prompt = (
                f"Charles (Executive Assistant) is delegating an inbox update to you.\n\n"
                f"YOUR TASK:\n{task_desc}\n\n"
                f"ORIGINAL UPDATE FROM ROWAN (full context):\n{original_content}\n\n"
                f"IMPORTANT: Use your tools. If this involves updating documents, "
                f"you MUST call write_document. If you need context, use read_document "
                f"or search_knowledge first. If you need to research real-world "
                f"information, use web_search. Persist ALL work to your owned documents."
            )

            try:
                full_result = await self.orchestrator._run_agent_with_tools(agent, prompt)
                entry["status"] = "completed"
                entry["full_result"] = full_result

                if tq:
                    tq.add_notification(agent_id, f"Done: {task_desc[:60]}", "completed")
                    tq.save_delegation_report(
                        parent_task_id=task_id,
                        agent_id=agent_id,
                        description=task_desc,
                        full_result=full_result,
                        status="completed",
                        user_id=self.orchestrator.current_user_id,
                    )
            except Exception as e:
                entry["full_result"] = str(e)
                if tq:
                    tq.add_notification(agent_id, f"Failed: {task_desc[:40]} — {e}", "failed")
                    tq.save_delegation_report(
                        parent_task_id=task_id,
                        agent_id=agent_id,
                        description=task_desc,
                        full_result=str(e),
                        status="failed",
                        user_id=self.orchestrator.current_user_id,
                    )

            return entry

        if assignments:
            gathered = await asyncio.gather(
                *[_run_one(a) for a in assignments],
                return_exceptions=True,
            )
            for r in gathered:
                if isinstance(r, dict):
                    results.append(r)
                elif isinstance(r, Exception):
                    results.append(
                        {"agent_id": "unknown", "status": "failed", "full_result": str(r)}
                    )

        return results

    async def _review_loop(
        self,
        task_id: str,
        original_content: str,
        delegation_results: list[dict],
    ) -> str:
        """
        Theo reviews for quality issues, Orion reviews for operational gaps.
        Either can trigger re-delegations if needed.
        """
        tq = self.orchestrator.task_queue

        # Build a summary of what was done
        summary_lines = []
        for r in delegation_results:
            status_icon = "OK" if r.get("status") == "completed" else "FAIL"
            summary_lines.append(
                f"- [{status_icon}] {r.get('agent_id', '?')}: {r.get('description', '')[:100]}"
            )
        delegation_summary = "\n".join(summary_lines) or "No delegations executed."

        # ── Theo: quality review ─────────────────────────
        theo_review = ""
        try:
            llm = self.orchestrator._get_llm_for_agent("theo") or self.orchestrator.llm
            theo = self.orchestrator.registry.get_agent("theo")
            if llm and theo:
                if tq:
                    tq.add_notification("theo", "Reviewing inbox delegation results", "running")

                theo_prompt = (
                    f"You are Theo, reviewing delegation results from an inbox update.\n\n"
                    f"ORIGINAL UPDATE:\n{original_content[:800]}\n\n"
                    f"DELEGATION RESULTS:\n{delegation_summary}\n\n"
                    f"Review for:\n"
                    f"1. Were the right agents assigned?\n"
                    f"2. Did any fail that should be retried?\n"
                    f"3. Was anything missed that should have been delegated?\n"
                    f"4. Any quality concerns with the assignments?\n\n"
                    f"If you find gaps, respond with a JSON array of re-delegations:\n"
                    f'  [{{"agent_id": "...", "task_description": "..."}}]\n'
                    f"If no gaps, respond with: NO_GAPS"
                )

                result = await llm.generate(
                    system_prompt="You are Theo, system quality reviewer.",
                    messages=[{"role": "user", "content": theo_prompt}],
                    model=self.orchestrator._resolve_model_for_agent("theo"),
                    temperature=0.3,
                )
                theo_review = result.strip()

                if tq:
                    if "NO_GAPS" in theo_review.upper():
                        tq.add_notification("theo", "Review complete: no gaps found", "completed")
                    else:
                        tq.add_notification("theo", "Review complete: gaps identified", "info")

                    tq.save_delegation_report(
                        parent_task_id=task_id,
                        agent_id="theo",
                        description="Quality review of inbox delegations",
                        full_result=theo_review,
                        status="completed",
                        user_id=self.orchestrator.current_user_id,
                    )
        except Exception as e:
            logger.debug("Theo review failed: %s", e)

        # ── Orion: operational/gap review ────────────────
        orion_review = ""
        try:
            llm = self.orchestrator._get_llm_for_agent("orion") or self.orchestrator.llm
            orion = self.orchestrator.registry.get_agent("orion")
            if llm and orion:
                if tq:
                    tq.add_notification(
                        "orion", "Reviewing inbox delegation for operational gaps", "running"
                    )

                orion_prompt = (
                    f"You are Orion, reviewing operational completeness of delegations.\n\n"
                    f"ORIGINAL UPDATE:\n{original_content[:800]}\n\n"
                    f"DELEGATION RESULTS:\n{delegation_summary}\n\n"
                    f"Review for:\n"
                    f"1. Were cross-team dependencies handled?\n"
                    f"2. Does Charles's dashboard need updating?\n"
                    f"3. Should any milestones be updated?\n"
                    f"4. Are there operational follow-ups needed?\n\n"
                    f"If you find gaps, respond with a JSON array of re-delegations:\n"
                    f'  [{{"agent_id": "...", "task_description": "..."}}]\n'
                    f"If no gaps, respond with: NO_GAPS"
                )

                result = await llm.generate(
                    system_prompt="You are Orion, operations reviewer.",
                    messages=[{"role": "user", "content": orion_prompt}],
                    model=self.orchestrator._resolve_model_for_agent("orion"),
                    temperature=0.3,
                )
                orion_review = result.strip()

                if tq:
                    if "NO_GAPS" in orion_review.upper():
                        tq.add_notification(
                            "orion", "Review complete: no operational gaps", "completed"
                        )
                    else:
                        tq.add_notification(
                            "orion", "Review complete: operational gaps identified", "info"
                        )

                    tq.save_delegation_report(
                        parent_task_id=task_id,
                        agent_id="orion",
                        description="Operational review of inbox delegations",
                        full_result=orion_review,
                        status="completed",
                        user_id=self.orchestrator.current_user_id,
                    )
        except Exception as e:
            logger.debug("Orion review failed: %s", e)

        # ── Re-delegate if reviewers found gaps ──────────
        import json

        for reviewer, review_text in [("theo", theo_review), ("orion", orion_review)]:
            if "NO_GAPS" in review_text.upper() or not review_text:
                continue
            try:
                # Try to parse re-delegation JSON
                text = review_text.strip()
                if "```" in text:
                    text = text.split("```")[1]
                    if text.startswith("json"):
                        text = text[4:]
                    text = text.strip()

                re_delegations = json.loads(text)
                if isinstance(re_delegations, list) and re_delegations:
                    if tq:
                        tq.add_notification(
                            reviewer,
                            f"Re-delegating {len(re_delegations)} tasks from {reviewer}'s review",
                            "info",
                        )
                    # Execute re-delegations
                    await self._execute_delegations(task_id, re_delegations, original_content)
            except (json.JSONDecodeError, Exception):
                pass  # Review was text commentary, not actionable re-delegations

        return f"Theo: {theo_review[:200]}\nOrion: {orion_review[:200]}"

    async def _update_digital_clone(
        self,
        content: str,
        triage_result: list[dict],
        delegation_results: list[str],
    ) -> None:
        """
        Charles analyzes the inbox content for the user's communication patterns,
        decision-making style, values signals, and thinking patterns, then updates
        the digital clone documents (communication-style.md, thinking-patterns.md).
        """
        llm = self.orchestrator._get_llm_for_agent("charles") or self.orchestrator.llm
        if llm is None:
            return

        charles = self.orchestrator.registry.get_agent("charles")
        if charles is None:
            return

        # Read current clone documents for context
        settings = self.orchestrator.settings
        docs_path = (settings.docs_dir / "rowan") if settings else Path("docs") / "rowan"
        comm_style = ""
        thinking = ""
        try:
            comm_path = docs_path / "communication-style.md"
            if comm_path.exists():
                comm_style = comm_path.read_text(encoding="utf-8")
        except Exception:
            pass
        try:
            think_path = docs_path / "thinking-patterns.md"
            if think_path.exists():
                thinking = think_path.read_text(encoding="utf-8")
        except Exception:
            pass

        user_name = settings.user_name if settings else "the user"
        clone_prompt = (
            f"You are Charles. You just processed an inbox update from {user_name}. "
            f"Your secondary job is to learn about {user_name}'s communication style and "
            f"thinking patterns from every interaction to improve the digital clone.\n\n"
            f"INBOX CONTENT (written by {user_name}):\n{content[:3000]}\n\n"
            f"CURRENT COMMUNICATION STYLE DOC:\n{comm_style[:2000]}\n\n"
            f"CURRENT THINKING PATTERNS DOC:\n{thinking[:2000]}\n\n"
            f"Analyze this inbox message for:\n"
            f"1. Communication patterns: tone, vocabulary, sentence structure, how they give instructions\n"
            f"2. Thinking patterns: priorities, decision-making signals, values, risk signals\n"
            f"3. New vocabulary or phrases to track\n"
            f"4. Decision-making signals or value statements\n\n"
            f"If you find NEW patterns not already in the documents, respond with a JSON object:\n"
            f'{{"communication_updates": "markdown text to APPEND to communication-style.md (or empty string)", '
            f'"thinking_updates": "markdown text to APPEND to thinking-patterns.md (or empty string)", '
            f'"summary": "one-line summary of what you learned"}}\n\n'
            f"If there is NOTHING NEW to add, respond with:\n"
            f'{{"communication_updates": "", "thinking_updates": "", "summary": "no new patterns detected"}}\n\n'
            f"ONLY respond with the JSON object."
        )

        import json

        try:
            result = await llm.generate(
                system_prompt=f"You are Charles analyzing {user_name}'s communication for the digital clone. ONLY respond with valid JSON.",
                messages=[{"role": "user", "content": clone_prompt}],
                model=self.orchestrator._resolve_model_for_agent("charles"),
                temperature=0.3,
            )

            text = result.strip()
            if "```" in text:
                text = text.split("```")[1]
                if text.startswith("json"):
                    text = text[4:]
                text = text.strip()

            updates = json.loads(text)

            if updates.get("communication_updates"):
                try:
                    comm_path = docs_path / "communication-style.md"
                    existing = comm_path.read_text(encoding="utf-8") if comm_path.exists() else ""
                    # Insert before the final "---" separator
                    if "\n---\n" in existing:
                        parts = existing.rsplit("\n---\n", 1)
                        updated = (
                            parts[0]
                            + f"\n\n### Learned from Inbox ({datetime.now(UTC).strftime('%Y-%m-%d')})\n{updates['communication_updates']}\n\n---\n"
                            + parts[1]
                        )
                    else:
                        updated = (
                            existing
                            + f"\n\n### Learned from Inbox ({datetime.now(UTC).strftime('%Y-%m-%d')})\n{updates['communication_updates']}\n"
                        )
                    comm_path.write_text(updated, encoding="utf-8")
                    logger.info("Charles updated communication-style.md for digital clone")
                except Exception as e:
                    logger.debug("Failed to update communication-style.md: %s", e)

            if updates.get("thinking_updates"):
                try:
                    think_path = docs_path / "thinking-patterns.md"
                    existing = think_path.read_text(encoding="utf-8") if think_path.exists() else ""
                    if "\n---\n" in existing:
                        parts = existing.rsplit("\n---\n", 1)
                        updated = (
                            parts[0]
                            + f"\n\n### Learned from Inbox ({datetime.now(UTC).strftime('%Y-%m-%d')})\n{updates['thinking_updates']}\n\n---\n"
                            + parts[1]
                        )
                    else:
                        updated = (
                            existing
                            + f"\n\n### Learned from Inbox ({datetime.now(UTC).strftime('%Y-%m-%d')})\n{updates['thinking_updates']}\n"
                        )
                    think_path.write_text(updated, encoding="utf-8")
                    logger.info("Charles updated thinking-patterns.md for digital clone")
                except Exception as e:
                    logger.debug("Failed to update thinking-patterns.md: %s", e)

            summary = updates.get("summary", "")
            if summary and summary != "no new patterns detected":
                tq = self.orchestrator.task_queue
                if tq:
                    tq.add_notification(
                        "charles",
                        f"Digital clone updated: {summary}",
                        "info",
                    )
                logger.info("Digital clone update: %s", summary)

        except Exception as e:
            logger.debug("Digital clone analysis failed: %s", e)
