"""Discord bot — relays Antigravity brain events to Discord channels.

Multi-project channel architecture:
- One channel per project: AG-{project_name} (e.g. ag-gravity_control, ag-deriva)
- Each conversation maps to a project via conv_to_project dict
- Extension registers projects via bridge/pending/ files
- Commands include project_name for routing to correct IDE window

Multi-PC UX:
- When multiple AG instances are active, messages get instance numbers (PC #1, #2)
- Users can target specific instances with !N <message> (e.g. !2 hello)
- When only one instance is active, natural conversation without numbers
"""

import asyncio
import re
import json
import logging
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path

import discord
from discord.ext import commands, tasks

from config import Config
from parser import (
    parse_task_progress,
    md_to_discord_text,
    format_task_embed_text,
)
from models import BrainEvent, EventType, ApprovalRequest, UserResponse

logger = logging.getLogger(__name__)


# ─── Discord UI Components ──────────────────────────────────────────

class ApprovalView(discord.ui.View):
    """Discord buttons for approving/rejecting Antigravity actions.

    Supports two modes:
    1. Legacy: ✅ 승인 / ❌ 거부 (when no buttons array)
    2. Multi-choice: dynamic buttons from pending's buttons array
       (e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
    """

    def __init__(self, request: ApprovalRequest,
                 buttons: list[dict] | None = None, hub=None):
        super().__init__(timeout=1800)  # 30 minutes
        self.hub = hub  # WSHub instance for WS response routing
        self.request = request
        self.responded = False
        self.buttons_data = buttons

        if buttons and len(buttons) > 1:
            # Multi-choice mode: remove the default decorated buttons first
            # (they are added by @discord.ui.button at class definition time)
            self.clear_items()

            # Add a Discord button for each option
            for btn_info in buttons:
                btn_text = btn_info.get("text", "?")
                btn_index = btn_info.get("index", 0)
                is_reject = btn_text.lower() in ("deny", "reject", "cancel",
                                                  "reject all", "decline",
                                                  "dismiss", "stop")
                style = discord.ButtonStyle.red if is_reject else discord.ButtonStyle.green
                emoji = "❌" if is_reject else "✅"

                button = discord.ui.Button(
                    label=f"{emoji} {btn_text}",
                    style=style,
                    custom_id=f"choice_{request.request_id}_{btn_index}",
                )
                # Bind the callback with closure over btn_index and btn_text
                button.callback = self._make_choice_callback(btn_index, btn_text,
                                                             is_reject)
                self.add_item(button)
        # else: use the default @discord.ui.button decorated methods below

    def _make_choice_callback(self, btn_index: int, btn_text: str,
                               is_reject: bool):
        async def callback(interaction: discord.Interaction):
            if self.responded:
                await interaction.response.send_message("이미 응답됨",
                                                        ephemeral=True)
                return
            self.responded = True
            response_data = {
                "request_id": self.request.request_id,
                "approved": not is_reject,
                "button_index": btn_index,
                "step_type": getattr(self.request, 'step_type', ''),
                "project_name": getattr(self.request, 'project_name', ''),
            }
            # Hub WS route (primary — reaches remote Extensions)
            delivered = False
            if self.hub:
                await self.hub.send_response_to_pending_owner(self.request.request_id, {
                    "type": "response", "data": response_data,
                })
            embed = interaction.message.embeds[0] if interaction.message.embeds else None
            if embed:
                color = discord.Color.red() if is_reject else discord.Color.green()
                embed.color = color
                emoji = "❌" if is_reject else "✅"
                embed.set_footer(
                    text=f"{emoji} {btn_text} by {interaction.user.display_name}"
                )
            await interaction.response.edit_message(embed=embed, view=None)
        return callback

    @discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
    async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
        # Only active in legacy mode (no buttons array)
        if self.buttons_data and len(self.buttons_data) > 1:
            return  # multi-choice mode handles via dynamic buttons
        if self.responded:
            await interaction.response.send_message("이미 응답됨", ephemeral=True)
            return
        self.responded = True
        response_data = {
            "request_id": self.request.request_id, "approved": True,
            "step_type": getattr(self.request, 'step_type', ''),
            "project_name": getattr(self.request, 'project_name', ''),
        }
        if self.hub:
            await self.hub.send_response_to_pending_owner(self.request.request_id, {
                "type": "response", "data": response_data,
            })
        embed = interaction.message.embeds[0] if interaction.message.embeds else None
        if embed:
            embed.color = discord.Color.green()
            embed.set_footer(text=f"✅ 승인됨 by {interaction.user.display_name}")
        await interaction.response.edit_message(embed=embed, view=None)

    @discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
    async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
        # Only active in legacy mode (no buttons array)
        if self.buttons_data and len(self.buttons_data) > 1:
            return  # multi-choice mode handles via dynamic buttons
        if self.responded:
            await interaction.response.send_message("이미 응답됨", ephemeral=True)
            return
        self.responded = True
        response_data = {
            "request_id": self.request.request_id, "approved": False,
            "step_type": getattr(self.request, 'step_type', ''),
            "project_name": getattr(self.request, 'project_name', ''),
        }
        if self.hub:
            await self.hub.send_response_to_pending_owner(self.request.request_id, {
                "type": "response", "data": response_data,
            })
        embed = interaction.message.embeds[0] if interaction.message.embeds else None
        if embed:
            embed.color = discord.Color.red()
            embed.set_footer(text=f"❌ 거부됨 by {interaction.user.display_name}")
        await interaction.response.edit_message(embed=embed, view=None)

    async def on_timeout(self):
        if not self.responded and self.hub:
            await self.hub.send_response_to_pending_owner(self.request.request_id, {
                "type": "response", "data": {
                    "request_id": self.request.request_id, "approved": False,
                    "step_type": getattr(self.request, 'step_type', ''),
                    "project_name": getattr(self.request, 'project_name', ''),
                }
            })


# ─── Bot ─────────────────────────────────────────────────────────────

class GravityBot(commands.Bot):
    """Discord bot for Antigravity session monitoring.

    Multi-project architecture:
    - project_channels: project_name → TextChannel (ag-gravity_control, ag-deriva, etc.)
    - conv_to_project: conversation_id → project_name (learned from pending approvals)
    - channel_to_project: channel_id → project_name (for Discord→IDE routing)
    """

    def __init__(self, event_queue: asyncio.Queue):
        intents = discord.Intents.default()
        intents.message_content = True
        intents.guilds = True
        super().__init__(command_prefix="!", intents=intents)

        self.event_queue = event_queue
        self.project_channels: dict[str, discord.TextChannel] = {}  # project → channel
        self.conv_to_project: dict[str, str] = {}  # conv_id → project
        self.channel_to_project: dict[int, str] = {}  # channel.id → project
        self.session_status_messages: dict[str, int] = {}  # conv_id → msg_id
        self._sent_approval_ids: dict[str, bool] = {} # request_id → bool 
        self._deferred_ids: dict[str, int] = {}  # request_id → defer count
        self._sent_commands: dict[str, str] = {}  # request_id → command text (for MERGE edit detection)
        self._ready_event = asyncio.Event()
        self._channel_lock = asyncio.Lock()
        self.session_category: discord.CategoryChannel | None = None
        self.guild: discord.Guild | None = None
        self.auto_approve_projects: set[str] = set()  # projects with auto-approve enabled
        self._processed_message_ids: deque[int] = deque(maxlen=200)  # dedup for Gateway event replay
        self._approval_messages: dict[str, int] = {}  # FIX #4: request_id → discord message_id (for auto_resolved lookup)
        self._last_auto_toggle: dict[str, float] = {}  # project → timestamp (dedup for !auto embed)
        self.gateway = None  # Set by main.py in gateway mode
        self.hub = None       # Set by main.py in gateway mode (WSHub instance)

    def _write_command(self, project: str, text: str, *,
                       target_instance: int | None = None, **kwargs):
        """Write command to Extension via Hub WS (primary) or file bridge (fallback).

        When Hub is connected, ONLY use WS to prevent duplicate delivery.
        File bridge + Gateway are legacy fallbacks for when Hub is unavailable.

        Args:
            target_instance: If set, send only to this instance number (via Hub).
                           If None, broadcast to all instances.
        """
        cmd_data = {
            "text": text,
            "project_name": kwargs.get('project_name', project),
        }

        # Hub route (primary)
        if self.hub:
            import time as _time
            cmd_data["id"] = str(int(_time.time() * 1000))
            msg = {"type": "command", "data": cmd_data}
            if target_instance is not None:
                asyncio.create_task(
                    self.hub.send_to_instance(project, target_instance, msg)
                )
            else:
                asyncio.create_task(
                    self.hub.broadcast_to_project(project, msg)
                )

    def _cap_dict(self, d: dict, max_size: int = 5000):
        """Prevent memory leaks by capping dictionary sizes using insertion order (oldest first)."""
        if len(d) >= max_size:
            to_remove = len(d) - max_size + max_size // 10  # remove 10%
            for k in list(d.keys())[:to_remove]:
                d.pop(k, None)

    @staticmethod
    def _make_channel_name(project_name: str) -> str:
        """ag-gravity_control, ag-deriva, etc."""
        return f"{Config.CHANNEL_PREFIX}-{project_name}".lower()

    async def setup_hook(self):
        self.loop.create_task(self._process_events())
        self._register_slash_commands()
        # Register Hub handlers (if Hub is available, set after setup_hook by main.py)
        asyncio.get_event_loop().call_soon(self._register_hub_handlers)
        logger.info("Bot setup complete")

    def _register_slash_commands(self):
        """Register Discord slash commands."""

        @self.tree.command(name="stop", description="AI 작업 중지")
        async def slash_stop(interaction: discord.Interaction):
            project = self.channel_to_project.get(interaction.channel_id)
            if not project:
                await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
                return
            self._write_command(project, "!stop", project_name=project)
            await interaction.response.send_message(
                embed=discord.Embed(
                    title="⏹️ AI 작업 중지",
                    description=f"**{project}** IDE에 중지 요청 전달됨",
                    color=discord.Color.orange(),
                )
            )

        @self.tree.command(name="auto", description="자동 승인 토글")
        async def slash_auto(interaction: discord.Interaction):
            project = self.channel_to_project.get(interaction.channel_id)
            if not project:
                await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
                return
            # Toggle
            if project in self.auto_approve_projects:
                self.auto_approve_projects.discard(project)
                enabled = False
            else:
                self.auto_approve_projects.add(project)
                enabled = True
            self._write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
            emoji = "🟢" if enabled else "🔴"
            await interaction.response.send_message(
                embed=discord.Embed(
                    title=f"{emoji} {'자동 승인' if enabled else '수동 승인'} 모드",
                    description=f"프로젝트: **{project}**",
                    color=discord.Color.green() if enabled else discord.Color.red(),
                )
            )

        @self.tree.command(name="send", description="IDE 채팅에 메시지 전송")
        async def slash_send(interaction: discord.Interaction, message: str):
            project = self.channel_to_project.get(interaction.channel_id)
            if not project:
                await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
                return
            self._write_command(project, message, project_name=project)
            await interaction.response.send_message(
                embed=discord.Embed(
                    description=f"📨 → **{project}** IDE에 전달됨\n`{message[:100]}`",
                    color=discord.Color.blurple(),
                ),
                delete_after=10,
            )

    async def on_ready(self):
        logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")

        self.guild = self.get_guild(Config.DISCORD_GUILD_ID)
        if not self.guild:
            logger.error(f"Guild {Config.DISCORD_GUILD_ID} not found!")
            return

        # Find or create category
        category_name = "Antigravity Sessions"
        self.session_category = discord.utils.get(
            self.guild.categories, name=category_name
        )
        if not self.session_category:
            try:
                self.session_category = await self.guild.create_category(category_name)
                logger.info(f"Created category: {category_name}")
            except discord.errors.Forbidden:
                logger.error("No permission to create category!")
                return

        # Start WS Hub processors by ensuring ready gate is open
        self._ready_event.set()
        logger.info("Ready gate opened — event processing enabled")

    # ─── Channel Management ──────────────────────────────────────────


    # ─── Channel Management ──────────────────────────────────────────

    async def _discover_channels(self):
        """Find existing project channels via Discord API (not cache)."""
        all_channels = await self.guild.fetch_channels()
        prefix = Config.CHANNEL_PREFIX.lower() + "-"

        for ch in all_channels:
            if (isinstance(ch, discord.TextChannel)
                    and ch.category_id == self.session_category.id
                    and ch.name.startswith(prefix)):
                project = ch.name[len(prefix):]
                self.project_channels[project] = ch
                self.channel_to_project[ch.id] = project
                logger.info(f"Found channel: #{ch.name} → project={project}")

        logger.info(f"Discovered {len(self.project_channels)} project channels")

    async def _get_channel(self, project_name: str) -> discord.TextChannel:
        """Get or create a channel for a project.

        Uses guild.channels cache first (NO API call), only locks + creates
        if channel truly doesn't exist. This prevents O(N) fetch_channels()
        API calls when multiple projects arrive simultaneously.
        """
        if project_name in self.project_channels:
            return self.project_channels[project_name]

        if not self.session_category:
            logger.error(f"[CHANNEL] session_category is None — cannot get channel for project={project_name}")
            return None

        channel_name = self._make_channel_name(project_name)

        # 1. Check guild channel cache (NO API call — instant)
        existing = discord.utils.get(
            self.guild.channels, name=channel_name,
            category_id=self.session_category.id,
        )
        if existing and isinstance(existing, discord.TextChannel):
            self.project_channels[project_name] = existing
            self.channel_to_project[existing.id] = project_name
            logger.info(f"Found channel (cache): #{channel_name}")
            return existing

        # 2. Only lock + API call if truly creating new channel
        async with self._channel_lock:
            # Double-check after lock (another coroutine may have created it)
            if project_name in self.project_channels:
                return self.project_channels[project_name]

            try:
                ch = await self.guild.create_text_channel(
                    name=channel_name,
                    category=self.session_category,
                    topic=f"Antigravity Bridge — {project_name}",
                )
                self.project_channels[project_name] = ch
                self.channel_to_project[ch.id] = project_name
                logger.info(f"Created channel: #{channel_name}")

                embed = discord.Embed(
                    title=f"🚀 {project_name}",
                    description=f"Antigravity Bridge 연결됨",
                    color=discord.Color.blue(),
                    timestamp=datetime.now(timezone.utc),
                )
                await ch.send(embed=embed)
                return ch
            except discord.errors.Forbidden:
                logger.error(f"No permission to create channel: {channel_name}")
                return None
            except Exception as e:
                logger.error(f"[CHANNEL] Failed to create channel #{channel_name}: {e}")
                return None

    def _resolve_project(self, conversation_id: str) -> str:
        """Get project name for a conversation. Falls back to default."""
        return self.conv_to_project.get(
            conversation_id, Config.PROJECT_NAME
        )

    # ─── Event Processing ─────────────────────────────────────────────

    async def _process_events(self):
        """Main event loop — ALL events go through here sequentially."""
        await self.wait_until_ready()
        await self._ready_event.wait()
        logger.info("Event processor started (ready gate passed)")

        while not self.is_closed():
            try:
                event = await asyncio.wait_for(
                    self.event_queue.get(), timeout=5.0
                )
                await self._handle_event(event)
            except asyncio.TimeoutError:
                continue
            except Exception as e:
                logger.error(f"Error processing event: {e}", exc_info=True)

    async def _handle_event(self, event: BrainEvent):
        """Route brain events to the correct project channel."""
        project = self._resolve_project(event.conversation_id)
        channel = await self._get_channel(project)
        if not channel:
            return

        if event.event_type == EventType.SESSION_START:
            return

        try:
            if event.file_name == "task.md":
                await self._send_task_update(channel, event)
            else:
                await self._send_artifact_update(channel, event)
        except discord.NotFound:
            self.project_channels.pop(project, None)
            logger.warning(f"Channel deleted for project {project}, will recreate")

    # ─── Message Senders ─────────────────────────────────────────────

    async def _send_task_update(
        self, channel: discord.TextChannel, event: BrainEvent
    ):
        progress = parse_task_progress(event.content)

        # Full task content (truncated to embed limit)
        full_content = event.content.strip()
        description = format_task_embed_text(progress) + "\n\n" + full_content
        if len(description) > 4000:
            description = description[:4000] + "\n…(truncated)"

        embed = discord.Embed(
            title="📋 Task 진행 현황",
            description=description,
            color=discord.Color.gold() if progress.in_progress > 0
                  else discord.Color.green() if progress.done == progress.total
                  else discord.Color.greyple(),
            timestamp=datetime.now(timezone.utc),
        )
        embed.set_footer(text=f"Session: {event.conversation_id[:8]}")

        msg_id = self.session_status_messages.get(event.conversation_id)
        if msg_id:
            try:
                msg = await channel.fetch_message(msg_id)
                await msg.edit(embed=embed)
                return
            except (discord.NotFound, discord.HTTPException):
                pass

        msg = await channel.send(embed=embed)
        self.session_status_messages[event.conversation_id] = msg.id

    async def _send_artifact_update(
        self, channel: discord.TextChannel, event: BrainEvent
    ):
        labels = {
            "implementation_plan.md": "📐 구현 계획",
            "walkthrough.md": "📝 작업 결과 요약",
        }
        label = labels.get(event.file_name, f"📄 {event.file_name}")
        event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"

        full_content = event.content.strip()
        if not full_content:
            full_content = "(빈 파일)"

        FILE_ATTACH_THRESHOLD = 4000  # Above this, send as file attachment

        if len(full_content) > FILE_ATTACH_THRESHOLD:
            # Long content → summary embed + file attachment
            # Extract first meaningful paragraph for summary
            summary_lines = []
            for line in full_content.split('\n'):
                if line.strip():
                    summary_lines.append(line.strip())
                if len('\n'.join(summary_lines)) > 300:
                    break
            summary = '\n'.join(summary_lines[:5])
            if len(summary) > 500:
                summary = summary[:500] + '...'

            embed = discord.Embed(
                title=f"{label} ({event_label}됨)",
                description=f"{summary}\n\n📎 *전체 내용은 첨부 파일을 확인하세요* ({len(full_content):,}자)",
                color=discord.Color.blue(),
                timestamp=datetime.now(timezone.utc),
            )
            embed.set_footer(text=f"Session: {event.conversation_id[:8]}")

            # Create in-memory file attachment
            import io
            file_bytes = full_content.encode('utf-8')
            discord_file = discord.File(
                io.BytesIO(file_bytes),
                filename=event.file_name,
            )
            await channel.send(embed=embed, file=discord_file)
        else:
            # Short content → inline embed (original behavior)
            embed = discord.Embed(
                title=f"{label} ({event_label}됨)",
                description=full_content,
                color=discord.Color.blue(),
                timestamp=datetime.now(timezone.utc),
            )
            embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
            await channel.send(embed=embed)

    # ─── Approval Scanner ────────────────────────────────────────────




    # ─── Discord → IDE Text Relay + Multi-PC UX ───────────────────────────

    def _get_instance_header(self, project: str, instance_number: int) -> str:
        """Format instance header based on active count.

        Single instance: empty string (natural conversation)
        Multiple instances: **[PC #N]** prefix
        """
        if not self.hub:
            return ""
        active = self.hub.get_active_count(project)
        if active <= 1:
            return ""
        return f"**[PC #{instance_number}]** "

    def _parse_instance_target(self, text: str) -> tuple[int | None, str]:
        """Parse !N prefix from message text.

        Returns (target_instance, remaining_text).
        '!2 hello' -> (2, 'hello')
        'hello' -> (None, 'hello')
        '!stop' -> (None, '!stop')  # special commands not treated as targeting
        """
        match = re.match(r'^!(\d+)\s+(.+)', text, re.DOTALL)
        if match:
            return int(match.group(1)), match.group(2).strip()
        return None, text

    async def on_message(self, message: discord.Message):
        if message.author == self.user:
            return

        # Dedup: Discord Gateway can deliver MESSAGE_CREATE twice on reconnection
        if message.id in self._processed_message_ids:
            return
        self._processed_message_ids.append(message.id)

        # Determine project from channel
        project = self.channel_to_project.get(message.channel.id)
        if not project:
            await self.process_commands(message)
            return

        text = message.content.strip()

        # Parse !N instance targeting (before special commands)
        target_instance, actual_text = self._parse_instance_target(text)

        # Special command: !stop — cancel AI work
        if actual_text == "!stop":
            self._write_command(project, "!stop", target_instance=target_instance,
                               project_name=project)
            target_label = f" (PC #{target_instance})" if target_instance else ""
            embed = discord.Embed(
                title="⏹️ AI 작업 중지",
                description=f"프로젝트: **{project}**{target_label}\n중지 요청을 Extension에 전달했습니다.",
                color=discord.Color.orange(),
            )
            await message.channel.send(embed=embed)
            return

        # Special command: !auto — toggle auto-approve
        if actual_text == "!auto":
            # Dedup: skip if toggled within 5s for same project (Gateway event replay)
            now = time.time()
            last = self._last_auto_toggle.get(project, 0)
            if now - last < 5.0:
                logger.info(f"[AUTO] Dedup: skipping duplicate !auto for {project} ({now-last:.1f}s ago)")
                return
            self._last_auto_toggle[project] = now

            # Toggle per-project auto-approve
            if project in self.auto_approve_projects:
                self.auto_approve_projects.discard(project)
                enabled = False
            else:
                self.auto_approve_projects.add(project)
                enabled = True
            self._write_command(project, f"!auto {'on' if enabled else 'off'}",
                               target_instance=target_instance, project_name=project)
            emoji = "🟢" if enabled else "🔴"
            mode = "자동 승인" if enabled else "수동 승인"
            embed = discord.Embed(
                title=f"{emoji} {mode} 모드",
                description=f"프로젝트: **{project}**\n"
                            f"모든 승인 요청이 {'자동으로 승인됩니다' if enabled else '수동 확인이 필요합니다'}",
                color=discord.Color.green() if enabled else discord.Color.red(),
            )
            await message.channel.send(embed=embed)
            return

        # General text relay — routed by project (+ optional instance targeting)
        if actual_text:
            self._write_command(project, actual_text, target_instance=target_instance,
                               project_name=project)
            await message.add_reaction("📨")
            target_label = f" PC #{target_instance}" if target_instance else ""
            embed = discord.Embed(
                description=f"📨 → **{project}**{target_label} IDE에 전달됨\n`{actual_text[:100]}`",
                color=discord.Color.blurple(),
            )
            await message.channel.send(embed=embed, delete_after=10)

        await self.process_commands(message)

    # ─── Hub Event Handlers ──────────────────────────────────────────

    def _register_hub_handlers(self):
        """Register callbacks on the Hub for Extension->Bot messages."""
        if not self.hub:
            return
        self.hub.set_bot_handlers(
            on_pending=self._hub_on_pending,
            on_chat=self._hub_on_chat,
            on_register=self._hub_on_register,
            on_auto_resolve=self._hub_on_auto_resolve,
            on_brain_event=self._hub_on_brain_event,
        )
        logger.info("[BOT] Hub handlers registered")

    async def _hub_on_pending(self, project: str, data: dict):
        """Handle pending approval from Hub (Extension->Hub->Bot)."""
        try:
            request_id = data.get("request_id", "")
            if not request_id:
                return

            # Skip if already sent
            if request_id in self._sent_approval_ids:
                return

            # Check auto_resolved / auto_approved status
            status = data.get("status", "pending")
            if status in ("auto_resolved", "expired"):
                await self._handle_auto_resolved(request_id, status)
                return
            if status == "auto_approved":
                # Bridge-level auto-approve (e.g. "Always run") — show notification only
                channel = await self._get_channel(project)
                if channel:
                    cmd_text = data.get("command", "")[:200]
                    desc_text = data.get("description", "")[:300]
                    embed = discord.Embed(
                        title="🤖 자동 승인됨 (Always run)",
                        description=f"✅ **{cmd_text}**" + (f"\n```\n{desc_text}\n```" if desc_text and len(desc_text) > 3 else ""),
                        color=discord.Color.green(),
                    )
                    embed.set_footer(text=f"auto-approve | {request_id[:12]}")
                    await channel.send(embed=embed)
                self._cap_dict(self._sent_approval_ids)
                self._sent_approval_ids[request_id] = True
                logger.info(f"[HUB-PENDING] Auto-approved (Always run): {request_id[:12]} project={project}")
                return

            instance_number = data.get("_instance_number", 0)
            pc_name = data.get("_pc_name", "")
            header = self._get_instance_header(project, instance_number)

            # Build approval request
            request = ApprovalRequest(
                request_id=request_id,
                conversation_id=data.get("conversation_id", ""),
                command=data.get("command", ""),
                description=data.get("description", ""),
                timestamp=data.get("timestamp", time.time()),
                project_name=project,
                step_type=data.get("step_type", ""),
                status=status,
            )

            # Auto-approve check
            if project in self.auto_approve_projects:
                await self._auto_approve_via_hub(request)
                return

            # Send to Discord
            channel = await self._get_channel(project)
            if not channel:
                logger.warning(f"[HUB-PENDING] No channel for project={project}")
                return

            buttons = data.get("buttons", [])
            desc_parts = []
            if header:
                desc_parts.append(header)

            # Clean command text (remove "Running2" artifacts → "Running 2")
            cmd_text = request.command[:200]
            import re
            cmd_text = re.sub(r'Running(\d)', r'Running \1', cmd_text)
            desc_parts.append(f"**명령:** `{cmd_text}`")

            if buttons:
                btn_names = [b.get("text", "?") for b in buttons]
                desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")

            # Clean description: strip noise headers and garbage
            desc_raw = request.description or ""
            # Remove old-style headers
            desc_raw = re.sub(r'\[AI 본문 요약\]\s*', '', desc_raw)
            desc_raw = re.sub(r'\[결행 명령\]\s*', '', desc_raw)
            # Remove lines that are clearly noise
            desc_lines = desc_raw.split('\n')
            clean_desc_lines = []
            for dline in desc_lines:
                dline_stripped = dline.strip()
                if not dline_stripped:
                    continue
                # Skip UI artifacts
                if dline_stripped in ('chevron_right', 'chevron_left', 'close', 'check',
                                     'content_copy', 'expand_more', 'expand_less',
                                     'Show more', 'Show less', 'Copy', 'Edit', 'Copied!'):
                    continue
                # Skip "Thought for Xs"
                if re.match(r'^Thought for \d+', dline_stripped):
                    continue
                # Skip TypeScript declarations and file paths
                if re.match(r'^(declare|import|export)\s+(class|function|interface|type|enum|const)', dline_stripped):
                    continue
                if re.search(r'\.ts:\d+:', dline_stripped):
                    continue
                if re.search(r'extension.*src.*sdk', dline_stripped, re.IGNORECASE):
                    continue
                clean_desc_lines.append(dline_stripped)

            clean_desc = '\n'.join(clean_desc_lines).strip()
            if clean_desc and len(clean_desc) > 3:
                # Truncate and wrap in code block for readability
                if len(clean_desc) > 300:
                    clean_desc = clean_desc[:300] + '…'
                desc_parts.append(f"```\n{clean_desc}\n```")

            embed = discord.Embed(
                title=f"⚠️ 승인 요청 — {request.step_type or 'action'}",
                description="\n".join(desc_parts),
                color=discord.Color.orange(),
                timestamp=datetime.now(timezone.utc),
            )
            embed.set_footer(text=f"ID: {request_id}")

            view = ApprovalView(request, buttons=buttons, hub=self.hub)
            msg = await channel.send(
                content=f"🔔 **새로운 승인 요청이 도착했습니다** (ID: {request_id[:8]})", 
                embed=embed, 
                view=view
            )

            self._cap_dict(self._sent_approval_ids)
            self._sent_approval_ids[request_id] = True
            
            self._cap_dict(self._approval_messages)
            self._approval_messages[request_id] = msg.id
            logger.info(f"[HUB-PENDING] Sent approval: {request_id[:12]} project={project} | URL: {msg.jump_url}")

        except Exception as e:
            logger.error(f"[HUB-PENDING] Error: {e}")

    async def _auto_approve_via_hub(self, request: ApprovalRequest):
        """Auto-approve a pending request via Hub."""
        self._cap_dict(self._sent_approval_ids)
        self._sent_approval_ids[request.request_id] = True

        if self.hub:
            await self.hub.send_response_to_pending_owner(request.request_id, {
                "type": "response",
                "data": {
                    "request_id": request.request_id,
                    "approved": True,
                    "button_index": 0,
                    "step_type": request.step_type,
                    "project_name": request.project_name,
                },
            })
        # Send compact auto-approved embed to Discord (was missing — caused silent approvals)
        channel = await self._get_channel(request.project_name)
        if channel:
            try:
                embed = discord.Embed(
                    title="🤖 자동 승인됨",
                    description=f"✅ **{request.command}**\n\n```\n{request.description[:2000]}\n```" if getattr(request, "description", "") else f"✅ **{request.command}**",
                    color=discord.Color.green(),
                )
                embed.set_footer(text=f"auto-approve | {request.request_id[:12]}")
                await channel.send(embed=embed)
            except Exception as e:
                logger.error(f"[HUB-AUTO] Discord send failed: {e}")
        logger.info(f"[HUB-AUTO] Auto-approved: {request.request_id[:12]} project={request.project_name}")

    async def _hub_on_chat(self, project: str, data: dict):
        """Handle chat snapshot from Hub (Extension->Hub->Bot->Discord)."""
        try:
            content = data.get("content", "")
            attached_files = data.get("attached_files", [])
            if not content and not attached_files:
                return

            instance_number = data.get("_instance_number", 0)
            header = self._get_instance_header(project, instance_number)

            channel = await self._get_channel(project)
            if not channel:
                return

            import io as _io
            discord_files = []
            for af in attached_files:
                af_name = af.get("name", "document.md")
                af_content = af.get("content", "")
                if af_content:
                    discord_files.append(discord.File(
                        _io.BytesIO(af_content.encode("utf-8")),
                        filename=af_name,
                    ))

            display_content = f"{header}{content}" if header else content

            FILE_ATTACH_THRESHOLD = 4000
            if len(display_content) > FILE_ATTACH_THRESHOLD:
                summary = display_content[:500].rsplit('\n', 1)[0]
                embed = discord.Embed(
                    title="💬 AI 대화 내용",
                    description=f"{summary}\n\n📎 *전체 내용은 첨부 파일 참조* ({len(content):,}자)",
                    color=discord.Color.purple(),
                    timestamp=datetime.now(timezone.utc),
                )
                discord_files.append(discord.File(
                    _io.BytesIO(content.encode("utf-8")),
                    filename="chat_message.md",
                ))
                await channel.send(embed=embed, files=discord_files)
            else:
                embed = discord.Embed(
                    title="💬 AI 대화 내용",
                    description=display_content,
                    color=discord.Color.purple(),
                    timestamp=datetime.now(timezone.utc),
                )
                await channel.send(
                    embed=embed,
                    files=discord_files if discord_files else discord.utils.MISSING,
                )

            logger.info(f"[HUB-CHAT] Sent to #{channel.name} ({len(content)} chars)")
        except Exception as e:
            logger.error(f"[HUB-CHAT] Error: {e}")

    async def _hub_on_register(self, data: dict):
        """Handle session registration from Hub."""
        conv_id = data.get("conversation_id", "")
        project = data.get("project_name", "")
        if conv_id and project:
            self.conv_to_project[conv_id] = project
            logger.info(f"[HUB-REG] {conv_id[:8]} → {project}")

    async def _hub_on_auto_resolve(self, project: str, data: dict):
        """Handle auto_resolve notification from Hub."""
        request_id = data.get("request_id", "")
        if request_id:
            await self._handle_auto_resolved(request_id, "auto_resolved")

    async def _hub_on_brain_event(self, project: str, data: dict):
        """Handle brain event from Hub (Extension->Hub->Bot->Discord)."""
        try:
            from models import BrainEvent, EventType
            event = BrainEvent(
                event_type=EventType(data.get("event_type", "file_changed")),
                conversation_id=data.get("conversation_id", ""),
                file_name=data.get("file_name", ""),
                file_path=None,
                content=data.get("content", ""),
                timestamp=data.get("timestamp", time.time()),
            )
            await self.event_queue.put(event)
        except Exception as e:
            logger.error(f"[HUB-EVENT] Error: {e}")

    async def _handle_auto_resolved(self, request_id: str, status: str):
        """Edit Discord message to show auto-resolved/expired status."""
        msg_id = self._approval_messages.get(request_id)
        if not msg_id:
            return
        # Find the channel containing this message
        for channel in self.project_channels.values():
            try:
                msg = await channel.fetch_message(msg_id)
                embed = msg.embeds[0] if msg.embeds else None
                if embed:
                    if status == "auto_resolved":
                        embed.color = discord.Color.green()
                        embed.set_footer(text="✅ 자동 해결됨")
                    else:
                        embed.color = discord.Color.greyple()
                        embed.set_footer(text="⏰ 만료됨")
                    await msg.edit(embed=embed, view=None)
                self._approval_messages.pop(request_id, None)
                break
            except (discord.NotFound, discord.Forbidden):
                continue
            except Exception:
                break

    # ─── Chat Snapshot Scanner ─────────────────────────────────────────


