/**
 * P0 Integration Tests: Core LadybugDB Adapter
 *
 * Tests: loadGraphToLbug CSV round-trip, createFTSIndex, getLbugStats.
 *
 * IMPORTANT: All core adapter tests share ONE coreHandle and ONE coreInitLbug
 * call because the core adapter is a module-level singleton. Calling
 * coreInitLbug with a different path closes the previous native DB handle
 * and opens a new one — sharing a single handle avoids unnecessary churn.
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import type { GraphRelationship } from 'gitnexus-shared';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { skipUnlessFtsAvailable } from '../helpers/fts-availability.js';
import {
  SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION,
  SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX,
} from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';

/**
 * LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
 * does not release the underlying file lock until the process exits, so any
 * `closeLbug()` followed by `initLbug(samePath)` in the same process raises
 * Win32 Error 33. Production paths are unaffected (single open per process).
 *
 * Tracking: kuzudb/kuzu#3872 / #3883 / #4730 (file-lock UX gaps on Windows).
 */
const itLbugReopen = process.platform === 'win32' ? it.skip : it;

// The FTS extension is optional and defaults to a `load-only` install policy
// (PR #1161 — offline-first), so on a machine where it was never pre-installed
// it cannot load. The tests below exercise the FTS *primitives* directly and
// have nothing to assert without the extension — skip them rather than fail.
// Graceful degradation when FTS is unavailable is covered at the analyze /
// query layer (see run-analyze.ts and the BM25 fallback tests).
// See test/helpers/fts-availability.ts for skipUnlessFtsAvailable's contract.

// ─── Core LadybugDB Adapter ─────────────────────────────────────────────

withTestLbugDB(
  'core-adapter',
  (handle) => {
    describe('core adapter', () => {
      it('loadGraphToLbug: loads a minimal graph and node counts match', async () => {
        const { executeQuery: coreExecuteQuery } =
          await import('../../src/core/lbug/lbug-adapter.js');

        // createMinimalTestGraph has 2 File, 2 Function, 1 Class, 1 Folder = 6 nodes
        const fileRows = await coreExecuteQuery('MATCH (n:File) RETURN n.id AS id');
        expect(fileRows).toHaveLength(2);

        const funcRows = await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id');
        expect(funcRows).toHaveLength(2);

        const classRows = await coreExecuteQuery('MATCH (n:Class) RETURN n.id AS id');
        expect(classRows).toHaveLength(1);

        const folderRows = await coreExecuteQuery('MATCH (n:Folder) RETURN n.id AS id');
        expect(folderRows).toHaveLength(1);
      });

      it('createFTSIndex: creates FTS index on Function table without error', async (ctx) => {
        await skipUnlessFtsAvailable(ctx);
        const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');

        await expect(
          createFTSIndex('Function', 'function_fts', ['name', 'content']),
        ).resolves.toBeUndefined();
      });

      it('loadFTSExtension(conn): loads on an explicit connection and returns true', async (ctx) => {
        await skipUnlessFtsAvailable(ctx);
        const lbug = (await import('@ladybugdb/core')).default;
        const { loadFTSExtension, getDatabase } =
          await import('../../src/core/lbug/lbug-adapter.js');

        const db = getDatabase();
        expect(db).not.toBeNull();

        // Fresh Connection on the same Database — simulates the pool adapter's
        // path where loadFTSExtension is called with an explicit connection
        // rather than the module-level singleton.
        const freshConn = new lbug.Connection(db!);
        try {
          const loaded = await loadFTSExtension(freshConn);
          expect(loaded).toBe(true);

          // Idempotent on the same connection — calling again still returns true
          // (exercises the "already loaded" catch branch in the fallback path).
          const loadedAgain = await loadFTSExtension(freshConn);
          expect(loadedAgain).toBe(true);
        } finally {
          await freshConn.close().catch(() => {});
        }
      });

      it('getLbugStats: returns correct node and edge counts for seeded data', async () => {
        const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');

        const stats = await getLbugStats();

        // createMinimalTestGraph: 6 nodes (2 File, 2 Function, 1 Class, 1 Folder)
        expect(stats.nodes).toBe(6);

        // 4 relationships (2 CALLS, 2 CONTAINS)
        expect(stats.edges).toBe(4);

        // STRUCTURAL count must be a real number, not `undefined`.
        //
        // This assertion exists because the failure mode is silent: the query is
        // wrapped in a try/catch that yields `undefined` on error, and
        // `undefined` makes the graph-write-collapse check decline to compare.
        // A typo in the Cypher would therefore not throw, not fail any test, and
        // simply switch the collapse guard off — the exact shape of
        // confidently-doing-nothing this whole area exists to prevent.
        //
        // The seeded graph has no PDG layers, so structural == total here; the
        // point is that the count was TAKEN.
        expect(stats.structuralEdges).toBe(4);

        // ...and that it was taken WITHOUT an error, which is the fact the
        // collapse guard reads to tell "measured" from "could not measure".
        expect(stats.structuralEdgesError).toBeUndefined();
      });

      it('getLbugStats: the structural count EXCLUDES PDG rows that `edges` counts', async () => {
        // The assertion above cannot see the `WHERE NOT r.type IN [...]` filter
        // at all — its own comment says "structural == total here" — so a broken
        // or dropped predicate would pass it unchanged while silently switching
        // the graph-write-collapse guard from a structural comparison back to
        // the total one that let PDG volume mask structural loss.
        //
        // Seeds a real PDG-typed row (same CREATE pattern as the
        // deleteAllInterprocTaintPaths test below) and asserts the two counts
        // DIVERGE by exactly it. Removed again at the end: the count-based
        // assertions in this file share one singleton DB and run in declaration
        // order.
        const { getLbugStats, executeQuery: coreExecuteQuery } =
          await import('../../src/core/lbug/lbug-adapter.js');

        const before = await getLbugStats();
        expect(before.edges).toBe(before.structuralEdges);

        const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
          id: string;
        }[];
        expect(fns.length).toBe(2);
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'CFG', confidence: 1.0, reason: 'seq', step: 0}]->(b)`,
        );

        try {
          const after = await getLbugStats();
          // The total sees the new row...
          expect(after.edges).toBe((before.edges ?? 0) + 1);
          // ...and the structural count does NOT.
          expect(after.structuralEdges).toBe(before.structuralEdges);
          expect(after.structuralEdgesError).toBeUndefined();
        } finally {
          await coreExecuteQuery(`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'CFG' DELETE r`);
        }

        // Restored, so the later count-based assertions still see the seeded graph.
        const restored = await getLbugStats();
        expect(restored.edges).toBe(before.edges);
        expect(restored.structuralEdges).toBe(before.structuralEdges);
      });

      it('deleteAllInterprocTaintPaths: removes TAINT_PATH edges and is benign when none exist (#2084 review P2-5)', async () => {
        const { executeQuery: coreExecuteQuery, deleteAllInterprocTaintPaths } =
          await import('../../src/core/lbug/lbug-adapter.js');

        // Benign: no TAINT_PATH rows yet → returns 0, does NOT throw.
        await expect(deleteAllInterprocTaintPaths()).resolves.toEqual({ edgesDeleted: 0 });

        // Seed one TAINT_PATH edge between the two seeded Function nodes, then
        // delete-all and confirm it is removed (the incremental-rebuild guard).
        const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
          id: string;
        }[];
        expect(fns.length).toBe(2);
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'TAINT_PATH', confidence: 0.6, reason: '1', step: 0}]->(b)`,
        );
        const r = await deleteAllInterprocTaintPaths();
        expect(r.edgesDeleted).toBe(1);
        const left = await coreExecuteQuery(
          `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' RETURN count(r) AS cnt`,
        );
        expect(Number((left[0] as { cnt: number }).cnt)).toBe(0);
      });

      it('deleteAllInjects: removes only INJECTS edges and is benign when none exist (#2200)', async () => {
        // Mirrors the deleteAllInterprocTaintPaths test above (same contract:
        // COUNT-then-DELETE, missing-table carve-out, re-throw otherwise).
        // The re-throw path is not simulated here — doing so would require
        // breaking the shared singleton connection mid-suite. Its benign-vs-
        // rethrow classification is pinned as a pure function instead:
        // `classifyDeleteAllError` (lbug-config.ts), exhaustively covered in
        // test/unit/lbug-delete-all-error.test.ts.
        const { executeQuery: coreExecuteQuery, deleteAllInjects } =
          await import('../../src/core/lbug/lbug-adapter.js');

        // Benign: no INJECTS rows yet → returns 0, does NOT throw.
        await expect(deleteAllInjects()).resolves.toEqual({ edgesDeleted: 0 });

        // Seed one INJECTS edge plus one edge of ANOTHER type between the two
        // seeded Function nodes, then delete-all and confirm exactly the
        // INJECTS row is removed while the other-typed row survives.
        const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
          id: string;
        }[];
        expect(fns.length).toBe(2);
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'INJECTS', confidence: 0.8, reason: 'di', step: 0}]->(b)`,
        );
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'QUERIES', confidence: 0.8, reason: 'orm', step: 0}]->(b)`,
        );
        const r2 = await deleteAllInjects();
        expect(r2.edgesDeleted).toBe(1);
        const injectsLeft = await coreExecuteQuery(
          `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'INJECTS' RETURN count(r) AS cnt`,
        );
        expect(Number((injectsLeft[0] as { cnt: number }).cnt)).toBe(0);
        const queriesLeft = await coreExecuteQuery(
          `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'QUERIES' RETURN count(r) AS cnt`,
        );
        expect(Number((queriesLeft[0] as { cnt: number }).cnt)).toBe(1);
      });

      it('deleteAllAdvisedBy: removes only ADVISED_BY edges and is benign when none exist (#2416)', async () => {
        const { executeQuery: coreExecuteQuery, deleteAllAdvisedBy } =
          await import('../../src/core/lbug/lbug-adapter.js');

        await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 0 });
        const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{
          id: string;
        }>;
        expect(fns.length).toBe(2);
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'ADVISED_BY', confidence: 0.95, reason: 'spring-aop:v1:{}', step: 0}]->(b)`,
        );

        await expect(deleteAllAdvisedBy()).resolves.toEqual({ edgesDeleted: 1 });
        const advisedLeft = await coreExecuteQuery(
          `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'ADVISED_BY' RETURN count(r) AS cnt`,
        );
        expect(Number((advisedLeft[0] as { cnt: number }).cnt)).toBe(0);
      });

      it('deleteSpringAopEvidenceNodes: keys deletion to the owned ID namespace (#2416)', async () => {
        const { executeQuery: coreExecuteQuery, deleteSpringAopEvidenceNodes } =
          await import('../../src/core/lbug/lbug-adapter.js');

        await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 0 });
        await coreExecuteQuery(
          `CREATE (:CodeElement {id: 'CodeElement:spring-aop:test-evidence', name: 'Aop', filePath: 'A.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'ordinary text'})`,
        );
        await coreExecuteQuery(
          `CREATE (:CodeElement {id: 'CodeElement:ordinary-lookalike', name: 'Other', filePath: 'B.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP: lookalike'})`,
        );

        await expect(deleteSpringAopEvidenceNodes()).resolves.toEqual({ nodesDeleted: 1 });
        const rows = await coreExecuteQuery(
          `MATCH (n:CodeElement) WHERE n.id IN ['CodeElement:spring-aop:test-evidence', 'CodeElement:ordinary-lookalike'] RETURN n.id AS id`,
        );
        expect(rows).toEqual([{ id: 'CodeElement:ordinary-lookalike' }]);
        await coreExecuteQuery(
          `MATCH (n:CodeElement {id: 'CodeElement:ordinary-lookalike'}) DETACH DELETE n`,
        );
      });

      it('persists the Interface Spring AOP evidence relation pair (#2416)', async () => {
        const { executeQuery: coreExecuteQuery } =
          await import('../../src/core/lbug/lbug-adapter.js');
        await coreExecuteQuery(
          `CREATE (:Interface {id: 'Interface:aop-test', name: 'AdvisedInterface', filePath: 'I.java', startLine: 1, endLine: 2, isExported: true, content: '', description: ''})`,
        );
        await coreExecuteQuery(
          `CREATE (:CodeElement {id: 'CodeElement:aop-interface-evidence', name: 'Transactional', filePath: 'I.java', startLine: 1, endLine: 1, isExported: false, content: '', description: 'Spring AOP evidence'})`,
        );
        await coreExecuteQuery(
          `MATCH (source:Interface {id: 'Interface:aop-test'}), (target:CodeElement {id: 'CodeElement:aop-interface-evidence'}) CREATE (source)-[:CodeRelation {type: 'ADVISED_BY', confidence: 1.0, reason: 'spring-aop:v1:{}', step: 0}]->(target)`,
        );
        const rows = await coreExecuteQuery(
          `MATCH (source)-[r:CodeRelation]->() WHERE source.id = 'Interface:aop-test' AND r.type = 'ADVISED_BY' RETURN source.id AS id`,
        );
        expect(rows).toEqual([{ id: 'Interface:aop-test' }]);
        await coreExecuteQuery(
          `MATCH (n) WHERE n.id IN ['Interface:aop-test', 'CodeElement:aop-interface-evidence'] DETACH DELETE n`,
        );
      });

      it('deleteSpringAutoConfigurationDeclarations: removes only Spring DECLARES edges (#2415)', async () => {
        const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationDeclarations } =
          await import('../../src/core/lbug/lbug-adapter.js');

        await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({
          edgesDeleted: 0,
        });
        const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as Array<{
          id: string;
        }>;
        expect(fns.length).toBe(2);
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-import', step: 0}]->(b)`,
        );
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'spring-auto-configuration-factory', step: 0}]->(b)`,
        );
        await coreExecuteQuery(
          `MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
            `CREATE (a)-[:CodeRelation {type: 'DECLARES', confidence: 1.0, reason: 'other-metadata-system', step: 0}]->(b)`,
        );

        await expect(deleteSpringAutoConfigurationDeclarations()).resolves.toEqual({
          edgesDeleted: 2,
        });
        const left = await coreExecuteQuery(
          `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' RETURN count(r) AS cnt`,
        );
        expect(Number((left[0] as { cnt: number }).cnt)).toBe(1);
      });

      it('deleteSpringAutoConfigurationSyntheticClasses: removes only metadata placeholders (#2415)', async () => {
        const { executeQuery: coreExecuteQuery, deleteSpringAutoConfigurationSyntheticClasses } =
          await import('../../src/core/lbug/lbug-adapter.js');

        await coreExecuteQuery(
          `CREATE (:Class {id: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}com.example.ExternalAutoConfiguration', ` +
            `name: 'ExternalAutoConfiguration', ` +
            `filePath: 'META-INF/spring.factories', ` +
            `description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`,
        );
        await coreExecuteQuery(
          `CREATE (:Class {id: 'Class:src/Real.java:Real', name: 'Real', ` +
            `filePath: 'src/Real.java', ` +
            `description: '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_DESCRIPTION}'})`,
        );
        await expect(deleteSpringAutoConfigurationSyntheticClasses()).resolves.toEqual({
          nodesDeleted: 1,
        });
        const syntheticLeft = await coreExecuteQuery(
          `MATCH (n:Class) WHERE n.id STARTS WITH '${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` +
            'RETURN count(n) AS cnt',
        );
        expect(Number((syntheticLeft[0] as { cnt: number }).cnt)).toBe(0);
        const realClasses = await coreExecuteQuery('MATCH (n:Class) RETURN count(n) AS cnt');
        expect(Number((realClasses[0] as { cnt: number }).cnt)).toBe(2);
      });

      describe('unhappy path', () => {
        it('throws on malformed Cypher query', async () => {
          const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

          // Deliberately broken syntax: MATCH without a pattern clause
          await expect(executeQuery('MATCH RETURN 1')).rejects.toThrow();
        });

        it('returns empty results for query matching no nodes', async () => {
          const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

          // Valid Cypher, but the id will never exist in the seeded graph
          const rows = await executeQuery(
            "MATCH (n:Function) WHERE n.id = '__nonexistent_id__' RETURN n.id AS id",
          );
          expect(rows).toHaveLength(0);
        });

        it('handles query with non-existent table/node label', async () => {
          const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

          // LadybugDB throws when the node table does not exist in the schema
          await expect(executeQuery('MATCH (n:GhostTable) RETURN n')).rejects.toThrow();
        });
      });

      describe('error handling', () => {
        it('createFTSIndex handles already-existing index gracefully', async (ctx) => {
          await skipUnlessFtsAvailable(ctx);
          const { createFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');

          // First call creates the index (may already exist from earlier test)
          await createFTSIndex('Function', 'function_fts_dup', ['name', 'content']);

          // Second call with same params should NOT throw — createFTSIndex catches "already exists"
          await expect(
            createFTSIndex('Function', 'function_fts_dup', ['name', 'content']),
          ).resolves.toBeUndefined();
        });

        it('ensureFTSIndex is idempotent and caches across writable calls (#1224)', async (ctx) => {
          await skipUnlessFtsAvailable(ctx);
          const { ensureFTSIndex } = await import('../../src/core/lbug/lbug-adapter.js');

          // First call creates the index. Second call must short-circuit on the
          // in-process cache — guarantees the read-only guard added in #1224
          // still respects the success path.
          await expect(
            ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
          ).resolves.toBeUndefined();
          await expect(
            ensureFTSIndex('Function', 'function_fts_ensure', ['name', 'content']),
          ).resolves.toBeUndefined();
        });

        it('getLbugStats returns valid counts', async () => {
          const { getLbugStats } = await import('../../src/core/lbug/lbug-adapter.js');

          // getLbugStats NEVER throws — it has silent catch blocks per table
          const stats = await getLbugStats();
          expect(typeof stats.nodes).toBe('number');
          expect(typeof stats.edges).toBe('number');
          expect(stats.nodes).toBeGreaterThanOrEqual(0);
          expect(stats.edges).toBeGreaterThanOrEqual(0);
        });

        it('executeQuery with empty string rejects', async () => {
          const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

          // LadybugDB throws on empty query string
          await expect(executeQuery('')).rejects.toThrow();
        });

        it('deleteNodesForFile with non-existent path returns zero deleted', async () => {
          const { deleteNodesForFile } = await import('../../src/core/lbug/lbug-adapter.js');

          // deleteNodesForFile has per-query try/catch, returns {deletedNodes: 0} for missing paths
          const result = await deleteNodesForFile('/absolutely/nonexistent/path/file.ts');
          expect(result).toEqual({ deletedNodes: 0 });
        });
      });

      itLbugReopen(
        'initLbug loads FTS so reopened HTTP-style sessions can query existing indexes',
        async (ctx) => {
          await skipUnlessFtsAvailable(ctx);
          const adapter = await import('../../src/core/lbug/lbug-adapter.js');
          const indexName = 'function_fts_init_probe';

          await adapter.createFTSIndex('Function', indexName, ['name', 'content']);
          await adapter.closeLbug();

          await adapter.initLbug(handle.dbPath);

          await expect(adapter.queryFTS('Function', indexName, 'main', 5)).resolves.toEqual(
            expect.arrayContaining([expect.objectContaining({ filePath: 'src/index.ts' })]),
          );
        },
      );

      // ── Cypher escaping sweep (#2409, tri-review 4669518496 P2-2) ─────
      // Quoted-value round-trips through the three string-built statement
      // builders that used SQL-style `''` doubling — which LadybugDB rejects
      // as a parse error, so every quoted value silently failed wherever the
      // call site swallowed per-row errors. Declared LAST on purpose: these
      // tests APPEND rows to the shared singleton DB, and the count-based
      // assertions above (getLbugStats, the loadGraphToLbug round-trip) run
      // first in declaration order.
      describe('string-built Cypher escaping (quoted values)', () => {
        it('insertNodeToLbug: quoted filePath/name/content round-trip by exact match', async () => {
          const { insertNodeToLbug, executeQuery } =
            await import('../../src/core/lbug/lbug-adapter.js');
          const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');

          const filePath = "src/es'cape-probe.ts";
          const inserted = await insertNodeToLbug('File', {
            id: `File:${filePath}`,
            name: "es'cape-probe.ts",
            filePath,
            content: "const s = 'quoted';",
          });
          expect(inserted).toBe(true);

          const rows = await executeQuery(
            `MATCH (n:File) WHERE n.filePath = '${escapeCypherString(filePath)}' ` +
              `RETURN n.id AS id, n.name AS name, n.content AS content`,
          );
          expect(rows).toEqual([
            { id: `File:${filePath}`, name: "es'cape-probe.ts", content: "const s = 'quoted';" },
          ]);
        });

        it('fallbackRelationshipInserts: quoted endpoint ids create the edge; quoted reason round-trips', async () => {
          const { fallbackRelationshipInserts, insertNodeToLbug, executeQuery } =
            await import('../../src/core/lbug/lbug-adapter.js');
          const { getNodeLabel } = await import('../../src/core/lbug/rel-pair-routing.js');
          const { REL_CSV_HEADER, buildRelRow } =
            await import('../../src/core/lbug/csv-generator.js');
          const { NODE_TABLES, REL_TABLE_NAME } = await import('../../src/core/lbug/schema.js');
          const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');

          const quotedFile = "src/we'ird.ts";
          const fnId = `Function:${quotedFile}:fn:1`;
          const fileId = `File:${quotedFile}`;
          expect(
            await insertNodeToLbug('Function', {
              id: fnId,
              name: 'fn',
              filePath: quotedFile,
              startLine: 1,
              endLine: 3,
              isExported: true,
              content: 'function fn() {}',
            }),
          ).toBe(true);
          expect(
            await insertNodeToLbug('File', {
              id: fileId,
              name: "we'ird.ts",
              filePath: quotedFile,
              content: '',
            }),
          ).toBe(true);

          // Real buildRelRow bytes + the real rel-pair-routing getNodeLabel —
          // exactly the shapes the production COPY-failure fallback receives.
          // Direction is File→Function because that is a pair the CodeRelation
          // rel table declares (schema.ts); Function→File is NOT declared, so
          // the reverse edge would exercise schema validation, not escaping.
          // NOTE (pre-existing narrowing, distinct from the `''` escaping bug
          // and NOT fixed here): the fallback's row regex matches fields with
          // `[^"]*`, so an id containing a double quote never matches and its
          // edge is skipped — see the fallbackRelationshipInserts TSDoc.
          const rel: GraphRelationship = {
            id: 'rel-escaping-sweep-1',
            sourceId: fileId,
            targetId: fnId,
            type: 'CALLS',
            confidence: 1,
            reason: "it's quoted",
            step: 0,
          };
          await fallbackRelationshipInserts(
            [REL_CSV_HEADER, buildRelRow(rel)],
            new Set<string>(NODE_TABLES),
            getNodeLabel,
          );

          const edges = await executeQuery(
            `MATCH (a)-[r:${REL_TABLE_NAME}]->(b) ` +
              `WHERE r.reason = '${escapeCypherString("it's quoted")}' ` +
              `RETURN a.id AS fromId, b.id AS toId, r.type AS type, r.reason AS reason`,
          );
          expect(edges).toEqual([
            { fromId: fileId, toId: fnId, type: 'CALLS', reason: "it's quoted" },
          ]);
        });

        itLbugReopen(
          'batchInsertNodesToLbug: quoted values MERGE cleanly over its own connection',
          async () => {
            // batchInsertNodesToLbug opens its OWN connection on dbPath, which
            // cannot coexist with the singleton's exclusive file lock — close
            // the singleton around the call and reopen after. win32-skipped
            // for the same close→reopen native lock regression as the FTS
            // reopen probe above. Labels are File + Class (NOT Function): the
            // earlier tests in this suite put FTS indexes on Function, and a
            // write to an FTS-indexed table fails on a connection that has not
            // loaded the FTS extension (probed on 0.18.0) — an orthogonal
            // engine behavior this escaping test must not trip over. Class
            // exercises the same TABLES_WITH_EXPORTED + description branch.
            const adapter = await import('../../src/core/lbug/lbug-adapter.js');
            const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');

            const filePath = "src/ba'tch.ts";
            await adapter.closeLbug();
            let result: { inserted: number; failed: number };
            try {
              result = await adapter.batchInsertNodesToLbug(
                [
                  {
                    label: 'File',
                    properties: {
                      id: `File:${filePath}`,
                      name: "ba'tch.ts",
                      filePath,
                      content: "let q = 'x';",
                    },
                  },
                  {
                    label: 'Class',
                    properties: {
                      id: `Class:${filePath}:K:1`,
                      name: 'K',
                      filePath,
                      startLine: 1,
                      endLine: 2,
                      isExported: false,
                      content: '',
                      description: "batch'd",
                    },
                  },
                ],
                handle.dbPath,
              );
            } finally {
              await adapter.initLbug(handle.dbPath);
            }
            expect(result).toEqual({ inserted: 2, failed: 0 });

            const rows = await adapter.executeQuery(
              `MATCH (n:Class) WHERE n.filePath = '${escapeCypherString(filePath)}' ` +
                `RETURN n.name AS name, n.description AS description`,
            );
            expect(rows).toEqual([{ name: 'K', description: "batch'd" }]);
          },
        );

        it('backslash and raw-LF/CR values round-trip byte-identical', async () => {
          // The old escapeValue closures rewrote literal \n / \r into
          // two-character escape sequences; raw LF/CR are legal inside
          // LadybugDB single-quoted literals (live-probed on 0.18.0), so the
          // replaces are gone and content bytes must survive unchanged.
          const { insertNodeToLbug, executeQuery } =
            await import('../../src/core/lbug/lbug-adapter.js');
          const { escapeCypherString } = await import('../../src/core/lbug/cypher-escape.js');

          const id = 'File:src/bytes-probe.ts';
          const content = "line1\nC:\\temp\\it's ok\r\nline3";
          expect(
            await insertNodeToLbug('File', {
              id,
              name: 'bytes-probe.ts',
              filePath: 'src/bytes-probe.ts',
              content,
            }),
          ).toBe(true);

          const rows = await executeQuery(
            `MATCH (n:File) WHERE n.id = '${escapeCypherString(id)}' RETURN n.content AS content`,
          );
          expect(rows).toEqual([{ content }]);
        });
      });
    });
  },
  {
    afterSetup: async (handle) => {
      // Load a minimal graph via CSV round-trip (core adapter is already initialized by wrapper)
      const { loadGraphToLbug } = await import('../../src/core/lbug/lbug-adapter.js');
      const { createMinimalTestGraph } = await import('../helpers/test-graph.js');

      const graph = createMinimalTestGraph();
      const storagePath = path.join(handle.tmpHandle.dbPath, 'storage');
      await fs.mkdir(storagePath, { recursive: true });

      await loadGraphToLbug(graph, '/test/repo', storagePath);
    },
  },
);
