"""Discord bot adapter — slash commands and event handlers."""

import asyncio
import logging
import os
import signal
import discord
from discord import app_commands

from src.config import Config
from src.claude_session import (
    clear_session,
    get_active_session,
    set_session_model,
    PROJECT_ROOT,
    VALID_MODELS,
)
from src.fast_commands import dispatch as fast_dispatch, split_text_chunks, extract_url_text
from src.router import (
    route_message,
    _ralph_propose,
    _ralph_approve,
    _ralph_status,
    _ralph_stop,
    _load_approved_tasks,
    planning_advance,
    planning_approve,
    planning_cancel,
    start_planning_session,
)
from src.adapters._text_chunks import split_message
from src.adapters.discord_views import (
    RalphRootView,
    PlanningActiveView,
    PlanningFinalView,
    _split_chunks,
)

logger = logging.getLogger("echo-core.discord")
_security_log = logging.getLogger("echo-core.security")

# Module-level config reference, set by create_bot()
_config: Config | None = None


def _get_config() -> Config:
    """Return the module-level config, raising if not initialized."""
    if _config is None:
        raise RuntimeError("Bot not initialized — call create_bot() first")
    return _config


# --- Authorization helpers ---


def is_owner(user_id: str) -> bool:
    """Check if user_id matches config bot.owner."""
    return _get_config().get("bot.owner") == user_id


def is_admin(user_id: str) -> bool:
    """Check if user_id is owner or in admins list."""
    if is_owner(user_id):
        return True
    admins = _get_config().get("bot.admins", [])
    return user_id in admins


def is_registered_channel(channel_id: str) -> bool:
    """Check if channel_id is in any registered channel entry."""
    channels = _get_config().get("channels", {})
    return any(ch.get("id") == channel_id for ch in channels.values())


def _channel_alias_for_id(channel_id: str) -> str | None:
    """Resolve a Discord channel ID to its config alias."""
    channels = _get_config().get("channels", {})
    for alias, info in channels.items():
        if info.get("id") == channel_id:
            return alias
    return None


# --- Factory ---


def create_bot(config: Config) -> discord.Client:
    """Create and configure the Discord bot with all slash commands."""
    global _config
    _config = config

    intents = discord.Intents.default()
    intents.message_content = True
    intents.voice_states = True

    client = discord.Client(intents=intents)
    tree = app_commands.CommandTree(client)
    client.tree = tree  # type: ignore[attr-defined]

    # --- Slash commands ---

    @tree.command(name="ping", description="Check bot latency")
    async def ping(interaction: discord.Interaction) -> None:
        latency_ms = round(client.latency * 1000)
        await interaction.response.send_message(
            f"Pong! Latency: {latency_ms}ms", ephemeral=True
        )

    @tree.command(name="help", description="List available commands")
    async def help_cmd(interaction: discord.Interaction) -> None:
        lines = [
            "**Echo Commands**",
            "`/ping` — Check bot latency",
            "`/help` — Show this help message",
            "`/clear` — Clear the session for this channel",
            "`/status` — Show session status",
            "`/model [choice]` — View/change AI model",
            "",
            "**Email**",
            "`/email check` — Check unread emails",
            "`/email send <to> <subject> <body>` — Send an email",
            "`/email save` — Save unread emails to KB",
            "",
            "**Calendar**",
            "`/calendar today` — Today + tomorrow events",
            "`/calendar week` — This week's schedule",
            "`/calendar busy` — Am I in a meeting?",
            "",
            "**Notes**",
            "`/note <text>` — Quick note in daily file",
            "`/jurnal <text>` — Journal entry in daily file",
            "`/search <query>` — Search Echo's memory",
            "`/kb [category]` — Recent KB notes",
            "",
            "**Reminders**",
            "`/remind <time> <text> [date]` — Create reminder",
            "",
            "**Git**",
            "`/commit [message]` — Commit all changes",
            "`/push` — Push to remote",
            "`/pull` — Pull with rebase",
            "`/test [pattern]` — Run tests",
            "",
            "**Ops**",
            "`/logs [n]` — Last N log lines",
            "`/doctor` — System diagnostics",
            "`/heartbeat` — Health checks",
            "`/restart` — Restart bot (owner)",
            "",
            "**Ralph (autonomous projects)**",
            "`/p <slug> <description>` — Propose new project",
            "`/a [slug]` — Approve for tonight (autocomplete)",
            "`/l` — List projects status",
            "`/k <slug>` — Stop a running project (autocomplete)",
            "",
            "**Admin**",
            "`/setup` — Claim ownership",
            "`/channel add <alias>` — Register channel",
            "`/channels` — List channels",
            "`/admin add <user_id>` — Add admin",
            "`/cron list|run|add|remove|enable|disable` — Cron jobs",
        ]
        await interaction.response.send_message(
            "\n".join(lines), ephemeral=True
        )

    @tree.command(name="setup", description="Claim ownership of the bot")
    async def setup(interaction: discord.Interaction) -> None:
        if config.get("bot.owner") is not None:
            await interaction.response.send_message(
                "Owner already set.", ephemeral=True
            )
            return
        config.set("bot.owner", str(interaction.user.id))
        config.save()
        await interaction.response.send_message(
            "You are now the owner of Echo.", ephemeral=True
        )

    channel_group = app_commands.Group(
        name="channel", description="Channel management"
    )

    @channel_group.command(name="add", description="Register current channel")
    @app_commands.describe(alias="Short name for this channel")
    async def channel_add(
        interaction: discord.Interaction, alias: str
    ) -> None:
        if not is_owner(str(interaction.user.id)):
            _security_log.warning("Unauthorized owner command /channel add by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Owner only.", ephemeral=True
            )
            return
        config.set(
            f"channels.{alias}",
            {"id": str(interaction.channel_id), "default_model": "sonnet"},
        )
        config.save()
        await interaction.response.send_message(
            f"Channel registered as '{alias}'.", ephemeral=True
        )

    tree.add_command(channel_group)

    admin_group = app_commands.Group(
        name="admin", description="Admin management"
    )

    @admin_group.command(name="add", description="Add an admin user")
    @app_commands.describe(user_id="Discord user ID to add as admin")
    async def admin_add(
        interaction: discord.Interaction, user_id: str
    ) -> None:
        if not is_owner(str(interaction.user.id)):
            _security_log.warning("Unauthorized owner command /admin add by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Owner only.", ephemeral=True
            )
            return
        admins = config.get("bot.admins", [])
        if user_id not in admins:
            admins.append(user_id)
            config.set("bot.admins", admins)
            config.save()
        await interaction.response.send_message(
            f"User {user_id} added as admin.", ephemeral=True
        )

    tree.add_command(admin_group)

    # --- Cron commands ---

    cron_group = app_commands.Group(
        name="cron", description="Manage scheduled jobs"
    )

    @cron_group.command(name="list", description="List all scheduled jobs")
    async def cron_list(interaction: discord.Interaction) -> None:
        scheduler = getattr(client, "scheduler", None)
        if scheduler is None:
            await interaction.response.send_message(
                "Scheduler not available.", ephemeral=True
            )
            return
        jobs = scheduler.list_jobs()
        if not jobs:
            await interaction.response.send_message(
                "No scheduled jobs.", ephemeral=True
            )
            return
        lines = [
            f"{'Name':<24} {'Cron':<14} {'Channel':<10} {'Model':<8} {'On':<5} {'Status':<8} {'Next Run'}"
        ]
        for j in jobs:
            enabled = "yes" if j.get("enabled") else "no"
            last_status = j.get("last_status") or "\u2014"
            next_run = j.get("next_run") or "\u2014"
            if next_run != "\u2014" and len(next_run) > 19:
                next_run = next_run[:19]
            lines.append(
                f"{j['name']:<24} {j['cron']:<14} {j['channel']:<10} {j['model']:<8} {enabled:<5} {last_status:<8} {next_run}"
            )
        table = "```\n" + "\n".join(lines) + "\n```"
        await interaction.response.send_message(table, ephemeral=True)

    @cron_group.command(name="run", description="Force-run a scheduled job")
    @app_commands.describe(name="Job name to run")
    async def cron_run(interaction: discord.Interaction, name: str) -> None:
        scheduler = getattr(client, "scheduler", None)
        if scheduler is None:
            await interaction.response.send_message(
                "Scheduler not available.", ephemeral=True
            )
            return
        await interaction.response.defer()
        try:
            result = await scheduler.run_job(name)
            truncated = result[:1900] if len(result) > 1900 else result
            await interaction.followup.send(truncated)
        except KeyError:
            await interaction.followup.send(f"Job '{name}' not found.")
        except Exception as e:
            await interaction.followup.send(f"Error running job: {e}")

    @cron_group.command(name="add", description="Create a new scheduled job")
    @app_commands.describe(
        name="Job name (lowercase, hyphens allowed)",
        expression="Cron expression (e.g. '30 6 * * *')",
        model="AI model to use (default: sonnet)",
    )
    @app_commands.choices(model=[
        app_commands.Choice(name="opus", value="opus"),
        app_commands.Choice(name="sonnet", value="sonnet"),
        app_commands.Choice(name="haiku", value="haiku"),
    ])
    async def cron_add(
        interaction: discord.Interaction,
        name: str,
        expression: str,
        model: app_commands.Choice[str] | None = None,
    ) -> None:
        if not is_admin(str(interaction.user.id)):
            _security_log.warning("Unauthorized admin command /cron add by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Admin only.", ephemeral=True
            )
            return
        scheduler = getattr(client, "scheduler", None)
        if scheduler is None:
            await interaction.response.send_message(
                "Scheduler not available.", ephemeral=True
            )
            return
        channel_alias = _channel_alias_for_id(str(interaction.channel_id))
        if channel_alias is None:
            await interaction.response.send_message(
                "This channel is not registered. Use `/channel add` first.",
                ephemeral=True,
            )
            return
        model_value = model.value if model else "sonnet"
        await interaction.response.send_message(
            f"Creating job **{name}** (`{expression}`, model: {model_value}, channel: {channel_alias}).\n"
            "Send your prompt text as the next message in this channel.",
        )

        def check(m: discord.Message) -> bool:
            return (
                m.author == interaction.user
                and m.channel.id == interaction.channel_id
            )

        try:
            prompt_msg = await client.wait_for(
                "message", check=check, timeout=120
            )
        except asyncio.TimeoutError:
            await interaction.followup.send("Timed out waiting for prompt.")
            return

        try:
            job = scheduler.add_job(
                name=name,
                cron=expression,
                channel=channel_alias,
                prompt=prompt_msg.content,
                model=model_value,
            )
            next_run = job.get("next_run") or "\u2014"
            await interaction.channel.send(
                f"Job **{name}** created.\n"
                f"Cron: `{expression}` | Channel: {channel_alias} | Model: {model_value}\n"
                f"Next run: {next_run}"
            )
        except ValueError as e:
            await interaction.channel.send(f"Error creating job: {e}")

    @cron_group.command(name="remove", description="Remove a scheduled job")
    @app_commands.describe(name="Job name to remove")
    async def cron_remove(interaction: discord.Interaction, name: str) -> None:
        if not is_admin(str(interaction.user.id)):
            _security_log.warning("Unauthorized admin command /cron remove by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Admin only.", ephemeral=True
            )
            return
        scheduler = getattr(client, "scheduler", None)
        if scheduler is None:
            await interaction.response.send_message(
                "Scheduler not available.", ephemeral=True
            )
            return
        if scheduler.remove_job(name):
            await interaction.response.send_message(
                f"Job '{name}' removed.", ephemeral=True
            )
        else:
            await interaction.response.send_message(
                f"Job '{name}' not found.", ephemeral=True
            )

    @cron_group.command(name="enable", description="Enable a scheduled job")
    @app_commands.describe(name="Job name to enable")
    async def cron_enable(
        interaction: discord.Interaction, name: str
    ) -> None:
        if not is_admin(str(interaction.user.id)):
            _security_log.warning("Unauthorized admin command /cron enable by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Admin only.", ephemeral=True
            )
            return
        scheduler = getattr(client, "scheduler", None)
        if scheduler is None:
            await interaction.response.send_message(
                "Scheduler not available.", ephemeral=True
            )
            return
        if scheduler.enable_job(name):
            await interaction.response.send_message(
                f"Job '{name}' enabled.", ephemeral=True
            )
        else:
            await interaction.response.send_message(
                f"Job '{name}' not found.", ephemeral=True
            )

    @cron_group.command(name="disable", description="Disable a scheduled job")
    @app_commands.describe(name="Job name to disable")
    async def cron_disable(
        interaction: discord.Interaction, name: str
    ) -> None:
        if not is_admin(str(interaction.user.id)):
            _security_log.warning("Unauthorized admin command /cron disable by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Admin only.", ephemeral=True
            )
            return
        scheduler = getattr(client, "scheduler", None)
        if scheduler is None:
            await interaction.response.send_message(
                "Scheduler not available.", ephemeral=True
            )
            return
        if scheduler.disable_job(name):
            await interaction.response.send_message(
                f"Job '{name}' disabled.", ephemeral=True
            )
        else:
            await interaction.response.send_message(
                f"Job '{name}' not found.", ephemeral=True
            )

    tree.add_command(cron_group)

    # --- Email commands ---

    email_group = app_commands.Group(
        name="email", description="Email operations"
    )

    @email_group.command(name="check", description="Check unread emails")
    async def email_check(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "email", [])
        await interaction.followup.send(result)

    @email_group.command(name="send", description="Send an email")
    @app_commands.describe(
        to="Recipient email address",
        subject="Email subject",
        body="Email body text",
    )
    async def email_send(
        interaction: discord.Interaction, to: str, subject: str, body: str
    ) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(
            fast_dispatch, "email", ["send", f"{to} {subject} :: {body}"]
        )
        await interaction.followup.send(result)

    @email_group.command(name="save", description="Save unread emails to knowledge base")
    async def email_save(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "email", ["save"])
        await interaction.followup.send(result)

    @email_group.command(name="digest", description="Procesează emailuri necitite și trimite rezumate pe WhatsApp")
    async def email_digest(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "email", ["digest"])
        await interaction.followup.send(result)

    @email_group.command(name="forward", description="Forwardează emailuri necitite direct pe WhatsApp fără rezumat")
    async def email_forward(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "email", ["forward"])
        await interaction.followup.send(result)

    tree.add_command(email_group)

    # --- Calendar commands ---

    calendar_group = app_commands.Group(
        name="calendar", description="Calendar operations"
    )

    @calendar_group.command(name="today", description="Today and tomorrow events")
    async def calendar_today(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "calendar", [])
        await interaction.followup.send(result)

    @calendar_group.command(name="week", description="This week's schedule")
    async def calendar_week(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "calendar", ["week"])
        await interaction.followup.send(result)

    @calendar_group.command(name="busy", description="Am I in a meeting right now?")
    async def calendar_busy(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "calendar", ["busy"])
        await interaction.followup.send(result)

    tree.add_command(calendar_group)

    # --- Note & journal commands ---

    @tree.command(name="note", description="Add a quick note to today's daily file")
    @app_commands.describe(text="Note text")
    async def note_cmd(interaction: discord.Interaction, text: str) -> None:
        await interaction.response.defer(ephemeral=True)
        result = await asyncio.to_thread(fast_dispatch, "note", text.split())
        await interaction.followup.send(result, ephemeral=True)

    @tree.command(name="jurnal", description="Add a journal entry to today's daily file")
    @app_commands.describe(text="Journal entry text")
    async def jurnal_cmd(interaction: discord.Interaction, text: str) -> None:
        await interaction.response.defer(ephemeral=True)
        result = await asyncio.to_thread(fast_dispatch, "jurnal", text.split())
        await interaction.followup.send(result, ephemeral=True)

    # --- KB command ---

    @tree.command(name="kb", description="List recent knowledge base notes")
    @app_commands.describe(category="Filter by category (optional)")
    async def kb_cmd(
        interaction: discord.Interaction, category: str | None = None
    ) -> None:
        await interaction.response.defer()
        args = [category] if category else []
        result = await asyncio.to_thread(fast_dispatch, "kb", args)
        await interaction.followup.send(result)

    # --- Remind command ---

    @tree.command(name="remind", description="Create a calendar reminder")
    @app_commands.describe(
        time="Time in HH:MM format",
        text="Reminder text",
        date="Date in YYYY-MM-DD format (default: today)",
    )
    async def remind_cmd(
        interaction: discord.Interaction, time: str, text: str, date: str | None = None
    ) -> None:
        await interaction.response.defer(ephemeral=True)
        args = [date, time, text] if date else [time, text]
        result = await asyncio.to_thread(fast_dispatch, "remind", args)
        await interaction.followup.send(result, ephemeral=True)

    # --- Git commands ---

    @tree.command(name="commit", description="Git commit all changes")
    @app_commands.describe(message="Commit message (auto-generated if empty)")
    async def commit_cmd(
        interaction: discord.Interaction, message: str | None = None
    ) -> None:
        await interaction.response.defer()
        args = message.split() if message else []
        result = await asyncio.to_thread(fast_dispatch, "commit", args)
        await interaction.followup.send(result)

    @tree.command(name="push", description="Git push to remote")
    async def push_cmd(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "push", [])
        await interaction.followup.send(result)

    @tree.command(name="pull", description="Git pull with rebase")
    async def pull_cmd(interaction: discord.Interaction) -> None:
        await interaction.response.defer()
        result = await asyncio.to_thread(fast_dispatch, "pull", [])
        await interaction.followup.send(result)

    # --- Test command ---

    @tree.command(name="test", description="Run pytest tests")
    @app_commands.describe(pattern="Test filter pattern (optional)")
    async def test_cmd(
        interaction: discord.Interaction, pattern: str | None = None
    ) -> None:
        await interaction.response.defer()
        args = [pattern] if pattern else []
        result = await asyncio.to_thread(fast_dispatch, "test", args)
        # Truncate for Discord limit
        if len(result) > 1900:
            result = result[-1900:]
        await interaction.followup.send(f"```\n{result}\n```")

    # --- Doctor command ---

    @tree.command(name="doctor", description="System diagnostics")
    async def doctor_cmd(interaction: discord.Interaction) -> None:
        await interaction.response.defer(ephemeral=True)
        result = await asyncio.to_thread(fast_dispatch, "doctor", [])
        await interaction.followup.send(result, ephemeral=True)

    @tree.command(name="heartbeat", description="Run heartbeat health checks")
    async def heartbeat_cmd(interaction: discord.Interaction) -> None:
        from src.heartbeat import run_heartbeat
        await interaction.response.defer(ephemeral=True)
        try:
            result = await asyncio.to_thread(run_heartbeat)
            await interaction.followup.send(result, ephemeral=True)
        except Exception as e:
            await interaction.followup.send(
                f"Heartbeat error: {e}", ephemeral=True
            )

    @tree.command(name="search", description="Search Echo's memory")
    @app_commands.describe(query="What to search for")
    async def search_cmd(
        interaction: discord.Interaction, query: str
    ) -> None:
        await interaction.response.defer()
        try:
            from src.memory_search import search

            results = await asyncio.to_thread(search, query)
            if not results:
                await interaction.followup.send(
                    "No results found (index may be empty — run `echo memory reindex`)."
                )
                return

            lines = [f"**Search results for:** {query}\n"]
            for i, r in enumerate(results, 1):
                score = r["score"]
                preview = r["chunk"][:150]
                if len(r["chunk"]) > 150:
                    preview += "..."
                lines.append(
                    f"**{i}.** `{r['file']}` (score: {score:.3f})\n{preview}\n"
                )
            text = "\n".join(lines)
            if len(text) > 1900:
                text = text[:1900] + "\n..."
            await interaction.followup.send(text)
        except ConnectionError as e:
            await interaction.followup.send(f"Search error: {e}")
        except Exception as e:
            logger.exception("Search command failed")
            await interaction.followup.send(f"Search error: {e}")

    @tree.command(name="channels", description="List registered channels")
    async def channels(interaction: discord.Interaction) -> None:
        ch_map = config.get("channels", {})
        if not ch_map:
            await interaction.response.send_message(
                "No channels registered yet.", ephemeral=True
            )
            return
        lines = []
        for alias, info in ch_map.items():
            cid = info.get("id", "?")
            model = info.get("default_model", "?")
            lines.append(f"\u2022 {alias} \u2192 <#{cid}> (model: {model})")
        await interaction.response.send_message(
            "\n".join(lines), ephemeral=True
        )

    @tree.command(name="clear", description="Clear the session for this channel")
    async def clear(interaction: discord.Interaction) -> None:
        channel_id = str(interaction.channel_id)
        default_model = config.get("bot.default_model", "sonnet")
        removed = clear_session(channel_id)
        if removed:
            await interaction.response.send_message(
                f"Session cleared. Model reset to {default_model}.",
                ephemeral=True,
            )
        else:
            await interaction.response.send_message(
                "No active session for this channel.", ephemeral=True
            )

    @tree.command(name="status", description="Show session status")
    async def status(interaction: discord.Interaction) -> None:
        from datetime import datetime, timezone
        import subprocess

        channel_id = str(interaction.channel_id)
        now = datetime.now(timezone.utc)

        # Version info
        try:
            commit = subprocess.run(
                ["git", "log", "--format=%h", "-1"],
                capture_output=True, text=True, cwd=str(PROJECT_ROOT),
            ).stdout.strip() or "?"
        except Exception:
            commit = "?"

        # Latency
        try:
            lat = round(client.latency * 1000)
        except (ValueError, TypeError):
            lat = 0

        # Uptime
        uptime = ""
        if hasattr(client, "_ready_at"):
            elapsed = now - client._ready_at
            secs = int(elapsed.total_seconds())
            if secs < 60:
                uptime = f"{secs}s"
            elif secs < 3600:
                uptime = f"{secs // 60}m"
            else:
                uptime = f"{secs // 3600}h {(secs % 3600) // 60}m"

        # Channel count
        channels_count = len(config.get("channels", {}))

        # Session info
        session = get_active_session(channel_id)
        if session:
            sid = session.get("session_id", "?")[:8]
            model = session.get("model", "?")
            count = session.get("message_count", 0)
            created = session.get("created_at", "")
            last_msg = session.get("last_message_at", "")

            age = ""
            if created:
                try:
                    el = now - datetime.fromisoformat(created)
                    m = int(el.total_seconds() // 60)
                    age = f"{m}m" if m < 60 else f"{m // 60}h {m % 60}m"
                except (ValueError, TypeError):
                    pass

            updated = ""
            if last_msg:
                try:
                    el = now - datetime.fromisoformat(last_msg)
                    s = int(el.total_seconds())
                    if s < 60:
                        updated = "just now"
                    elif s < 3600:
                        updated = f"{s // 60}m ago"
                    else:
                        updated = f"{s // 3600}h ago"
                except (ValueError, TypeError):
                    pass

            # Token usage
            in_tok = session.get("total_input_tokens", 0)
            out_tok = session.get("total_output_tokens", 0)
            cost = session.get("total_cost_usd", 0)

            def _fmt_tokens(n):
                if n >= 1_000_000:
                    return f"{n / 1_000_000:.1f}M"
                if n >= 1_000:
                    return f"{n / 1_000:.1f}k"
                return str(n)

            tokens_line = f"Tokens: {_fmt_tokens(in_tok)} in / {_fmt_tokens(out_tok)} out"
            if cost > 0:
                tokens_line += f" | ${cost:.4f}"

            # Context window usage
            ctx = session.get("context_tokens", 0)
            max_ctx = 200_000
            pct = round(ctx / max_ctx * 100) if ctx else 0
            context_line = f"Context: {_fmt_tokens(ctx)}/{_fmt_tokens(max_ctx)} ({pct}%)"

            session_line = f"Session: `{sid}` | {count} msgs | {age}" + (f" | updated {updated}" if updated else "")
        else:
            model = config.get("bot", {}).get("default_model", "?")
            session_line = "No active session"
            tokens_line = ""
            context_line = ""

        lines = [
            f"Echo Core ({commit})",
            f"Model: {model} | Latency: {lat}ms",
            f"Channels: {channels_count} | Uptime: {uptime}",
            tokens_line,
            context_line,
            session_line,
        ]
        text = "\n".join(l for l in lines if l)
        await interaction.response.send_message(text, ephemeral=True)

    @tree.command(name="model", description="View or change the AI model")
    @app_commands.describe(choice="Model to switch to")
    @app_commands.choices(choice=[
        app_commands.Choice(name="opus", value="opus"),
        app_commands.Choice(name="sonnet", value="sonnet"),
        app_commands.Choice(name="haiku", value="haiku"),
    ])
    async def model_cmd(
        interaction: discord.Interaction,
        choice: app_commands.Choice[str] | None = None,
    ) -> None:
        channel_id = str(interaction.channel_id)
        if choice is None:
            # Show current model and available models
            session = get_active_session(channel_id)
            if session:
                current = session.get("model", "unknown")
            else:
                current = config.get("bot.default_model", "sonnet")
            available = ", ".join(sorted(VALID_MODELS))
            await interaction.response.send_message(
                f"**Current model:** {current}\n"
                f"**Available:** {available}",
                ephemeral=True,
            )
        else:
            model = choice.value
            session = get_active_session(channel_id)
            if session:
                set_session_model(channel_id, model)
            else:
                # No session yet — pre-set in active.json so next message uses it
                from src.claude_session import _load_sessions, _save_sessions
                from datetime import datetime, timezone
                sessions = _load_sessions()
                sessions[channel_id] = {
                    "session_id": "",
                    "model": model,
                    "created_at": datetime.now(timezone.utc).isoformat(),
                    "last_message_at": datetime.now(timezone.utc).isoformat(),
                    "message_count": 0,
                }
                _save_sessions(sessions)
            await interaction.response.send_message(
                f"Model changed to **{model}**.", ephemeral=True
            )

    @tree.command(name="restart", description="Restart the bot process")
    async def restart(interaction: discord.Interaction) -> None:
        if not is_owner(str(interaction.user.id)):
            _security_log.warning("Unauthorized owner command /restart by user=%s (%s)", interaction.user.id, interaction.user)
            await interaction.response.send_message(
                "Owner only.", ephemeral=True
            )
            return
        pid_file = PROJECT_ROOT / "echo-core.pid"
        if not pid_file.exists():
            await interaction.response.send_message(
                "No PID file found (echo-core.pid).", ephemeral=True
            )
            return
        try:
            pid = int(pid_file.read_text().strip())
            os.kill(pid, signal.SIGTERM)
            await interaction.response.send_message(
                "Restarting...", ephemeral=True
            )
        except ProcessLookupError:
            await interaction.response.send_message(
                f"Process {pid} not found.", ephemeral=True
            )
        except ValueError:
            await interaction.response.send_message(
                "Invalid PID file content.", ephemeral=True
            )

    @tree.command(name="logs", description="Show recent log lines")
    @app_commands.describe(n="Number of lines to show (default 10)")
    async def logs_cmd(
        interaction: discord.Interaction, n: int = 10
    ) -> None:
        log_path = PROJECT_ROOT / "logs" / "echo-core.log"
        if not log_path.exists():
            await interaction.response.send_message(
                "No log file found.", ephemeral=True
            )
            return
        try:
            all_lines = log_path.read_text(encoding="utf-8").splitlines()
            tail = all_lines[-n:] if len(all_lines) >= n else all_lines
            text = "\n".join(tail)
            # Truncate to fit Discord message limit (2000 - code block overhead)
            if len(text) > 1900:
                text = text[-1900:]
            await interaction.response.send_message(
                f"```\n{text}\n```", ephemeral=True
            )
        except Exception as e:
            await interaction.response.send_message(
                f"Error reading logs: {e}", ephemeral=True
            )

    def _wav_to_ogg(wav_path: str) -> str:
        """Convertește WAV → OGG Opus pentru upload Discord (10x mai mic). Returnează path-ul OGG."""
        import subprocess, tempfile
        ogg_path = wav_path.replace(".wav", ".ogg")
        if not ogg_path.endswith(".ogg"):
            ogg_path = wav_path + ".ogg"
        try:
            subprocess.run(
                [
                    "/home/moltbot/.local/bin/ffmpeg", "-y", "-i", wav_path,
                    "-c:a", "libopus", "-b:a", "24k", "-ar", "24000", ogg_path,
                ],
                capture_output=True,
                timeout=30,
            )
            return ogg_path if os.path.exists(ogg_path) else wav_path
        except Exception:
            return wav_path

    @tree.command(name="audio", description="TTS: convertește text sau URL în voice note")
    @app_commands.describe(
        voce="Voce (M1-M5 masculin, F1-F5 feminin; default M2)",
        text_sau_url="Text direct, URL articol, sau gol pentru ultimul răspuns Echo",
        rezumat="Dacă să facă Claude rezumat înainte de TTS (doar pentru URL)",
    )
    @app_commands.choices(
        voce=[
            app_commands.Choice(name="M1 — Masculin 1", value="M1"),
            app_commands.Choice(name="M2 — Masculin 2 (default)", value="M2"),
            app_commands.Choice(name="M3 — Masculin 3", value="M3"),
            app_commands.Choice(name="M4 — Masculin 4", value="M4"),
            app_commands.Choice(name="M5 — Masculin 5", value="M5"),
            app_commands.Choice(name="F1 — Feminin 1", value="F1"),
            app_commands.Choice(name="F2 — Feminin 2", value="F2"),
            app_commands.Choice(name="F3 — Feminin 3", value="F3"),
            app_commands.Choice(name="F4 — Feminin 4", value="F4"),
            app_commands.Choice(name="F5 — Feminin 5", value="F5"),
        ]
    )
    async def audio_cmd(
        interaction: discord.Interaction,
        voce: str | None = None,
        text_sau_url: str | None = None,
        rezumat: bool = False,
    ) -> None:
        await interaction.response.defer()
        voice = voce or "M2"

        # URL fără rezumat → fetch + split în chunks + trimite pe rând
        if text_sau_url and text_sau_url.startswith("http") and not rezumat:
            text = await asyncio.to_thread(extract_url_text, text_sau_url)
            if not text:
                await interaction.followup.send("Nu am putut extrage text din URL.")
                return
            chunks = split_text_chunks(text, max_chars=1500)
            total = len(chunks)
            for i, chunk in enumerate(chunks, 1):
                result = await asyncio.to_thread(fast_dispatch, "audio", [voice, chunk])
                if result and result.startswith("__AUDIO__:"):
                    wav_path = result[len("__AUDIO__:"):]
                    ogg_path = await asyncio.to_thread(_wav_to_ogg, wav_path)
                    try:
                        ext = "ogg" if ogg_path.endswith(".ogg") else "wav"
                        filename = f"echo-audio-{i}din{total}.{ext}" if total > 1 else f"echo-audio.{ext}"
                        await interaction.followup.send(
                            content=f"Bucata {i}/{total}" if total > 1 else None,
                            file=discord.File(ogg_path, filename=filename),
                        )
                    finally:
                        for p in {wav_path, ogg_path}:
                            try:
                                os.unlink(p)
                            except OSError:
                                pass
                else:
                    await interaction.followup.send(result or f"Eroare TTS la bucata {i}.")
                    return
            return

        # Comportament existent: text direct, gol, sau rezumat URL
        args: list[str] = []
        if voce:
            args.append(voce)
        if text_sau_url:
            args.extend(text_sau_url.split())
        if rezumat:
            args.append("rezumat")
        result = await asyncio.to_thread(fast_dispatch, "audio", args)
        if result and result.startswith("__AUDIO__:"):
            wav_path = result[len("__AUDIO__:"):]
            ogg_path = await asyncio.to_thread(_wav_to_ogg, wav_path)
            try:
                ext = "ogg" if ogg_path.endswith(".ogg") else "wav"
                await interaction.followup.send(
                    file=discord.File(ogg_path, filename=f"echo-audio.{ext}")
                )
            finally:
                for p in {wav_path, ogg_path}:
                    try:
                        os.unlink(p)
                    except OSError:
                        pass
        else:
            await interaction.followup.send(result or "Eroare TTS.")

    # Voice slash group (Pas 7)
    from src.adapters.discord_voice import register as register_voice
    voice_group = register_voice(tree, client)
    tree.add_command(voice_group)

    # --- Ralph commands (autonomous project execution) ---

    async def _autocomplete_by_status(
        interaction: discord.Interaction, current: str, statuses: tuple[str, ...]
    ) -> list[app_commands.Choice[str]]:
        try:
            data = _load_approved_tasks()
        except Exception:
            return []
        current_low = (current or "").lower()
        choices: list[app_commands.Choice[str]] = []
        for p in data.get("projects", []):
            if p.get("status") not in statuses:
                continue
            name = p.get("name", "")
            if current_low and current_low not in name.lower():
                continue
            desc = (p.get("description") or "").strip()
            label = f"{name} — {desc}"[:100] if desc else name
            choices.append(app_commands.Choice(name=label, value=name))
            if len(choices) >= 25:
                break
        return choices

    async def _ralph_autocomplete_pending(
        interaction: discord.Interaction, current: str
    ) -> list[app_commands.Choice[str]]:
        return await _autocomplete_by_status(interaction, current, ("pending",))

    async def _ralph_autocomplete_running(
        interaction: discord.Interaction, current: str
    ) -> list[app_commands.Choice[str]]:
        return await _autocomplete_by_status(interaction, current, ("running", "approved"))

    async def _ralph_autocomplete_workspace(
        interaction: discord.Interaction, current: str
    ) -> list[app_commands.Choice[str]]:
        from pathlib import Path
        ws = Path("/home/moltbot/workspace")
        if not ws.exists():
            return []
        current_low = (current or "").lower()
        choices: list[app_commands.Choice[str]] = []
        for p in sorted(ws.iterdir()):
            if not p.is_dir() or p.name.startswith("."):
                continue
            if current_low and current_low not in p.name.lower():
                continue
            choices.append(app_commands.Choice(name=p.name, value=p.name))
            if len(choices) >= 25:
                break
        return choices

    @tree.command(name="p", description="Propose new Ralph project")
    @app_commands.describe(slug="Project slug (e.g. game-library)", description="Short description of what to do")
    @app_commands.autocomplete(slug=_ralph_autocomplete_workspace)
    async def ralph_p(
        interaction: discord.Interaction, slug: str, description: str
    ) -> None:
        await interaction.response.send_message(_ralph_propose(slug, description))

    @tree.command(name="a", description="Approve Ralph project for tonight (no slug = list pending)")
    @app_commands.describe(slug="Project slug to approve (leave empty to list pending)")
    @app_commands.autocomplete(slug=_ralph_autocomplete_pending)
    async def ralph_a(
        interaction: discord.Interaction, slug: str | None = None
    ) -> None:
        slugs = [slug] if slug else []
        await interaction.response.send_message(_ralph_approve(slugs))

    @tree.command(name="l", description="List Ralph projects (interactive)")
    async def ralph_l(interaction: discord.Interaction) -> None:
        view = RalphRootView()
        await interaction.response.send_message(
            view.render_summary(), view=view, ephemeral=True
        )

    @tree.command(name="k", description="Stop a running Ralph project")
    @app_commands.describe(slug="Project slug to stop")
    @app_commands.autocomplete(slug=_ralph_autocomplete_running)
    async def ralph_k(
        interaction: discord.Interaction, slug: str
    ) -> None:
        await interaction.response.send_message(_ralph_stop(slug))

    # ---- Planning agent (W2) ---------------------------------------------

    @tree.command(name="plan", description="Pornește o sesiune de planning conversational pentru un proiect")
    @app_commands.describe(
        slug="Project slug (folosește /p ca să-l adaugi întâi)",
        description="Descriere opțională (default: cea din approved-tasks.json)",
    )
    @app_commands.autocomplete(slug=_ralph_autocomplete_pending)
    async def plan_cmd(
        interaction: discord.Interaction,
        slug: str,
        description: str | None = None,
    ) -> None:
        # Resolve description: explicit param wins, else look up in approved-tasks.
        # Done BEFORE defer so we can fall back to a modal if missing.
        desc = (description or "").strip()
        if not desc:
            try:
                data = _load_approved_tasks()
                for p in data.get("projects", []):
                    if p.get("name", "").lower() == slug.lower():
                        desc = p.get("description") or ""
                        break
            except Exception:
                logger.exception("approved-tasks lookup failed")
        if not desc:
            # No description anywhere — ask via modal and start planning on submit.
            from src.adapters.discord_views import RalphPlanModal
            await interaction.response.send_modal(RalphPlanModal(slug))
            return

        await interaction.response.defer(ephemeral=True)
        channel_id = str(interaction.channel_id)
        await interaction.followup.send(
            f"🧠 Pornesc planning pentru `{slug}`… (durează ~60s)", ephemeral=True
        )
        try:
            first = await asyncio.to_thread(
                start_planning_session, slug, desc, channel_id, "discord",
            )
        except Exception as e:
            logger.exception("start_planning_session failed for %s", slug)
            await interaction.followup.send(f"Planning blocat: {e}", ephemeral=True)
            return
        for chunk in _split_chunks(first):
            await interaction.followup.send(chunk, ephemeral=True)
        await interaction.followup.send(
            "Răspunde aici. Apasă **Continuă faza** când ești gata să trec la următoarea.",
            view=PlanningActiveView(),
            ephemeral=True,
        )

    @tree.command(name="cancel", description="Anulează sesiunea de planning curentă")
    async def cancel_planning_cmd(interaction: discord.Interaction) -> None:
        await interaction.response.defer(ephemeral=True)
        text = await asyncio.to_thread(
            planning_cancel, str(interaction.channel_id), "discord",
        )
        await interaction.followup.send(text, ephemeral=True)

    # --- Events ---

    @client.event
    async def on_ready() -> None:
        # Sync to each guild instantly, then global (global can take up to 1h)
        for guild in client.guilds:
            tree.copy_global_to(guild=guild)
            await tree.sync(guild=guild)
        await tree.sync()
        scheduler = getattr(client, "scheduler", None)
        if scheduler is not None:
            await scheduler.start()
        from datetime import datetime, timezone
        client._ready_at = datetime.now(timezone.utc)
        logger.info("Echo Core online as %s", client.user)
        # Voice models eager warmup (Pas 7)
        from src.adapters import discord_voice
        discord_voice._models_warmup_future = asyncio.create_task(
            discord_voice.warmup_models()
        )

    async def _handle_chat(message: discord.Message) -> None:
        """Process a chat message through the router and send the response."""
        channel_id = str(message.channel.id)
        user_id = str(message.author.id)
        text = message.content

        # Download attachments to /tmp and append paths to text
        for attachment in message.attachments:
            tmp_path = f"/tmp/{attachment.filename}"
            try:
                await attachment.save(tmp_path)
                text = (text + f"\n[ATTACHMENT:{tmp_path}]").strip()
                logger.info("Saved attachment: %s (%d bytes)", tmp_path, attachment.size)
            except Exception:
                logger.exception("Failed to save attachment: %s", attachment.filename)

        # React to acknowledge receipt
        await message.add_reaction("\U0001f440")

        # Track how many intermediate messages were sent via callback
        sent_count = 0
        loop = asyncio.get_event_loop()

        def on_text(text_block: str) -> None:
            """Send intermediate Claude text blocks to the channel."""
            nonlocal sent_count
            chunks = split_message(text_block)
            for chunk in chunks:
                asyncio.run_coroutine_threadsafe(
                    message.channel.send(chunk), loop
                )
            sent_count += 1

        try:
            async with message.channel.typing():
                response, _is_cmd = await asyncio.to_thread(
                    route_message, channel_id, user_id, text,
                    on_text=on_text,
                    adapter_name="discord",
                )

            # Only send the final combined response if no intermediates
            # were delivered (avoids duplicating content).
            if sent_count == 0:
                if response.startswith("__AUDIO__:"):
                    wav_path = response[len("__AUDIO__:"):]
                    await message.channel.send(
                        file=discord.File(wav_path, filename="echo-audio.wav")
                    )
                    try:
                        os.unlink(wav_path)
                    except OSError:
                        pass
                else:
                    chunks = split_message(response)
                    for chunk in chunks:
                        await message.channel.send(chunk)
        except Exception:
            logger.exception("Error processing message from %s", message.author)
            await message.channel.send(
                "Sorry, something went wrong processing your message."
            )
        finally:
            # Remove the eyes reaction
            try:
                await message.remove_reaction("\U0001f440", client.user)
            except discord.HTTPException:
                pass

    @client.event
    async def on_message(message: discord.Message) -> None:
        # Ignore messages from any bot (including self)
        if message.author.bot:
            return

        # DM handling: only process if sender is admin
        if isinstance(message.channel, discord.DMChannel):
            if not is_admin(str(message.author.id)):
                _security_log.warning(
                    "Unauthorized DM from user=%s (%s): %s",
                    message.author.id, message.author, message.content[:100],
                )
                return
            logger.info(
                "DM from admin %s: %s", message.author, message.content[:100]
            )
            await _handle_chat(message)
            return

        # Guild messages: ignore if channel not registered
        if not is_registered_channel(str(message.channel.id)):
            return

        logger.info(
            "Message in registered channel %s from %s: %s",
            message.channel,
            message.author,
            message.content[:100],
        )
        await _handle_chat(message)

    return client
