use base64::{engine::general_purpose::STANDARD, Engine as _};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::env;
use std::io::ErrorKind;
use std::net::{SocketAddr, TcpStream};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{Manager, State};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EngineStartRequest {
  pub binary_path: Option<String>,
  pub rpc_listen_port: Option<u16>,
  pub rpc_secret: Option<String>,
  pub download_dir: Option<String>,
  pub max_concurrent_downloads: Option<u16>,
  pub split: Option<u16>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2RpcConfig {
  pub rpc_listen_port: Option<u16>,
  pub rpc_secret: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2AddUriRequest {
  pub rpc: Aria2RpcConfig,
  pub uri: String,
  pub out: Option<String>,
  pub dir: Option<String>,
  pub split: Option<u16>,
  pub options: Option<BTreeMap<String, Value>>,
  pub position: Option<u32>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2AddTorrentRequest {
  pub rpc: Aria2RpcConfig,
  pub torrent_base64: String,
  pub out: Option<String>,
  pub dir: Option<String>,
  pub split: Option<u16>,
  pub options: Option<BTreeMap<String, Value>>,
  pub position: Option<u32>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskCommandRequest {
  pub rpc: Aria2RpcConfig,
  pub gid: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskDetailRequest {
  pub rpc: Aria2RpcConfig,
  pub gid: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2ChangeGlobalOptionRequest {
  pub rpc: Aria2RpcConfig,
  pub options: BTreeMap<String, String>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EngineStatusResponse {
  pub running: bool,
  pub pid: Option<u32>,
  pub binary_path: Option<String>,
  pub args: Vec<String>,
  pub started_at: Option<u64>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskSummary {
  pub gid: String,
  pub status: String,
  pub total_length: String,
  pub completed_length: String,
  pub download_speed: String,
  pub dir: String,
  pub file_name: String,
  pub uri: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskSnapshot {
  pub active: Vec<Aria2TaskSummary>,
  pub waiting: Vec<Aria2TaskSummary>,
  pub stopped: Vec<Aria2TaskSummary>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskFile {
  pub path: String,
  pub length: String,
  pub completed_length: String,
  pub selected: String,
  pub uris: Vec<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskPeer {
  pub peer_id: String,
  pub ip: String,
  pub port: String,
  pub bitfield: String,
  pub am_choking: String,
  pub peer_choking: String,
  pub download_speed: String,
  pub upload_speed: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskServer {
  pub uri: String,
  pub current_uri: String,
  pub download_speed: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2TaskDetail {
  pub gid: String,
  pub status: String,
  pub total_length: String,
  pub completed_length: String,
  pub upload_length: String,
  pub download_speed: String,
  pub upload_speed: String,
  pub num_seeders: String,
  pub connections: String,
  pub piece_length: String,
  pub num_pieces: String,
  pub dir: String,
  pub info_hash: String,
  pub creation_date: String,
  pub comment: String,
  pub error_code: String,
  pub error_message: String,
  pub trackers: Vec<String>,
  pub files: Vec<Aria2TaskFile>,
  pub peers: Vec<Aria2TaskPeer>,
  pub servers: Vec<Aria2TaskServer>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TorrentFilePayload {
  pub name: String,
  pub base64: String,
  pub size: u64,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Aria2BinaryProbeResponse {
  pub found: bool,
  pub binary_path: Option<String>,
  pub source: Option<String>,
  pub candidates: Vec<String>,
}

#[derive(Debug)]
struct EngineRuntime {
  child: Option<Child>,
  external_reuse: bool,
  rpc_port: Option<u16>,
  binary_path: Option<String>,
  args: Vec<String>,
  started_at: Option<u64>,
}

impl Default for EngineRuntime {
  fn default() -> Self {
    Self {
      child: None,
      external_reuse: false,
      rpc_port: None,
      binary_path: None,
      args: vec![],
      started_at: None,
    }
  }
}

#[derive(Default)]
pub struct EngineState {
  runtime: Mutex<EngineRuntime>,
}

impl EngineState {
  fn status(runtime: &EngineRuntime) -> EngineStatusResponse {
    EngineStatusResponse {
      running: runtime.child.is_some() || runtime.external_reuse,
      pid: runtime.child.as_ref().map(std::process::Child::id),
      binary_path: runtime.binary_path.clone(),
      args: runtime.args.clone(),
      started_at: runtime.started_at,
    }
  }
}

fn reset_runtime_after_stop(runtime: &mut EngineRuntime) {
  runtime.child = None;
  runtime.external_reuse = false;
  runtime.rpc_port = None;
  runtime.started_at = None;
}

fn force_kill_pid(pid: u32) -> Result<(), String> {
  #[cfg(target_os = "windows")]
  {
    let status = Command::new("taskkill")
      .args(["/PID", &pid.to_string(), "/F", "/T"])
      .status()
      .map_err(|err| format!("failed to execute taskkill for pid {pid}: {err}"))?;
    if !status.success() {
      return Err(format!("taskkill failed for pid {pid} with status {status}"));
    }
    return Ok(());
  }

  #[cfg(not(target_os = "windows"))]
  {
    let status = Command::new("kill")
      .args(["-9", &pid.to_string()])
      .status()
      .map_err(|err| format!("failed to execute kill -9 for pid {pid}: {err}"))?;
    if !status.success() {
      return Err(format!("kill -9 failed for pid {pid} with status {status}"));
    }
    Ok(())
  }
}

fn list_listening_pids_on_port(port: u16) -> Result<Vec<u32>, String> {
  #[cfg(target_os = "windows")]
  {
    let _ = port;
    Ok(vec![])
  }

  #[cfg(not(target_os = "windows"))]
  {
    let target = format!(":{port}");
    let output = Command::new("lsof")
      .args(["-nP", "-iTCP", target.as_str(), "-sTCP:LISTEN", "-t"])
      .output()
      .map_err(|err| format!("failed to execute lsof for port {port}: {err}"))?;

    if !output.status.success() && output.stdout.is_empty() {
      return Ok(vec![]);
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let mut pids: Vec<u32> = text
      .lines()
      .filter_map(|line| line.trim().parse::<u32>().ok())
      .collect();
    pids.sort_unstable();
    pids.dedup();
    Ok(pids)
  }
}

fn try_release_local_port(port: u16) -> Result<(), String> {
  if !is_local_port_open(port) {
    return Ok(());
  }

  let pids = list_listening_pids_on_port(port)?;
  for pid in &pids {
    #[cfg(target_os = "windows")]
    {
      let status = Command::new("taskkill")
        .args(["/PID", &pid.to_string(), "/T"])
        .status()
        .map_err(|err| format!("failed to execute taskkill for pid {pid}: {err}"))?;
      if !status.success() {
        let _ = force_kill_pid(*pid);
      }
    }

    #[cfg(not(target_os = "windows"))]
    {
      let status = Command::new("kill")
        .args(["-15", &pid.to_string()])
        .status()
        .map_err(|err| format!("failed to execute kill -15 for pid {pid}: {err}"))?;
      if !status.success() {
        let _ = force_kill_pid(*pid);
      }
    }
  }

  std::thread::sleep(Duration::from_millis(350));
  if is_local_port_open(port) {
    for pid in list_listening_pids_on_port(port)? {
      let _ = force_kill_pid(pid);
    }
    std::thread::sleep(Duration::from_millis(200));
  }
  Ok(())
}

fn terminate_child_process(child: &mut Child) -> Result<(), String> {
  match child.try_wait() {
    Ok(Some(_)) => return Ok(()),
    Ok(None) => {}
    Err(err) => return Err(format!("failed to inspect aria2 process before stop: {err}")),
  }

  let pid = child.id();
  if let Err(err) = child.kill() {
    if err.kind() != ErrorKind::NotFound {
      return Err(format!("failed to stop aria2 engine: {err}"));
    }
  }

  let deadline = Instant::now() + Duration::from_millis(900);
  while Instant::now() < deadline {
    match child.try_wait() {
      Ok(Some(_)) => return Ok(()),
      Ok(None) => std::thread::sleep(Duration::from_millis(40)),
      Err(err) => {
        if err.kind() == ErrorKind::InvalidInput || err.kind() == ErrorKind::NotFound {
          return Ok(());
        }
        return Err(format!("failed while waiting aria2 stop: {err}"));
      }
    }
  }

  let _ = force_kill_pid(pid);
  let _ = child.wait();
  Ok(())
}

fn stop_engine_runtime(runtime: &mut EngineRuntime) -> Result<(), String> {
  let mut error: Option<String> = None;
  if let Some(mut child) = runtime.child.take() {
    if let Err(err) = terminate_child_process(&mut child) {
      error = Some(err);
    }
  }
  reset_runtime_after_stop(runtime);
  if let Some(err) = error {
    return Err(err);
  }
  Ok(())
}

pub fn stop_engine_for_exit(state: &EngineState) {
  if let Ok(mut runtime) = state.runtime.lock() {
    let _ = stop_engine_runtime(&mut runtime);
  }
}

fn build_engine_args(request: &EngineStartRequest) -> Vec<String> {
  let mut args = vec![
    "--enable-rpc=true".to_string(),
    "--rpc-listen-all=true".to_string(),
    "--rpc-allow-origin-all=true".to_string(),
    "--rpc-listen-port=".to_string() + &request.rpc_listen_port.unwrap_or(6800).to_string(),
    "--max-concurrent-downloads=".to_string() + &request.max_concurrent_downloads.unwrap_or(5).to_string(),
    "--split=".to_string() + &request.split.unwrap_or(8).to_string(),
    "--continue=true".to_string(),
    "--check-certificate=false".to_string(),
  ];

  if let Some(secret) = &request.rpc_secret {
    if !secret.trim().is_empty() {
      args.push(format!("--rpc-secret={secret}"));
    }
  }
  if let Some(download_dir) = &request.download_dir {
    if !download_dir.trim().is_empty() {
      args.push(format!("--dir={download_dir}"));
    }
  }

  args
}

fn default_aria2_binary() -> String {
  if cfg!(target_os = "windows") {
    "aria2c.exe".to_string()
  } else {
    "aria2c".to_string()
  }
}

fn normalize_binary_hint(binary_hint: Option<&str>) -> String {
  let value = binary_hint.unwrap_or("").trim();
  if value.is_empty() {
    default_aria2_binary()
  } else {
    value.to_string()
  }
}

fn candidate_push(path: &Path, out: &mut Vec<String>) {
  if let Some(s) = path.to_str() {
    let v = s.trim();
    if !v.is_empty() {
      out.push(v.to_string());
    }
  }
}

fn platform_aliases() -> Vec<&'static str> {
  match env::consts::OS {
    "macos" => vec!["macos", "darwin"],
    "windows" => vec!["windows", "win32"],
    "linux" => vec!["linux"],
    _ => vec![env::consts::OS],
  }
}

fn arch_aliases() -> Vec<&'static str> {
  match env::consts::ARCH {
    "aarch64" => vec!["aarch64", "arm64"],
    "x86_64" => vec!["x86_64", "x64"],
    "x86" => vec!["x86", "ia32"],
    "arm" => vec!["arm", "armv7l"],
    other => vec![other],
  }
}

fn collect_binary_candidates(app: &tauri::AppHandle, binary_hint: Option<&str>) -> Vec<String> {
  let binary_name = normalize_binary_hint(binary_hint);
  let mut candidates: Vec<String> = vec![];

  if let Some(raw) = binary_hint.map(str::trim).filter(|v| !v.is_empty()) {
    candidates.push(raw.to_string());
  }

  if let Ok(env_path) = env::var("ARIA2C_BIN") {
    let trimmed = env_path.trim();
    if !trimmed.is_empty() {
      candidates.push(trimmed.to_string());
    }
  }
  if let Ok(env_path) = env::var("ARIA2C_PATH") {
    let trimmed = env_path.trim();
    if !trimmed.is_empty() {
      candidates.push(trimmed.to_string());
    }
  }

  if let Some(path_var) = env::var_os("PATH") {
    for dir in env::split_paths(&path_var) {
      candidate_push(&dir.join(&binary_name), &mut candidates);
      #[cfg(target_os = "windows")]
      {
        let lower = binary_name.to_ascii_lowercase();
        if !lower.ends_with(".exe") {
          candidate_push(&dir.join(format!("{binary_name}.exe")), &mut candidates);
        }
      }
    }
  }

  let source_engine_base = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources").join("engine");
  let mut engine_bases = vec![source_engine_base];
  if let Ok(resource_dir) = app.path().resource_dir() {
    engine_bases.push(resource_dir.join("engine"));
  }

  let platforms = platform_aliases();
  let arches = arch_aliases();
  for base in engine_bases {
    for platform in &platforms {
      for arch in &arches {
        candidate_push(&base.join(platform).join(arch).join(&binary_name), &mut candidates);
      }
      candidate_push(&base.join(platform).join(&binary_name), &mut candidates);
    }
    candidate_push(&base.join(&binary_name), &mut candidates);
  }

  let mut deduped = Vec::with_capacity(candidates.len());
  for path in candidates {
    if !deduped.contains(&path) {
      deduped.push(path);
    }
  }
  deduped
}

fn resolve_binary_from_candidates(candidates: &[String]) -> Option<(String, String)> {
  for (idx, candidate) in candidates.iter().enumerate() {
    let path = Path::new(candidate);
    if path.is_file() {
      let source = if idx == 0 { "user_or_default" } else { "auto_detected" };
      return Some((candidate.clone(), source.to_string()));
    }
  }
  None
}

fn classify_engine_spawn_error(binary: &str, err: &std::io::Error) -> String {
  use std::io::ErrorKind;
  match err.kind() {
    ErrorKind::NotFound => format!(
      "aria2 binary not found: '{binary}'. Binary Path를 지정하거나 PATH/resources/engine 경로를 확인하세요."
    ),
    ErrorKind::PermissionDenied => {
      format!("aria2 binary is not executable: '{binary}'. 실행 권한(chmod +x)을 확인하세요.")
    }
    _ => format!("failed to start aria2 engine with '{binary}': {err}"),
  }
}

fn is_local_port_open(port: u16) -> bool {
  let addr = SocketAddr::from(([127, 0, 0, 1], port));
  TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(220)).is_ok()
}

fn rpc_endpoint(config: &Aria2RpcConfig) -> String {
  format!(
    "http://127.0.0.1:{}/jsonrpc",
    config.rpc_listen_port.unwrap_or(6800)
  )
}

fn rpc_token(config: &Aria2RpcConfig) -> Option<String> {
  let secret = config.rpc_secret.as_ref()?.trim();
  if secret.is_empty() {
    None
  } else {
    Some(format!("token:{secret}"))
  }
}

async fn call_aria2_rpc(
  client: &Client,
  config: &Aria2RpcConfig,
  method: &str,
  mut params: Vec<Value>,
) -> Result<Value, String> {
  if let Some(token) = rpc_token(config) {
    params.insert(0, json!(token));
  }

  let endpoint = rpc_endpoint(config);
  let payload = json!({
    "jsonrpc": "2.0",
    "id": "gdown",
    "method": method,
    "params": params,
  });

  let response = client
    .post(&endpoint)
    .json(&payload)
    .send()
    .await
    .map_err(|err| format!("aria2 RPC request failed ({method}): {err}"))?;

  let status = response.status();
  let body: Value = response
    .json()
    .await
    .map_err(|err| format!("aria2 RPC invalid response ({method}): {err}"))?;

  if !status.is_success() {
    return Err(format!("aria2 RPC HTTP error ({method}): {status} / {body}"));
  }

  if let Some(error) = body.get("error") {
    return Err(format!("aria2 RPC error ({method}): {error}"));
  }

  body
    .get("result")
    .cloned()
    .ok_or_else(|| format!("aria2 RPC missing result ({method}): {body}"))
}

fn value_to_string(value: Option<&Value>) -> String {
  value
    .and_then(Value::as_str)
    .unwrap_or_default()
    .to_string()
}

fn pick_file_name(path: &str) -> String {
  if path.is_empty() {
    return "-".to_string();
  }
  Path::new(path)
    .file_name()
    .map(|name| name.to_string_lossy().to_string())
    .filter(|name| !name.is_empty())
    .unwrap_or_else(|| path.to_string())
}

fn map_task(task: &Value) -> Aria2TaskSummary {
  let file_path = task
    .get("files")
    .and_then(Value::as_array)
    .and_then(|files| files.first())
    .and_then(|file| file.get("path"))
    .and_then(Value::as_str)
    .unwrap_or("");

  Aria2TaskSummary {
    gid: value_to_string(task.get("gid")),
    status: value_to_string(task.get("status")),
    total_length: value_to_string(task.get("totalLength")),
    completed_length: value_to_string(task.get("completedLength")),
    download_speed: value_to_string(task.get("downloadSpeed")),
    dir: value_to_string(task.get("dir")),
    file_name: pick_file_name(file_path),
    uri: task
      .get("files")
      .and_then(Value::as_array)
      .and_then(|files| files.first())
      .and_then(|file| file.get("uris"))
      .and_then(Value::as_array)
      .and_then(|uris| uris.first())
      .and_then(|uri| uri.get("uri"))
      .and_then(Value::as_str)
      .unwrap_or_default()
      .to_string(),
  }
}

fn map_tasks(result: Value) -> Vec<Aria2TaskSummary> {
  result
    .as_array()
    .map(|items| items.iter().map(map_task).collect())
    .unwrap_or_default()
}

fn map_task_files(status_result: &Value) -> Vec<Aria2TaskFile> {
  status_result
    .get("files")
    .and_then(Value::as_array)
    .map(|items| {
      items
        .iter()
        .map(|file| Aria2TaskFile {
          path: value_to_string(file.get("path")),
          length: value_to_string(file.get("length")),
          completed_length: value_to_string(file.get("completedLength")),
          selected: value_to_string(file.get("selected")),
          uris: file
            .get("uris")
            .and_then(Value::as_array)
            .map(|uris| {
              uris
                .iter()
                .filter_map(|u| u.get("uri").and_then(Value::as_str).map(str::to_string))
                .collect::<Vec<String>>()
            })
            .unwrap_or_default(),
        })
        .collect::<Vec<Aria2TaskFile>>()
    })
    .unwrap_or_default()
}

fn map_task_peers(result: &Value) -> Vec<Aria2TaskPeer> {
  result
    .as_array()
    .map(|items| {
      items
        .iter()
        .map(|peer| Aria2TaskPeer {
          peer_id: value_to_string(peer.get("peerId")),
          ip: value_to_string(peer.get("ip")),
          port: value_to_string(peer.get("port")),
          bitfield: value_to_string(peer.get("bitfield")),
          am_choking: value_to_string(peer.get("amChoking")),
          peer_choking: value_to_string(peer.get("peerChoking")),
          download_speed: value_to_string(peer.get("downloadSpeed")),
          upload_speed: value_to_string(peer.get("uploadSpeed")),
        })
        .collect::<Vec<Aria2TaskPeer>>()
    })
    .unwrap_or_default()
}

fn map_task_servers(result: &Value) -> Vec<Aria2TaskServer> {
  result
    .as_array()
    .map(|items| {
      items
        .iter()
        .map(|server| {
          let current_uri = server
            .get("currentUri")
            .and_then(Value::as_object)
            .and_then(|obj| obj.get("uri"))
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_string();
          Aria2TaskServer {
            uri: value_to_string(server.get("uri")),
            current_uri,
            download_speed: value_to_string(server.get("downloadSpeed")),
          }
        })
        .collect::<Vec<Aria2TaskServer>>()
    })
    .unwrap_or_default()
}

fn map_task_trackers(status_result: &Value) -> Vec<String> {
  let mut trackers: Vec<String> = vec![];
  if let Some(announce_list) = status_result
    .get("bittorrent")
    .and_then(|v| v.get("announceList"))
    .and_then(Value::as_array)
  {
    for tier in announce_list {
      if let Some(items) = tier.as_array() {
        for tracker in items {
          if let Some(url) = tracker.as_str() {
            let trimmed = url.trim();
            if !trimmed.is_empty() {
              trackers.push(trimmed.to_string());
            }
          }
        }
      }
    }
  }

  if trackers.is_empty() {
    if let Some(primary) = status_result
      .get("bittorrent")
      .and_then(|v| v.get("announce"))
      .and_then(Value::as_str)
    {
      let trimmed = primary.trim();
      if !trimmed.is_empty() {
        trackers.push(trimmed.to_string());
      }
    }
  }
  trackers.sort();
  trackers.dedup();
  trackers
}

fn build_rpc_options(
  out: Option<&String>,
  dir: Option<&String>,
  split: Option<u16>,
  extra: Option<&BTreeMap<String, Value>>,
) -> Value {
  let mut options = serde_json::Map::new();
  if let Some(value) = out {
    if !value.trim().is_empty() {
      options.insert("out".to_string(), json!(value));
    }
  }
  if let Some(value) = dir {
    if !value.trim().is_empty() {
      options.insert("dir".to_string(), json!(value));
    }
  }
  if let Some(value) = split {
    options.insert("split".to_string(), json!(value.to_string()));
  }
  if let Some(extra_options) = extra {
    for (key, value) in extra_options {
      options.insert(key.to_string(), value.clone());
    }
  }
  Value::Object(options)
}

fn is_gid_not_found_error(message: &str) -> bool {
  let lower = message.to_ascii_lowercase();
  lower.contains("not found for gid#")
}

#[tauri::command]
pub fn engine_start(
  app: tauri::AppHandle,
  state: State<'_, EngineState>,
  request: EngineStartRequest,
) -> Result<EngineStatusResponse, String> {
  let rpc_port = request.rpc_listen_port.unwrap_or(6800);
  let mut runtime = state
    .runtime
    .lock()
    .map_err(|err| format!("failed to lock engine state: {err}"))?;

  if let Some(child) = runtime.child.as_mut() {
    match child.try_wait() {
      Ok(Some(_)) => {
        runtime.child = None;
      }
      Ok(None) => {
        return Ok(EngineState::status(&runtime));
      }
      Err(err) => {
        return Err(format!("failed to inspect current engine process: {err}"));
      }
    }
  }

  let args = build_engine_args(&request);

  if is_local_port_open(rpc_port) {
    try_release_local_port(rpc_port)?;
    if is_local_port_open(rpc_port) {
      return Err(format!(
        "RPC 포트 {rpc_port} 가 이미 사용 중입니다. 점유 프로세스 정리 후 다시 시도하세요."
      ));
    }
  }

  let candidates = collect_binary_candidates(&app, request.binary_path.as_deref());
  let (binary, _) = resolve_binary_from_candidates(&candidates).ok_or_else(|| {
    let sample = candidates.into_iter().take(6).collect::<Vec<String>>().join(", ");
    format!(
      "aria2 binary를 찾지 못했습니다. Binary Path를 직접 지정하세요. (검색 후보: {sample})"
    )
  })?;

  let child = Command::new(&binary)
    .args(&args)
    .stdin(Stdio::null())
    .stdout(Stdio::null())
    .stderr(Stdio::null())
    .spawn()
    .map_err(|err| classify_engine_spawn_error(&binary, &err))?;

  let mut child = child;
  std::thread::sleep(std::time::Duration::from_millis(220));
  match child.try_wait() {
    Ok(Some(status)) => {
      return Err(format!(
        "aria2 engine exited immediately (status={status}). 포트 충돌 또는 잘못된 옵션일 수 있습니다."
      ));
    }
    Ok(None) => {}
    Err(err) => {
      return Err(format!("failed to inspect aria2 engine process: {err}"));
    }
  }

  runtime.child = Some(child);
  runtime.external_reuse = false;
  runtime.rpc_port = Some(rpc_port);
  runtime.binary_path = Some(binary);
  runtime.args = args;
  runtime.started_at = Some(
    SystemTime::now()
      .duration_since(UNIX_EPOCH)
      .map_err(|err| format!("failed to get system time: {err}"))?
      .as_secs(),
  );

  Ok(EngineState::status(&runtime))
}

#[tauri::command]
pub fn detect_aria2_binary(
  app: tauri::AppHandle,
  binary_path: Option<String>,
) -> Aria2BinaryProbeResponse {
  let candidates = collect_binary_candidates(&app, binary_path.as_deref());
  let resolved = resolve_binary_from_candidates(&candidates);
  Aria2BinaryProbeResponse {
    found: resolved.is_some(),
    binary_path: resolved.as_ref().map(|v| v.0.clone()),
    source: resolved.map(|v| v.1),
    candidates,
  }
}

#[tauri::command]
pub fn engine_stop(state: State<'_, EngineState>) -> Result<EngineStatusResponse, String> {
  let mut runtime = state
    .runtime
    .lock()
    .map_err(|err| format!("failed to lock engine state: {err}"))?;

  stop_engine_runtime(&mut runtime)?;
  Ok(EngineState::status(&runtime))
}

#[tauri::command]
pub fn engine_status(state: State<'_, EngineState>) -> Result<EngineStatusResponse, String> {
  let mut runtime = state
    .runtime
    .lock()
    .map_err(|err| format!("failed to lock engine state: {err}"))?;

  if let Some(child) = runtime.child.as_mut() {
    match child.try_wait() {
      Ok(Some(_)) => {
        runtime.child = None;
        runtime.started_at = None;
      }
      Ok(None) => {}
      Err(err) => {
        return Err(format!("failed to inspect engine process status: {err}"));
      }
    }
  }

  if runtime.external_reuse {
    let alive = runtime.rpc_port.map(is_local_port_open).unwrap_or(false);
    if !alive {
      runtime.external_reuse = false;
      runtime.rpc_port = None;
      runtime.started_at = None;
    }
  }

  Ok(EngineState::status(&runtime))
}

#[tauri::command]
pub async fn aria2_add_uri(request: Aria2AddUriRequest) -> Result<String, String> {
  if request.uri.trim().is_empty() {
    return Err("uri is required".to_string());
  }

  let client = Client::new();

  let mut params = vec![json!([request.uri.trim()])];
  let options = build_rpc_options(
    request.out.as_ref(),
    request.dir.as_ref(),
    request.split,
    request.options.as_ref(),
  );
  if options.as_object().map(|obj| !obj.is_empty()).unwrap_or(false) {
    params.push(options);
  }
  if let Some(position) = request.position {
    params.push(json!(position));
  }

  let result = call_aria2_rpc(&client, &request.rpc, "aria2.addUri", params).await?;
  result
    .as_str()
    .map(|gid| gid.to_string())
    .ok_or_else(|| format!("aria2.addUri returned unexpected result: {result}"))
}

#[tauri::command]
pub async fn aria2_add_torrent(request: Aria2AddTorrentRequest) -> Result<String, String> {
  if request.torrent_base64.trim().is_empty() {
    return Err("torrentBase64 is required".to_string());
  }

  let client = Client::new();
  let options = build_rpc_options(
    request.out.as_ref(),
    request.dir.as_ref(),
    request.split,
    request.options.as_ref(),
  );

  let mut params = vec![json!(request.torrent_base64.trim())];
  if options.as_object().map(|obj| !obj.is_empty()).unwrap_or(false) {
    params.push(json!([]));
    params.push(options);
    if let Some(position) = request.position {
      params.push(json!(position));
    }
  } else if let Some(position) = request.position {
    params.push(json!([]));
    params.push(json!({}));
    params.push(json!(position));
  }

  let result = call_aria2_rpc(&client, &request.rpc, "aria2.addTorrent", params).await?;
  result
    .as_str()
    .map(|gid| gid.to_string())
    .ok_or_else(|| format!("aria2.addTorrent returned unexpected result: {result}"))
}

#[tauri::command]
pub async fn aria2_list_tasks(config: Aria2RpcConfig) -> Result<Aria2TaskSnapshot, String> {
  let client = Client::new();

  let active = call_aria2_rpc(&client, &config, "aria2.tellActive", vec![]).await?;
  let waiting =
    call_aria2_rpc(&client, &config, "aria2.tellWaiting", vec![json!(0), json!(30)]).await?;
  let stopped =
    call_aria2_rpc(&client, &config, "aria2.tellStopped", vec![json!(0), json!(30)]).await?;

  Ok(Aria2TaskSnapshot {
    active: map_tasks(active),
    waiting: map_tasks(waiting),
    stopped: map_tasks(stopped),
  })
}

#[tauri::command]
pub async fn aria2_get_task_detail(request: Aria2TaskDetailRequest) -> Result<Aria2TaskDetail, String> {
  let gid = request.gid.trim();
  if gid.is_empty() {
    return Err("gid is required".to_string());
  }

  let client = Client::new();
  let status_result =
    call_aria2_rpc(&client, &request.rpc, "aria2.tellStatus", vec![json!(gid)]).await?;
  let peers_result = call_aria2_rpc(&client, &request.rpc, "aria2.getPeers", vec![json!(gid)])
    .await
    .unwrap_or_else(|_| json!([]));
  let servers_result = call_aria2_rpc(&client, &request.rpc, "aria2.getServers", vec![json!(gid)])
    .await
    .unwrap_or_else(|_| json!([]));

  Ok(Aria2TaskDetail {
    gid: value_to_string(status_result.get("gid")),
    status: value_to_string(status_result.get("status")),
    total_length: value_to_string(status_result.get("totalLength")),
    completed_length: value_to_string(status_result.get("completedLength")),
    upload_length: value_to_string(status_result.get("uploadLength")),
    download_speed: value_to_string(status_result.get("downloadSpeed")),
    upload_speed: value_to_string(status_result.get("uploadSpeed")),
    num_seeders: value_to_string(status_result.get("numSeeders")),
    connections: value_to_string(status_result.get("connections")),
    piece_length: value_to_string(status_result.get("pieceLength")),
    num_pieces: value_to_string(status_result.get("numPieces")),
    dir: value_to_string(status_result.get("dir")),
    info_hash: value_to_string(status_result.get("infoHash")),
    creation_date: value_to_string(
      status_result
        .get("bittorrent")
        .and_then(|v| v.get("creationDate")),
    ),
    comment: value_to_string(status_result.get("bittorrent").and_then(|v| v.get("comment"))),
    error_code: value_to_string(status_result.get("errorCode")),
    error_message: value_to_string(status_result.get("errorMessage")),
    trackers: map_task_trackers(&status_result),
    files: map_task_files(&status_result),
    peers: map_task_peers(&peers_result),
    servers: map_task_servers(&servers_result),
  })
}

#[tauri::command]
pub async fn aria2_pause_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
  let gid = request.gid.trim();
  if gid.is_empty() {
    return Err("gid is required".to_string());
  }
  let client = Client::new();
  let result = call_aria2_rpc(&client, &request.rpc, "aria2.pause", vec![json!(gid)]).await?;
  result
    .as_str()
    .map(|value| value.to_string())
    .ok_or_else(|| format!("aria2.pause returned unexpected result: {result}"))
}

#[tauri::command]
pub async fn aria2_resume_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
  let gid = request.gid.trim();
  if gid.is_empty() {
    return Err("gid is required".to_string());
  }
  let client = Client::new();
  let result = call_aria2_rpc(&client, &request.rpc, "aria2.unpause", vec![json!(gid)]).await?;
  result
    .as_str()
    .map(|value| value.to_string())
    .ok_or_else(|| format!("aria2.unpause returned unexpected result: {result}"))
}

#[tauri::command]
pub async fn aria2_remove_task(request: Aria2TaskCommandRequest) -> Result<String, String> {
  let gid = request.gid.trim();
  if gid.is_empty() {
    return Err("gid is required".to_string());
  }
  let client = Client::new();
  // Download 중 항목이 즉시 지워지지 않는 케이스를 줄이기 위해 forceRemove를 우선 시도한다.
  match call_aria2_rpc(&client, &request.rpc, "aria2.forceRemove", vec![json!(gid)]).await {
    Ok(result) => {
      return result
        .as_str()
        .map(|value| value.to_string())
        .ok_or_else(|| format!("aria2.forceRemove returned unexpected result: {result}"))
    }
    Err(err) if !is_gid_not_found_error(&err) => return Err(err),
    Err(_) => {}
  };

  match call_aria2_rpc(&client, &request.rpc, "aria2.remove", vec![json!(gid)]).await {
    Ok(result) => {
      return result
        .as_str()
        .map(|value| value.to_string())
        .ok_or_else(|| format!("aria2.remove returned unexpected result: {result}"))
    }
    Err(err) if !is_gid_not_found_error(&err) => return Err(err),
    Err(_) => {}
  };

  match call_aria2_rpc(
    &client,
    &request.rpc,
    "aria2.removeDownloadResult",
    vec![json!(gid)],
  )
  .await
  {
    Ok(result) => result
      .as_str()
      .map(|value| value.to_string())
      .ok_or_else(|| format!("aria2.removeDownloadResult returned unexpected result: {result}")),
    Err(fallback_err) if is_gid_not_found_error(&fallback_err) => Ok(gid.to_string()),
    Err(fallback_err) => Err(fallback_err),
  }
}

#[tauri::command]
pub async fn aria2_remove_task_record(request: Aria2TaskCommandRequest) -> Result<String, String> {
  let gid = request.gid.trim();
  if gid.is_empty() {
    return Err("gid is required".to_string());
  }
  let client = Client::new();
  match call_aria2_rpc(&client, &request.rpc, "aria2.removeDownloadResult", vec![json!(gid)]).await
  {
    Ok(result) => result
      .as_str()
      .map(|value| value.to_string())
      .ok_or_else(|| format!("aria2.removeDownloadResult returned unexpected result: {result}")),
    Err(err) if is_gid_not_found_error(&err) => Ok(gid.to_string()),
    Err(err) => Err(err),
  }
}

#[tauri::command]
pub async fn aria2_pause_all(config: Aria2RpcConfig) -> Result<String, String> {
  let client = Client::new();
  let result = call_aria2_rpc(&client, &config, "aria2.pauseAll", vec![]).await?;
  result
    .as_str()
    .map(|value| value.to_string())
    .ok_or_else(|| format!("aria2.pauseAll returned unexpected result: {result}"))
}

#[tauri::command]
pub async fn aria2_resume_all(config: Aria2RpcConfig) -> Result<String, String> {
  let client = Client::new();
  let result = call_aria2_rpc(&client, &config, "aria2.unpauseAll", vec![]).await?;
  result
    .as_str()
    .map(|value| value.to_string())
    .ok_or_else(|| format!("aria2.unpauseAll returned unexpected result: {result}"))
}

#[tauri::command]
pub async fn aria2_change_global_option(
  request: Aria2ChangeGlobalOptionRequest,
) -> Result<String, String> {
  let client = Client::new();
  let options = serde_json::to_value(request.options)
    .map_err(|err| format!("global option 직렬화 실패: {err}"))?;
  let result = call_aria2_rpc(
    &client,
    &request.rpc,
    "aria2.changeGlobalOption",
    vec![options],
  )
  .await?;

  result
    .as_str()
    .map(|value| value.to_string())
    .ok_or_else(|| format!("aria2.changeGlobalOption returned unexpected result: {result}"))
}

#[tauri::command]
pub fn load_torrent_file(path: String) -> Result<TorrentFilePayload, String> {
  if !path.to_ascii_lowercase().ends_with(".torrent") {
    return Err("torrent 파일만 허용됩니다.".to_string());
  }

  let bytes = std::fs::read(&path).map_err(|err| format!("파일 읽기 실패: {err}"))?;
  let name = Path::new(&path)
    .file_name()
    .map(|v| v.to_string_lossy().to_string())
    .filter(|v| !v.is_empty())
    .ok_or_else(|| "파일명을 확인할 수 없습니다.".to_string())?;

  Ok(TorrentFilePayload {
    name,
    base64: STANDARD.encode(bytes.as_slice()),
    size: bytes.len() as u64,
  })
}

#[tauri::command]
pub fn open_path_in_file_manager(path: String) -> Result<(), String> {
  let target = path.trim();
  if target.is_empty() {
    return Err("path is required".to_string());
  }

  let mut command = if cfg!(target_os = "macos") {
    let mut cmd = Command::new("open");
    cmd.arg(target);
    cmd
  } else if cfg!(target_os = "windows") {
    let mut cmd = Command::new("explorer");
    cmd.arg(target);
    cmd
  } else {
    let mut cmd = Command::new("xdg-open");
    cmd.arg(target);
    cmd
  };

  let status = command
    .status()
    .map_err(|err| format!("failed to open path in file manager: {err}"))?;

  if !status.success() {
    return Err(format!("file manager command exited with status: {status}"));
  }
  Ok(())
}
