//! RPC method registry for the `/crdt-sync` WebSocket multiplexer.
//!
//! Incoming [`RpcFrame::RpcRequest`] frames are dispatched through this
//! registry.  Each method handler is an async function that accepts a
//! `serde_json::Value` parameter bag and returns a `serde_json::Value` result.
//!
//! # Registering handlers
//!
//! Add a new entry to the `HANDLERS` static slice:
//!
//! ```rust,ignore
//! ("my.method", |p| Box::pin(handle_my_method(p))),
//! ```
//!
//! # Unknown methods
//!
//! [`dispatch`] returns `Err("NOT_FOUND")` for any method not present in the
//! registry.  The caller should translate this into an
//! [`RpcFrame::RpcResponse`] with `ok: false, code: "NOT_FOUND"`.
//!
//! # Global context
//!
//! Many handlers need access to project state (session root, store, workflow).
//! Call [`init_rpc_context`] once at server startup to register these.
//! Handlers that require context return an error result when it has not been
//! set.

use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};

use serde_json::Value;

use super::rpc_contract::{
    BotConfigPayload, EditorSettingsResult, ForgetProjectParams, OkResult, OpenFileParams,
    OpenProjectParams, OpenProjectResult, ProjectSettingsPayload, PutEditorParams,
    SetAnthropicApiKeyParams, SetModelPreferenceParams,
};
use super::wire::RpcFrame;
use crate::agents::AgentPool;
use crate::state::SessionState;
use crate::store::JsonFileStore;
use crate::workflow::WorkflowState;

/// Future returned by an RPC handler.
type HandlerFuture = Pin<Box<dyn Future<Output = Value> + Send>>;

/// Signature for an async RPC method handler.
pub(super) type Handler = fn(Value) -> HandlerFuture;

/// Shared state made available to all RPC handlers.
pub struct RpcState {
    pub state: Arc<SessionState>,
    pub store: Arc<JsonFileStore>,
    pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
}

/// Global RPC context, initialised once at server startup via [`init_rpc_context`].
static RPC_CTX: OnceLock<RpcState> = OnceLock::new();

/// Global agent pool, registered once at startup via [`init_rpc_agents`].
static RPC_AGENTS: OnceLock<Arc<AgentPool>> = OnceLock::new();

/// Register the global RPC context.
///
/// Must be called before any handler that accesses project state is invoked.
/// Subsequent calls are silently ignored (OnceLock semantics).
pub fn init_rpc_context(
    state: Arc<SessionState>,
    store: Arc<JsonFileStore>,
    workflow: Arc<std::sync::Mutex<WorkflowState>>,
) {
    let _ = RPC_CTX.set(RpcState {
        state,
        store,
        workflow,
    });
}

/// Register the agent pool for use in RPC handlers.
///
/// Must be called after [`AgentPool`] is constructed (after `init_rpc_context`).
/// Subsequent calls are silently ignored (OnceLock semantics).
pub fn init_rpc_agents(agents: Arc<AgentPool>) {
    let _ = RPC_AGENTS.set(agents);
}

/// Static registry mapping method names to handlers.
///
/// Add new handlers here.  The registry is a plain slice — linear scan is
/// fine for the small number of methods expected.
static HANDLERS: &[(&str, Handler)] = &[
    ("health.check", |p| Box::pin(handle_health_check(p))),
    ("active_agents.list", |p| {
        Box::pin(handle_active_agents_list(p))
    }),
    ("agent_config.list", |p| {
        Box::pin(handle_agent_config_list(p))
    }),
    ("settings.get_project", |p| {
        Box::pin(handle_settings_get_project(p))
    }),
    ("settings.get_editor", |p| {
        Box::pin(handle_settings_get_editor(p))
    }),
    ("model.get_preference", |p| {
        Box::pin(handle_model_get_preference(p))
    }),
    ("project.current", |p| Box::pin(handle_project_current(p))),
    ("project.known", |p| Box::pin(handle_project_known(p))),
    ("anthropic.key_exists", |p| {
        Box::pin(handle_anthropic_key_exists(p))
    }),
    ("anthropic.list_models", |p| {
        Box::pin(handle_anthropic_list_models(p))
    }),
    ("ollama.list_models", |p| {
        Box::pin(handle_ollama_list_models(p))
    }),
    ("io.home_directory", |p| {
        Box::pin(handle_io_home_directory(p))
    }),
    ("io.list_project_files", |p| {
        Box::pin(handle_io_list_project_files(p))
    }),
    ("work_items.get", |p| Box::pin(handle_work_items_get(p))),
    ("work_items.test_results", |p| {
        Box::pin(handle_work_items_test_results(p))
    }),
    ("work_items.token_cost", |p| {
        Box::pin(handle_work_items_token_cost(p))
    }),
    ("token_usage.all", |p| Box::pin(handle_token_usage_all(p))),
    ("oauth.status", |p| Box::pin(handle_oauth_status(p))),
    ("bot_config.get", |p| Box::pin(handle_bot_config_get(p))),
    ("agents.get_output", |p| {
        Box::pin(handle_agents_get_output(p))
    }),
    // ── typed write methods ──────────────────────────────────────────────
    ("model.set_preference", |p| {
        Box::pin(handle_model_set_preference(p))
    }),
    ("anthropic.set_api_key", |p| {
        Box::pin(handle_anthropic_set_api_key(p))
    }),
    ("settings.put_editor", |p| {
        Box::pin(handle_settings_put_editor(p))
    }),
    ("settings.open_file", |p| {
        Box::pin(handle_settings_open_file(p))
    }),
    ("settings.put_project", |p| {
        Box::pin(handle_settings_put_project(p))
    }),
    ("project.open", |p| Box::pin(handle_project_open(p))),
    ("project.close", |p| Box::pin(handle_project_close(p))),
    ("project.forget", |p| Box::pin(handle_project_forget(p))),
    ("bot_config.save", |p| Box::pin(handle_bot_config_save(p))),
    ("chat.cancel", |p| Box::pin(handle_chat_cancel(p))),
    // ── formerly REST-only endpoints, now RPC ────────────────────────────────
    ("io.read_file", |p| Box::pin(handle_io_read_file(p))),
    ("io.list_directory_absolute", |p| {
        Box::pin(handle_io_list_directory_absolute(p))
    }),
    ("bot.command", |p| Box::pin(handle_bot_command(p))),
    ("agents.start", |p| Box::pin(handle_agents_start(p))),
    ("agents.stop", |p| Box::pin(handle_agents_stop(p))),
    ("wizard.confirm_step", |p| {
        Box::pin(handle_wizard_confirm_step(p))
    }),
    ("wizard.skip_step", |p| Box::pin(handle_wizard_skip_step(p))),
];

// ── typed-write helper macros ───────────────────────────────────────────────

/// Parse the incoming JSON params into a typed struct or return a typed error
/// response shaped as `{"error": "..."}`.
macro_rules! parse_params {
    ($params:expr, $ty:ty) => {
        match serde_json::from_value::<$ty>($params) {
            Ok(v) => v,
            Err(e) => return serde_json::json!({"error": format!("invalid params: {e}")}),
        }
    };
}

fn ok_json() -> Value {
    serde_json::to_value(OkResult { ok: true }).unwrap_or(Value::Null)
}

fn err_json(msg: impl Into<String>) -> Value {
    serde_json::json!({"error": msg.into()})
}

// ── handlers ─────────────────────────────────────────────────────────────────

/// Handler for the `health.check` method.
///
/// Returns `{"status": "ok"}` unconditionally.  Used as a smoke test to
/// verify that the RPC multiplexer is wired up correctly.
async fn handle_health_check(_params: Value) -> Value {
    serde_json::json!({"status": "ok"})
}

/// Handler for the `active_agents.list` method.
///
/// Reads the `active_agents` collection from the CRDT and returns an array
/// matching the shape formerly served by `GET /api/agents`.  Each entry
/// contains `story_id`, `agent_name`, `status`, `session_id`, and
/// `worktree_path`.
async fn handle_active_agents_list(_params: Value) -> Value {
    let entries = crate::crdt_state::read_all_active_agents().unwrap_or_default();
    let list: Vec<Value> = entries
        .into_iter()
        .map(|view| {
            let (story_id, agent_name) = view
                .agent_id
                .rsplit_once(':')
                .map(|(s, a)| (s.to_string(), a.to_string()))
                .unwrap_or_else(|| (view.story_id.unwrap_or_default(), view.agent_id.clone()));
            serde_json::json!({
                "story_id": story_id,
                "agent_name": agent_name,
                "status": "running",
                "session_id": null,
                "worktree_path": null,
            })
        })
        .collect();
    Value::Array(list)
}

/// Handler for the `agent_config.list` method.
///
/// Returns the configured agent roster from `project.toml`, matching the
/// shape formerly served by `GET /api/agents/config`.
async fn handle_agent_config_list(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"error": "RPC context not initialised"});
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return Value::Array(vec![]);
    };
    let entries = crate::service::agents::get_agent_config(&root).unwrap_or_default();
    let list: Vec<Value> = entries
        .into_iter()
        .map(|e| {
            serde_json::json!({
                "name": e.name,
                "role": e.role,
                "stage": e.stage,
                "model": e.model,
                "allowed_tools": e.allowed_tools,
                "max_turns": e.max_turns,
                "max_budget_usd": e.max_budget_usd,
            })
        })
        .collect();
    Value::Array(list)
}

/// Handler for the `settings.get_project` method.
///
/// Returns the current `project.toml` scalar settings, matching the shape
/// formerly served by `GET /api/settings`.
async fn handle_settings_get_project(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"error": "RPC context not initialised"});
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return serde_json::json!({"error": "No project open"});
    };
    match crate::service::settings::load_project_settings(&root) {
        Ok(s) => serde_json::json!({
            "default_qa": s.default_qa,
            "default_coder_model": s.default_coder_model,
            "max_coders": s.max_coders,
            "max_retries": s.max_retries,
            "base_branch": s.base_branch,
            "rate_limit_notifications": s.rate_limit_notifications,
            "timezone": s.timezone,
            "rendezvous": s.rendezvous,
            "watcher_sweep_interval_secs": s.watcher_sweep_interval_secs,
            "watcher_done_retention_secs": s.watcher_done_retention_secs,
        }),
        Err(e) => serde_json::json!({"error": e.to_string()}),
    }
}

/// Handler for the `settings.get_editor` method.
///
/// Returns the configured editor command from the store, matching the shape
/// formerly served by `GET /api/settings/editor`.
async fn handle_settings_get_editor(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"editor_command": null});
    };
    let cmd = crate::service::settings::get_editor_command(ctx.store.as_ref());
    serde_json::json!({"editor_command": cmd})
}

/// Handler for the `model.get_preference` method.
///
/// Returns the user's saved LLM model name from the store, matching the
/// shape formerly served by `GET /api/model`.
async fn handle_model_get_preference(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return Value::Null;
    };
    match crate::io::fs::get_model_preference(ctx.store.as_ref()) {
        Ok(pref) => serde_json::to_value(pref).unwrap_or(Value::Null),
        Err(_) => Value::Null,
    }
}

/// Handler for the `project.current` method.
///
/// Returns the currently open project path (or null), matching the shape
/// formerly served by `GET /api/project`.
async fn handle_project_current(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return Value::Null;
    };
    match crate::service::project::get_current_project(&ctx.state, ctx.store.as_ref()) {
        Ok(path) => serde_json::to_value(path).unwrap_or(Value::Null),
        Err(_) => Value::Null,
    }
}

/// Handler for the `project.known` method.
///
/// Returns the list of previously-opened project paths from the store,
/// matching the shape formerly served by `GET /api/projects`.
async fn handle_project_known(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return Value::Array(vec![]);
    };
    match crate::service::project::get_known_projects(ctx.store.as_ref()) {
        Ok(paths) => serde_json::to_value(paths).unwrap_or(Value::Array(vec![])),
        Err(_) => Value::Array(vec![]),
    }
}

/// Handler for the `anthropic.key_exists` method.
///
/// Returns true when an Anthropic API key is stored, matching the shape
/// formerly served by `GET /api/anthropic/key/exists`.
async fn handle_anthropic_key_exists(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return Value::Bool(false);
    };
    match crate::service::anthropic::get_api_key_exists(ctx.store.as_ref()) {
        Ok(exists) => Value::Bool(exists),
        Err(_) => Value::Bool(false),
    }
}

/// Handler for the `anthropic.list_models` method.
///
/// Returns the available Anthropic models, matching the shape formerly
/// served by `GET /api/anthropic/models`.  Surfaces upstream errors as a
/// JSON object `{"error": "..."}`.
async fn handle_anthropic_list_models(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"error": "RPC context not initialised"});
    };
    match crate::service::anthropic::list_models(ctx.store.as_ref()).await {
        Ok(models) => serde_json::to_value(models).unwrap_or(Value::Array(vec![])),
        Err(e) => serde_json::json!({"error": e.to_string()}),
    }
}

/// Handler for the `ollama.list_models` method.
///
/// Returns the available Ollama models for the configured base URL,
/// matching the shape formerly served by `GET /api/ollama/models`.
///
/// Parameters: `{ "base_url"?: string }`.
async fn handle_ollama_list_models(params: Value) -> Value {
    let base_url = params
        .get("base_url")
        .and_then(|v| v.as_str())
        .map(str::to_string);
    match crate::llm::chat::get_ollama_models(base_url).await {
        Ok(models) => serde_json::to_value(models).unwrap_or(Value::Array(vec![])),
        Err(_) => Value::Array(vec![]),
    }
}

/// Handler for the `io.home_directory` method.
///
/// Returns the user's home directory path, matching the shape formerly
/// served by `GET /api/io/fs/home`.
async fn handle_io_home_directory(_params: Value) -> Value {
    match crate::service::file_io::get_home_directory() {
        Ok(home) => Value::String(home),
        Err(_) => Value::Null,
    }
}

/// Handler for the `io.list_project_files` method.
///
/// Returns the list of files in the currently open project, matching the
/// shape formerly served by `GET /api/io/fs/files`.
async fn handle_io_list_project_files(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return Value::Array(vec![]);
    };
    match crate::service::file_io::list_project_files(&ctx.state).await {
        Ok(files) => serde_json::to_value(files).unwrap_or(Value::Array(vec![])),
        Err(_) => Value::Array(vec![]),
    }
}

/// Handler for the `work_items.get` method.
///
/// Returns the markdown content and metadata for a work item, matching the
/// shape formerly served by `GET /api/work-items/{story_id}`.
///
/// Parameters: `{ "story_id": string }`.
async fn handle_work_items_get(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"error": "RPC context not initialised"});
    };
    let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
        return serde_json::json!({"error": "missing story_id"});
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return serde_json::json!({"error": "No project open"});
    };
    match crate::service::agents::get_work_item_content(&root, story_id) {
        Ok(c) => serde_json::json!({
            "content": c.content,
            "stage": c.stage,
            "name": c.name,
            "agent": c.agent,
            "origin": c.origin,
        }),
        Err(e) => serde_json::json!({"error": e.to_string()}),
    }
}

/// Handler for the `work_items.test_results` method.
///
/// Returns the most recent test-suite results for a story, matching the
/// shape formerly served by `GET /api/work-items/{story_id}/test-results`.
///
/// Parameters: `{ "story_id": string }`.
async fn handle_work_items_test_results(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return Value::Null;
    };
    let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
        return Value::Null;
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return Value::Null;
    };
    let workflow = ctx.workflow.lock().unwrap();
    match crate::service::agents::get_test_results(&root, story_id, &workflow) {
        Some(results) => serde_json::to_value(results).unwrap_or(Value::Null),
        None => Value::Null,
    }
}

/// Handler for the `work_items.token_cost` method.
///
/// Returns the aggregated LLM token cost for a story, matching the shape
/// formerly served by `GET /api/work-items/{story_id}/token-cost`.
///
/// Parameters: `{ "story_id": string }`.
async fn handle_work_items_token_cost(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"error": "RPC context not initialised"});
    };
    let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
        return serde_json::json!({"error": "missing story_id"});
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return serde_json::json!({"error": "No project open"});
    };
    match crate::service::agents::get_work_item_token_cost(&root, story_id) {
        Ok(summary) => serde_json::json!({
            "total_cost_usd": summary.total_cost_usd,
            "agents": summary.agents.into_iter().map(|a| serde_json::json!({
                "agent_name": a.agent_name,
                "model": a.model,
                "input_tokens": a.input_tokens,
                "output_tokens": a.output_tokens,
                "cache_creation_input_tokens": a.cache_creation_input_tokens,
                "cache_read_input_tokens": a.cache_read_input_tokens,
                "total_cost_usd": a.total_cost_usd,
            })).collect::<Vec<_>>(),
        }),
        Err(e) => serde_json::json!({"error": e.to_string()}),
    }
}

/// Handler for the `token_usage.all` method.
///
/// Returns every token-usage record for the project, matching the shape
/// formerly served by `GET /api/token-usage`.
async fn handle_token_usage_all(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"records": []});
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return serde_json::json!({"records": []});
    };
    let records = crate::service::agents::get_all_token_usage(&root).unwrap_or_default();
    serde_json::json!({"records": records})
}

/// Handler for the `oauth.status` method.
///
/// Returns the status of every stored OAuth account in the login pool,
/// matching the shape formerly served by `GET /oauth/status`.
async fn handle_oauth_status(_params: Value) -> Value {
    let accounts = crate::service::oauth::check_all_accounts();
    serde_json::json!({"accounts": accounts})
}

/// Handler for the `bot_config.get` method.
///
/// Reads the credentials stored in `.huskies/bot.toml`, matching the shape
/// formerly served by `GET /api/bot/config`.
async fn handle_bot_config_get(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return bot_config_default();
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return bot_config_default();
    };
    let path = root.join(".huskies").join("bot.toml");
    match std::fs::read_to_string(&path) {
        Ok(s) => match toml::from_str::<Value>(&s) {
            Ok(v) => merge_bot_config_defaults(v),
            Err(_) => bot_config_default(),
        },
        Err(_) => bot_config_default(),
    }
}

/// Default bot config payload — every key present, every value null.
fn bot_config_default() -> Value {
    serde_json::json!({
        "transport": null,
        "enabled": null,
        "homeserver": null,
        "username": null,
        "password": null,
        "room_ids": null,
        "slack_bot_token": null,
        "slack_signing_secret": null,
        "slack_channel_ids": null,
    })
}

/// Fill in missing keys with `null` so the frontend always sees the full shape.
fn merge_bot_config_defaults(mut v: Value) -> Value {
    let obj = v.as_object_mut();
    let keys = [
        "transport",
        "enabled",
        "homeserver",
        "username",
        "password",
        "room_ids",
        "slack_bot_token",
        "slack_signing_secret",
        "slack_channel_ids",
    ];
    if let Some(map) = obj {
        for k in keys {
            map.entry(k.to_string()).or_insert(Value::Null);
        }
    }
    v
}

/// Handler for the `agents.get_output` method.
///
/// Returns the concatenated output text for an agent's most recent session,
/// matching the shape formerly served by
/// `GET /api/agents/{story_id}/{agent_name}/output`.
///
/// Parameters: `{ "story_id": string, "agent_name": string }`.
async fn handle_agents_get_output(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return serde_json::json!({"error": "RPC context not initialised"});
    };
    let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
        return serde_json::json!({"error": "missing story_id"});
    };
    let Some(agent_name) = params.get("agent_name").and_then(|v| v.as_str()) else {
        return serde_json::json!({"error": "missing agent_name"});
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return serde_json::json!({"error": "no project open"});
    };
    match crate::service::agents::get_agent_output(&root, story_id, agent_name) {
        Ok(output) => serde_json::json!({"output": output}),
        Err(e) => serde_json::json!({"error": e.to_string()}),
    }
}

// ── typed write handlers ────────────────────────────────────────────────────

/// Handler for `model.set_preference`. Persists the user's chosen LLM model.
async fn handle_model_set_preference(params: Value) -> Value {
    let typed = parse_params!(params, SetModelPreferenceParams);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    match crate::io::fs::set_model_preference(typed.model, ctx.store.as_ref()) {
        Ok(()) => ok_json(),
        Err(e) => err_json(e),
    }
}

/// Handler for `anthropic.set_api_key`. Stores an Anthropic API key.
async fn handle_anthropic_set_api_key(params: Value) -> Value {
    let typed = parse_params!(params, SetAnthropicApiKeyParams);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    match crate::service::anthropic::set_api_key(ctx.store.as_ref(), typed.api_key) {
        Ok(()) => ok_json(),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `settings.put_editor`. Updates the configured editor command.
async fn handle_settings_put_editor(params: Value) -> Value {
    use crate::store::StoreOps;
    let typed = parse_params!(params, PutEditorParams);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let trimmed = typed
        .editor_command
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string());
    match trimmed {
        Some(cmd) => {
            ctx.store.set(
                crate::service::settings::EDITOR_COMMAND_KEY,
                serde_json::json!(cmd.clone()),
            );
            if let Err(e) = ctx.store.save() {
                return err_json(e);
            }
            serde_json::to_value(EditorSettingsResult {
                editor_command: Some(cmd),
            })
            .unwrap_or(Value::Null)
        }
        None => {
            ctx.store
                .delete(crate::service::settings::EDITOR_COMMAND_KEY);
            if let Err(e) = ctx.store.save() {
                return err_json(e);
            }
            serde_json::to_value(EditorSettingsResult {
                editor_command: None,
            })
            .unwrap_or(Value::Null)
        }
    }
}

/// Handler for `settings.open_file`. Spawns the configured editor.
async fn handle_settings_open_file(params: Value) -> Value {
    let typed = parse_params!(params, OpenFileParams);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    match crate::service::settings::open_file_in_editor(ctx.store.as_ref(), &typed.path, typed.line)
    {
        Ok(()) => ok_json(),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `settings.put_project`. Persists project.toml scalar settings.
async fn handle_settings_put_project(params: Value) -> Value {
    let typed = parse_params!(params, ProjectSettingsPayload);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let domain = crate::service::settings::ProjectSettings {
        default_qa: typed.default_qa,
        default_coder_model: typed
            .default_coder_model
            .map(|s| crate::agents::AgentModel::from_api_str(&s)),
        max_coders: typed.max_coders,
        max_retries: typed.max_retries,
        base_branch: typed.base_branch,
        rate_limit_notifications: typed.rate_limit_notifications,
        timezone: typed.timezone,
        rendezvous: typed.rendezvous,
        watcher_sweep_interval_secs: typed.watcher_sweep_interval_secs,
        watcher_done_retention_secs: typed.watcher_done_retention_secs,
    };
    if let Err(e) = crate::service::settings::validate_project_settings(&domain) {
        return err_json(e.to_string());
    }
    if let Err(e) = crate::service::settings::write_project_settings(&root, &domain) {
        return err_json(e.to_string());
    }
    match crate::service::settings::load_project_settings(&root) {
        Ok(s) => serde_json::to_value(ProjectSettingsPayload {
            default_qa: s.default_qa,
            default_coder_model: s.default_coder_model.map(|m| m.as_str().to_string()),
            max_coders: s.max_coders,
            max_retries: s.max_retries,
            base_branch: s.base_branch,
            rate_limit_notifications: s.rate_limit_notifications,
            timezone: s.timezone,
            rendezvous: s.rendezvous,
            watcher_sweep_interval_secs: s.watcher_sweep_interval_secs,
            watcher_done_retention_secs: s.watcher_done_retention_secs,
        })
        .unwrap_or(Value::Null),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `project.open`. Opens a project on disk and updates session state.
async fn handle_project_open(params: Value) -> Value {
    let typed = parse_params!(params, OpenProjectParams);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let port = crate::http::resolve_port();
    match crate::service::project::open_project(typed.path, &ctx.state, ctx.store.as_ref(), port)
        .await
    {
        Ok(path) => serde_json::to_value(OpenProjectResult { path }).unwrap_or(Value::Null),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `project.close`. Closes the currently open project.
async fn handle_project_close(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    match crate::service::project::close_project(&ctx.state, ctx.store.as_ref()) {
        Ok(()) => ok_json(),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `project.forget`. Removes a project from the known list.
async fn handle_project_forget(params: Value) -> Value {
    let typed = parse_params!(params, ForgetProjectParams);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    match crate::service::project::forget_known_project(typed.path, ctx.store.as_ref()) {
        Ok(()) => ok_json(),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `bot_config.save`. Writes `.huskies/bot.toml`.
async fn handle_bot_config_save(params: Value) -> Value {
    let typed = parse_params!(params, BotConfigPayload);
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let bot_dir = root.join(".huskies");
    if let Err(e) = std::fs::create_dir_all(&bot_dir) {
        return err_json(format!("create .huskies dir: {e}"));
    }
    let bot_path = bot_dir.join("bot.toml");
    match toml::to_string_pretty(&typed) {
        Ok(s) => {
            if let Err(e) = std::fs::write(&bot_path, s) {
                return err_json(format!("write bot.toml: {e}"));
            }
        }
        Err(e) => return err_json(format!("serialise bot.toml: {e}")),
    }
    serde_json::to_value(typed).unwrap_or(Value::Null)
}

/// Handler for `chat.cancel`. Cancels the in-flight LLM chat stream.
async fn handle_chat_cancel(_params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    match crate::llm::chat::cancel_chat(&ctx.state) {
        Ok(()) => ok_json(),
        Err(e) => err_json(e),
    }
}

// ── formerly REST-only handlers ──────────────────────────────────────────────

/// Handler for `io.read_file`. Reads a project-scoped file and returns its content.
///
/// Parameters: `{ "path": string }`.
async fn handle_io_read_file(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Some(path) = params.get("path").and_then(|v| v.as_str()) else {
        return err_json("missing path");
    };
    match crate::service::file_io::read_file(path.to_string(), &ctx.state).await {
        Ok(content) => Value::String(content),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `io.list_directory_absolute`. Lists entries at an absolute path.
///
/// Parameters: `{ "path": string }`.
async fn handle_io_list_directory_absolute(params: Value) -> Value {
    let Some(path) = params.get("path").and_then(|v| v.as_str()) else {
        return err_json("missing path");
    };
    match crate::service::file_io::list_directory_absolute(path.to_string()).await {
        Ok(entries) => serde_json::to_value(entries).unwrap_or(Value::Array(vec![])),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `bot.command`. Dispatches a slash command and returns markdown output.
///
/// Parameters: `{ "command": string, "args"?: string }`.
async fn handle_bot_command(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Some(command) = params.get("command").and_then(|v| v.as_str()) else {
        return err_json("missing command");
    };
    let args = params.get("args").and_then(|v| v.as_str()).unwrap_or("");
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let Some(agents) = RPC_AGENTS.get() else {
        return err_json("Agent pool not initialised");
    };
    match crate::service::bot_command::execute(command, args, &root, agents).await {
        Ok(response) => serde_json::json!({"response": response}),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `agents.start`. Starts an agent for a story.
///
/// Parameters: `{ "story_id": string, "agent_name"?: string }`.
async fn handle_agents_start(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
        return err_json("missing story_id");
    };
    let agent_name = params.get("agent_name").and_then(|v| v.as_str());
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let Some(agents) = RPC_AGENTS.get() else {
        return err_json("Agent pool not initialised");
    };
    match crate::service::agents::start_agent(agents, &root, story_id, agent_name, None, None).await
    {
        Ok(info) => serde_json::json!({
            "story_id": info.story_id,
            "agent_name": info.agent_name,
            "status": info.status.to_string(),
            "session_id": info.session_id,
            "worktree_path": info.worktree_path,
            "base_branch": info.base_branch,
            "log_session_id": info.log_session_id,
        }),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `agents.stop`. Stops a running agent.
///
/// Parameters: `{ "story_id": string, "agent_name": string }`.
async fn handle_agents_stop(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
        return err_json("missing story_id");
    };
    let Some(agent_name) = params.get("agent_name").and_then(|v| v.as_str()) else {
        return err_json("missing agent_name");
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let Some(agents) = RPC_AGENTS.get() else {
        return err_json("Agent pool not initialised");
    };
    match crate::service::agents::stop_agent(agents, &root, story_id, agent_name).await {
        Ok(()) => Value::Bool(true),
        Err(e) => err_json(e.to_string()),
    }
}

/// Serialise a [`crate::io::wizard::WizardState`] into the frontend's expected JSON shape.
fn wizard_state_to_value(state: &crate::io::wizard::WizardState) -> Value {
    let steps: Vec<Value> = state
        .steps
        .iter()
        .map(|s| {
            let step_str = serde_json::to_value(s.step)
                .ok()
                .and_then(|v| v.as_str().map(String::from))
                .unwrap_or_default();
            let status_str = serde_json::to_value(&s.status)
                .ok()
                .and_then(|v| v.as_str().map(String::from))
                .unwrap_or_default();
            serde_json::json!({
                "step": step_str,
                "label": s.step.label(),
                "status": status_str,
                "content": s.content,
            })
        })
        .collect();
    serde_json::json!({
        "steps": steps,
        "current_step_index": state.current_step_index(),
        "completed": state.completed,
    })
}

/// Handler for `wizard.confirm_step`. Confirms the current wizard step.
///
/// Parameters: `{ "step": string }`.
async fn handle_wizard_confirm_step(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Some(step_str) = params.get("step").and_then(|v| v.as_str()) else {
        return err_json("missing step");
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let quoted = format!("\"{step_str}\"");
    let step = match serde_json::from_str::<crate::io::wizard::WizardStep>(&quoted) {
        Ok(s) => s,
        Err(_) => return err_json(format!("Unknown wizard step: {step_str}")),
    };
    match crate::service::wizard::mark_step_confirmed(&root, step) {
        Ok(state) => wizard_state_to_value(&state),
        Err(e) => err_json(e.to_string()),
    }
}

/// Handler for `wizard.skip_step`. Skips the current wizard step.
///
/// Parameters: `{ "step": string }`.
async fn handle_wizard_skip_step(params: Value) -> Value {
    let Some(ctx) = RPC_CTX.get() else {
        return err_json("RPC context not initialised");
    };
    let Some(step_str) = params.get("step").and_then(|v| v.as_str()) else {
        return err_json("missing step");
    };
    let Ok(root) = ctx.state.get_project_root() else {
        return err_json("No project open");
    };
    let quoted = format!("\"{step_str}\"");
    let step = match serde_json::from_str::<crate::io::wizard::WizardStep>(&quoted) {
        Ok(s) => s,
        Err(_) => return err_json(format!("Unknown wizard step: {step_str}")),
    };
    match crate::service::wizard::mark_step_skipped(&root, step) {
        Ok(state) => wizard_state_to_value(&state),
        Err(e) => err_json(e.to_string()),
    }
}

// ── dispatch ──────────────────────────────────────────────────────────────────

/// Dispatch an incoming RPC method call to the registered handler.
///
/// Returns `Ok(result)` on success or `Err("NOT_FOUND")` if no handler is
/// registered for `method`.
pub(super) async fn dispatch(method: &str, params: Value) -> Result<Value, &'static str> {
    for (name, handler) in HANDLERS {
        if *name == method {
            return Ok(handler(params).await);
        }
    }
    Err("NOT_FOUND")
}

/// Try to parse `text` as an [`RpcFrame::RpcRequest`] and, if successful,
/// dispatch it and return the corresponding [`RpcFrame::RpcResponse`].
///
/// Returns `None` if the text is not a valid `rpc_request` frame (i.e. it
/// should be forwarded to the CRDT sync handler instead).
pub(crate) async fn try_handle_rpc_text(text: &str) -> Option<RpcFrame> {
    let frame: RpcFrame = serde_json::from_str(text).ok()?;
    match frame {
        RpcFrame::RpcRequest {
            version,
            correlation_id,
            method,
            params,
            ..
        } => {
            let response = match dispatch(&method, params).await {
                Ok(result) => RpcFrame::RpcResponse {
                    version,
                    correlation_id,
                    ok: true,
                    result: Some(result),
                    error: None,
                    code: None,
                },
                Err(code) => RpcFrame::RpcResponse {
                    version,
                    correlation_id,
                    ok: false,
                    result: None,
                    error: Some(format!("unknown method: {method}")),
                    code: Some(code.to_string()),
                },
            };
            Some(response)
        }
        // Response frames arriving on our socket are not requests — nothing to send back.
        RpcFrame::RpcResponse { .. } => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn health_check_returns_ok_status() {
        let result = dispatch("health.check", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert_eq!(val["status"], "ok");
    }

    #[tokio::test]
    async fn unknown_method_returns_not_found() {
        let result = dispatch("nonexistent.method", serde_json::json!({})).await;
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "NOT_FOUND");
    }

    #[tokio::test]
    async fn try_handle_rpc_text_health_check() {
        let req = serde_json::json!({
            "kind": "rpc_request",
            "version": 1,
            "correlation_id": "test-corr-1",
            "ttl_ms": 5000,
            "method": "health.check",
            "params": {}
        });
        let text = serde_json::to_string(&req).unwrap();
        let resp = try_handle_rpc_text(&text)
            .await
            .expect("must produce a response");
        match resp {
            RpcFrame::RpcResponse {
                ok,
                correlation_id,
                result,
                code,
                ..
            } => {
                assert!(ok, "health.check must succeed");
                assert_eq!(correlation_id, "test-corr-1");
                assert!(result.is_some());
                assert_eq!(result.unwrap()["status"], "ok");
                assert!(code.is_none());
            }
            _ => panic!("Expected RpcResponse"),
        }
    }

    #[tokio::test]
    async fn try_handle_rpc_text_unknown_method_returns_not_found() {
        let req = serde_json::json!({
            "kind": "rpc_request",
            "version": 1,
            "correlation_id": "test-corr-2",
            "ttl_ms": 1000,
            "method": "no.such.method",
            "params": {}
        });
        let text = serde_json::to_string(&req).unwrap();
        let resp = try_handle_rpc_text(&text)
            .await
            .expect("must produce a response for unknown method");
        match resp {
            RpcFrame::RpcResponse { ok, code, .. } => {
                assert!(!ok, "unknown method must not succeed");
                assert_eq!(code.unwrap(), "NOT_FOUND");
            }
            _ => panic!("Expected RpcResponse"),
        }
    }

    #[tokio::test]
    async fn try_handle_rpc_text_ignores_non_rpc_frames() {
        let bulk = r#"{"type":"bulk","ops":[]}"#;
        assert!(try_handle_rpc_text(bulk).await.is_none());
    }

    #[tokio::test]
    async fn try_handle_rpc_text_ignores_rpc_response_frames() {
        let resp = serde_json::json!({
            "kind": "rpc_response",
            "version": 1,
            "correlation_id": "x",
            "ok": true,
            "result": {"status": "ok"}
        });
        let text = serde_json::to_string(&resp).unwrap();
        assert!(try_handle_rpc_text(&text).await.is_none());
    }

    #[tokio::test]
    async fn try_handle_rpc_text_ignores_invalid_json() {
        assert!(try_handle_rpc_text("not json at all").await.is_none());
    }

    #[tokio::test]
    async fn rpc_response_correlation_id_mirrors_request() {
        let req = serde_json::json!({
            "kind": "rpc_request",
            "version": 1,
            "correlation_id": "mirror-me",
            "ttl_ms": 500,
            "method": "health.check",
            "params": {}
        });
        let text = serde_json::to_string(&req).unwrap();
        let resp = try_handle_rpc_text(&text).await.unwrap();
        match resp {
            RpcFrame::RpcResponse { correlation_id, .. } => {
                assert_eq!(correlation_id, "mirror-me");
            }
            _ => panic!("Expected RpcResponse"),
        }
    }

    // ── context-dependent handlers (no context set) ──────────────────────────

    #[tokio::test]
    async fn agent_config_list_returns_value_without_context() {
        let result = dispatch("agent_config.list", serde_json::json!({})).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn settings_get_editor_returns_editor_command_key() {
        let result = dispatch("settings.get_editor", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert!(val.get("editor_command").is_some());
    }

    #[tokio::test]
    async fn model_get_preference_returns_a_value() {
        let result = dispatch("model.get_preference", serde_json::json!({})).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn project_current_returns_a_value() {
        let result = dispatch("project.current", serde_json::json!({})).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn project_known_returns_a_value() {
        let result = dispatch("project.known", serde_json::json!({})).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn anthropic_key_exists_returns_a_value() {
        let result = dispatch("anthropic.key_exists", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert!(val.is_boolean() || val.is_object());
    }

    #[tokio::test]
    async fn io_home_directory_returns_a_value() {
        let result = dispatch("io.home_directory", serde_json::json!({})).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn token_usage_all_returns_records_envelope() {
        let result = dispatch("token_usage.all", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert!(val.get("records").is_some());
    }

    #[tokio::test]
    async fn oauth_status_returns_accounts_envelope() {
        let result = dispatch("oauth.status", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert!(val.get("accounts").is_some());
    }

    #[tokio::test]
    async fn bot_config_get_returns_full_shape() {
        let result = dispatch("bot_config.get", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        for key in [
            "transport",
            "enabled",
            "homeserver",
            "username",
            "password",
            "room_ids",
            "slack_bot_token",
            "slack_signing_secret",
            "slack_channel_ids",
        ] {
            assert!(val.get(key).is_some(), "missing key {key}");
        }
    }

    #[tokio::test]
    async fn work_items_get_missing_story_id_returns_error() {
        let result = dispatch("work_items.get", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert!(val.get("error").is_some());
    }

    #[tokio::test]
    async fn agents_get_output_missing_story_id_returns_error() {
        let result = dispatch("agents.get_output", serde_json::json!({})).await;
        assert!(result.is_ok());
        let val = result.unwrap();
        assert!(val.get("error").is_some());
    }
}
