import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import * as childProcess from "node:child_process";
import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants";
import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from "../shared/types";
import { compactErrorText, humanSize } from "./utils";
import { logger } from "./logger";

const RELEASE_FETCH_TIMEOUT_MS = 12_000;
const CONNECT_TIMEOUT_MS = 30_000;
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1_500;
const MAX_DOWNLOAD_PASSES = 3;
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;

type UpdateSource = {
  name: string;
  webBase: string;
  apiBase: string;
};

type ReleaseAsset = {
  name: string;
  browser_download_url: string;
  digest: string;
};

type UpdateProgressCallback = (progress: UpdateInstallProgress) => void;

type ExpectedDigest = {
  algorithm: "sha256" | "sha512";
  digest: string;
  encoding: "hex" | "base64";
};

const UPDATE_SOURCES: UpdateSource[] = [
  { name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" }
];

const PRIMARY_SOURCE = UPDATE_SOURCES[0];
const WEB_BASE = PRIMARY_SOURCE.webBase;
const API_BASE = PRIMARY_SOURCE.apiBase;

let activeAbortController: AbortController | null = null;

function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
  if (!cb) return;
  try {
    cb(progress);
  } catch {
  }
}

export function parseVersionParts(version: string): number[] {
  const cleaned = version.replace(/^v/i, "").trim();
  return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
}

export function isRemoteNewer(currentVersion: string, latestVersion: string): boolean {
  const current = parseVersionParts(currentVersion);
  const latest = parseVersionParts(latestVersion);
  const len = Math.max(current.length, latest.length);
  for (let i = 0; i < len; i += 1) {
    const a = current[i] ?? 0;
    const b = latest[i] ?? 0;
    if (b > a) return true;
    if (b < a) return false;
  }
  return false;
}

function isValidRepoPart(value: string): boolean {
  const part = String(value || "").trim();
  if (!part || part === "." || part === ".." || part.includes("..")) return false;
  return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part);
}

function extractOwnerRepo(input: string): string {
  const cleaned = input
    .replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
    .replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
    .replace(/^git@(?:codeberg\.org|github\.com):/i, "")
    .replace(/\.git$/i, "")
    .replace(/^\/+|\/+$/g, "");
  const parts = cleaned.split("/").filter(Boolean);
  if (parts.length >= 2 && isValidRepoPart(parts[0]) && isValidRepoPart(parts[1])) {
    return `${parts[0]}/${parts[1]}`;
  }
  return "";
}

export function normalizeUpdateRepo(repo: string): string {
  const raw = String(repo || "").trim();
  if (!raw) return DEFAULT_UPDATE_REPO;

  try {
    const url = new URL(raw);
    const host = url.hostname.toLowerCase();
    if (
      host === "codeberg.org"
      || host === "www.codeberg.org"
      || host === "github.com"
      || host === "www.github.com"
    ) {
      const result = extractOwnerRepo(url.pathname);
      if (result) return result;
    }
  } catch {
  }

  const result = extractOwnerRepo(raw);
  return result || DEFAULT_UPDATE_REPO;
}

function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
  return { signal: ctrl.signal, clear: () => clearTimeout(timer) };
}

function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSignal {
  if (!secondary) return primary;
  return AbortSignal.any([primary, secondary]);
}

async function readJsonBody(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> {
  let timer: NodeJS.Timeout | null = null;
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
    timer = setTimeout(() => {
      void response.body?.cancel().catch(() => undefined);
      reject(new Error(`timeout:${timeoutMs}`));
    }, timeoutMs);
  });

  try {
    const data = await Promise.race([
      response.json().catch(() => null) as Promise<unknown>,
      timeoutPromise,
    ]);
    if (!data || typeof data !== "object" || Array.isArray(data)) return null;
    return data as Record<string, unknown>;
  } finally {
    if (timer) clearTimeout(timer);
  }
}

async function readTextBody(response: Response, timeoutMs: number): Promise<string> {
  let timer: NodeJS.Timeout | null = null;
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
    timer = setTimeout(() => {
      void response.body?.cancel().catch(() => undefined);
      reject(new Error(`timeout:${timeoutMs}`));
    }, timeoutMs);
  });

  try {
    return String(await Promise.race([response.text(), timeoutPromise]) || "");
  } finally {
    if (timer) clearTimeout(timer);
  }
}

function getBodyIdleTimeout(): number {
  const env = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
  if (Number.isFinite(env) && env >= 1000 && env <= 30 * 60 * 1000) return Math.floor(env);
  return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
}

function normalizeBase64(raw: string): string {
  return String(raw || "")
    .trim()
    .replace(/-/g, "+")
    .replace(/_/g, "/")
    .replace(/=+$/g, "");
}

export function parseExpectedDigest(raw: string): ExpectedDigest | null {
  const text = String(raw || "").trim();
  if (!text) return null;

  const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
  if (pre256hex) {
    return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
  }

  const pre256b64 = text.match(/^sha256:([A-Za-z0-9+/_-]{43,44}={0,1})$/i);
  if (pre256b64) {
    return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
  }

  const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
  if (pre512hex) {
    return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
  }

  const pre512b64 = text.match(/^sha512:([A-Za-z0-9+/_-]{86,88}={0,2})$/i);
  if (pre512b64) {
    return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
  }

  if (/^[a-fA-F0-9]{64}$/.test(text)) {
    return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
  }
  if (/^[a-fA-F0-9]{128}$/.test(text)) {
    return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
  }

  const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
  if (plain512b64) {
    return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
  }

  const plain256b64 = text.match(/^([A-Za-z0-9+/_-]{43,44}={0,1})$/);
  if (plain256b64) {
    return { algorithm: "sha256", digest: normalizeBase64(plain256b64[1]), encoding: "base64" };
  }

  logger.warn(`Unrecognized digest format (${text.length} chars): ${text.slice(0, 40)}...`);
  return null;
}

async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encoding: "hex" | "base64"): Promise<string> {
  const hash = crypto.createHash(algorithm);
  const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
  return new Promise<string>((resolve, reject) => {
    stream.on("data", (chunk: string | Buffer) => {
      hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
    });
    stream.on("error", reject);
    stream.on("end", () => {
      const result = hash.digest(encoding);
      resolve(encoding === "hex" ? result.toLowerCase() : result);
    });
  });
}

function normalizeNameForMatch(value: string): string {
  const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
  return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}

function stripYamlQuotes(raw: string): string {
  return String(raw || "").trim().replace(/^['"]+|['"]+$/g, "").trim();
}

function extractSha512Value(raw: string): string {
  const stripped = stripYamlQuotes(raw);
  const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
  if (b64) return b64[1];
  const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
  if (hex) return hex[1];
  return "";
}

function parseSha512FromLatestYml(content: string, setupAssetName: string): string {
  const lines = String(content || "").split(/\r?\n/);
  const target = normalizeNameForMatch(setupAssetName);

  let topLevelPath = "";
  let topLevelSha = "";
  let currentFileUrl = "";
  let firstFileSha = "";

  for (const rawLine of lines) {
    const line = String(rawLine);

    const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
    if (fileUrlItem?.[1]) {
      currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
      continue;
    }

    const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
    if (urlMatch?.[1]) {
      currentFileUrl = stripYamlQuotes(urlMatch[1]);
      continue;
    }

    const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
    if (pathMatch?.[1]) {
      topLevelPath = stripYamlQuotes(pathMatch[1]);
      continue;
    }

    const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
    if (!shaMatch?.[1]) continue;

    const sha = extractSha512Value(shaMatch[1]);
    if (!sha) continue;

    if (currentFileUrl) {
      if (!firstFileSha) firstFileSha = sha;
      if (target && normalizeNameForMatch(currentFileUrl) === target) {
        return sha;
      }
      currentFileUrl = "";
      continue;
    }

    if (!topLevelSha) topLevelSha = sha;
  }

  if (target && topLevelPath && topLevelSha) {
    if (normalizeNameForMatch(topLevelPath) === target) {
      return topLevelSha;
    }
  }

  return topLevelSha || firstFileSha || "";
}

async function verifyBinaryShape(filePath: string): Promise<void> {
  const stats = await fs.promises.stat(filePath);
  if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
    throw new Error("Update-Installer ungültig (Datei zu klein)");
  }

  const handle = await fs.promises.open(filePath, "r");
  try {
    const header = Buffer.alloc(2);
    const result = await handle.read(header, 0, 2, 0);
    if (result.bytesRead < 2 || header[0] !== 0x4d || header[1] !== 0x5a) {
      throw new Error("Update-Installer ungültig (keine EXE-Datei)");
    }
  } finally {
    await handle.close();
  }
}

async function verifyDownloadedInstaller(filePath: string, digestRaw: string): Promise<void> {
  await verifyBinaryShape(filePath);

  const expected = parseExpectedDigest(digestRaw);
  if (!expected) {
    if (String(process.env.RD_ALLOW_UNSIGNED_UPDATE || "").trim() === "1") {
      logger.warn("Update-Asset ohne gültigen SHA-Digest (RD_ALLOW_UNSIGNED_UPDATE=1) - nur EXE-Basisprüfung durchgeführt");
      return;
    }
    throw new Error("Update-Asset ohne gültigen SHA-Digest");
  }

  const actualRaw = await hashFile(filePath, expected.algorithm, expected.encoding);
  const actual = expected.encoding === "base64" ? normalizeBase64(actualRaw) : actualRaw;
  const expectedNorm = expected.encoding === "base64" ? normalizeBase64(expected.digest) : expected.digest;

  if (actual !== expectedNorm) {
    const algo = expected.algorithm.toUpperCase();
    logger.error(
      `${algo} mismatch!\n  Erwartet: ${expectedNorm.slice(0, 40)}...\n  Tatsächlich: ${actual.slice(0, 40)}...`
    );
    throw new Error(`Update-Integritätsprüfung fehlgeschlagen (${algo} mismatch)`);
  }

  logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
}

async function fetchRelease(repo: string, endpoint: string): Promise<{
  ok: boolean;
  status: number;
  payload: Record<string, unknown> | null;
}> {
  const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
  try {
    const response = await fetch(`${API_BASE}/repos/${repo}/${endpoint}`, {
      headers: { Accept: "application/vnd.github+json", "User-Agent": USER_AGENT },
      signal: tc.signal,
    });
    const payload = await readJsonBody(response, RELEASE_FETCH_TIMEOUT_MS);
    return { ok: response.ok, status: response.status, payload };
  } finally {
    tc.clear();
  }
}

function readAssets(payload: Record<string, unknown>): ReleaseAsset[] {
  const raw = Array.isArray(payload.assets) ? (payload.assets as Array<Record<string, unknown>>) : [];
  return raw
    .map((a) => ({
      name: String(a.name || ""),
      browser_download_url: String(a.browser_download_url || ""),
      digest: String(a.digest || "").trim(),
    }))
    .filter((a) => a.name && a.browser_download_url);
}

function pickSetupAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
  const executables = assets.filter((a) => /\.(exe|msi|msix|msixbundle)$/i.test(a.name));
  if (executables.length === 0) return null;
  return (
    executables.find((a) => /setup/i.test(a.name))
    || executables.find((a) => !/portable/i.test(a.name))
    || executables[0]
  );
}

function pickLatestYml(assets: ReleaseAsset[]): ReleaseAsset | null {
  return (
    assets.find((a) => /^latest\.ya?ml$/i.test(a.name))
    || assets.find((a) => /latest/i.test(a.name) && /\.ya?ml$/i.test(a.name))
    || null
  );
}

function isDraftOrPrerelease(payload: Record<string, unknown>): boolean {
  return Boolean(payload.draft) || Boolean(payload.prerelease);
}

function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: string): UpdateCheckResult {
  const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim();
  const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION;
  const releaseUrl = String(payload.html_url || fallbackUrl);
  const setup = pickSetupAsset(readAssets(payload));
  const body = typeof payload.body === "string" ? payload.body.trim() : "";

  return {
    updateAvailable: isRemoteNewer(APP_VERSION, latestVersion),
    currentVersion: APP_VERSION,
    latestVersion,
    latestTag,
    releaseUrl,
    setupAssetUrl: setup?.browser_download_url || "",
    setupAssetName: setup?.name || "",
    setupAssetDigest: setup?.digest || "",
    releaseNotes: body || undefined,
  };
}

function uniqueStrings(values: string[]): string[] {
  const seen = new Set<string>();
  const out: string[] = [];
  for (const value of values) {
    const trimmed = String(value || "").trim();
    if (!trimmed || seen.has(trimmed)) continue;
    seen.add(trimmed);
    out.push(trimmed);
  }
  return out;
}

function extractFileNameFromUrl(url: string): string {
  try {
    const parsed = new URL(url);
    const fileName = path.basename(parsed.pathname || "");
    return fileName ? decodeURIComponent(fileName) : "";
  } catch {
    return "";
  }
}

function deriveTagFromUrl(releaseUrl: string): string {
  try {
    const match = new URL(releaseUrl).pathname.match(/\/releases\/tag\/([^/?#]+)/i);
    return match?.[1] ? decodeURIComponent(match[1]) : "";
  } catch {
    return "";
  }
}

function deriveNameVariants(name: string, url: string): string[] {
  const directName = String(name || "").trim();
  const fromUrl = extractFileNameFromUrl(url);
  const source = directName || fromUrl;
  if (!source) return [];
  const ext = path.extname(source);
  const stem = ext ? source.slice(0, -ext.length) : source;
  const dashed = `${stem.replace(/\s+/g, "-")}${ext}`;
  return uniqueStrings([source, fromUrl, dashed]);
}

function buildCandidates(repo: string, check: UpdateCheckResult): string[] {
  const assetUrl = String(check.setupAssetUrl || "").trim();
  const tag = String(check.latestTag || "").trim() || deriveTagFromUrl(String(check.releaseUrl || ""));
  const names = deriveNameVariants(String(check.setupAssetName || ""), assetUrl);

  const urls: string[] = [assetUrl];
  if (tag && names.length > 0) {
    for (const name of names) {
      urls.push(`${WEB_BASE}/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`);
    }
  }
  if (!tag && names.length > 0) {
    for (const name of names) {
      urls.push(`${WEB_BASE}/${repo}/releases/latest/download/${encodeURIComponent(name)}`);
    }
  }
  return uniqueStrings(urls);
}

function deriveFileName(check: UpdateCheckResult, url: string): string {
  const sanitize = (raw: string): string => {
    const base = path.basename(String(raw || "").trim());
    if (!base) return "update.exe";
    const safe = base.replace(/[\\/:*?"<>|]/g, "_").replace(/^\.+/, "").trim();
    return safe || "update.exe";
  };

  const fromName = String(check.setupAssetName || "").trim();
  if (fromName) return sanitize(fromName);
  try {
    return sanitize(new URL(url).pathname || "update.exe");
  } catch {
    return "update.exe";
  }
}

function httpStatusFromError(error: unknown): number {
  const match = String(error || "").match(/HTTP\s+(\d{3})/i);
  return match ? Number(match[1]) : 0;
}

function isRetryable(error: unknown): boolean {
  const status = httpStatusFromError(error);
  if (status === 429 || status >= 500) return true;
  const text = String(error || "").toLowerCase();
  return text.includes("timeout")
    || text.includes("fetch failed")
    || text.includes("network")
    || text.includes("econnreset")
    || text.includes("enotfound")
    || text.includes("aborted");
}

function shouldTryNextCandidate(error: unknown): boolean {
  const status = httpStatusFromError(error);
  if (status >= 400 && status <= 599) return true;
  return isRetryable(error);
}

function isIntegrityError(error: unknown): boolean {
  const text = String(error || "").toLowerCase();
  return text.includes("integrit") || text.includes("mismatch");
}

async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
  if (signal.aborted) throw new Error("aborted:update_shutdown");

  return new Promise((resolve, reject) => {
    let timer: NodeJS.Timeout | null = setTimeout(() => {
      timer = null;
      signal.removeEventListener("abort", onAbort);
      resolve();
    }, Math.max(0, ms));

    const onAbort = (): void => {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      signal.removeEventListener("abort", onAbort);
      reject(new Error("aborted:update_shutdown"));
    };

    signal.addEventListener("abort", onAbort, { once: true });
  });
}

async function downloadFile(
  url: string,
  targetPath: string,
  onProgress?: UpdateProgressCallback,
): Promise<void> {
  const shutdown = activeAbortController?.signal;
  if (shutdown?.aborted) throw new Error("aborted:update_shutdown");

  logger.info(`Update-Download versucht: ${url}`);

  const tc = timeoutController(CONNECT_TIMEOUT_MS);
  let response: Response;
  try {
    response = await fetch(url, {
      headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "identity" },
      redirect: "follow",
      signal: combineSignals(tc.signal, shutdown),
    });
  } finally {
    tc.clear();
  }

  if (!response.ok || !response.body) {
    throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
  }

  const clRaw = Number(response.headers.get("content-length") || NaN);
  const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;

  let downloadedBytes = 0;
  let lastProgressAt = 0;

  const reportProgress = (force: boolean): void => {
    const now = Date.now();
    if (!force && now - lastProgressAt < 160) return;
    lastProgressAt = now;
    const percent = totalBytes && totalBytes > 0
      ? Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)))
      : null;
    const message = totalBytes && percent !== null
      ? `Update wird heruntergeladen: ${percent}% (${humanSize(downloadedBytes)} / ${humanSize(totalBytes)})`
      : `Update wird heruntergeladen (${humanSize(downloadedBytes)})`;
    emitProgress(onProgress, { stage: "downloading", percent, downloadedBytes, totalBytes, message });
  };

  reportProgress(true);

  await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
  const tempPath = `${targetPath}.tmp`;
  const writeStream = fs.createWriteStream(tempPath);
  const reader = response.body.getReader();

  const idleMs = getBodyIdleTimeout();
  let idleTimer: NodeJS.Timeout | null = null;
  let idleTimedOut = false;

  const clearIdle = (): void => {
    if (idleTimer) {
      clearTimeout(idleTimer);
      idleTimer = null;
    }
  };

  const resetIdle = (): void => {
    clearIdle();
    if (idleMs > 0) {
      idleTimer = setTimeout(() => {
        idleTimedOut = true;
        reader.cancel().catch(() => undefined);
      }, idleMs);
    }
  };

  try {
    resetIdle();
    for (;;) {
      if (shutdown?.aborted) {
        await reader.cancel().catch(() => undefined);
        throw new Error("aborted:update_shutdown");
      }
      const { done, value } = await reader.read();
      if (done) break;

      const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
      if (!writeStream.write(buf)) {
        await new Promise<void>((resolve) => writeStream.once("drain", resolve));
      }
      downloadedBytes += buf.byteLength;
      resetIdle();
      reportProgress(false);
    }
  } catch (error) {
    writeStream.destroy();
    await fs.promises.rm(tempPath, { force: true }).catch(() => {});
    if (idleTimedOut) {
      throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
    }
    throw error;
  } finally {
    clearIdle();
  }

  await new Promise<void>((resolve, reject) => {
    writeStream.end(() => resolve());
    writeStream.on("error", reject);
  });

  if (idleTimedOut) {
    await fs.promises.rm(tempPath, { force: true }).catch(() => {});
    throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
  }

  if (totalBytes && downloadedBytes !== totalBytes) {
    await fs.promises.rm(tempPath, { force: true }).catch(() => {});
    throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
  }

  await fs.promises.rename(tempPath, targetPath);
  reportProgress(true);
  logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
}

async function downloadWithRetries(
  url: string,
  targetPath: string,
  onProgress?: UpdateProgressCallback,
): Promise<void> {
  const shutdown = activeAbortController?.signal;
  let lastError: unknown;

  for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
    if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
    try {
      await downloadFile(url, targetPath, onProgress);
      return;
    } catch (error) {
      lastError = error;
      await fs.promises.rm(targetPath, { force: true }).catch(() => {});
      if (attempt < RETRIES_PER_CANDIDATE && isRetryable(error)) {
        logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
        await sleep(RETRY_DELAY_MS * attempt, shutdown);
        continue;
      }
      break;
    }
  }
  throw lastError;
}

async function downloadFromCandidates(
  candidates: string[],
  targetPath: string,
  onProgress?: UpdateProgressCallback,
): Promise<void> {
  const shutdown = activeAbortController?.signal;
  let lastError: unknown = new Error("Update Download fehlgeschlagen");

  logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`);
  for (let i = 0; i < candidates.length; i += 1) {
    if (shutdown?.aborted) throw new Error("aborted:update_shutdown");
    emitProgress(onProgress, {
      stage: "downloading",
      percent: null,
      downloadedBytes: 0,
      totalBytes: null,
      message: `Update-Download: Quelle ${i + 1}/${candidates.length}`,
    });
    try {
      await downloadWithRetries(candidates[i], targetPath, onProgress);
      return;
    } catch (error) {
      lastError = error;
      logger.warn(`Update-Download Kandidat ${i + 1}/${candidates.length} endgültig fehlgeschlagen: ${compactErrorText(error)}`);
      if (i < candidates.length - 1 && shouldTryNextCandidate(error)) continue;
      break;
    }
  }
  throw lastError;
}

async function resolveAssetFromApi(repo: string, tag: string): Promise<{
  setupAssetUrl: string;
  setupAssetName: string;
  setupAssetDigest: string;
} | null> {
  const endpoints = uniqueStrings([
    tag ? `releases/tags/${encodeURIComponent(tag)}` : "",
    "releases/latest",
  ]);

  for (const ep of endpoints) {
    try {
      const { ok, payload } = await fetchRelease(repo, ep);
      if (!ok || !payload || isDraftOrPrerelease(payload)) continue;
      const setup = pickSetupAsset(readAssets(payload));
      if (!setup) continue;
      return {
        setupAssetUrl: setup.browser_download_url,
        setupAssetName: setup.name,
        setupAssetDigest: setup.digest,
      };
    } catch {
    }
  }
  return null;
}

async function resolveDigestFromYml(repo: string, tag: string, setupName: string): Promise<string> {
  const endpoints = uniqueStrings([
    tag ? `releases/tags/${encodeURIComponent(tag)}` : "",
    "releases/latest",
  ]);

  for (const ep of endpoints) {
    try {
      const { ok, payload } = await fetchRelease(repo, ep);
      if (!ok || !payload || isDraftOrPrerelease(payload)) continue;

      const yml = pickLatestYml(readAssets(payload));
      if (!yml) continue;

      const tc = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
      let response: Response;
      try {
        response = await fetch(yml.browser_download_url, {
          headers: { "User-Agent": USER_AGENT },
          signal: tc.signal,
        });
      } finally {
        tc.clear();
      }
      if (!response.ok) continue;

      const yamlText = await readTextBody(response, RELEASE_FETCH_TIMEOUT_MS);
      const sha = parseSha512FromLatestYml(yamlText, setupName);
      if (sha) return `sha512:${sha}`;
    } catch {
    }
  }
  return "";
}

export function buildInstallerLaunchArgs(): string[] {
  return ["/S", "--updated", "--force-run"];
}

export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
  const safeRepo = normalizeUpdateRepo(repo);
  const fallbackUrl = `${WEB_BASE}/${safeRepo}/releases/latest`;
  const fallback: UpdateCheckResult = {
    updateAvailable: false,
    currentVersion: APP_VERSION,
    latestVersion: APP_VERSION,
    latestTag: `v${APP_VERSION}`,
    releaseUrl: fallbackUrl,
  };

  try {
    const { ok, status, payload } = await fetchRelease(safeRepo, "releases/latest");
    if (!ok || !payload) {
      const reason = String((payload?.message as string) || `HTTP ${status}`);
      return { ...fallback, error: reason };
    }
    return parseReleasePayload(payload, fallbackUrl);
  } catch (error) {
    return { ...fallback, error: compactErrorText(error) };
  }
}

export async function installLatestUpdate(
  repo: string,
  prechecked?: UpdateCheckResult,
  onProgress?: UpdateProgressCallback,
): Promise<UpdateInstallResult> {
  if (activeAbortController && !activeAbortController.signal.aborted) {
    emitProgress(onProgress, {
      stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
      message: "Update-Download läuft bereits",
    });
    return { started: false, message: "Update-Download läuft bereits" };
  }

  const abortCtrl = new AbortController();
  activeAbortController = abortCtrl;
  const safeRepo = normalizeUpdateRepo(repo);

  const check = prechecked && !prechecked.error
    ? prechecked
    : await checkGitHubUpdate(safeRepo);

  if (check.error) {
    activeAbortController = null;
    emitProgress(onProgress, {
      stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
      message: check.error,
    });
    return { started: false, message: check.error };
  }

  if (!check.updateAvailable) {
    activeAbortController = null;
    emitProgress(onProgress, {
      stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
      message: "Kein neues Update verfügbar",
    });
    return { started: false, message: "Kein neues Update verfügbar" };
  }

  let effective: UpdateCheckResult = {
    ...check,
    setupAssetUrl: String(check.setupAssetUrl || ""),
    setupAssetName: String(check.setupAssetName || ""),
    setupAssetDigest: String(check.setupAssetDigest || ""),
  };

  if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
    const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
    if (refreshed) {
      effective = {
        ...effective,
        setupAssetUrl: refreshed.setupAssetUrl,
        setupAssetName: refreshed.setupAssetName,
        setupAssetDigest: refreshed.setupAssetDigest,
      };
    }
  }

  if (!effective.setupAssetDigest && effective.setupAssetUrl) {
    const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
    if (digest) {
      effective = { ...effective, setupAssetDigest: digest };
      logger.info("Update-Integritätsdigest aus latest.yml übernommen");
    }
  }

  let candidates = buildCandidates(safeRepo, effective);
  if (candidates.length === 0) {
    activeAbortController = null;
    emitProgress(onProgress, {
      stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
      message: "Setup-Asset nicht gefunden",
    });
    return { started: false, message: "Setup-Asset nicht gefunden" };
  }

  const fileName = deriveFileName(effective, candidates[0]);
  const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`);

  try {
    emitProgress(onProgress, {
      stage: "starting", percent: 0, downloadedBytes: 0, totalBytes: null,
      message: "Update wird vorbereitet",
    });

    if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");

    let verified = false;
    let lastVerifyError: unknown = null;
    let integrityError: unknown = null;

    for (let pass = 0; pass < MAX_DOWNLOAD_PASSES && !verified; pass += 1) {
      logger.info(`Update-Download Kandidaten (Pass ${pass + 1}/${MAX_DOWNLOAD_PASSES}): ${candidates.join(" | ")}`);
      lastVerifyError = null;

      for (let i = 0; i < candidates.length; i += 1) {
        if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");

        try {
          await downloadWithRetries(candidates[i], targetPath, onProgress);

          if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");

          emitProgress(onProgress, {
            stage: "verifying", percent: 100, downloadedBytes: 0, totalBytes: null,
            message: `Prüfe Installer-Integrität (${i + 1}/${candidates.length})`,
          });

          await verifyDownloadedInstaller(targetPath, String(effective.setupAssetDigest || ""));
          verified = true;
          break;
        } catch (error) {
          lastVerifyError = error;
          if (!integrityError && isIntegrityError(error)) {
            integrityError = error;
          }
          await fs.promises.rm(targetPath, { force: true }).catch(() => {});
          if (i < candidates.length - 1) {
            logger.warn(`Update-Kandidat ${i + 1}/${candidates.length} verworfen: ${compactErrorText(error)}`);
          }
        }
      }

      if (verified) break;

      const status = httpStatusFromError(lastVerifyError);
      const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
      if (!shouldRefresh) break;

      logger.warn(`Pass ${pass + 1} fehlgeschlagen (${integrityError ? "Integritätsfehler" : "HTTP 404"}), aktualisiere Kandidaten...`);

      const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
      if (refreshed) {
        effective = {
          ...effective,
          setupAssetUrl: refreshed.setupAssetUrl || effective.setupAssetUrl,
          setupAssetName: refreshed.setupAssetName || effective.setupAssetName,
          setupAssetDigest: refreshed.setupAssetDigest || effective.setupAssetDigest,
        };
      }

      const ymlDigest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
      if (ymlDigest) {
        effective = { ...effective, setupAssetDigest: ymlDigest };
        logger.info("Update-Integritätsdigest aus latest.yml aktualisiert");
      }

      const refreshedCandidates = buildCandidates(safeRepo, effective);
      if (refreshedCandidates.length > 0) {
        const changed = refreshedCandidates.length !== candidates.length
          || refreshedCandidates.some((v, idx) => v !== candidates[idx]);
        if (changed) {
          candidates = refreshedCandidates;
          logger.info("Kandidatenliste aktualisiert");
        }
      }

      if (integrityError) {
        integrityError = null;
        logger.warn("SHA-Mismatch erkannt, erneuter Download-Versuch");
      }
    }

    if (!verified) {
      throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
    }

    emitProgress(onProgress, {
      stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
      message: "Starte stille Update-Installation",
    });

    const child = childProcess.spawn(targetPath, buildInstallerLaunchArgs(), {
      detached: true,
      stdio: "ignore",
      windowsHide: true,
    });
    child.once("error", (spawnError) => {
      logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
    });
    child.unref();

    emitProgress(onProgress, {
      stage: "done", percent: 100, downloadedBytes: 0, totalBytes: null,
      message: "Update wird im Hintergrund installiert und danach neu gestartet",
    });

    return { started: true, message: "Stille Update-Installation gestartet" };
  } catch (error) {
    try {
      await fs.promises.rm(targetPath, { force: true });
    } catch {
    }
    const releaseUrl = String(effective.releaseUrl || "").trim();
    const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
    const message = `${compactErrorText(error)}${hint}`;
    emitProgress(onProgress, {
      stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
      message,
    });
    return { started: false, message };
  } finally {
    if (activeAbortController === abortCtrl) {
      activeAbortController = null;
    }
  }
}

export function abortActiveUpdateDownload(): void {
  if (!activeAbortController || activeAbortController.signal.aborted) return;
  activeAbortController.abort("shutdown");
}
