/**
 * The wiki's graph queries, executed by a REAL LadybugDB.
 *
 * `test/unit/wiki-graph-queries-list-binding.test.ts` mocks the pool adapter and
 * answers from a hand-written JS reimplementation dispatched on
 * `query.includes(...)`. That is the right instrument for query SHAPE — that a
 * module's file list is bound rather than spliced into the text — and the wrong
 * one for everything the ENGINE decides. Two bugs shipped through that blind
 * spot on this branch:
 *
 *  - a `--` comment inside a Cypher string (Cypher comments are `//`), which
 *    LadybugDB rejects at PREPARE and `detect_changes` swallowed into "No
 *    changes detected."; every mocked test passed.
 *  - `ORDER BY pid, r.step` next to `WHERE p.id IN $ids`, which drops the second
 *    sort key once the scan is large enough and hands back partially sorted
 *    runs. `formatProcesses` (prompts.ts) prints `${s.step}. ${s.name}`, so
 *    every module and overview page carried a scrambled execution trace. The
 *    fake returned rows pre-ordered per pid, so it could not see it.
 *
 * So: mock for shape, engine for semantics. Everything below drives the real
 * exported functions through the real pool adapter.
 */
import { afterAll, describe, expect, it } from 'vitest';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import {
  closeWikiDb,
  getAllFiles,
  getAllProcesses,
  getFilesWithExports,
  getInterFileCallEdges,
  getInterModuleCallEdges,
  getInterModuleEdgesForOverview,
  getIntraModuleCallEdges,
  getProcessesForFiles,
  initWikiDb,
} from '../../src/core/wiki/graph-queries.js';
import { CALL_EDGE_LIMIT } from '../../src/core/wiki/prompts.js';
import { compareCodeUnits } from '../../src/lib/utils.js';

// ─── Fixture ──────────────────────────────────────────────────────────────

const ALPHA = 'src/mod/alpha.ts';
const BETA = 'src/mod/beta.ts';
const GAMMA = 'src/other/gamma.ts';
/** A tracked file with no exported symbol — `getAllFiles` sees it, the other doesn't. */
const EMPTY = 'src/empty/void.ts';

/** The module every module-scoped query below is asked about. */
const MODULE_FILES = [ALPHA, BETA];

const pad = (n: number): string => String(n).padStart(2, '0');

/**
 * 40 bulk callers plus the two hand-written intra-module edges put 42 rows in
 * front of `CALL_EDGE_LIMIT` (imported above from prompts.ts, the one place
 * that number lives), so the LIMIT has to cut — and `bulkNN` sorts after both
 * hand-written names, which makes the kept set an exactly predictable ordered
 * prefix.
 */
const BULK_CALLERS = Array.from({ length: 40 }, (_, i) => `bulk${pad(i)}`);

/**
 * Twenty processes with DISTINCT step counts, 45 down to 26 — 710 step edges.
 *
 * Distinct on purpose: the header query is `ORDER BY stepCount DESC, id`, and a
 * fixture that needed the tie-breaker would rest the whole suite on the same
 * second-sort-key behavior these tests exist to distrust. Here the leading key
 * is already a total order, and `getAllProcesses()`'s default LIMIT 20 lands
 * exactly on this set.
 *
 * The SIZE is load-bearing. The dropped-second-key defect belongs to the plan
 * the engine picks and does NOT appear on a toy scan — measured on this
 * fixture's shape, `ORDER BY pid, r.step` returns perfectly sorted rows at 100
 * step edges, is intermittent around 400, and scrambled in 6 of 6 runs here.
 * A smaller fixture would leave the ordering assertion below unable to fail
 * against the bug it names, which is the whole reason this file exists.
 */
const TRACE_PROCESSES = Array.from({ length: 20 }, (_, i) => ({
  id: `proc-${pad(i)}`,
  stepCount: 45 - i,
}));

const MAX_STEPS = TRACE_PROCESSES[0].stepCount;
/** Reused across processes, so 710 step edges need only 45 symbols. */
const STEP_SYMBOLS = Array.from({ length: MAX_STEPS }, (_, i) => ({
  name: `step-${pad(i + 1)}`,
  file: i % 2 === 0 ? ALPHA : BETA,
}));
const GAMMA_STEP_SYMBOLS = Array.from({ length: 3 }, (_, i) => ({
  name: `gstep-${pad(i + 1)}`,
  file: GAMMA,
}));

/**
 * Step edges, seeded DESCENDING within each process and interleaved across
 * them: slot `j` writes step `stepCount - j` for every process still that long.
 *
 * So insertion order is the exact REVERSE of the order every assertion below
 * demands, for all 20 processes at once, and no grouping of the rows can make
 * it look sorted by accident. That is what gives the ordering test teeth: with
 * `ORDER BY pid, r.step` the engine leaks this seeded order back out.
 */
const STEP_EDGES: Array<{ proc: string; symbol: string; step: number }> = Array.from(
  { length: MAX_STEPS },
  (_, slot) => slot,
).flatMap((slot) =>
  TRACE_PROCESSES.map((proc) => ({ proc: proc.id, step: proc.stepCount - slot }))
    .filter((e) => e.step >= 1)
    .map((e) => ({ ...e, symbol: STEP_SYMBOLS[e.step - 1].name })),
);

const symbolFile = new Map(
  [...STEP_SYMBOLS, ...GAMMA_STEP_SYMBOLS].map((s) => [s.name, s.file] as const),
);

const fn = (
  file: string,
  name: string,
  isExported: boolean,
  line: number,
): string => `{id: 'Function:${file}:${name}', name: '${name}', filePath: '${file}',
     startLine: ${line}, endLine: ${line}, isExported: ${isExported}, content: '', description: ''}`;

/** `step` is interpolated raw, so a caller may pass a Cypher expression. */
const rel = (type: string, step: number | string = 0): string =>
  `[:CodeRelation {type: '${type}', confidence: 1.0, reason: 'seed', step: ${step}}]`;

/** Every STEP_IN_PROCESS edge this fixture needs, seeded together below. */
const ALL_STEP_EDGES = [
  ...STEP_EDGES,
  ...GAMMA_STEP_SYMBOLS.map((s, i) => ({ proc: 'proc-gamma', symbol: s.name, step: i + 1 })),
  { proc: 'proc-blank', symbol: STEP_SYMBOLS[0].name, step: 1 },
];

/**
 * All 714 step edges in ONE statement.
 *
 * `withTestLbugDB` runs each seed string as its own query, so one
 * `MATCH … CREATE` per edge is 714 round trips — measured at 6.91s wall for
 * this file against 0.19s for its mocked sibling, on a pool whose bare read
 * round trip is 0.53ms. The edges themselves are unchanged: the list keeps its
 * seeded order (see STEP_EDGES) and every row still resolves both endpoints by
 * id, so nothing about what the ordering tests below can observe moves.
 */
const stepEdges = (edges: typeof ALL_STEP_EDGES): string =>
  `UNWIND [${edges
    .map(
      (e) =>
        `{sid: 'Function:${symbolFile.get(e.symbol)}:${e.symbol}', pid: '${e.proc}', step: ${e.step}}`,
    )
    .join(', ')}] AS e
   MATCH (s:Function), (p:Process) WHERE s.id = e.sid AND p.id = e.pid
   CREATE (s)-${rel('STEP_IN_PROCESS', 'e.step')}->(p)`;

const SEED: string[] = [
  // Files
  `CREATE (f:File {id: 'File:${ALPHA}', name: 'alpha.ts', filePath: '${ALPHA}', content: ''})`,
  `CREATE (f:File {id: 'File:${BETA}', name: 'beta.ts', filePath: '${BETA}', content: ''})`,
  `CREATE (f:File {id: 'File:${GAMMA}', name: 'gamma.ts', filePath: '${GAMMA}', content: ''})`,
  `CREATE (f:File {id: 'File:${EMPTY}', name: 'void.ts', filePath: '${EMPTY}', content: ''})`,

  // Exported top-level symbols — UNION arm 1 of getFilesWithExports
  `CREATE (n:Function ${fn(ALPHA, 'alphaFn', true, 1)})`,
  `CREATE (n:Function ${fn(BETA, 'betaFn', true, 1)})`,
  `CREATE (n:Function ${fn(GAMMA, 'gammaFn', true, 1)})`,
  `CREATE (n:Class {id: 'Class:${BETA}:BetaService', name: 'BetaService', filePath: '${BETA}',
     startLine: 10, endLine: 20, isExported: true, content: '', description: '',
     frameworkAnnotations: []})`,
  // Exported class member — reachable only through UNION arm 2
  `CREATE (n:Method {id: 'Method:${BETA}:serve', name: 'serve', filePath: '${BETA}',
     startLine: 12, endLine: 14, isExported: true, content: '', description: '',
     parameterCount: 0, returnType: 'void'})`,

  // Unexported call fodder and step symbols
  `CREATE (n:Function ${fn(BETA, 'sink', false, 30)})`,
  `CREATE ${BULK_CALLERS.map((name, i) => `(:Function ${fn(ALPHA, name, false, 100 + i)})`).join(', ')}`,
  `CREATE ${[...STEP_SYMBOLS, ...GAMMA_STEP_SYMBOLS]
    .map((s, i) => `(:Function ${fn(s.file, s.name, false, 200 + i)})`)
    .join(', ')}`,

  // File → symbol DEFINES. The label is named on both ends: LadybugDB refuses to
  // CREATE a relationship whose endpoint is bound to several node labels.
  ...[
    [ALPHA, 'Function', `Function:${ALPHA}:alphaFn`],
    [BETA, 'Function', `Function:${BETA}:betaFn`],
    [BETA, 'Class', `Class:${BETA}:BetaService`],
    [GAMMA, 'Function', `Function:${GAMMA}:gammaFn`],
  ].map(
    ([file, label, id]) =>
      `MATCH (f:File), (n:${label}) WHERE f.id = 'File:${file}' AND n.id = '${id}'
       CREATE (f)-${rel('DEFINES')}->(n)`,
  ),
  `MATCH (c:Class), (m:Method)
   WHERE c.id = 'Class:${BETA}:BetaService' AND m.id = 'Method:${BETA}:serve'
   CREATE (c)-${rel('HAS_METHOD')}->(m)`,

  // Call edges: two inside the module, one out, one in, 40 bulk inside.
  ...[
    [`Function:${ALPHA}:alphaFn`, `Function:${BETA}:betaFn`],
    [`Function:${BETA}:betaFn`, `Function:${ALPHA}:alphaFn`],
    [`Function:${ALPHA}:alphaFn`, `Function:${GAMMA}:gammaFn`],
    [`Function:${GAMMA}:gammaFn`, `Function:${BETA}:betaFn`],
  ].map(
    ([from, to]) =>
      `MATCH (a:Function), (b:Function) WHERE a.id = '${from}' AND b.id = '${to}'
       CREATE (a)-${rel('CALLS')}->(b)`,
  ),
  `MATCH (a:Function), (b:Function)
   WHERE a.name STARTS WITH 'bulk' AND b.id = 'Function:${BETA}:sink'
   CREATE (a)-${rel('CALLS')}->(b)`,

  // Processes.
  ...TRACE_PROCESSES.map(
    (p) =>
      `CREATE (p:Process {id: '${p.id}', label: 'L${p.id}', heuristicLabel: 'Flow ${p.id}',
         processType: 'intra_community', stepCount: ${p.stepCount}, communities: [],
         entryPointId: '', terminalId: ''})`,
  ),
  // Steps entirely outside the module — visible to getAllProcesses, invisible to
  // getProcessesForFiles(MODULE_FILES).
  `CREATE (p:Process {id: 'proc-gamma', label: 'LGamma', heuristicLabel: 'Gamma Flow',
     processType: 'cross_community', stepCount: 3, communities: [], entryPointId: '', terminalId: ''})`,
  // An EMPTY label and type: `??` keeps them, where the `||` this replaced
  // substituted the id and 'unknown'.
  `CREATE (p:Process {id: 'proc-blank', label: 'LBlank', heuristicLabel: '',
     processType: '', stepCount: 1, communities: [], entryPointId: '', terminalId: ''})`,
  // No heuristicLabel/processType column at all — the genuine NULL, which must
  // still fall back to the id and 'unknown'. No steps either.
  `CREATE (p:Process {id: 'proc-null', label: 'LNull', stepCount: 0, communities: []})`,

  stepEdges(ALL_STEP_EDGES),
];

/** The trace every `proc-NN` must come back with: 1..stepCount, ascending. */
const expectedTrace = (stepCount: number): number[] =>
  Array.from({ length: stepCount }, (_, i) => i + 1);

// ─── Suite ────────────────────────────────────────────────────────────────

withTestLbugDB(
  'wiki-graph-queries-engine',
  () => {
    // Nested so this afterAll is guaranteed to run BEFORE withTestLbugDB's own
    // teardown closes the Database these pooled connections were opened from.
    describe('#2915 wiki graph queries against a real engine', () => {
      afterAll(async () => {
        await closeWikiDb();
      });

      it('prepares and executes every exported query', async () => {
        // A malformed query throws at PREPARE inside `executeParameterized`, so
        // calling each function IS the prepare test — the `--`-comment class of
        // bug cannot reach a wiki page without failing here.
        await expect(
          Promise.all([
            getAllFiles(),
            getFilesWithExports(),
            getInterFileCallEdges(),
            getIntraModuleCallEdges(MODULE_FILES),
            getInterModuleCallEdges(MODULE_FILES),
            getProcessesForFiles(MODULE_FILES),
            getAllProcesses(),
            // Aggregates in JS over `getInterFileCallEdges`, so it issues no
            // Cypher of its own — included anyway because this test claims to
            // cover every exported query, and `generateOverview` calls it.
            getInterModuleEdgesForOverview({ mod: MODULE_FILES, other: [GAMMA] }),
          ]),
        ).resolves.toBeDefined();
      });

      it('returns every tracked file, including one with no exports', async () => {
        expect(await getAllFiles()).toEqual([EMPTY, ALPHA, BETA, GAMMA]);
      });

      it('labels each exported symbol with its real node label, not an empty string', async () => {
        // `labels(n)[0]`: the engine returns a node's label as a SCALAR string,
        // and subscripting a string is 1-based over characters, so `[0]` was ''
        // and `formatFileListForGrouping` (prompts.ts) described every exported
        // symbol to the LLM as `name ()`.
        const byFile = new Map((await getFilesWithExports()).map((f) => [f.filePath, f.symbols]));

        expect(byFile.get(ALPHA)).toEqual([{ name: 'alphaFn', type: 'Function' }]);
        // UNION arm 1 (Function, Class) and arm 2 (Method, via HAS_METHOD) each
        // carry a label — the subscript blanked both sites.
        expect(
          [...(byFile.get(BETA) ?? [])].sort((a, b) => compareCodeUnits(a.name, b.name)),
        ).toEqual([
          { name: 'BetaService', type: 'Class' },
          { name: 'betaFn', type: 'Function' },
          { name: 'serve', type: 'Method' },
        ]);
        expect(byFile.has(EMPTY)).toBe(false);
      });

      it('returns cross-file call edges only', async () => {
        const edges = await getInterFileCallEdges();

        expect(edges).toContainEqual({
          fromFile: ALPHA,
          fromName: 'alphaFn',
          toFile: GAMMA,
          toName: 'gammaFn',
        });
        expect(edges.filter((e) => e.fromFile === e.toFile)).toEqual([]);
      });

      it('cuts the intra-module edge list at the limit, keeping the ordered prefix', async () => {
        const edges = await getIntraModuleCallEdges(MODULE_FILES);

        // 42 edges match; `ORDER BY fromName, toName, fromFile, toFile / LIMIT`
        // decides which 30 survive. Drop the LIMIT and this is 42 rows; drop the
        // ORDER BY and the engine picks an arbitrary 30 (#2787).
        expect(edges).toHaveLength(CALL_EDGE_LIMIT);
        expect(edges.map((e) => e.fromName)).toEqual([
          'alphaFn',
          'betaFn',
          ...BULK_CALLERS.slice(0, CALL_EDGE_LIMIT - 2),
        ]);
        expect(edges[0]).toEqual({
          fromFile: ALPHA,
          fromName: 'alphaFn',
          toFile: BETA,
          toName: 'betaFn',
        });
      });

      it('splits inter-module edges by direction and excludes intra-module ones', async () => {
        const { outgoing, incoming } = await getInterModuleCallEdges(MODULE_FILES);

        expect(outgoing).toEqual([
          { fromFile: ALPHA, fromName: 'alphaFn', toFile: GAMMA, toName: 'gammaFn' },
        ]);
        expect(incoming).toEqual([
          { fromFile: GAMMA, fromName: 'gammaFn', toFile: BETA, toName: 'betaFn' },
        ]);
      });

      it('returns every overview trace in ascending step order', async () => {
        // The regression this file exists for, through the exact call the
        // overview page makes (`getAllProcesses()`, default limit 20).
        const processes = await getAllProcesses();

        expect(processes.map((p) => ({ id: p.id, steps: p.steps.map((s) => s.step) }))).toEqual(
          TRACE_PROCESSES.map((p) => ({ id: p.id, steps: expectedTrace(p.stepCount) })),
        );
        // Grouping and sorting are separate properties: the longest and the
        // shortest trace must each hold ITS OWN symbols, in order.
        const longest = TRACE_PROCESSES[0];
        const shortest = TRACE_PROCESSES[TRACE_PROCESSES.length - 1];
        expect(processes[0].steps.map((s) => s.name)).toEqual(
          STEP_SYMBOLS.slice(0, longest.stepCount).map((s) => s.name),
        );
        expect(processes[processes.length - 1].steps.map((s) => s.name)).toEqual(
          STEP_SYMBOLS.slice(0, shortest.stepCount).map((s) => s.name),
        );
      });

      it('scopes processes to the files asked about, still in step order', async () => {
        const [inModule, inGamma] = await Promise.all([
          getProcessesForFiles(MODULE_FILES, 20),
          getProcessesForFiles([GAMMA], 5),
        ]);

        // proc-gamma's steps live outside the module, so it is not a module process.
        expect(inModule.map((p) => p.id)).toEqual(TRACE_PROCESSES.map((p) => p.id));
        expect(inModule.map((p) => p.steps.map((s) => s.step))).toEqual(
          TRACE_PROCESSES.map((p) => expectedTrace(p.stepCount)),
        );
        expect(inGamma.map((p) => p.id)).toEqual(['proc-gamma']);
        expect(inGamma[0].steps.map((s) => s.name)).toEqual(GAMMA_STEP_SYMBOLS.map((s) => s.name));
      });

      it('labels each step with its real node label', async () => {
        // The third `labels(x)[0]` site, reached only through withSteps.
        const [first] = await getAllProcesses();

        expect([...new Set(first.steps.map((s) => s.type))]).toEqual(['Function']);
        expect([...new Set(first.steps.map((s) => s.filePath))].sort(compareCodeUnits)).toEqual([
          ALPHA,
          BETA,
        ]);
      });

      it('keeps an empty label and type, and falls back only for a null one', async () => {
        const byId = new Map((await getAllProcesses(30)).map((p) => [p.id, p]));

        // `??`, not `||`: a process genuinely labelled '' keeps ''.
        expect(byId.get('proc-blank')).toMatchObject({ label: '', type: '', stepCount: 1 });
        // A NULL column still falls back — to the id, and to 'unknown'.
        expect(byId.get('proc-null')).toMatchObject({
          label: 'proc-null',
          type: 'unknown',
          stepCount: 0,
        });
        // …and a process with no STEP_IN_PROCESS edge gets an empty trace, not a
        // borrowed one: the grouped query returns no row for it at all.
        expect(byId.get('proc-null')?.steps).toEqual([]);
      });

      it('ranks processes by step count across the whole graph', async () => {
        const processes = await getAllProcesses(30);

        expect(processes.map((p) => p.id)).toEqual([
          ...TRACE_PROCESSES.map((p) => p.id),
          'proc-gamma',
          'proc-blank',
          'proc-null',
        ]);
        expect(processes[0].label).toBe('Flow proc-00');
      });
    });
  },
  {
    seed: SEED,
    poolAdapter: true,
    // graph-queries.ts pins its own repo id (`__wiki__`) inside the module, so
    // the suite opens a second pool entry onto the SAME Database the helper
    // injected — initLbug reuses the cached handle for this dbPath rather than
    // taking a second file lock.
    afterSetup: async (handle) => {
      await initWikiDb(handle.dbPath);
    },
  },
);
