//! Customization page — produces a signed `custom.txt` blob the patched
//! RustDesk client accepts at startup. The client's `read_custom_client`
//! decodes the file as standard base64, verifies it against the embedded
//! branding public key (Ed25519 / sodium `sign::verify`), and applies the
//! contained JSON: `app-name`, `default-settings`, `override-settings`,
//! `app-icon` (base64 PNG/ICO/JPEG), plus arbitrary string keys that map
//! into the client's various settings registries.
//!
//! Distinct from the Deploy page (`deploy.rs`), which produces *unsigned*
//! `CustomServer` blobs for embedding in the installer filename. That blob
//! travels in a different channel and uses URL_SAFE_NO_PAD base64 — do not
//! reuse `encode_blob` from there.

use super::shared::{html_escape, notice_html, require_admin};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use axum::extract::{Form, Multipart};
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sodiumoxide::crypto::sign;

/// Hard cap on uploaded logo bytes (raw, before base64). 256 KiB is enough
/// for a 256x256 PNG with room to spare; base64 inflation puts the JSON
/// payload around 340 KiB which the signed blob comfortably absorbs.
const MAX_LOGO_BYTES: usize = 256 * 1024;

pub async fn index(admin: AuthedUser) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let pubkey = crate::common::read_branding_pubkey();
    Ok(Html(render_form(&FormState::default(), &pubkey, "", None)))
}

pub async fn generate(
    admin: AuthedUser,
    mut multipart: Multipart,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;

    let pubkey = crate::common::read_branding_pubkey();
    let mut state = FormState::default();
    let mut logo_bytes: Option<Vec<u8>> = None;
    let mut logo_too_big = false;
    let mut logo_bad_format = false;

    while let Some(field) = multipart
        .next_field()
        .await
        .map_err(|e| ApiError::BadRequest(format!("multipart parse: {e}")))?
    {
        let name = field.name().unwrap_or("").to_string();
        match name.as_str() {
            "app_name" => {
                state.app_name = field.text().await.unwrap_or_default();
            }
            "default_settings_json" => {
                state.default_settings_json = field.text().await.unwrap_or_default();
            }
            "override_settings_json" => {
                state.override_settings_json = field.text().await.unwrap_or_default();
            }
            "kv_key" => {
                state.kv_keys.push(field.text().await.unwrap_or_default());
            }
            "kv_value" => {
                state.kv_values.push(field.text().await.unwrap_or_default());
            }
            "logo" => {
                let bytes = field.bytes().await.unwrap_or_default();
                if bytes.is_empty() {
                    continue;
                }
                if bytes.len() > MAX_LOGO_BYTES {
                    logo_too_big = true;
                    continue;
                }
                if !is_supported_image(&bytes) {
                    logo_bad_format = true;
                    continue;
                }
                logo_bytes = Some(bytes.to_vec());
            }
            _ => {
                let _ = field.bytes().await;
            }
        }
    }

    if state.app_name.trim().is_empty() {
        return Ok(Html(render_form(
            &state,
            &pubkey,
            "",
            Some(("error", "App name is required.")),
        )));
    }
    if let Err(msg) = validate_app_name(state.app_name.trim()) {
        return Ok(Html(render_form(&state, &pubkey, "", Some(("error", msg)))));
    }
    if logo_too_big {
        return Ok(Html(render_form(
            &state,
            &pubkey,
            "",
            Some((
                "error",
                "Logo exceeds 256 KiB. Please upload a smaller PNG/ICO/JPEG.",
            )),
        )));
    }
    if logo_bad_format {
        return Ok(Html(render_form(
            &state,
            &pubkey,
            "",
            Some((
                "error",
                "Logo must be a PNG, ICO, or JPEG image (detected by magic bytes).",
            )),
        )));
    }

    let default_obj = match parse_settings_json(&state.default_settings_json) {
        Ok(o) => o,
        Err(msg) => {
            return Ok(Html(render_form(
                &state,
                &pubkey,
                "",
                Some(("error", &format!("default-settings: {msg}"))),
            )))
        }
    };
    let override_obj = match parse_settings_json(&state.override_settings_json) {
        Ok(o) => o,
        Err(msg) => {
            return Ok(Html(render_form(
                &state,
                &pubkey,
                "",
                Some(("error", &format!("override-settings: {msg}"))),
            )))
        }
    };

    let Some(sk) = crate::common::read_branding_sk() else {
        return Ok(Html(render_form(
            &state,
            &pubkey,
            "",
            Some((
                "error",
                "branding_ed25519 not found or malformed on the server. Restart hbbs to regenerate.",
            )),
        )));
    };

    let mut payload: Map<String, Value> = Map::new();
    payload.insert("app-name".into(), json!(state.app_name));
    if !default_obj.is_empty() {
        payload.insert("default-settings".into(), Value::Object(default_obj));
    }
    // `app-icon` must live inside `override-settings`, not at top level. The
    // client routes top-level string keys into HARD_SETTINGS, but the Flutter
    // logo loader (`mainGetBuildinOption('app-icon')`) reads from
    // BUILTIN_SETTINGS — and BUILTIN_SETTINGS is only populated when a key is
    // matched against KEYS_BUILDIN_SETTINGS inside default-/override-settings.
    let mut overrides = override_obj;
    if let Some(bytes) = &logo_bytes {
        overrides.insert("app-icon".into(), json!(base64::encode(bytes)));
    }
    if !overrides.is_empty() {
        payload.insert("override-settings".into(), Value::Object(overrides));
    }
    for (k, v) in collect_kv_pairs(&state.kv_keys, &state.kv_values) {
        payload.insert(k, json!(v));
    }

    let body = match serde_json::to_vec(&Value::Object(payload)) {
        Ok(v) => v,
        Err(e) => {
            return Ok(Html(render_form(
                &state,
                &pubkey,
                "",
                Some(("error", &format!("json encode: {e}"))),
            )))
        }
    };
    let signed = sign::sign(&body, &sk);
    let blob = base64::encode(&signed);

    let result = render_result(&blob, logo_bytes.as_deref());
    Ok(Html(render_form(&state, &pubkey, &result, None)))
}

#[derive(Debug, Deserialize)]
pub struct DownloadForm {
    pub blob: String,
}

pub async fn download(admin: AuthedUser, Form(f): Form<DownloadForm>) -> Result<Response, ApiError> {
    require_admin(&admin)?;
    if f.blob.trim().is_empty() {
        return Err(ApiError::BadRequest("blob is empty".into()));
    }
    let mut resp = (StatusCode::OK, f.blob).into_response();
    let h = resp.headers_mut();
    h.insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("text/plain; charset=utf-8"),
    );
    h.insert(
        header::CONTENT_DISPOSITION,
        HeaderValue::from_static(r#"attachment; filename="custom.txt""#),
    );
    Ok(resp)
}

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

#[derive(Default)]
struct FormState {
    app_name: String,
    default_settings_json: String,
    override_settings_json: String,
    kv_keys: Vec<String>,
    kv_values: Vec<String>,
}

/// Reject app names that won't survive the round-trip through the patched
/// client's Windows install/registry/service code paths. Spaces are fine
/// (e.g. "Nintendo Support"), but path separators, quotes, shell
/// metacharacters, and control characters are not — `get_app_name()` is
/// interpolated unquoted into batch scripts, registry subkeys, install
/// dirs, service names, and named-pipe paths. We also reject Windows
/// reserved device names (CON/PRN/AUX/NUL/COM1-9/LPT1-9). This does not
/// change anything about the signing pipeline; it just stops operators
/// from baking foot-guns into custom.txt.
fn validate_app_name(name: &str) -> Result<(), &'static str> {
    if name.len() > 64 {
        return Err("App name must be 64 characters or fewer.");
    }
    // Disallowed: ASCII control chars, Windows path-illegal chars
    // (`<>:"/\|?*`), and shell-injection-prone chars (`& ; $ \` \n` etc.).
    // Allowed printable punctuation is limited to space, '-', '_', '.', '(',
    // ')' which is enough for human-friendly names like "Acme Remote (EU)".
    const ALLOWED_PUNCT: &[char] = &[' ', '-', '_', '.', '(', ')'];
    if !name
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || ALLOWED_PUNCT.contains(&c))
    {
        return Err("App name may only contain ASCII letters, digits, spaces, and `- _ . ( )`. Other punctuation breaks Windows install paths.");
    }
    if name.starts_with(' ') || name.ends_with(' ') {
        return Err("App name must not start or end with a space.");
    }
    if name.contains("  ") {
        return Err("App name must not contain consecutive spaces.");
    }
    let upper = name.to_ascii_uppercase();
    const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL"];
    if RESERVED.contains(&upper.as_str()) {
        return Err("App name conflicts with a Windows reserved device name.");
    }
    if (upper.starts_with("COM") || upper.starts_with("LPT"))
        && upper.len() == 4
        && upper.as_bytes()[3].is_ascii_digit()
    {
        return Err("App name conflicts with a Windows reserved device name.");
    }
    Ok(())
}

fn parse_settings_json(raw: &str) -> Result<Map<String, Value>, String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Ok(Map::new());
    }
    let parsed: Value = serde_json::from_str(trimmed).map_err(|e| e.to_string())?;
    match parsed {
        Value::Object(m) => Ok(m),
        _ => Err("must be a JSON object".into()),
    }
}

fn collect_kv_pairs(keys: &[String], values: &[String]) -> Vec<(String, String)> {
    keys.iter()
        .zip(values.iter())
        .filter_map(|(k, v)| {
            let k = k.trim();
            if k.is_empty() {
                None
            } else {
                Some((k.to_string(), v.clone()))
            }
        })
        .collect()
}

/// Magic-byte sniff. Don't trust the multipart Content-Type — clients can
/// lie. PNG, JPEG (any variant), and ICO cover the formats the Flutter
/// `Image.memory` widget renders without a decoder plugin.
fn is_supported_image(bytes: &[u8]) -> bool {
    if bytes.len() < 4 {
        return false;
    }
    let png = bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]);
    let jpeg = bytes.starts_with(&[0xFF, 0xD8, 0xFF]);
    let ico = bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]);
    png || jpeg || ico
}

fn render_form(
    state: &FormState,
    pubkey: &str,
    result_html: &str,
    notice: Option<(&str, &str)>,
) -> String {
    let notice_block = match notice {
        Some((kind, msg)) => notice_html(kind, msg),
        None => String::new(),
    };

    let pubkey_display = if pubkey.is_empty() {
        r#"<span class="text-rose-300">branding_ed25519.pub not found on the server. Restart hbbs to generate it.</span>"#.to_string()
    } else {
        format!(
            r#"<code class="text-emerald-300 break-all">{}</code>"#,
            html_escape(pubkey)
        )
    };

    let default_settings_default = if state.default_settings_json.is_empty() {
        "{}"
    } else {
        &state.default_settings_json
    };
    let override_settings_default = if state.override_settings_json.is_empty() {
        "{}"
    } else {
        &state.override_settings_json
    };

    let kv_rows = render_kv_rows(&state.kv_keys, &state.kv_values);

    format!(
        r##"<div class="space-y-6">
  <header>
    <h2 class="text-lg font-semibold">Customization</h2>
    <p class="text-xs text-slate-500 mt-1">Generate a signed <code>custom.txt</code> the patched RustDesk client picks up at startup. The client must be built with this server's branding public key embedded (see below). Drop the resulting <code>custom.txt</code> next to <code>rustdesk.exe</code> on Windows, in <code>RustDesk.app/Contents/Resources/</code> on macOS, or alongside the Linux binary.</p>
  </header>

  <div class="bg-slate-900 border border-slate-800 rounded-lg p-4">
    <p class="text-xs font-medium text-slate-400 mb-1">Branding public key</p>
    <div class="text-xs font-mono">{pubkey_display}</div>
    <p class="text-xs text-slate-500 mt-2">Bake this into your client build with <code>RUSTDESK_BRANDING_PUBKEY=$(cat branding_ed25519.pub) cargo build --release</code>. Stock RustDesk clients will silently ignore the resulting <code>custom.txt</code>.</p>
  </div>

  {notice_block}

  <form
    class="space-y-4 bg-slate-900 border border-slate-800 rounded-lg p-4"
    hx-post="/admin/pages/customization/generate"
    hx-target="#main"
    hx-swap="innerHTML"
    hx-encoding="multipart/form-data"
  >
    <div>
      <label class="block text-xs font-medium text-slate-400 mb-1" for="app_name">App name (required)</label>
      <input id="app_name" name="app_name" type="text" required value="{app_name}"
        placeholder="Acme Remote"
        class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-sky-500" />
      <p class="text-xs text-slate-500 mt-1">Replaces "RustDesk" in window titles, the about dialog, and other in-app surfaces. Setting this flips <code>is_custom_client()</code> on the client.</p>
    </div>

    <div>
      <label class="block text-xs font-medium text-slate-400 mb-1" for="logo">Logo (PNG / ICO / JPEG, ≤ 256 KiB, optional)</label>
      <input id="logo" name="logo" type="file" accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon"
        class="block w-full text-xs text-slate-300 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:bg-slate-800 file:text-slate-200 hover:file:bg-slate-700" />
      <p class="text-xs text-slate-500 mt-1">Rendered in the connection page header, the about dialog, and the system tray when the patched client supports it. OS-level taskbar/dock icons are unaffected (those need build-time resource patching).</p>
    </div>

    <div>
      <label class="block text-xs font-medium text-slate-400 mb-1" for="default_settings_json">default-settings (JSON object, optional)</label>
      <textarea id="default_settings_json" name="default_settings_json" rows="3"
        class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{default_settings}</textarea>
      <p class="text-xs text-slate-500 mt-1">Defaults applied on first launch — user can still change these. Example: <code>{{"theme": "dark"}}</code></p>
    </div>

    <div>
      <label class="block text-xs font-medium text-slate-400 mb-1" for="override_settings_json">override-settings (JSON object, optional)</label>
      <textarea id="override_settings_json" name="override_settings_json" rows="3"
        class="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500">{override_settings}</textarea>
      <p class="text-xs text-slate-500 mt-1">Forced settings — user cannot change these. Example: <code>{{"hide-powered-by-me": "Y", "force-always-relay": "Y"}}</code></p>
    </div>

    <div>
      <label class="block text-xs font-medium text-slate-400 mb-1">Extra key/value pairs (optional)</label>
      <p class="text-xs text-slate-500 mb-2">Top-level keys merged into the signed JSON. Useful for buildin flags like <code>hide-tray</code>, <code>preset-user-name</code>, etc.</p>
      <div id="kv-rows" class="space-y-2">
        {kv_rows}
      </div>
      <button type="button"
        onclick="
          const tpl = document.getElementById('kv-row-template');
          document.getElementById('kv-rows').appendChild(tpl.content.cloneNode(true));
        "
        class="mt-2 text-xs text-sky-400 hover:text-sky-300">+ Add row</button>
    </div>

    <button type="submit"
      class="bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium rounded px-4 py-2 transition">
      Generate signed blob
    </button>
  </form>

  <template id="kv-row-template">
    <div class="flex gap-2">
      <input type="text" name="kv_key" placeholder="key"
        class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
      <input type="text" name="kv_value" placeholder="value"
        class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
      <button type="button"
        onclick="this.closest('div').remove()"
        class="text-rose-400 hover:text-rose-300 text-sm px-2">×</button>
    </div>
  </template>

  {result_html}
</div>"##,
        notice_block = notice_block,
        pubkey_display = pubkey_display,
        app_name = html_escape(&state.app_name),
        default_settings = html_escape(default_settings_default),
        override_settings = html_escape(override_settings_default),
        kv_rows = kv_rows,
        result_html = result_html,
    )
}

fn render_kv_rows(keys: &[String], values: &[String]) -> String {
    if keys.is_empty() {
        return String::new();
    }
    keys.iter()
        .zip(values.iter().chain(std::iter::repeat(&String::new())))
        .map(|(k, v)| {
            format!(
                r##"<div class="flex gap-2">
      <input type="text" name="kv_key" value="{k}" placeholder="key"
        class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
      <input type="text" name="kv_value" value="{v}" placeholder="value"
        class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-sky-500" />
      <button type="button"
        onclick="this.closest('div').remove()"
        class="text-rose-400 hover:text-rose-300 text-sm px-2">×</button>
    </div>"##,
                k = html_escape(k),
                v = html_escape(v),
            )
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn render_result(blob: &str, logo_bytes: Option<&[u8]>) -> String {
    let preview = match logo_bytes {
        Some(bytes) if !bytes.is_empty() => {
            let mime = sniff_mime(bytes);
            format!(
                r##"<div class="mb-3">
    <p class="text-xs font-medium text-slate-400 mb-1">Logo preview</p>
    <img src="data:{mime};base64,{b64}" alt="logo" class="h-16 w-16 object-contain bg-slate-800 rounded border border-slate-700" />
  </div>"##,
                mime = mime,
                b64 = base64::encode(bytes),
            )
        }
        _ => String::new(),
    };

    format!(
        r##"<section class="space-y-4 bg-slate-900 border border-emerald-800/40 rounded-lg p-4">
  <header>
    <h3 class="text-sm font-semibold text-emerald-300">Signed customization blob</h3>
    <p class="text-xs text-slate-500 mt-1">Save the blob below as <code>custom.txt</code> and drop it next to the patched RustDesk binary.</p>
  </header>

  {preview}

  <div>
    <label class="block text-xs font-medium text-slate-400 mb-1">Base64 blob</label>
    <pre class="text-xs bg-slate-950 border border-slate-800 rounded p-2 overflow-x-auto select-all whitespace-pre-wrap break-all">{blob}</pre>
  </div>

  <form action="/admin/pages/customization/download" method="post" target="_blank">
    <input type="hidden" name="blob" value="{blob_attr}" />
    <button type="submit"
      class="bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-medium rounded px-4 py-2 transition">
      Download custom.txt
    </button>
  </form>

  <details class="text-xs text-slate-400">
    <summary class="cursor-pointer text-slate-300 select-none">Where to drop custom.txt</summary>
    <ul class="mt-2 space-y-1 list-disc pl-5">
      <li>Windows: <code>C:\Program Files\RustDesk\custom.txt</code></li>
      <li>macOS: <code>/Applications/RustDesk.app/Contents/Resources/custom.txt</code></li>
      <li>Linux: alongside the binary (e.g. <code>/usr/lib/rustdesk/custom.txt</code>)</li>
    </ul>
    <p class="mt-2">The file is read once at process start; restart the client (or its tray service) after dropping it.</p>
  </details>
</section>"##,
        preview = preview,
        blob = html_escape(blob),
        blob_attr = html_escape(blob),
    )
}

fn sniff_mime(bytes: &[u8]) -> &'static str {
    if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
        "image/png"
    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
        "image/jpeg"
    } else if bytes.starts_with(&[0x00, 0x00, 0x01, 0x00]) {
        "image/x-icon"
    } else {
        "application/octet-stream"
    }
}
