//! 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 backlog;
mod cleanup_worktrees;
mod convert;
mod cost;
mod coverage;
mod depends;
mod diff;
mod freeze;
mod git;
mod help;
pub(crate) mod loc;
mod logs;
mod move_story;
mod new_project;
mod overview;
mod run_tests;
mod setup;
mod show;
mod status;
mod timer;
mod triage;
pub(crate) mod unblock;
mod unreleased;

use crate::chat::util::strip_bot_mention;
use crate::services::Services;
use std::path::Path;

/// 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 the [`Services`] bundle with per-message dispatch context.
/// 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> {
    /// Shared services bundle (project root, agent pool, ambient rooms, …).
    pub services: &'a Services,
    /// Effective project root — usually `services.project_root`, but the Matrix
    /// transport overrides this in gateway mode to point at the active project.
    pub project_root: &'a Path,
    /// Bot user ID for mention-stripping — transport-specific (e.g. Matrix's
    /// `OwnedUserId` string differs from `services.bot_user_id`).
    pub bot_user_id: &'a str,
    /// The room this message came from (needed by ambient).
    pub room_id: &'a str,
}

/// Context passed to individual command handlers.
///
/// Holds a reference to the shared [`Services`] bundle so that handlers access
/// project-wide state via `ctx.services.*`. The effective project root may
/// differ from `services.project_root` in gateway mode — use
/// [`effective_root()`](Self::effective_root) to get the correct path.
pub struct CommandContext<'a> {
    /// Shared services bundle.
    pub services: &'a Services,
    /// Any text after the command keyword, trimmed.
    pub args: &'a str,
    /// The room this message came from (needed by ambient).
    pub room_id: &'a str,
    /// Effective project root for this dispatch. Equals `services.project_root`
    /// in standalone mode; in gateway mode the Matrix transport sets this to
    /// the active-project subdirectory.
    project_root: &'a Path,
}

impl<'a> CommandContext<'a> {
    /// Returns the effective project root for this command invocation.
    ///
    /// In standalone mode this equals `services.project_root`. In gateway mode
    /// (Matrix transport) it resolves to the active project subdirectory.
    pub fn effective_root(&self) -> &Path {
        self.project_root
    }

    /// Test-only constructor that allows submodule tests to build a
    /// `CommandContext` despite the private `project_root` field.
    #[cfg(test)]
    pub fn new_test(
        services: &'a Services,
        args: &'a str,
        room_id: &'a str,
        project_root: &'a Path,
    ) -> Self {
        Self {
            services,
            args,
            room_id,
            project_root,
        }
    }
}

/// 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: "backlog",
            description: "Show all items in the backlog with dependency satisfaction status",
            handler: backlog::handle_backlog,
        },
        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 pipeline info (stage, ACs, git diff, recent commits)",
            handler: status::handle_status,
        },
        BotCommand {
            name: "logs",
            description: "Show last agent log lines for a story: `logs <number>`",
            handler: logs::handle_logs,
        },
        BotCommand {
            name: "ambient",
            description: "Toggle ambient mode for this room: `ambient on` or `ambient off`",
            handler: ambient::handle_ambient,
        },
        BotCommand {
            name: "depends",
            description: "Set story dependencies: `depends <number> [dep1 dep2 ...]` (no deps = clear)",
            handler: depends::handle_depends,
        },
        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: "coverage",
            description: "Show test coverage: cached baseline by default, or `coverage run` to rerun the full suite",
            handler: coverage::handle_coverage,
        },
        BotCommand {
            name: "run_tests",
            description: "Run the project's test suite (`script/test`) and show pass/fail with output",
            handler: run_tests::handle_test,
        },
        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: "diff",
            description: "Show git diff from main branch to story worktree HEAD: `diff <number>`",
            handler: diff::handle_diff,
        },
        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: "compact",
            description: "Distill the session transcript into a seed, then reset (unlike `reset`, keeps distilled context for the next message)",
            handler: handle_compact_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: "convert",
            description: "Convert a work item's type: `convert <number> <type>` (types: story, bug, spike, refactor, epic)",
            handler: convert::handle_convert,
        },
        BotCommand {
            name: "unblock",
            description: "Reset a blocked story: `unblock <number>` (clears blocked flag and resets retry count)",
            handler: unblock::handle_unblock,
        },
        BotCommand {
            name: "freeze",
            description: "Freeze a story at its current stage: `freeze <number>` (suppresses pipeline advancement and auto-assign)",
            handler: freeze::handle_freeze,
        },
        BotCommand {
            name: "unfreeze",
            description: "Unfreeze a story: `unfreeze <number>` (resumes normal pipeline behaviour)",
            handler: freeze::handle_unfreeze,
        },
        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 generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
            handler: setup::handle_setup,
        },
        BotCommand {
            name: "cleanup_worktrees",
            description: "List orphaned worktrees (dry run), or `cleanup_worktrees --confirm` to remove them",
            handler: handle_cleanup_worktrees_fallback,
        },
        BotCommand {
            name: "health",
            description: "Show subsystem health: gateway, sled, matrix-sync, creds, and build-hash",
            handler: handle_health_fallback,
        },
        BotCommand {
            name: "new",
            description: "Bootstrap a new project container (gateway only): `new project <name>`",
            handler: new_project::handle_new_project_fallback,
        },
        BotCommand {
            name: "project-rebuild",
            description: "Rebuild a project's Docker image and swap the container (gateway only): `project-rebuild <name> [--timeout <secs>] [--force]`",
            handler: handle_project_rebuild_fallback,
        },
        BotCommand {
            name: "projects",
            description: "List all registered gateway projects: name, url, ssh port, host path, and adopted/built-in marker (gateway only)",
            handler: handle_projects_fallback,
        },
    ]
}

/// 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.services.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, ""),
        };
        // Status command: emoji indicators render natively in all clients.
        if cmd_name.eq_ignore_ascii_case("status") && args.is_empty() {
            let body =
                status::build_pipeline_status(dispatch.project_root, &dispatch.services.agents);
            let html = plain_to_html(&body);
            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.services.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 {
        services: dispatch.services,
        args,
        room_id: dispatch.room_id,
        project_root: dispatch.project_root,
    };

    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 `compact` command when it is not intercepted by
/// the async handler beforehand. For Matrix, `on_room_message` detects and
/// handles `compact` before `try_handle_command` is invoked, so this is
/// never called. Discord/Slack/WhatsApp check for `compact` only after their
/// own `try_handle_command` dispatch, so this handler does run there — it
/// deliberately always returns `None` so their subsequent async check gets a
/// chance to run the real handler instead of the LLM. The entry exists in
/// the registry so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving "compact" as a prompt.
fn handle_compact_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `cleanup_worktrees` command when it is not
/// intercepted by the async handler in `on_room_message`.  In practice this is
/// never called — cleanup_worktrees 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 "cleanup_worktrees" as a prompt.
fn handle_cleanup_worktrees_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

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

/// Fallback handler for the `health` command when it is not intercepted by the
/// async handler in `on_room_message`.  In practice this is never called — health
/// 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 "health" as a prompt.
fn handle_health_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

/// Fallback handler for the `projects` command when it is not intercepted by the
/// async gateway handler in `on_room_message`.  In practice this is never called —
/// `projects` is detected and handled before `try_handle_command` runs in gateway
/// mode.  The entry exists in the registry so `help` lists it.
///
/// Returns `None` to prevent the LLM from receiving the raw command text.
fn handle_projects_fallback(_ctx: &CommandContext) -> Option<String> {
    None
}

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

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::services::Services;
    use std::sync::Arc;

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

    /// Build a [`Services`] bundle for tests with the given bot name and a `/tmp`
    /// project root.
    pub fn test_services_named(bot_name: &str) -> Arc<Services> {
        Services::new_test(std::path::PathBuf::from("/tmp"), bot_name.to_string())
    }

    /// Dispatch a message through the command registry using the given Services.
    pub fn try_cmd_with_services(
        bot_user_id: &str,
        message: &str,
        services: &Services,
    ) -> Option<String> {
        let room_id = "!test:example.com".to_string();
        let dispatch = CommandDispatch {
            services,
            project_root: &services.project_root,
            bot_user_id,
            room_id: &room_id,
        };
        try_handle_command(&dispatch, message)
    }

    /// Convenience helper: create a temporary [`Services`] with the given bot
    /// name and dispatch `message`.
    pub fn try_cmd_addressed(bot_name: &str, bot_user_id: &str, message: &str) -> Option<String> {
        let services = test_services_named(bot_name);
        try_cmd_with_services(bot_user_id, message, &services)
    }

    // 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:?}"
        );
    }

    // -- malformed-args routing (story 1034) -----------------------------------

    #[test]
    fn malformed_unblock_args_route_to_timmy() {
        // "unblock to fix the blocking issue" — verb recognised, args look like
        // natural language rather than a numeric story ID.  Must return None so
        // the message is forwarded to the LLM instead of showing a usage error.
        let result = try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy unblock to fix the blocking issue",
        );
        assert!(
            result.is_none(),
            "unblock with natural-language args must route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn malformed_start_args_route_to_timmy_via_registry() {
        // "start to get the cheese grater working" — the registry entry for
        // start always returns None (handled async), so even bad args produce None.
        let result = try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy start to get the cheese grater working",
        );
        assert!(
            result.is_none(),
            "start with natural-language args must route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn malformed_show_args_route_to_timmy() {
        let result = try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy show me the login bug",
        );
        assert!(
            result.is_none(),
            "show with natural-language args must route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn malformed_freeze_args_route_to_timmy() {
        let result = try_cmd_addressed(
            "Timmy",
            "@timmy:homeserver.local",
            "@timmy freeze the pipeline until Friday",
        );
        assert!(
            result.is_none(),
            "freeze with natural-language args must route to LLM (None): {result:?}"
        );
    }

    #[test]
    fn well_formed_unblock_runs_handler() {
        // Numeric story ID → command handler runs (story not found, but Some is returned).
        let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy unblock 1010");
        assert!(
            result.is_some(),
            "well-formed unblock command should run the handler: {result:?}"
        );
    }

    #[test]
    fn well_formed_show_runs_handler() {
        let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy show 984");
        assert!(
            result.is_some(),
            "well-formed show command should run the handler: {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
            );
        }
    }
}
