//! Per-device PowerShell remote-exec page.
//!
//! Layout:
//!   GET  /admin/pages/devices/:peer_id/exec                — full page
//!   POST /admin/pages/devices/:peer_id/exec                — dispatch
//!   GET  /admin/pages/devices/:peer_id/exec/:cmd_id/poll   — single-row fragment, auto-refreshes
//!
//! Gates:
//!   - AuthedUser.is_admin
//!   - peer.managed = 1   (no exec on legacy/unsigned peers)
//!   - strategy.config_options."enable-remote-exec" = "Y"
//!   - no other in-flight exec for this peer

use crate::api::admin::i18n::{t, tf1, tf2, Lang};
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::api::strategy;
use crate::database::ExecRow;
use axum::extract::{Extension, Form, Path};
use axum::response::Html;
use serde::Deserialize;
use std::fmt::Write as _;
use std::sync::Arc;

const HISTORY_LIMIT: i64 = 20;
const MAX_SCRIPT_BYTES: usize = 32 * 1024;
/// Wall-clock cap, mirrored from heartbeat.rs::EXEC_MAX_SECS so the dispatch
/// confirm dialog can surface the same number. Kept duplicated rather than
/// shared as a pub const because the two values legitimately differ in
/// future (per-strategy override is a likely next step).
const UI_MAX_SECS: u64 = 300;
const UI_MAX_BYTES: u64 = 1024 * 1024;

#[derive(Debug, Deserialize)]
pub struct DispatchForm {
    pub script: String,
}

/// Main page (full content for `#main`). Renders the gate banner, the
/// script form (only when allowed), and the recent-history table.
pub async fn index(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    Ok(Html(render_page(&state, lang, &peer_id, None).await?))
}

/// Dispatch handler. Re-checks all gates server-side (the UI also gates
/// the form, but the form is just HTML — never trust the client). On
/// success: insert into exec_history with status='queued', return the
/// page with a success notice; the next heartbeat will flip it to
/// 'running' and the history row picks up auto-refresh.
pub async fn dispatch(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path(peer_id): Path<String>,
    Form(form): Form<DispatchForm>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let script = form.script.trim_end_matches(['\r', '\n']).to_string();

    // Validation runs BEFORE the gate checks so an empty script doesn't
    // get a confusing "managed required" error. Order: shape → policy.
    if script.trim().is_empty() {
        return Ok(Html(
            render_page(&state, lang, &peer_id, Some(("error", t(lang, "exec.error_empty").to_string()))).await?,
        ));
    }
    if script.len() > MAX_SCRIPT_BYTES {
        return Ok(Html(
            render_page(
                &state,
                lang,
                &peer_id,
                Some((
                    "error",
                    tf2(
                        lang,
                        "exec.error_too_large",
                        &script.len().to_string(),
                        &MAX_SCRIPT_BYTES.to_string(),
                    ),
                )),
            )
            .await?,
        ));
    }

    let gate = check_gate(&state, &peer_id).await?;
    if !gate.allowed {
        return Ok(Html(
            render_page(
                &state,
                lang,
                &peer_id,
                Some(("error", gate_reason_message(lang, &gate))),
            )
            .await?,
        ));
    }
    if gate.in_flight > 0 {
        return Ok(Html(
            render_page(
                &state,
                lang,
                &peer_id,
                Some(("error", t(lang, "exec.error_in_flight").to_string())),
            )
            .await?,
        ));
    }

    let cmd_id = uuid::Uuid::new_v4().to_string();
    state
        .db
        .exec_create(&cmd_id, &peer_id, admin.user_id, &script)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    hbb_common::log::info!(
        "admin {} queued exec cmd_id={} for peer {} ({} bytes)",
        admin.name,
        cmd_id,
        peer_id,
        script.len()
    );

    Ok(Html(
        render_page(
            &state,
            lang,
            &peer_id,
            Some(("ok", tf1(lang, "exec.queued", &cmd_id))),
        )
        .await?,
    ))
}

/// Single-row poll fragment. Returned by an HTMX `hx-get` whose
/// `hx-trigger` keeps firing until the row reaches a terminal state.
pub async fn poll(
    Extension(state): Extension<Arc<AppState>>,
    admin: AuthedUser,
    lang: Lang,
    Path((peer_id, cmd_id)): Path<(String, String)>,
) -> Result<Html<String>, ApiError> {
    require_admin(&admin)?;
    let row = state
        .db
        .exec_get_by_cmd_id(&cmd_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let row = match row {
        Some(r) if r.peer_id == peer_id => r,
        _ => {
            return Ok(Html(format!(
                r##"<tr><td colspan="5" class="px-3 py-2 text-rose-300 text-xs">{}</td></tr>"##,
                t(lang, "exec.not_found"),
            )));
        }
    };
    Ok(Html(render_history_row(lang, &row)))
}

// ───────────────────────── helpers ─────────────────────────

async fn check_gate(state: &Arc<AppState>, peer_id: &str) -> Result<GateCheck, ApiError> {
    let auth = state
        .db
        .peer_get_auth(peer_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    let managed = matches!(auth, Some((_, true)));
    let strategy_allows = strategy::allows_remote_exec(state, peer_id).await;
    let in_flight = state
        .db
        .exec_in_flight_count(peer_id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    Ok(GateCheck {
        allowed: managed && strategy_allows,
        managed,
        strategy_allows,
        in_flight,
    })
}

struct GateCheck {
    allowed: bool,
    managed: bool,
    strategy_allows: bool,
    in_flight: i64,
}

fn gate_reason_message(lang: Lang, g: &GateCheck) -> String {
    if !g.managed {
        return t(lang, "exec.reason_not_managed").to_string();
    }
    if !g.strategy_allows {
        return t(lang, "exec.reason_strategy").to_string();
    }
    t(lang, "exec.reason_unknown").to_string()
}

async fn render_page(
    state: &Arc<AppState>,
    lang: Lang,
    peer_id: &str,
    notice: Option<(&'static str, String)>,
) -> Result<String, ApiError> {
    let gate = check_gate(state, peer_id).await?;
    let history = state
        .db
        .exec_list_for_peer(peer_id, HISTORY_LIMIT)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;

    let mut out = String::new();
    let _ = write!(
        &mut out,
        r##"<div class="space-y-4">
  <header class="flex items-center justify-between">
    <h2 class="text-lg font-semibold">{heading} <code class="font-mono text-sky-300">{id}</code></h2>
    <button class="text-xs text-sky-300 hover:text-sky-200"
      hx-get="/admin/pages/devices/list-fragment"
      hx-target="#devices-region" hx-swap="innerHTML"
      hx-push-url="#devices">{back}</button>
  </header>"##,
        heading = t(lang, "exec.heading"),
        id = html_escape(peer_id),
        back = t(lang, "devices.back"),
    );

    if let Some((kind, msg)) = notice {
        let _ = write!(&mut out, "{}", notice_html(kind, &msg));
    }

    // Gate banner
    let _ = write!(&mut out, "{}", render_gate_banner(lang, &gate));

    // Script form — only rendered when gate is open
    if gate.allowed {
        let _ = write!(
            &mut out,
            r##"<form class="space-y-2" hx-post="/admin/pages/devices/{id}/exec"
      hx-target="#devices-region" hx-swap="innerHTML"
      hx-confirm="{confirm}">
    <label class="block text-xs text-slate-400">{label}</label>
    <textarea name="script" rows="8" required
      class="w-full font-mono text-sm rounded border border-slate-700 bg-slate-950 text-slate-200 p-2"
      placeholder="Get-Service hello-agent | Select-Object Status, Name"></textarea>
    <div class="flex items-center justify-between">
      <p class="text-xs text-slate-500">{caps}</p>
      <button type="submit" class="rounded bg-sky-700 hover:bg-sky-600 text-sky-100 text-xs px-3 py-1.5">{run}</button>
    </div>
  </form>"##,
            id = html_escape(peer_id),
            label = t(lang, "exec.script_label"),
            confirm = html_escape(&tf1(lang, "exec.confirm_dispatch", peer_id)),
            caps = html_escape(&tf2(
                lang,
                "exec.caps_note",
                &UI_MAX_SECS.to_string(),
                &(UI_MAX_BYTES / 1024 / 1024).to_string(),
            )),
            run = t(lang, "exec.run"),
        );
    }

    // History table
    let _ = write!(
        &mut out,
        r##"<section>
    <h3 class="text-sm font-semibold text-slate-300 mb-2">{hist}</h3>
    <div class="rounded-md border border-slate-800 bg-slate-900">
      <table class="w-full text-sm">
        <thead class="text-xs uppercase text-slate-500 bg-slate-950">
          <tr>
            <th class="text-left font-medium px-3 py-2">{c_when}</th>
            <th class="text-left font-medium px-3 py-2">{c_who}</th>
            <th class="text-left font-medium px-3 py-2">{c_status}</th>
            <th class="text-left font-medium px-3 py-2">{c_script}</th>
            <th class="text-left font-medium px-3 py-2">{c_output}</th>
          </tr>
        </thead>
        <tbody class="divide-y divide-slate-800">"##,
        hist = t(lang, "exec.history"),
        c_when = t(lang, "exec.col_when"),
        c_who = t(lang, "exec.col_who"),
        c_status = t(lang, "exec.col_status"),
        c_script = t(lang, "exec.col_script"),
        c_output = t(lang, "exec.col_output"),
    );
    if history.is_empty() {
        let _ = write!(
            &mut out,
            r##"<tr><td colspan="5" class="px-3 py-3 text-center text-xs text-slate-500">{}</td></tr>"##,
            t(lang, "exec.no_history"),
        );
    } else {
        for row in &history {
            out.push_str(&render_history_row(lang, row));
        }
    }
    out.push_str(r##"      </tbody>
      </table>
    </div>
  </section>
</div>"##);
    Ok(out)
}

fn render_gate_banner(lang: Lang, g: &GateCheck) -> String {
    let (kind, msg) = if g.allowed {
        ("ok", t(lang, "exec.gate_open").to_string())
    } else if !g.managed {
        ("error", t(lang, "exec.reason_not_managed").to_string())
    } else if !g.strategy_allows {
        ("error", t(lang, "exec.reason_strategy").to_string())
    } else {
        ("error", t(lang, "exec.reason_unknown").to_string())
    };
    notice_html(kind, &msg)
}

fn render_history_row(lang: Lang, r: &ExecRow) -> String {
    // Auto-refresh while the row is non-terminal so the operator sees
    // running → finished without manual reload. HTMX `hx-trigger` with
    // `every 1s` fires until the SERVER emits a row missing the trigger
    // (i.e. the row reached finished/timed_out/errored).
    let row_id = format!("exec-row-{}", html_escape(&r.cmd_id));
    let polling_attrs = if matches!(r.status.as_str(), "queued" | "running") {
        format!(
            r##"hx-get="/admin/pages/devices/{peer}/exec/{cmd}/poll" hx-trigger="load delay:1s" hx-target="this" hx-swap="outerHTML""##,
            peer = html_escape(&r.peer_id),
            cmd = html_escape(&r.cmd_id),
        )
    } else {
        String::new()
    };
    let status_cell = render_status_badge(lang, r);
    let when = fmt_unix(r.issued_at);
    let script_preview = preview(&r.script, 80);
    let output_block = render_output_block(lang, r);

    format!(
        r##"<tr id="{row_id}" {polling}>
    <td class="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{when}</td>
    <td class="px-3 py-2 text-xs text-slate-400">#{user}</td>
    <td class="px-3 py-2 whitespace-nowrap">{status}</td>
    <td class="px-3 py-2"><code class="font-mono text-xs text-slate-300">{script}</code></td>
    <td class="px-3 py-2">{output}</td>
  </tr>"##,
        row_id = row_id,
        polling = polling_attrs,
        when = html_escape(&when),
        user = r.issued_by_user_id,
        status = status_cell,
        script = html_escape(&script_preview),
        output = output_block,
    )
}

fn render_status_badge(lang: Lang, r: &ExecRow) -> String {
    let (border, bg, text_color, label) = match r.status.as_str() {
        "queued" => ("slate-700", "slate-800/40", "slate-300", t(lang, "exec.status_queued")),
        "running" => ("amber-700/50", "amber-900/30", "amber-300", t(lang, "exec.status_running")),
        "finished" => {
            if r.exit_code == Some(0) {
                ("emerald-700/50", "emerald-900/30", "emerald-300", t(lang, "exec.status_finished_ok"))
            } else {
                ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_finished_err"))
            }
        }
        "timed_out" => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_timed_out")),
        _ => ("rose-700/50", "rose-900/30", "rose-300", t(lang, "exec.status_errored")),
    };
    let exit_suffix = match (r.status.as_str(), r.exit_code) {
        ("finished", Some(c)) => format!(" (exit {c})"),
        _ => String::new(),
    };
    format!(
        r##"<span class="inline-flex items-center gap-1 rounded border border-{b} bg-{bg} px-2 py-0.5 text-xs text-{t}">{label}{exit}</span>"##,
        b = border,
        bg = bg,
        t = text_color,
        label = html_escape(label),
        exit = exit_suffix,
    )
}

fn render_output_block(lang: Lang, r: &ExecRow) -> String {
    if matches!(r.status.as_str(), "queued" | "running") {
        return format!(
            r##"<span class="text-xs text-slate-500">{}</span>"##,
            t(lang, "exec.output_pending"),
        );
    }
    let mut s = String::new();
    if !r.stdout.is_empty() {
        let _ = write!(
            &mut s,
            r##"<details class="text-xs"><summary class="cursor-pointer text-slate-400 hover:text-slate-200">stdout ({n} bytes)</summary>
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-slate-300 whitespace-pre-wrap">{out}</pre>
</details>"##,
            n = r.stdout.len(),
            out = html_escape(&r.stdout),
        );
    }
    if !r.stderr.is_empty() {
        let _ = write!(
            &mut s,
            r##"<details class="text-xs mt-1"><summary class="cursor-pointer text-rose-400 hover:text-rose-300">stderr ({n} bytes)</summary>
<pre class="mt-1 max-h-80 overflow-auto rounded bg-slate-950 border border-slate-800 p-2 font-mono text-rose-300 whitespace-pre-wrap">{out}</pre>
</details>"##,
            n = r.stderr.len(),
            out = html_escape(&r.stderr),
        );
    }
    if r.truncated {
        let _ = write!(
            &mut s,
            r##"<p class="text-xs text-amber-300 mt-1">{}</p>"##,
            t(lang, "exec.output_truncated"),
        );
    }
    if s.is_empty() {
        s = format!(r##"<span class="text-xs text-slate-500">{}</span>"##, t(lang, "exec.output_empty"));
    }
    s
}

fn fmt_unix(ts: i64) -> String {
    use chrono::TimeZone;
    chrono::Utc
        .timestamp_opt(ts, 0)
        .single()
        .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
        .unwrap_or_else(|| "—".to_string())
}

fn preview(s: &str, max: usize) -> String {
    let first_line = s.lines().next().unwrap_or("");
    if first_line.len() <= max {
        first_line.to_string()
    } else {
        format!("{}…", &first_line[..max])
    }
}

fn notice_html(kind: &str, msg: &str) -> String {
    let (border, bg, text) = match kind {
        "ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
        _ => ("rose-700/50", "rose-900/30", "rose-300"),
    };
    format!(
        r##"<div class="rounded border border-{border} bg-{bg} p-3 text-sm text-{text}">{msg}</div>"##,
        border = border,
        bg = bg,
        text = text,
        msg = html_escape(msg),
    )
}

fn html_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
    if u.is_admin {
        Ok(())
    } else {
        Err(ApiError::Forbidden("admin required".into()))
    }
}
