use std::{
    net::SocketAddr,
    path::PathBuf,
    sync::{Arc, OnceLock, RwLock},
    thread,
    time::{Duration, Instant},
};

use ab_glyph::{FontArc, PxScale};
use anyhow::{Context, Result};
use asterctl::img;
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
use axum::{
    Json, Router,
    body::Body,
    extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
    http::{HeaderValue, StatusCode, header},
    response::{Html, IntoResponse, Response},
    routing::{get, post},
};
use chrono::{Local, Utc};
use clap::Parser;
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType};
use imageproc::{
    drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
    rect::Rect,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sysinfo::{Components, Disks, System};
use tokio::fs;
use tracing::{error, info, warn};

const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376;
const SYSTEM_FRAME_NAME: &str = "System Specs";
const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Cli {
    #[arg(long, default_value = "0.0.0.0:8080")]
    bind: String,
    #[arg(long, default_value = "/config")]
    config_dir: PathBuf,
    #[arg(long)]
    device: Option<String>,
    #[arg(long)]
    usb: Option<String>,
    #[arg(long)]
    simulate: bool,
    #[arg(long)]
    write_only: bool,
    #[arg(long)]
    disable_display: bool,
}

#[derive(Clone)]
struct AppState {
    monitor_path: PathBuf,
    image_dir: PathBuf,
    display_status: Arc<RwLock<DisplayStatus>>,
}

#[derive(Clone)]
struct DisplayConfig {
    device: Option<String>,
    usb: Option<String>,
    simulate: bool,
    write_only: bool,
    native_enabled: bool,
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct RotationSnapshot {
    custom_panel: bool,
    switch_time: u32,
    specs_enabled: bool,
    memes_enabled: bool,
    active_images: Vec<String>,
}

impl RotationSnapshot {
    fn rotation_active(&self) -> bool {
        self.custom_panel && !self.frames().is_empty()
    }

    fn frames(&self) -> Vec<RotationFrame> {
        let mut frames = Vec::new();
        if self.memes_enabled {
            frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image));
        }
        if frames.is_empty() && self.specs_enabled {
            frames.push(RotationFrame::SystemSpecs);
        }
        frames
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
enum RotationFrame {
    SystemSpecs,
    Image(String),
}

#[derive(Clone, Serialize)]
struct StateResponse {
    monitor_path: String,
    image_dir: String,
    setup: SetupView,
    active_images: Vec<String>,
    images: Vec<ImageView>,
    display: DisplayStatus,
    system: SystemView,
}

#[derive(Clone, Serialize)]
struct SetupView {
    custom_panel: bool,
    switch_time: String,
    specs_enabled: bool,
    memes_enabled: bool,
}

#[derive(Clone, Serialize)]
struct ImageView {
    name: String,
    size: u64,
    url: String,
}

#[derive(Clone, Debug, Default, Serialize)]
struct DisplayStatus {
    native_enabled: bool,
    connected: bool,
    mode: String,
    device: String,
    custom_panel: bool,
    specs_enabled: bool,
    memes_enabled: bool,
    rotation_active: bool,
    switch_time: String,
    active_images: Vec<String>,
    current_image: Option<String>,
    last_error: Option<String>,
    updated_at: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
struct SystemView {
    cpu_usage_percent: String,
    load_avg_one: String,
    load_avg_five: String,
    load_avg_fifteen: String,
    mem_usage_percent: String,
    mem_used: String,
    mem_total: String,
    swap_usage_percent: String,
    swap_used: String,
    swap_total: String,
    disk_usage_percent: String,
    disk_used: String,
    disk_total: String,
    cache_usage_percent: String,
    cache_used: String,
    cache_total: String,
    user_usage_percent: String,
    user_used: String,
    user_total: String,
    cpu_count: usize,
    process_count: usize,
    uptime: String,
    temperature_cpu: Option<String>,
    temperature_gpu: Option<String>,
}

#[derive(Deserialize)]
struct ActivateRequest {
    images: Vec<String>,
    switch_time: Option<u32>,
    specs_enabled: Option<bool>,
    memes_enabled: Option<bool>,
}

#[derive(Deserialize)]
struct DeleteRequest {
    name: String,
}

#[derive(Serialize)]
struct ErrorResponse {
    error: String,
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "info,aster_webui=debug".into()),
        )
        .init();

    let cli = Cli::parse();
    let addr: SocketAddr = cli
        .bind
        .parse()
        .with_context(|| format!("Invalid bind address: {}", cli.bind))?;

    let display_config = DisplayConfig {
        device: cli.device,
        usb: cli.usb,
        simulate: cli.simulate,
        write_only: cli.write_only,
        native_enabled: !cli.disable_display,
    };

    let state = Arc::new(AppState {
        monitor_path: cli.config_dir.join("Monitor3.json"),
        image_dir: cli.config_dir.join("img"),
        display_status: Arc::new(RwLock::new(initial_display_status(&display_config))),
    });

    ensure_layout(&state).await?;
    spawn_display_worker(state.clone(), display_config);

    let app = Router::new()
        .route("/", get(index))
        .route("/healthz", get(healthz))
        .route("/api/state", get(api_state))
        .route("/api/upload", post(api_upload))
        .route("/api/panels/activate", post(api_activate))
        .route("/api/panels/disable", post(api_disable))
        .route("/api/images/delete", post(api_delete))
        .route("/api/assets/nyan-cat.gif", get(api_nyan_cat))
        .route("/api/images/{name}", get(api_image))
        .layer(DefaultBodyLimit::max(64 * 1024 * 1024))
        .with_state(state);

    info!("Starting aster-webui on {addr}");
    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
    let mut status = DisplayStatus {
        native_enabled: config.native_enabled,
        connected: false,
        mode: display_mode(config),
        device: display_target(config),
        custom_panel: false,
        specs_enabled: false,
        memes_enabled: false,
        rotation_active: false,
        switch_time: "10".into(),
        active_images: Vec::new(),
        current_image: None,
        last_error: None,
        updated_at: Some(stamp_now()),
    };

    if !config.native_enabled {
        status.last_error = Some("Native display loop disabled via --disable-display".into());
    }

    status
}

async fn ensure_layout(state: &AppState) -> Result<()> {
    fs::create_dir_all(&state.image_dir).await?;

    if fs::try_exists(&state.monitor_path).await? {
        return Ok(());
    }

    let default = json!({
        "credentials": {
            "username": "admin",
            "password": "123456"
        },
        "setup": {
            "type": 1,
            "offDisplay": true,
            "controlParams": true,
            "controlDiskTemp": true,
            "customPanel": false,
            "language": 1,
            "switchTime": "10",
            "nativeSpecs": false,
            "nativeMemes": true,
            "operationMode": 0,
            "theme": 1,
            "diskUpdate": 300,
            "ha_url": "",
            "ha_token": "",
            "refresh": 1
        },
        "mianban": [],
        "diy": []
    });

    save_monitor_json(state, &default).await
}

async fn load_monitor_json(state: &AppState) -> Result<Value> {
    let raw = fs::read_to_string(&state.monitor_path)
        .await
        .with_context(|| format!("Failed to read {:?}", state.monitor_path))?;
    serde_json::from_str(&raw).context("Failed to parse Monitor3.json")
}

async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> {
    let payload = serde_json::to_string_pretty(value)?;
    let tmp_path = temp_monitor_path(&state.monitor_path);

    fs::write(&tmp_path, payload)
        .await
        .with_context(|| format!("Failed to write {:?}", tmp_path))?;
    fs::rename(&tmp_path, &state.monitor_path)
        .await
        .with_context(|| format!("Failed to replace {:?}", state.monitor_path))
}

fn temp_monitor_path(path: &PathBuf) -> PathBuf {
    let file_name = path
        .file_name()
        .map(|name| format!("{}.tmp", name.to_string_lossy()))
        .unwrap_or_else(|| "Monitor3.json.tmp".into());
    path.with_file_name(file_name)
}

fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
    let body = Json(ErrorResponse {
        error: message.into(),
    });
    (status, body).into_response()
}

async fn index() -> Response {
    (
        [
            (header::CACHE_CONTROL, "no-store, no-cache, must-revalidate, max-age=0"),
            (header::PRAGMA, "no-cache"),
            (header::EXPIRES, "0"),
        ],
        Html(INDEX_HTML),
    )
        .into_response()
}

async fn healthz(State(state): State<Arc<AppState>>) -> Json<Value> {
    let display = read_display_status(&state.display_status);
    Json(json!({
        "ok": true,
        "nativeDisplay": display.native_enabled,
        "displayConnected": display.connected,
    }))
}

async fn api_state(State(state): State<Arc<AppState>>) -> Response {
    match build_state_response(&state).await {
        Ok(payload) => Json(payload).into_response(),
        Err(err) => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    }
}

async fn api_upload(State(state): State<Arc<AppState>>, mut multipart: Multipart) -> Response {
    let mut stored = None;

    while let Ok(Some(field)) = multipart.next_field().await {
        if field.name() != Some("file") {
            continue;
        }

        let file_name = field
            .file_name()
            .map(ToString::to_string)
            .unwrap_or_else(|| "panel".into());
        let bytes = match field.bytes().await {
            Ok(bytes) => bytes,
            Err(err) => {
                return error_response(StatusCode::BAD_REQUEST, format!("Upload failed: {err}"));
            }
        };

        match convert_and_store_image(&state, &file_name, &bytes).await {
            Ok(name) => stored = Some(name),
            Err(err) => return error_response(StatusCode::BAD_REQUEST, err.to_string()),
        }
    }

    match stored {
        Some(name) => Json(json!({ "ok": true, "name": name })).into_response(),
        None => error_response(StatusCode::BAD_REQUEST, "No file field provided"),
    }
}

async fn api_activate(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<ActivateRequest>,
) -> Response {
    let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600);
    let specs_enabled = payload.specs_enabled.unwrap_or(false);
    let available = match list_images(&state).await {
        Ok(items) => items,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    let available_names: Vec<String> = available.into_iter().map(|item| item.name).collect();
    let mut valid = Vec::new();
    for name in payload.images {
        if available_names.iter().any(|candidate| candidate == &name) && !valid.contains(&name) {
            valid.push(name);
        }
    }

    let memes_enabled = payload
        .memes_enabled
        .unwrap_or(!valid.is_empty());

    if !specs_enabled && (!memes_enabled || valid.is_empty()) {
        return error_response(
            StatusCode::BAD_REQUEST,
            "Enable specs or select at least one meme image",
        );
    }

    let mut monitor = match load_monitor_json(&state).await {
        Ok(value) => value,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &valid);

    if let Err(err) = save_monitor_json(&state, &monitor).await {
        return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
    }

    Json(json!({
        "ok": true,
        "switchTime": switch_time,
        "specsEnabled": specs_enabled,
        "memesEnabled": memes_enabled,
        "activeImages": valid,
    }))
    .into_response()
}

async fn api_disable(State(state): State<Arc<AppState>>) -> Response {
    let mut monitor = match load_monitor_json(&state).await {
        Ok(value) => value,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };
    let switch_time = current_switch_time(&monitor);
    let active_images = current_active_images(&monitor);

    set_custom_panels(&mut monitor, switch_time, false, false, &active_images);

    if let Err(err) = save_monitor_json(&state, &monitor).await {
        return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
    }

    Json(json!({ "ok": true })).into_response()
}

async fn api_delete(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<DeleteRequest>,
) -> Response {
    if payload.name.contains('/') || payload.name.contains('\\') {
        return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
    }

    let path = state.image_dir.join(&payload.name);
    match fs::remove_file(&path).await {
        Ok(_) => {}
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return error_response(StatusCode::NOT_FOUND, "Image not found");
        }
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    }

    let mut monitor = match load_monitor_json(&state).await {
        Ok(value) => value,
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    let mut active_images = current_active_images(&monitor);
    let switch_time = current_switch_time(&monitor);
    let specs_enabled = current_specs_enabled(&monitor);
    let memes_enabled = current_memes_enabled(&monitor);
    let before = active_images.len();
    active_images.retain(|name| name != &payload.name);
    if active_images.len() != before {
        set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images);
        if let Err(err) = save_monitor_json(&state, &monitor).await {
            return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
        }
    }

    Json(json!({ "ok": true })).into_response()
}

async fn api_image(
    AxumPath(name): AxumPath<String>,
    State(state): State<Arc<AppState>>,
) -> Response {
    if name.contains('/') || name.contains('\\') {
        return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
    }

    let path = state.image_dir.join(&name);
    let bytes = match fs::read(&path).await {
        Ok(bytes) => bytes,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return error_response(StatusCode::NOT_FOUND, "Image not found");
        }
        Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
    };

    let mime = mime_guess::from_path(&path).first_or_octet_stream();
    let mut response = Response::new(Body::from(bytes));
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_str(mime.as_ref())
            .unwrap_or(HeaderValue::from_static("application/octet-stream")),
    );
    response
}

async fn api_nyan_cat() -> Response {
    let mut response = Response::new(Body::from(NYAN_CAT_GIF));
    response.headers_mut().insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("image/gif"),
    );
    response.headers_mut().insert(
        header::CACHE_CONTROL,
        HeaderValue::from_static("public, max-age=86400"),
    );
    response
}

async fn build_state_response(state: &AppState) -> Result<StateResponse> {
    let monitor = load_monitor_json(state).await?;
    let setup = monitor
        .get("setup")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();

    let custom_panel = setup
        .get("customPanel")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let switch_time = setup
        .get("switchTime")
        .and_then(Value::as_str)
        .unwrap_or("10")
        .to_string();
    let specs_enabled = current_specs_enabled(&monitor);
    let memes_enabled = current_memes_enabled(&monitor);

    Ok(StateResponse {
        monitor_path: state.monitor_path.display().to_string(),
        image_dir: state.image_dir.display().to_string(),
        setup: SetupView {
            custom_panel,
            switch_time,
            specs_enabled,
            memes_enabled,
        },
        active_images: current_active_images(&monitor),
        images: list_images(state).await?,
        display: read_display_status(&state.display_status),
        system: collect_system_view(),
    })
}

fn collect_system_view() -> SystemView {
    fn mount_usage(disks: &Disks, mount_point: &str) -> (String, String, String) {
        disks
            .iter()
            .find(|disk| disk.mount_point() == std::path::Path::new(mount_point))
            .map(|disk| {
                let total = disk.total_space();
                let used = total.saturating_sub(disk.available_space());
                let usage_percent = if total == 0 {
                    0.0
                } else {
                    used as f64 / total as f64 * 100.0
                };
                (
                    format!("{usage_percent:.0}"),
                    format_bytes(used),
                    format_bytes(total),
                )
            })
            .unwrap_or_else(|| ("n/a".into(), "n/a".into(), "n/a".into()))
    }

    let mut sys = System::new_all();
    sys.refresh_all();

    let load_avg = System::load_average();
    let total_memory = sys.total_memory();
    let used_memory = sys.used_memory();
    let total_swap = sys.total_swap();
    let used_swap = sys.used_swap();

    let mut disks = Disks::new();
    disks.refresh(false);
    let disk_total: u64 = disks.iter().map(|disk| disk.total_space()).sum();
    let disk_used: u64 = disks
        .iter()
        .map(|disk| disk.total_space().saturating_sub(disk.available_space()))
        .sum();

    let mut components = Components::new();
    components.refresh(false);

    let (cache_usage_percent, cache_used, cache_total) = mount_usage(&disks, "/mnt/cache");
    let (user_usage_percent, user_used, user_total) = mount_usage(&disks, "/mnt/user");

    SystemView {
        cache_usage_percent,
        cache_used,
        cache_total,
        user_usage_percent,
        user_used,
        user_total,
        cpu_usage_percent: format!("{:.1}", sys.global_cpu_usage()),
        load_avg_one: format!("{:.2}", load_avg.one),
        load_avg_five: format!("{:.2}", load_avg.five),
        load_avg_fifteen: format!("{:.2}", load_avg.fifteen),
        mem_usage_percent: format!("{:.1}", percentage(used_memory, total_memory)),
        mem_used: format_bytes(used_memory),
        mem_total: format_bytes(total_memory),
        swap_usage_percent: format!("{:.1}", percentage(used_swap, total_swap)),
        swap_used: format_bytes(used_swap),
        swap_total: format_bytes(total_swap),
        disk_usage_percent: format!("{:.1}", percentage(disk_used, disk_total)),
        disk_used: format_bytes(disk_used),
        disk_total: format_bytes(disk_total),
        cpu_count: sys.cpus().len(),
        process_count: sys.processes().len(),
        uptime: format_uptime(System::uptime()),
        temperature_cpu: component_temperature(&components, "Tctl"),
        temperature_gpu: component_temperature(&components, "amdgpu"),
    }
}

fn component_temperature(components: &Components, needle: &str) -> Option<String> {
    components
        .iter()
        .find(|component| component.label().contains(needle))
        .and_then(|component| component.temperature())
        .map(|value| format!("{value:.1} °C"))
}

fn percentage(used: u64, total: u64) -> f64 {
    if total == 0 {
        0.0
    } else {
        used as f64 * 100.0 / total as f64
    }
}

fn format_uptime(seconds: u64) -> String {
    let days = seconds / 86_400;
    let hours = (seconds % 86_400) / 3_600;
    let minutes = (seconds % 3_600) / 60;

    if days == 0 {
        format!("{hours:02}:{minutes:02}")
    } else if days == 1 {
        format!("1 day, {hours:02}:{minutes:02}")
    } else {
        format!("{days} days, {hours:02}:{minutes:02}")
    }
}

fn format_bytes(bytes: u64) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
    const THRESHOLD: f64 = 1024.0;

    if bytes == 0 {
        return "0 B".to_string();
    }

    let mut size = bytes as f64;
    let mut unit_index = 0;

    while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
        size /= THRESHOLD;
        unit_index += 1;
    }

    if unit_index > 0 {
        format!("{size:.2} {}", UNITS[unit_index])
    } else {
        format!("{} {}", size, UNITS[unit_index])
    }
}

async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
    let mut out = Vec::new();
    let mut dir = fs::read_dir(&state.image_dir).await?;
    while let Some(entry) = dir.next_entry().await? {
        let path = entry.path();
        let file_type = entry.file_type().await?;
        if !file_type.is_file() {
            continue;
        }
        let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
            continue;
        };
        let meta = entry.metadata().await?;
        out.push(ImageView {
            url: format!("/api/images/{name}"),
            name,
            size: meta.len(),
        });
    }
    out.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(out)
}

fn current_active_images(monitor: &Value) -> Vec<String> {
    monitor
        .get("diy")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|panel| panel.get("img").and_then(Value::as_str))
        .map(ToString::to_string)
        .collect()
}

fn current_switch_time(monitor: &Value) -> u32 {
    monitor
        .get("setup")
        .and_then(Value::as_object)
        .and_then(|setup| setup.get("switchTime"))
        .and_then(Value::as_str)
        .and_then(|value| value.parse::<u32>().ok())
        .filter(|value| (1..=600).contains(value))
        .unwrap_or(10)
}

fn current_custom_panel_enabled(monitor: &Value) -> bool {
    monitor
        .get("setup")
        .and_then(Value::as_object)
        .and_then(|setup| setup.get("customPanel"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn current_specs_enabled(monitor: &Value) -> bool {
    monitor
        .get("setup")
        .and_then(Value::as_object)
        .and_then(|setup| setup.get("nativeSpecs"))
        .and_then(Value::as_bool)
        .unwrap_or(false)
}

fn current_memes_enabled(monitor: &Value) -> bool {
    monitor
        .get("setup")
        .and_then(Value::as_object)
        .and_then(|setup| setup.get("nativeMemes"))
        .and_then(Value::as_bool)
        .unwrap_or_else(|| !current_active_images(monitor).is_empty())
}

fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
    RotationSnapshot {
        custom_panel: current_custom_panel_enabled(monitor),
        switch_time: current_switch_time(monitor),
        specs_enabled: current_specs_enabled(monitor),
        memes_enabled: current_memes_enabled(monitor),
        active_images: current_active_images(monitor),
    }
}

fn set_custom_panels(
    monitor: &mut Value,
    switch_time: u32,
    specs_enabled: bool,
    memes_enabled: bool,
    images: &[String],
) {
    let enabled = specs_enabled || (memes_enabled && !images.is_empty());
    let setup = monitor
        .as_object_mut()
        .expect("monitor config must be an object")
        .entry("setup")
        .or_insert_with(|| Value::Object(Default::default()));

    if let Some(setup) = setup.as_object_mut() {
        setup.insert("customPanel".into(), Value::Bool(enabled));
        setup.insert("switchTime".into(), Value::String(switch_time.to_string()));
        setup.insert("nativeSpecs".into(), Value::Bool(specs_enabled));
        setup.insert("nativeMemes".into(), Value::Bool(memes_enabled));
    }

    monitor["mianban"] = Value::Array(
        (1..=images.len())
            .map(|index| Value::Number((index as u64).into()))
            .collect(),
    );
    monitor["diy"] = Value::Array(
        images
            .iter()
            .map(|name| json!({"type": 5, "img": name, "sensor": []}))
            .collect(),
    );
}

async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
    let image = image::load_from_memory(bytes)
        .with_context(|| format!("Unsupported image format: {file_name}"))?;
    let target_name = make_image_name(file_name);
    let path = state.image_dir.join(&target_name);
    let rendered = render_display_panel(&image);
    rendered
        .save_with_format(&path, ImageFormat::Jpeg)
        .with_context(|| format!("Failed to save {}", path.display()))?;
    Ok(target_name)
}

fn render_display_panel(image: &DynamicImage) -> RgbImage {
    let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
    let rgb = resized.to_rgb8();

    let mut canvas = RgbImage::from_pixel(
        DISPLAY_WIDTH,
        DISPLAY_HEIGHT,
        image::Rgb([8, 10, 12]),
    );

    let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64;
    let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64;
    image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y);
    canvas
}

fn render_system_panel() -> RgbImage {
    let system = collect_system_view();
    let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, Rgb([10, 14, 18]));
    let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
    let timestamp = Local::now().format("%d.%m.%Y %H:%M:%S").to_string();

    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(0, 0).of_size(DISPLAY_WIDTH, 84),
        Rgb([18, 27, 36]),
    );
    draw_filled_rect_mut(
        &mut canvas,
        Rect::at(0, 84).of_size(DISPLAY_WIDTH, DISPLAY_HEIGHT - 84),
        Rgb([8, 11, 15]),
    );

    draw_text_line(
        &mut canvas,
        Rgb([216, 253, 114]),
        28,
        20,
        36.0,
        "AOOSTAR Native Specs",
    );
    draw_text_line(
        &mut canvas,
        Rgb([146, 163, 181]),
        30,
        58,
        20.0,
        &format!("{host_name}  |  {timestamp}"),
    );

    let cards = [
        (
            24,
            106,
            "CPU",
            format!("{} %", system.cpu_usage_percent),
            format!(
                "Load {} / {} / {}",
                system.load_avg_one, system.load_avg_five, system.load_avg_fifteen
            ),
            Rgb([124, 231, 191]),
        ),
        (
            332,
            106,
            "Memory",
            format!("{} %", system.mem_usage_percent),
            format!("{} / {}", system.mem_used, system.mem_total),
            Rgb([125, 196, 255]),
        ),
        (
            640,
            106,
            "Storage",
            format!("C {}%  U {}%", system.cache_usage_percent, system.user_usage_percent),
            format!(
                "/mnt/cache {} / {}  |  /mnt/user {} / {}",
                system.cache_used, system.cache_total, system.user_used, system.user_total
            ),
            Rgb([255, 199, 115]),
        ),
        (
            24,
            236,
            "Swap",
            format!("{} %", system.swap_usage_percent),
            format!("{} / {}", system.swap_used, system.swap_total),
            Rgb([194, 167, 255]),
        ),
        (
            332,
            236,
            "Temperatures",
            format!("CPU {}", system.temperature_cpu.as_deref().unwrap_or("-")),
            format!("GPU {}", system.temperature_gpu.as_deref().unwrap_or("-")),
            Rgb([255, 127, 115]),
        ),
        (
            640,
            236,
            "System",
            system.uptime.clone(),
            format!("{} CPU / {} proc", system.cpu_count, system.process_count),
            Rgb([216, 253, 114]),
        ),
    ];

    for (x, y, title, value, meta, accent) in cards {
        draw_metric_card(&mut canvas, x, y, title, &value, &meta, accent);
    }

    canvas
}

fn overlay_system_specs(canvas: &mut RgbImage) {
    let system = collect_system_view();
    let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
    let timestamp = Local::now().format("%H:%M:%S").to_string();

    draw_filled_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([12, 18, 24]));
    draw_hollow_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([36, 50, 67]));
    draw_text_line(
        canvas,
        Rgb([216, 253, 114]),
        34,
        28,
        22.0,
        &format!(
            "{host_name}  CPU {}%  RAM {}%  C {}%  U {}%",
            system.cpu_usage_percent,
            system.mem_usage_percent,
            system.cache_usage_percent,
            system.user_usage_percent
        ),
    );
    draw_text_line(
        canvas,
        Rgb([146, 163, 181]),
        34,
        52,
        16.0,
        &format!(
            "Load {} / {} / {}  Temp {} / {}  Uptime {}  {}",
            system.load_avg_one,
            system.load_avg_five,
            system.load_avg_fifteen,
            system.temperature_cpu.as_deref().unwrap_or("-"),
            system.temperature_gpu.as_deref().unwrap_or("-"),
            system.uptime,
            timestamp
        ),
    );

    draw_filled_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([12, 18, 24]));
    draw_hollow_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([36, 50, 67]));
    draw_text_line(
        canvas,
        Rgb([237, 242, 247]),
        34,
        320,
        18.0,
        &format!(
            "Cache {} / {}  User {} / {}  Proc {}  CPU {}  GPU {}",
            system.cache_used,
            system.cache_total,
            system.user_used,
            system.user_total,
            system.process_count,
            system.temperature_cpu.as_deref().unwrap_or("-"),
            system.temperature_gpu.as_deref().unwrap_or("-"),
        ),
    );
}

fn draw_metric_card(
    canvas: &mut RgbImage,
    x: i32,
    y: i32,
    title: &str,
    value: &str,
    meta: &str,
    accent: Rgb<u8>,
) {
    draw_filled_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([16, 22, 30]));
    draw_hollow_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([36, 50, 67]));
    draw_filled_rect_mut(canvas, Rect::at(x + 1, y + 1).of_size(6, 110), accent);

    draw_text_line(canvas, accent, x + 20, y + 16, 19.0, title);
    draw_text_line(canvas, Rgb([237, 242, 247]), x + 20, y + 44, 28.0, value);
    draw_text_line(canvas, Rgb([146, 163, 181]), x + 20, y + 82, 18.0, meta);
}

fn draw_text_line(
    canvas: &mut RgbImage,
    color: Rgb<u8>,
    x: i32,
    y: i32,
    size: f32,
    text: &str,
) {
    draw_text_mut(
        canvas,
        color,
        x,
        y,
        PxScale { x: size, y: size },
        display_font(),
        text,
    );
}

fn display_font() -> &'static FontArc {
    static FONT: OnceLock<FontArc> = OnceLock::new();
    FONT.get_or_init(|| {
        FontArc::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf"))
            .expect("embedded display font must be valid")
    })
}

fn make_image_name(file_name: &str) -> String {
    let base = file_name
        .rsplit_once('.')
        .map(|(name, _)| name)
        .unwrap_or(file_name);
    let clean: String = base
        .chars()
        .map(|ch| match ch {
            'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
            _ => '-',
        })
        .collect();
    let clean = clean.trim_matches('-');
    let clean = if clean.is_empty() { "panel" } else { clean };
    let timestamp = Local::now().format("%Y%m%d-%H%M%S");
    format!("panel-{timestamp}-{clean}.jpg")
}

fn spawn_display_worker(state: Arc<AppState>, config: DisplayConfig) {
    thread::spawn(move || run_display_worker(state, config));
}

fn run_display_worker(state: Arc<AppState>, config: DisplayConfig) {
    if !config.native_enabled {
        info!("Native display loop disabled");
        return;
    }

    loop {
        match open_screen(&config) {
            Ok(mut screen) => {
                info!("Display target opened: {}", display_target(&config));
                if let Err(err) = screen.init() {
                    warn!("Display init failed: {err}");
                    set_display_error(
                        &state.display_status,
                        false,
                        format!("Display init failed: {err}"),
                    );
                    thread::sleep(Duration::from_secs(3));
                    continue;
                }

                update_display_status(&state.display_status, |status| {
                    status.connected = true;
                    status.last_error = None;
                    status.updated_at = Some(stamp_now());
                });

                if let Err(err) = run_display_session(&state, &mut screen) {
                    error!("Display loop error: {err}");
                    set_display_error(
                        &state.display_status,
                        false,
                        format!("Display loop error: {err}"),
                    );
                }
            }
            Err(err) => {
                warn!("Failed to open display target: {err}");
                set_display_error(
                    &state.display_status,
                    false,
                    format!("Failed to open display target: {err}"),
                );
            }
        }

        thread::sleep(Duration::from_secs(3));
    }
}

fn open_screen(config: &DisplayConfig) -> Result<AooScreen> {
    let mut builder = AooScreenBuilder::new();
    builder.no_init_check(config.write_only);

    if config.simulate {
        builder.simulate()
    } else if let Some(device) = &config.device {
        builder.open_device(device)
    } else if let Some(usb) = &config.usb {
        builder.open_usb_id(usb)
    } else {
        builder.open_default()
    }
}

fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
    let mut snapshot = RotationSnapshot::default();
    let mut cycle_started_at = Instant::now();
    let mut current_frame_index = None;

    loop {
        let monitor = match load_monitor_json_sync(&state.monitor_path) {
            Ok(monitor) => monitor,
            Err(err) => {
                update_display_status(&state.display_status, |status| {
                    status.last_error = Some(format!("Config read failed: {err}"));
                    status.connected = true;
                    status.updated_at = Some(stamp_now());
                });
                thread::sleep(Duration::from_secs(1));
                continue;
            }
        };

        let new_snapshot = rotation_snapshot(&monitor);
        if new_snapshot != snapshot {
            snapshot = new_snapshot;
            cycle_started_at = Instant::now();
            current_frame_index = None;
        }

        let frames = snapshot.frames();

        update_display_status(&state.display_status, |status| {
            status.custom_panel = snapshot.custom_panel;
            status.specs_enabled = snapshot.specs_enabled;
            status.memes_enabled = snapshot.memes_enabled;
            status.rotation_active = snapshot.rotation_active();
            status.switch_time = snapshot.switch_time.to_string();
            status.active_images = snapshot.active_images.clone();
            if !snapshot.rotation_active() {
                status.current_image = None;
            }
            status.connected = true;
            status.updated_at = Some(stamp_now());
        });

        if !snapshot.rotation_active() {
            thread::sleep(Duration::from_millis(750));
            continue;
        }

        let switch_after = Duration::from_secs(snapshot.switch_time as u64);
        let elapsed = cycle_started_at.elapsed();
        let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64;
        let next_index = (frame_slot as usize) % frames.len();
        let send_due = current_frame_index != Some(next_index);

        if send_due {
            let frame = frames[next_index].clone();
            match frame {
                RotationFrame::SystemSpecs => {
                    let panel_name = SYSTEM_FRAME_NAME.to_string();
                    let rgb_img = render_system_panel();
                    screen
                        .send_image(&rgb_img)
                        .context("Failed to send system specs panel")?;

                    update_display_status(&state.display_status, |status| {
                        status.current_image = Some(panel_name);
                        status.last_error = None;
                        status.connected = true;
                        status.updated_at = Some(stamp_now());
                    });
                }
                RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name)
                {
                    Ok(mut rgb_img) => {
                        if snapshot.specs_enabled {
                            overlay_system_specs(&mut rgb_img);
                        }
                        screen
                            .send_image(&rgb_img)
                            .with_context(|| format!("Failed to send panel {image_name}"))?;

                        update_display_status(&state.display_status, |status| {
                            status.current_image = Some(image_name.clone());
                            status.last_error = None;
                            status.connected = true;
                            status.updated_at = Some(stamp_now());
                        });
                    }
                    Err(err) => {
                        warn!("Skipping panel {image_name}: {err}");
                        update_display_status(&state.display_status, |status| {
                            status.current_image = Some(image_name.clone());
                            status.last_error =
                                Some(format!("Panel {image_name} could not be rendered: {err}"));
                            status.connected = true;
                            status.updated_at = Some(stamp_now());
                        });
                    }
                },
            }
            current_frame_index = Some(next_index);
        }

        thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at));
    }
}

fn load_monitor_json_sync(path: &PathBuf) -> Result<Value> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read {}", path.display()))?;
    serde_json::from_str(&raw).context("Failed to parse Monitor3.json")
}

fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
    let path = image_dir.join(image_name);
    let image = img::load_image(&path, Some(DISPLAY_SIZE))
        .with_context(|| format!("Failed to load panel image {}", path.display()))?;
    Ok(image.to_rgb8())
}

fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
    if frame_count <= 1 {
        return Duration::from_millis(750);
    }

    let elapsed = cycle_started_at.elapsed();
    let switch_secs = switch_after.as_secs().max(1);
    let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs;
    let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed);
    if remaining > Duration::from_millis(750) {
        Duration::from_millis(750)
    } else if remaining < Duration::from_millis(200) {
        Duration::from_millis(200)
    } else {
        remaining
    }
}

fn read_display_status(status: &Arc<RwLock<DisplayStatus>>) -> DisplayStatus {
    status
        .read()
        .expect("display status lock poisoned")
        .clone()
}

fn update_display_status(
    status: &Arc<RwLock<DisplayStatus>>,
    apply: impl FnOnce(&mut DisplayStatus),
) {
    let mut guard = status.write().expect("display status lock poisoned");
    apply(&mut guard);
}

fn set_display_error(status: &Arc<RwLock<DisplayStatus>>, connected: bool, message: String) {
    update_display_status(status, |current| {
        current.connected = connected;
        current.last_error = Some(message);
        current.updated_at = Some(stamp_now());
        if !connected {
            current.current_image = None;
        }
    });
}

fn display_mode(config: &DisplayConfig) -> String {
    if !config.native_enabled {
        "disabled".into()
    } else if config.simulate {
        "simulate".into()
    } else if config.device.is_some() {
        "device".into()
    } else if config.usb.is_some() {
        "usb-id".into()
    } else {
        "usb-default".into()
    }
}

fn display_target(config: &DisplayConfig) -> String {
    if !config.native_enabled {
        "native loop disabled".into()
    } else if config.simulate {
        "simulated LCD".into()
    } else if let Some(device) = &config.device {
        device.clone()
    } else if let Some(usb) = &config.usb {
        format!("USB {usb}")
    } else {
        "default AOOSTAR USB UART 0416:90A1".into()
    }
}

fn stamp_now() -> String {
    Utc::now().to_rfc3339()
}

const INDEX_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>aster-webui</title>
  <style>
    :root {
      --bg: #0d1117;
      --panel: #121821;
      --line: #243243;
      --text: #edf2f7;
      --muted: #92a3b5;
      --accent: #7ce7bf;
      --accent-2: #d8fd72;
      --danger: #ff7f73;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      background:
        radial-gradient(circle at top left, rgba(124,231,191,.15), transparent 30%),
        radial-gradient(circle at bottom right, rgba(216,253,114,.12), transparent 25%),
        var(--bg);
      color: var(--text);
      font-family: "IBM Plex Sans", sans-serif;
    }
    main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
    .hero, .content, .status-grid { display: grid; gap: 18px; }
    .hero { grid-template-columns: 1.4fr 1fr; margin-bottom: 18px; }
    .status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .card {
      background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
      border: 1px solid var(--line);
      border-radius: 26px;
      padding: 22px;
    }
    .hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; }
    .hero p, .muted { color: var(--muted); }
    .status strong { display: block; margin-top: 8px; font-size: 1.3rem; }
    .status small { display: block; margin-top: 6px; color: var(--muted); }
    .controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; }
    .toggle-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
    .toggle-pill {
      display: inline-flex; align-items: center; gap: 10px; padding: 10px 14px;
      border: 1px solid var(--line); border-radius: 999px; background: rgba(9,14,20,.7);
      color: var(--text); font-size: .95rem;
    }
    .toggle-pill input { margin: 0; width: 18px; height: 18px; accent-color: var(--accent); }
    label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
    input, button {
      border-radius: 14px;
      border: 1px solid var(--line);
      padding: 12px 14px;
      font: inherit;
    }
    input { background: rgba(6,10,15,.8); color: var(--text); }
    .actions { display: flex; gap: 10px; flex-wrap: wrap; }
    button { cursor: pointer; }
    .primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #071016; font-weight: 700; }
    .ghost { background: rgba(9,14,20,.7); color: var(--text); }
    .danger { background: rgba(255,127,115,.14); color: #ffc7c1; }
    .content { grid-template-columns: 1.45fr 1fr; }
    .metrics-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; margin-bottom: 18px; }
    .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
    .tile {
      border: 1px solid var(--line);
      border-radius: 20px;
      overflow: hidden;
      background: rgba(8,12,18,.78);
    }
    .tile.selected { outline: 2px solid var(--accent-2); }
    .tile img { width: 100%; aspect-ratio: 960 / 376; object-fit: cover; display: block; }
    .tile-body { padding: 12px; display: grid; gap: 10px; }
    .tile-title { word-break: break-word; }
    .mini { display: flex; gap: 8px; flex-wrap: wrap; }
    .order { display: grid; gap: 10px; }
    .order-item {
      display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
      border: 1px solid var(--line); border-radius: 18px; padding: 12px 14px; background: rgba(8,12,18,.78);
    }
    .index { color: var(--accent-2); font-weight: 700; }
    .meta { display: grid; gap: 4px; }
    .toast {
      position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
      padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
      background: rgba(10,14,20,.94); display: none;
    }
    @media (max-width: 1024px) {
      .hero, .content, .controls, .status-grid, .metrics-grid { grid-template-columns: 1fr; }
    }
    .nyan-wrap {
      position: fixed;
      right: 24px;
      bottom: 18px;
      width: 260px;
      pointer-events: none;
      z-index: 30;
      opacity: 0.98;
      filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35));
    }
    .nyan-wrap img {
      display: block;
      width: 100%;
      height: auto;
    }

    @media (max-width: 900px) {
      .nyan-wrap {
        right: 14px;
        bottom: 12px;
        width: 180px;
      }
    }
</style>
</head>
<body>
  <main>
    <section class="hero">
      <div class="card">
        <p class="muted">aoostar-rs fork</p>
        <h1>aster-webui</h1>
        <p>Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.</p>
        <div class="controls">
          <label>
            <span>Switch Time</span>
            <input id="switchTime" type="number" min="1" max="600" value="10">
          </label>
          <label>
            <span>Upload New Source</span>
            <input id="upload" type="file" accept="image/*">
          </label>
          <div class="actions">
            <button id="apply" class="primary" type="button">Apply Rotation</button>
            <button id="disable" class="danger" type="button">Disable</button>
            <button id="refresh" class="ghost" type="button">Refresh</button>
          </div>
        </div>
        <div class="toggle-row">
          <label class="toggle-pill"><input id="specsEnabled" type="checkbox"> System Specs on LCD</label>
          <label class="toggle-pill"><input id="memesEnabled" type="checkbox"> Memes on LCD</label>
        </div>
      </div>
      <div class="status-grid">
        <div class="card status"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
        <div class="card status"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
        <div class="card status"><span class="muted">Display Status</span><strong id="displayStatusValue">-</strong></div>
        <div class="card status"><span class="muted">Current Frame</span><strong id="currentImageValue" style="font-size: 1rem;">-</strong></div>
        <div class="card status"><span class="muted">Display Target</span><strong id="displayTargetValue" style="font-size: 1rem;">-</strong></div>
        <div class="card status"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
      </div>
    </section>
    <section class="metrics-grid">
      <div class="card status"><span class="muted">CPU</span><strong id="cpuUsageValue">-</strong><small id="cpuMetaValue">-</small></div>
      <div class="card status"><span class="muted">Memory</span><strong id="memUsageValue">-</strong><small id="memMetaValue">-</small></div>
      <div class="card status"><span class="muted">Storage</span><strong id="diskUsageValue">-</strong><small id="diskMetaValue">-</small></div>
      <div class="card status"><span class="muted">Swap</span><strong id="swapUsageValue">-</strong><small id="swapMetaValue">-</small></div>
      <div class="card status"><span class="muted">Load Avg</span><strong id="loadValue">-</strong><small id="uptimeValue">-</small></div>
      <div class="card status"><span class="muted">Temperatures</span><strong id="tempCpuValue">-</strong><small id="tempGpuValue">-</small></div>
    </section>
    <section class="content">
      <div class="card">
        <h2>Available Images</h2>
        <div id="gallery" class="gallery"></div>
      </div>
      <div class="card">
        <h2>Rotation Order</h2>
        <div id="order" class="order"></div>
        <p id="displayErrorValue" class="muted"></p>
      </div>
    </section>
  </main>
  <div id="toast" class="toast"></div>
  <div class="nyan-wrap" aria-hidden="true">
    <img src="/api/assets/nyan-cat.gif" alt="Nyancat">
  </div>

  <script>
    const state = {
      images: [],
      activeImages: [],
      setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false },
      display: {},
      system: {},
    };
    const el = {
      gallery: document.getElementById("gallery"),
      order: document.getElementById("order"),
      switchTime: document.getElementById("switchTime"),
      specsEnabled: document.getElementById("specsEnabled"),
      memesEnabled: document.getElementById("memesEnabled"),
      customPanelValue: document.getElementById("customPanelValue"),
      imageCountValue: document.getElementById("imageCountValue"),
      displayStatusValue: document.getElementById("displayStatusValue"),
      currentImageValue: document.getElementById("currentImageValue"),
      displayTargetValue: document.getElementById("displayTargetValue"),
      displayErrorValue: document.getElementById("displayErrorValue"),
      configPathValue: document.getElementById("configPathValue"),
      cpuUsageValue: document.getElementById("cpuUsageValue"),
      cpuMetaValue: document.getElementById("cpuMetaValue"),
      memUsageValue: document.getElementById("memUsageValue"),
      memMetaValue: document.getElementById("memMetaValue"),
      diskUsageValue: document.getElementById("diskUsageValue"),
      diskMetaValue: document.getElementById("diskMetaValue"),
      swapUsageValue: document.getElementById("swapUsageValue"),
      swapMetaValue: document.getElementById("swapMetaValue"),
      loadValue: document.getElementById("loadValue"),
      uptimeValue: document.getElementById("uptimeValue"),
      tempCpuValue: document.getElementById("tempCpuValue"),
      tempGpuValue: document.getElementById("tempGpuValue"),
      upload: document.getElementById("upload"),
      apply: document.getElementById("apply"),
      disable: document.getElementById("disable"),
      refresh: document.getElementById("refresh"),
      toast: document.getElementById("toast"),
    };

    function toast(msg) {
      el.toast.textContent = msg;
      el.toast.style.display = "block";
      clearTimeout(toast.timer);
      toast.timer = setTimeout(() => el.toast.style.display = "none", 2600);
    }

    function bytes(n) {
      if (n < 1024) return n + " B";
      if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
      return (n / 1024 / 1024).toFixed(1) + " MB";
    }

    function move(index, delta) {
      const target = index + delta;
      if (target < 0 || target >= state.activeImages.length) return;
      const next = [...state.activeImages];
      const [item] = next.splice(index, 1);
      next.splice(target, 0, item);
      state.activeImages = next;
      render();
    }

    function toggle(name) {
      if (state.activeImages.includes(name)) {
        state.activeImages = state.activeImages.filter((item) => item !== name);
      } else {
        state.activeImages = [...state.activeImages, name];
      }
      render();
    }

    async function request(url, options = {}) {
      const res = await fetch(url, options);
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "request failed");
      return data;
    }

    async function load() {
      const data = await request("/api/state");
      state.images = data.images;
      state.activeImages = data.active_images;
      state.setup = data.setup;
      state.display = data.display || {};
      state.system = data.system || {};
      el.switchTime.value = data.setup.switch_time || "10";
      el.specsEnabled.checked = !!data.setup.specs_enabled;
      el.memesEnabled.checked = !!data.setup.memes_enabled;
      const enabledModes = [
        data.setup.specs_enabled ? "Specs" : "",
        data.setup.memes_enabled ? "Memes" : "",
      ].filter(Boolean);
      el.customPanelValue.textContent = data.setup.custom_panel
        ? (enabledModes.join(" + ") || "Active")
        : "Disabled";
      el.imageCountValue.textContent = String(data.active_images.length);
      el.displayStatusValue.textContent = state.display.connected
        ? `Connected (${state.display.mode || "native"})`
        : (state.display.native_enabled ? "Disconnected" : "Disabled");
      el.currentImageValue.textContent = state.display.current_image || "-";
      el.displayTargetValue.textContent = state.display.device || "-";
      el.displayErrorValue.textContent = state.display.last_error || "";
      el.configPathValue.textContent = data.monitor_path;
      el.cpuUsageValue.textContent = `${state.system.cpu_usage_percent || "-"} %`;
      el.cpuMetaValue.textContent = `Load ${state.system.load_avg_one || "-"} / ${state.system.load_avg_five || "-"} / ${state.system.load_avg_fifteen || "-"}`;
      el.memUsageValue.textContent = `${state.system.mem_usage_percent || "-"} %`;
      el.memMetaValue.textContent = `${state.system.mem_used || "-"} / ${state.system.mem_total || "-"}`;
      el.diskUsageValue.innerHTML = `/mnt/cache ${state.system.cache_usage_percent || "-"}%<br>/mnt/user ${state.system.user_usage_percent || "-"}%`;
      el.diskMetaValue.textContent = `${state.system.cache_used || "-"} / ${state.system.cache_total || "-"} | ${state.system.user_used || "-"} / ${state.system.user_total || "-"}`;
      el.swapUsageValue.textContent = `${state.system.swap_usage_percent || "-"} %`;
      el.swapMetaValue.textContent = `${state.system.swap_used || "-"} / ${state.system.swap_total || "-"}`;
      el.loadValue.textContent = `${state.system.cpu_count || "-"} CPU / ${state.system.process_count || "-"} proc`;
      el.uptimeValue.textContent = `Uptime ${state.system.uptime || "-"}`;
      el.tempCpuValue.textContent = `CPU ${state.system.temperature_cpu || "-"}`;
      el.tempGpuValue.textContent = `GPU ${state.system.temperature_gpu || "-"}`;
      render();
    }

    async function uploadFile(event) {
      const file = event.target.files[0];
      if (!file) return;
      const form = new FormData();
      form.append("file", file);
      await request("/api/upload", { method: "POST", body: form });
      event.target.value = "";
      toast("Upload complete");
      await load();
    }

    async function apply() {
      await request("/api/panels/activate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          images: state.activeImages,
          switch_time: Number.parseInt(el.switchTime.value || "10", 10),
          specs_enabled: el.specsEnabled.checked,
          memes_enabled: el.memesEnabled.checked,
        }),
      });
      toast("Rotation updated");
      await load();
    }

    async function disablePanels() {
      await request("/api/panels/disable", { method: "POST" });
      toast("Custom panels disabled");
      await load();
    }

    async function removeImage(name) {
      await request("/api/images/delete", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name }),
      });
      toast("Image deleted");
      await load();
    }

    function render() {
      el.gallery.innerHTML = "";
      for (const image of state.images) {
        const selected = state.activeImages.includes(image.name);
        const tile = document.createElement("article");
        tile.className = "tile" + (selected ? " selected" : "");
        tile.innerHTML = `
          <img src="${image.url}" alt="${image.name}">
          <div class="tile-body">
            <div class="tile-title">${image.name}</div>
            <div class="muted">${bytes(image.size)}</div>
            <div class="mini">
              <button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
              <button class="danger" type="button">Delete</button>
            </div>
          </div>
        `;
        const [toggleBtn, deleteBtn] = tile.querySelectorAll("button");
        toggleBtn.addEventListener("click", () => toggle(image.name));
        deleteBtn.addEventListener("click", () => removeImage(image.name));
        el.gallery.appendChild(tile);
      }

      el.order.innerHTML = "";
      if (state.setup.specs_enabled && !state.activeImages.length) {
        const row = document.createElement("div");
        row.className = "order-item";
        row.innerHTML = `
          <div class="index">S</div>
          <div class="meta">
            <div>System Specs</div>
            <div class="muted">${state.display.current_image === "System Specs" ? "Currently shown" : "Fixed first frame when enabled"}</div>
          </div>
          <div class="mini"><span class="muted">Toggle above</span></div>
        `;
        el.order.appendChild(row);
      }

      if (state.setup.specs_enabled && state.activeImages.length) {
        const row = document.createElement("div");
        row.className = "order-item";
        row.innerHTML = `
          <div class="index">S</div>
          <div class="meta">
            <div>System Specs Overlay</div>
            <div class="muted">Live stats are drawn over every meme while enabled.</div>
          </div>
          <div class="mini"><span class="muted">Toggle above</span></div>
        `;
        el.order.appendChild(row);
      }

      if (!state.activeImages.length && !state.setup.specs_enabled) {
        el.order.innerHTML = `<p class="muted">No active images.</p>`;
        return;
      }
      state.activeImages.forEach((name, index) => {
        const row = document.createElement("div");
        row.className = "order-item";
        row.innerHTML = `
          <div class="index">${index + 1}</div>
          <div class="meta">
            <div>${name}</div>
            <div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
          </div>
          <div class="mini">
            <button class="ghost" type="button">Up</button>
            <button class="ghost" type="button">Down</button>
            <button class="danger" type="button">Remove</button>
          </div>
        `;
        const [up, down, remove] = row.querySelectorAll("button");
        up.addEventListener("click", () => move(index, -1));
        down.addEventListener("click", () => move(index, 1));
        remove.addEventListener("click", () => toggle(name));
        el.order.appendChild(row);
      });
    }

    el.upload.addEventListener("change", (event) => uploadFile(event).catch((err) => toast(err.message)));
    el.apply.addEventListener("click", () => apply().catch((err) => toast(err.message)));
    el.disable.addEventListener("click", () => disablePanels().catch((err) => toast(err.message)));
    el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));

    load().catch((err) => toast(err.message));
    setInterval(() => load().catch(() => {}), 5000);
  </script>
</body>
</html>
"##;
