/**
 * U4 (#2227 follow-up plan) — `BasicBlock.calleeIds` column wiring end-to-end.
 *
 * U3 (already committed) sets `node.properties.calleeIds` on every emitted
 * BasicBlock; U4 wires that property through the FIVE places the existing
 * `callees` column lives so it actually persists and reads back:
 *   1. schema DDL                (BASICBLOCK_SCHEMA)
 *   2. CSV header + row builder  (BASICBLOCK_CSV_HEADER / buildBasicBlockRow)
 *   3. bulk COPY column list     (getCopyQuery('BasicBlock'))
 *   4. single-node CREATE        (insertNodeToLbug)
 *   5. incremental MERGE         (batchInsertNodesToLbug)
 * plus its presence in the fingerprinted DDL set (KTD5).
 *
 * `calleeIds` is added LAST in the CSV/COPY/CREATE/MERGE tuple, so the column
 * order MUST stay identical across header, COPY list, and row array — the
 * parity test below is the drift guard.
 *
 * The CSV/COPY/schema/version assertions are pure (no DB). The CREATE and MERGE
 * query-string assertions mock `lbug-config.js` to capture the executed cypher,
 * mirroring the established `lbug-adapter-wal-schema.test.ts` pattern (typed
 * fake `conn`/`db`, no `any`/`as any`).
 */
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GraphNode, NodeProperties } from 'gitnexus-shared';
import { BASICBLOCK_CSV_HEADER, buildBasicBlockRow } from '../../src/core/lbug/csv-generator.js';
import { getCopyQuery } from '../../src/core/lbug/lbug-adapter.js';
import { BASICBLOCK_SCHEMA, NODE_SCHEMA_QUERIES } from '../../src/core/lbug/schema.js';

// ── helpers ─────────────────────────────────────────────────────────────────

/** Build a typed BasicBlock GraphNode with the given extra properties. */
const basicBlock = (id: string, props: Partial<NodeProperties>): GraphNode => ({
  id,
  label: 'BasicBlock',
  properties: {
    name: '', // BasicBlock has no name column; identified by id + span
    filePath: 'src/a.ts',
    startLine: 1,
    endLine: 3,
    text: 'foo(); bar();',
    ...props,
  },
});

/** Parse the COPY column tuple out of `COPY Table(a, b, c) FROM "…"`. */
const copyColumns = (copyQuery: string): string[] => {
  const open = copyQuery.indexOf('(');
  const close = copyQuery.indexOf(')', open);
  return copyQuery
    .slice(open + 1, close)
    .split(',')
    .map((c) => c.trim());
};

// ── 1. CSV header / row builder (pure) ────────────────────────────────────────

describe('BasicBlock calleeIds — CSV header + row builder', () => {
  it('header lists calleeIds LAST, after callees', () => {
    expect(BASICBLOCK_CSV_HEADER).toBe('id,filePath,startLine,endLine,text,callees,calleeIds');
    const cols = BASICBLOCK_CSV_HEADER.split(',');
    expect(cols[cols.length - 1]).toBe('calleeIds');
    expect(cols[cols.length - 2]).toBe('callees');
  });

  it('round-trip: calleeIds lands in the column the header names for it', () => {
    const node = basicBlock('BasicBlock:src/a.ts:0', {
      callees: 'foo bar',
      calleeIds: 'id1 id2',
    });
    const headerCols = BASICBLOCK_CSV_HEADER.split(',');
    const rowCells = buildBasicBlockRow(node).split(',');

    // Same arity as the header → positional column match is meaningful.
    expect(rowCells).toHaveLength(headerCols.length);
    const calleeIdsIdx = headerCols.indexOf('calleeIds');
    const calleesIdx = headerCols.indexOf('callees');
    // escapeCSVField always wraps the cell in double quotes; the space-joined
    // id list contains no comma, so the cell is a single CSV column.
    expect(rowCells[calleeIdsIdx]).toBe('"id1 id2"');
    expect(rowCells[calleesIdx]).toBe('"foo bar"');
  });

  it('empty default: a node with no calleeIds property → empty cell, not "undefined"', () => {
    const node = basicBlock('BasicBlock:src/a.ts:1', { callees: 'foo bar' });
    const headerCols = BASICBLOCK_CSV_HEADER.split(',');
    const rowCells = buildBasicBlockRow(node).split(',');
    const calleeIdsIdx = headerCols.indexOf('calleeIds');
    expect(rowCells[calleeIdsIdx]).toBe('""');
    expect(rowCells[calleeIdsIdx]).not.toContain('undefined');
  });
});

// ── 2. Header / COPY tuple / row array PARITY (drift guard) ────────────────────

describe('BasicBlock calleeIds — header/COPY/row column parity', () => {
  it('header column count == COPY tuple arity == row array length', () => {
    const headerCols = BASICBLOCK_CSV_HEADER.split(',');
    const copyCols = copyColumns(getCopyQuery('BasicBlock', '/tmp/bb.csv'));
    const rowCells = buildBasicBlockRow(
      basicBlock('BasicBlock:src/a.ts:0', { callees: 'foo', calleeIds: 'id1' }),
    ).split(',');

    // Exact counts (the drift guard): 7 columns through and through.
    expect(headerCols).toHaveLength(7);
    expect(copyCols).toHaveLength(7);
    expect(rowCells).toHaveLength(7);
    expect(copyCols).toHaveLength(headerCols.length);
    expect(rowCells).toHaveLength(headerCols.length);
  });

  it('COPY column order matches the CSV header order exactly', () => {
    const headerCols = BASICBLOCK_CSV_HEADER.split(',');
    const copyCols = copyColumns(getCopyQuery('BasicBlock', '/tmp/bb.csv'));
    expect(copyCols).toEqual(headerCols);
    expect(copyCols[copyCols.length - 1]).toBe('calleeIds');
  });
});

// ── 3. schema DDL + fingerprint coverage (pure) ───────────────────────────────

describe('BasicBlock calleeIds — schema DDL + fingerprint coverage', () => {
  it('BASICBLOCK_SCHEMA declares the calleeIds STRING column', () => {
    expect(BASICBLOCK_SCHEMA).toContain('callees STRING');
    expect(BASICBLOCK_SCHEMA).toContain('calleeIds STRING');
  });

  it('the calleeIds column is inside the schema fingerprint, so a pre-column index cannot be reused', () => {
    // This replaces the old `INCREMENTAL_SCHEMA_VERSION >= 3` floor (#2798).
    // The hand-incremented integer is gone: reuse is now gated on a digest of
    // the DDL itself, so the invariant to pin is that BASICBLOCK_SCHEMA is one
    // of the strings that digest covers. An index built before the column
    // existed therefore carries a different fingerprint and is rebuilt.
    expect(NODE_SCHEMA_QUERIES).toContain(BASICBLOCK_SCHEMA);
  });
});

// ── 4 + 5. CREATE / MERGE query strings carry the calleeIds assignment ─────────
//
// `insertNodeToLbug` (CREATE) and `batchInsertNodesToLbug` (MERGE) build their
// cypher inline and execute it via the `lbug-config.js` connection. Mock that
// module with a typed fake `conn` whose `query` records the cypher, then assert
// the recorded string contains the calleeIds assignment.

interface FakeQueryResult {
  getAll: () => Promise<unknown[]>;
  close: () => void;
}
interface FakeConn {
  query: (cypher: string) => Promise<FakeQueryResult>;
  close: () => Promise<void>;
}
interface FakeDb {
  close: () => Promise<void>;
}

const makeConfigMock = () => {
  const queries: string[] = [];
  const queryResult: FakeQueryResult = { getAll: async () => [], close: vi.fn() };
  const conn: FakeConn = {
    query: vi.fn(async (cypher: string) => {
      queries.push(cypher);
      return queryResult;
    }),
    close: vi.fn(async () => {}),
  };
  const db: FakeDb = { close: vi.fn(async () => {}) };
  const mock = {
    openLbugConnection: vi.fn(async () => ({ db, conn })),
    closeLbugConnection: async (handle: { conn: FakeConn; db: FakeDb }) => {
      await handle.conn.close();
      await handle.db.close();
    },
    isDbBusyError: vi.fn(() => false),
    isOpenRetryExhausted: vi.fn(() => false),
    isWalCorruptionError: vi.fn(() => false),
    toNativeSafePath: (p: string) => p,
    resolveNativeSafeStorageDir: (p: string) => p,
    WAL_RECOVERY_SUGGESTION: 'run analyze --force',
    waitForWindowsHandleRelease: vi.fn(async () => true),
  };
  return { mock, queries };
};

describe('BasicBlock calleeIds — CREATE / MERGE query strings', () => {
  afterEach(() => {
    vi.doUnmock('../../src/core/lbug/lbug-config.js');
    vi.resetModules();
    vi.clearAllMocks();
  });

  it('insertNodeToLbug CREATE assigns calleeIds (after callees)', async () => {
    vi.resetModules();
    const { mock, queries } = makeConfigMock();
    vi.doMock('../../src/core/lbug/lbug-config.js', () => mock);

    const adapter = await import('../../src/core/lbug/lbug-adapter.js');
    const ok = await adapter.insertNodeToLbug(
      'BasicBlock',
      {
        id: 'BasicBlock:src/a.ts:0',
        filePath: 'src/a.ts',
        startLine: 1,
        endLine: 3,
        text: 'foo();',
        callees: 'foo',
        calleeIds: 'id1 id2',
      },
      '/tmp/gitnexus-bb-create/lbug',
    );

    expect(ok).toBe(true);
    const createQuery = queries.find((q) => q.startsWith('CREATE (n:BasicBlock'));
    expect(createQuery).toBeDefined();
    expect(createQuery).toContain("calleeIds: 'id1 id2'");
    expect(createQuery).toContain("callees: 'foo'");
    // calleeIds is the LAST assignment in the tuple (mirrors the column order).
    expect(createQuery?.indexOf('calleeIds:')).toBeGreaterThan(
      createQuery?.indexOf('callees:') ?? -1,
    );
  });

  it('insertNodeToLbug CREATE defaults a missing calleeIds to an empty string', async () => {
    vi.resetModules();
    const { mock, queries } = makeConfigMock();
    vi.doMock('../../src/core/lbug/lbug-config.js', () => mock);

    const adapter = await import('../../src/core/lbug/lbug-adapter.js');
    await adapter.insertNodeToLbug(
      'BasicBlock',
      {
        id: 'BasicBlock:src/a.ts:0',
        filePath: 'src/a.ts',
        startLine: 1,
        endLine: 3,
        text: 'foo();',
        callees: 'foo',
      },
      '/tmp/gitnexus-bb-create-default/lbug',
    );

    const createQuery = queries.find((q) => q.startsWith('CREATE (n:BasicBlock'));
    expect(createQuery).toContain("calleeIds: ''");
    expect(createQuery).not.toContain('calleeIds: undefined');
  });

  it('batchInsertNodesToLbug MERGE sets n.calleeIds (after n.callees)', async () => {
    vi.resetModules();
    const { mock, queries } = makeConfigMock();
    vi.doMock('../../src/core/lbug/lbug-config.js', () => mock);

    const adapter = await import('../../src/core/lbug/lbug-adapter.js');
    const result = await adapter.batchInsertNodesToLbug(
      [
        {
          label: 'BasicBlock',
          properties: {
            id: 'BasicBlock:src/a.ts:0',
            filePath: 'src/a.ts',
            startLine: 1,
            endLine: 3,
            text: 'foo();',
            callees: 'foo',
            calleeIds: 'id1 id2',
          },
        },
      ],
      '/tmp/gitnexus-bb-merge/lbug',
    );

    expect(result.inserted).toBe(1);
    const mergeQuery = queries.find((q) => q.startsWith('MERGE (n:BasicBlock'));
    expect(mergeQuery).toBeDefined();
    expect(mergeQuery).toContain('n.calleeIds');
    expect(mergeQuery).toContain("n.calleeIds = 'id1 id2'");
    expect(mergeQuery).toContain("n.callees = 'foo'");
    expect(mergeQuery?.indexOf('n.calleeIds')).toBeGreaterThan(
      mergeQuery?.indexOf('n.callees') ?? -1,
    );
  });

  it('batchInsertNodesToLbug MERGE defaults a missing calleeIds to an empty string', async () => {
    vi.resetModules();
    const { mock, queries } = makeConfigMock();
    vi.doMock('../../src/core/lbug/lbug-config.js', () => mock);

    const adapter = await import('../../src/core/lbug/lbug-adapter.js');
    await adapter.batchInsertNodesToLbug(
      [
        {
          label: 'BasicBlock',
          properties: {
            id: 'BasicBlock:src/a.ts:0',
            filePath: 'src/a.ts',
            startLine: 1,
            endLine: 3,
            text: 'foo();',
            callees: 'foo',
          },
        },
      ],
      '/tmp/gitnexus-bb-merge-default/lbug',
    );

    const mergeQuery = queries.find((q) => q.startsWith('MERGE (n:BasicBlock'));
    expect(mergeQuery).toContain("n.calleeIds = ''");
    expect(mergeQuery).not.toContain('n.calleeIds = undefined');
  });
});
