// Server-side qBittorrent access for the dashboard.
//
// The browser never talks to qBittorrent. Ampelos is served over HTTPS and
// qBittorrent over plain HTTP on the LAN, so a browser would refuse the request
// as mixed content -- and qBittorrent additionally sends
// `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`, which is why
// embedding its WebUI in an iframe cannot work either. Going through the server
// sidesteps all of it: this process is on the same host, on loopback.
//
// Read-only helpers plus the few actions the Downloads page needs. Anything
// more exotic belongs in qBittorrent's own UI.

const QBT_URL = process.env.QBT_URL ?? "http://127.0.0.1:8080";
const TIMEOUT_MS = 8_000;

export type TorrentState = {
  infoHash: string;
  name: string;
  state: string;
  progress: number;
  sizeBytes: number | null;
  downloadedBytes: number | null;
  dlSpeed: number | null;
  upSpeed: number | null;
  seeders: number | null;
  leechers: number | null;
  ratio: number | null;
  eta: number | null;
  category: string | null;
  contentPath: string | null;
  isComplete: boolean;
  isFailed: boolean;
  isPaused: boolean;
};

// qBittorrent's state vocabulary is large and mostly about how it got here.
// What the page needs is three questions: has the data arrived, is it stopped,
// is it broken.
const COMPLETE = new Set([
  "uploading", "stalledUP", "queuedUP", "forcedUP", "pausedUP", "stoppedUP", "checkingUP",
]);
const PAUSED = new Set(["pausedDL", "pausedUP", "stoppedDL", "stoppedUP"]);
const FAILED = new Set(["error", "missingFiles"]);

async function qbt(path: string, init?: RequestInit) {
  const response = await fetch(`${QBT_URL}${path}`, {
    ...init,
    headers: {
      ...(init?.body ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
      ...init?.headers,
    },
    signal: AbortSignal.timeout(TIMEOUT_MS),
    cache: "no-store",
  });
  if (!response.ok) {
    throw new Error(`qBittorrent returned HTTP ${response.status} for ${path}`);
  }
  return response;
}

/**
 * State for specific infohashes.
 *
 * Takes an explicit list rather than fetching everything: the dashboard shows
 * what Ampelos grabbed, and enumerating the whole client would also surface
 * torrents the user added by hand, which are deliberately none of its business.
 *
 * Returns an empty map rather than throwing when qBittorrent is unreachable --
 * the page is still useful showing the database's view, and a VPN reconnect
 * should not turn the page into an error screen.
 */
export async function torrentStates(infoHashes: string[]): Promise<{
  states: Map<string, TorrentState>;
  error: string | null;
}> {
  if (infoHashes.length === 0) return { states: new Map(), error: null };

  try {
    const hashes = infoHashes.map((h) => h.toLowerCase()).join("|");
    const response = await qbt(`/api/v2/torrents/info?hashes=${hashes}`);
    const raw = (await response.json()) as Array<Record<string, unknown>>;

    const states = new Map<string, TorrentState>();
    for (const t of raw) {
      const state = String(t.state ?? "");
      const progress = Number(t.progress ?? 0);
      const infoHash = String(t.hash ?? "").toLowerCase();
      states.set(infoHash, {
        infoHash,
        name: String(t.name ?? ""),
        state,
        progress: Math.round(progress * 100),
        sizeBytes: numberOrNull(t.size),
        downloadedBytes: numberOrNull(t.downloaded),
        dlSpeed: numberOrNull(t.dlspeed),
        upSpeed: numberOrNull(t.upspeed),
        seeders: numberOrNull(t.num_seeds),
        leechers: numberOrNull(t.num_leechs),
        ratio: numberOrNull(t.ratio),
        eta: numberOrNull(t.eta),
        category: t.category ? String(t.category) : null,
        contentPath: t.content_path ? String(t.content_path) : null,
        isComplete: COMPLETE.has(state) || progress >= 1,
        isFailed: FAILED.has(state),
        isPaused: PAUSED.has(state),
      });
    }
    return { states, error: null };
  } catch (error) {
    return {
      states: new Map(),
      error: error instanceof Error ? error.message : String(error),
    };
  }
}

/** Aggregate transfer info, for the header. Null when unreachable. */
export async function transferInfo() {
  try {
    const response = await qbt("/api/v2/transfer/info");
    const raw = (await response.json()) as Record<string, unknown>;
    return {
      dlSpeed: numberOrNull(raw.dl_info_speed) ?? 0,
      upSpeed: numberOrNull(raw.up_info_speed) ?? 0,
      connectionStatus: String(raw.connection_status ?? "unknown"),
    };
  } catch {
    return null;
  }
}

export async function pauseTorrent(infoHash: string) {
  // qBittorrent 5 renamed pause/resume to stop/start and keeps the old paths as
  // aliases. Using the old names keeps this working on both.
  await qbt("/api/v2/torrents/pause", {
    method: "POST",
    body: new URLSearchParams({ hashes: infoHash.toLowerCase() }),
  });
}

export async function resumeTorrent(infoHash: string) {
  await qbt("/api/v2/torrents/resume", {
    method: "POST",
    body: new URLSearchParams({ hashes: infoHash.toLowerCase() }),
  });
}

/**
 * Remove a torrent.
 *
 * `deleteFiles` deletes qBittorrent's own copy -- which, after import, is a
 * hardlink. The library copy is a separate directory entry to the same inode
 * and survives untouched. That is the whole reason import uses hardlinks: this
 * button cannot take the library with it.
 */
export async function removeTorrent(infoHash: string, deleteFiles: boolean) {
  await qbt("/api/v2/torrents/delete", {
    method: "POST",
    body: new URLSearchParams({
      hashes: infoHash.toLowerCase(),
      deleteFiles: String(deleteFiles),
    }),
  });
}

function numberOrNull(value: unknown): number | null {
  const parsed = Number(value);
  return Number.isFinite(parsed) ? parsed : null;
}

export function formatBytes(value: number | bigint | null | undefined): string {
  if (value === null || value === undefined) return "—";
  let size = Number(value);
  if (!Number.isFinite(size)) return "—";
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
  let unit = 0;
  while (size >= 1024 && unit < units.length - 1) {
    size /= 1024;
    unit += 1;
  }
  return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}

export function formatSpeed(bytesPerSecond: number | null): string {
  if (!bytesPerSecond) return "—";
  return `${formatBytes(bytesPerSecond)}/s`;
}

export function formatEta(seconds: number | null): string {
  // qBittorrent uses 8640000 (100 days) to mean "no estimate".
  if (seconds === null || seconds <= 0 || seconds >= 8_640_000) return "—";
  if (seconds < 60) return `${seconds}s`;
  if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
  if (seconds < 86_400) return `${Math.round(seconds / 3600)}h`;
  return `${Math.round(seconds / 86_400)}d`;
}
