import {
  sanitizeFilename,
  sanitizeArtifactPath,
  flattenArtifactPath,
  resolveUploadErrorMessage,
} from './files';

jest.mock('node:crypto', () => {
  const actualModule = jest.requireActual('node:crypto');
  return {
    ...actualModule,
    randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')),
  };
});

/* `sanitizeArtifactPath` and `flattenArtifactPath` use `crypto.createHash`
 * (real, not mocked) to produce a deterministic 6-hex disambiguator from
 * the input. Tests compute the expected hash inline so we can compare
 * exact strings without re-mocking. */
function expectedHexSuffix(input: string): string {
  const { createHash } = jest.requireActual('node:crypto');
  return createHash('sha256').update(input).digest('hex').slice(0, 6);
}

function utf8ByteLength(input: string): number {
  return Buffer.byteLength(input, 'utf8');
}

describe('sanitizeFilename', () => {
  test('removes directory components (1/2)', () => {
    expect(sanitizeFilename('/path/to/file.txt')).toBe('file.txt');
  });

  test('removes directory components (2/2)', () => {
    expect(sanitizeFilename('../../../../file.txt')).toBe('file.txt');
  });

  test('replaces non-alphanumeric characters', () => {
    expect(sanitizeFilename('file name@#$.txt')).toBe('file_name___.txt');
  });

  test('preserves Unicode filenames', () => {
    expect(sanitizeFilename('日本語レポート.xlsx')).toBe('日本語レポート.xlsx');
    expect(sanitizeFilename('résumé-данные-تقرير-보고서📊.csv')).toBe(
      'résumé-данные-تقرير-보고서📊.csv',
    );
  });

  test('normalizes decomposed Unicode marks before sanitizing', () => {
    expect(sanitizeFilename('Cafe\u0301.txt')).toBe('Café.txt');
  });

  test('preserves dots and hyphens', () => {
    expect(sanitizeFilename('file-name.with.dots.txt')).toBe('file-name.with.dots.txt');
  });

  test('prepends underscore to filenames starting with a dot', () => {
    expect(sanitizeFilename('.hiddenfile')).toBe('_.hiddenfile');
  });

  test('truncates long filenames', () => {
    const longName = 'a'.repeat(300) + '.txt';
    const result = sanitizeFilename(longName);
    expect(result.length).toBe(255);
    expect(result).toMatch(/^a+-abc123\.txt$/);
  });

  test('truncates Unicode filenames by UTF-8 bytes, preserving the extension', () => {
    const longName = '界'.repeat(100) + '.txt';
    const result = sanitizeFilename(longName);
    expect(utf8ByteLength(result)).toBeLessThanOrEqual(255);
    expect(result.endsWith('-abc123.txt')).toBe(true);
  });

  test('handles filenames with no extension', () => {
    const longName = 'a'.repeat(300);
    const result = sanitizeFilename(longName);
    expect(result.length).toBe(255);
    expect(result).toMatch(/^a+-abc123$/);
  });

  test('handles empty input', () => {
    expect(sanitizeFilename('')).toBe('_');
  });

  test('handles input with only special characters', () => {
    expect(sanitizeFilename('@#$%^&*')).toBe('_______');
  });
});

describe('sanitizeArtifactPath', () => {
  test('preserves a single nested directory component', () => {
    expect(sanitizeArtifactPath('test_folder/test_file.txt')).toBe('test_folder/test_file.txt');
  });

  test('preserves multiple nested directory components', () => {
    expect(sanitizeArtifactPath('a/b/c/file.txt')).toBe('a/b/c/file.txt');
  });

  test('preserves Unicode path segments', () => {
    expect(sanitizeArtifactPath('分析/結果📊.csv')).toBe('分析/結果📊.csv');
  });

  test('replaces non-alphanumeric characters per segment + adds raw-input disambiguator', () => {
    /* Different raw inputs that sanitize to the same form (`out 1.csv`
     * vs `out_1.csv`, `out@1.csv` vs `out#1.csv`) would otherwise share
     * a `claimCodeFile` compound key and silently overwrite each other.
     * When character-level sanitization changed something, we embed a
     * deterministic SHA-256 prefix of the raw input in the leaf so
     * different raw inputs produce different safe forms. Same raw
     * input is still idempotent. */
    const raw = 'proj name/file@v1.txt';
    expect(sanitizeArtifactPath(raw)).toBe(`proj_name/file_v1-${expectedHexSuffix(raw)}.txt`);
  });

  test('falls back to basename for parent traversal', () => {
    expect(sanitizeArtifactPath('../escape.txt')).toBe('escape.txt');
  });

  test('resolves embedded parent traversal via path normalization (with disambiguator)', () => {
    /* `path.posix.normalize('a/../escape.txt')` collapses to `escape.txt`,
     * which then passes the traversal guard and goes through the normal
     * segment-split path. Because normalization mutated the input
     * (raw had `a/../`, safe form doesn't), the disambiguator fires —
     * raw `a/../escape.txt` and raw `escape.txt` would otherwise both
     * resolve to `escape.txt` and collide on `claimCodeFile`. */
    const raw = 'a/../escape.txt';
    expect(sanitizeArtifactPath(raw)).toBe(`escape-${expectedHexSuffix(raw)}.txt`);
  });

  test('falls back to basename for absolute paths', () => {
    expect(sanitizeArtifactPath('/abs/path.txt')).toBe('path.txt');
  });

  test('collapses redundant separators (with disambiguator)', () => {
    /* `dir//file.txt` and `dir/file.txt` would both resolve to
     * `dir/file.txt` without disambiguation — collision on
     * `claimCodeFile`. The empty-segment collapse counts as
     * mutation, so the disambiguator fires for the doubled-slash
     * variant and the clean one passes through unchanged. */
    const raw = 'dir//file.txt';
    expect(sanitizeArtifactPath(raw)).toBe(`dir/file-${expectedHexSuffix(raw)}.txt`);
  });

  test('strips current-directory segments (with disambiguator)', () => {
    const raw = './dir/./file.txt';
    expect(sanitizeArtifactPath(raw)).toBe(`dir/file-${expectedHexSuffix(raw)}.txt`);
  });

  test('prepends underscore on dotfile leaf and disambiguates against literal _.x', () => {
    /* `.hidden` and `_.hidden` both resolve to `_.hidden` without
     * disambiguation. The dotfile-prefix step counts as mutation, so
     * `.hidden` → `_.hidden-<hash>` and the literal `_.hidden` passes
     * through. The disambiguator appends at the end (rather than
     * splitting `_.hidden` into stem + extension) because `dot <= 1`. */
    const raw = 'dir/.hidden';
    expect(sanitizeArtifactPath(raw)).toBe(`dir/_.hidden-${expectedHexSuffix(raw)}`);
  });

  test('returns underscore for empty input', () => {
    expect(sanitizeArtifactPath('')).toBe('_');
  });

  test('caps the leaf segment at 255 chars with extension-preserving truncation', () => {
    /* Regression for the unbounded-leaf path: without the per-segment
     * cap, a 300-char artifact name would flow through to saveBuffer's
     * storage key (`${file_id}__${flatName}`) and trip ENAMETOOLONG on
     * filesystems that enforce NAME_MAX. */
    const longName = 'a'.repeat(300) + '.txt';
    const result = sanitizeArtifactPath(longName);
    expect(result.length).toBe(255);
    expect(result).toMatch(new RegExp(`^a+-${expectedHexSuffix(longName)}\\.txt$`));
  });

  test('caps Unicode leaf segments at 255 UTF-8 bytes with extension-preserving truncation', () => {
    const longName = '界'.repeat(100) + '.txt';
    const result = sanitizeArtifactPath(longName);
    expect(utf8ByteLength(result)).toBeLessThanOrEqual(255);
    expect(result.endsWith(`-${expectedHexSuffix(longName)}.txt`)).toBe(true);
  });

  test('caps the leaf when nested under a directory, preserving the directory verbatim', () => {
    const longLeaf = 'b'.repeat(300) + '.csv';
    const result = sanitizeArtifactPath(`reports/${longLeaf}`);
    const [dir, leaf] = result.split('/');
    expect(dir).toBe('reports');
    expect(leaf.length).toBe(255);
    expect(leaf).toMatch(new RegExp(`^b+-${expectedHexSuffix(longLeaf)}\\.csv$`));
  });

  test('caps non-leaf directory segments at 255 chars', () => {
    /* Directory components hit `NAME_MAX` independently of the leaf —
     * each `mkdir` along the path has to satisfy the per-component limit
     * regardless of the basename truncation. */
    const longDir = 'd'.repeat(300);
    const result = sanitizeArtifactPath(`${longDir}/notes.txt`);
    const [dir, leaf] = result.split('/');
    expect(dir.length).toBe(255);
    expect(dir).toMatch(new RegExp(`^d+-${expectedHexSuffix(longDir)}$`));
    expect(leaf).toBe('notes.txt');
  });

  test('caps Unicode non-leaf directory segments at 255 UTF-8 bytes', () => {
    const longDir = '界'.repeat(100);
    const result = sanitizeArtifactPath(`${longDir}/notes.txt`);
    const [dir, leaf] = result.split('/');
    expect(utf8ByteLength(dir)).toBeLessThanOrEqual(255);
    expect(dir.endsWith(`-${expectedHexSuffix(longDir)}`)).toBe(true);
    expect(leaf).toBe('notes.txt');
  });

  test('produces deterministic output across calls (no orphaned uploads on re-truncation)', () => {
    /* Codex review P2: `sanitizeFilename`'s `crypto.randomBytes(3)` made
     * the truncated form non-deterministic — re-uploading the same long
     * name would compute a different storage key, orphaning the previous
     * on-disk file under the reused `file_id`. The new helpers hash the
     * input so the same input always produces the same output. */
    const longName = 'a'.repeat(300) + '.txt';
    const a = sanitizeArtifactPath(longName);
    const b = sanitizeArtifactPath(longName);
    expect(a).toBe(b);
    /* Two *different* long names that share a truncation prefix must
     * still produce different outputs (collision avoidance). */
    const otherName = 'a'.repeat(299) + 'X.txt';
    expect(sanitizeArtifactPath(otherName)).not.toBe(a);
  });

  test('caps every segment in a nested path with mixed lengths', () => {
    /* Every segment respects the cap — the truncate is per-component,
     * not on the join. Use 2 segments here (rather than 3+) so the
     * joined form stays under `ARTIFACT_PATH_TOTAL_MAX` (512); the
     * total-cap fallback gets its own test below. */
    const segA = 'x'.repeat(260);
    const leaf = 'z'.repeat(260) + '.json';
    const result = sanitizeArtifactPath(`${segA}/${leaf}`);
    const parts = result.split('/');
    expect(parts).toHaveLength(2);
    expect(parts[0].length).toBe(255);
    expect(parts[1].length).toBe(255);
    expect(parts[1]).toMatch(/\.json$/);
  });

  test('does not truncate filenames that are exactly at the 255-char limit', () => {
    /* Off-by-one guard: 255 itself is allowed (filesystem boundary), 256
     * is not. */
    const exact = 'e'.repeat(251) + '.txt'; // 255 chars total
    expect(sanitizeArtifactPath(exact)).toBe(exact);
    expect(sanitizeArtifactPath(`dir/${exact}`)).toBe(`dir/${exact}`);
  });

  test('falls back to leaf-only when total path length exceeds the DB-index cap (Codex review P2)', () => {
    /* MongoDB 4.0 rejects indexed values past 1024 bytes, and even on
     * 4.2+ where the limit is configurable, runaway nested paths bloat
     * the unique compound index on (file_id, filename, conversationId,
     * context). At 3+ at-cap (255-char) segments + separators the joined
     * form blows past the safety budget; the helper falls back to leaf-
     * only (already segment-capped to ≤ 255). */
    const segA = 'x'.repeat(255);
    const segB = 'y'.repeat(255);
    const segC = 'z'.repeat(255);
    const result = sanitizeArtifactPath(`${segA}/${segB}/${segC}/file.txt`);
    /* Joined would be ~768 chars + slashes; well past the 512 cap. */
    expect(result).toBe('file.txt');
  });

  test('falls back to leaf-only when Unicode path bytes exceed the DB-index cap', () => {
    const segA = '界'.repeat(80);
    const segB = '分'.repeat(80);
    const segC = '析'.repeat(80);
    const result = sanitizeArtifactPath(`${segA}/${segB}/${segC}/file.txt`);
    expect(result).toBe('file.txt');
  });

  test('keeps the nested path when total length is within the DB-index cap', () => {
    /* The cap doesn't fire for realistic outputs — typical artifact
     * depth is ≤ 3 segments × short names. */
    const result = sanitizeArtifactPath('reports/2026/q1.csv');
    expect(result).toBe('reports/2026/q1.csv');
  });

  describe('collision avoidance (Codex review P2)', () => {
    /* `sanitizeArtifactPath` is not injective — multiple raw inputs can
     * collapse to the same regex-and-normalize output. `claimCodeFile`
     * is keyed on the schema's compound unique
     * `(filename, conversationId, context, tenantId)` index, so a
     * collision would silently overwrite an earlier artifact's bytes
     * via a reused `file_id`. The disambiguator branch embeds a
     * SHA-256 prefix of the raw input in the leaf to keep different
     * raw inputs distinct. */
    test('different raw inputs that sanitize to the same form get distinct safe forms', () => {
      const a = sanitizeArtifactPath('out 1.csv');
      const b = sanitizeArtifactPath('out_1.csv');
      const c = sanitizeArtifactPath('out@1.csv');
      const d = sanitizeArtifactPath('out#1.csv');
      /* Pre-fix all four collapsed to `out_1.csv`. Post-fix only the
       * already-clean `out_1.csv` keeps that form; the others get
       * disambiguators based on their raw inputs. */
      expect(b).toBe('out_1.csv');
      expect(a).not.toBe(b);
      expect(c).not.toBe(b);
      expect(d).not.toBe(b);
      expect(a).not.toBe(c);
      expect(c).not.toBe(d);
    });

    test('whitespace-vs-underscore collision in directory segment', () => {
      /* Codex's specific example: `reports 2026/out.csv` and
       * `reports_2026/out.csv` would both have safeName
       * `reports_2026/out.csv` without disambiguation. */
      const a = sanitizeArtifactPath('reports 2026/out.csv');
      const b = sanitizeArtifactPath('reports_2026/out.csv');
      expect(b).toBe('reports_2026/out.csv');
      expect(a).not.toBe(b);
      /* The disambiguator is on the LEAF (not the mutated dir
       * segment) so the layout matches normal path-preserving
       * outputs. */
      expect(a.startsWith('reports_2026/out-')).toBe(true);
      expect(a.endsWith('.csv')).toBe(true);
    });

    test('dotfile prefix collision: `.x` vs `_.x`', () => {
      const a = sanitizeArtifactPath('.hidden');
      const b = sanitizeArtifactPath('_.hidden');
      expect(b).toBe('_.hidden');
      expect(a).not.toBe(b);
      expect(a).toBe(`_.hidden-${expectedHexSuffix('.hidden')}`);
    });

    test('normalization-only collisions get distinct safe forms', () => {
      const composed = 'reports/Café.csv';
      const decomposed = 'reports/Cafe\u0301.csv';
      const normalizedDecomposed = `reports/Café-${expectedHexSuffix(decomposed)}.csv`;

      expect(sanitizeArtifactPath(composed)).toBe(composed);
      expect(sanitizeArtifactPath(decomposed)).toBe(normalizedDecomposed);
      expect(normalizedDecomposed).not.toBe(composed);
    });

    test('idempotent: same raw input always produces the same safe form', () => {
      /* Disambiguator is deterministic (SHA-256 prefix of raw input),
       * so re-uploading the same long-or-mutated name lands at the
       * same storage key on every call. */
      const raw = 'proj name/data file.csv';
      expect(sanitizeArtifactPath(raw)).toBe(sanitizeArtifactPath(raw));
    });

    test('clean inputs (no mutation) skip the disambiguator', () => {
      /* Cosmetic: don't clutter human-readable filenames with a hash
       * when no collision is possible. The check is on the
       * post-regex form vs the raw input — when they match exactly,
       * the disambiguator branch doesn't fire. */
      expect(sanitizeArtifactPath('reports/2026/q1.csv')).toBe('reports/2026/q1.csv');
      expect(sanitizeArtifactPath('plain.txt')).toBe('plain.txt');
    });

    test('disambiguator survives leaf truncation (long mutated leaf)', () => {
      /* Long input + mutation: truncateLeafSegment caps at 255 first,
       * then embedDisambiguatorInLeaf re-trims to insert the input
       * hash. The seg-hash from the first truncation is replaced by
       * the input-hash from the second pass — that's intentional:
       * input-hash is the load-bearing collision-avoidance suffix. */
      const raw = 'data file ' + 'a'.repeat(280) + '.csv';
      const result = sanitizeArtifactPath(raw);
      expect(result.length).toBe(255);
      expect(result.endsWith(`-${expectedHexSuffix(raw)}.csv`)).toBe(true);
    });

    test('disambiguator survives total-cap fallback', () => {
      /* When joined > ARTIFACT_PATH_TOTAL_MAX, we fall back to the
       * leaf-only form. The leaf has already had the disambiguator
       * embedded, so the fallback preserves collision avoidance for
       * the pathological-depth case too. */
      const raw =
        'a b'.repeat(100) + '/' + 'c d'.repeat(100) + '/' + 'e f'.repeat(100) + '/file.csv';
      const result = sanitizeArtifactPath(raw);
      /* Result is leaf-only (no slashes). */
      expect(result.includes('/')).toBe(false);
      /* And carries the disambiguator. */
      expect(result.endsWith(`-${expectedHexSuffix(raw)}.csv`)).toBe(true);
    });
  });

  test('preserves the dotfile underscore prefix when the leaf also needs truncation', () => {
    /* A long hidden-file leaf (`._very_long_name`) goes through the
     * underscore-prefix branch first; truncation must run AFTER that
     * rewrite or the leaf would still leak past the cap. */
    const longHidden = '.' + 'a'.repeat(300);
    const result = sanitizeArtifactPath(`dir/${longHidden}`);
    const [dir, leaf] = result.split('/');
    expect(dir).toBe('dir');
    expect(leaf.length).toBe(255);
    expect(leaf.startsWith('_.')).toBe(true);
  });
});

describe('flattenArtifactPath', () => {
  test('joins path components with __ for storage keys', () => {
    expect(flattenArtifactPath('a/b/c.txt')).toBe('a__b__c.txt');
  });

  test('passes through paths with no separator', () => {
    expect(flattenArtifactPath('file.txt')).toBe('file.txt');
  });

  test('handles single-level nesting', () => {
    expect(flattenArtifactPath('test_folder/test_file.txt')).toBe('test_folder__test_file.txt');
  });

  test('passes through unchanged when no maxLength is supplied', () => {
    /* The cap is opt-in — callers that aren't building filesystem keys
     * (e.g. tests, log messages) shouldn't get truncation. */
    const longPath = 'a'.repeat(200) + '/' + 'b'.repeat(200) + '.txt';
    expect(flattenArtifactPath(longPath)).toBe(longPath.replace(/\//g, '__'));
  });

  test('passes through unchanged when flat form fits within maxLength', () => {
    expect(flattenArtifactPath('short/file.txt', 100)).toBe('short__file.txt');
    expect(flattenArtifactPath('short/file.txt', 15)).toBe('short__file.txt');
  });

  test('truncates flat form when it exceeds maxLength, preserving leaf extension', () => {
    /* Regression for the deep-nesting flat-key overflow path: three
     * 100-char segments → 308-char flat form that would blow past
     * NAME_MAX=255 once `${file_id}__` is prepended. The truncation
     * keeps the .ext on the leaf so download MIME inference still works. */
    const a = 'a'.repeat(100);
    const b = 'b'.repeat(100);
    const safePath = `${a}/${b}/c.txt`;
    const result = flattenArtifactPath(safePath, 200);
    expect(result.length).toBe(200);
    expect(result.endsWith('.txt')).toBe(true);
    expect(result).toMatch(new RegExp(`-${expectedHexSuffix(safePath)}\\.txt$`));
  });

  test('truncates Unicode flat forms by UTF-8 bytes', () => {
    const safePath = `${'界'.repeat(80)}/結果.csv`;
    const result = flattenArtifactPath(safePath, 100);
    expect(utf8ByteLength(result)).toBeLessThanOrEqual(100);
    expect(result.endsWith('.csv')).toBe(true);
    expect(result).toMatch(new RegExp(`-${expectedHexSuffix(safePath)}\\.csv$`));
  });

  test('preserves the extension even when only the leaf overflows', () => {
    const longLeaf = 'L'.repeat(300);
    const result = flattenArtifactPath(`${longLeaf}.json`, 200);
    expect(result.length).toBe(200);
    expect(result.endsWith('.json')).toBe(true);
  });

  test('falls back to whole-key truncation (no extension preservation) when ext is pathologically long', () => {
    /* `path.extname`-style logic: a single dot followed by 100 chars is
     * not a real extension. Keep the cap honored even if a contrived
     * input would yield a "stem budget" of zero or negative. */
    const result = flattenArtifactPath('stem.' + 'x'.repeat(100), 50);
    expect(result.length).toBe(50);
  });

  test('handles paths with no extension by truncating the stem', () => {
    const longName = 'n'.repeat(300);
    const result = flattenArtifactPath(longName, 50);
    expect(result.length).toBe(50);
    expect(result).toMatch(new RegExp(`-${expectedHexSuffix(longName)}$`));
  });

  test('matches the boundary length exactly when input is right at the cap', () => {
    /* Off-by-one guard: maxLength of N should allow flat forms of N. */
    const flat = 'a'.repeat(50);
    expect(flattenArtifactPath(flat, 50)).toBe(flat);
    expect(flattenArtifactPath(flat, 49).length).toBe(49);
  });

  test('produces deterministic output across calls (no orphaned uploads on re-flatten)', () => {
    /* Codex review P2: re-flattening the same input must produce the
     * same key so re-uploads land at the same storage location. The
     * hash-based suffix replaces the previous random one. */
    const longPath = 'a'.repeat(100) + '/b.csv';
    const a = flattenArtifactPath(longPath, 50);
    const b = flattenArtifactPath(longPath, 50);
    expect(a).toBe(b);
    /* And different inputs that share a truncation prefix still produce
     * different outputs (collision avoidance). */
    const otherPath = 'a'.repeat(100) + '/c.csv';
    expect(flattenArtifactPath(otherPath, 50)).not.toBe(a);
  });

  test('clamps the result to maxLength even when ext.length > maxLength - 7 (Codex review P2)', () => {
    /* Pathological maxLength: 5, ext: ".txt" (4 chars). Stem budget is
     * Math.max(0, 5 - 4 - 7) = 0, so the formula yields
     * `'' + '-' + 6-char-hash + '.txt'` = 11 chars — past maxLength.
     * The final clamp guarantees the result is ≤ maxLength regardless. */
    const result = flattenArtifactPath('foo/bar.txt', 5);
    expect(result.length).toBeLessThanOrEqual(5);
  });

  test('returns empty string when maxLength <= 0', () => {
    /* Edge case: a negative or zero budget can't fit any output. Don't
     * attempt to construct a key; let the caller handle the empty case
     * (in practice this never fires — `process.js` passes
     * `255 - file_id.length - 2`, and file_id is bounded). */
    expect(flattenArtifactPath('a/b.txt', 0)).toBe('');
    expect(flattenArtifactPath('a/b.txt', -5)).toBe('');
  });
});

describe('resolveUploadErrorMessage', () => {
  test('returns default message for null error', () => {
    expect(resolveUploadErrorMessage(null)).toBe('Error processing file');
  });

  test('returns default message for undefined error', () => {
    expect(resolveUploadErrorMessage(undefined)).toBe('Error processing file');
  });

  test('returns default message when error has no message property', () => {
    expect(resolveUploadErrorMessage({})).toBe('Error processing file');
  });

  test('returns default message for unrecognized error', () => {
    expect(resolveUploadErrorMessage({ message: 'ENOENT: no such file or directory' })).toBe(
      'Error processing file',
    );
  });

  test('prepends default message for file_ids errors', () => {
    expect(resolveUploadErrorMessage({ message: 'max file_ids reached' })).toBe(
      'Error processing file: max file_ids reached',
    );
  });

  test('surfaces "Invalid file format" errors', () => {
    expect(resolveUploadErrorMessage({ message: 'Invalid file format: .xyz' })).toBe(
      'Invalid file format: .xyz',
    );
  });

  test('surfaces "exceeds token limit" errors', () => {
    expect(resolveUploadErrorMessage({ message: 'Content exceeds token limit' })).toBe(
      'Content exceeds token limit',
    );
  });

  test('surfaces "Unable to extract text from" errors', () => {
    const msg = 'Unable to extract text from "doc.pdf". The document may be image-based.';
    expect(resolveUploadErrorMessage({ message: msg })).toBe(msg);
  });

  test('accepts a custom default message', () => {
    expect(resolveUploadErrorMessage(null, 'Custom default')).toBe('Custom default');
  });

  test('uses custom default in file_ids prepend', () => {
    expect(resolveUploadErrorMessage({ message: 'file_ids limit' }, 'Upload failed')).toBe(
      'Upload failed: file_ids limit',
    );
  });
});

describe('sanitizeFilename with real crypto', () => {
  // Temporarily unmock crypto for these tests
  beforeAll(() => {
    jest.resetModules();
    jest.unmock('node:crypto');
  });

  afterAll(() => {
    jest.resetModules();
    jest.mock('node:crypto', () => {
      const actualModule = jest.requireActual('node:crypto');
      return {
        ...actualModule,
        randomBytes: jest.fn().mockReturnValue(Buffer.from('abc123', 'hex')),
      };
    });
  });

  test('truncates long filenames with real crypto', async () => {
    const { sanitizeFilename: realSanitizeFilename } = await import('./files');
    const longName = 'b'.repeat(300) + '.pdf';
    const result = realSanitizeFilename(longName);

    expect(result.length).toBe(255);
    expect(result).toMatch(/^b+-[a-f0-9]{6}\.pdf$/);
    expect(result.endsWith('.pdf')).toBe(true);
  });

  test('handles filenames with no extension with real crypto', async () => {
    const { sanitizeFilename: realSanitizeFilename } = await import('./files');
    const longName = 'c'.repeat(300);
    const result = realSanitizeFilename(longName);

    expect(result.length).toBe(255);
    expect(result).toMatch(/^c+-[a-f0-9]{6}$/);
    expect(result).not.toContain('.');
  });

  test('generates unique suffixes for identical long filenames', async () => {
    const { sanitizeFilename: realSanitizeFilename } = await import('./files');
    const longName = 'd'.repeat(300) + '.doc';
    const result1 = realSanitizeFilename(longName);
    const result2 = realSanitizeFilename(longName);

    expect(result1.length).toBe(255);
    expect(result2.length).toBe(255);
    expect(result1).not.toBe(result2); // Should be different due to random suffix
    expect(result1.endsWith('.doc')).toBe(true);
    expect(result2.endsWith('.doc')).toBe(true);
  });

  test('real crypto produces valid hex strings', async () => {
    const { sanitizeFilename: realSanitizeFilename } = await import('./files');
    const longName = 'test'.repeat(100) + '.txt';
    const result = realSanitizeFilename(longName);

    const hexMatch = result.match(/-([a-f0-9]{6})\.txt$/);
    expect(hexMatch).toBeTruthy();
    expect(hexMatch![1]).toMatch(/^[a-f0-9]{6}$/);
  });
});
