//! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints.
/// Server-sent event stream for real-time agent output.
pub mod agents_sse;
/// Shared application context threaded through handlers.
pub mod context;
/// Node identity endpoint (public key, node ID).
pub mod identity;
/// Model Context Protocol (MCP) HTTP endpoint and tool modules.
pub mod mcp;
/// OAuth 2.0 PKCE flow endpoints for Anthropic authentication.
pub mod oauth;
#[cfg(test)]
pub(crate) mod test_helpers;
/// Workflow helpers for story/bug file operations.
pub mod workflow;

/// Gateway-mode HTTP endpoints for multi-project proxy.
pub mod gateway;
/// WebSocket handler for real-time frontend communication.
pub mod ws;

use context::AppContext;
use poem::EndpointExt;
use poem::http::StatusCode;
use poem::{Route, get, post};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::chat::transport::slack::SlackWebhookContext;
use crate::chat::transport::whatsapp::WhatsAppWebhookContext;

const DEFAULT_PORT: u16 = 3001;

/// Parse an optional port string, falling back to the default (3001).
pub fn parse_port(value: Option<String>) -> u16 {
    value
        .and_then(|v| v.parse::<u16>().ok())
        .unwrap_or(DEFAULT_PORT)
}

/// Read the server port from the `HUSKIES_PORT` env var, or use the default.
pub fn resolve_port() -> u16 {
    parse_port(std::env::var("HUSKIES_PORT").ok())
}

/// Write a `.huskies_port` file so other processes can discover the port.
pub fn write_port_file(dir: &Path, port: u16) -> Option<PathBuf> {
    let path = dir.join(".huskies_port");
    std::fs::write(&path, port.to_string()).ok()?;
    Some(path)
}

/// Delete the `.huskies_port` file on shutdown.
pub fn remove_port_file(path: &Path) {
    let _ = std::fs::remove_file(path);
}

/// Liveness probe — always returns 200 OK.
#[poem::handler]
pub fn health_handler() -> poem::Response {
    poem::Response::builder().status(StatusCode::OK).body("ok")
}

/// Version probe — reports the crate version and compile-time git hash.
///
/// Used by the gateway to verify that an upgrade converged: after `upgrade all`
/// the gateway polls this endpoint until the reported `git_hash` matches the
/// published artifact's hash (or times out).
#[poem::handler]
pub fn version_handler() -> poem::Response {
    let body = serde_json::json!({
        "version": env!("CARGO_PKG_VERSION"),
        "git_hash": option_env!("BUILD_GIT_HASH").unwrap_or("unknown"),
    });
    poem::Response::builder()
        .status(StatusCode::OK)
        .content_type("application/json")
        .body(body.to_string())
}

/// Assemble the full Poem route tree (WebSocket, MCP, OAuth, assets, webhooks).
pub fn build_routes(
    ctx: AppContext,
    whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
    slack_ctx: Option<Arc<SlackWebhookContext>>,
    port: u16,
) -> impl poem::Endpoint {
    let ctx_arc = std::sync::Arc::new(ctx);

    let oauth_state = Arc::new(oauth::OAuthState::new(port));

    let mut route = Route::new()
        .at("/health", get(health_handler))
        .at("/api/version", get(version_handler))
        .at("/ws", get(ws::ws_handler))
        .at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler))
        .at("/rpc", post(rpc_http_handler))
        .at(
            "/mcp",
            post(mcp::mcp_post_handler).get(mcp::mcp_get_handler),
        )
        .at(
            "/agents/:story_id/:agent_name/stream",
            get(agents_sse::agent_stream),
        )
        .at("/identity", get(identity::identity_handler))
        .at(
            "/oauth/authorize",
            get(oauth::oauth_authorize).data(oauth_state.clone()),
        )
        .at(
            "/callback",
            get(oauth::oauth_callback).data(oauth_state.clone()),
        )
        .at("/oauth/status", get(oauth::oauth_status))
        .at("/debug/crdt", get(debug_crdt_handler));

    route = route
        .at("/api/upgrade", post(upgrade_trigger_handler))
        .at("/api/artifacts/:filename", get(serve_artifact_handler));

    if let Some(wa_ctx) = whatsapp_ctx {
        route = route.at(
            "/webhook/whatsapp",
            get(crate::chat::transport::whatsapp::webhook_verify)
                .post(crate::chat::transport::whatsapp::webhook_receive)
                .data(wa_ctx),
        );
    }

    if let Some(sl_ctx) = slack_ctx {
        route = route
            .at(
                "/webhook/slack",
                post(crate::chat::transport::slack::webhook_receive).data(sl_ctx.clone()),
            )
            .at(
                "/webhook/slack/command",
                post(crate::chat::transport::slack::slash_command_receive).data(sl_ctx),
            );
    }

    route.data(ctx_arc)
}

/// HTTP bridge for the read-RPC protocol.
///
/// Accepts a JSON [`RpcFrame::RpcRequest`] body and returns the corresponding
/// [`RpcFrame::RpcResponse`].  This allows HTTP clients (e.g. the frontend) to
/// call read-RPC methods without maintaining a `/crdt-sync` WebSocket connection.
#[poem::handler]
pub async fn rpc_http_handler(body: poem::web::Json<serde_json::Value>) -> poem::Response {
    let text = serde_json::to_string(&body.0).unwrap_or_default();
    match crate::crdt_sync::try_handle_rpc_text(&text).await {
        Some(response) => {
            let json = serde_json::to_string(&response).unwrap_or_default();
            poem::Response::builder()
                .status(poem::http::StatusCode::OK)
                .header(poem::http::header::CONTENT_TYPE, "application/json")
                .body(json)
        }
        None => poem::Response::builder()
            .status(poem::http::StatusCode::BAD_REQUEST)
            .body("Invalid RPC request"),
    }
}

/// Debug HTTP endpoint: `GET /debug/crdt[?story_id=<id>]`
///
/// Returns the raw in-memory CRDT state as JSON.  Accepts an optional
/// `story_id` query parameter to restrict the dump to a single item.
///
/// **This is a debug endpoint.**  Use `GET /api/pipeline` or the
/// `get_pipeline_status` MCP tool for normal pipeline introspection.
#[poem::handler]
pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response {
    let story_id_filter = req.uri().query().and_then(|q| {
        q.split('&').find_map(|pair| {
            let (key, val) = pair.split_once('=')?;
            if key == "story_id" {
                Some(val.to_string())
            } else {
                None
            }
        })
    });

    let dump = crate::crdt_state::dump_crdt_state(story_id_filter.as_deref());

    let items: Vec<serde_json::Value> = dump
        .items
        .into_iter()
        .map(|item| {
            serde_json::json!({
                "story_id": item.story_id,
                "stage": item.stage,
                "name": item.name,
                "agent": item.agent,
                "retry_count": item.retry_count,
                "depends_on": item.depends_on,
                "claimed_by": item.claim_agent,
                "claimed_at": item.claim_ts,
                "content_index": item.content_index,
                "is_deleted": item.is_deleted,
            })
        })
        .collect();

    let body = serde_json::json!({
        "metadata": {
            "in_memory_state_loaded": dump.in_memory_state_loaded,
            "total_items": dump.total_items,
            "total_ops_in_list": dump.total_ops_in_list,
            "max_seq_in_list": dump.max_seq_in_list,
            "persisted_ops_count": dump.persisted_ops_count,
            "pending_persist_ops_count": dump.pending_persist_ops_count,
        },
        "items": items,
    });

    poem::Response::builder()
        .status(poem::http::StatusCode::OK)
        .header(poem::http::header::CONTENT_TYPE, "application/json")
        .body(serde_json::to_string_pretty(&body).unwrap_or_default())
}

/// `POST /api/upgrade` — trigger a self-update on the running sled.
///
/// Accepts `{"source_url": "http://<gateway>/api/artifacts/<name>"}` and
/// spawns the upgrade task in the background, returning 202 immediately.
/// The sled exits after the binary swap; Docker restarts it.
#[poem::handler]
pub async fn upgrade_trigger_handler(
    body: poem::web::Json<serde_json::Value>,
    ctx: poem::web::Data<&std::sync::Arc<AppContext>>,
) -> poem::Response {
    let source_url = match body
        .0
        .get("source_url")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
    {
        Some(u) => u,
        None => {
            return poem::Response::builder()
                .status(StatusCode::BAD_REQUEST)
                .body("Missing required field: source_url");
        }
    };

    // Fail fast if the binary cannot be replaced — a background failure after
    // a 202 looks like a healthy sled that silently stayed on the old version.
    if let Err(e) = crate::upgrade::preflight_target_writable() {
        return poem::Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(e);
    }

    let project_root = ctx.state.get_project_root().unwrap_or_default();

    // Spawn the download + swap in the background so we can return 202 before
    // the process exits for its Docker restart.
    tokio::spawn(async move {
        if let Err(e) = crate::upgrade::upgrade_and_reexec(&source_url, &project_root).await {
            crate::slog!("[upgrade] Upgrade failed: {e}");
        }
    });

    poem::Response::builder()
        .status(StatusCode::ACCEPTED)
        .body("Upgrade triggered. The sled will restart momentarily.")
}

/// Canonical artifact filename for sled binaries on this deployment's platform.
///
/// Sleds run linux/arm64 under OrbStack on Apple Silicon.  When amd64 hosts
/// arrive, `release` publishes one artifact per platform and this becomes a
/// lookup instead of a constant.
pub const SLED_ARTIFACT_NAME: &str = "huskies-linux-arm64";

/// Directory where the gateway stores distributable sled binaries.
///
/// Host-global (`~/.huskies/artifacts/`), not per-project: one artifact serves
/// every sled behind this gateway.
pub fn artifacts_dir() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());
    PathBuf::from(home).join(".huskies").join("artifacts")
}

/// Validate an artifact filename: plain name, no path components.
fn is_valid_artifact_name(name: &str) -> bool {
    !name.is_empty()
        && !name.starts_with('.')
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}

/// `GET /api/artifacts/:filename` — serve a published sled binary.
///
/// The gateway is the only place sleds download binaries from; where the
/// artifact came from (local build, release channel) is the gateway's concern.
/// Returns 400 for names with path components, 404 when the artifact does not
/// exist.
#[poem::handler]
pub async fn serve_artifact_handler(
    poem::web::Path(filename): poem::web::Path<String>,
) -> poem::Response {
    if !is_valid_artifact_name(&filename) {
        return poem::Response::builder()
            .status(StatusCode::BAD_REQUEST)
            .body("Invalid artifact name");
    }

    let path = artifacts_dir().join(&filename);
    match tokio::fs::read(&path).await {
        Ok(bytes) => poem::Response::builder()
            .status(StatusCode::OK)
            .header("Content-Type", "application/octet-stream")
            .header(
                "Content-Disposition",
                format!("attachment; filename=\"{filename}\""),
            )
            .body(bytes),
        Err(_) => poem::Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(format!("No artifact named {filename}")),
    }
}

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

    #[test]
    fn parse_port_defaults_to_3001() {
        assert_eq!(parse_port(None), 3001);
    }

    #[test]
    fn parse_port_reads_valid_value() {
        assert_eq!(parse_port(Some("4200".to_string())), 4200);
    }

    #[test]
    fn parse_port_ignores_invalid_value() {
        assert_eq!(parse_port(Some("not_a_number".to_string())), 3001);
    }

    #[test]
    fn write_and_remove_port_file() {
        let tmp = tempfile::tempdir().unwrap();

        let path = write_port_file(tmp.path(), 4567).expect("should write port file");
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "4567");

        remove_port_file(&path);
        assert!(!path.exists());
    }

    #[test]
    fn write_port_file_returns_none_on_nonexistent_dir() {
        let bad = std::path::Path::new("/this_dir_does_not_exist_storykit_test_xyz");
        assert!(write_port_file(bad, 1234).is_none());
    }

    #[test]
    fn remove_port_file_does_not_panic_for_missing_file() {
        let path = std::path::Path::new("/tmp/nonexistent_storykit_port_test_xyz_999");
        remove_port_file(path);
    }

    #[test]
    fn resolve_port_returns_a_valid_port() {
        // Exercises the resolve_port code path (reads HUSKIES_PORT env var or defaults).
        let port = resolve_port();
        assert!(port > 0);
    }

    #[test]
    fn build_routes_constructs_without_panic() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
        let _endpoint = build_routes(ctx, None, None, 3001);
    }

    #[test]
    fn artifact_name_validation() {
        assert!(is_valid_artifact_name("huskies-linux-arm64"));
        assert!(is_valid_artifact_name("huskies-linux-amd64.v2"));
        assert!(!is_valid_artifact_name(""));
        assert!(!is_valid_artifact_name(".."));
        assert!(!is_valid_artifact_name(".hidden"));
        assert!(!is_valid_artifact_name("a/b"));
        assert!(!is_valid_artifact_name("../../etc/passwd"));
        assert!(!is_valid_artifact_name("name with spaces"));
    }

    #[tokio::test]
    async fn artifact_endpoint_rejects_traversal_and_misses() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
        let app = build_routes(ctx, None, None, 3001);
        let cli = poem::test::TestClient::new(app);

        let resp = cli.get("/api/artifacts/..").send().await;
        assert_eq!(resp.0.status(), StatusCode::BAD_REQUEST);

        let resp = cli
            .get("/api/artifacts/definitely-not-a-real-artifact-xyz9")
            .send()
            .await;
        assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn version_endpoint_reports_version_and_git_hash() {
        let tmp = tempfile::tempdir().unwrap();
        let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
        let app = build_routes(ctx, None, None, 3001);
        let cli = poem::test::TestClient::new(app);

        let resp = cli.get("/api/version").send().await;
        resp.assert_status_is_ok();
        let text = resp.0.into_body().into_string().await.unwrap();
        let body: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            body.get("version").and_then(|v| v.as_str()),
            Some(env!("CARGO_PKG_VERSION"))
        );
        assert!(
            body.get("git_hash").and_then(|v| v.as_str()).is_some(),
            "git_hash must be present: {body}"
        );
    }

    #[test]
    fn build_routes_accepts_custom_port() {
        // Verify build_routes compiles and runs with a non-default port,
        // ensuring the port parameter flows through to OAuthState.
        let tmp = tempfile::tempdir().unwrap();
        let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
        let _endpoint = build_routes(ctx, None, None, 9999);
    }
}
