import { execSync } from 'node:child_process';
import { constants, type Dirent, type Stats } from 'node:fs';
import * as fsSync from 'node:fs';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { DispatchContext, GatewayLogger } from '../dispatch-adapter.js';
import { formatBytes, renderLocalAttachmentSection } from '../prompt-fragments.js';
import type { LocalAttachmentResult, PreparedLocalImage } from '../prompt-fragments.js';
import type { ParallEvent } from '../types.js';

// Re-export so external consumers (codex-agent, claude-agent) keep importing
// via the internal/attachment-input subpath. The canonical definition lives
// in prompt-fragments.ts alongside the renderer that consumes the same shape.
export type { LocalAttachmentResult, PreparedLocalImage };

export type LocalAttachmentOptions = {
  workspaceDir: string;
  maxTotalImageBytes?: number;
  maxCacheBytes?: number;
  downloadTimeoutMs?: number;
  attachmentTtlMs?: number;
  /**
   * Cooldown window for background cache maintenance (TTL cleanup + LRU prune).
   * When >0 (default), only one maintenance pass per cooldown interval runs per
   * root, in the background, and dispatch never waits on it. When 0, the pass
   * runs synchronously in-line — used by tests that need deterministic prune
   * timing.
   */
  maintenanceCooldownMs?: number;
  log?: GatewayLogger;
};

/**
 * Per-dispatch cap on image bytes a runtime turn receives. Counts declared
 * attachment sizes plus fresh download bytes; cache hits are NOT recharged
 * against this budget — those are already on disk and bounded separately by
 * `DEFAULT_ATTACHMENT_CACHE_MAX_BYTES` below. The cap exists to keep any one
 * dispatch from saturating the bridge↔runtime hop with a single oversized
 * batch; the cache cap exists to keep the workspace from growing unbounded.
 */
export const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
/**
 * Workspace cache ceiling. The default is generous for typical operator setups,
 * but on small PVCs (e.g. 1 GiB) it can claim a meaningful fraction of disk.
 * Operators on constrained storage should override via `LocalAttachmentOptions.maxCacheBytes`.
 */
export const DEFAULT_ATTACHMENT_CACHE_MAX_BYTES = 512 * 1024 * 1024;
export const DEFAULT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS = 30_000;
export const DEFAULT_ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
export const DEFAULT_MAINTENANCE_COOLDOWN_MS = 60 * 1000;

const SUPPORTED_IMAGE_MIME_TYPES = new Set([
  'image/png',
  'image/jpeg',
  'image/jpg',
  'image/webp',
  'image/gif',
]);

// Process-global state — correct under the current "one gateway per agent"
// architecture (each runtime bridge is a single-tenant subprocess, so all
// in-flight dispatches share one logical agent identity). If the gateway
// ever multiplexes multiple agent identities in a single process, both
// `activeAttachmentDirs` and `maintenanceStateByRoot` need to be scoped per
// tenant — otherwise they leak active-dispatch and cooldown state across
// agents that should not share cache visibility.
const activeAttachmentDirs = new Set<string>();

export async function prepareLocalImageAttachments(
  event: ParallEvent,
  context: DispatchContext,
  opts: LocalAttachmentOptions,
): Promise<LocalAttachmentResult> {
  const attachments = event.attachments ?? [];
  const imageAttachments = attachments.filter((att) =>
    SUPPORTED_IMAGE_MIME_TYPES.has(att.mimeType.toLowerCase()),
  );
  if (imageAttachments.length === 0) return { images: [], notes: [] };

  const maxTotalImageBytes = opts.maxTotalImageBytes ?? DEFAULT_MAX_TOTAL_IMAGE_BYTES;
  const totalDeclaredBytes = imageAttachments.reduce(
    (sum, att) => sum + Math.max(0, att.fileSize),
    0,
  );
  if (totalDeclaredBytes > maxTotalImageBytes) {
    return {
      images: [],
      notes: [
        `[Image attachments not downloaded: total size ${formatBytes(totalDeclaredBytes)} exceeds bridge limit ${formatBytes(maxTotalImageBytes)}]`,
      ],
    };
  }

  const rootDir = await ensureAttachmentRootDir(opts.workspaceDir);

  // Pin this dispatch's message directory in `activeAttachmentDirs` BEFORE
  // scheduling cache maintenance. The maintenance pass snapshots active dirs
  // when it starts; if we scheduled first, a concurrent prune could observe
  // the snapshot without our entry and delete a same-`messageId` cache from a
  // prior dispatch (retry / replay) right under us.
  const messageDir = path.join(rootDir, sanitizePathSegment(event.messageId));
  await ensurePathIsNotSymlink(messageDir);
  await fs.mkdir(messageDir, { recursive: true });
  await ensurePathIsNotSymlink(messageDir);
  const activeMessageDir = path.resolve(messageDir);
  activeAttachmentDirs.add(activeMessageDir);

  // Cache maintenance (TTL cleanup + LRU prune) walks every cached message
  // directory and stats every file. Doing it synchronously per-dispatch turns
  // dispatch latency into O(cache_dirs) and burns CPU under high concurrency
  // (1000 in-flight dispatches → 1000 walks). Instead we throttle by root
  // (one pass per cooldown window) and run it off the dispatch path. The
  // cache cap is best-effort, not a hard ceiling — cooldown drift is fine.
  const maintenanceCooldownMs = opts.maintenanceCooldownMs ?? DEFAULT_MAINTENANCE_COOLDOWN_MS;
  const maintenancePromise = scheduleAttachmentMaintenance(rootDir, {
    ttlMs: opts.attachmentTtlMs ?? DEFAULT_ATTACHMENT_TTL_MS,
    maxBytes: opts.maxCacheBytes ?? DEFAULT_ATTACHMENT_CACHE_MAX_BYTES,
    cooldownMs: maintenanceCooldownMs,
    log: opts.log,
  });
  if (maintenanceCooldownMs === 0 && maintenancePromise) {
    // Sync-mode for tests that need deterministic prune ordering relative to
    // active-dispatch tracking. Production paths leave this on the background.
    await maintenancePromise;
  }

  try {
    const images: PreparedLocalImage[] = [];
    const notes: string[] = [];
    let downloadedBytes = 0;

    for (const att of imageAttachments) {
      const localPath = path.join(messageDir, localFileName(att.id, att.fileName, att.mimeType));
      const downloadTimeoutMs = opts.downloadTimeoutMs ?? DEFAULT_ATTACHMENT_DOWNLOAD_TIMEOUT_MS;
      const fetchFresh = async (): Promise<void> => {
        const fileInfo = await withTimeout(
          context.client.getFileUrl(att.id),
          downloadTimeoutMs,
          `file URL lookup timed out after ${downloadTimeoutMs}ms`,
        );
        downloadedBytes += await downloadAttachmentToFile(
          fileInfo.url,
          localPath,
          maxTotalImageBytes - downloadedBytes,
          downloadTimeoutMs,
          rootDir,
        );
      };

      const cameFromCache = await existingUsableFile(localPath, att.fileSize, rootDir);
      if (!cameFromCache) {
        try {
          await fetchFresh();
        } catch (err) {
          opts.log?.warn?.(
            `agent-core: failed to prepare local image attachment ${att.id}: ${String(err)}`,
          );
          notes.push(`[Image attachment unavailable locally: prll://${sanitizeMeta(att.id)}]`);
          continue;
        }
      }

      let mimeOk = await downloadedFileMatchesImageMime(localPath, att.mimeType, rootDir);
      if (!mimeOk && cameFromCache) {
        // Cache hit produced a file whose magic bytes don't match the declared
        // MIME — the cached copy is corrupt (partial write, agent edit,
        // bit-rot). Drop it and try once with a fresh download before giving
        // up; a genuinely broken upstream will fail the second MIME check
        // too and fall through to the unavailable note.
        await removeLocalFileIfInside(localPath, rootDir);
        try {
          await fetchFresh();
        } catch (err) {
          opts.log?.warn?.(
            `agent-core: failed to re-download corrupted cache for ${att.id}: ${String(err)}`,
          );
          notes.push(`[Image attachment unavailable locally: prll://${sanitizeMeta(att.id)}]`);
          continue;
        }
        mimeOk = await downloadedFileMatchesImageMime(localPath, att.mimeType, rootDir);
      }

      if (!mimeOk) {
        await removeLocalFileIfInside(localPath, rootDir);
        notes.push(
          `[Image attachment unavailable locally: prll://${sanitizeMeta(att.id)} (downloaded content does not match ${sanitizeMeta(att.mimeType)})]`,
        );
        continue;
      }

      images.push({
        attachmentId: att.id,
        fileName: att.fileName,
        mimeType: att.mimeType,
        fileSize: att.fileSize,
        localPath,
      });
    }

    return { images, notes };
  } finally {
    activeAttachmentDirs.delete(activeMessageDir);
  }
}

export function appendLocalAttachmentRefs(body: string, result: LocalAttachmentResult): string {
  const section = renderLocalAttachmentSection(result);
  return section ? `${body}\n\n${section}` : body;
}

export async function appendPreparedLocalAttachmentRefs(
  body: string,
  event: ParallEvent,
  context: DispatchContext,
  opts: LocalAttachmentOptions,
): Promise<{ body: string; attachments: LocalAttachmentResult }> {
  const attachments = await prepareLocalImageAttachments(event, context, opts);
  return { body: appendLocalAttachmentRefs(body, attachments), attachments };
}

export function pinLocalAttachmentPaths(images: PreparedLocalImage[]): () => void {
  const dirs = new Set(images.map((image) => path.resolve(path.dirname(image.localPath))));
  for (const dir of dirs) {
    activeAttachmentDirs.add(dir);
  }

  let released = false;
  return () => {
    if (released) return;
    released = true;
    for (const dir of dirs) {
      activeAttachmentDirs.delete(dir);
    }
  };
}

function attachmentRootDir(workspaceDir: string): string {
  return path.join(path.resolve(workspaceDir), '.parall', 'attachments');
}

/**
 * Append `.parall/` to the workspace's git info/exclude. Best-effort — a
 * missing exclude entry should not block dispatch. Used by both Claude and
 * Codex bridges' workspace bootstrap; lives here next to the attachment
 * download logic so the directory convention stays in one place.
 */
export function ensureLocalAttachmentGitExclude(workingDirectory: string): void {
  try {
    const rel = execSync('git rev-parse --git-path info/exclude', {
      cwd: workingDirectory,
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'ignore'],
    }).trim();
    const excludePath = path.isAbsolute(rel) ? rel : path.join(workingDirectory, rel);
    fsSync.mkdirSync(path.dirname(excludePath), { recursive: true });
    const existing = fsSync.existsSync(excludePath) ? fsSync.readFileSync(excludePath, 'utf8') : '';
    if (existing.split(/\r?\n/).some((line) => line.trim() === '.parall/')) return;
    const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
    fsSync.appendFileSync(excludePath, `${prefix}.parall/\n`, 'utf8');
  } catch {
    // Best-effort only. A missing exclude entry should not block dispatch.
  }
}

type MaintenanceState = { lastRunMs: number; inFlight: Promise<void> | null };

const maintenanceStateByRoot = new Map<string, MaintenanceState>();

type MaintenanceOptions = {
  ttlMs: number;
  maxBytes: number;
  cooldownMs: number;
  log?: GatewayLogger;
};

/**
 * Throttled, off-path cache maintenance. Returns a promise the caller can
 * await in test mode; production callers ignore the return value so the prune
 * walk runs in the background without blocking dispatch latency.
 *
 * Concurrency model: when a pass is in-flight, concurrent dispatches see the
 * existing promise and skip; they don't pile up redundant walks. When the
 * cooldown is unexpired, dispatches return null immediately. Worst case the
 * cache exceeds `maxBytes` by a bounded amount until the next cooldown
 * window — acceptable, since the cap is a soft ceiling, not a hard limit.
 */
function scheduleAttachmentMaintenance(
  rootDir: string,
  opts: MaintenanceOptions,
): Promise<void> | null {
  const state = maintenanceStateByRoot.get(rootDir);
  if (state?.inFlight) return state.inFlight;
  const now = Date.now();
  if (state && now - state.lastRunMs < opts.cooldownMs) return null;

  const newState: MaintenanceState = state ?? { lastRunMs: 0, inFlight: null };
  if (!state) maintenanceStateByRoot.set(rootDir, newState);

  const run = (async () => {
    try {
      await cleanupOldAttachmentFiles(rootDir, opts.ttlMs, opts.log, activeDirsForRoot(rootDir));
      await pruneAttachmentCache(rootDir, opts.maxBytes, opts.log, activeDirsForRoot(rootDir));
    } catch (err) {
      opts.log?.warn?.(`agent-core: attachment cache maintenance failed: ${String(err)}`);
    } finally {
      newState.lastRunMs = Date.now();
      newState.inFlight = null;
    }
  })();
  newState.inFlight = run;
  return run;
}

async function ensureAttachmentRootDir(workspaceDir: string): Promise<string> {
  const workspaceRoot = path.resolve(workspaceDir);
  const parallDir = path.join(workspaceRoot, '.parall');
  const rootDir = attachmentRootDir(workspaceRoot);

  await fs.mkdir(workspaceRoot, { recursive: true });
  await ensurePathIsNotSymlink(parallDir);
  await fs.mkdir(parallDir, { recursive: true, mode: 0o700 });
  await ensurePathIsNotSymlink(parallDir);
  await ensurePathIsNotSymlink(rootDir);
  await fs.mkdir(rootDir, { recursive: true, mode: 0o700 });
  await ensurePathIsNotSymlink(rootDir);

  const realWorkspace = await fs.realpath(workspaceRoot);
  const realRoot = await fs.realpath(rootDir);
  if (!isPathInside(realRoot, realWorkspace)) {
    throw new Error(`attachment root escapes workspace: ${rootDir}`);
  }
  return rootDir;
}

async function ensurePathIsNotSymlink(filePath: string): Promise<void> {
  try {
    const stat = await fs.lstat(filePath);
    if (stat.isSymbolicLink()) {
      throw new Error(`refusing to use symlinked attachment path ${filePath}`);
    }
  } catch (err) {
    if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return;
    throw err;
  }
}

function isPathInside(childPath: string, parentPath: string): boolean {
  const rel = path.relative(parentPath, childPath);
  return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
}

async function existingUsableFile(
  filePath: string,
  expectedSize: number,
  rootDir: string,
): Promise<boolean> {
  try {
    const stat = await localFileStatInsideRoot(filePath, rootDir);
    return stat.isFile() && stat.size > 0 && (expectedSize <= 0 || stat.size === expectedSize);
  } catch {
    return false;
  }
}

async function localFileStatInsideRoot(filePath: string, rootDir: string) {
  const stat = await fs.lstat(filePath);
  if (stat.isSymbolicLink()) {
    throw new Error(`refusing to use symlinked attachment file ${filePath}`);
  }
  if (!stat.isFile()) {
    throw new Error(`attachment path is not a file ${filePath}`);
  }

  const realRoot = await fs.realpath(rootDir);
  const realFile = await fs.realpath(filePath);
  if (!isPathInside(realFile, realRoot)) {
    throw new Error(`attachment file escapes workspace: ${filePath}`);
  }
  return stat;
}

async function localDirectoryStatInsideRoot(dirPath: string, rootDir: string) {
  const stat = await fs.lstat(dirPath);
  if (stat.isSymbolicLink()) {
    throw new Error(`refusing to use symlinked attachment directory ${dirPath}`);
  }
  if (!stat.isDirectory()) {
    throw new Error(`attachment path is not a directory ${dirPath}`);
  }

  const realRoot = await fs.realpath(rootDir);
  const realDir = await fs.realpath(dirPath);
  if (!isPathInside(realDir, realRoot)) {
    throw new Error(`attachment directory escapes workspace: ${dirPath}`);
  }
  return stat;
}

async function openLocalFileInsideRoot(filePath: string, rootDir: string) {
  const checkedStat = await localFileStatInsideRoot(filePath, rootDir);
  const file = await fs.open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
  let keepOpen = false;
  try {
    const openedStat = await file.stat();
    if (!sameFile(checkedStat, openedStat)) {
      throw new Error(`attachment file changed during validation: ${filePath}`);
    }
    keepOpen = true;
    return file;
  } finally {
    if (!keepOpen) {
      await file.close();
    }
  }
}

async function openLocalTempFileInsideRoot(filePath: string, rootDir: string) {
  // Trusted-workspace assumption: the workspace dir is owned by the runtime
  // bridge and not shared with untrusted local writers. There is a narrow
  // TOCTOU window between the parent-directory validation here and the
  // O_NOFOLLOW open; closing it would require descriptor-relative (openat)
  // operations, which Node's fs API does not expose. The post-open inode
  // identity check below + the post-write rename validation in
  // writeResponseToFileWithLimit ensure downloaded bytes can never escape
  // the attachment root, even if the window is exploited — the worst case
  // is a write that lands in a directory the attacker already controls.
  await localDirectoryStatInsideRoot(path.dirname(filePath), rootDir);
  const file = await fs.open(
    filePath,
    constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
    0o600,
  );
  let keepOpen = false;
  try {
    const checkedStat = await localFileStatInsideRoot(filePath, rootDir);
    const openedStat = await file.stat();
    if (!sameFileIdentity(checkedStat, openedStat)) {
      throw new Error(`attachment temp file changed during validation: ${filePath}`);
    }
    keepOpen = true;
    return { file, openedStat };
  } finally {
    if (!keepOpen) {
      await file.close();
    }
  }
}

async function assertLocalFileIdentity(
  filePath: string,
  rootDir: string,
  expected: Stats,
): Promise<void> {
  const stat = await localFileStatInsideRoot(filePath, rootDir);
  if (!sameFileIdentity(stat, expected)) {
    throw new Error(`attachment file changed during validation: ${filePath}`);
  }
}

async function removeLocalFileIfInside(filePath: string, rootDir: string): Promise<void> {
  try {
    await localFileStatInsideRoot(filePath, rootDir);
    await fs.rm(filePath, { force: true });
  } catch {
    // If the path no longer resolves inside the attachment root, do not follow it for cleanup.
  }
}

function sameFileIdentity(a: Stats, b: Stats): boolean {
  return a.dev === b.dev && a.ino === b.ino;
}

function sameFile(a: Stats, b: Stats): boolean {
  return sameFileIdentity(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs;
}

async function cleanupOldAttachmentFiles(
  rootDir: string,
  ttlMs: number,
  log?: GatewayLogger,
  preserveDirs?: ReadonlySet<string>,
): Promise<void> {
  let entries: Array<Dirent>;
  try {
    entries = await fs.readdir(rootDir, { withFileTypes: true });
  } catch {
    return;
  }

  const cutoff = Date.now() - ttlMs;
  await Promise.all(
    entries.map(async (entry) => {
      if (!entry.isDirectory()) return;
      const fullPath = path.join(rootDir, entry.name);
      try {
        if (preserveDirs?.has(path.resolve(fullPath))) return;
        // lstat (not stat) for consistency with the symlink-hardened download
        // path: if a racing local writer swapped the dir entry for a symlink
        // between readdir and now, we don't want fs.rm to follow it and delete
        // outside the attachment root.
        const stat = await fs.lstat(fullPath);
        if (!stat.isDirectory()) return;
        if (stat.mtimeMs < cutoff) {
          await fs.rm(fullPath, { recursive: true, force: true });
        }
      } catch (err) {
        log?.warn?.(`agent-core: failed to clean attachment temp dir ${fullPath}: ${String(err)}`);
      }
    }),
  );
}

async function pruneAttachmentCache(
  rootDir: string,
  maxBytes: number,
  log?: GatewayLogger,
  preserveDirs?: ReadonlySet<string>,
): Promise<void> {
  if (maxBytes <= 0) return;

  let entries: Array<Dirent>;
  try {
    entries = await fs.readdir(rootDir, { withFileTypes: true });
  } catch {
    return;
  }

  const dirs: Array<{ path: string; mtimeMs: number; size: number }> = [];
  let total = 0;
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    const fullPath = path.join(rootDir, entry.name);
    try {
      // lstat (not stat) for the same reason as cleanupOldAttachmentFiles —
      // never follow a swapped-in symlink during the readdir → rm window.
      const stat = await fs.lstat(fullPath);
      if (!stat.isDirectory()) continue;
      const size = await directorySize(fullPath);
      dirs.push({ path: fullPath, mtimeMs: stat.mtimeMs, size });
      total += size;
    } catch (err) {
      log?.warn?.(`agent-core: failed to inspect attachment cache dir ${fullPath}: ${String(err)}`);
    }
  }

  if (total <= maxBytes) return;

  dirs.sort((a, b) => a.mtimeMs - b.mtimeMs);
  for (const dir of dirs) {
    if (total <= maxBytes) break;
    if (preserveDirs?.has(path.resolve(dir.path))) continue;
    try {
      await fs.rm(dir.path, { recursive: true, force: true });
      total -= dir.size;
    } catch (err) {
      log?.warn?.(`agent-core: failed to prune attachment cache dir ${dir.path}: ${String(err)}`);
    }
  }
}

async function directorySize(dirPath: string): Promise<number> {
  let total = 0;
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = path.join(dirPath, entry.name);
    // lstat (not stat) for consistency with cleanup/prune — never follow a
    // racing-swap symlink during the recursive walk, even just to count
    // bytes. Skip anything that isn't a real file or real directory.
    let stat;
    try {
      stat = await fs.lstat(fullPath);
    } catch {
      continue;
    }
    if (stat.isDirectory()) {
      total += await directorySize(fullPath);
      continue;
    }
    if (!stat.isFile()) continue;
    total += stat.size;
  }
  return total;
}

function activeDirsForRoot(rootDir: string): ReadonlySet<string> {
  const root = path.resolve(rootDir);
  const dirs = new Set<string>();
  for (const dir of activeAttachmentDirs) {
    if (dir === root || dir.startsWith(`${root}${path.sep}`)) {
      dirs.add(dir);
    }
  }
  return dirs;
}

async function downloadAttachmentToFile(
  url: string,
  filePath: string,
  maxBytes: number,
  timeoutMs: number,
  rootDir: string,
): Promise<number> {
  // Defense-in-depth: the SDK currently always returns HTTPS signed URLs, but a
  // misconfigured API server (or a future regression) handing back `file://` /
  // `data:` would let `fetch` read local files or inline payloads as if they
  // were the attachment. Reject anything that isn't HTTP(S) before we open the
  // network call. http:// stays allowed for local dev / tests.
  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`invalid attachment URL`);
  }
  if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
    throw new Error(`refusing attachment URL with scheme ${parsed.protocol}`);
  }

  const controller = new AbortController();
  let timeout: NodeJS.Timeout | undefined;
  if (timeoutMs > 0) {
    timeout = setTimeout(() => controller.abort(), timeoutMs);
  }

  try {
    const res = await fetch(url, { signal: controller.signal });
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    // Content-Length is only an early-exit hint; the stream byte counter below is authoritative.
    const contentLength = parseContentLength(res.headers.get('content-length'));
    if (contentLength !== undefined && contentLength > maxBytes) {
      throw new Error(`download would exceed ${formatBytes(maxBytes)} remaining turn limit`);
    }
    return await writeResponseToFileWithLimit(res, filePath, maxBytes, rootDir);
  } catch (err) {
    if (isAbortError(err)) {
      throw new Error(`download timed out after ${timeoutMs}ms`);
    }
    throw err;
  } finally {
    if (timeout) clearTimeout(timeout);
  }
}

async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
  if (timeoutMs <= 0) return promise;
  let timeout: NodeJS.Timeout | undefined;
  try {
    return await Promise.race([
      promise,
      new Promise<never>((_, reject) => {
        timeout = setTimeout(() => reject(new Error(message)), timeoutMs);
      }),
    ]);
  } finally {
    if (timeout) clearTimeout(timeout);
  }
}

async function writeResponseToFileWithLimit(
  res: Response,
  filePath: string,
  maxBytes: number,
  rootDir: string,
): Promise<number> {
  if (maxBytes <= 0) {
    throw new Error('download would exceed turn limit');
  }

  const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
  const { file, openedStat } = await openLocalTempFileInsideRoot(tmpPath, rootDir);
  let written = 0;
  let completed = false;
  let fileClosed = false;
  let writtenStat = openedStat;

  const closeFile = async () => {
    if (fileClosed) return;
    await file.close();
    fileClosed = true;
  };

  try {
    if (!res.body) {
      const buffer = Buffer.from(await res.arrayBuffer());
      if (buffer.byteLength > maxBytes) {
        throw new Error(`download exceeded ${formatBytes(maxBytes)} remaining turn limit`);
      }
      await file.write(buffer);
      written = buffer.byteLength;
    } else {
      const reader = res.body.getReader();
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        const chunk = Buffer.from(value);
        written += chunk.byteLength;
        if (written > maxBytes) {
          throw new Error(`download exceeded ${formatBytes(maxBytes)} remaining turn limit`);
        }
        await file.write(chunk);
      }
    }
    writtenStat = await file.stat();
    await closeFile();
    await localDirectoryStatInsideRoot(path.dirname(filePath), rootDir);
    await assertLocalFileIdentity(tmpPath, rootDir, writtenStat);
    await fs.rename(tmpPath, filePath);
    completed = true;
    return written;
  } finally {
    if (!fileClosed) {
      try {
        await closeFile();
      } catch {
        // Preserve the original write/rename error; cleanup below is best-effort.
      }
    }
    if (!completed) {
      await removeLocalFileIfInside(tmpPath, rootDir);
    }
  }
}

function localFileName(attachmentId: string, fileName: string, mimeType: string): string {
  const safeName = sanitizePathSegment(path.basename(fileName || attachmentId));
  const ext = path.extname(safeName) || extensionForMime(mimeType);
  const stem = path.basename(safeName, path.extname(safeName)) || attachmentId;
  return `${sanitizePathSegment(attachmentId)}-${stem}${ext}`;
}

function extensionForMime(mimeType: string): string {
  switch (mimeType.toLowerCase()) {
    case 'image/jpeg':
    case 'image/jpg':
      return '.jpg';
    case 'image/webp':
      return '.webp';
    case 'image/gif':
      return '.gif';
    case 'image/png':
    default:
      return '.png';
  }
}

function sanitizePathSegment(value: string): string {
  const safe = value.replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^_+|_+$/g, '');
  if (!safe || /^\.+$/.test(safe)) return 'attachment';
  return safe;
}

async function downloadedFileMatchesImageMime(
  filePath: string,
  mimeType: string,
  rootDir: string,
): Promise<boolean> {
  const file = await openLocalFileInsideRoot(filePath, rootDir);
  try {
    const buffer = Buffer.alloc(16);
    const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
    return imageHeaderMatches(buffer.subarray(0, bytesRead), mimeType);
  } finally {
    await file.close();
  }
}

function imageHeaderMatches(bytes: Buffer, mimeType: string): boolean {
  switch (mimeType.toLowerCase()) {
    case 'image/png':
      return (
        bytes.length >= 8 &&
        bytes[0] === 0x89 &&
        bytes[1] === 0x50 &&
        bytes[2] === 0x4e &&
        bytes[3] === 0x47 &&
        bytes[4] === 0x0d &&
        bytes[5] === 0x0a &&
        bytes[6] === 0x1a &&
        bytes[7] === 0x0a
      );
    case 'image/jpeg':
    case 'image/jpg':
      return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
    case 'image/gif':
      return (
        bytes.length >= 6 &&
        (bytes.subarray(0, 6).toString('ascii') === 'GIF87a' ||
          bytes.subarray(0, 6).toString('ascii') === 'GIF89a')
      );
    case 'image/webp':
      return (
        bytes.length >= 12 &&
        bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
        bytes.subarray(8, 12).toString('ascii') === 'WEBP'
      );
    default:
      return false;
  }
}

function isAbortError(err: unknown): boolean {
  return err instanceof DOMException && err.name === 'AbortError';
}

function sanitizeMeta(value: string): string {
  return value
    .replace(/[\r\n]+/g, ' ')
    .replace(/[[\]|]/g, ' ')
    .trim();
}

function parseContentLength(value: string | null): number | undefined {
  if (!value) return undefined;
  const n = Number(value);
  if (!Number.isFinite(n) || n < 0) return undefined;
  return n;
}
