import { spawn, execFile, type ChildProcess } from 'child_process'
import { existsSync, unlinkSync, readdirSync } from 'fs'
import { tmpdir } from 'os'
import { join, parse } from 'path'
import { BrowserWindow, Notification, type WebContents } from 'electron'
import {
  getYtdlpPath,
  getFfmpegDir,
  getAria2cPath,
  getFfmpegPath,
  getFfprobePath,
  getSystem32Path,
  getAppIconImage,
  YTDLP_MISSING_MSG
} from './binaries'
import { getSettings } from './settings'
import { execFileAsync } from './lib/exec'
import { createLineBuffer } from './lib/lineBuffer'
import { orphanPartials } from './lib/partials'
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
import { ensureManagedYtdlp } from './ytdlp'
import { materializeCookies, hasStoredCookies } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { isSafeOutputDir } from './core/validation'
import {
  buildArgs,
  selectExtraArgs,
  formatCommandLine,
  collectionOutputTemplate,
  PROGRESS_MARKER,
  FILEPATH_MARKER,
  DEST_MARKER
} from './core/buildArgs'
import { describeDownloadError, stderrDetail } from './log'
import { addErrorLog } from './errorlog'
import {
  STALL_TIMEOUT_MS,
  META_PROBE_TIMEOUT_MS,
  META_MAX_BUFFER,
  STDERR_TAIL_BYTES
} from './constants'
import {
  IpcChannels,
  type StartDownloadOptions,
  type StartDownloadResult,
  type CommandPreviewResult,
  type DownloadEvent,
  type DownloadMeta,
  type Settings
} from '@shared/ipc'

interface ActiveDownload {
  child: ChildProcess
  canceled: boolean
  paused: boolean
}

const active = new Map<string, ActiveDownload>()

// Per-spawn sequence, so a retry/resume that reuses the item id still gets a
// unique transient cookie file (below) — otherwise a superseded download's
// teardown could unlink the new spawn's jar mid-read.
let spawnSeq = 0

// Remove an item from the active map, but only if THIS rec still owns the slot.
// cancel/pause release the slot synchronously (so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard while the killed tree is
// still tearing down); the doomed child's later 'close'/'error' then runs this as
// a no-op. The identity check matters because a retry/resume reuses the item id,
// so a stale teardown must not evict the newer same-id spawn. (L140/L148)
function releaseActive(id: string, rec: ActiveDownload): void {
  if (active.get(id) === rec) active.delete(id)
}

/**
 * Whether any yt-dlp download is currently running. Used by the window's close
 * handler to keep the app alive in the tray (instead of quitting and killing the
 * spawned processes) when the user closes the window mid-download.
 */
export function hasActiveDownloads(): boolean {
  return active.size > 0
}

// --- Formatting helpers (raw yt-dlp numbers → human strings) ----------------
// parseProgress lives in lib/formatters.ts (no electron import chain) so it can
// be unit-tested in isolation (L37). Byte/ETA formatters live in @shared/format.
export { parseProgress } from './lib/formatters'
import { parseProgress } from './lib/formatters'

function send(wc: WebContents, ev: DownloadEvent): void {
  if (!wc.isDestroyed()) wc.send(IpcChannels.downloadEvent, ev)
}

/** Native OS notification on completion/failure, gated by Settings.notifyOnComplete. */
function notify(wc: WebContents, title: string, body: string, incognito = false): void {
  if (!getSettings().notifyOnComplete || !Notification.isSupported()) return
  // L136: an incognito ("private") download must not leak its title/URL into an OS
  // toast (it persists in the Windows Action Center). Callers pass a generic outcome
  // title for the private case; here we additionally drop the detail body.
  const n = new Notification({ title, body: incognito ? '' : body, icon: getAppIconImage() })
  n.on('click', () => {
    if (wc.isDestroyed()) return
    const win = BrowserWindow.fromWebContents(wc)
    if (win) {
      if (win.isMinimized()) win.restore()
      win.show()
      win.focus()
    }
  })
  n.show()
  // W10: flash the taskbar button for attention when the event happened in the
  // background (window unfocused/minimized/hidden). Windows stops the flash on its
  // own once the window gets focus, so there's no explicit stop to manage.
  const flashWin = BrowserWindow.fromWebContents(wc)
  if (flashWin && !flashWin.isDestroyed() && !flashWin.isFocused()) flashWin.flashFrame(true)
}

function logFailure(
  opts: StartDownloadOptions,
  title: string | undefined,
  error: string,
  detail?: string
): void {
  // L136: incognito downloads are never recorded — not even a failure entry, which
  // would persist the title + URL in the user-facing diagnostics log.
  if (opts.incognito) return
  addErrorLog({
    id: opts.id,
    title,
    url: opts.url,
    kind: opts.kind,
    error,
    detail,
    occurredAt: Date.now()
  })
}

// --- Best-effort metadata probe (runs alongside the download) ---------------

// Unit Separator (0x1F): a control char that can't appear in a title/uploader,
// so it safely delimits the three fields in ONE --print template. Splitting on
// newlines instead (B4) would mis-assign channel/duration whenever a title
// itself contains a newline, since each --print field is emitted on its own line.
const META_SEP = '\u001f'

async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
  const r = await execFileAsync(
    ytdlp,
    [
      '--no-playlist',
      '--no-warnings',
      '--skip-download',
      '--print',
      `%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
      '--',
      url
    ],
    { maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS }
  )
  if (!r.ok) return null
  const [title, uploader, duration] = r.stdout.split(META_SEP).map((l) => l.trim())
  const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
  return {
    title: clean(title),
    channel: clean(uploader),
    durationLabel: clean(duration)
  }
}

// --- Argv construction (shared by startDownload and the command preview) ---

// A per-download override (opts.extraArgs, even '') wins over the persisted
// default template — but BOTH are gated on the enableCustomCommands consent flag
// (see selectExtraArgs / audit F2). The gate is enforced here in main, not just
// in the renderer UI, so the renderer can't be trusted to apply it.
function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): string[] {
  // Gate the file read: listTemplates() parses templates.json on every call, so
  // skip it entirely when the feature is off (PERF2).
  if (!settings.enableCustomCommands) return []
  return selectExtraArgs({
    enableCustomCommands: settings.enableCustomCommands,
    perDownloadExtraArgs: opts.extraArgs,
    defaultTemplateId: settings.defaultTemplateId,
    templates: listTemplates(),
    url: opts.url
  })
}

/** Resolve settings + per-download overrides into the full yt-dlp argv.
 *  `cookiesFile` is the transient decrypted jar the caller materialises for the
 *  'login' cookie source (H7); the 'browser' source uses --cookies-from-browser. */
export function buildCommand(opts: StartDownloadOptions, cookiesFile?: string): string[] {
  const settings = getSettings()
  // Output dir resolution: a per-download override wins, then the user's explicit
  // per-kind folder (Settings → Video/Audio folder), and finally — when that's
  // blank — the per-kind default (Documents\Video for video, Documents\Audio for audio).
  //
  // A per-download outputDir override must clear the same safety check the persisted
  // setting does (absolute path only); a renderer-supplied override is otherwise
  // untrusted — it dictates where downloaded files get written. An unsafe override is
  // ignored in favour of the per-kind folder, then the per-kind default. (audit F4)
  const override = opts.outputDir?.trim()
  const safeOverride = override && isSafeOutputDir(override) ? override : ''
  const perKindDir = (opts.kind === 'audio' ? settings.audioFolder : settings.videoFolder)?.trim()
  const outDir = safeOverride || perKindDir || getDefaultMediaDir(opts.kind)
  // A collection (media-manager) download is filed into <channel>/<playlist>/
  // <NNN> - <title> folders by default, or into a power-user's raw yt-dlp `-o`
  // layout (Settings.collectionOutputTemplate); an ordinary download uses the
  // flat filenameTemplate.
  const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
  const outputTemplate = opts.collection
    ? collectionOutputTemplate(
        outDir,
        opts.collection,
        '%(title)s.%(ext)s',
        // A per-download override (a Media Profile's outputTemplate) wins over the
        // global collection-layout setting.
        opts.collectionOutputTemplate ?? settings.collectionOutputTemplate
      )
    : join(outDir, filenameTemplate)
  // Per-download override wins; otherwise use the persisted defaults.
  const options = opts.options ?? settings.downloadOptions
  // Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
  // into resources/bin — the toggle shouldn't turn into a hard error.
  const aria2cPath = settings.useAria2c && existsSync(getAria2cPath()) ? getAria2cPath() : undefined
  const access = {
    proxy: settings.proxy,
    rateLimit: settings.rateLimit,
    sleepRequests: settings.sleepRequests,
    sleepInterval: settings.sleepInterval,
    maxSleepInterval: settings.maxSleepInterval,
    aria2cPath,
    aria2cConnections: settings.aria2cConnections,
    // L136/M6: incognito attaches no cookies from the browser either.
    cookiesFromBrowser:
      !opts.incognito && settings.cookieSource === 'browser' ? settings.cookiesBrowser : undefined,
    cookiesFile,
    restrictFilenames: settings.restrictFilenames,
    downloadArchivePath: settings.useDownloadArchive ? getDownloadArchivePath() : undefined,
    youtubePlayerClient: settings.youtubePlayerClient,
    youtubePoToken: settings.youtubePoToken
  }
  const extraArgs = resolveExtraArgs(opts, settings)
  // binDir feeds only `--ffmpeg-location`, so it's the managed ffmpeg dir (userData/bin),
  // where the first-run download / dev seed places ffmpeg.exe + ffprobe.exe.
  return buildArgs({ opts, outputTemplate, options, binDir: getFfmpegDir(), access, extraArgs })
}

/** Build the exact command line for the current form state, without running it. */
export function previewCommand(opts: StartDownloadOptions): CommandPreviewResult {
  let normalized: StartDownloadOptions
  try {
    // Use the parser-normalised URL (audit F5), so the previewed command matches
    // exactly what startDownload would spawn.
    normalized = { ...opts, url: assertHttpUrl(opts.url) }
  } catch (e) {
    return { ok: false, error: (e as Error).message }
  }
  try {
    return { ok: true, command: formatCommandLine(getYtdlpPath(), buildCommand(normalized)) }
  } catch (e) {
    return { ok: false, error: (e as Error).message }
  }
}

// --- Public API -------------------------------------------------------------

export function startDownload(wc: WebContents, opts: StartDownloadOptions): StartDownloadResult {
  // Self-heal the managed copy from the bundled seed before spawning, so a
  // never-seeded or deleted yt-dlp.exe doesn't fail an otherwise-fine download.
  ensureManagedYtdlp()
  const ytdlp = getYtdlpPath()
  if (!existsSync(ytdlp)) {
    return {
      ok: false,
      error: YTDLP_MISSING_MSG
    }
  }
  // ffmpeg is used by nearly every download (merge, audio extract, thumbnail/
  // metadata embed) and ffprobe by duration-aware post-processing (SponsorBlock-
  // remove, --force-keyframes-at-cuts, --split-chapters). Assert both up front so a
  // missing binary is a clear AeroFetch message, not a cryptic mid-download yt-dlp
  // postprocessing error (e.g. "Unable to determine video duration: ffprobe not found").
  const missingBins: string[] = []
  if (!existsSync(getFfmpegPath())) missingBins.push('ffmpeg.exe')
  if (!existsSync(getFfprobePath())) missingBins.push('ffprobe.exe')
  if (missingBins.length > 0) {
    // The first-run setup gate normally guarantees these are present; this backstops a
    // mid-session loss (AV quarantine, a cleared userData/bin) with a clear repair path.
    return {
      ok: false,
      error: `${missingBins.join(' and ')} not found. Open Settings → About and choose "Repair ffmpeg" to reinstall the media tools.`
    }
  }
  // Reject anything that isn't an http(s) URL before it reaches yt-dlp's argv,
  // and replace opts.url with the parser-normalised form so the exact string we
  // validated is the one that gets spawned/probed — not a raw variant carrying
  // interior tabs/newlines or leading control chars that URL parsing silently
  // tolerates. (audit F5)
  try {
    opts = { ...opts, url: assertHttpUrl(opts.url) }
  } catch (e) {
    return { ok: false, error: (e as Error).message }
  }
  if (active.has(opts.id)) {
    return { ok: false, error: 'A download with this id is already running.' }
  }
  // Defence-in-depth: the renderer's pump() already caps concurrency, but the main
  // process shouldn't trust it — a buggy or compromised renderer could otherwise
  // spawn unbounded yt-dlp processes. Enforce the same cap here on active spawns.
  const maxConcurrent = getSettings().maxConcurrent
  if (active.size >= maxConcurrent) {
    return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
  }
  // L139: don't launch the managed yt-dlp while a self-update is rewriting the exe —
  // on Windows that races into a sharing violation or a half-written binary. The
  // updater backs off whenever a download is live and only holds the lock for the
  // brief rewrite, so this is a rare, retryable refusal.
  if (isYtdlpUpdating()) {
    return {
      ok: false,
      error: 'yt-dlp is updating in the background — try this download again in a moment.'
    }
  }

  // Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
  // yt-dlp reads --cookies once at startup; we delete it the moment the download
  // settles, so the plaintext never lingers at rest.
  let cookiesFile: string | undefined
  // L136/M6: an incognito download attaches no saved login cookies (the UI promises
  // "no cookies"), so it can't be tied to the user's signed-in identity.
  if (!opts.incognito && getSettings().cookieSource === 'login' && hasStoredCookies()) {
    // Unique per spawn (not just per id): a retry/resume reuses opts.id, and the
    // superseded download's cleanupCookies() must not unlink the new spawn's jar.
    const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`)
    if (materializeCookies(tmp)) cookiesFile = tmp
  }
  function cleanupCookies(): void {
    if (cookiesFile) {
      try {
        unlinkSync(cookiesFile)
      } catch {
        /* best-effort */
      }
      cookiesFile = undefined
    }
  }

  let child: ChildProcess
  try {
    child = spawn(ytdlp, buildCommand(opts, cookiesFile), { windowsHide: true })
  } catch (e) {
    cleanupCookies()
    return { ok: false, error: (e as Error).message }
  }
  // L139: a live yt-dlp process now exists — the updater must defer to it until the
  // matching releaseSpawn() on settle (close/error/stall) below.
  acquireSpawn()

  const rec: ActiveDownload = { child, canceled: false, paused: false }
  active.set(opts.id, rec)

  // Title/channel/duration are kept locally so the completion notification and
  // error log can show a real title instead of just the raw URL.
  let resolvedTitle: string | undefined
  if (opts.meta && (opts.meta.title || opts.meta.channel || opts.meta.durationLabel)) {
    // The renderer already probed this URL and passed the metadata along — reuse
    // it instead of spawning a redundant second yt-dlp probe (audit P2).
    resolvedTitle = opts.meta.title
    send(wc, { type: 'meta', id: opts.id, meta: opts.meta })
  } else {
    // No pre-probed metadata (e.g. a direct paste that skipped the probe) — fetch
    // it in parallel so the card fills in quickly.
    probeMeta(ytdlp, opts.url).then((meta) => {
      if (meta?.title) resolvedTitle = meta.title
      if (active.has(opts.id)) {
        // Always emit a meta event: on success it fills in title/channel/duration;
        // on failure (null from timeout/error) the renderer clears the
        // "Resolving…" placeholder via applyEvent('meta') (SR6 / L47).
        send(wc, { type: 'meta', id: opts.id, meta: meta ?? {} })
      }
    })
  }

  // Wire the child's streams + teardown (CL3). resolvedTitle is passed as a getter
  // because the parallel probeMeta above may fill it in after this returns.
  wireChildProcess({ wc, opts, rec, cleanup: cleanupCookies, getTitle: () => resolvedTitle })

  return { ok: true }
}

// --- Child-process wiring ---------------------------------------------------

/**
 * Wire a spawned yt-dlp child's stdout/stderr/close/error to download events, and
 * run the B1 idle watchdog. Extracted from startDownload so that function reads as
 * a linear spawn + pre-flight and this owns the streaming/teardown lifecycle (CL3).
 *
 * `getTitle` is read lazily on each event: the completion/error paths need the
 * best title known *at settle time*, which the parallel metadata probe may only
 * fill in after wiring is set up.
 */
function wireChildProcess(params: {
  wc: WebContents
  opts: StartDownloadOptions
  rec: ActiveDownload
  cleanup: () => void
  getTitle: () => string | undefined
}): void {
  const { wc, opts, rec, cleanup, getTitle } = params
  const child = rec.child

  let stderrTail = ''
  let filePath: string | undefined
  // The resolved FINAL output path (from the before_dl `dest|` print), captured
  // while the download is still running so a cancel can delete this download's
  // orphaned partials by stem (R4). Undefined until the first stream starts.
  let outputPath: string | undefined
  // Latched once the first download stream reports 'finished'; flags later
  // progress as the merge/post-processing "finishing" phase (SR7).
  let finishing = false
  // 'error' and 'close' can both fire for one process; only act on the first.
  let settled = false

  // Idle watchdog (B1): reset on any output; if it ever fires, the child has been
  // silent for STALL_TIMEOUT_MS, so kill + error it and free the slot.
  let stallTimer: ReturnType<typeof setTimeout> | null = null
  function clearWatchdog(): void {
    if (stallTimer) {
      clearTimeout(stallTimer)
      stallTimer = null
    }
  }
  function bumpWatchdog(): void {
    clearWatchdog()
    stallTimer = setTimeout(() => {
      // A cancel/pause races the timer: it already released the slot and the
      // child's close stays silent, so don't fire a spurious stall error (mirrors
      // the canceled/paused guards on the close handler below).
      if (settled || rec.canceled || rec.paused) return
      settled = true
      clearWatchdog()
      cleanup()
      releaseActive(opts.id, rec)
      releaseSpawn()
      killTree(rec)
      const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
      send(wc, { type: 'error', id: opts.id, error: msg })
      logFailure(opts, getTitle(), msg)
      notify(
        wc,
        opts.incognito ? 'Download stopped' : (getTitle() ?? 'Download failed'),
        msg,
        opts.incognito
      )
    }, STALL_TIMEOUT_MS)
  }
  bumpWatchdog()

  // Split stdout into lines (shared helper, CC3/CC4) and parse our --print markers.
  // No flush on close: yt-dlp's progress/path template lines are always
  // newline-terminated, so a trailing partial is never a real marker.
  const stdoutLines = createLineBuffer((line) => {
    if (line.startsWith(PROGRESS_MARKER)) {
      const p = parseProgress(line.slice(PROGRESS_MARKER.length))
      if (p) {
        // SR7: yt-dlp reports a 'finished' status when each download stream
        // completes. A video+audio download has two streams, so the bar would
        // otherwise fill 0→100% twice. Latch on the first 'finished' and flag
        // every later tick as "finishing" so the renderer shows an indeterminate
        // merge state instead of a visible restart.
        if (p.status === 'finished') finishing = true
        send(wc, { type: 'progress', id: opts.id, progress: { ...p, finishing } })
      }
    } else if (line.startsWith(FILEPATH_MARKER)) {
      filePath = line.slice(FILEPATH_MARKER.length).trim()
    } else if (line.startsWith(DEST_MARKER)) {
      // before_dl fires once per stream; every emission carries the same final
      // path, so the last-write-wins overwrite is harmless.
      outputPath = line.slice(DEST_MARKER.length).trim()
    }
  })
  child.stdout?.on('data', (chunk: Buffer) => {
    bumpWatchdog()
    stdoutLines.push(chunk.toString())
  })

  child.stderr?.on('data', (chunk: Buffer) => {
    bumpWatchdog()
    stderrTail = (stderrTail + chunk.toString()).slice(-STDERR_TAIL_BYTES)
  })

  child.on('error', (err) => {
    if (settled) return
    settled = true
    clearWatchdog()
    cleanup()
    releaseActive(opts.id, rec)
    releaseSpawn()
    // A canceled download's partials are now orphans — remove them (R4). A paused
    // download was also killed on purpose, but keeps its .part for a later resume.
    if (rec.canceled) cleanupPartials(outputPath)
    // A paused download was killed on purpose — stay silent, like a cancel.
    if (!rec.canceled && !rec.paused) {
      send(wc, { type: 'error', id: opts.id, error: err.message })
      logFailure(opts, getTitle(), err.message)
      notify(
        wc,
        opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
        err.message,
        opts.incognito
      )
    }
  })

  child.on('close', (code) => {
    if (settled) return
    settled = true
    clearWatchdog()
    cleanup()
    releaseActive(opts.id, rec)
    releaseSpawn()
    // Canceled: renderer already showed 'canceled'; the process is now gone (its
    // file handles released), so delete this download's orphaned partials (R4).
    // Paused: renderer showed 'paused' and KEEPS the .part for a later resume.
    // Either way, no event.
    if (rec.canceled) {
      cleanupPartials(outputPath)
      return
    }
    if (rec.paused) return
    if (code === 0) {
      send(wc, { type: 'done', id: opts.id, filePath })
      notify(
        wc,
        opts.incognito ? 'Download complete' : (getTitle() ?? 'Download complete'),
        'Finished downloading.',
        opts.incognito
      )
    } else {
      const msg = describeDownloadError(stderrTail) || `yt-dlp exited with code ${code}`
      send(wc, { type: 'error', id: opts.id, error: msg })
      logFailure(opts, getTitle(), msg, stderrDetail(stderrTail))
      notify(
        wc,
        opts.incognito ? 'Download failed' : (getTitle() ?? 'Download failed'),
        msg,
        opts.incognito
      )
    }
  })
}

// Kill the whole process tree (/T) so the spawned ffmpeg child dies too. Resolve
// taskkill from System32 by absolute path, never the bare name, so a planted
// taskkill.exe on PATH / in the CWD can't run in its place. (audit F3)
function killTree(rec: ActiveDownload): void {
  const pid = rec.child.pid
  if (pid != null) {
    execFile(
      getSystem32Path('taskkill.exe'),
      ['/pid', String(pid), '/T', '/F'],
      { windowsHide: true },
      () => {}
    )
  } else {
    rec.child.kill()
  }
}

/**
 * Delete a canceled download's orphaned intermediate files (R4). `outputPath` is the
 * resolved FINAL destination captured from the before_dl `dest|` print; orphanPartials
 * (pure, unit-tested in lib/partials.ts) turns its dir listing into the exact set of
 * this download's `.part` / `.part-Frag*` / `.ytdl` / `.fNNN.<ext>` intermediates —
 * never a completed `<stem>.<ext>` or another download's files. Best-effort: a
 * still-locked or already-gone entry is skipped. Called from the child's close/error
 * handler, so the process (and its file handles) are already gone.
 */
function cleanupPartials(outputPath: string | undefined): void {
  if (!outputPath) return
  const { dir } = parse(outputPath)
  if (!dir) return
  let entries: string[]
  try {
    entries = readdirSync(dir)
  } catch {
    return // folder vanished / unreadable — nothing to clean
  }
  for (const entry of orphanPartials(outputPath, entries)) {
    try {
      unlinkSync(join(dir, entry))
    } catch {
      /* best-effort: locked or already removed */
    }
  }
}

export function cancelDownload(id: string): void {
  const rec = active.get(id)
  if (!rec) return
  rec.canceled = true
  // Free the slot now (not on the async 'close') so the renderer's just-promoted
  // next item isn't rejected by the maxConcurrent guard — or run briefly over the
  // cap — while taskkill tears down this tree. The child's later 'close' stays
  // silent (rec.canceled) and releaseActive() no-ops. (L148)
  active.delete(id)
  killTree(rec)
}

/**
 * Pause a running download: kill the process tree but flag it `paused` (not
 * `canceled`) so the close handler stays silent — no error event, no error log,
 * no notification. yt-dlp leaves the partial `.part` file in place, so resuming
 * is just a fresh startDownload with the same options: yt-dlp's default
 * `--continue` picks the partial file back up.
 */
export function pauseDownload(id: string): void {
  const rec = active.get(id)
  if (!rec) return
  rec.paused = true
  // Free the slot now (see cancelDownload / L148). The partial .part stays on disk
  // for a later resume, which reuses this id — releaseActive()'s identity check
  // keeps the doomed child's 'close' from evicting that fresh spawn. (L140)
  active.delete(id)
  killTree(rec)
}
