//! `/admin/connect/:peer_id` — serves the embedded web client SPA.
//!
//! Architecture: the SPA at web_client/src/main.ts opens WebSockets directly
//! to the existing rendezvous (hbbs:21118) and relay (hbbr:21119) endpoints
//! and speaks the same protocol the desktop client speaks. The role of this
//! handler is to (a) gate access via the AuthedUser cookie middleware,
//! (b) inject per-request config (rendezvous host, relay host, server pubkey,
//! peer id, admin name) into the SPA, and (c) serve the bundled JS/CSS via
//! `include_bytes!` so the binary is self-contained.
//!
//! Same `{{CUSTOM_CONFIG}}` template substitution pattern as deploy.rs.

use super::shared::{html_escape, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::extract::Path;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde_json::json;

const CONNECT_HTML: &str = include_str!("../../../../admin_ui/connect.html");
const BUNDLE_JS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.js");
const BUNDLE_CSS: &[u8] = include_bytes!("../../../../web_client/dist/bundle.css");

/// `GET /admin/connect/:peer_id` — render the SPA shell with config injected.
pub async fn index(
    admin: AuthedUser,
    headers: HeaderMap,
    Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;

    // Derive default rendezvous/relay hosts from the request Host header so
    // operators don't need to configure separately for the common case where
    // hbbs and hbbr live on the same machine the browser is currently talking
    // to. Same approach as the deploy page.
    let host = headers
        .get(header::HOST)
        .and_then(|v| v.to_str().ok())
        .map(host_only)
        .unwrap_or("")
        .to_string();

    let pubkey = read_pubkey();
    let api_server = format!(
        "{}://{}",
        if is_https(&headers) { "https" } else { "http" },
        headers
            .get(header::HOST)
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
    );

    let cfg = json!({
        "api_server":        api_server,
        "rendezvous_server": host,
        "relay_server":      host,
        "key":               pubkey,
        "peer_id":           peer_id,
        "admin_name":        admin.name.clone(),
    });

    let cfg_str = cfg.to_string();
    // The placeholder is inside <script id="custom-config" type="application/json">
    // so the JSON content is parsed verbatim by JSON.parse — no further escaping
    // beyond ensuring no literal "</script>" appears (which a JSON serializer
    // never produces) and HTML-escaping any peer_id we substitute elsewhere.
    let html = CONNECT_HTML.replace("{{CUSTOM_CONFIG}}", &cfg_str);

    // Defensive: if a peer_id ever ends up reflected outside the JSON tag
    // (the template doesn't currently do this, but future edits might),
    // having html_escape called as part of the page-build flow is a habit
    // worth preserving.
    let _ = html_escape;

    Ok(Html(html))
}

/// `GET /admin/connect/assets/bundle.js` — serve the SPA bundle.
pub async fn bundle_js() -> Response {
    asset_response(BUNDLE_JS, "application/javascript; charset=utf-8")
}

/// `GET /admin/connect/assets/bundle.css` — serve the SPA stylesheet.
pub async fn bundle_css() -> Response {
    asset_response(BUNDLE_CSS, "text/css; charset=utf-8")
}

fn asset_response(body: &'static [u8], content_type: &'static str) -> Response {
    let mut resp = (StatusCode::OK, body).into_response();
    let headers = resp.headers_mut();
    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
    // Bundles are content-addressed by SHA in name? Not yet — until we add
    // hashed filenames, force fresh fetches so admin upgrades pick up new JS.
    headers.insert(
        header::CACHE_CONTROL,
        HeaderValue::from_static("no-cache"),
    );
    resp
}

// ---------- helpers ----------

/// Read the server's Ed25519 public key from `id_ed25519.pub` in CWD —
/// same path `common::gen_sk` writes it to and what the deploy page reads.
fn read_pubkey() -> String {
    std::fs::read_to_string("id_ed25519.pub")
        .ok()
        .map(|s| s.trim().to_string())
        .unwrap_or_default()
}

/// Strip `:port` (and IPv6 brackets) from a Host-header value. Borrowed
/// from the deploy page; kept inline here rather than promoting to shared
/// to avoid a cross-module dep on a one-liner.
fn host_only(s: &str) -> &str {
    if let Some(rest) = s.strip_prefix('[') {
        if let Some(end) = rest.find(']') {
            return &rest[..end];
        }
    }
    s.rsplit_once(':').map(|(h, _)| h).unwrap_or(s)
}

/// Heuristic: were we reached via HTTPS? The presence of any
/// `X-Forwarded-Proto: https` from a reverse proxy is the standard signal.
/// Falls back to false; the SPA only uses this to construct the displayed
/// API URL, the actual WebSockets pick `ws://` vs `wss://` based on the
/// page's own protocol.
fn is_https(headers: &HeaderMap) -> bool {
    headers
        .get("x-forwarded-proto")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.eq_ignore_ascii_case("https"))
        .unwrap_or(false)
}
