//! Bot-level command registry shared by all chat transports.
//!
//! Commands registered here are handled directly by the bot without invoking
//! the LLM. The registry is the single source of truth — the `help` command
//! iterates it automatically so new commands appear in the help output as soon
//! as they are added.

mod ambient;
mod assign;
mod cost;
mod git;
mod help;
pub(crate) mod loc;
mod move_story;
mod overview;
mod setup;
mod show;
mod status;
mod timer;
mod triage;
pub(crate) mod unblock;
mod unreleased;

use crate::agents::AgentPool;
use crate::chat::util::strip_bot_mention;
use std::collections::HashSet;
use std::path::Path;
use std::sync::{Arc, Mutex};

/// A bot-level command that is handled without LLM invocation.
pub struct BotCommand {
    /// The command keyword (e.g., `"help"`). Always lowercase.
    pub name: &'static str,
    /// Short description shown in help output.
    pub description: &'static str,
    /// Handler that produces the response text (Markdown), or `None` to fall
    /// through to the LLM (e.g. when a command requires direct addressing but
    /// the message arrived via ambient mode).
    pub handler: fn(&CommandContext) -> Option<String>,
}

/// Dispatch parameters passed to `try_handle_command`.
///
/// Groups all the caller-supplied context needed to dispatch and execute bot
/// commands.  Construct one per incoming message and pass it alongside the raw
/// message body.
///
/// All identifiers are platform-agnostic strings so this struct works with
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
pub struct CommandDispatch<'a> {
    /// The bot's display name (e.g., "Timmy").
    pub bot_name: &'a str,
    /// The bot's full user ID (e.g., `"@timmy:homeserver.local"` on Matrix).
    pub bot_user_id: &'a str,
    /// Project root directory (needed by status, ambient).
    pub project_root: &'a Path,
    /// Agent pool (needed by status).
    pub agents: &'a AgentPool,
    /// Set of room IDs with ambient mode enabled (needed by ambient).
    pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
    /// The room this message came from (needed by ambient).
    pub room_id: &'a str,
}

/// Context passed to individual command handlers.
pub struct CommandContext<'a> {
    /// The bot's display name (e.g., "Timmy").
    pub bot_name: &'a str,
    /// Any text after the command keyword, trimmed.
    pub args: &'a str,
    /// Project root directory (needed by status, ambient).
    pub project_root: &'a Path,
    /// Agent pool (needed by status).
    pub agents: &'a AgentPool,
    /// Set of room IDs with ambient mode enabled (needed by ambient).
    pub ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
    /// The room this message came from (needed by ambient).
    pub room_id: &'a str,
}

/// Returns the full list of registered bot commands.
///
/// Add new commands here — they will automatically appear in `help` output.
pub fn commands() -> &'static [BotCommand] {
    &[
        BotCommand {
            name: "assign",
            description: "Pre-assign a model to a story: `assign <number> <model>` (e.g. `assign 42 opus`)",
            handler: assign::handle_assign,
        },
        BotCommand {
            name: "help",
            description: "Show this list of available commands",
            handler: help::handle_help,
        },
        BotCommand {
            name: "status",
            description: "Show pipeline status and agent availability; or `status <number>` for a story triage dump",
            handler: status::handle_status,
        },
        BotCommand {
            name: "ambient",
            description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
            handler: ambient::handle_ambient,
        },
        BotCommand {
            name: "git",
            description: "Show git status: branch, uncommitted changes, and ahead/behind remote",
            handler: git::handle_git,
        },
        BotCommand {
            name: "htop",
            description: "Show live system and agent process dashboard (`htop`, `htop 10m`, `htop stop`)",
            handler: handle_htop_fallback,
        },
        BotCommand {
            name: "cost",
            description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total",
            handler: cost::handle_cost,
        },
        BotCommand {
            name: "loc",
            description: "Show top source files by line count: `loc` (top 10), `loc <N>`, or `loc <filepath>` for a specific file",
            handler: loc::handle_loc,
        },
        BotCommand {
            name: "move",
            description: "Move a work item to a pipeline stage: `move <number> <stage>` (stages: backlog, current, qa, merge, done)",
            handler: move_story::handle_move,
        },
        BotCommand {
            name: "show",
            description: "Display the full text of a work item: `show <number>`",
            handler: show::handle_show,
        },
        BotCommand {
            name: "overview",
            description: "Show implementation summary for a merged story: `overview <number>`",
            handler: overview::handle_overview,
        },
        BotCommand {
            name: "start",
            description: "Start a coder on a story: `start <number>` or `start <number> opus`",
            handler: handle_start_fallback,
        },
        BotCommand {
            name: "delete",
            description: "Remove a work item from the pipeline: `delete <number>`",
            handler: handle_delete_fallback,
        },
        BotCommand {
            name: "rmtree",
            description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
            handler: handle_rmtree_fallback,
        },
        BotCommand {
            name: "reset",
            description: "Clear the current Claude Code session and start fresh",
            handler: handle_reset_fallback,
        },
        BotCommand {
            name: "rebuild",
            description: "Rebuild the server binary and restart",
            handler: handle_rebuild_fallback,
        },
        BotCommand {
            name: "timer",
            description: "Schedule a deferred agent start: `timer <story_id> <HH:MM>`, `timer list`, `timer cancel <story_id>`",
            handler: timer::handle_timer,
        },
        BotCommand {
            name: "unblock",
            description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",
            handler: unblock::handle_unblock,
        },
        BotCommand {
            name: "unreleased",
            description: "Show stories merged to master since the last release tag",
            handler: unreleased::handle_unreleased,
        },
        BotCommand {
            name: "setup",
            description: "Show setup wizard progress; or `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
            handler: setup::handle_setup,
        },
    ]
}

/// Like [`try_handle_command`] but returns `(plain_body, html_body)`.
///
/// The plain body is unchanged Markdown text suitable for the Matrix `body`
/// field (non-HTML clients). The HTML body is suitable for `formatted_body`.
///
/// The pipeline-status command (no args) injects Matrix `<font data-mx-color>`
/// tags on the traffic-light dots. All other commands produce HTML by running
/// the plain body through pulldown-cmark.
pub fn try_handle_command_with_html(
    dispatch: &CommandDispatch<'_>,
    message: &str,
) -> Option<(String, String)> {
    let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
    let trimmed = command_text.trim();
    if !trimmed.is_empty() {
        let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
            Some((c, a)) => (c, a.trim()),
            None => (trimmed, ""),
        };
        // Only the no-arg status variant shows the pipeline with traffic-light
        // dots; `status <number>` is a triage dump that needs no colour tags.
        if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
            let body = status::build_pipeline_status(dispatch.project_root, dispatch.agents);
            let html = status::build_pipeline_status_html(dispatch.project_root, dispatch.agents);
            return Some((body, html));
        }
    }
    // Generic path: plain text body → Markdown-to-HTML.
    let body = try_handle_command(dispatch, message)?;
    let html = plain_to_html(&body);
    Some((body, html))
}

/// Convert a Markdown string to HTML using the same options as the Matrix
/// transport's `markdown_to_html` helper.
fn plain_to_html(markdown: &str) -> String {
    use pulldown_cmark::{Options, Parser, html};
    let normalized = crate::chat::util::normalize_line_breaks(markdown);
    let options = Options::ENABLE_TABLES
        | Options::ENABLE_FOOTNOTES
        | Options::ENABLE_STRIKETHROUGH
        | Options::ENABLE_TASKLISTS;
    let parser = Parser::new_ext(&normalized, options);
    let mut out = String::new();
    html::push_html(&mut out, parser);
    out
}

/// Try to match a user message against a registered bot command.
///
/// The message is expected to be the raw body text (e.g., `"@timmy help"`).
/// The bot mention prefix is stripped before matching.
///
/// Returns `Some(response)` if a command matched and was handled, `None`
/// otherwise (the caller should fall through to the LLM).
pub fn try_handle_command(dispatch: &CommandDispatch<'_>, message: &str) -> Option<String> {
    let command_text = strip_bot_mention(message, dispatch.bot_name, dispatch.bot_user_id);
    let trimmed = command_text.trim();
    if trimmed.is_empty() {
        return None;
    }

    let (cmd_name, args) = match trimmed.split_once(char::is_whitespace) {
        Some((c, a)) => (c, a.trim()),
        None => (trimmed, ""),
    };
    let cmd_lower = cmd_name.to_ascii_lowercase();

    let ctx = CommandContext {
        bot_name: dispatch.bot_name,
        args,
        project_root: dispatch.project_root,
        agents: dispatch.agents,
        ambient_rooms: dispatch.ambient_rooms,
        room_id: dispatch.room_id,
    };

    commands()
        .iter()
        .find(|c| c.name == cmd_lower)
        .and_then(|c| (c.handler)(&ctx))
}

/// Fallback handler for the `htop` command when it is not intercepted by the
/// async handler in `on_room_message`.  In practice this is never called —
/// htop is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "htop" as a prompt.
fn handle_htop_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `start` command when it is not intercepted by
/// the async handler in `on_room_message`.  In practice this is never called —
/// start is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "start" as a prompt.
fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `rmtree` command when it is not intercepted by
/// the async handler in `on_room_message`.  In practice this is never called —
/// rmtree is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rmtree" as a prompt.
fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `delete` command when it is not intercepted by
/// the async handler in `on_room_message`.  In practice this is never called —
/// delete is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "delete" as a prompt.
fn handle_delete_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `reset` command when it is not intercepted by
/// the async handler in `on_room_message`.  In practice this is never called —
/// reset is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "reset" as a prompt.
fn handle_reset_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `rebuild` command when it is not intercepted by
/// the async handler in `on_room_message`.  In practice this is never called —
/// rebuild is detected and handled before `try_handle_command` is invoked.
/// The entry exists in the registry only so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "rebuild" as a prompt.
fn handle_rebuild_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::agents::AgentPool;

    // -- test helpers (shared with submodule tests) -------------------------

    pub fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
        Arc::new(Mutex::new(HashSet::new()))
    }

    pub fn test_agents() -> Arc<AgentPool> {
        Arc::new(AgentPool::new_test(3000))
    }

    pub fn try_cmd(
        bot_name: &str,
        bot_user_id: &str,
        message: &str,
        ambient_rooms: &Arc<Mutex<HashSet<String>>>,
    ) -> Option<String> {
        let agents = test_agents();
        let room_id = "!test:example.com".to_string();
        let dispatch = CommandDispatch {
            bot_name,
            bot_user_id,
            project_root: std::path::Path::new("/tmp"),
            agents: &agents,
            ambient_rooms,
            room_id: &room_id,
        };
        try_handle_command(&dispatch, message)
    }

    pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
        try_cmd(bot_name, bot_user_id, message, &test_ambient_rooms())
    }

    // Re-export commands() for submodule tests
    pub use super::commands;

    // -- try_handle_command -------------------------------------------------

    #[test]
    fn unknown_command_returns_none() {
        let result = try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy what is the weather?",
        );
        assert!(result.is_none(), "non-command should return None");
    }

    #[test]
    fn empty_message_after_mention_returns_none() {
        let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy");
        assert!(
            result.is_none(),
            "bare mention with no command should fall through to LLM"
        );
    }

    #[test]
    fn htop_command_falls_through_to_none() {
        // The htop handler returns None so the message is handled asynchronously
        // in on_room_message, not here.  try_handle_command must return None.
        let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy htop");
        assert!(
            result.is_none(),
            "htop should not produce a sync response (handled async): {result:?}"
        );
    }

    // -- commands registry --------------------------------------------------

    #[test]
    fn commands_registry_is_not_empty() {
        assert!(
            !commands().is_empty(),
            "command registry must contain at least one command"
        );
    }

    #[test]
    fn all_command_names_are_lowercase() {
        for cmd in commands() {
            assert_eq!(
                cmd.name,
                cmd.name.to_ascii_lowercase(),
                "command name '{}' must be lowercase",
                cmd.name
            );
        }
    }

    #[test]
    fn all_commands_have_descriptions() {
        for cmd in commands() {
            assert!(
                !cmd.description.is_empty(),
                "command '{}' must have a description",
                cmd.name
            );
        }
    }
}
