/**
 * P1 Integration Tests: CSV Pipeline
 *
 * Tests: streamAllCSVsToDisk with real graph data.
 * Covers hardening fixes: LRU cache (#24), BufferedCSVWriter flush
 */
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import { readdirSync } from 'node:fs';
import { finished } from 'stream/promises';
import path from 'path';
import { constants as bufferConstants } from 'node:buffer';
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
import { buildTestGraph, type TestNodeInput, type TestRelInput } from '../helpers/test-graph.js';
import {
  streamAllCSVsToDisk,
  buildRelRow,
  REL_CSV_HEADER,
  shouldFlushCSVBuffer,
  FLUSH_BYTES,
} from '../../src/core/lbug/csv-generator.js';
import { splitRelCsvByLabelPair } from '../../src/core/lbug/lbug-adapter.js';
import { getNodeLabel } from '../../src/core/lbug/rel-pair-routing.js';
import { NODE_TABLES } from '../../src/core/lbug/schema.js';
import { TREE_SITTER_MAX_BUFFER } from '../../src/core/ingestion/constants.js';
import { CJK_BIGRAM_WORST_CASE_GROWTH_FACTOR } from '../../src/core/search/cjk-segmentation.js';

let tmpHandle: TestDBHandle;
let csvDir: string;
let repoDir: string;

/** Data rows (header dropped) of one CSV file's text. */
const dataRowsOf = (csv: string): string[] =>
  csv
    .trim()
    .split('\n')
    .slice(1)
    .filter((l) => l.length > 0);

/** Concatenate data rows from every per-pair rel file (#2203 U2), pair keys
 * sorted so the concatenation order is deterministic regardless of map order. */
const readAllRelRows = async (
  relsByPair: Map<string, { csvPath: string; rows: number }>,
): Promise<string[]> => {
  const rows: string[] = [];
  for (const key of [...relsByPair.keys()].sort()) {
    rows.push(...dataRowsOf(await fs.readFile(relsByPair.get(key)!.csvPath, 'utf-8')));
  }
  return rows;
};

beforeAll(async () => {
  tmpHandle = await createTempDir('csv-pipeline-test-');
  csvDir = path.join(tmpHandle.dbPath, 'csv');
  repoDir = path.join(tmpHandle.dbPath, 'repo');

  // Create a fake repo directory with source files
  await fs.mkdir(path.join(repoDir, 'src'), { recursive: true });
  await fs.writeFile(
    path.join(repoDir, 'src', 'index.ts'),
    'export function main() {\n  console.log("hello");\n  helper();\n}\n\nexport class App {\n  run() {}\n}\n',
  );
  await fs.writeFile(
    path.join(repoDir, 'src', 'utils.ts'),
    'export function helper() {\n  return 42;\n}\n',
  );
});

afterAll(async () => {
  try {
    await tmpHandle.cleanup();
  } catch {
    /* best-effort */
  }
});

describe('streamAllCSVsToDisk', () => {
  it('generates CSV files for all node types in the graph', async () => {
    const graph = buildTestGraph(
      [
        { id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
        { id: 'file:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
        {
          id: 'func:main',
          label: 'Function',
          name: 'main',
          filePath: 'src/index.ts',
          startLine: 1,
          endLine: 4,
          isExported: true,
        },
        {
          id: 'func:helper',
          label: 'Function',
          name: 'helper',
          filePath: 'src/utils.ts',
          startLine: 1,
          endLine: 3,
          isExported: true,
        },
        {
          id: 'class:App',
          label: 'Class',
          name: 'App',
          filePath: 'src/index.ts',
          startLine: 6,
          endLine: 8,
          isExported: true,
        },
        { id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
      ],
      [
        { sourceId: 'Function:main', targetId: 'Function:helper', type: 'CALLS' },
        { sourceId: 'File:src/index.ts', targetId: 'Function:main', type: 'CONTAINS' },
        { sourceId: 'File:src/utils.ts', targetId: 'Function:helper', type: 'CONTAINS' },
      ],
    );

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);

    // Check that CSV files were created
    expect(result.nodeFiles.size).toBeGreaterThan(0);
    expect(result.totalValidRels).toBe(3);
    expect(result.skippedRels).toBe(0);

    // Verify File CSV
    const fileCsv = result.nodeFiles.get('File');
    expect(fileCsv).toBeDefined();
    expect(fileCsv!.rows).toBe(2);

    // Verify Function CSV
    const funcCsv = result.nodeFiles.get('Function');
    expect(funcCsv).toBeDefined();
    expect(funcCsv!.rows).toBe(2);

    // Verify Class CSV
    const classCsv = result.nodeFiles.get('Class');
    expect(classCsv).toBeDefined();
    expect(classCsv!.rows).toBe(1);

    // Verify Folder CSV
    const folderCsv = result.nodeFiles.get('Folder');
    expect(folderCsv).toBeDefined();
    expect(folderCsv!.rows).toBe(1);

    // Relationships are routed to per-FROM→TO-label-pair files (#2203 U2):
    // Function→Function (CALLS) + File→Function (2× CONTAINS).
    expect(result.relsByPair.has('Function|Function')).toBe(true);
    expect(result.relsByPair.has('File|Function')).toBe(true);
    expect(result.relsByPair.get('File|Function')!.rows).toBe(2);
    expect(await readAllRelRows(result.relsByPair)).toHaveLength(3);
  });

  it('CSV content is properly escaped', async () => {
    const graph = buildTestGraph([
      {
        id: 'file:src/index.ts',
        label: 'File',
        name: 'index.ts',
        filePath: 'src/index.ts',
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const fileCsv = result.nodeFiles.get('File');
    expect(fileCsv).toBeDefined();

    const content = await fs.readFile(fileCsv!.csvPath, 'utf-8');
    // Content should be properly quoted
    expect(content).toContain('"file:src/index.ts"');
    expect(content).toContain('"index.ts"');
  });

  it('stores exact symbol content, pinned against a ±1 boundary shift', async () => {
    // Neighbors sit DIRECTLY adjacent to the [2,4] span (no blank buffer), so a
    // one-line slice shift at either edge — the #2379 COBOL/JCL failure mode —
    // pulls a guard line into the snippet and fails an assertion.
    await fs.writeFile(
      path.join(repoDir, 'src', 'symbol-window.ts'),
      [
        'const guardTop = 0;',
        'const before = 1;',
        'export function target() {',
        '  return before;',
        '}',
        'const after = 2;',
        'const guardBottom = 3;',
      ].join('\n'),
    );
    const graph = buildTestGraph([
      {
        id: 'func:target',
        label: 'Function',
        name: 'target',
        filePath: 'src/symbol-window.ts',
        startLine: 2,
        endLine: 4,
        isExported: true,
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const functionCsv = result.nodeFiles.get('Function');
    expect(functionCsv).toBeDefined();
    const content = await fs.readFile(functionCsv!.csvPath, 'utf-8');
    expect(content).toContain('export function target()');
    expect(content).toContain('return before;');
    // Directly-adjacent neighbors must NOT leak — catches an off-by-one either way.
    expect(content).not.toContain('const before = 1;');
    expect(content).not.toContain('const after = 2;');
  });

  it('stores exact content for a one-line symbol (startLine === endLine)', async () => {
    await fs.writeFile(
      path.join(repoDir, 'src', 'one-line.ts'),
      ['AAA_TOP', 'BBB_BEFORE', 'const only = 1;', 'CCC_AFTER', 'DDD_BOTTOM'].join('\n'),
    );
    const graph = buildTestGraph([
      {
        id: 'func:only',
        label: 'Function',
        name: 'only',
        filePath: 'src/one-line.ts',
        startLine: 2,
        endLine: 2,
        isExported: true,
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const functionCsv = result.nodeFiles.get('Function');
    expect(functionCsv).toBeDefined();
    const content = await fs.readFile(functionCsv!.csvPath, 'utf-8');
    expect(content).toContain('const only = 1;');
    expect(content).not.toContain('BBB_BEFORE');
    expect(content).not.toContain('CCC_AFTER');
  });

  it('keeps ±2 neighbor context for non-exact labels (Section)', async () => {
    // `Section` is NOT in EXACT_SYMBOL_CONTENT_LABELS, so it retains the ±2
    // context window — the fallback branch the exact-content change left in place.
    await fs.writeFile(
      path.join(repoDir, 'src', 'section-window.ts'),
      [
        's0_alpha',
        's1_bravo',
        's2_charlie',
        's3_delta',
        's4_echo',
        's5_foxtrot',
        's6_golf',
        's7_hotel',
      ].join('\n'),
    );
    const graph = buildTestGraph([
      {
        id: 'sec:s',
        label: 'Section',
        name: 's',
        filePath: 'src/section-window.ts',
        startLine: 4,
        endLine: 4,
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const sectionCsv = result.nodeFiles.get('Section');
    expect(sectionCsv).toBeDefined();
    const content = await fs.readFile(sectionCsv!.csvPath, 'utf-8');
    expect(content).toContain('s4_echo'); // the section's own line
    expect(content).toContain('s2_charlie'); // startLine - 2
    expect(content).toContain('s6_golf'); // endLine + 2
    expect(content).not.toContain('s1_bravo'); // outside the ±2 window
    expect(content).not.toContain('s7_hotel');
  });

  it('keeps full text file content searchable past 10KB', async () => {
    const lateNeedle = 'late_text_file_needle_after_10kb';
    await fs.writeFile(
      path.join(repoDir, 'src', 'large.txt'),
      `${'filler line for large text indexing\n'.repeat(400)}${lateNeedle}\n`,
    );
    const graph = buildTestGraph([
      {
        id: 'file:src/large.txt',
        label: 'File',
        name: 'large.txt',
        filePath: 'src/large.txt',
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const fileCsv = result.nodeFiles.get('File');
    expect(fileCsv).toBeDefined();

    const content = await fs.readFile(fileCsv!.csvPath, 'utf-8');
    expect(content).toContain(lateNeedle);
    expect(content).not.toContain('[truncated]');
  });

  describe('GITNEXUS_FTS_CJK_SEGMENTATION (#2331)', () => {
    afterEach(() => {
      vi.unstubAllEnvs();
    });

    // The description phrase is deliberately different from anything in the
    // file's own source text (and the function's startLine/endLine keep the
    // extracted content snippet away from the file-level comment). If
    // description and content were segmented via the same accidental code
    // path, or formatFtsDescription silently used the wrong property, a
    // description-only phrase could not appear in either CSV row.
    const FILE_CJK_PHRASE = '采购订单自动审批流程';
    const DESCRIPTION_CJK_PHRASE = '库存管理系统更新';

    it('leaves File content and Function description byte-identical by default (mode: none)', async () => {
      const cjkContent = `// ${FILE_CJK_PHRASE}\nexport function approve() {\n  return true;\n}\n`;
      await fs.writeFile(path.join(repoDir, 'src', 'cjk.ts'), cjkContent);
      const graph = buildTestGraph([
        { id: 'file:src/cjk.ts', label: 'File', name: 'cjk.ts', filePath: 'src/cjk.ts' },
        {
          id: 'func:approve',
          label: 'Function',
          name: 'approve',
          filePath: 'src/cjk.ts',
          extra: { description: DESCRIPTION_CJK_PHRASE, startLine: 3, endLine: 3 },
        },
      ]);

      const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
      const fileContent = await fs.readFile(result.nodeFiles.get('File')!.csvPath, 'utf-8');
      const funcContent = await fs.readFile(result.nodeFiles.get('Function')!.csvPath, 'utf-8');
      expect(fileContent).toContain(FILE_CJK_PHRASE);
      expect(funcContent).toContain(DESCRIPTION_CJK_PHRASE);
      expect(funcContent).not.toContain(FILE_CJK_PHRASE);
      // No bigram-separator spaces inserted into the CJK run.
      expect(fileContent).not.toContain('采购 购订');
      expect(funcContent).not.toContain('库存 存管');
    });

    it('bigram-segments both File content and Function description when enabled', async () => {
      vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
      const cjkContent = `// ${FILE_CJK_PHRASE}\nexport function approve() {\n  return true;\n}\n`;
      await fs.writeFile(path.join(repoDir, 'src', 'cjk-bigram.ts'), cjkContent);
      const graph = buildTestGraph([
        {
          id: 'file:src/cjk-bigram.ts',
          label: 'File',
          name: 'cjk-bigram.ts',
          filePath: 'src/cjk-bigram.ts',
        },
        {
          id: 'func:approve-bigram',
          label: 'Function',
          name: 'approveBigram',
          filePath: 'src/cjk-bigram.ts',
          extra: { description: DESCRIPTION_CJK_PHRASE, startLine: 3, endLine: 3 },
        },
      ]);

      const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
      const fileContent = await fs.readFile(result.nodeFiles.get('File')!.csvPath, 'utf-8');
      const funcContent = await fs.readFile(result.nodeFiles.get('Function')!.csvPath, 'utf-8');
      // Every expected overlapping bigram from the issue's own example must
      // be present as a real, space-delimited FTS token in the File row.
      const expectedFileBigrams = [
        '采购',
        '购订',
        '订单',
        '单自',
        '自动',
        '动审',
        '审批',
        '批流',
        '流程',
      ];
      for (const bigram of expectedFileBigrams) {
        expect(fileContent).toContain(bigram);
      }
      // The Function row's description column is segmented independently —
      // proven against its own (distinct) phrase, not the file's.
      const expectedDescriptionBigrams = ['库存', '存管', '管理', '理系', '系统', '统更', '更新'];
      for (const bigram of expectedDescriptionBigrams) {
        expect(funcContent).toContain(bigram);
      }
      // #2339: every one of the bigram substrings above is ALSO a literal
      // substring of the original unsegmented phrase (bigrams are
      // overlapping substrings by construction), so the positive assertions
      // alone would pass even if applyCjkSegmentationIfEnabled silently
      // became a no-op. Mirror the `mode: none` test's negative-assertion
      // pattern above: the original contiguous run must NOT survive intact.
      expect(fileContent).not.toContain(FILE_CJK_PHRASE);
      expect(funcContent).not.toContain(DESCRIPTION_CJK_PHRASE);
    });
  });

  describe('binary descriptions (#2889)', () => {
    // What an embedded binary payload actually looks like by the time it
    // reaches the emitter: every read decodes `utf-8`, so the invalid bytes
    // are already gone, replaced with U+FFFD. `MethCw` is the readable tail of
    // a Java constant-pool run — the marker that proves the payload, not just
    // the corruption around it, stayed out of the indexed column.
    const DECODED_BINARY_DESCRIPTION = `用户服务 ${'�'.repeat(24)}MethCw`;
    const CLEAN_DESCRIPTION = 'approves an inventory transfer';

    // `Method` has its own emission branch; `Function` falls to the `default:`
    // one. Both call the same helper, so one graph carrying both proves the gate
    // is in the helper rather than in one branch's copy of the call.
    const BRANCHES = ['Function', 'Method'] as const;

    it('drops a binary description on every emission branch, keeping the row', async () => {
      await fs.writeFile(
        path.join(repoDir, 'src', 'payload.ts'),
        'export function fromPayload() {\n  return 1;\n}\nexport class Svc {\n  run() {\n    return 1;\n  }\n}\n',
      );
      const graph = buildTestGraph([
        {
          id: 'file:src/payload.ts',
          label: 'File',
          name: 'payload.ts',
          filePath: 'src/payload.ts',
        },
        {
          id: 'func:fromPayload',
          label: 'Function',
          name: 'fromPayload',
          filePath: 'src/payload.ts',
          extra: { description: DECODED_BINARY_DESCRIPTION, startLine: 0, endLine: 2 },
        },
        {
          id: 'func:clean',
          label: 'Function',
          name: 'cleanFn',
          filePath: 'src/payload.ts',
          extra: { description: CLEAN_DESCRIPTION, startLine: 0, endLine: 2 },
        },
        {
          id: 'method:Svc.run',
          label: 'Method',
          name: 'run',
          filePath: 'src/payload.ts',
          extra: { description: DECODED_BINARY_DESCRIPTION, startLine: 4, endLine: 6 },
        },
        {
          id: 'method:Svc.clean',
          label: 'Method',
          name: 'cleanRun',
          filePath: 'src/payload.ts',
          extra: { description: CLEAN_DESCRIPTION, startLine: 4, endLine: 6 },
        },
      ]);

      const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);

      for (const label of BRANCHES) {
        const csv = await fs.readFile(result.nodeFiles.get(label)!.csvPath, 'utf-8');
        expect(csv, label).not.toContain('MethCw');
        expect(csv, label).not.toContain('�');
        // A clean description on the same branch is untouched, so the gate
        // cannot pass by emptying the column for everyone.
        expect(csv, label).toContain(CLEAN_DESCRIPTION);
      }
      // The symbols themselves still ship — only their descriptions were dropped.
      const functionCsv = await fs.readFile(result.nodeFiles.get('Function')!.csvPath, 'utf-8');
      const methodCsv = await fs.readFile(result.nodeFiles.get('Method')!.csvPath, 'utf-8');
      expect(functionCsv).toContain('fromPayload');
      expect(methodCsv).toContain('"run"');
    });
  });

  it('handles community nodes with keywords', async () => {
    const graph = buildTestGraph([
      {
        id: 'comm:auth',
        label: 'Community' as any,
        name: 'Auth',
        filePath: '',
        extra: {
          heuristicLabel: 'Authentication',
          keywords: ['auth', 'login', 'pass,word'],
          description: 'Auth module',
          enrichedBy: 'heuristic',
          cohesion: 0.85,
          symbolCount: 5,
        },
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const commCsv = result.nodeFiles.get('Community');
    expect(commCsv).toBeDefined();
    expect(commCsv!.rows).toBe(1);

    const content = await fs.readFile(commCsv!.csvPath, 'utf-8');
    // Keywords with commas should be escaped with \,
    expect(content).toContain('pass\\,word');
  });

  it('handles process nodes', async () => {
    const graph = buildTestGraph([
      {
        id: 'proc:flow',
        label: 'Process' as any,
        name: 'LoginFlow',
        filePath: '',
        extra: {
          heuristicLabel: 'User Login',
          processType: 'intra_community',
          stepCount: 3,
          communities: ['auth'],
          entryPointId: 'func:login',
          terminalId: 'func:validate',
        },
      },
    ]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const procCsv = result.nodeFiles.get('Process');
    expect(procCsv).toBeDefined();
    expect(procCsv!.rows).toBe(1);
  });

  it('deduplicates File nodes', async () => {
    const graph = buildTestGraph([
      { id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
      // Duplicate (same id) — should not appear twice
    ]);
    // Add the same node again manually
    graph.addNode({
      id: 'file:src/index.ts',
      label: 'File',
      properties: { name: 'index.ts', filePath: 'src/index.ts' },
    });

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const fileCsv = result.nodeFiles.get('File');
    expect(fileCsv).toBeDefined();
    expect(fileCsv!.rows).toBe(1);
  });

  // ─── Unhappy paths ──────────────────────────────────────────────────

  it('handles empty graph (zero nodes)', async () => {
    const graph = buildTestGraph([], []);
    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    expect(result.nodeFiles.size).toBe(0);
    expect(result.totalValidRels).toBe(0);
    expect(result.relsByPair.size).toBe(0);
  });

  it('handles node with empty string properties', async () => {
    const graph = buildTestGraph([{ id: 'file:empty', label: 'File', name: '', filePath: '' }]);

    const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
    const fileCsv = result.nodeFiles.get('File');
    expect(fileCsv).toBeDefined();
    expect(fileCsv!.rows).toBe(1);
  });

  it('crosses the BufferedCSVWriter FLUSH_BYTES boundary without losing rows', async () => {
    // FLUSH_BYTES=8MB; real File content totalling >8MB forces ≥1 mid-stream
    // flush, exercising addRow's flush-promise return + the loop's
    // `if (pending) await pending` path that the small fixtures above never
    // reach (only the bench did).
    const N = 10;
    const CONTENT_SIZE = 1024 * 1024; // 1MB/file, 10MB total > FLUSH_BYTES
    const bigContent = 'x'.repeat(CONTENT_SIZE);
    await fs.mkdir(path.join(repoDir, 'src', 'big'), { recursive: true });
    const nodes: TestNodeInput[] = [];
    for (let i = 0; i < N; i++) {
      const filePath = `src/big/f${i}.ts`;
      await fs.writeFile(path.join(repoDir, filePath), bigContent);
      nodes.push({ id: `File:${filePath}`, label: 'File', name: `f${i}.ts`, filePath });
    }
    const result = await streamAllCSVsToDisk(buildTestGraph(nodes), repoDir, csvDir);

    const fileCsv = result.nodeFiles.get('File');
    expect(fileCsv).toBeDefined();
    expect(fileCsv!.rows).toBe(N); // no rows dropped/duplicated at the flush boundary
    const dataRows = dataRowsOf(await fs.readFile(fileCsv!.csvPath, 'utf-8'));
    expect(dataRows).toHaveLength(N);
    expect(new Set(dataRows).size).toBe(N); // all distinct — no flush-boundary corruption
  });

  it('flushes the buffered CSV chunk once the byte threshold is reached', () => {
    expect(shouldFlushCSVBuffer(FLUSH_BYTES - 1)).toBe(false);
    expect(shouldFlushCSVBuffer(FLUSH_BYTES)).toBe(true);
  });

  it('shouldFlushCSVBuffer stays within the V8 string-length ceiling', () => {
    // One more max-size row (TREE_SITTER_MAX_BUFFER, hard-clamped — see
    // max-file-size.ts) can land right after the buffer was just under
    // FLUSH_BYTES. Two transforms can each grow that row before it's joined:
    // applyCjkSegmentationIfEnabled (#2331, ~7/3x worst case on an all-CJK
    // row with GITNEXUS_FTS_CJK_SEGMENTATION=bigram) and escapeCSVField's
    // quote-doubling (2x). The resulting join() must stay well under Node's
    // MAX_STRING_LENGTH, or BufferedCSVWriter.flush() throws
    // `RangeError: Invalid string length`.
    const worstCaseJoinSize =
      FLUSH_BYTES + 2 * CJK_BIGRAM_WORST_CASE_GROWTH_FACTOR * TREE_SITTER_MAX_BUFFER;
    expect(worstCaseJoinSize).toBeLessThan(bufferConstants.MAX_STRING_LENGTH / 2);
  });
});

/**
 * Deterministic output — `GITNEXUS_SORT_GRAPH_OUTPUT` makes the CSV a pure function of the
 * graph's node/edge SET (id-sorted) instead of of insertion order. This is the
 * structural enabler for the out-of-core / windowed resolve: with it on,
 * a windowed emit that produces the same edge set in a different order yields
 * byte-identical CSV. Default off = today's insertion-order bytes exactly.
 */
describe('streamAllCSVsToDisk — deterministic output ordering', () => {
  // Folder nodes: single-line CSV rows (no multi-line `content` column), so the
  // id is the first comma-separated field and split('\n') is safe. ids are
  // deliberately NOT in insertion order (c, a, b).
  // ids use the `Folder:` prefix so getNodeLabel derives the valid `Folder`
  // table — edges route to rel_Folder_Folder.csv (#2203 U2). Deliberately NOT
  // in insertion order (c, a, b).
  const NODES: TestNodeInput[] = [
    { id: 'Folder:c', label: 'Folder', name: 'c', filePath: 'c' },
    { id: 'Folder:a', label: 'Folder', name: 'a', filePath: 'a' },
    { id: 'Folder:b', label: 'Folder', name: 'b', filePath: 'b' },
  ];
  const RELS: TestRelInput[] = [
    { sourceId: 'Folder:c', targetId: 'Folder:a', type: 'CONTAINS' },
    { sourceId: 'Folder:a', targetId: 'Folder:b', type: 'CONTAINS' },
    { sourceId: 'Folder:b', targetId: 'Folder:c', type: 'CONTAINS' },
  ];
  const dataRows = (csv: string): string[] =>
    csv
      .trim()
      .split('\n')
      .slice(1)
      .filter((l) => l.length > 0);
  const firstCol = (row: string): string => row.split(',')[0];

  const run = async (
    nodes: TestNodeInput[],
    rels: TestRelInput[],
    sorted: boolean,
    sub: string,
  ): Promise<{ folderIds: string[]; relRows: string[] }> => {
    if (sorted) process.env.GITNEXUS_SORT_GRAPH_OUTPUT = '1';
    else delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
    try {
      const result = await streamAllCSVsToDisk(
        buildTestGraph(nodes, rels),
        repoDir,
        path.join(csvDir, sub),
      );
      const folderCsv = result.nodeFiles.get('Folder');
      const folderIds = folderCsv
        ? dataRows(await fs.readFile(folderCsv.csvPath, 'utf-8')).map(firstCol)
        : [];
      const relRows = await readAllRelRows(result.relsByPair);
      return { folderIds, relRows };
    } finally {
      delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
    }
  };

  it('default off: node rows follow graph insertion order (not id-sorted)', async () => {
    const { folderIds } = await run(NODES, RELS, false, 'u6a-off');
    expect(folderIds).not.toEqual([...folderIds].sort()); // insertion order c, a, b
  });

  it('flag on: node rows are sorted by id', async () => {
    const { folderIds } = await run(NODES, RELS, true, 'u6a-on');
    expect(folderIds).toEqual([...folderIds].sort());
  });

  it('flag on makes output independent of graph insertion order; off does not', async () => {
    const nodesRev = [...NODES].reverse();
    const relsRev = [...RELS].reverse();

    const onFwd = await run(NODES, RELS, true, 'u6a-on-fwd');
    const onRev = await run(nodesRev, relsRev, true, 'u6a-on-rev');
    // SORTED: byte-for-byte identical regardless of insertion order — the deterministic-output property.
    expect(onRev.folderIds).toEqual(onFwd.folderIds);
    expect(onRev.relRows).toEqual(onFwd.relRows);

    const offFwd = await run(NODES, RELS, false, 'u6a-off-fwd');
    const offRev = await run(nodesRev, relsRev, false, 'u6a-off-rev');
    // UNSORTED: insertion order leaks into the bytes (today's behavior).
    expect(offRev.folderIds).not.toEqual(offFwd.folderIds);

    // SAME node/edge SET in both modes — sorting reorders rows, never adds/drops.
    expect([...onFwd.folderIds].sort()).toEqual([...offFwd.folderIds].sort());
    expect([...onFwd.relRows].sort()).toEqual([...offFwd.relRows].sort());
  });
});

/**
 * #2203 U2 byte-identity: for all quote-free ids the direct per-pair emit must
 * produce per-pair files byte-for-byte identical to the legacy
 * splitRelCsvByLabelPair oracle run over an equivalent monolithic relations.csv
 * from the same graph. This is the load-bearing guard for "byte-identical graph
 * content" (issue acceptance). The ONE intentional divergence — ids containing a
 * double-quote, where the router (raw-id label) is more correct than the oracle
 * (regex over the escaped row) — is asserted explicitly in its own test below.
 */
describe('streamAllCSVsToDisk — direct per-pair emit matches the split oracle', () => {
  // The oracle always emits in graph.iterRelationships() (unsorted) order; the
  // production path honours GITNEXUS_SORT_GRAPH_OUTPUT. Clear it so a value
  // leaked from a prior test can't desync the two and produce a spurious diff.
  beforeEach(() => {
    delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
  });

  it('produces byte-identical per-pair files + identical skip/total accounting', async () => {
    // Multiple valid pairs, getNodeLabel special prefixes (comm_ AND proc_), and
    // one invalid-label edge that BOTH paths must skip identically.
    const graph = buildTestGraph(
      [
        { id: 'File:a.ts', label: 'File', name: 'a.ts', filePath: 'a.ts' },
        { id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
        { id: 'Function:a.ts:g:5', label: 'Function', name: 'g', filePath: 'a.ts' },
        { id: 'comm_1', label: 'Community' as never, name: 'c1', filePath: '' },
        { id: 'proc_1', label: 'Process' as never, name: 'p1', filePath: '' },
      ],
      [
        { sourceId: 'File:a.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
        { sourceId: 'File:a.ts', targetId: 'Function:a.ts:g:5', type: 'CONTAINS' },
        { sourceId: 'Function:a.ts:f:1', targetId: 'Function:a.ts:g:5', type: 'CALLS' },
        // comm_ target prefix → Community label (getNodeLabel special case);
        // Function→Community is a real schema pair.
        { sourceId: 'Function:a.ts:f:1', targetId: 'comm_1', type: 'MEMBER_OF' },
        // proc_ target prefix → Process label; Function→Process is likewise
        // declared in the production relation schema.
        { sourceId: 'Function:a.ts:g:5', targetId: 'proc_1', type: 'STEP_IN_PROCESS' },
        // Invalid FROM label ('Bogus' ∉ NODE_TABLES) — skipped by both paths.
        { sourceId: 'Bogus:x', targetId: 'File:a.ts', type: 'CONTAINS' },
        // Invalid TO label — exercises the OTHER branch of the skip condition.
        { sourceId: 'File:a.ts', targetId: 'Bogus:y', type: 'CONTAINS' },
      ],
    );

    const directDir = path.join(csvDir, 'diff-direct');
    const oracleDir = path.join(csvDir, 'diff-oracle');
    await fs.mkdir(oracleDir, { recursive: true });

    // Direct emit (production path).
    const direct = await streamAllCSVsToDisk(graph, repoDir, directDir);

    // Oracle: build the monolithic relations.csv this graph would have produced
    // (same insertion order, same row bytes via buildRelRow), then split it.
    const relCsv = path.join(oracleDir, 'relations.csv');
    const lines = [REL_CSV_HEADER];
    for (const rel of graph.iterRelationships()) lines.push(buildRelRow(rel));
    await fs.writeFile(relCsv, lines.join('\n') + '\n', 'utf-8');

    const split = await splitRelCsvByLabelPair(
      relCsv,
      oracleDir,
      new Set<string>(NODE_TABLES),
      getNodeLabel,
    );
    await Promise.all(
      Array.from(split.pairWriteStreams.values()).map(async (ws) => {
        ws.end();
        await finished(ws);
      }),
    );

    // Identical accounting.
    expect(direct.totalValidRels).toBe(split.totalValidRels);
    expect(direct.totalValidRels).toBe(5);
    expect(direct.skippedRels).toBe(split.skippedRels);
    expect(direct.skippedRels).toBe(2); // invalid-FROM + invalid-TO, both skipped
    expect(direct.relHeader).toBe(split.relHeader);

    // Identical pair set.
    expect([...direct.relsByPair.keys()].sort()).toEqual([...split.relsByPairMeta.keys()].sort());

    // Byte-identical per-pair file contents.
    for (const key of direct.relsByPair.keys()) {
      const directContent = await fs.readFile(direct.relsByPair.get(key)!.csvPath, 'utf-8');
      const oracleContent = await fs.readFile(split.relsByPairMeta.get(key)!.csvPath, 'utf-8');
      expect(directContent, `pair ${key}`).toBe(oracleContent);
    }
  });

  it('quote-in-id edge: router routes it (raw-id label) while the oracle drops it — intended divergence', async () => {
    // A node id with an embedded double-quote (legal in a POSIX filePath). The
    // router derives the label from the RAW id (`File`), so it routes the edge;
    // the oracle re-derives the label via /"([^"]*)","([^"]*)"/ over the ESCAPED
    // row (`"File:a""b.ts",...`), mis-reads the field, and drops it. This locks
    // the intended divergence so a future change can't silently revert the
    // router to the buggy regex semantics.
    const graph = buildTestGraph(
      [
        { id: 'File:clean.ts', label: 'File', name: 'clean.ts', filePath: 'clean.ts' },
        { id: 'File:a"b.ts', label: 'File', name: 'a"b.ts', filePath: 'a"b.ts' },
        { id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
      ],
      [
        { sourceId: 'File:clean.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
        { sourceId: 'File:a"b.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
      ],
    );

    const directDir = path.join(csvDir, 'qd-direct');
    const oracleDir = path.join(csvDir, 'qd-oracle');
    await fs.mkdir(oracleDir, { recursive: true });

    const direct = await streamAllCSVsToDisk(graph, repoDir, directDir);

    const relCsv = path.join(oracleDir, 'relations.csv');
    const lines = [REL_CSV_HEADER];
    for (const rel of graph.iterRelationships()) lines.push(buildRelRow(rel));
    await fs.writeFile(relCsv, lines.join('\n') + '\n', 'utf-8');
    const split = await splitRelCsvByLabelPair(
      relCsv,
      oracleDir,
      new Set<string>(NODE_TABLES),
      getNodeLabel,
    );
    await Promise.all(
      Array.from(split.pairWriteStreams.values()).map(async (ws) => {
        ws.end();
        await finished(ws);
      }),
    );

    // Router routes BOTH edges — the raw-id label `File` is valid for both.
    expect(direct.totalValidRels).toBe(2);
    expect(direct.skippedRels).toBe(0);
    expect(direct.relsByPair.get('File|Function')!.rows).toBe(2);

    // Oracle DIVERGES: its regex mis-reads the quote-in-id row and drops that
    // edge, so it routes strictly fewer edges. Asserted robustly — we do NOT
    // pin the oracle's exact mis-derived label.
    expect(split.totalValidRels).toBeLessThan(direct.totalValidRels);
    expect(split.skippedRels).toBeGreaterThan(direct.skippedRels);
  });

  it('sorted path (GITNEXUS_SORT_GRAPH_OUTPUT=1): per-pair files byte-identical to the oracle', async () => {
    // The earlier differential test covers the default (insertion-order) path.
    // Here the sorted emit path must also match the oracle — fed the SAME
    // id-sorted order orderedRelationships() uses (sort by rel.id).
    process.env.GITNEXUS_SORT_GRAPH_OUTPUT = '1';
    try {
      const graph = buildTestGraph(
        [
          { id: 'File:a.ts', label: 'File', name: 'a.ts', filePath: 'a.ts' },
          { id: 'Function:a.ts:f:1', label: 'Function', name: 'f', filePath: 'a.ts' },
          { id: 'Function:a.ts:g:5', label: 'Function', name: 'g', filePath: 'a.ts' },
        ],
        // Deliberately NOT in id-sorted order so the sort actually reorders rows.
        [
          { sourceId: 'Function:a.ts:f:1', targetId: 'Function:a.ts:g:5', type: 'CALLS' },
          { sourceId: 'File:a.ts', targetId: 'Function:a.ts:g:5', type: 'CONTAINS' },
          { sourceId: 'File:a.ts', targetId: 'Function:a.ts:f:1', type: 'CONTAINS' },
        ],
      );

      const directDir = path.join(csvDir, 'sorted-direct');
      const oracleDir = path.join(csvDir, 'sorted-oracle');
      await fs.mkdir(oracleDir, { recursive: true });

      const direct = await streamAllCSVsToDisk(graph, repoDir, directDir);

      // Oracle fed the same id-sorted order the sorted emit produces.
      const sortedRels = [...graph.iterRelationships()].sort((a, b) =>
        a.id < b.id ? -1 : a.id > b.id ? 1 : 0,
      );
      const relCsv = path.join(oracleDir, 'relations.csv');
      const lines = [REL_CSV_HEADER];
      for (const rel of sortedRels) lines.push(buildRelRow(rel));
      await fs.writeFile(relCsv, lines.join('\n') + '\n', 'utf-8');
      const split = await splitRelCsvByLabelPair(
        relCsv,
        oracleDir,
        new Set<string>(NODE_TABLES),
        getNodeLabel,
      );
      await Promise.all(
        Array.from(split.pairWriteStreams.values()).map(async (ws) => {
          ws.end();
          await finished(ws);
        }),
      );

      expect([...direct.relsByPair.keys()].sort()).toEqual([...split.relsByPairMeta.keys()].sort());
      for (const key of direct.relsByPair.keys()) {
        const directContent = await fs.readFile(direct.relsByPair.get(key)!.csvPath, 'utf-8');
        const oracleContent = await fs.readFile(split.relsByPairMeta.get(key)!.csvPath, 'utf-8');
        expect(directContent, `pair ${key} (sorted)`).toBe(oracleContent);
      }
    } finally {
      delete process.env.GITNEXUS_SORT_GRAPH_OUTPUT;
    }
  });
});

// The overlap leg (#2203) needs to start COPY-ing nodes while relationship CSVs
// are still being written. streamAllCSVsToDisk exposes that boundary via an
// onNodePhaseComplete callback. These tests pin the contract: it fires once,
// after node CSVs exist and before any rel CSV does, and supplying it does not
// change the emitted output.
describe('onNodePhaseComplete hook (#2203 overlap boundary)', () => {
  const hookGraph = () =>
    buildTestGraph(
      [
        { id: 'File:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
        {
          id: 'Function:src/index.ts:main:1',
          label: 'Function',
          name: 'main',
          filePath: 'src/index.ts',
          startLine: 1,
          endLine: 3,
        },
      ],
      [
        {
          sourceId: 'File:src/index.ts',
          targetId: 'Function:src/index.ts:main:1',
          type: 'DEFINES',
        },
      ],
    );

  it('fires exactly once, after node CSVs are flushed and before any rel CSV exists', async () => {
    const hookCsvDir = path.join(tmpHandle.dbPath, 'csv-hook-timing');
    let calls = 0;
    let nodeCsvsPresent = false;
    let relCsvsPresent = true;
    let handedKeys: string[] = [];

    const result = await streamAllCSVsToDisk(hookGraph(), repoDir, hookCsvDir, (nodeFiles) => {
      calls++;
      handedKeys = [...nodeFiles.keys()].sort();
      const entries = readdirSync(hookCsvDir);
      nodeCsvsPresent = entries.includes('file.csv') && entries.includes('function.csv');
      relCsvsPresent = entries.some((f) => f.startsWith('rel_'));
    });

    expect(calls).toBe(1);
    expect(nodeCsvsPresent).toBe(true);
    // No relationship CSV may exist yet — the rel pass starts after the hook.
    expect(relCsvsPresent).toBe(false);
    // The manifest handed to the callback is the one returned to the caller.
    expect(handedKeys).toEqual([...result.nodeFiles.keys()].sort());
    expect(result.totalValidRels).toBe(1);
  });

  it('supplying the callback does not change the node manifest (no behavior change)', async () => {
    const withDir = path.join(tmpHandle.dbPath, 'csv-hook-with');
    const withoutDir = path.join(tmpHandle.dbPath, 'csv-hook-without');
    const withCb = await streamAllCSVsToDisk(hookGraph(), repoDir, withDir, () => {});
    const without = await streamAllCSVsToDisk(hookGraph(), repoDir, withoutDir);

    const manifest = (r: typeof withCb) =>
      [...r.nodeFiles.entries()].map(([k, v]) => `${k}:${v.rows}`).sort();
    expect(manifest(withCb)).toEqual(manifest(without));
    expect(withCb.totalValidRels).toBe(without.totalValidRels);
    expect(withCb.skippedRels).toBe(without.skippedRels);
  });
});
