use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use axum::body::Bytes;
use axum::http::Method;
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
use rand::Rng;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tokio::fs;
use tokio::process::Command;
use tokio_postgres::Row;

use crate::db::record_event;
use crate::http::{json_response, percent_decode, read_limit, ParsedUrl};
use crate::json_util::{compact_json, truncate_text};
use crate::state::AppState;
use crate::task_dispatcher::{
    create_and_send_task, is_provider_dispatch_command, is_terminal_task_status,
    wait_for_task_terminal,
};
use crate::types::{RawTaskRow, ScheduledTaskRow, ScheduledTaskRunRow};

#[derive(Debug, Clone)]
enum ScheduleSpec {
    Daily {
        time_of_day: String,
        timezone: String,
    },
    Interval {
        every_seconds: i64,
    },
}

#[derive(Debug, Clone)]
enum ScheduleAction {
    Dispatch {
        provider_id: String,
        command: String,
        payload: Value,
        timeout_ms: Option<u64>,
    },
    PgdataBackup {
        volume_name: String,
        remote_base_dir: String,
        staging_subdir: String,
        baidu_base_url: String,
        timeout_ms: u64,
        cleanup_local: bool,
    },
}

fn row_iso(value: Option<DateTime<Utc>>) -> Value {
    value
        .map(|value| Value::String(value.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
        .unwrap_or(Value::Null)
}

fn schedule_run_from_row(row: Row) -> ScheduledTaskRunRow {
    ScheduledTaskRunRow {
        id: row.get("id"),
        schedule_id: row.get("schedule_id"),
        trigger_type: row.get("trigger_type"),
        status: row.get("status"),
        task_id: row.get("task_id"),
        result: row.get("result"),
        error: row.get("error"),
        started_at: row.get("started_at"),
        finished_at: row.get("finished_at"),
        duration_ms: row.get("duration_ms"),
        created_at: row.get("created_at"),
        updated_at: row.get("updated_at"),
    }
}

fn schedule_task_from_row(row: Row) -> ScheduledTaskRow {
    ScheduledTaskRow {
        id: row.get("id"),
        name: row.get("name"),
        description: row.get("description"),
        enabled: row.get("enabled"),
        schedule_json: row.get("schedule_json"),
        action_json: row.get("action_json"),
        concurrency_policy: row.get("concurrency_policy"),
        next_run_at: row.get("next_run_at"),
        last_run_at: row.get("last_run_at"),
        last_run_id: row.get("last_run_id"),
        created_at: row.get("created_at"),
        updated_at: row.get("updated_at"),
    }
}

fn schedule_run_view(row: &ScheduledTaskRunRow) -> Value {
    json!({
        "id": row.id,
        "scheduleId": row.schedule_id,
        "triggerType": row.trigger_type,
        "status": row.status,
        "taskId": row.task_id,
        "result": row.result,
        "error": row.error,
        "startedAt": row_iso(row.started_at),
        "finishedAt": row_iso(row.finished_at),
        "durationMs": row.duration_ms,
        "createdAt": row.created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
        "updatedAt": row.updated_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
    })
}

fn scheduled_task_view(row: &ScheduledTaskRow, runs: &[ScheduledTaskRunRow]) -> Value {
    json!({
        "id": row.id,
        "name": row.name,
        "description": row.description,
        "enabled": row.enabled,
        "schedule": row.schedule_json,
        "action": row.action_json,
        "concurrencyPolicy": row.concurrency_policy,
        "nextRunAt": row_iso(row.next_run_at),
        "lastRunAt": row_iso(row.last_run_at),
        "lastRunId": row.last_run_id,
        "createdAt": row.created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
        "updatedAt": row.updated_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
        "recentRuns": runs.iter().map(schedule_run_view).collect::<Vec<_>>(),
    })
}

fn scheduled_raw_task_json(task: Option<&RawTaskRow>) -> Value {
    match task {
        Some(task) => json!({
            "id": task.id,
            "providerId": task.provider_id,
            "command": task.command,
            "status": task.status,
            "payload": compact_json(&task.payload),
            "result": task.result.as_ref().map(compact_json).unwrap_or(Value::Null),
            "updatedAt": task.updated_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
        }),
        None => Value::Null,
    }
}

fn normalize_schedule_id(value: Option<&Value>) -> anyhow::Result<String> {
    let raw = value
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .map(|value| value.trim().to_string())
        .unwrap_or_else(|| {
            format!(
                "schedule_{}_{}",
                Utc::now().timestamp_millis(),
                format!("{:x}", rand::thread_rng().gen::<u32>())
            )
        });
    if raw.len() > 120
        || raw.is_empty()
        || !raw
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ':' | '-'))
    {
        anyhow::bail!("schedule id must be 1-120 chars using letters, numbers, _, ., :, or -");
    }
    Ok(raw)
}

fn normalize_time_of_day(value: Option<&Value>) -> anyhow::Result<String> {
    let raw = value
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("03:00")
        .trim();
    let parts = raw.split(':').collect::<Vec<_>>();
    if parts.len() != 2 {
        anyhow::bail!("daily schedule timeOfDay must use HH:MM in UTC");
    }
    let hour = parts[0].parse::<u32>().unwrap_or(99);
    let minute = parts[1].parse::<u32>().unwrap_or(99);
    if hour > 23 || minute > 59 || parts[0].len() != 2 || parts[1].len() != 2 {
        anyhow::bail!("daily schedule timeOfDay must use HH:MM in UTC");
    }
    Ok(format!("{hour:02}:{minute:02}"))
}

fn number_in_range(value: Option<&Value>, fallback: i64, min: i64, max: i64) -> i64 {
    let parsed = value
        .and_then(|value| {
            value
                .as_i64()
                .or_else(|| value.as_str().and_then(|text| text.parse::<i64>().ok()))
        })
        .unwrap_or(fallback);
    parsed.clamp(min, max)
}

fn normalize_schedule_spec(value: Option<&Value>) -> anyhow::Result<(ScheduleSpec, Value)> {
    let record = value.and_then(Value::as_object);
    if record
        .and_then(|record| record.get("type"))
        .and_then(Value::as_str)
        == Some("interval")
    {
        let every_seconds = number_in_range(
            record.and_then(|record| record.get("everySeconds")),
            3600,
            60,
            366 * 24 * 3600,
        );
        return Ok((
            ScheduleSpec::Interval { every_seconds },
            json!({ "type": "interval", "everySeconds": every_seconds }),
        ));
    }
    let time_of_day = normalize_time_of_day(record.and_then(|record| record.get("timeOfDay")))?;
    let timezone = record
        .and_then(|record| record.get("timezone"))
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .unwrap_or("Etc/UTC")
        .to_string();
    Ok((
        ScheduleSpec::Daily {
            time_of_day: time_of_day.clone(),
            timezone: timezone.clone(),
        },
        json!({ "type": "daily", "timeOfDay": time_of_day, "timezone": timezone }),
    ))
}

fn normalize_remote_dir(input: &str) -> anyhow::Result<String> {
    let raw = if input.trim().is_empty() {
        "/"
    } else {
        input.trim()
    };
    if raw.contains('\0') {
        anyhow::bail!("remote directory must not contain null bytes");
    }
    let rooted;
    let input = if raw.starts_with('/') {
        raw
    } else {
        rooted = format!("/{raw}");
        &rooted
    };
    let mut normalized =
        Path::new(input)
            .components()
            .fold(String::new(), |mut path, component| {
                match component {
                    std::path::Component::Normal(part) => {
                        path.push('/');
                        path.push_str(&part.to_string_lossy());
                    }
                    std::path::Component::RootDir => {}
                    _ => {}
                }
                path
            });
    if normalized.is_empty() {
        normalized = "/".to_string();
    }
    if normalized != "/" {
        normalized = normalized.trim_end_matches('/').to_string();
    }
    Ok(normalized)
}

fn normalize_relative_staging_path(root: &str, input: &str) -> anyhow::Result<(String, PathBuf)> {
    let cleaned = input
        .replace('\\', "/")
        .trim_start_matches('/')
        .trim()
        .to_string();
    let normalized = Path::new(if cleaned.is_empty() { "." } else { &cleaned })
        .components()
        .fold(PathBuf::new(), |mut path, component| {
            if let std::path::Component::Normal(part) = component {
                path.push(part);
            }
            path
        });
    if normalized.as_os_str().is_empty() {
        anyhow::bail!("staging path must be a relative child path");
    }
    let absolute = Path::new(root).join(&normalized);
    let relative = normalized.to_string_lossy().replace('\\', "/");
    Ok((relative, absolute))
}

fn normalize_schedule_action(
    state: &Arc<AppState>,
    value: Option<&Value>,
) -> anyhow::Result<(ScheduleAction, Value)> {
    let Some(record) = value.and_then(Value::as_object) else {
        anyhow::bail!("scheduled task action must be an object");
    };
    match record.get("type").and_then(Value::as_str) {
        Some("dispatch") => {
            let provider_id = record
                .get("providerId")
                .and_then(Value::as_str)
                .unwrap_or("")
                .trim()
                .to_string();
            if provider_id.is_empty() {
                anyhow::bail!("dispatch action providerId is required");
            }
            let command = record
                .get("command")
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string();
            if !is_provider_dispatch_command(&command) {
                anyhow::bail!("dispatch action command is invalid");
            }
            let payload = record
                .get("payload")
                .filter(|value| value.is_object())
                .cloned()
                .unwrap_or_else(|| json!({}));
            let timeout_ms = record.get("timeoutMs").map(|_| {
                number_in_range(
                    record.get("timeoutMs"),
                    state.config.task_pending_timeout_ms as i64,
                    1_000,
                    24 * 3600_000,
                ) as u64
            });
            let mut action_json = json!({ "type": "dispatch", "providerId": provider_id, "command": command, "payload": payload });
            if let Some(timeout_ms) = timeout_ms {
                action_json["timeoutMs"] = json!(timeout_ms);
            }
            Ok((
                ScheduleAction::Dispatch {
                    provider_id,
                    command,
                    payload,
                    timeout_ms,
                },
                action_json,
            ))
        }
        Some("pgdata_backup") => {
            let volume_name = record
                .get("volumeName")
                .and_then(Value::as_str)
                .filter(|value| !value.is_empty())
                .unwrap_or(&state.config.database_volume_name)
                .to_string();
            let remote_base_dir = normalize_remote_dir(
                record
                    .get("remoteBaseDir")
                    .and_then(Value::as_str)
                    .unwrap_or("/SERVER_DATA/UNIDESK_PG_DATA"),
            )?;
            let (staging_subdir, _) = normalize_relative_staging_path(
                &state.config.pgdata_backup_staging_dir,
                record
                    .get("stagingSubdir")
                    .and_then(Value::as_str)
                    .unwrap_or("server-data/unidesk-pg-data"),
            )?;
            let baidu_base_url = record
                .get("baiduBaseUrl")
                .and_then(Value::as_str)
                .filter(|value| !value.is_empty())
                .unwrap_or(&state.config.baidu_netdisk_internal_url)
                .to_string();
            let timeout_ms =
                number_in_range(record.get("timeoutMs"), 60 * 60_000, 60_000, 24 * 3600_000) as u64;
            let cleanup_local = record.get("cleanupLocal").and_then(Value::as_bool) != Some(false);
            Ok((
                ScheduleAction::PgdataBackup {
                    volume_name: volume_name.clone(),
                    remote_base_dir: remote_base_dir.clone(),
                    staging_subdir: staging_subdir.clone(),
                    baidu_base_url: baidu_base_url.clone(),
                    timeout_ms,
                    cleanup_local,
                },
                json!({
                    "type": "pgdata_backup",
                    "volumeName": volume_name,
                    "remoteBaseDir": remote_base_dir,
                    "stagingSubdir": staging_subdir,
                    "baiduBaseUrl": baidu_base_url,
                    "timeoutMs": timeout_ms,
                    "cleanupLocal": cleanup_local,
                }),
            ))
        }
        _ => anyhow::bail!("scheduled task action.type must be dispatch or pgdata_backup"),
    }
}

fn compute_next_run_at(schedule: &ScheduleSpec, after: DateTime<Utc>) -> DateTime<Utc> {
    match schedule {
        ScheduleSpec::Interval { every_seconds } => {
            after + chrono::Duration::seconds((*every_seconds).max(60))
        }
        ScheduleSpec::Daily { time_of_day, .. } => {
            let parts = time_of_day.split(':').collect::<Vec<_>>();
            let hour = parts
                .first()
                .and_then(|value| value.parse::<u32>().ok())
                .unwrap_or(3);
            let minute = parts
                .get(1)
                .and_then(|value| value.parse::<u32>().ok())
                .unwrap_or(0);
            let mut candidate = Utc
                .with_ymd_and_hms(after.year(), after.month(), after.day(), hour, minute, 0)
                .unwrap();
            if candidate <= after {
                candidate += chrono::Duration::days(1);
            }
            candidate
        }
    }
}

fn utc_backup_parts(date: DateTime<Utc>) -> (String, String, String, String) {
    let date_part = format!("{:04}{:02}{:02}", date.year(), date.month(), date.day());
    let time_part = format!("{:02}{:02}{:02}", date.hour(), date.minute(), date.second());
    let month = format!("{:04}{:02}", date.year(), date.month());
    let stamp = format!("{date_part}_{time_part}");
    (date_part, time_part, month, stamp)
}

fn safe_file_part(value: &str) -> String {
    let filtered = value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    let trimmed = filtered.trim_matches('_');
    if trimmed.is_empty() {
        "data".to_string()
    } else {
        trimmed[..trimmed.len().min(80)].to_string()
    }
}

fn database_connection_parts(
    database_url: &str,
) -> anyhow::Result<(String, String, String, String, String)> {
    let url = url::Url::parse(database_url)?;
    Ok((
        url.host_str().unwrap_or("database").to_string(),
        if url.port().is_some() {
            url.port().unwrap().to_string()
        } else {
            "5432".to_string()
        },
        percent_decode(url.username()),
        url.password().map(percent_decode).unwrap_or_default(),
        {
            let name = percent_decode(url.path().trim_start_matches('/'));
            if name.is_empty() {
                "postgres".to_string()
            } else {
                name
            }
        },
    ))
}

async fn run_local_command(
    command: &str,
    args: &[String],
    envs: Vec<(&str, String)>,
    timeout_ms: u64,
) -> anyhow::Result<(bool, String, String, Option<i32>, bool)> {
    let mut cmd = Command::new(command);
    cmd.args(args);
    for (key, value) in envs {
        cmd.env(key, value);
    }
    cmd.stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    let child = cmd.spawn()?;
    match tokio::time::timeout(
        Duration::from_millis(timeout_ms.max(1)),
        child.wait_with_output(),
    )
    .await
    {
        Ok(output) => {
            let output = output?;
            let stdout = truncate_text(&String::from_utf8_lossy(&output.stdout), 4000);
            let stderr = truncate_text(&String::from_utf8_lossy(&output.stderr), 4000);
            Ok((
                output.status.success(),
                stdout,
                stderr,
                output.status.code(),
                false,
            ))
        }
        Err(_) => Ok((
            false,
            String::new(),
            "command timed out".to_string(),
            None,
            true,
        )),
    }
}

async fn sha256_file(path: &Path) -> anyhow::Result<String> {
    let bytes = fs::read(path).await?;
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    Ok(format!("{:x}", hasher.finalize()))
}

async fn fetch_json_with_timeout(
    _url: String,
    init: reqwest::RequestBuilder,
    timeout_ms: u64,
) -> anyhow::Result<Value> {
    let response =
        tokio::time::timeout(Duration::from_millis(timeout_ms.max(1)), init.send()).await??;
    let status = response.status();
    let text = response.text().await.unwrap_or_default();
    let parsed = serde_json::from_str::<Value>(&text).unwrap_or_else(|_| json!({ "text": text }));
    if !status.is_success() {
        anyhow::bail!(
            "HTTP {}: {}",
            status.as_u16(),
            truncate_text(&parsed.to_string(), 1000)
        );
    }
    Ok(parsed)
}

async fn baidu_json(
    base_url: &str,
    path: &str,
    method: reqwest::Method,
    body: Option<Value>,
    timeout_ms: u64,
) -> anyhow::Result<Value> {
    let client = reqwest::Client::new();
    let url = reqwest::Url::parse(base_url)?.join(path.trim_start_matches('/'))?;
    let mut request = client.request(method, url);
    if let Some(body) = body {
        request = request.json(&body);
    }
    let value = fetch_json_with_timeout(path.to_string(), request, timeout_ms).await?;
    if value.get("ok").and_then(Value::as_bool) == Some(false) {
        anyhow::bail!("Baidu Netdisk API failed: {}", compact_json(&value));
    }
    Ok(value)
}

async fn baidu_raw_json(base_url: &str, path: &str, timeout_ms: u64) -> anyhow::Result<Value> {
    let client = reqwest::Client::new();
    let url = reqwest::Url::parse(base_url)?.join(path.trim_start_matches('/'))?;
    fetch_json_with_timeout(path.to_string(), client.get(url), timeout_ms).await
}

fn preflight_failure(
    failure_kind: &str,
    retryable: bool,
    recommended_action: &str,
    checks: Value,
) -> Value {
    json!({
        "ok": false,
        "stage": "preflight",
        "failureKind": failure_kind,
        "retryable": retryable,
        "recommendedAction": recommended_action,
        "observedAt": crate::json_util::now_iso(),
        "checks": checks,
    })
}

fn baidu_health_preflight_failure(health: &Value) -> Option<(&'static str, bool, &'static str)> {
    if health.get("ok").and_then(Value::as_bool) == Some(false) {
        return Some((
            "baidu-unreachable",
            true,
            "Check the baidu-netdisk service health and PostgreSQL schema readiness before retrying the backup.",
        ));
    }
    let auth = health.get("auth").unwrap_or(&Value::Null);
    let client_id_configured = auth
        .get("clientIdConfigured")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let client_secret_configured = auth
        .get("clientSecretConfigured")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    let token_key_configured = auth
        .get("tokenKeyConfigured")
        .and_then(Value::as_bool)
        .unwrap_or(false);
    if !client_id_configured || !client_secret_configured || !token_key_configured {
        return Some((
            "config-missing",
            false,
            "Restore BAIDU_NETDISK_CLIENT_ID, BAIDU_NETDISK_CLIENT_SECRET and BAIDU_NETDISK_TOKEN_KEY in the baidu-netdisk runtime environment, then verify microservice health baidu-netdisk.",
        ));
    }
    if auth.get("loggedIn").and_then(Value::as_bool) != Some(true) {
        return Some((
            "auth-missing",
            false,
            "Log in again from the UniDesk Baidu Netdisk page, then verify /api/auth/status reports loggedIn=true.",
        ));
    }
    None
}

async fn pgdata_backup_preflight(
    state: &Arc<AppState>,
    staging_subdir: &str,
    baidu_base_url: &str,
    db_host: &str,
    db_port: &str,
    db_user: &str,
    db_password: &str,
    db_name: &str,
    run_id: &str,
) -> Value {
    let mut checks = serde_json::Map::new();

    let health = match baidu_raw_json(baidu_base_url, "/health", 10_000).await {
        Ok(health) => health,
        Err(error) => {
            checks.insert(
                "baidu".to_string(),
                json!({ "ok": false, "error": error.to_string(), "baseUrl": baidu_base_url }),
            );
            return preflight_failure(
                "baidu-unreachable",
                true,
                "Check that baidu-netdisk is running on the internal Compose network and retry after the health endpoint is reachable.",
                Value::Object(checks),
            );
        }
    };
    if let Some((failure_kind, retryable, recommended_action)) =
        baidu_health_preflight_failure(&health)
    {
        checks.insert(
            "baidu".to_string(),
            json!({ "ok": false, "health": compact_json(&health) }),
        );
        return preflight_failure(
            failure_kind,
            retryable,
            recommended_action,
            Value::Object(checks),
        );
    }
    checks.insert(
        "baidu".to_string(),
        json!({ "ok": true, "health": compact_json(&health) }),
    );

    let probe_relative_dir = format!("{staging_subdir}/.preflight_{run_id}");
    let (_, probe_dir) = match normalize_relative_staging_path(
        &state.config.pgdata_backup_staging_dir,
        &probe_relative_dir,
    ) {
        Ok(paths) => paths,
        Err(error) => {
            checks.insert(
                "staging".to_string(),
                json!({ "ok": false, "error": error.to_string() }),
            );
            return preflight_failure(
                "staging-unwritable",
                false,
                "Fix PGDATA backup staging path configuration so it stays inside PGDATA_BACKUP_STAGING_DIR.",
                Value::Object(checks),
            );
        }
    };
    let probe_file = probe_dir.join("write-check.tmp");
    let staging_result = async {
        fs::create_dir_all(&probe_dir).await?;
        fs::write(&probe_file, b"ok").await?;
        anyhow::Ok(())
    }
    .await;
    if let Err(error) = staging_result {
        checks.insert(
            "staging".to_string(),
            json!({ "ok": false, "path": probe_dir.to_string_lossy(), "error": error.to_string() }),
        );
        return preflight_failure(
            "staging-unwritable",
            false,
            "Ensure the shared Baidu Netdisk staging directory is mounted and writable by backend-core before retrying.",
            Value::Object(checks),
        );
    }
    let _ = fs::remove_file(&probe_file).await;
    let _ = fs::remove_dir(&probe_dir).await;
    checks.insert(
        "staging".to_string(),
        json!({ "ok": true, "path": probe_dir.to_string_lossy() }),
    );

    let args = vec![
        "-h".to_string(),
        db_host.to_string(),
        "-p".to_string(),
        db_port.to_string(),
        "-U".to_string(),
        db_user.to_string(),
        "-d".to_string(),
        db_name.to_string(),
        "-tAc".to_string(),
        "select 1".to_string(),
    ];
    match run_local_command(
        "psql",
        &args,
        vec![
            ("PGPASSWORD", db_password.to_string()),
            ("PGCONNECT_TIMEOUT", "5".to_string()),
        ],
        10_000,
    )
    .await
    {
        Ok((true, stdout, _stderr, exit_code, timed_out)) => {
            checks.insert(
                "database".to_string(),
                json!({ "ok": true, "host": db_host, "port": db_port, "database": db_name, "exitCode": exit_code, "timedOut": timed_out, "stdout": truncate_text(&stdout, 200) }),
            );
        }
        Ok((false, stdout, stderr, exit_code, timed_out)) => {
            checks.insert(
                "database".to_string(),
                json!({ "ok": false, "host": db_host, "port": db_port, "database": db_name, "exitCode": exit_code, "timedOut": timed_out, "stdout": truncate_text(&stdout, 500), "stderr": truncate_text(&stderr, 1000) }),
            );
            return preflight_failure(
                "database-unreachable",
                true,
                "Check database reachability and credentials from backend-core before retrying the PGDATA backup.",
                Value::Object(checks),
            );
        }
        Err(error) => {
            checks.insert(
                "database".to_string(),
                json!({ "ok": false, "host": db_host, "port": db_port, "database": db_name, "error": error.to_string() }),
            );
            return preflight_failure(
                "database-unreachable",
                true,
                "Check that PostgreSQL client tools and the database route are available inside backend-core.",
                Value::Object(checks),
            );
        }
    }

    json!({
        "ok": true,
        "stage": "preflight",
        "observedAt": crate::json_util::now_iso(),
        "checks": Value::Object(checks),
    })
}

async fn wait_for_baidu_transfer(
    state: &Arc<AppState>,
    base_url: &str,
    job_id: &str,
    timeout_ms: u64,
) -> anyhow::Result<Value> {
    let deadline = Utc::now().timestamp_millis() + timeout_ms as i64;
    let mut latest = json!({});
    let mut latest_error = String::new();
    while Utc::now().timestamp_millis() < deadline {
        match baidu_json(
            base_url,
            &format!("/api/transfers/{job_id}"),
            reqwest::Method::GET,
            None,
            30_000,
        )
        .await
        {
            Ok(detail) => {
                latest = detail.clone();
                latest_error.clear();
                let status = detail
                    .pointer("/job/status")
                    .and_then(Value::as_str)
                    .unwrap_or("");
                if status == "succeeded" {
                    return Ok(detail);
                }
                if status == "failed" || status == "canceled" {
                    anyhow::bail!(
                        "Baidu transfer {status}: {}",
                        detail
                            .pointer("/job/error")
                            .and_then(Value::as_str)
                            .unwrap_or("no error detail")
                    );
                }
            }
            Err(error) => {
                latest_error = error.to_string();
                state.log(
                    "warn",
                    "baidu_transfer_poll_failed",
                    Some(json!({ "jobId": job_id, "error": latest_error })),
                );
            }
        }
        tokio::time::sleep(Duration::from_secs(2)).await;
    }
    anyhow::bail!(
        "Baidu transfer timed out after {timeout_ms}ms: {}",
        compact_json(&json!({ "latest": latest, "latestError": latest_error }))
    );
}

async fn execute_dispatch_schedule_action(
    state: &Arc<AppState>,
    provider_id: &str,
    command: &str,
    payload: &Value,
    timeout_ms: Option<u64>,
) -> anyhow::Result<(bool, String, Value)> {
    let mut dispatch_payload = payload.clone();
    if let Some(object) = dispatch_payload.as_object_mut() {
        object.insert(
            "source".to_string(),
            Value::String("scheduled-task".to_string()),
        );
    }
    let (task_id, provider_online) =
        create_and_send_task(state, provider_id, command, dispatch_payload.clone()).await?;
    if !provider_online {
        return Ok((
            false,
            task_id.clone(),
            json!({ "providerOnline": provider_online, "taskId": task_id, "error": format!("provider is offline: {provider_id}") }),
        ));
    }
    let timeout_ms = timeout_ms.unwrap_or_else(|| state.config.task_pending_timeout_ms + 5000);
    let task = wait_for_task_terminal(state, &task_id, timeout_ms).await?;
    let ok = task.as_ref().is_some_and(|task| task.status == "succeeded");
    Ok((
        ok,
        task_id.clone(),
        json!({
            "providerOnline": provider_online,
            "taskId": task_id,
            "timeoutMs": timeout_ms,
            "terminal": task.as_ref().is_some_and(|task| is_terminal_task_status(&task.status)),
            "task": scheduled_raw_task_json(task.as_ref()),
        }),
    ))
}

async fn execute_pgdata_backup_action(
    state: &Arc<AppState>,
    action: &ScheduleAction,
    run_id: &str,
) -> anyhow::Result<(bool, Value)> {
    let ScheduleAction::PgdataBackup {
        volume_name,
        remote_base_dir,
        staging_subdir,
        baidu_base_url,
        timeout_ms,
        cleanup_local,
    } = action
    else {
        unreachable!();
    };
    let started_at = Utc::now();
    let (_, _, month, stamp) = utc_backup_parts(started_at);
    let filename = format!(
        "{}_{}.pg_basebackup.tar.gz",
        stamp,
        safe_file_part(volume_name)
    );
    let month_relative_dir = format!("{staging_subdir}/{month}");
    let backup_relative_path = format!("{month_relative_dir}/{filename}");
    let (_, backup_path) = normalize_relative_staging_path(
        &state.config.pgdata_backup_staging_dir,
        &backup_relative_path,
    )?;
    let temp_relative_dir = format!("{staging_subdir}/{month}/.tmp_{run_id}");
    let (_, temp_dir) = normalize_relative_staging_path(
        &state.config.pgdata_backup_staging_dir,
        &temp_relative_dir,
    )?;
    let remote_dir = normalize_remote_dir(&format!("{remote_base_dir}/{month}"))?;
    let remote_path = normalize_remote_dir(&format!("{remote_dir}/{filename}"))?;
    let (db_host, db_port, db_user, db_password, db_name) =
        database_connection_parts(&state.config.database_url)?;
    let preflight = pgdata_backup_preflight(
        state,
        staging_subdir,
        baidu_base_url,
        &db_host,
        &db_port,
        &db_user,
        &db_password,
        &db_name,
        run_id,
    )
    .await;
    if preflight.get("ok").and_then(Value::as_bool) != Some(true) {
        return Ok((false, preflight));
    }
    if let Some(parent) = backup_path.parent() {
        fs::create_dir_all(parent).await?;
    }
    let _ = fs::remove_dir_all(&temp_dir).await;
    fs::create_dir_all(&temp_dir).await?;
    let mut upload_detail: Option<Value> = None;
    let mut upload_job_id = String::new();
    let result = async {
        let backup_timeout_ms = (*timeout_ms as f64 * 0.55).floor().max(60_000.0) as u64;
        let package_timeout_ms = (*timeout_ms as f64 * 0.15).floor().max(60_000.0) as u64;
        let upload_timeout_ms = timeout_ms.saturating_sub(backup_timeout_ms + package_timeout_ms).max(60_000);
        let args = vec![
            "-h".to_string(), db_host,
            "-p".to_string(), db_port,
            "-U".to_string(), db_user,
            "-D".to_string(), temp_dir.to_string_lossy().to_string(),
            "-Ft".to_string(),
            "-X".to_string(), "stream".to_string(),
            "-z".to_string(),
            "--checkpoint=fast".to_string(),
            "--no-sync".to_string(),
        ];
        let (ok, stdout, stderr, exit_code, timed_out) = run_local_command("pg_basebackup", &args, vec![("PGPASSWORD", db_password)], backup_timeout_ms).await?;
        if !ok {
            anyhow::bail!("pg_basebackup failed exit={exit_code:?} timedOut={timed_out}: {}", if stderr.is_empty() { stdout } else { stderr });
        }
        let tar_args = vec!["-czf".to_string(), backup_path.to_string_lossy().to_string(), "-C".to_string(), temp_dir.to_string_lossy().to_string(), ".".to_string()];
        let (ok, stdout, stderr, exit_code, timed_out) = run_local_command("tar", &tar_args, vec![], package_timeout_ms).await?;
        if !ok {
            anyhow::bail!("tar packaging failed exit={exit_code:?} timedOut={timed_out}: {}", if stderr.is_empty() { stdout } else { stderr });
        }
        let metadata = fs::metadata(&backup_path).await?;
        let backup_bytes = metadata.len();
        let backup_sha256 = sha256_file(&backup_path).await?;
        let mut current = String::new();
        for part in remote_dir.split('/').filter(|part| !part.is_empty()) {
            current.push('/');
            current.push_str(part);
            let _ = baidu_json(baidu_base_url, "/api/folders", reqwest::Method::POST, Some(json!({ "path": current })), 60_000).await?;
        }
        let created = baidu_json(baidu_base_url, "/api/transfers/upload-from-path", reqwest::Method::POST, Some(json!({ "localPath": backup_relative_path, "remotePath": remote_path })), 60_000).await?;
        let job_id = created.pointer("/job/id").and_then(Value::as_str).unwrap_or("").to_string();
        if job_id.is_empty() {
            anyhow::bail!("Baidu upload did not return a job id: {}", compact_json(&created));
        }
        upload_job_id = job_id.clone();
        let detail = wait_for_baidu_transfer(state, baidu_base_url, &job_id, upload_timeout_ms).await?;
        upload_detail = Some(detail.clone());
        let uploaded_job = detail.get("job").cloned().unwrap_or_else(|| json!({}));
        anyhow::Ok((true, json!({
            "backupType": "pg_basebackup",
            "volumeName": volume_name,
            "backupBytes": backup_bytes,
            "backupSha256": backup_sha256,
            "localRelativePath": backup_relative_path,
            "remotePath": remote_path,
            "remoteDir": remote_dir,
            "month": month,
            "timestampPrefix": stamp,
            "preflight": preflight,
            "baiduTransferJobId": job_id,
            "baiduTransferStatus": uploaded_job.get("status").and_then(Value::as_str).unwrap_or(""),
            "baiduFsId": uploaded_job.get("fsId").and_then(Value::as_str).unwrap_or(""),
            "baiduResult": compact_json(uploaded_job.get("result").unwrap_or(&Value::Null)),
        })))
    }.await;
    let _ = fs::remove_dir_all(&temp_dir).await;
    if *cleanup_local && (upload_job_id.is_empty() || upload_detail.is_some()) {
        let _ = fs::remove_file(&backup_path).await;
    }
    result
}

async fn execute_schedule_action(
    state: &Arc<AppState>,
    action: &ScheduleAction,
    run_id: &str,
) -> anyhow::Result<(bool, Option<String>, Value)> {
    match action {
        ScheduleAction::Dispatch {
            provider_id,
            command,
            payload,
            timeout_ms,
        } => {
            let (ok, task_id, result) =
                execute_dispatch_schedule_action(state, provider_id, command, payload, *timeout_ms)
                    .await?;
            Ok((ok, Some(task_id), result))
        }
        ScheduleAction::PgdataBackup { .. } => {
            let (ok, result) = execute_pgdata_backup_action(state, action, run_id).await?;
            Ok((ok, None, result))
        }
    }
}

async fn get_scheduled_task_row(
    state: &Arc<AppState>,
    schedule_id: &str,
) -> anyhow::Result<Option<ScheduledTaskRow>> {
    let client = state.pool.get().await?;
    Ok(client
        .query_opt(
            "SELECT * FROM unidesk_scheduled_tasks WHERE id = $1 LIMIT 1",
            &[&schedule_id],
        )
        .await?
        .map(schedule_task_from_row))
}

async fn get_scheduled_task_run_row(
    state: &Arc<AppState>,
    run_id: &str,
) -> anyhow::Result<Option<ScheduledTaskRunRow>> {
    let client = state.pool.get().await?;
    Ok(client
        .query_opt(
            "SELECT * FROM unidesk_scheduled_task_runs WHERE id = $1 LIMIT 1",
            &[&run_id],
        )
        .await?
        .map(schedule_run_from_row))
}

async fn execute_scheduled_run(state: Arc<AppState>, run_id: String) {
    {
        let mut active = state.active_scheduled_runs.lock().await;
        if !active.insert(run_id.clone()) {
            return;
        }
    }
    let started = Utc::now().timestamp_millis();
    let result = async {
        let client = state.pool.get().await?;
        let queued = client.query_opt("SELECT * FROM unidesk_scheduled_task_runs WHERE id = $1 LIMIT 1", &[&run_id]).await?.map(schedule_run_from_row);
        let Some(queued) = queued else { return anyhow::Ok(()) };
        let Some(schedule_row) = get_scheduled_task_row(&state, &queued.schedule_id).await? else { return anyhow::Ok(()) };
        let updated = client.query("UPDATE unidesk_scheduled_task_runs SET status = 'running', started_at = now(), updated_at = now() WHERE id = $1 AND status = 'queued' RETURNING *", &[&run_id]).await?;
        if updated.is_empty() {
            return anyhow::Ok(());
        }
        let (action, _) = normalize_schedule_action(&state, Some(&schedule_row.action_json))?;
        match execute_schedule_action(&state, &action, &run_id).await {
            Ok((ok, task_id, outcome)) => {
                let duration_ms = Utc::now().timestamp_millis() - started;
                let status = if ok { "succeeded" } else { "failed" };
                let error: Option<String> = if ok { None } else { Some("scheduled action failed".to_string()) };
                client.execute(
                    "UPDATE unidesk_scheduled_task_runs SET status = $2, task_id = $3, result = $4, error = $5, finished_at = now(), duration_ms = $6, updated_at = now() WHERE id = $1",
                    &[&run_id, &status, &task_id, &outcome, &error, &duration_ms],
                ).await?;
                client.execute("UPDATE unidesk_scheduled_tasks SET last_run_at = now(), last_run_id = $2, updated_at = now() WHERE id = $1", &[&schedule_row.id, &run_id]).await?;
                record_event(&state, &format!("scheduled_task_{status}"), "scheduler", json!({ "scheduleId": schedule_row.id, "runId": run_id, "durationMs": duration_ms, "result": compact_json(&outcome) })).await;
            }
            Err(error) => {
                let duration_ms = Utc::now().timestamp_millis() - started;
                let error_text = error.to_string();
                let result = json!({ "name": "Error", "message": error_text });
                client.execute(
                    "UPDATE unidesk_scheduled_task_runs SET status = 'failed', result = $2, error = $3, finished_at = now(), duration_ms = $4, updated_at = now() WHERE id = $1",
                    &[&run_id, &result, &error_text, &duration_ms],
                ).await?;
                client.execute("UPDATE unidesk_scheduled_tasks SET last_run_at = now(), last_run_id = $2, updated_at = now() WHERE id = $1", &[&schedule_row.id, &run_id]).await?;
                record_event(&state, "scheduled_task_failed", "scheduler", json!({ "scheduleId": schedule_row.id, "runId": run_id, "durationMs": duration_ms, "error": error_text })).await;
            }
        }
        anyhow::Ok(())
    }.await;
    if let Err(error) = result {
        state.log(
            "error",
            "scheduled_run_uncaught",
            Some(json!({ "runId": run_id, "error": error.to_string() })),
        );
    }
    state.active_scheduled_runs.lock().await.remove(&run_id);
}

async fn get_scheduled_tasks(state: &Arc<AppState>, limit: i64) -> anyhow::Result<Vec<Value>> {
    let client = state.pool.get().await?;
    let rows = client.query("SELECT * FROM unidesk_scheduled_tasks ORDER BY enabled DESC, next_run_at ASC NULLS LAST, updated_at DESC LIMIT $1", &[&limit]).await?;
    let tasks = rows
        .into_iter()
        .map(schedule_task_from_row)
        .collect::<Vec<_>>();
    let run_limit = (limit * 5).max(20);
    let runs = client
        .query(
            "SELECT * FROM unidesk_scheduled_task_runs ORDER BY updated_at DESC LIMIT $1",
            &[&run_limit],
        )
        .await?
        .into_iter()
        .map(schedule_run_from_row)
        .collect::<Vec<_>>();
    Ok(tasks
        .iter()
        .map(|task| {
            let recent = runs
                .iter()
                .filter(|run| run.schedule_id == task.id)
                .take(5)
                .cloned()
                .collect::<Vec<_>>();
            scheduled_task_view(task, &recent)
        })
        .collect())
}

async fn get_scheduled_task_runs(
    state: &Arc<AppState>,
    schedule_id: Option<&str>,
    limit: i64,
) -> anyhow::Result<Vec<Value>> {
    let client = state.pool.get().await?;
    let rows = if let Some(schedule_id) = schedule_id {
        client.query("SELECT * FROM unidesk_scheduled_task_runs WHERE schedule_id = $1 ORDER BY updated_at DESC LIMIT $2", &[&schedule_id, &limit]).await?
    } else {
        client
            .query(
                "SELECT * FROM unidesk_scheduled_task_runs ORDER BY updated_at DESC LIMIT $1",
                &[&limit],
            )
            .await?
    };
    Ok(rows
        .into_iter()
        .map(schedule_run_from_row)
        .map(|run| schedule_run_view(&run))
        .collect())
}

async fn upsert_scheduled_task(
    state: &Arc<AppState>,
    body: Value,
    schedule_id_from_path: Option<&str>,
) -> anyhow::Result<crate::http::HttpResponse> {
    let id_value = schedule_id_from_path
        .map(|value| Value::String(value.to_string()))
        .or_else(|| body.get("id").cloned());
    let id = normalize_schedule_id(id_value.as_ref())?;
    let (schedule, schedule_json) = normalize_schedule_spec(body.get("schedule"))?;
    let (_action, action_json) = normalize_schedule_action(state, body.get("action"))?;
    let name = body
        .get("name")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&id)
        .trim()
        .to_string();
    let description = body
        .get("description")
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string();
    let enabled = body.get("enabled").and_then(Value::as_bool).unwrap_or(true);
    let concurrency_policy =
        if body.get("concurrencyPolicy").and_then(Value::as_str) == Some("parallel") {
            "parallel"
        } else {
            "skip"
        };
    let existing = get_scheduled_task_row(state, &id).await?;
    let next_run_at = if let Some(existing) = &existing {
        if existing.schedule_json == schedule_json {
            existing
                .next_run_at
                .unwrap_or_else(|| compute_next_run_at(&schedule, Utc::now()))
        } else {
            compute_next_run_at(&schedule, Utc::now())
        }
    } else {
        compute_next_run_at(&schedule, Utc::now())
    };
    let client = state.pool.get().await?;
    let row = client.query_one(
        r#"
        INSERT INTO unidesk_scheduled_tasks (id, name, description, enabled, schedule_json, action_json, concurrency_policy, next_run_at, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
        ON CONFLICT (id) DO UPDATE SET
          name = EXCLUDED.name,
          description = EXCLUDED.description,
          enabled = EXCLUDED.enabled,
          schedule_json = EXCLUDED.schedule_json,
          action_json = EXCLUDED.action_json,
          concurrency_policy = EXCLUDED.concurrency_policy,
          next_run_at = EXCLUDED.next_run_at,
          updated_at = now()
        RETURNING *
        "#,
        &[&id, &name, &description, &enabled, &schedule_json, &action_json, &concurrency_policy, &next_run_at],
    ).await?;
    let row = schedule_task_from_row(row);
    record_event(state, if existing.is_none() { "scheduled_task_created" } else { "scheduled_task_updated" }, "scheduler", json!({ "scheduleId": id, "name": name, "enabled": enabled, "schedule": schedule_json, "action": compact_json(&action_json) })).await;
    Ok(json_response(
        json!({ "ok": true, "schedule": scheduled_task_view(&row, &[]) }),
        if existing.is_none() { 201 } else { 200 },
    ))
}

async fn patch_scheduled_task(
    state: &Arc<AppState>,
    schedule_id: &str,
    body: Value,
) -> anyhow::Result<crate::http::HttpResponse> {
    let Some(current) = get_scheduled_task_row(state, schedule_id).await? else {
        return Ok(json_response(
            json!({ "ok": false, "error": format!("scheduled task not found: {schedule_id}") }),
            404,
        ));
    };
    let (schedule, schedule_json) = if body.get("schedule").is_some() {
        normalize_schedule_spec(body.get("schedule"))?
    } else {
        normalize_schedule_spec(Some(&current.schedule_json))?
    };
    let (_, action_json) = if body.get("action").is_some() {
        normalize_schedule_action(state, body.get("action"))?
    } else {
        normalize_schedule_action(state, Some(&current.action_json))?
    };
    let name = body
        .get("name")
        .and_then(Value::as_str)
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&current.name)
        .trim()
        .to_string();
    let description = body
        .get("description")
        .and_then(Value::as_str)
        .unwrap_or(&current.description)
        .to_string();
    let enabled = body
        .get("enabled")
        .and_then(Value::as_bool)
        .unwrap_or(current.enabled);
    let concurrency_policy = match body.get("concurrencyPolicy").and_then(Value::as_str) {
        Some("parallel") => "parallel",
        Some("skip") => "skip",
        _ => &current.concurrency_policy,
    };
    let schedule_changed = body.get("schedule").is_some() && schedule_json != current.schedule_json;
    let next_run_at = if schedule_changed || current.next_run_at.is_none() {
        compute_next_run_at(&schedule, Utc::now())
    } else {
        current.next_run_at.unwrap()
    };
    let client = state.pool.get().await?;
    let row = client.query_one(
        "UPDATE unidesk_scheduled_tasks SET name=$2, description=$3, enabled=$4, schedule_json=$5, action_json=$6, concurrency_policy=$7, next_run_at=$8, updated_at=now() WHERE id=$1 RETURNING *",
        &[&schedule_id, &name, &description, &enabled, &schedule_json, &action_json, &concurrency_policy, &next_run_at],
    ).await?;
    let row = schedule_task_from_row(row);
    record_event(state, "scheduled_task_updated", "scheduler", json!({ "scheduleId": schedule_id, "name": name, "enabled": enabled, "schedule": schedule_json, "action": compact_json(&action_json) })).await;
    Ok(json_response(
        json!({ "ok": true, "schedule": scheduled_task_view(&row, &[]) }),
        200,
    ))
}

async fn delete_scheduled_task(
    state: &Arc<AppState>,
    schedule_id: &str,
) -> anyhow::Result<crate::http::HttpResponse> {
    let client = state.pool.get().await?;
    let rows = client
        .query(
            "DELETE FROM unidesk_scheduled_tasks WHERE id = $1 RETURNING id",
            &[&schedule_id],
        )
        .await?;
    if rows.is_empty() {
        return Ok(json_response(
            json!({ "ok": false, "error": format!("scheduled task not found: {schedule_id}") }),
            404,
        ));
    }
    record_event(
        state,
        "scheduled_task_deleted",
        "scheduler",
        json!({ "scheduleId": schedule_id }),
    )
    .await;
    Ok(json_response(
        json!({ "ok": true, "deleted": schedule_id }),
        200,
    ))
}

async fn trigger_scheduled_task(
    state: &Arc<AppState>,
    schedule_id: &str,
    trigger_type: &str,
) -> anyhow::Result<Option<Value>> {
    let run_id = format!(
        "schedrun_{}_{}",
        Utc::now().timestamp_millis(),
        format!("{:x}", rand::thread_rng().gen::<u32>())
    );
    let client = state.pool.get().await?;
    let row = client
        .query_opt(
            "SELECT * FROM unidesk_scheduled_tasks WHERE id = $1",
            &[&schedule_id],
        )
        .await?;
    let Some(row) = row else {
        anyhow::bail!("scheduled task not found: {schedule_id}");
    };
    let schedule_row = schedule_task_from_row(row);
    if trigger_type == "schedule" && !schedule_row.enabled {
        return Ok(None);
    }
    let running_count: i64 = client.query_one("SELECT count(*)::bigint AS count FROM unidesk_scheduled_task_runs WHERE schedule_id = $1 AND status IN ('queued', 'running')", &[&schedule_id]).await?.get("count");
    let (schedule, _) = normalize_schedule_spec(Some(&schedule_row.schedule_json))?;
    if trigger_type == "schedule" {
        let next_run_at = compute_next_run_at(&schedule, Utc::now());
        client.execute("UPDATE unidesk_scheduled_tasks SET next_run_at = $2, updated_at = now() WHERE id = $1", &[&schedule_id, &next_run_at]).await?;
    }
    let run_row = if schedule_row.concurrency_policy != "parallel" && running_count > 0 {
        client.query_one("INSERT INTO unidesk_scheduled_task_runs (id, schedule_id, trigger_type, status, result, error, finished_at, duration_ms) VALUES ($1, $2, $3, 'skipped', $4, 'previous run still active', now(), 0) RETURNING *", &[&run_id, &schedule_id, &trigger_type, &json!({ "reason": "previous run still active", "runningCount": running_count })]).await?
    } else {
        client.query_one("INSERT INTO unidesk_scheduled_task_runs (id, schedule_id, trigger_type, status) VALUES ($1, $2, $3, 'queued') RETURNING *", &[&run_id, &schedule_id, &trigger_type]).await?
    };
    let run = schedule_run_from_row(run_row);
    if run.status == "queued" {
        tokio::spawn(execute_scheduled_run(state.clone(), run_id.clone()));
    }
    record_event(state, "scheduled_task_triggered", "scheduler", json!({ "scheduleId": schedule_id, "runId": run_id, "triggerType": trigger_type, "status": run.status })).await;
    Ok(Some(schedule_run_view(&run)))
}

async fn retry_scheduled_run(
    state: &Arc<AppState>,
    original_run_id: &str,
) -> anyhow::Result<crate::http::HttpResponse> {
    let Some(original_run) = get_scheduled_task_run_row(state, original_run_id).await? else {
        return Ok(json_response(
            json!({ "ok": false, "error": format!("scheduled run not found: {original_run_id}") }),
            404,
        ));
    };
    if original_run.status != "failed" {
        return Ok(json_response(
            json!({
                "ok": false,
                "error": "only failed scheduled runs can be retried through this endpoint",
                "originalRun": schedule_run_view(&original_run),
            }),
            409,
        ));
    }
    let schedule_id = original_run.schedule_id.clone();
    match trigger_scheduled_task(state, &schedule_id, "retry").await? {
        Some(run) => {
            let new_run_id = run.get("id").and_then(Value::as_str).unwrap_or("");
            Ok(json_response(
                json!({
                    "ok": true,
                    "originalRunId": original_run_id,
                    "scheduleId": schedule_id,
                    "newRunId": new_run_id,
                    "originalRun": schedule_run_view(&original_run),
                    "run": run,
                }),
                200,
            ))
        }
        None => Ok(json_response(
            json!({ "ok": false, "error": format!("scheduled task was not retried: {schedule_id}") }),
            409,
        )),
    }
}

pub async fn scheduled_task_route(
    state: &Arc<AppState>,
    method: Method,
    path: &str,
    query: &str,
    body: Bytes,
) -> anyhow::Result<crate::http::HttpResponse> {
    let prefix = "/api/schedules";
    let rest = if path == prefix {
        ""
    } else {
        path.strip_prefix(&format!("{prefix}/")).unwrap_or("")
    };
    let segments = rest
        .split('/')
        .filter(|part| !part.is_empty())
        .map(percent_decode)
        .collect::<Vec<_>>();
    let url = ParsedUrl::new(path, query);
    match (segments.as_slice(), method.as_str()) {
        ([], "GET") => Ok(json_response(
            json!({ "ok": true, "schedules": get_scheduled_tasks(state, read_limit(&url, 100)).await? }),
            200,
        )),
        ([], "POST") => {
            let body = serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| json!({}));
            upsert_scheduled_task(state, body, None).await
        }
        ([only], "GET") if only == "runs" => Ok(json_response(
            json!({ "ok": true, "scope": "global", "scheduleId": Value::Null, "runs": get_scheduled_task_runs(state, None, read_limit(&url, 100)).await? }),
            200,
        )),
        ([scope, run_id], "GET") if scope == "runs" => {
            let Some(run) = get_scheduled_task_run_row(state, run_id).await? else {
                return Ok(json_response(
                    json!({ "ok": false, "error": format!("scheduled run not found: {run_id}") }),
                    404,
                ));
            };
            Ok(json_response(
                json!({ "ok": true, "run": schedule_run_view(&run) }),
                200,
            ))
        }
        ([scope, run_id, action], "POST") if scope == "runs" && action == "retry" => {
            retry_scheduled_run(state, run_id).await
        }
        ([schedule_id], "GET") => {
            let Some(schedule) = get_scheduled_task_row(state, schedule_id).await? else {
                return Ok(json_response(
                    json!({ "ok": false, "error": format!("scheduled task not found: {schedule_id}") }),
                    404,
                ));
            };
            let runs = get_scheduled_task_runs_raw(state, schedule_id, 20).await?;
            Ok(json_response(
                json!({ "ok": true, "schedule": scheduled_task_view(&schedule, &runs) }),
                200,
            ))
        }
        ([schedule_id], "PUT") => {
            let body = serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| json!({}));
            upsert_scheduled_task(state, body, Some(schedule_id)).await
        }
        ([schedule_id], "PATCH") => {
            let body = serde_json::from_slice::<Value>(&body).unwrap_or_else(|_| json!({}));
            patch_scheduled_task(state, schedule_id, body).await
        }
        ([schedule_id], "DELETE") => delete_scheduled_task(state, schedule_id).await,
        ([schedule_id, action], "POST") if action == "run" => {
            match trigger_scheduled_task(state, schedule_id, "manual").await? {
                Some(run) => Ok(json_response(json!({ "ok": true, "run": run }), 200)),
                None => Ok(json_response(
                    json!({ "ok": false, "error": format!("scheduled task was not triggered: {schedule_id}") }),
                    409,
                )),
            }
        }
        ([schedule_id, action], "GET") if action == "runs" => Ok(json_response(
            json!({ "ok": true, "scope": "schedule", "scheduleId": schedule_id, "runs": get_scheduled_task_runs(state, Some(schedule_id), read_limit(&url, 100)).await? }),
            200,
        )),
        _ => Ok(json_response(
            json!({ "ok": false, "error": "scheduled task route not found", "path": path }),
            404,
        )),
    }
}

async fn get_scheduled_task_runs_raw(
    state: &Arc<AppState>,
    schedule_id: &str,
    limit: i64,
) -> anyhow::Result<Vec<ScheduledTaskRunRow>> {
    let client = state.pool.get().await?;
    Ok(client.query("SELECT * FROM unidesk_scheduled_task_runs WHERE schedule_id = $1 ORDER BY updated_at DESC LIMIT $2", &[&schedule_id, &limit]).await?.into_iter().map(schedule_run_from_row).collect())
}

pub async fn recover_scheduled_runs(state: &Arc<AppState>) -> anyhow::Result<()> {
    let client = state.pool.get().await?;
    let rows = client.query(
        r#"
        UPDATE unidesk_scheduled_task_runs
        SET status = 'failed',
            error = 'backend-core restarted before scheduled run completed',
            result = jsonb_build_object('error', 'backend-core restarted before scheduled run completed'),
            finished_at = now(),
            updated_at = now()
        WHERE status IN ('queued', 'running')
        RETURNING *
        "#,
        &[],
    ).await?;
    if !rows.is_empty() {
        record_event(
            state,
            "scheduled_runs_recovered",
            "scheduler",
            json!({ "failedRunCount": rows.len() }),
        )
        .await;
    }
    let schedules = client
        .query(
            "SELECT * FROM unidesk_scheduled_tasks WHERE next_run_at IS NULL LIMIT 200",
            &[],
        )
        .await?
        .into_iter()
        .map(schedule_task_from_row)
        .collect::<Vec<_>>();
    for schedule in schedules {
        let (spec, _) = normalize_schedule_spec(Some(&schedule.schedule_json))?;
        let next_run_at = compute_next_run_at(&spec, Utc::now());
        client.execute("UPDATE unidesk_scheduled_tasks SET next_run_at = $2, updated_at = now() WHERE id = $1", &[&schedule.id, &next_run_at]).await?;
    }
    Ok(())
}

pub async fn run_due_scheduled_tasks(state: &Arc<AppState>) -> anyhow::Result<()> {
    if !state.db_ready() {
        return Ok(());
    }
    let client = state.pool.get().await?;
    let rows = client.query(
        "SELECT * FROM unidesk_scheduled_tasks WHERE enabled = true AND (next_run_at IS NULL OR next_run_at <= now()) ORDER BY next_run_at ASC NULLS FIRST LIMIT 5",
        &[],
    ).await?;
    for row in rows {
        let schedule = schedule_task_from_row(row);
        if let Err(error) = trigger_scheduled_task(state, &schedule.id, "schedule").await {
            state.log(
                "error",
                "scheduled_task_due_trigger_failed",
                Some(json!({ "scheduleId": schedule.id, "error": error.to_string() })),
            );
        }
    }
    Ok(())
}

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

    #[test]
    fn baidu_health_preflight_failure_classifies_missing_token_config() {
        let health = json!({
            "ok": true,
            "auth": {
                "clientIdConfigured": true,
                "clientSecretConfigured": true,
                "tokenKeyConfigured": false,
                "loggedIn": true
            }
        });
        let failure = baidu_health_preflight_failure(&health).expect("missing token must fail");
        assert_eq!(failure.0, "config-missing");
        assert!(!failure.1);
        assert!(failure.2.contains("BAIDU_NETDISK_TOKEN_KEY"));
    }

    #[test]
    fn baidu_health_preflight_failure_classifies_logged_out_auth() {
        let health = json!({
            "ok": true,
            "auth": {
                "clientIdConfigured": true,
                "clientSecretConfigured": true,
                "tokenKeyConfigured": true,
                "loggedIn": false
            }
        });
        let failure = baidu_health_preflight_failure(&health).expect("logged out must fail");
        assert_eq!(failure.0, "auth-missing");
        assert!(!failure.1);
    }

    #[test]
    fn preflight_failure_exposes_structured_fields() {
        let failure = preflight_failure(
            "config-missing",
            false,
            "restore runtime env",
            json!({ "baidu": { "ok": false } }),
        );
        assert_eq!(failure.get("ok").and_then(Value::as_bool), Some(false));
        assert_eq!(
            failure.get("stage").and_then(Value::as_str),
            Some("preflight")
        );
        assert_eq!(
            failure.get("failureKind").and_then(Value::as_str),
            Some("config-missing")
        );
        assert_eq!(
            failure.get("retryable").and_then(Value::as_bool),
            Some(false)
        );
        assert!(failure.get("observedAt").and_then(Value::as_str).is_some());
        assert_eq!(
            failure.get("recommendedAction").and_then(Value::as_str),
            Some("restore runtime env")
        );
    }
}
