import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
import { AllDebridHostInfo, AppSettings, DebridFallbackProvider, DebridLinkHostLimitInfo, DebridProvider } from "../shared/types";
import { isDebridLinkApiKeyDailyLimitReached, isMegaDebridAccountDisabled, isMegaDebridAccountDailyLimitReached, isProviderDailyLimitReached } from "../shared/provider-daily-limits";
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger";
import { logAccountRotation } from "./account-rotation-log";
import { traceConversionPhase } from "./conversion-trace";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { MEGA_DEBRID_NO_SERVER_RE } from "./mega-web-fallback";
import { isMegaFileUrl, resolveMegaFilename } from "./mega-public-api";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";

const API_TIMEOUT_MS = 30000;
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;

const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
const ALL_DEBRID_API_BASE = "https://api.alldebrid.com/v4";
const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1";

const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";

const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;

const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]);
const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
  "disabledServerHost",
  "notFreeHost",
  "serverNotAllowed",
  "freeServerOverload",
  "maintenanceHost",
  "noServerHost",
  "fileNotAvailable"
]);
const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]);
const debridLinkKeyCooldowns = new Map<string, number>();
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
type DebridLinkRuntimeState = DebridLinkHostLimitInfo["state"];
type DebridLinkRuntimeStatus = {
  state: DebridLinkRuntimeState;
  detail: string;
  updatedAt: number;
};
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000;
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;

export function resetDebridLinkRuntimeStateForTests(): void {
  debridLinkKeyCooldowns.clear();
  debridLinkKeyCooldownDetails.clear();
  debridLinkKeyRuntimeStatuses.clear();
  debridLinkKeyHostCooldowns.clear();
  debridLinkKeyHostCooldownDetails.clear();
}

export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
  for (const keyId of debridLinkKeyCooldowns.keys()) {
    if (!activeKeyIds.has(keyId)) {
      debridLinkKeyCooldowns.delete(keyId);
      debridLinkKeyCooldownDetails.delete(keyId);
    }
  }
  for (const keyId of debridLinkKeyRuntimeStatuses.keys()) {
    if (!activeKeyIds.has(keyId)) {
      debridLinkKeyRuntimeStatuses.delete(keyId);
    }
  }
  for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
    const sepIdx = stateKey.indexOf("|");
    const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
    if (!activeKeyIds.has(keyId)) {
      debridLinkKeyHostCooldowns.delete(stateKey);
      debridLinkKeyHostCooldownDetails.delete(stateKey);
    }
  }
}

export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
  let removed = 0;
  const grace = 60 * 60 * 1000;
  for (const [keyId, until] of debridLinkKeyCooldowns) {
    if (until + grace < now) {
      debridLinkKeyCooldowns.delete(keyId);
      debridLinkKeyCooldownDetails.delete(keyId);
      removed += 1;
    }
  }
  for (const [keyId, status] of debridLinkKeyRuntimeStatuses) {
    if (!debridLinkKeyCooldowns.has(keyId) && now - status.updatedAt > grace) {
      debridLinkKeyRuntimeStatuses.delete(keyId);
      removed += 1;
    }
  }
  for (const [stateKey, until] of debridLinkKeyHostCooldowns) {
    if (until + grace < now) {
      debridLinkKeyHostCooldowns.delete(stateKey);
      debridLinkKeyHostCooldownDetails.delete(stateKey);
      removed += 1;
    }
  }
  return removed;
}

export function primeDebridLinkRuntimeCooldownForTests(keyId: string, cooldownMs: number, message = "Debrid-Link Key im Cooldown"): void {
  setDebridLinkKeyCooldownState(keyId, cooldownMs, message, "temporary");
}

export function getDebridLinkKeyRuntimeStateForTests(keyId: string): DebridLinkRuntimeState | null {
  const status = debridLinkKeyRuntimeStatuses.get(keyId);
  return status ? status.state : null;
}

export function getDebridLinkKeyCooldownStateForTests(keyId: string, now = Date.now()): { remainingMs: number; message: string } | null {
  const state = getDebridLinkKeyCooldownState(keyId, now);
  return state ? { remainingMs: state.remainingMs, message: state.message } : null;
}

function clearDebridLinkKeyCooldownState(keyId: string): void {
  debridLinkKeyCooldowns.delete(keyId);
  debridLinkKeyCooldownDetails.delete(keyId);
}

function setDebridLinkKeyRuntimeStatus(keyId: string, state: DebridLinkRuntimeState, detail: string): void {
  debridLinkKeyRuntimeStatuses.set(keyId, {
    state,
    detail: String(detail || "").trim(),
    updatedAt: Date.now()
  });
}

function getDebridLinkKeyRuntimeStatus(keyId: string): DebridLinkRuntimeStatus | null {
  return debridLinkKeyRuntimeStatuses.get(keyId) || null;
}

function mapDebridLinkCooldownCategoryToRuntimeState(category: DebridLinkCooldownCategory): DebridLinkRuntimeState {
  if (category === "invalid") {
    return "invalid";
  }
  if (category === "quota") {
    return "quota";
  }
  if (category === "rate_limit") {
    return "rate_limit";
  }
  return "cooldown";
}

function setDebridLinkKeyCooldownState(
  keyId: string,
  cooldownMs: number,
  message: string,
  category: DebridLinkCooldownCategory
): void {
  if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
    clearDebridLinkKeyCooldownState(keyId);
    return;
  }
  const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
  const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
  const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
  const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid";
  const existingIsStrongCategory = existingDetail
    ? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
    : false;
  if (existingUntil > Date.now()) {
    if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
      return;
    }
    if (existingIsStrongCategory && !newIsStrongCategory) {
      return;
    }
  }
  debridLinkKeyCooldowns.set(keyId, newUntil);
  debridLinkKeyCooldownDetails.set(keyId, { message, category });
  setDebridLinkKeyRuntimeStatus(keyId, mapDebridLinkCooldownCategoryToRuntimeState(category), message);
}

function getDebridLinkKeyCooldownState(
  keyId: string,
  now = Date.now()
): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null {
  const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
  if (!until) {
    return null;
  }
  if (until <= now) {
    clearDebridLinkKeyCooldownState(keyId);
    return null;
  }
  const detail = debridLinkKeyCooldownDetails.get(keyId);
  return {
    until,
    remainingMs: until - now,
    message: detail?.message || "Debrid-Link Key im Cooldown",
    category: detail?.category || "temporary"
  };
}

const debridLinkKeyHostCooldowns = new Map<string, number>();
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();

function makeDebridLinkKeyHostCooldownKey(keyId: string, hoster: string): string {
  return `${keyId}|${hoster.toLowerCase()}`;
}

function clearDebridLinkKeyHostCooldownState(keyId: string, hoster: string): void {
  const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
  debridLinkKeyHostCooldowns.delete(stateKey);
  debridLinkKeyHostCooldownDetails.delete(stateKey);
}

function setDebridLinkKeyHostCooldownState(
  keyId: string,
  hoster: string,
  cooldownMs: number,
  message: string,
  category: DebridLinkCooldownCategory
): void {
  if (!hoster) {
    setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
    return;
  }
  if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
    clearDebridLinkKeyHostCooldownState(keyId, hoster);
    return;
  }
  const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
  const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
  const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
  const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
  const newIsStrongCategory = category === "rate_limit" || category === "quota" || category === "invalid";
  const existingIsStrongCategory = existingDetail
    ? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
    : false;
  if (existingUntil > Date.now()) {
    if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
      return;
    }
    if (existingIsStrongCategory && !newIsStrongCategory) {
      return;
    }
  }
  debridLinkKeyHostCooldowns.set(stateKey, newUntil);
  debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
}

function getDebridLinkKeyHostCooldownState(
  keyId: string,
  hoster: string,
  now = Date.now()
): { until: number; remainingMs: number; message: string; category: DebridLinkCooldownCategory } | null {
  if (!hoster) {
    return null;
  }
  const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
  const until = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
  if (!until) {
    return null;
  }
  if (until <= now) {
    debridLinkKeyHostCooldowns.delete(stateKey);
    debridLinkKeyHostCooldownDetails.delete(stateKey);
    return null;
  }
  const detail = debridLinkKeyHostCooldownDetails.get(stateKey);
  return {
    until,
    remainingMs: until - now,
    message: detail?.message || "Debrid-Link Key fuer Host im Cooldown",
    category: detail?.category || "quota"
  };
}

type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;

// A Mega-Web account abort (the shared unrestrict timeout firing while this
// account ran) only cools the account down — so the next attempt rotates on —
// if it actually ran this long. Below this, it's treated as a quick user-cancel
// (no cooldown). Env-overridable for tests.
const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000;
function getMegaDebridAbortMinRunMs(): number {
  const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN);
  return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT;
}

const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 10;

let megaDebridRotationCursor = 0;
let megaDebridStickyCount = 0;
// Mega-Web cacht Sessions pro Account (~20 Min). Wuerde jede Link-Aufloesung den
// Account wechseln (reines Round-Robin), zahlte JEDER Link einen kalten Login in
// die serielle Single-Flight-Queue → minutenlanger Vorlauf. Stattdessen bleibt die
// Rotation "klebrig": ein funktionierender Account wird fuer einen Schwung Links
// behalten (warm/schnell), erst danach (oder bei Limit/Cooldown/Fehler) auf den
// naechsten gewechselt. So bleibt es schnell UND ueber die Zeit kommen alle dran.
export const MEGA_DEBRID_STICKY_LINKS = 25;

// Wie viele Umwandlungen pro Account (key `${id}:${mode}`) GERADE laufen/anstehen. Gleichzeitige
// Aufloesungen waehlen den am WENIGSTEN belegten Account → verschiedene Accounts wandeln parallel
// um (je eigene Queue in MegaWebFallback), und auch bei mehr gleichzeitigen Links als Accounts
// verteilt es sich gleichmaessig statt sich hinter einem Account zu stauen. Tiefe (nicht nur
// belegt/frei), damit ein frueh fertiger Erst-Job einen noch laufenden Folge-Job nicht "frei" meldet.
const megaDebridInFlight = new Map<string, number>();

export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
  const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
  megaDebridEmptyResponseStreaks.set(accountId, streak);
  return streak;
}

export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
  megaDebridEmptyResponseStreaks.delete(accountId);
}

export function resetMegaDebridRuntimeStateForTests(): void {
  megaDebridAccountCooldowns.clear();
  megaDebridEmptyResponseStreaks.clear();
  megaDebridRotationCursor = 0;
  megaDebridStickyCount = 0;
  megaDebridInFlight.clear();
}

export function getMegaDebridInFlightCountForMode(mode: "api" | "web"): number {
  const suffix = `:${mode}`;
  let total = 0;
  for (const [key, count] of megaDebridInFlight) {
    if (key.endsWith(suffix)) {
      total += count;
    }
  }
  return total;
}

export function primeMegaDebridInFlightForTests(key: string, count: number): void {
  if (count <= 0) {
    megaDebridInFlight.delete(key);
    return;
  }
  megaDebridInFlight.set(key, count);
}

export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
  let removed = 0;
  const grace = 60 * 60 * 1000;
  for (const [id, detail] of megaDebridAccountCooldowns) {
    if (detail.until + grace < now) {
      megaDebridAccountCooldowns.delete(id);
      removed += 1;
    }
  }
  return removed;
}

export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldownMs: number, message = "Mega-Debrid Account im Cooldown"): void {
  setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
}

export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis zum Tagesreset gesperrt"): void {
  setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
}

export function classifyMegaDebridAccountFailureForTests(
  error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
  return MegaDebridClient.classifyAccountFailure(error);
}

function clearMegaDebridAccountCooldownState(accountId: string): void {
  megaDebridAccountCooldowns.delete(accountId);
}

function megaDebridDailyParkExpiry(now: number): number {
  const midnight = new Date(now);
  midnight.setHours(24, 0, 0, 0);
  return Math.max(midnight.getTime(), now + MEGA_DEBRID_ACCOUNT_COOLDOWN_MS);
}

function setMegaDebridAccountCooldownState(
  accountId: string,
  cooldownMs: number,
  message: string,
  category: MegaDebridCooldownCategory,
  untilRestart = false
): void {
  if (untilRestart) {
    megaDebridAccountCooldowns.set(accountId, {
      until: megaDebridDailyParkExpiry(Date.now()),
      message,
      category,
      untilRestart: true
    });
    return;
  }
  if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
    clearMegaDebridAccountCooldownState(accountId);
    return;
  }
  megaDebridAccountCooldowns.set(accountId, {
    until: Date.now() + Math.max(1000, Math.floor(cooldownMs)),
    message,
    category
  });
}

export function getMegaDebridAccountCooldownState(
  accountId: string,
  now = Date.now()
): { until: number; remainingMs: number; message: string; category: MegaDebridCooldownCategory; untilRestart: boolean } | null {
  const detail = megaDebridAccountCooldowns.get(accountId);
  if (!detail) {
    return null;
  }
  if (detail.until <= now) {
    clearMegaDebridAccountCooldownState(accountId);
    return null;
  }
  return {
    until: detail.until,
    remainingMs: detail.until - now,
    message: detail.message,
    category: detail.category,
    untilRestart: detail.untilRestart === true
  };
}

export interface ProviderRuntimeCooldown {
  untilMs: number;
  remainingMs: number;
  message: string;
  category: string;
  untilRestart?: boolean;
}

export interface ProviderRuntimeSnapshot {
  capturedAtMs: number;
  megaDebrid: {
    rotationCursor: number;
    stickyCount: number;
    accounts: Array<{
      key: string;
      cooldown: ProviderRuntimeCooldown | null;
      inFlight: number;
      emptyResponseStreak: number;
    }>;
  };
  debridLink: {
    keys: Array<{
      keyId: string;
      cooldown: ProviderRuntimeCooldown | null;
      runtimeStatus: { state: string; detail: string; updatedAt: number } | null;
    }>;
    hostCooldowns: Array<{ key: string; cooldown: ProviderRuntimeCooldown }>;
  };
}

export function getProviderRuntimeSnapshot(now = Date.now()): ProviderRuntimeSnapshot {
  const megaKeys = new Set<string>([
    ...megaDebridAccountCooldowns.keys(),
    ...megaDebridInFlight.keys(),
    ...megaDebridEmptyResponseStreaks.keys()
  ]);
  const megaAccounts = [...megaKeys].sort().map((key) => {
    const detail = megaDebridAccountCooldowns.get(key);
    return {
      key,
      cooldown: detail
        ? {
            untilMs: detail.until,
            remainingMs: Math.max(0, detail.until - now),
            message: detail.message,
            category: detail.category,
            untilRestart: detail.untilRestart === true
          }
        : null,
      inFlight: megaDebridInFlight.get(key) ?? 0,
      emptyResponseStreak: megaDebridEmptyResponseStreaks.get(key) ?? 0
    };
  });

  const dlKeyIds = new Set<string>([
    ...debridLinkKeyCooldowns.keys(),
    ...debridLinkKeyRuntimeStatuses.keys()
  ]);
  const dlKeys = [...dlKeyIds].sort().map((keyId) => {
    const until = Number(debridLinkKeyCooldowns.get(keyId) || 0);
    const detail = debridLinkKeyCooldownDetails.get(keyId);
    const status = debridLinkKeyRuntimeStatuses.get(keyId) || null;
    return {
      keyId,
      cooldown: until > 0
        ? {
            untilMs: until,
            remainingMs: Math.max(0, until - now),
            message: detail?.message ?? "",
            category: detail?.category ?? "temporary"
          }
        : null,
      runtimeStatus: status ? { state: status.state, detail: status.detail, updatedAt: status.updatedAt } : null
    };
  });

  const dlHostCooldowns = [...debridLinkKeyHostCooldowns].map(([key, until]) => {
    const detail = debridLinkKeyHostCooldownDetails.get(key);
    return {
      key,
      cooldown: {
        untilMs: until,
        remainingMs: Math.max(0, until - now),
        message: detail?.message ?? "",
        category: detail?.category ?? "temporary"
      }
    };
  });

  return {
    capturedAtMs: now,
    megaDebrid: {
      rotationCursor: megaDebridRotationCursor,
      stickyCount: megaDebridStickyCount,
      accounts: megaAccounts
    },
    debridLink: { keys: dlKeys, hostCooldowns: dlHostCooldowns }
  };
}

const LINKSNAPPY_API_BASE = "https://linksnappy.com/api";

const PROVIDER_LABELS: Record<DebridProvider, string> = {
  realdebrid: "Real-Debrid",
  megadebrid: "Mega-Debrid",
  "megadebrid-api": "Mega-Debrid API",
  "megadebrid-web": "Mega-Debrid Web",
  bestdebrid: "BestDebrid",
  alldebrid: "AllDebrid",
  ddownload: "DDownload",
  onefichier: "1Fichier",
  debridlink: "Debrid-Link",
  linksnappy: "LinkSnappy"
};

function extractHosterFromUrl(url: string): string {
  try {
    const host = new URL(url).hostname.replace(/^www\./, "").toLowerCase();
    const parts = host.split(".");
    return parts.length >= 2 ? parts[parts.length - 2] : host;
  } catch {
    return "";
  }
}

interface ProviderUnrestrictedLink extends UnrestrictedLink {
  provider: DebridProvider;
  providerLabel: string;
}

export type MegaWebUnrestrictor = (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => Promise<UnrestrictedLink | null>;
export type AllDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type RealDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;
export type BestDebridWebUnrestrictor = (link: string, signal?: AbortSignal) => Promise<UnrestrictedLink | null>;

interface DebridServiceOptions {
  megaWebUnrestrict?: MegaWebUnrestrictor;
  allDebridWebUnrestrict?: AllDebridWebUnrestrictor;
  realDebridWebUnrestrict?: RealDebridWebUnrestrictor;
  bestDebridWebUnrestrict?: BestDebridWebUnrestrictor;
}

function cloneSettings(settings: AppSettings): AppSettings {
  return {
    ...settings,
    bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })),
    debridLinkDisabledKeyIds: [...(settings.debridLinkDisabledKeyIds || [])],
    providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) },
    providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) },
    providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) },
    debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) },
    debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) },
    debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) },
    megaDebridDisabledAccountIds: [...(settings.megaDebridDisabledAccountIds || [])],
    megaDebridAccountDailyLimitBytes: { ...(settings.megaDebridAccountDailyLimitBytes || {}) },
    megaDebridAccountDailyUsageBytes: { ...(settings.megaDebridAccountDailyUsageBytes || {}) },
    megaDebridAccountTotalUsageBytes: { ...(settings.megaDebridAccountTotalUsageBytes || {}) }
  };
}

export function isDebridLinkApiKeyDisabled(settings: AppSettings, keyId: string): boolean {
  return (settings.debridLinkDisabledKeyIds || []).includes(keyId);
}

export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = Date.now()) {
  return parseDebridLinkApiKeys(settings.debridLinkApiKeys).filter(
    (entry) => !isDebridLinkApiKeyDisabled(settings, entry.id) && !isDebridLinkApiKeyDailyLimitReached(settings, entry.id, epochMs)
  );
}

export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
  return getMegaDebridAccountList(settings).filter(
    (entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
  );
}

function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
  const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
  if (multiAccounts.length > 0) {
    return multiAccounts;
  }
  if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
    return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
  }
  return [];
}

function hasMegaDebridCredentials(settings: AppSettings): boolean {
  return getMegaDebridAccountList(settings).length > 0;
}

function isMegaDebridModeEnabled(settings: AppSettings, mode: "api" | "web"): boolean {
  if (mode === "api") {
    return settings.megaDebridApiEnabled
      || (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && settings.megaDebridPreferApi);
  }
  return settings.megaDebridWebEnabled
    || (hasMegaDebridCredentials(settings) && !settings.megaDebridApiEnabled && !settings.megaDebridWebEnabled && !settings.megaDebridPreferApi);
}

function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider): DebridProvider {
  if (provider !== "megadebrid") {
    return provider;
  }
  if (isMegaDebridModeEnabled(settings, "api") && !isMegaDebridModeEnabled(settings, "web")) {
    return "megadebrid-api";
  }
  if (isMegaDebridModeEnabled(settings, "web") && !isMegaDebridModeEnabled(settings, "api")) {
    return "megadebrid-web";
  }
  return settings.megaDebridPreferApi ? "megadebrid-api" : "megadebrid-web";
}

type BestDebridRequest = {
  url: string;
  useAuthHeader: boolean;
};

function canonicalLink(link: string): string {
  try {
    const parsed = new URL(link);
    return `${parsed.host.toLowerCase()}${parsed.pathname}${parsed.search}`;
  } catch {
    return link.trim().toLowerCase();
  }
}

function shouldRetryStatus(status: number): boolean {
  return status === 429 || status >= 500;
}

function retryDelay(attempt: number): number {
  return Math.min(5000, 400 * 2 ** attempt);
}

function parseRetryAfterMs(value: string | null): number {
  const text = String(value || "").trim();
  if (!text) {
    return 0;
  }

  const maxRetryMs = 60 * 60 * 1000;
  const asSeconds = Number(text);
  if (Number.isFinite(asSeconds) && asSeconds >= 0) {
    return Math.min(maxRetryMs, Math.floor(asSeconds * 1000));
  }

  const asDate = Date.parse(text);
  if (Number.isFinite(asDate)) {
    return Math.min(maxRetryMs, Math.max(0, asDate - Date.now()));
  }

  return 0;
}

function retryDelayForResponse(response: Response, attempt: number): number {
  if (response.status !== 429) {
    return retryDelay(attempt);
  }
  const fromHeader = parseRetryAfterMs(response.headers.get("retry-after"));
  return fromHeader > 0 ? fromHeader : retryDelay(attempt);
}

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

function isRetryableErrorText(text: string): boolean {
  const status = readHttpStatusFromErrorText(text);
  if (status === 429 || status >= 500) {
    return true;
  }
  const lower = String(text || "").toLowerCase();
  return lower.includes("timeout")
    || lower.includes("network")
    || lower.includes("fetch failed")
    || lower.includes("aborted")
    || lower.includes("econnreset")
    || lower.includes("enotfound")
    || lower.includes("etimedout")
    || lower.includes("html statt json");
}

async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
  if (!signal) {
    await sleep(ms);
    return;
  }
  if (signal.aborted) {
    throw new Error("aborted:debrid");
  }
  await new Promise<void>((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:debrid"));
    };

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

function asRecord(value: unknown): Record<string, unknown> | null {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    return null;
  }
  return value as Record<string, unknown>;
}

function parseJson(text: string): unknown {
  try {
    return JSON.parse(text) as unknown;
  } catch {
    return null;
  }
}

function parseJsonSafe(text: string): Record<string, unknown> | null {
  const parsed = parseJson(text);
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    return null;
  }
  return parsed as Record<string, unknown>;
}

function pickString(payload: Record<string, unknown> | null, keys: string[]): string {
  if (!payload) {
    return "";
  }
  for (const key of keys) {
    const value = payload[key];
    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
  }
  return "";
}

function pickNumber(payload: Record<string, unknown> | null, keys: string[]): number | null {
  if (!payload) {
    return null;
  }
  for (const key of keys) {
    const value = Number(payload[key] ?? NaN);
    if (Number.isFinite(value) && value >= 0) {
      return Math.floor(value);
    }
  }
  return null;
}

function parseError(status: number, responseText: string, payload: Record<string, unknown> | null): string {
  const fromPayload = pickString(payload, ["response_text", "error", "message", "detail", "error_description"]);
  if (fromPayload) {
    return fromPayload;
  }
  const compact = compactErrorText(responseText);
  if (compact && compact !== "Unbekannter Fehler") {
    return compact;
  }
  return `HTTP ${status}`;
}

function parseAllDebridError(payload: Record<string, unknown> | null): string {
  const errorValue = payload?.error;
  if (typeof errorValue === "string" && errorValue.trim()) {
    return errorValue.trim();
  }
  const errorObj = asRecord(errorValue);
  return pickString(errorObj, ["message", "code"]) || "AllDebrid API error";
}

function normalizeAllDebridHostKey(value: string): string {
  return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}

function toAllDebridHostState(value: unknown): AllDebridHostInfo["state"] {
  if (value === true) {
    return "up";
  }
  if (value === false) {
    return "down";
  }
  const normalized = String(value || "").trim().toLowerCase();
  if (normalized === "up" || normalized === "online" || normalized === "available") {
    return "up";
  }
  if (normalized === "down" || normalized === "offline" || normalized === "unavailable") {
    return "down";
  }
  if (normalized === "not_tracked" || normalized === "not tracked") {
    return "not_tracked";
  }
  return "unknown";
}

function toAllDebridHostStatusLabel(state: AllDebridHostInfo["state"]): string {
  if (state === "up") {
    return "Verfügbar";
  }
  if (state === "down") {
    return "Unverfügbar";
  }
  if (state === "not_tracked") {
    return "Nicht getrackt";
  }
  return "Unbekannt";
}

function normalizeDebridLinkHostKey(value: string): string {
  return String(value || "").replace(/[^a-z0-9]+/gi, "").toLowerCase();
}

function parseDebridLinkSuccess(payload: Record<string, unknown> | null): boolean {
  if (!payload) {
    return false;
  }
  if (typeof payload.success === "boolean") {
    return payload.success;
  }
  return pickString(payload, ["result"]).toUpperCase() === "OK";
}

function parseDebridLinkHosters(payload: Record<string, unknown> | null): Record<string, unknown>[] {
  const value = asRecord(payload?.value);
  const hosters = value?.hosters ?? payload?.hosters;
  if (Array.isArray(hosters)) {
    return hosters.filter((entry): entry is Record<string, unknown> => Boolean(asRecord(entry))).map((entry) => entry as Record<string, unknown>);
  }
  return [];
}

function findDebridLinkHostEntry(payload: Record<string, unknown> | null, host: string): Record<string, unknown> | null {
  const wanted = normalizeDebridLinkHostKey(host);
  for (const entry of parseDebridLinkHosters(payload)) {
    const name = normalizeDebridLinkHostKey(pickString(entry, ["name", "host"]));
    if (name === wanted) {
      return entry;
    }
  }
  return null;
}

function parseDebridLinkErrorCode(payload: Record<string, unknown> | null): string {
  return pickString(payload, ["error", "ERR"]);
}

function parseDebridLinkErrorDescription(payload: Record<string, unknown> | null): string {
  return pickString(payload, ["error_description", "message", "detail", "response_text", "error", "ERR"]);
}

function looksLikeHtmlResponse(contentType: string, body: string): boolean {
  const type = String(contentType || "").toLowerCase();
  if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
    return true;
  }
  return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
}

function parsePositiveNumber(value: unknown): number | null {
  const numeric = Number(value ?? NaN);
  if (!Number.isFinite(numeric) || numeric <= 0) {
    return null;
  }
  return Math.floor(numeric);
}

function parseDebridLinkNextResetMs(payload: Record<string, unknown> | null): number {
  const value = asRecord(payload?.value);
  const nextReset = value?.nextResetSeconds;
  const nextResetRecord = asRecord(nextReset);
  const seconds = parsePositiveNumber(nextReset)
    ?? parsePositiveNumber(nextResetRecord?.current)
    ?? parsePositiveNumber(nextResetRecord?.value);
  if (!seconds) {
    return 0;
  }
  return Math.min(24 * 60 * 60 * 1000, seconds * 1000);
}

function parseDebridLinkLinkEntries(value: unknown): Record<string, unknown>[] {
  if (Array.isArray(value)) {
    return value
      .map((entry) => asRecord(entry))
      .filter((entry): entry is Record<string, unknown> => Boolean(entry));
  }
  const entry = asRecord(value);
  return entry ? [entry] : [];
}

class DebridLinkApiError extends Error {
  public readonly status: number;
  public readonly code: string;
  public readonly retryAfterMs: number;
  public readonly payload: Record<string, unknown> | null;

  public constructor(
    status: number,
    code: string,
    description: string,
    retryAfterMs: number,
    payload: Record<string, unknown> | null
  ) {
    super(description || code || `HTTP ${status || 0}`);
    this.name = "DebridLinkApiError";
    this.status = status;
    this.code = code;
    this.retryAfterMs = retryAfterMs;
    this.payload = payload;
  }
}

function toDebridLinkKeyStateLabel(state: DebridLinkHostLimitInfo["state"]): string {
  if (state === "ready") {
    return "Bereit";
  }
  if (state === "cooldown") {
    return "Cooldown";
  }
  if (state === "invalid") {
    return "Ungueltig";
  }
  if (state === "quota") {
    return "Quota";
  }
  if (state === "rate_limit") {
    return "Rate-Limit";
  }
  if (state === "error") {
    return "Fehler";
  }
  return "Unbekannt";
}

function toDebridLinkHostStateLabel(state: DebridLinkHostLimitInfo["hostState"]): string {
  if (state === "up") {
    return "Online";
  }
  if (state === "down") {
    return "Offline";
  }
  return "Unbekannt";
}

function shouldRetryDebridLinkApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean {
  if (attempt >= maxAttempts) {
    return false;
  }
  if (error.status === 429 || error.status >= 500) {
    return true;
  }
  return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code);
}

function retryDelayForDebridLinkApiError(error: DebridLinkApiError, attempt: number): number {
  if (error.retryAfterMs > 0) {
    return error.retryAfterMs;
  }
  return retryDelay(attempt);
}

async function requestDebridLinkPayloadWithKey(
  apiKey: { token: string },
  method: "GET" | "POST" | "DELETE",
  apiPath: string,
  body: Record<string, unknown> | undefined,
  signal?: AbortSignal,
  maxAttempts = REQUEST_RETRIES
): Promise<Record<string, unknown>> {
  let lastTransportError = "";
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      const headers: Record<string, string> = {
        Accept: "application/json",
        Authorization: `Bearer ${apiKey.token}`,
        "User-Agent": DEBRID_USER_AGENT
      };
      let payloadBody: string | undefined;
      if (method !== "GET" && method !== "DELETE" && body) {
        headers["Content-Type"] = "application/json";
        payloadBody = JSON.stringify(body);
      }

      const response = await fetch(`${DEBRID_LINK_API_BASE}${apiPath}`, {
        method,
        headers,
        body: payloadBody,
        signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
      });
      const responseText = await response.text();
      const payload = parseJsonSafe(responseText);
      if (!payload) {
        const description = looksLikeHtmlResponse(response.headers.get("content-type") || "", responseText)
          ? `Debrid-Link lieferte HTML statt JSON (HTTP ${response.status})`
          : compactErrorText(responseText) || `Debrid-Link lieferte kein JSON (HTTP ${response.status})`;
        const error = new DebridLinkApiError(
          response.status,
          "requestError",
          description,
          parseRetryAfterMs(response.headers.get("retry-after")),
          null
        );
        if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) {
          await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal);
          continue;
        }
        throw error;
      }

      if (!response.ok || !parseDebridLinkSuccess(payload)) {
        const error = new DebridLinkApiError(
          response.status,
          parseDebridLinkErrorCode(payload) || `HTTP ${response.status}`,
          parseDebridLinkErrorDescription(payload) || `HTTP ${response.status}`,
          parseRetryAfterMs(response.headers.get("retry-after")),
          payload
        );
        if (shouldRetryDebridLinkApiError(error, attempt, maxAttempts)) {
          await sleepWithSignal(retryDelayForDebridLinkApiError(error, attempt), signal);
          continue;
        }
        throw error;
      }

      return payload;
    } catch (error) {
      if (error instanceof DebridLinkApiError) {
        throw error;
      }
      lastTransportError = compactErrorText(error);
      if (signal?.aborted || (/aborted/i.test(lastTransportError) && !/timeout/i.test(lastTransportError))) {
        throw error;
      }
      if (attempt >= maxAttempts || !isRetryableErrorText(lastTransportError)) {
        throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
      }
      await sleepWithSignal(retryDelay(attempt), signal);
    }
  }
  throw new Error(lastTransportError || "Debrid-Link Request fehlgeschlagen");
}

async function fetchDebridLinkPublicHostInfo(
  host: string,
  signal?: AbortSignal
): Promise<Pick<DebridLinkHostLimitInfo, "hostState" | "hostStateLabel" | "hostNote">> {
  const hostLabel = host.trim() || "rapidgator";
  try {
    const response = await fetch(`${DEBRID_LINK_API_BASE}/downloader/hosts?keys=name,status,domains`, {
      method: "GET",
      headers: {
        Accept: "application/json",
        "User-Agent": DEBRID_USER_AGENT
      },
      signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
    });
    const responseText = await response.text();
    const payload = parseJsonSafe(responseText);
    if (!response.ok || !payload || !parseDebridLinkSuccess(payload)) {
      throw new Error(parseError(response.status, responseText, payload));
    }
    const entries = Array.isArray(payload.value)
      ? payload.value.map((entry) => asRecord(entry)).filter((entry): entry is Record<string, unknown> => Boolean(entry))
      : [];
    const wanted = normalizeDebridLinkHostKey(hostLabel);
    const hostEntry = entries.find((entry) => {
      const name = normalizeDebridLinkHostKey(pickString(entry, ["name"]));
      if (name === wanted) {
        return true;
      }
      const domains = Array.isArray(entry.domains) ? entry.domains.map((value) => normalizeDebridLinkHostKey(String(value || ""))) : [];
      return domains.some((domain) => domain === wanted);
    });
    if (!hostEntry) {
      return {
        hostState: "unknown",
        hostStateLabel: toDebridLinkHostStateLabel("unknown"),
        hostNote: `${hostLabel} nicht in /downloader/hosts gefunden.`
      };
    }
    const statusValue = Number(hostEntry.status ?? NaN);
    const hostState: DebridLinkHostLimitInfo["hostState"] = Number.isFinite(statusValue)
      ? (statusValue >= 1 ? "up" : "down")
      : "unknown";
    return {
      hostState,
      hostStateLabel: toDebridLinkHostStateLabel(hostState),
      hostNote: hostState === "down"
        ? `${hostLabel} ist laut Debrid-Link /downloader/hosts aktuell offline.`
        : `${hostLabel} ist laut Debrid-Link /downloader/hosts erreichbar.`
    };
  } catch (error) {
    return {
      hostState: "unknown",
      hostStateLabel: toDebridLinkHostStateLabel("unknown"),
      hostNote: `Hoststatus konnte nicht geladen werden: ${compactErrorText(error)}`
    };
  }
}

async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: string; token: string }, host: string, signal?: AbortSignal): Promise<DebridLinkHostLimitInfo> {
  let lastError = "";
  const hostLabel = host.trim() || "rapidgator";
  const endpoints = [`${DEBRID_LINK_API_BASE}/downloader/limits/all`, `${DEBRID_LINK_API_BASE}/downloader/limits`];

  for (const endpoint of endpoints) {
    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      try {
        const response = await fetch(endpoint, {
          method: "GET",
          headers: {
            Authorization: `Bearer ${apiKey.token}`,
            "User-Agent": DEBRID_USER_AGENT
          },
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });
        const text = await response.text();
        const payload = parseJsonSafe(text);

        if (response.status === 404 && endpoint.endsWith("/all")) {
          break;
        }

        if (!response.ok) {
          const reason = parseError(response.status, text, payload);
          if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
            await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
            continue;
          }
          throw new Error(reason);
        }

        if (!payload) {
          throw new Error("Debrid-Link Limits Antwort ist kein JSON-Objekt");
        }
        if (!parseDebridLinkSuccess(payload)) {
          throw new Error(pickString(payload, ["error_description", "error", "message"]) || "Debrid-Link Limits fehlgeschlagen");
        }

        const hostEntry = findDebridLinkHostEntry(payload, hostLabel);
        if (!hostEntry) {
          if (endpoint.endsWith("/all")) {
            break;
          }
          return {
            keyId: apiKey.id,
            keyLabel: apiKey.label,
            host: hostLabel,
            fetchedAt: Date.now(),
            trafficCurrentBytes: null,
            trafficMaxBytes: null,
            linksCurrent: null,
            linksMax: null,
            note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.`,
            state: "unknown",
            stateLabel: toDebridLinkKeyStateLabel("unknown"),
            stateDetail: "",
            cooldownUntil: null,
            cooldownRemainingMs: 0,
            lastCheckedAt: Date.now(),
            hostState: "unknown",
            hostStateLabel: toDebridLinkHostStateLabel("unknown"),
            hostNote: ""
          };
        }

        const daySize = asRecord(hostEntry.daySize);
        const dayCount = asRecord(hostEntry.dayCount);
        return {
          keyId: apiKey.id,
          keyLabel: apiKey.label,
          host: pickString(hostEntry, ["name", "host"]) || hostLabel,
          fetchedAt: Date.now(),
          trafficCurrentBytes: pickNumber(daySize, ["current"]),
          trafficMaxBytes: pickNumber(daySize, ["value", "max"]),
          linksCurrent: pickNumber(dayCount, ["current"]),
          linksMax: pickNumber(dayCount, ["value", "max"]),
          note: "",
          state: "ready",
          stateLabel: toDebridLinkKeyStateLabel("ready"),
          stateDetail: "API erreichbar",
          cooldownUntil: null,
          cooldownRemainingMs: 0,
          lastCheckedAt: Date.now(),
          hostState: "unknown",
          hostStateLabel: toDebridLinkHostStateLabel("unknown"),
          hostNote: ""
        };
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          break;
        }
        if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
          break;
        }
        await sleepWithSignal(retryDelay(attempt), signal);
      }
    }
  }

  throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, ""));
}

async function fetchDebridLinkHostLimitForKeyDetailed(
  apiKey: { id: string; label: string; token: string },
  host: string,
  publicHostInfo: Pick<DebridLinkHostLimitInfo, "hostState" | "hostStateLabel" | "hostNote">,
  signal?: AbortSignal
): Promise<DebridLinkHostLimitInfo> {
  const hostLabel = host.trim() || "rapidgator";
  const runtimeStatus = getDebridLinkKeyRuntimeStatus(apiKey.id);
  const buildInfo = (overrides: Partial<DebridLinkHostLimitInfo>): DebridLinkHostLimitInfo => ({
    keyId: apiKey.id,
    keyLabel: apiKey.label,
    host: hostLabel,
    fetchedAt: Date.now(),
    trafficCurrentBytes: null,
    trafficMaxBytes: null,
    linksCurrent: null,
    linksMax: null,
    note: "",
    state: runtimeStatus?.state || "unknown",
    stateLabel: toDebridLinkKeyStateLabel(runtimeStatus?.state || "unknown"),
    stateDetail: runtimeStatus?.detail || "",
    cooldownUntil: null,
    cooldownRemainingMs: 0,
    lastCheckedAt: runtimeStatus?.updatedAt || null,
    hostState: publicHostInfo.hostState,
    hostStateLabel: publicHostInfo.hostStateLabel,
    hostNote: publicHostInfo.hostNote,
    ...overrides
  });

  const cooldownState = getDebridLinkKeyCooldownState(apiKey.id);
  if (cooldownState) {
    const state = mapDebridLinkCooldownCategoryToRuntimeState(cooldownState.category);
    return buildInfo({
      state,
      stateLabel: toDebridLinkKeyStateLabel(state),
      stateDetail: cooldownState.message,
      cooldownUntil: cooldownState.until,
      cooldownRemainingMs: cooldownState.remainingMs,
      lastCheckedAt: runtimeStatus?.updatedAt || Date.now(),
      note: cooldownState.message
    });
  }

  for (const apiPath of ["/downloader/limits/all", "/downloader/limits"]) {
    try {
      const payload = await requestDebridLinkPayloadWithKey(apiKey, "GET", apiPath, undefined, signal);
      const hostEntry = findDebridLinkHostEntry(payload, hostLabel);
      if (!hostEntry) {
        if (apiPath.endsWith("/all")) {
          continue;
        }
        clearDebridLinkKeyCooldownState(apiKey.id);
        setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar");
        return buildInfo({
          state: "ready",
          stateLabel: toDebridLinkKeyStateLabel("ready"),
          stateDetail: "API erreichbar",
          lastCheckedAt: Date.now(),
          note: `${hostLabel} nicht in der Debrid-Link-Limits-Antwort vorhanden.`
        });
      }

      const daySize = asRecord(hostEntry.daySize);
      const dayCount = asRecord(hostEntry.dayCount);
      clearDebridLinkKeyCooldownState(apiKey.id);
      setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "API erreichbar");
      return buildInfo({
        host: pickString(hostEntry, ["name", "host"]) || hostLabel,
        trafficCurrentBytes: pickNumber(daySize, ["current"]),
        trafficMaxBytes: pickNumber(daySize, ["value", "max"]),
        linksCurrent: pickNumber(dayCount, ["current"]),
        linksMax: pickNumber(dayCount, ["value", "max"]),
        state: "ready",
        stateLabel: toDebridLinkKeyStateLabel("ready"),
        stateDetail: "API erreichbar",
        lastCheckedAt: Date.now(),
        note: ""
      });
    } catch (error) {
      if (error instanceof DebridLinkApiError && error.status === 404 && apiPath.endsWith("/all")) {
        continue;
      }

      const checkedAt = Date.now();
      if (error instanceof DebridLinkApiError) {
        const code = String(error.code || "").trim() || `HTTP ${error.status}`;
        const description = error.message || code;

        if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
          const detail = `API-Key ungueltig oder deaktiviert (${code}: ${description})`;
          setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_INVALID_KEY_COOLDOWN_MS, detail, "invalid");
          const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt);
          return buildInfo({
            state: "invalid",
            stateLabel: toDebridLinkKeyStateLabel("invalid"),
            stateDetail: detail,
            cooldownUntil: nextCooldown?.until || null,
            cooldownRemainingMs: nextCooldown?.remainingMs || 0,
            lastCheckedAt: checkedAt,
            note: detail
          });
        }

        if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) {
          const detail = `API-Rate-Limit erreicht (${code}: ${description})`;
          setDebridLinkKeyCooldownState(apiKey.id, error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS, detail, "rate_limit");
          const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt);
          return buildInfo({
            state: "rate_limit",
            stateLabel: toDebridLinkKeyStateLabel("rate_limit"),
            stateDetail: detail,
            cooldownUntil: nextCooldown?.until || null,
            cooldownRemainingMs: nextCooldown?.remainingMs || 0,
            lastCheckedAt: checkedAt,
            note: detail
          });
        }

        if (DEBRID_LINK_QUOTA_ERRORS.has(code)) {
          const detail = `Quota erreicht (${code}: ${description})`;
          setDebridLinkKeyCooldownState(apiKey.id, parseDebridLinkNextResetMs(error.payload) || DEBRID_LINK_KEY_COOLDOWN_MS, detail, "quota");
          const nextCooldown = getDebridLinkKeyCooldownState(apiKey.id, checkedAt);
          return buildInfo({
            state: "quota",
            stateLabel: toDebridLinkKeyStateLabel("quota"),
            stateDetail: detail,
            cooldownUntil: nextCooldown?.until || null,
            cooldownRemainingMs: nextCooldown?.remainingMs || 0,
            lastCheckedAt: checkedAt,
            note: detail
          });
        }

        const detail = `${code}: ${description}`;
        setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail);
        return buildInfo({
          state: "error",
          stateLabel: toDebridLinkKeyStateLabel("error"),
          stateDetail: detail,
          lastCheckedAt: checkedAt,
          note: detail
        });
      }

      const detail = compactErrorText(error).replace(/^Error:\s*/i, "") || `Debrid-Link Limits fuer ${apiKey.label} fehlgeschlagen`;
      setDebridLinkKeyRuntimeStatus(apiKey.id, "error", detail);
      return buildInfo({
        state: "error",
        stateLabel: toDebridLinkKeyStateLabel("error"),
        stateDetail: detail,
        lastCheckedAt: checkedAt,
        note: detail
      });
    }
  }

  return buildInfo({
    state: "unknown",
    stateLabel: toDebridLinkKeyStateLabel("unknown"),
    stateDetail: `Keine Limits fuer ${apiKey.label} gefunden`,
    lastCheckedAt: Date.now(),
    note: `Keine Limits fuer ${apiKey.label} gefunden`
  });
}

function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] {
  const seen = new Set<DebridProvider>();
  const result: DebridProvider[] = [];
  for (const provider of order) {
    if (seen.has(provider)) {
      continue;
    }
    seen.add(provider);
    result.push(provider);
  }
  return result;
}

function toProviderOrder(primary: DebridProvider, secondary: DebridFallbackProvider, tertiary: DebridFallbackProvider): DebridProvider[] {
  const order: DebridProvider[] = [primary];
  if (secondary !== "none") {
    order.push(secondary);
  }
  if (tertiary !== "none") {
    order.push(tertiary);
  }
  return uniqueProviderOrder(order);
}

export function leadProviderChainWith(order: readonly DebridProvider[], preferred: DebridProvider | null | undefined): DebridProvider[] {
  if (!preferred || !order.includes(preferred)) {
    return [...order];
  }
  return [preferred, ...order.filter((provider) => provider !== preferred)];
}

function isRapidgatorLink(link: string): boolean {
  try {
    const hostname = new URL(link).hostname.toLowerCase();
    return hostname === "rapidgator.net"
      || hostname.endsWith(".rapidgator.net")
      || hostname === "rg.to"
      || hostname.endsWith(".rg.to")
      || hostname === "rapidgator.asia"
      || hostname.endsWith(".rapidgator.asia");
  } catch {
    return false;
  }
}

function decodeHtmlEntities(text: string): string {
  return text
    .replace(/&amp;/g, "&")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">");
}

function safeDecode(value: string): string {
  try {
    return decodeURIComponent(value);
  } catch {
    return value;
  }
}

function looksLikeFileName(value: string): boolean {
  return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value);
}

export function normalizeResolvedFilename(value: string): string {
  const candidate = decodeHtmlEntities(String(value || ""))
    .replace(/<[^>]*>/g, " ")
    .replace(/\s+/g, " ")
    .replace(/^['"]+|['"]+$/g, "")
    .replace(/^download\s+file\s+/i, "")
    .replace(/\s*[-|]\s*rapidgator.*$/i, "")
    .trim();
  if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) {
    return "";
  }
  return candidate;
}

export function filenameFromRapidgatorUrlPath(link: string): string {
  try {
    const parsed = new URL(link);
    const pathParts = parsed.pathname.split("/").filter(Boolean);
    for (let index = pathParts.length - 1; index >= 0; index -= 1) {
      const raw = safeDecode(pathParts[index]).replace(/\.html?$/i, "").trim();
      const normalized = normalizeResolvedFilename(raw);
      if (normalized) {
        return normalized;
      }
    }
    return "";
  } catch {
    return "";
  }
}

export function extractRapidgatorFilenameFromHtml(html: string): string {
  const patterns = [
    /<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
    /<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
    /<title>([^<]{1,260})<\/title>/i,
    /(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]{1,260})\s*</i,
    /(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]{1,260})/i
  ];

  for (const pattern of patterns) {
    const match = html.match(pattern);
    const raw = match?.[1] || match?.[2] || "";
    const normalized = normalizeResolvedFilename(raw);
    if (normalized) {
      return normalized;
    }
  }

  return "";
}

async function runWithConcurrency<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
  if (items.length === 0) {
    return;
  }
  const size = Math.max(1, Math.min(concurrency, items.length));
  let index = 0;
  let firstError: unknown = null;
  const next = (): T | undefined => {
    if (firstError || index >= items.length) {
      return undefined;
    }
    const item = items[index];
    index += 1;
    return item;
  };
  const runners = Array.from({ length: size }, async () => {
    let current = next();
    while (current !== undefined) {
      try {
        await worker(current);
      } catch (error) {
        if (!firstError) {
          firstError = error;
        }
      }
      current = next();
    }
  });
  await Promise.all(runners);
  if (firstError) {
    throw firstError;
  }
}

function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
  if (!signal) {
    return AbortSignal.timeout(timeoutMs);
  }
  return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
}

async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> {
  const body = response.body;
  if (!body) {
    return "";
  }

  const reader = body.getReader();
  const chunks: Buffer[] = [];
  let readBytes = 0;

  try {
    while (readBytes < maxBytes) {
      if (signal?.aborted) {
        throw new Error("aborted:debrid");
      }

      const { done, value } = await reader.read();
      if (done || !value || value.byteLength === 0) {
        break;
      }

      const remaining = maxBytes - readBytes;
      const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
      chunks.push(Buffer.from(slice));
      readBytes += slice.byteLength;
    }
  } finally {
    try {
      await reader.cancel();
    } catch {
    }
    try {
      reader.releaseLock();
    } catch {
    }
  }

  return Buffer.concat(chunks).toString("utf8");
}

async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Promise<string> {
  if (!isRapidgatorLink(link)) {
    return "";
  }
  const fromUrl = filenameFromRapidgatorUrlPath(link);
  if (fromUrl) {
    return fromUrl;
  }

  if (signal?.aborted) {
    throw new Error("aborted:debrid");
  }

  for (let attempt = 1; attempt <= REQUEST_RETRIES + 2; attempt += 1) {
    try {
      const response = await fetch(link, {
        method: "GET",
        headers: {
          "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
          Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
          "Accept-Language": "en-US,en;q=0.9,de;q=0.8"
        },
        signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
      });
      if (!response.ok) {
        try { await response.body?.cancel(); } catch {  }
        if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
          await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
          continue;
        }
        return "";
      }

      const contentType = String(response.headers.get("content-type") || "").toLowerCase();
      const contentLength = Number(response.headers.get("content-length") || NaN);
      if (contentType
        && !contentType.includes("text/html")
        && !contentType.includes("application/xhtml")
        && !contentType.includes("text/plain")
        && !contentType.includes("text/xml")
        && !contentType.includes("application/xml")) {
        try { await response.body?.cancel(); } catch {  }
        return "";
      }
      if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
        try { await response.body?.cancel(); } catch {  }
        return "";
      }

      const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
      const fromHtml = extractRapidgatorFilenameFromHtml(html);
      if (fromHtml) {
        return fromHtml;
      }
      return "";
    } catch (error) {
      const errorText = compactErrorText(error);
      if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
        throw error;
      }
      if (attempt >= REQUEST_RETRIES + 2 || !isRetryableErrorText(errorText)) {
        return "";
      }
    }

    if (attempt < REQUEST_RETRIES + 2) {
      await sleepWithSignal(retryDelay(attempt), signal);
    }
  }

  return "";
}

export interface RapidgatorCheckResult {
  online: boolean;
  fileName: string;
  fileSize: string | null;
}

const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;

export async function checkRapidgatorOnline(
  link: string,
  signal?: AbortSignal
): Promise<RapidgatorCheckResult | null> {
  if (!isRapidgatorLink(link)) {
    return null;
  }

  const fileIdMatch = link.match(RG_FILE_ID_RE);
  if (!fileIdMatch) {
    return null;
  }
  const fileId = fileIdMatch[1];
  const headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
    Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9,de;q=0.8"
  };

  for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
    try {
      if (signal?.aborted) throw new Error("aborted:debrid");

      const response = await fetch(link, {
        method: "HEAD",
        redirect: "follow",
        headers,
        signal: withTimeoutSignal(signal, 15000)
      });

      if (response.status === 404) {
        return { online: false, fileName: "", fileSize: null };
      }

      if (response.ok) {
        const finalUrl = response.url || link;
        if (!finalUrl.includes(fileId)) {
          return { online: false, fileName: "", fileSize: null };
        }
        const fileName = filenameFromRapidgatorUrlPath(link);
        return { online: true, fileName, fileSize: null };
      }

      if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
        await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
        continue;
      }

      break;
    } catch (error) {
      const errorText = compactErrorText(error);
      if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
      if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
        break;
      }
      await sleepWithSignal(retryDelay(attempt), signal);
    }
  }

  for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
    try {
      if (signal?.aborted) throw new Error("aborted:debrid");

      const response = await fetch(link, {
        method: "GET",
        redirect: "follow",
        headers,
        signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
      });

      if (response.status === 404) {
        try { await response.body?.cancel(); } catch {  }
        return { online: false, fileName: "", fileSize: null };
      }

      if (!response.ok) {
        try { await response.body?.cancel(); } catch {  }
        if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
          await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
          continue;
        }
        return null;
      }

      const finalUrl = response.url || link;
      if (!finalUrl.includes(fileId)) {
        try { await response.body?.cancel(); } catch {  }
        return { online: false, fileName: "", fileSize: null };
      }

      const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);

      if (RG_FILE_NOT_FOUND_RE.test(html)) {
        return { online: false, fileName: "", fileSize: null };
      }

      const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
      const sizeMatch = html.match(RG_FILESIZE_RE);
      const fileSize = sizeMatch ? sizeMatch[1].trim() : null;

      return { online: true, fileName, fileSize };
    } catch (error) {
      const errorText = compactErrorText(error);
      if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
      if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
        return null;
      }
    }

    if (attempt <= REQUEST_RETRIES) {
      await sleepWithSignal(retryDelay(attempt), signal);
    }
  }

  return null;
}

function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
  const linkParam = encodeURIComponent(link);
  const safeToken = String(token || "").trim();
  const useAuthHeader = Boolean(safeToken);
  return [
    {
      url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`,
      useAuthHeader
    }
  ];
}

class MegaDebridClient {
  private megaWebUnrestrict?: MegaWebUnrestrictor;

  private login: string;

  private password: string;

  private mode: "api" | "web";

  private allowApiFallback: boolean;

  private static cachedApiTokens = new Map<string, { token: string; at: number }>();

  private static pendingConnects = new Map<string, Promise<string | null>>();

  public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
    const keep = new Set<string>();
    for (const login of activeLogins) {
      keep.add(String(login || "").toLowerCase());
    }
    for (const login of MegaDebridClient.cachedApiTokens.keys()) {
      if (!keep.has(login)) {
        MegaDebridClient.cachedApiTokens.delete(login);
      }
    }
    for (const login of MegaDebridClient.pendingConnects.keys()) {
      if (!keep.has(login)) {
        MegaDebridClient.pendingConnects.delete(login);
      }
    }
  }

  public static clearCachedApiToken(login: string): void {
    const key = String(login || "").toLowerCase();
    MegaDebridClient.cachedApiTokens.delete(key);
    MegaDebridClient.pendingConnects.delete(key);
  }

  public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
    this.login = login;
    this.password = password;
    this.mode = mode;
    this.allowApiFallback = allowApiFallback;
    this.megaWebUnrestrict = megaWebUnrestrict;
  }

  private get cacheKey(): string {
    return this.login.trim().toLowerCase();
  }

  private async connectApi(signal?: AbortSignal): Promise<string | null> {
    const key = this.cacheKey;
    const cached = MegaDebridClient.cachedApiTokens.get(key);
    if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
      traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
      return cached.token;
    }

    const pending = MegaDebridClient.pendingConnects.get(key);
    if (pending) {
      traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
      return pending;
    }

    const promise = this.doConnectApi(signal).finally(() => {
      MegaDebridClient.pendingConnects.delete(key);
    });
    MegaDebridClient.pendingConnects.set(key, promise);
    return promise;
  }

  private clearTokenCache(): void {
    MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
  }

  private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
    const connectStartedAt = Date.now();
    const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
    const response = await fetch(url, {
      headers: { "User-Agent": DEBRID_USER_AGENT },
      signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
    });
    const text = await response.text();
    if (!response.ok) {
      if (response.status === 401 || response.status === 403) {
        this.clearTokenCache();
      }
      traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
      return null;
    }
    const payload = parseJsonSafe(text);
    if (!payload || payload.response_code !== "ok") {
      if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
        this.clearTokenCache();
      }
      traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
      return null;
    }
    const token = String(payload.token || "").trim();
    if (!token) {
      traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
      return null;
    }
    MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
    traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
    return token;
  }

  private async unrestrictViaApi(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
    const token = await this.connectApi(signal);
    if (!token) {
      return null;
    }

    const getLinkStartedAt = Date.now();
    const url = `${MEGA_DEBRID_API_BASE}?action=getLink&token=${encodeURIComponent(token)}`;
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        "User-Agent": DEBRID_USER_AGENT
      },
      body: new URLSearchParams({ link }),
      signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
    });
    const text = await response.text();
    if (!response.ok) {
      if (response.status === 401 || response.status === 403) {
        this.clearTokenCache();
      }
      traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
      return null;
    }
    const payload = parseJsonSafe(text);
    if (!payload || payload.response_code !== "ok") {
      const tokenInvalidated = Boolean(payload && String(payload.response_code || "").includes("token"));
      if (tokenInvalidated) {
        this.clearTokenCache();
      }
      const errorText = String(payload?.response_text || "").trim();
      traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"}${tokenInvalidated ? " (token-cache-geleert)" : ""} ${errorText}`.trim() });
      if (errorText) {
        throw new Error(`Mega-Debrid API: ${errorText}`);
      }
      return null;
    }

    const directUrl = String(payload.debridLink || "").trim();
    if (!directUrl) {
      traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "error", detail: "kein debridLink" });
      return null;
    }
    traceConversionPhase({ phase: "api-getlink", provider: "megadebrid-api", workMs: Date.now() - getLinkStartedAt, outcome: "ok" });
    const fileName = String(payload.filename || "").trim() || filenameFromUrl(directUrl) || filenameFromUrl(link);
    return {
      directUrl,
      fileName,
      fileSize: null,
      retriesUsed: 0,
      sourceLabel: "API"
    };
  }

  private async unrestrictViaWeb(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    if (!this.megaWebUnrestrict) {
      throw new Error("Mega-Web-Fallback nicht verfügbar");
    }
    let lastError = "";
    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      if (signal?.aborted) {
        throw new Error("aborted:debrid");
      }
      const web = await this.megaWebUnrestrict(link, signal, { login: this.login, password: this.password }).catch((error) => {
        lastError = compactErrorText(error);
        return null;
      });
      if (signal?.aborted) {
        if (/queue.?timeout/i.test(lastError)) {
          throw new Error(lastError.replace(/^Error:\s*/i, ""));
        }
        throw new Error("aborted:debrid");
      }
      if (web?.directUrl) {
        web.retriesUsed = attempt - 1;
        web.sourceLabel = "Web";
        return web;
      }
      if (web && !web.directUrl) {
        throw new Error("Mega-Web Antwort ohne Download-Link");
      }
      if (!lastError) {
        lastError = "Mega-Web Antwort leer";
      }
      if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) {
        break;
      }
      if (attempt < REQUEST_RETRIES) {
        await sleepWithSignal(retryDelay(attempt), signal);
      }
    }
    throw new Error(String(lastError || "Mega-Web Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
  }

  public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    if (this.mode === "api" && this.login.trim() && this.password.trim()) {
      try {
        const apiResult = await this.unrestrictViaApi(link, signal);
        if (apiResult) {
          logger.info(`Mega-Debrid (API) unrestrict OK: ${apiResult.fileName}`);
          return apiResult;
        }
        throw new Error("Mega-Debrid API: Linkgenerierung lieferte kein Ergebnis");
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          throw error;
        }
        if (!this.allowApiFallback) {
          throw error;
        }
        logger.warn(`Mega-Debrid API fehlgeschlagen, versuche Web-Fallback: ${errorText}`);
      }
      return this.unrestrictViaWeb(link, signal);
    }

    return this.unrestrictViaWeb(link, signal);
  }

  public static async unrestrictWithAccounts(
    settings: AppSettings,
    mode: "api" | "web",
    allowApiFallback: boolean,
    link: string,
    megaWebUnrestrict: MegaWebUnrestrictor | undefined,
    signal?: AbortSignal
  ): Promise<UnrestrictedLink> {
    const accounts = getMegaDebridAccountList(settings);
    if (accounts.length === 0) {
      throw new Error("Mega-Debrid: Kein Account konfiguriert");
    }

    if (getAvailableMegaDebridAccounts(settings).length === 0) {
      throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar (deaktiviert oder am Tageslimit)");
    }

    const failures: string[] = [];
    let usableAccountSeen = false;
    const cooldownFailures: string[] = [];
    let earliestCooldownUntil = 0;
    let parkedUntilRestartSeen = false;
    const totalAccounts = accounts.length;
    const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
    const linkShort = String(link || "").slice(0, 80);

    // Klebrige Rotation: gestartet wird beim Cursor (zuletzt erfolgreich genutzter,
    // also warmer Account), danach der Reihe nach als Failover. Alle Skip-/Cooldown-
    // Checks bleiben. Der Cursor wird erst im Erfolgszweig weitergesetzt — nach einem
    // Schwung erfolgreicher Umwandlungen (MEGA_DEBRID_STICKY_LINKS) — sodass aufeinander
    // folgende Links auf demselben warmen Account laufen statt jeweils neu einzuloggen.
    const startOffset = ((megaDebridRotationCursor % accounts.length) + accounts.length) % accounts.length;
    const cursorOrder: { account: MegaDebridAccountEntry; idx: number }[] = [];
    for (let step = 0; step < accounts.length; step += 1) {
      const idx = (startOffset + step) % accounts.length;
      cursorOrder.push({ account: accounts[idx], idx });
    }
    // Parallel ueber mehrere Accounts: nach AKTUELLER Auslastung (in-flight-Tiefe) sortieren —
    // am wenigsten belegter Account zuerst, cursorOrder als stabiler Gleichstand-Tiebreak. So
    // verteilen sich auch MEHR gleichzeitige Aufloesungen als Accounts gleichmaessig (statt sich
    // alle hinter dem Cursor-Account zu stauen). Nichts in-flight (sequenziell) => alle Tiefe 0 =>
    // Reihenfolge == cursorOrder => bleibt klebrig beim warmen Account.
    const inFlightDepth = (entry: { account: MegaDebridAccountEntry }): number =>
      megaDebridInFlight.get(`${entry.account.id}:${mode}`) ?? 0;
    const orderedEntries = cursorOrder
      .map((entry, position) => ({ entry, position }))
      .sort((a, b) => (inFlightDepth(a.entry) - inFlightDepth(b.entry)) || (a.position - b.position))
      .map((wrapped) => wrapped.entry);

    for (let orderPos = 0; orderPos < orderedEntries.length; orderPos += 1) {
      const entry = orderedEntries[orderPos];
      const account = entry.account;
      const idx = entry.idx;
      const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
      const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;

      if (isMegaDebridAccountDisabled(settings, account.id)) {
        logger.info(`Mega-Debrid${accountLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Account`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
        continue;
      }
      if (isMegaDebridAccountDailyLimitReached(settings, account.id)) {
        logger.info(`Mega-Debrid${accountLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Account`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
        continue;
      }
      const cooldownKey = `${account.id}:${mode}`;
      const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
      if (accountCooldownState) {
        const untilStr = accountCooldownState.untilRestart
          ? "Neustart"
          : new Date(accountCooldownState.until).toLocaleTimeString();
        const reasonText = accountCooldownState.untilRestart
          ? "Tageslimit erreicht — bis zum Tagesreset gesperrt"
          : `Cooldown bis ${untilStr}`;
        logger.info(`Mega-Debrid${accountLabel}: uebersprungen (${reasonText}), pruefe naechsten Account`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
          reason: accountCooldownState.message,
          category: accountCooldownState.category,
          until: untilStr
        });
        cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
        if (accountCooldownState.untilRestart) {
          parkedUntilRestartSeen = true;
        } else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
          earliestCooldownUntil = accountCooldownState.until;
        }
        continue;
      }

      logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
      logAccountRotation("INFO", providerName, rotationLabel, "TEST", {
        link: linkShort
      });
      const testStartedAt = Date.now();

      usableAccountSeen = true;
      megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
      try {
        const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
        const result = await client.unrestrictLink(link, signal);
        clearMegaDebridAccountCooldownState(cooldownKey);
        clearMegaDebridEmptyResponseStreak(cooldownKey);
        const elapsedMs = Date.now() - testStartedAt;
        traceConversionPhase({ phase: "mega-account", provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web", account: rotationLabel, workMs: elapsedMs, outcome: "ok" });
        megaDebridStickyCount += 1;
        if (megaDebridStickyCount >= MEGA_DEBRID_STICKY_LINKS) {
          megaDebridRotationCursor = idx + 1;
          megaDebridStickyCount = 0;
        } else {
          megaDebridRotationCursor = idx;
        }
        logger.info(`Mega-Debrid${accountLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
        logAccountRotation("INFO", providerName, rotationLabel, "OK", {
          elapsedMs,
          fileName: result.fileName || "",
          link: linkShort
        });
        return {
          ...result,
          sourceLabel: `${result.sourceLabel ? `${result.sourceLabel} ` : ""}${account.label}`,
          sourceAccountId: account.id,
          sourceAccountLabel: account.label
        };
      } catch (error) {
        const elapsedMs = Date.now() - testStartedAt;
        const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
        // Timeout/abort on THIS account (the shared unrestrict timeout fired). The
        // account-wide cooldown exists ONLY to make the retry rotate to another
        // account — so it is set only when another usable account actually exists.
        // With no rotation target (single account / all others busy), cooling the
        // sole account would freeze EVERY queued item while the account is healthy;
        // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
        // we park just this link (mega_debrid_slow_link) and leave the account free
        // for other items. A quick user-cancel (below the min run) parks nothing.
        if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
          const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
          const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
            if (candidate.account.id === account.id) {
              return count;
            }
            if (isMegaDebridAccountDisabled(settings, candidate.account.id)) {
              return count;
            }
            if (isMegaDebridAccountDailyLimitReached(settings, candidate.account.id)) {
              return count;
            }
            if (getMegaDebridAccountCooldownState(`${candidate.account.id}:${mode}`)) {
              return count;
            }
            return count + 1;
          }, 0);
          const rotateToAnotherAccount = ranLongEnough && otherUsableAccounts > 0;
          if (rotateToAnotherAccount) {
            setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
          }
          traceConversionPhase({
            phase: "mega-account",
            provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
            account: rotationLabel,
            workMs: elapsedMs,
            outcome: "aborted",
            detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
          });
          failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
          logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
            elapsedMs,
            reason: abortText,
            cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
            next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
          });
          if (ranLongEnough && !rotateToAnotherAccount) {
            throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
          }
          throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
        }
        const failure = MegaDebridClient.classifyAccountFailure(error);
        traceConversionPhase({
          phase: "mega-account",
          provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
          account: rotationLabel,
          workMs: Date.now() - testStartedAt,
          outcome: failure.fatal ? "fatal" : "failed",
          detail: `${failure.message}${failure.cooldownMs > 0 ? ` cd=${Math.ceil(failure.cooldownMs / 1000)}s` : ""}`
        });
        failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);

        let parkUntilRestart = false;
        let parkMessage = failure.message;
        if (failure.limitSignal) {
          const streak = recordMegaDebridEmptyResponseStreak(cooldownKey);
          if (streak >= MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART) {
            parkUntilRestart = true;
            parkMessage = `Tageslimit erreicht (${streak}x leere Antwort in Folge) — bis zum Tagesreset gesperrt`;
          }
        } else {
          clearMegaDebridEmptyResponseStreak(cooldownKey);
        }

        if (parkUntilRestart) {
          setMegaDebridAccountCooldownState(cooldownKey, 0, parkMessage, "quota", true);
        } else if (failure.cooldownMs > 0) {
          setMegaDebridAccountCooldownState(cooldownKey, failure.cooldownMs, failure.message, failure.category);
        } else {
          clearMegaDebridAccountCooldownState(cooldownKey);
        }
        if (failure.fatal) {
          logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", {
            elapsedMs,
            reason: failure.message,
            category: failure.category,
            link: linkShort
          });
          throw new Error(`Mega-Debrid${accountLabel}: ${failure.message}`);
        }
        const cooldownInfo = parkUntilRestart
          ? ", bis zum Tagesreset gesperrt"
          : failure.cooldownMs > 0
            ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
            : "";
        let nextLabel = "ENDE";
        for (let nextPos = orderPos + 1; nextPos < orderedEntries.length; nextPos += 1) {
          const nextAcc = orderedEntries[nextPos].account;
          if (!isMegaDebridAccountDisabled(settings, nextAcc.id) && !isMegaDebridAccountDailyLimitReached(settings, nextAcc.id) && !getMegaDebridAccountCooldownState(`${nextAcc.id}:${mode}`)) {
            nextLabel = `${nextAcc.label}/${totalAccounts} (${nextAcc.maskedLogin})`;
            break;
          }
        }
        logger.warn(`Mega-Debrid${accountLabel}: ${parkUntilRestart ? parkMessage : failure.message}${cooldownInfo}, pruefe naechsten Account (${nextLabel})`);
        logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
          reason: parkUntilRestart ? parkMessage : failure.message,
          elapsedMs,
          category: parkUntilRestart ? "quota" : failure.category,
          cooldownSec: parkUntilRestart || failure.cooldownMs <= 0 ? 0 : Math.ceil(failure.cooldownMs / 1000),
          next: nextLabel,
          link: linkShort
        });
      } finally {
        const remainingInFlight = (megaDebridInFlight.get(cooldownKey) ?? 1) - 1;
        if (remainingInFlight <= 0) {
          megaDebridInFlight.delete(cooldownKey);
        } else {
          megaDebridInFlight.set(cooldownKey, remainingInFlight);
        }
      }
    }

    if (!usableAccountSeen) {
      if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) {
        const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000);
        throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
      }
      if (parkedUntilRestartSeen) {
        const resetParkMs = Math.max(1000, megaDebridDailyParkExpiry(Date.now()) - Date.now() + 1000);
        throw new Error(`mega_debrid_reset_park:${resetParkMs}:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
      }
      throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
    }
    throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
  }

  static classifyAccountFailure(
    error: unknown
  ): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
    const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");

    if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
      return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
    }

    if (/token.?error|please log.?in/i.test(errorText)) {
      return { fatal: false, cooldownMs: 15_000, message: errorText, category: "temporary" };
    }

    if (/bad.?login|incorrect.?(login|password)|invalid.?(login|password|credentials)|wrong.?password|unauthorized|forbidden|connectUser/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS,
        message: `ungueltiger Account (${errorText})`,
        category: "invalid"
      };
    }

    if (isMegaDebridResolveFailure(errorText)) {
      return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
    }

    if (/lieferte kein ergebnis|linkgenerierung[^.]*kein ergebnis/i.test(errorText)) {
      return { fatal: false, cooldownMs: 0, message: germanMegaDebridResolveReason(errorText), category: "temporary" };
    }

    if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
      return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
    }

    if (/rate.?limit|too.?many|429/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
        message: `Rate-Limit (${errorText})`,
        category: "rate_limit"
      };
    }

    if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
        message: `Quota/Limit erreicht (${errorText})`,
        category: "quota"
      };
    }

    if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: MEGA_DEBRID_ACCOUNT_COOLDOWN_MS,
        message: "Kein Server fuer diesen Hoster (Tageslimit/Hoster nicht verfuegbar)",
        category: "quota"
      };
    }

    if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: 20_000,
        message: errorText || "Mega-Web transient empty response",
        category: "temporary",
        limitSignal: true
      };
    }

    if (/queue.?timeout/i.test(errorText)) {
      // Lokaler Stau (die Queue dieses Accounts war zu lange belegt) — KEIN Signal fuer einen
      // ungesunden Account. Kein Cooldown, sonst wuerde der warme Account fuer Eigen-Stau bestraft;
      // der Link wird einfach erneut versucht und rotiert dann natuerlich weiter.
      return { fatal: false, cooldownMs: 0, message: errorText, category: "temporary" };
    }

    if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: 30_000,
        message: errorText || "temporaerer Fehler",
        category: "temporary"
      };
    }

    return {
      fatal: false,
      cooldownMs: 30_000,
      message: errorText || "unbekannter Fehler",
      category: "temporary"
    };
  }
}

class BestDebridClient {
  private token: string;

  public constructor(token: string) {
    this.token = token;
  }

  public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    const requests = buildBestDebridRequests(link, this.token);
    let lastError = "";

    for (const request of requests) {
      try {
        return await this.tryRequest(request, link, signal);
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          throw error;
        }
        lastError = errorText;
      }
    }

    throw new Error(lastError || "BestDebrid Unrestrict fehlgeschlagen");
  }

  private async tryRequest(request: BestDebridRequest, originalLink: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    let lastError = "";
    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      try {
        const headers: Record<string, string> = {
          "User-Agent": DEBRID_USER_AGENT
        };
        if (request.useAuthHeader) {
          headers.Authorization = `Bearer ${this.token}`;
        }

        const response = await fetch(request.url, {
          method: "GET",
          headers,
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });
        const text = await response.text();
        const parsed = parseJson(text);
        const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed);

        if (!response.ok) {
          const reason = parseError(response.status, text, payload);
          if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
            await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
            continue;
          }
          throw new Error(reason);
        }

        const directUrl = pickString(payload, ["download", "debridLink", "link"]);
        if (directUrl) {
          let parsedDirect: URL;
          try {
            parsedDirect = new URL(directUrl);
          } catch {
            throw new Error("BestDebrid Antwort enthält keine gültige Download-URL");
          }
          if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
            throw new Error(`BestDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
          }
          const fileName = pickString(payload, ["filename", "fileName"]) || filenameFromUrl(originalLink);
          const fileSize = pickNumber(payload, ["filesize", "size", "bytes"]);
          return {
            fileName,
            directUrl,
            fileSize,
            retriesUsed: attempt - 1
          };
        }

        const message = pickString(payload, ["response_text", "message", "error"]);
        if (message) {
          throw new Error(message);
        }

        throw new Error("BestDebrid Antwort ohne Download-Link");
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          break;
        }
        if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
          break;
        }
        await sleepWithSignal(retryDelay(attempt), signal);
      }
    }
    throw new Error(String(lastError || "BestDebrid Request fehlgeschlagen").replace(/^Error:\s*/i, ""));
  }
}

class AllDebridClient {
  private token: string;

  public constructor(token: string) {
    this.token = token;
  }

  public async getLinkInfos(links: string[], signal?: AbortSignal): Promise<Map<string, string>> {
    const result = new Map<string, string>();
    const canonicalToInput = new Map<string, string>();
    const uniqueLinks: string[] = [];

    for (const link of links) {
      const trimmed = link.trim();
      if (!trimmed) {
        continue;
      }
      const canonical = canonicalLink(trimmed);
      if (canonicalToInput.has(canonical)) {
        continue;
      }
      canonicalToInput.set(canonical, trimmed);
      uniqueLinks.push(trimmed);
    }

    for (let index = 0; index < uniqueLinks.length; index += 32) {
      if (signal?.aborted) {
        throw new Error("aborted:debrid");
      }
      const chunk = uniqueLinks.slice(index, index + 32);
      const body = new URLSearchParams();
      for (const link of chunk) {
        body.append("link[]", link);
      }

      let payload: Record<string, unknown> | null = null;
      let chunkResolved = false;
      for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
        let response: Response;
        let text = "";
        try {
          response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, {
            method: "POST",
            headers: {
              Authorization: `Bearer ${this.token}`,
              "Content-Type": "application/x-www-form-urlencoded",
              "User-Agent": DEBRID_USER_AGENT
            },
            body,
            signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
          });

          text = await response.text();
          payload = asRecord(parseJson(text));
          if (!response.ok) {
            const reason = parseError(response.status, text, payload);
            if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
              await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
              continue;
            }
            throw new Error(reason);
          }

          const contentType = String(response.headers.get("content-type") || "").toLowerCase();
          const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
          if (looksHtml) {
            throw new Error("AllDebrid lieferte HTML statt JSON");
          }
          if (!payload) {
            throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
          }

          const status = pickString(payload, ["status"]);
          if (status && status.toLowerCase() === "error") {
            throw new Error(parseAllDebridError(payload));
          }

          chunkResolved = true;
          break;
        } catch (error) {
          const errorText = compactErrorText(error);
          if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
            throw error;
          }
          if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
            throw error;
          }
          await sleepWithSignal(retryDelay(attempt), signal);
        }
      }

      if (!chunkResolved || !payload) {
        throw new Error("AllDebrid Link-Infos konnten nicht geladen werden");
      }

      const data = asRecord(payload?.data);
      const infos = Array.isArray(data?.infos) ? data.infos : [];
      const hasAnyLinkedInfo = infos.some((entry) => {
        const info = asRecord(entry);
        return Boolean(pickString(info, ["link"]));
      });
      const allowPositionalFallback = infos.length === chunk.length && !hasAnyLinkedInfo;
      for (let i = 0; i < infos.length; i += 1) {
        const info = asRecord(infos[i]);
        if (!info) {
          continue;
        }
        const fileName = pickString(info, ["filename", "fileName"]);
        if (!fileName) {
          continue;
        }

        const responseLink = pickString(info, ["link"]);
        const byResponse = canonicalToInput.get(canonicalLink(responseLink));
        const byIndex = chunk.length === 1
          ? chunk[0]
          : allowPositionalFallback
            ? chunk[i]
            : "";
        const original = byResponse || byIndex;
        if (!original) {
          continue;
        }
        result.set(original, fileName);
      }
    }

    return result;
  }

  public async getHostInfo(host: string, signal?: AbortSignal): Promise<AllDebridHostInfo> {
    const wanted = normalizeAllDebridHostKey(host);
    let lastError = "";

    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      try {
        const response = await fetch(`${ALL_DEBRID_API_BASE_V41}/user/hosts`, {
          method: "GET",
          headers: {
            Authorization: `Bearer ${this.token}`,
            "User-Agent": DEBRID_USER_AGENT
          },
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });
        const text = await response.text();
        const payload = asRecord(parseJson(text));

        if (!response.ok) {
          const reason = parseError(response.status, text, payload);
          if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
            await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
            continue;
          }
          throw new Error(reason);
        }

        const contentType = String(response.headers.get("content-type") || "").toLowerCase();
        const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
        if (looksHtml) {
          throw new Error("AllDebrid lieferte HTML statt JSON");
        }
        if (!payload) {
          throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
        }

        const status = pickString(payload, ["status"]);
        if (status && status.toLowerCase() === "error") {
          throw new Error(parseAllDebridError(payload));
        }

        const data = asRecord(payload.data);
        const hosts = asRecord(data?.hosts);
        if (!hosts) {
          throw new Error("AllDebrid Antwort ohne Host-Liste");
        }

        let hostEntry = asRecord(hosts[host]) || asRecord(hosts[wanted]);
        if (!hostEntry) {
          for (const entry of Object.values(hosts)) {
            const candidate = asRecord(entry);
            const candidateName = normalizeAllDebridHostKey(pickString(candidate, ["name"]));
            if (candidateName === wanted) {
              hostEntry = candidate;
              break;
            }
          }
        }

        if (!hostEntry) {
          throw new Error(`AllDebrid Host ${host} nicht gefunden`);
        }

        const state = toAllDebridHostState(hostEntry.status);
        const quota = pickNumber(hostEntry, ["quota"]);
        const quotaMax = pickNumber(hostEntry, ["quotaMax"]);
        const limitSimuDl = pickNumber(hostEntry, ["limitSimuDl"]);
        const quotaType = pickString(hostEntry, ["quotaType"]);
        const note = quota === null && quotaMax === null && limitSimuDl === null
          ? "AllDebrid liefert für diesen Host aktuell keine Quota- oder Slot-Daten."
          : "";

        return {
          host: pickString(hostEntry, ["name"]) || host,
          source: "api",
          state,
          statusLabel: toAllDebridHostStatusLabel(state),
          fetchedAt: Date.now(),
          lastCheckedAt: null,
          quota,
          quotaMax,
          quotaType,
          limitSimuDl,
          note
        };
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          break;
        }
        if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
          break;
        }
        await sleepWithSignal(retryDelay(attempt), signal);
      }
    }

    throw new Error(String(lastError || "AllDebrid Host-Info fehlgeschlagen").replace(/^Error:\s*/i, ""));
  }

  public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    let lastError = "";
    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      try {
        const response = await fetch(`${ALL_DEBRID_API_BASE}/link/unlock`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${this.token}`,
            "Content-Type": "application/x-www-form-urlencoded",
            "User-Agent": DEBRID_USER_AGENT
          },
          body: new URLSearchParams({ link }),
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });
        const text = await response.text();
        const payload = asRecord(parseJson(text));

        if (!response.ok) {
          const reason = parseError(response.status, text, payload);
          if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
            await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
            continue;
          }
          throw new Error(reason);
        }

        const contentType = String(response.headers.get("content-type") || "").toLowerCase();
        const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
        if (looksHtml) {
          throw new Error("AllDebrid lieferte HTML statt JSON");
        }
        if (!payload) {
          throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
        }

        const status = pickString(payload, ["status"]);
        if (status && status.toLowerCase() === "error") {
          throw new Error(parseAllDebridError(payload));
        }

        const data = asRecord(payload?.data);
        const directUrl = pickString(data, ["link"]);
        if (!directUrl) {
          throw new Error("AllDebrid Antwort ohne Download-Link");
        }
        let parsedDirect: URL;
        try {
          parsedDirect = new URL(directUrl);
        } catch {
          throw new Error("AllDebrid Antwort enthält keine gültige Download-URL");
        }
        if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
          throw new Error(`AllDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
        }

        return {
          fileName: pickString(data, ["filename"]) || filenameFromUrl(link),
          directUrl,
          fileSize: pickNumber(data, ["filesize"]),
          retriesUsed: attempt - 1
        };
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          break;
        }
        if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
          break;
        }
        await sleepWithSignal(retryDelay(attempt), signal);
      }
    }

    throw new Error(String(lastError || "AllDebrid Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
  }
}

export async function fetchAllDebridHostInfo(token: string, host = "rapidgator", signal?: AbortSignal): Promise<AllDebridHostInfo> {
  return new AllDebridClient(token).getHostInfo(host, signal);
}

export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapidgator", signal?: AbortSignal): Promise<DebridLinkHostLimitInfo[]> {
  const apiKeys = parseDebridLinkApiKeys(apiKeysRaw);
  if (apiKeys.length === 0) {
    throw new Error("Debrid-Link ist nicht konfiguriert");
  }

  const publicHostInfo = await fetchDebridLinkPublicHostInfo(host, signal);
  const results: DebridLinkHostLimitInfo[] = [];
  for (const apiKey of apiKeys) {
    results.push(await fetchDebridLinkHostLimitForKeyDetailed(apiKey, host, publicHostInfo, signal));
  }
  return results;
}

class DebridLinkClient {
  private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;

  public constructor(apiKeysRaw: string) {
    this.apiKeys = parseDebridLinkApiKeys(apiKeysRaw);
  }

  public async unrestrictLink(link: string, settings: AppSettings, signal?: AbortSignal): Promise<UnrestrictedLink> {
    if (this.apiKeys.length === 0) {
      throw new Error("Debrid-Link: Kein API-Key konfiguriert");
    }

    if (getAvailableDebridLinkApiKeys(settings).length === 0) {
      throw new Error("Debrid-Link: Kein aktiver API-Key verfuegbar (deaktiviert oder am Tageslimit)");
    }

    const failures: string[] = [];
    let usableKeySeen = false;
    const cooldownFailures: string[] = [];
    let earliestCooldownUntil = 0;
    const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
    let consecutiveTransportFailures = 0;
    const totalKeys = this.apiKeys.length;
    const providerName = "Debrid-Link";
    const linkShort = String(link || "").slice(0, 80);
    const linkHoster = extractHosterFromUrl(link);

    for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
      const apiKey = this.apiKeys[keyIdx];
      const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
      const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
      if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
        logger.info(`Debrid-Link${keyLabel}: uebersprungen (manuell deaktiviert), pruefe naechsten Key`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DISABLED", { reason: "manually disabled" });
        continue;
      }
      if (isDebridLinkApiKeyDailyLimitReached(settings, apiKey.id)) {
        logger.info(`Debrid-Link${keyLabel}: uebersprungen (lokales Tageslimit erreicht), pruefe naechsten Key`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
        continue;
      }
      const keyCooldownState = getDebridLinkKeyCooldownState(apiKey.id);
      if (keyCooldownState) {
        const untilStr = new Date(keyCooldownState.until).toLocaleTimeString();
        logger.info(`Debrid-Link${keyLabel}: uebersprungen (Cooldown bis ${untilStr}), pruefe naechsten Key`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_COOLDOWN", {
          reason: keyCooldownState.message,
          category: keyCooldownState.category,
          until: untilStr
        });
        cooldownFailures.push(`Debrid-Link${keyLabel}: ${keyCooldownState.message}`);
        if (!earliestCooldownUntil || keyCooldownState.until < earliestCooldownUntil) {
          earliestCooldownUntil = keyCooldownState.until;
        }
        continue;
      }
      const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
      if (hostCooldownState) {
        const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
        logger.info(`Debrid-Link${keyLabel}: uebersprungen (Host-Cooldown ${linkHoster} bis ${untilStr}), pruefe naechsten Key`);
        logAccountRotation("INFO", providerName, rotationLabel, "SKIP_HOST_COOLDOWN", {
          reason: hostCooldownState.message,
          category: hostCooldownState.category,
          host: linkHoster,
          until: untilStr
        });
        cooldownFailures.push(`Debrid-Link${keyLabel}: ${hostCooldownState.message}`);
        if (!earliestCooldownUntil || hostCooldownState.until < earliestCooldownUntil) {
          earliestCooldownUntil = hostCooldownState.until;
        }
        continue;
      }

      logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
      logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
      const testStartedAt = Date.now();

      usableKeySeen = true;
      try {
        const result = await this.unrestrictWithKey(apiKey, link, signal);
        clearDebridLinkKeyCooldownState(apiKey.id);
        setDebridLinkKeyRuntimeStatus(apiKey.id, "ready", "Unrestrict erfolgreich");
        const elapsedMs = Date.now() - testStartedAt;
        logger.info(`Debrid-Link${keyLabel}: Unrestrict OK nach ${elapsedMs}ms -> ${result.fileName || "?"}`);
        logAccountRotation("INFO", providerName, rotationLabel, "OK", {
          elapsedMs,
          fileName: result.fileName || "",
          link: linkShort
        });
        return {
          ...result,
          sourceLabel: apiKey.label,
          sourceAccountId: apiKey.id,
          sourceAccountLabel: apiKey.label
        };
      } catch (error) {
        const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
        const elapsedMs = Date.now() - testStartedAt;
        const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
        if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
          const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
          if (ranLongEnough) {
            setDebridLinkKeyCooldownState(apiKey.id, DEBRID_LINK_KEY_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
          } else {
            clearDebridLinkKeyCooldownState(apiKey.id);
          }
          failures.push(`Debrid-Link${keyLabel}: ${abortText}`);
          logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
            elapsedMs,
            reason: abortText,
            cooldownSec: ranLongEnough ? Math.ceil(DEBRID_LINK_KEY_COOLDOWN_MS / 1000) : 0,
            next: "naechster Key beim Retry"
          });
          throw new Error(`Debrid-Link${keyLabel}: ${abortText}`);
        }
        attemptedKeyFailures.push({
          message: `Debrid-Link${keyLabel}: ${failure.message}`,
          cooldownMs: failure.cooldownMs,
          category: failure.category
        });
        failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
        if (failure.cooldownMs > 0) {
          if (failure.hostOnly) {
            setDebridLinkKeyHostCooldownState(
              apiKey.id,
              failure.hoster || "",
              failure.cooldownMs,
              failure.message,
              failure.category || "quota"
            );
          } else {
            setDebridLinkKeyCooldownState(apiKey.id, failure.cooldownMs, failure.message, failure.category || "temporary");
          }
        } else {
          clearDebridLinkKeyCooldownState(apiKey.id);
          if (failure.category === "invalid") {
            setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
          } else if (failure.category !== "skip") {
            setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message);
          }
        }
        if (failure.fatal) {
          logAccountRotation("ERROR", providerName, rotationLabel, "FATAL", {
            elapsedMs,
            reason: failure.message,
            category: failure.category,
            link: linkShort
          });
          throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
        }
        if (failure.providerWide) {
          const providerWideCooldownMs = 5 * 60 * 1000;
          logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
          logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
            elapsedMs,
            reason: failure.message,
            category: failure.category,
            cooldownSec: Math.ceil(providerWideCooldownMs / 1000),
            link: linkShort
          });
          throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
        }
        const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
        consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
        if (consecutiveTransportFailures >= 2) {
          const cascadeCooldownMs = 3 * 60 * 1000;
          logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
          logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
            elapsedMs,
            consecutive: consecutiveTransportFailures,
            cooldownSec: Math.ceil(cascadeCooldownMs / 1000),
            link: linkShort
          });
          throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
        }
        let nextLabel = "ENDE";
        for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
          const nextKey = this.apiKeys[nextIdx];
          if (!isDebridLinkApiKeyDisabled(settings, nextKey.id) && !isDebridLinkApiKeyDailyLimitReached(settings, nextKey.id) && !getDebridLinkKeyCooldownState(nextKey.id)) {
            nextLabel = `${nextKey.label}/${totalKeys} (${nextKey.masked})`;
            break;
          }
        }
        const cooldownInfo = failure.cooldownMs > 0
          ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
          : "";
        logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key (${nextLabel})`);
        logAccountRotation("WARN", providerName, rotationLabel, "FAILED", {
          elapsedMs,
          reason: failure.message,
          category: failure.category,
          cooldownSec: failure.cooldownMs > 0 ? Math.ceil(failure.cooldownMs / 1000) : 0,
          next: nextLabel,
          link: linkShort
        });
      }
    }

    if (!usableKeySeen) {
      if (cooldownFailures.length > 0 && earliestCooldownUntil > Date.now()) {
        const retryMs = Math.max(1000, earliestCooldownUntil - Date.now() + 1000);
        throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
      }
      throw new Error("debrid_link_no_active_key:Debrid-Link: Kein aktiver API-Key verfuegbar");
    }

    if (attemptedKeyFailures.length > 0 && attemptedKeyFailures.every((entry) => entry.category === "invalid")) {
      throw new Error(`debrid_link_invalid_all:${attemptedKeyFailures.map((entry) => entry.message).join(" | ")}`);
    }

    const cooldownOnlyFailures = attemptedKeyFailures.filter((entry) => entry.cooldownMs > 0);
    if (attemptedKeyFailures.length > 0 && cooldownOnlyFailures.length === attemptedKeyFailures.length) {
      const retryMs = Math.max(1000, Math.min(...cooldownOnlyFailures.map((entry) => Math.max(1000, entry.cooldownMs))) + 1000);
      throw new Error(`debrid_link_cooldown:${retryMs}:${cooldownOnlyFailures.map((entry) => entry.message).join(" | ")}`);
    }
    throw new Error(failures.join(" | ") || "Debrid-Link: Kein aktiver API-Key verfuegbar");
  }

  private async unrestrictWithKey(
    apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
    link: string,
    signal?: AbortSignal
  ): Promise<UnrestrictedLink> {
    const payload = await this.requestPayload(apiKey, "POST", "/downloader/add", { url: link }, signal);
    const entry = await this.resolveDownloaderEntry(apiKey, payload.value, link, signal);
    const directUrl = pickString(entry, ["downloadUrl"]);
    const expired = Boolean(entry.expired === true);
    if (!directUrl || expired) {
      throw new Error("Debrid-Link: Keine gueltige Download-URL in Antwort");
    }
    return {
      fileName: pickString(entry, ["name"]) || filenameFromUrl(directUrl) || filenameFromUrl(link),
      directUrl,
      fileSize: pickNumber(entry, ["size"]),
      retriesUsed: 0
    };
  }

  private async requestPayload(
    apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
    method: "GET" | "POST" | "DELETE",
    apiPath: string,
    body: Record<string, unknown> | undefined,
    signal?: AbortSignal,
    maxAttempts = REQUEST_RETRIES
  ): Promise<Record<string, unknown>> {
    return requestDebridLinkPayloadWithKey(apiKey, method, apiPath, body, signal, maxAttempts);
  }

  private shouldRetryApiError(error: DebridLinkApiError, attempt: number, maxAttempts: number): boolean {
    if (attempt >= maxAttempts) {
      return false;
    }
    if (error.status === 429 || error.status >= 500) {
      return true;
    }
    return DEBRID_LINK_RETRYABLE_ERRORS.has(error.code);
  }

  private retryDelayForApiError(error: DebridLinkApiError, attempt: number): number {
    if (error.retryAfterMs > 0) {
      return error.retryAfterMs;
    }
    return retryDelay(attempt);
  }

  private async resolveDownloaderEntry(
    apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
    rawValue: unknown,
    originalLink: string,
    signal?: AbortSignal
  ): Promise<Record<string, unknown>> {
    const entries = parseDebridLinkLinkEntries(rawValue);
    if (entries.length === 0) {
      throw new Error("Debrid-Link: Keine Daten in Antwort");
    }

    const matchingEntries = entries.filter((entry) => {
      const url = pickString(entry, ["url"]);
      return url ? canonicalLink(url) === canonicalLink(originalLink) : false;
    });
    const chosen = matchingEntries.length === 1
      ? matchingEntries[0]
      : entries.length === 1
        ? entries[0]
        : null;
    if (!chosen) {
      throw new Error(`Debrid-Link: Link lieferte ${entries.length} Dateien statt einer Einzeldatei`);
    }

    const needsRefresh = !pickString(chosen, ["downloadUrl"]) || chosen.expired === true;
    if (!needsRefresh) {
      return chosen;
    }

    const id = pickString(chosen, ["id"]);
    if (!id) {
      return chosen;
    }

    const maxPolls = 5;
    for (let poll = 0; poll < maxPolls; poll++) {
      if (signal?.aborted) {
        throw new Error("aborted");
      }
      if (poll > 0) {
        await sleepWithSignal(2000, signal);
      }
      const refreshed = await this.fetchDownloaderEntry(apiKey, id, signal);
      if (refreshed) {
        const url = pickString(refreshed, ["downloadUrl"]);
        const expired = refreshed.expired === true;
        if (url && !expired) {
          return refreshed;
        }
      }
    }
    return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen;
  }

  private async fetchDownloaderEntry(
    apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
    id: string,
    signal?: AbortSignal
  ): Promise<Record<string, unknown> | null> {
    const query = new URLSearchParams({ ids: id });
    const payload = await this.requestPayload(apiKey, "GET", `/downloader/list?${query.toString()}`, undefined, signal);
    const entries = parseDebridLinkLinkEntries(payload.value);
    if (entries.length === 0) {
      return null;
    }
    return entries.find((entry) => pickString(entry, ["id"]) === id) || entries[0] || null;
  }

  private async fetchQuotaCooldownMs(
    apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
    signal?: AbortSignal
  ): Promise<number> {
    try {
      const payload = await this.requestPayload(apiKey, "GET", "/downloader/limits", undefined, signal, 1);
      return parseDebridLinkNextResetMs(payload) || DEBRID_LINK_KEY_COOLDOWN_MS;
    } catch {
      return DEBRID_LINK_KEY_COOLDOWN_MS;
    }
  }

  private async classifyKeyFailure(
    error: unknown,
    apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
    link: string,
    signal?: AbortSignal
  ): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean; hostOnly?: boolean; hoster?: string }> {
    const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
    if (error instanceof DebridLinkApiError) {
      const code = String(error.code || "").trim() || `HTTP ${error.status}`;
      const description = error.message || code;

      if (DEBRID_LINK_INVALID_TOKEN_ERRORS.has(code)) {
        return {
          fatal: false,
          cooldownMs: DEBRID_LINK_INVALID_KEY_COOLDOWN_MS,
          message: `ungueltiger oder deaktivierter API-Key (${code}: ${description})`,
          category: "invalid"
        };
      }
      if (DEBRID_LINK_RATE_LIMIT_ERRORS.has(code) || error.status === 429) {
        return {
          fatal: false,
          cooldownMs: error.retryAfterMs || DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS,
          message: `API-Rate-Limit erreicht (${code}: ${description})`,
          category: "rate_limit"
        };
      }
      if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
        const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
        const hosterRaw = extractHosterFromUrl(link);
        const hosterLabel = hosterRaw || "host";
        return {
          fatal: false,
          cooldownMs,
          message: `Quota erreicht fuer ${hosterLabel} (${code}: ${description})`,
          category: "quota",
          hostOnly: true,
          hoster: hosterRaw
        };
      }
      if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
        const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
        return {
          fatal: false,
          cooldownMs,
          message: `Quota erreicht (${code}: ${description})`,
          category: "quota"
        };
      }
      if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
        return {
          fatal: false,
          cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
          message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
          category: "temporary",
          providerWide: true
        };
      }
      if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) {
        return {
          fatal: false,
          cooldownMs: 0,
          message: `Key kann Link aktuell nicht verarbeiten (${code}: ${description})`,
          category: "skip"
        };
      }
      if (DEBRID_LINK_FATAL_LINK_ERRORS.has(code)) {
        return {
          fatal: true,
          cooldownMs: 0,
          message: description,
          category: "temporary"
        };
      }
      if (DEBRID_LINK_RETRYABLE_ERRORS.has(code) || error.status >= 500) {
        return {
          fatal: false,
          cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
          message: `temporärer API-Fehler (${code}: ${description})`
        };
      }
      return {
        fatal: true,
        cooldownMs: 0,
        message: description
      };
    }

    if (/keine gueltige download-url/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
        message: errorText || "Download-URL nicht verfuegbar",
        category: "temporary"
      };
    }

    if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) {
      const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText)
        && !(error instanceof DebridLinkApiError);
      return {
        fatal: false,
        cooldownMs: isTransport ? 15_000 : DEBRID_LINK_KEY_COOLDOWN_MS,
        message: errorText || "temporärer Transportfehler"
      };
    }

    if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) {
      return {
        fatal: false,
        cooldownMs: 30_000,
        message: errorText,
        category: "temporary"
      };
    }

    return {
      fatal: true,
      cooldownMs: 0,
      message: errorText || "Unbekannter Debrid-Link-Fehler"
    };
  }
}

class LinkSnappyClient {
  private username: string;
  private password: string;
  private sessionCookies: string | null = null;

  public constructor(username: string, password: string) {
    this.username = username;
    this.password = password;
  }

  private async authenticate(signal?: AbortSignal): Promise<void> {
    const params = new URLSearchParams({ username: this.username, password: this.password });
    const res = await fetch(`${LINKSNAPPY_API_BASE}/AUTHENTICATE?${params.toString()}`, {
      signal: withTimeoutSignal(signal, API_TIMEOUT_MS),
      redirect: "manual"
    });

    const cookies: string[] = [];
    const setCookie = res.headers.getSetCookie?.() ?? [];
    for (const sc of setCookie) {
      const nameValue = sc.split(";")[0];
      if (nameValue) cookies.push(nameValue);
    }

    const json = await res.json() as Record<string, unknown>;
    if (json.status !== "OK") {
      throw new Error(`LinkSnappy: Login fehlgeschlagen – ${String(json.error || "Unbekannter Fehler")}`);
    }

    if (cookies.length > 0) {
      this.sessionCookies = cookies.join("; ");
    } else {
      this.sessionCookies = `username=${encodeURIComponent(this.username)}; Auth=manual`;
    }

    logger.info("LinkSnappy: Authentifizierung erfolgreich");
  }

  public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    if (!this.username || !this.password) {
      throw new Error("LinkSnappy: Kein Login konfiguriert");
    }

    let lastError = "";
    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      if (signal?.aborted) throw new Error("aborted:debrid");
      try {
        if (!this.sessionCookies) {
          await this.authenticate(signal);
        }

        const genLinks = `{"link":"${encodeURIComponent(link)}","type":"","linkpass":""}`;
        const url = `${LINKSNAPPY_API_BASE}/linkgen?genLinks=${genLinks}`;

        const res = await fetch(url, {
          headers: { Cookie: this.sessionCookies! },
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });

        const json = await res.json() as Record<string, unknown>;

        if (json.status === "ERROR" && json.error) {
          const errorMsg = String(json.error);
          if (/not logged in|session expired|unauthorized/i.test(errorMsg)) {
            this.sessionCookies = null;
            if (attempt < REQUEST_RETRIES) {
              continue;
            }
            throw new Error(`LinkSnappy: ${errorMsg}`);
          }
          throw new Error(`LinkSnappy: ${errorMsg}`);
        }

        const links = json.links as Array<Record<string, unknown>> | undefined;
        if (!links || links.length === 0) {
          throw new Error("LinkSnappy: Keine Antwort-Daten");
        }

        const entry = links[0];
        if (entry.status === "ERROR" || (entry.error && entry.status !== "OK")) {
          const errText = String(entry.error);
          if (/quota|limit/i.test(errText)) {
            throw new Error(`LinkSnappy: Quota erreicht – ${errText}`);
          }
          throw new Error(`LinkSnappy: ${errText}`);
        }

        let directUrl = String(entry.generated || "");
        if (!directUrl) {
          throw new Error("LinkSnappy: Keine Download-URL in Antwort");
        }
        if (directUrl.startsWith("http://")) {
          directUrl = directUrl.replace("http://", "https://");
        }

        const fileName = String(entry.filename || "") || filenameFromUrl(directUrl) || filenameFromUrl(link);
        const rawSize = entry.filesize;
        let fileSize: number | null = null;
        if (typeof rawSize === "number" && rawSize > 0) {
          fileSize = rawSize;
        } else if (typeof rawSize === "string") {
          const parsed = parseFileSizeString(rawSize);
          if (parsed > 0) fileSize = parsed;
        }

        logger.info(`LinkSnappy: Unrestrict OK → ${fileName || "?"}`);

        return {
          fileName,
          directUrl,
          fileSize,
          retriesUsed: attempt - 1,
          sourceLabel: "API"
        };
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          throw error;
        }
        if (/fehlgeschlagen/i.test(lastError) && /Login/i.test(lastError)) {
          throw error;
        }
        if (attempt < REQUEST_RETRIES) {
          await sleepWithSignal(retryDelay(attempt), signal);
        }
      }
    }

    throw new Error(String(lastError || "LinkSnappy Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
  }
}

function parseFileSizeString(s: string): number {
  const match = s.trim().match(/^([\d.]+)\s*([KMGT]?)B?$/i);
  if (!match) return 0;
  const num = parseFloat(match[1]);
  const unit = (match[2] || "").toUpperCase();
  const multipliers: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
  return Math.floor(num * (multipliers[unit] || 1));
}

class OneFichierClient {
  private apiKey: string;

  public constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    if (!ONEFICHIER_URL_RE.test(link)) {
      throw new Error("Kein 1Fichier-Link");
    }

    let lastError = "";
    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      if (signal?.aborted) throw new Error("aborted:debrid");
      try {
        const res = await fetch(`${ONEFICHIER_API_BASE}/download/get_token.cgi`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${this.apiKey}`
          },
          body: JSON.stringify({ url: link, pretty: 1, cdn: 0 }),
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });

        const json = await res.json() as Record<string, unknown>;

        if (json.status === "KO" || json.error) {
          const msg = String(json.message || json.error || "Unbekannter 1Fichier-Fehler");
          throw new Error(msg);
        }

        const directUrl = String(json.url || "");
        if (!directUrl) {
          throw new Error("1Fichier: Keine Download-URL in Antwort");
        }

        return {
          fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
          directUrl,
          fileSize: null,
          retriesUsed: attempt - 1
        };
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          throw error;
        }
        if (attempt < REQUEST_RETRIES) {
          await sleepWithSignal(retryDelay(attempt), signal);
        }
      }
    }
    throw new Error(`1Fichier-Unrestrict fehlgeschlagen: ${lastError}`);
  }
}

const DDOWNLOAD_URL_RE = /^https?:\/\/(?:www\.)?(?:ddownload\.com|ddl\.to)\/([a-z0-9]+)/i;
const DDOWNLOAD_WEB_BASE = "https://ddownload.com";
const DDOWNLOAD_WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";

class DdownloadClient {
  private login: string;
  private password: string;
  private cookies: string = "";

  public constructor(login: string, password: string) {
    this.login = login;
    this.password = password;
  }

  private async webLogin(signal?: AbortSignal): Promise<void> {
    const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, {
      headers: { "User-Agent": DDOWNLOAD_WEB_UA },
      redirect: "manual",
      signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
    });
    const loginPageHtml = await loginPageRes.text();
    const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
    const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");

    const body = new URLSearchParams({
      op: "login",
      token: tokenMatch?.[1] || "",
      rand: "",
      redirect: "",
      login: this.login,
      password: this.password
    });
    const loginRes = await fetch(`${DDOWNLOAD_WEB_BASE}/`, {
      method: "POST",
      headers: {
        "User-Agent": DDOWNLOAD_WEB_UA,
        "Content-Type": "application/x-www-form-urlencoded",
        ...(pageCookies ? { Cookie: pageCookies } : {})
      },
      body: body.toString(),
      redirect: "manual",
      signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
    });

    try { await loginRes.text(); } catch {  }

    const setCookies = loginRes.headers.getSetCookie?.() || [];
    const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
    const loginCookie = setCookies.find((c: string) => c.startsWith("login="));
    if (!xfss) {
      throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)");
    }
    this.cookies = [loginCookie, xfss].filter((c): c is string => Boolean(c)).map((c) => c.split(";")[0]).join("; ");
  }

  public async unrestrictLink(link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    const match = link.match(DDOWNLOAD_URL_RE);
    if (!match) {
      throw new Error("Kein DDownload-Link");
    }
    const fileCode = match[1];
    let lastError = "";

    for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
      try {
        if (signal?.aborted) throw new Error("aborted:debrid");

        if (!this.cookies) {
          await this.webLogin(signal);
        }

        const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
          headers: {
            "User-Agent": DDOWNLOAD_WEB_UA,
            Cookie: this.cookies
          },
          redirect: "manual",
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });

        if (filePageRes.status >= 300 && filePageRes.status < 400) {
          const directUrl = filePageRes.headers.get("location") || "";
          try { await filePageRes.text(); } catch {  }
          if (directUrl) {
            return {
              fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
              directUrl,
              fileSize: null,
              retriesUsed: attempt - 1,
              skipTlsVerify: true
            };
          }
        }

        const html = await filePageRes.text();

        if (/File Not Found|file was removed|file was banned/i.test(html)) {
          throw new Error("DDownload: Datei nicht gefunden");
        }

        const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
        const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
        const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
        const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);

        const dlBody = new URLSearchParams({
          op: "download2",
          id: idVal,
          rand: randVal,
          referer: "",
          method_premium: "1",
          adblock_detected: "0"
        });

        const dlRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
          method: "POST",
          headers: {
            "User-Agent": DDOWNLOAD_WEB_UA,
            "Content-Type": "application/x-www-form-urlencoded",
            Cookie: this.cookies,
            Referer: `${DDOWNLOAD_WEB_BASE}/${fileCode}`
          },
          body: dlBody.toString(),
          redirect: "manual",
          signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
        });

        if (dlRes.status >= 300 && dlRes.status < 400) {
          const directUrl = dlRes.headers.get("location") || "";
          try { await dlRes.text(); } catch {  }
          if (directUrl) {
            return {
              fileName: fileName || filenameFromUrl(directUrl),
              directUrl,
              fileSize: null,
              retriesUsed: attempt - 1,
              skipTlsVerify: true
            };
          }
        }

        const dlHtml = await dlRes.text();
        const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i);
        if (directMatch) {
          return {
            fileName,
            directUrl: directMatch[0],
            fileSize: null,
            retriesUsed: attempt - 1,
            skipTlsVerify: true
          };
        }

        const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
        if (errMatch) {
          throw new Error(`DDownload: ${errMatch[1].trim()}`);
        }

        throw new Error("DDownload: Kein Download-Link erhalten");
      } catch (error) {
        lastError = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
          break;
        }
        if (/login|session|cookie/i.test(lastError)) {
          this.cookies = "";
        }
        if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
          break;
        }
        await sleepWithSignal(retryDelay(attempt), signal);
      }
    }

    throw new Error(String(lastError || "DDownload Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
  }
}

export class DebridService {
  private settings: AppSettings;

  private options: DebridServiceOptions;

  private cachedDdownloadClient: DdownloadClient | null = null;
  private cachedDdownloadKey = "";
  private cachedDebridLinkClient: DebridLinkClient | null = null;
  private cachedDebridLinkKey = "";
  private cachedLinkSnappyClient: LinkSnappyClient | null = null;
  private cachedLinkSnappyKey = "";

  public constructor(settings: AppSettings, options: DebridServiceOptions = {}) {
    this.settings = cloneSettings(settings);
    this.options = options;
  }

  public setSettings(next: AppSettings): void {
    const prev = this.settings;
    this.settings = cloneSettings(next);

    if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
      this.cachedDebridLinkClient = null;
      this.cachedDebridLinkKey = "";
    }
    if (prev.linkSnappyLogin !== next.linkSnappyLogin || prev.linkSnappyPassword !== next.linkSnappyPassword) {
      this.cachedLinkSnappyClient = null;
      this.cachedLinkSnappyKey = "";
    }
    if (prev.ddownloadLogin !== next.ddownloadLogin || prev.ddownloadPassword !== next.ddownloadPassword) {
      this.cachedDdownloadClient = null;
      this.cachedDdownloadKey = "";
    }

    const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
    const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
    const nextLogins = new Set<string>();
    const nextPasswordByLogin = new Map<string, string>();
    for (const acc of nextAccounts) {
      nextLogins.add(acc.login.toLowerCase());
      nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
    }
    MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
    for (const prevAcc of prevAccounts) {
      const loginKey = prevAcc.login.toLowerCase();
      if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
        MegaDebridClient.clearCachedApiToken(prevAcc.login);
      }
    }
    const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
    pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
  }

  private getDebridLinkClient(apiKeysRaw: string): DebridLinkClient {
    if (this.cachedDebridLinkClient && this.cachedDebridLinkKey === apiKeysRaw) {
      return this.cachedDebridLinkClient;
    }
    this.cachedDebridLinkClient = new DebridLinkClient(apiKeysRaw);
    this.cachedDebridLinkKey = apiKeysRaw;
    return this.cachedDebridLinkClient;
  }

  private getLinkSnappyClient(login: string, password: string): LinkSnappyClient {
    const key = `${login}\0${password}`;
    if (this.cachedLinkSnappyClient && this.cachedLinkSnappyKey === key) {
      return this.cachedLinkSnappyClient;
    }
    this.cachedLinkSnappyClient = new LinkSnappyClient(login, password);
    this.cachedLinkSnappyKey = key;
    return this.cachedLinkSnappyClient;
  }

  private getDdownloadClient(login: string, password: string): DdownloadClient {
    const key = `${login}\0${password}`;
    if (this.cachedDdownloadClient && this.cachedDdownloadKey === key) {
      return this.cachedDdownloadClient;
    }
    this.cachedDdownloadClient = new DdownloadClient(login, password);
    this.cachedDdownloadKey = key;
    return this.cachedDdownloadClient;
  }

  public async resolveFilenames(
    links: string[],
    onResolved?: (link: string, fileName: string) => void,
    signal?: AbortSignal
  ): Promise<Map<string, string>> {
    const settings = cloneSettings(this.settings);
    const allDebridClient = new AllDebridClient(settings.allDebridToken);
    const unresolved = links.filter((link) => looksLikeOpaqueFilename(filenameFromUrl(link)));
    if (unresolved.length === 0) {
      return new Map<string, string>();
    }

    const clean = new Map<string, string>();
    const reportResolved = (link: string, fileName: string): void => {
      const normalized = fileName.trim();
      if (!normalized || looksLikeOpaqueFilename(normalized) || normalized.toLowerCase() === "download.bin") {
        return;
      }
      if (clean.get(link) === normalized) {
        return;
      }
      clean.set(link, normalized);
      onResolved?.(link, normalized);
    };

    const token = settings.allDebridToken.trim();
    if (token) {
      try {
        const infos = await allDebridClient.getLinkInfos(unresolved, signal);
        for (const [link, fileName] of infos.entries()) {
          reportResolved(link, fileName);
        }
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          throw error;
        }
      }
    }

    const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
    if (megaLinks.length > 0) {
      await runWithConcurrency(megaLinks, 4, async (link) => {
        try {
          const info = await resolveMegaFilename(link, signal);
          if (info?.name) {
            reportResolved(link, info.name);
          }
        } catch (error) {
          const errorText = compactErrorText(error);
          if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
            throw error;
          }
        }
      });
    }

    const remaining = unresolved.filter((link) => !clean.has(link) && isRapidgatorLink(link));
    await runWithConcurrency(remaining, 6, async (link) => {
      const fromPage = await resolveRapidgatorFilename(link, signal);
      reportResolved(link, fromPage);
    });

    return clean;
  }

  private shouldUseRealDebridWeb(settings: AppSettings): boolean {
    return Boolean(settings.realDebridUseWebLogin && this.options.realDebridWebUnrestrict);
  }

  private shouldUseAllDebridWeb(settings: AppSettings): boolean {
    return Boolean(settings.allDebridUseWebLogin && this.options.allDebridWebUnrestrict);
  }

  private shouldUseBestDebridWeb(settings: AppSettings): boolean {
    return Boolean(settings.bestDebridUseWebLogin && this.options.bestDebridWebUnrestrict);
  }

  private isProviderDailyLimited(settings: AppSettings, provider: DebridProvider): boolean {
    const effectiveProvider = resolveMegaDebridProvider(settings, provider);
    if (effectiveProvider === "debridlink") {
      const configuredKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
      if (configuredKeys.length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
        return true;
      }
    }
    if (effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") {
      const configuredAccounts = getMegaDebridAccountList(settings);
      if (configuredAccounts.length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
        return true;
      }
    }
    return isProviderDailyLimitReached(settings, effectiveProvider);
  }

  private isProviderSelectableFor(settings: AppSettings, provider: DebridProvider): boolean {
    return this.isProviderConfiguredFor(settings, provider) && !this.isProviderDailyLimited(settings, provider);
  }

  private formatProviderLimitMessage(settings: AppSettings, provider: DebridProvider): string {
    const effectiveProvider = resolveMegaDebridProvider(settings, provider);
    if (effectiveProvider === "debridlink" && parseDebridLinkApiKeys(settings.debridLinkApiKeys).length > 0 && getAvailableDebridLinkApiKeys(settings).length === 0) {
      return "Debrid-Link nicht verfuegbar (alle aktiven API-Keys deaktiviert oder ausgeschopft)";
    }
    if ((effectiveProvider === "megadebrid-api" || effectiveProvider === "megadebrid-web") && getMegaDebridAccountList(settings).length > 0 && getAvailableMegaDebridAccounts(settings).length === 0) {
      return "Mega-Debrid nicht verfuegbar (alle aktiven Accounts deaktiviert oder ausgeschopft)";
    }
    return `${PROVIDER_LABELS[effectiveProvider]} Tageslimit erreicht`;
  }

  public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings, preferredLeadProvider?: DebridProvider | null): Promise<ProviderUnrestrictedLink> {
    const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);

    const routing = settings.hosterRouting || {};
    const hosterKey = extractHosterFromUrl(link);
    if (hosterKey && routing[hosterKey]) {
      const routedProvider = routing[hosterKey];
      if (this.isProviderSelectableFor(settings, routedProvider)) {
        logger.info(`Hoster-Zuordnung: ${hosterKey} → ${PROVIDER_LABELS[routedProvider]}`);
        try {
          const result = await this.unrestrictViaProvider(settings, routedProvider, link, signal);
          let fileName = result.fileName;
          if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
            const fromPage = await resolveRapidgatorFilename(link, signal);
            if (fromPage) fileName = fromPage;
          }
          return {
            ...result,
            fileName,
            provider: routedProvider,
            providerLabel: PROVIDER_LABELS[routedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
          };
        } catch (error) {
          const errorText = compactErrorText(error);
          if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
            throw error;
          }
          if (!settings.autoProviderFallback) {
            throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey} → ${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
          }
          logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
        }
      } else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
        logger.info(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
      } else {
        logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (Provider nicht konfiguriert/deaktiviert)`);
      }
    }

    if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
      try {
        const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
        return {
          ...result,
          provider: "onefichier",
          providerLabel: PROVIDER_LABELS["onefichier"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
        };
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          throw error;
        }
        if (!settings.autoProviderFallback) {
          throw error;
        }
      }
    }

    if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
      try {
        const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
        return {
          ...result,
          provider: "ddownload",
          providerLabel: PROVIDER_LABELS["ddownload"] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
        };
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          throw error;
        }
        if (!settings.autoProviderFallback) {
          throw error;
        }
      }
    }

    const baseOrder: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
      ? uniqueProviderOrder(settings.providerOrder)
      : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
    const order = leadProviderChainWith(baseOrder, preferredLeadProvider);

    const primary = order[0];
    if (!settings.autoProviderFallback) {
      if (!this.isProviderConfiguredFor(settings, primary)) {
        throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`);
      }
      const selectedProvider = this.isProviderDailyLimited(settings, primary)
        ? order.find((provider) => provider !== primary && this.isProviderSelectableFor(settings, provider))
        : primary;
      if (!selectedProvider) {
        throw new Error(this.formatProviderLimitMessage(settings, primary));
      }
      try {
        const result = await this.unrestrictViaProvider(settings, selectedProvider, link, signal);
        let fileName = result.fileName;
        if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
          const fromPage = await resolveRapidgatorFilename(link, signal);
          if (fromPage) {
            fileName = fromPage;
          }
        }
        return {
          ...result,
          fileName,
          provider: selectedProvider,
          providerLabel: PROVIDER_LABELS[selectedProvider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
        };
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          throw error;
        }
        throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[selectedProvider]}: ${errorText}`);
      }
    }

    let configuredFound = false;
    let limitReachedFound = false;
    const attempts: string[] = [];

    for (const provider of order) {
      if (!this.isProviderConfiguredFor(settings, provider)) {
        continue;
      }
      configuredFound = true;
      if (this.isProviderDailyLimited(settings, provider)) {
        limitReachedFound = true;
        logger.info(`Provider-Kette: ${PROVIDER_LABELS[provider]} uebersprungen (${this.formatProviderLimitMessage(settings, provider)})`);
        attempts.push(this.formatProviderLimitMessage(settings, provider));
        continue;
      }

      const providerStartedAt = Date.now();
      try {
        logger.info(`Provider-Kette: versuche ${PROVIDER_LABELS[provider]}`);
        traceConversionPhase({ phase: "chain-try", provider });
        const result = await this.unrestrictViaProvider(settings, provider, link, signal);
        traceConversionPhase({ phase: "chain-ok", provider, workMs: Date.now() - providerStartedAt, outcome: "ok" });
        let fileName = result.fileName;
        if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) {
          const fromPage = await resolveRapidgatorFilename(link, signal);
          if (fromPage) {
            fileName = fromPage;
          }
        }
        return {
          ...result,
          fileName,
          provider,
          providerLabel: PROVIDER_LABELS[provider] + (result.sourceLabel ? ` (${result.sourceLabel})` : "")
        };
      } catch (error) {
        const errorText = compactErrorText(error);
        if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
          traceConversionPhase({ phase: "chain-aborted", provider, workMs: Date.now() - providerStartedAt, outcome: "aborted", detail: errorText.slice(0, 120) });
          throw error;
        }
        const nextProvider = order.slice(order.indexOf(provider) + 1).find((candidate) => this.isProviderSelectableFor(settings, candidate));
        traceConversionPhase({
          phase: "chain-failed",
          provider,
          workMs: Date.now() - providerStartedAt,
          outcome: nextProvider ? "failover" : "exhausted",
          detail: `${errorText.slice(0, 120)}${nextProvider ? ` → ${nextProvider}` : ""}`
        });
        if (nextProvider) {
          logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), Fallback auf ${PROVIDER_LABELS[nextProvider]}`);
        } else {
          logger.warn(`Provider-Kette: ${PROVIDER_LABELS[provider]} fehlgeschlagen (${errorText}), kein weiterer Provider verfuegbar`);
        }
        attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`);
      }
    }

    if (!configuredFound) {
      throw new Error("Kein Debrid-Provider konfiguriert");
    }
    if (limitReachedFound && attempts.every((entry) => /Tageslimit erreicht$/i.test(entry))) {
      throw new Error("Alle konfigurierten Provider haben ihr Tageslimit erreicht");
    }

    throw new Error(`Unrestrict fehlgeschlagen: ${attempts.join(" | ")}`);
  }

  private isProviderConfiguredFor(settings: AppSettings, provider: DebridProvider): boolean {
    const effectiveProvider = resolveMegaDebridProvider(settings, provider);
    if ((settings.disabledProviders || []).includes(provider) || (settings.disabledProviders || []).includes(effectiveProvider)) return false;
    if (effectiveProvider === "realdebrid") {
      return Boolean(this.shouldUseRealDebridWeb(settings) || settings.token.trim());
    }
    if (effectiveProvider === "megadebrid-api") {
      return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "api"));
    }
    if (effectiveProvider === "megadebrid-web") {
      return Boolean(hasMegaDebridCredentials(settings) && isMegaDebridModeEnabled(settings, "web") && this.options.megaWebUnrestrict);
    }
    if (effectiveProvider === "alldebrid") {
      return Boolean(this.shouldUseAllDebridWeb(settings) || settings.allDebridToken.trim());
    }
    if (effectiveProvider === "ddownload") {
      return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim());
    }
    if (effectiveProvider === "onefichier") {
      return Boolean(settings.oneFichierApiKey.trim());
    }
    if (effectiveProvider === "debridlink") {
      return Boolean(settings.debridLinkApiKeys.trim());
    }
    if (effectiveProvider === "linksnappy") {
      return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword.trim());
    }
    return Boolean(this.shouldUseBestDebridWeb(settings) || settings.bestToken.trim());
  }

  private async unrestrictViaProvider(settings: AppSettings, provider: DebridProvider, link: string, signal?: AbortSignal): Promise<UnrestrictedLink> {
    const effectiveProvider = resolveMegaDebridProvider(settings, provider);
    if (effectiveProvider === "realdebrid") {
      if (this.shouldUseRealDebridWeb(settings) && this.options.realDebridWebUnrestrict) {
        const result = await this.options.realDebridWebUnrestrict(link, signal);
        if (!result) {
          throw new Error("Real-Debrid-Web-Fallback nicht verfügbar");
        }
        result.sourceLabel = "Web";
        return result;
      }
      const result = await new RealDebridClient(settings.token).unrestrictLink(link, signal);
      result.sourceLabel = "API";
      return result;
    }
    if (effectiveProvider === "megadebrid-api") {
      return MegaDebridClient.unrestrictWithAccounts(settings, "api", provider === "megadebrid" && settings.megaDebridPreferApi, link, this.options.megaWebUnrestrict, signal);
    }
    if (effectiveProvider === "megadebrid-web") {
      return MegaDebridClient.unrestrictWithAccounts(settings, "web", false, link, this.options.megaWebUnrestrict, signal);
    }
    if (effectiveProvider === "alldebrid") {
      if (this.shouldUseAllDebridWeb(settings) && this.options.allDebridWebUnrestrict) {
        const result = await this.options.allDebridWebUnrestrict(link, signal);
        if (!result) {
          throw new Error("AllDebrid-Web-Fallback nicht verfügbar");
        }
        result.sourceLabel = "Web";
        return result;
      }
      const adResult = await new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal);
      adResult.sourceLabel = "API";
      return adResult;
    }
    if (effectiveProvider === "ddownload") {
      return this.getDdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal);
    }
    if (effectiveProvider === "onefichier") {
      return new OneFichierClient(settings.oneFichierApiKey).unrestrictLink(link, signal);
    }
    if (effectiveProvider === "debridlink") {
      const dlResult = await this.getDebridLinkClient(settings.debridLinkApiKeys).unrestrictLink(link, settings, signal);
      dlResult.sourceLabel = dlResult.sourceLabel || "API";
      return dlResult;
    }
    if (effectiveProvider === "linksnappy") {
      return this.getLinkSnappyClient(settings.linkSnappyLogin, settings.linkSnappyPassword).unrestrictLink(link, signal);
    }
    if (this.shouldUseBestDebridWeb(settings) && this.options.bestDebridWebUnrestrict) {
      const bdResult = await this.options.bestDebridWebUnrestrict(link, signal);
      if (!bdResult) {
        throw new Error("BestDebrid-Web-Fallback nicht verfügbar");
      }
      bdResult.sourceLabel = "Web";
      return bdResult;
    }
    const bdResult = await new BestDebridClient(settings.bestToken).unrestrictLink(link, signal);
    bdResult.sourceLabel = "API";
    return bdResult;
  }
}
