/**
 * GraphEmitSink unit tests (issue #2680).
 *
 * Verifies the streaming structural emit sink:
 *  - routes non-retained relationships to bounded CSV-on-disk and never stores
 *    them, while retained types reach the real graph untouched;
 *  - dedups by relationship id (the whole-graph emit does, and COPY into a
 *    PK-bearing table would violate on a repeat) — PdgEmitSink relies on an
 *    upstream per-file guarantee that does NOT exist for structural edges;
 *  - refuses to silently forget a streamed edge on removeRelationship;
 *  - exposes the streamed-endpoint predicate the local-symbol pruner needs to
 *    avoid pruning a node that a streamed edge still references;
 *  - fails loudly rather than handing a truncated CSV to the bulk COPY.
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import {
  GraphEmitSink,
  RETAINED_REL_TYPES,
  StreamedRelationshipRemovalError,
} from '../../../src/core/lbug/graph-emit-sink.js';
import type { GraphRelationship } from 'gitnexus-shared';

const fnId = (name: string): string => `Function:src/a.ts:${name}`;

const rel = (
  type: GraphRelationship['type'],
  from: string,
  to: string,
  suffix = '',
): GraphRelationship => ({
  id: `${type}:${fnId(from)}->${fnId(to)}${suffix}`,
  sourceId: fnId(from),
  targetId: fnId(to),
  type,
  confidence: 1,
  reason: 'direct',
});

const dataRows = async (csvPath: string): Promise<string[]> => {
  const text = await fsp.readFile(csvPath, 'utf8');
  return text
    .split('\n')
    .filter((l) => l.length > 0)
    .slice(1); // drop header
};

let tmpRoot: string;
let csvDir: string;

beforeEach(() => {
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-emit-sink-'));
  csvDir = path.join(tmpRoot, 'streamed');
});

afterEach(() => {
  fs.rmSync(tmpRoot, { recursive: true, force: true });
});

describe('GraphEmitSink routing', () => {
  it('streams a non-retained type to CSV and keeps it out of the graph', async () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    sink.addRelationship(rel('CALLS', 'a', 'b'));
    const manifest = sink.finalize();

    expect(real.relationshipCount).toBe(0);
    expect(manifest).toMatchObject({ totalRows: 1 });
    const pair = manifest.relsByPair.get('Function|Function');
    expect(pair).toMatchObject({ rows: 1 });
    expect(await dataRows(pair!.csvPath)).toHaveLength(1);
  });

  it('delegates every retained type to the real graph and writes no CSV', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    for (const type of RETAINED_REL_TYPES) {
      sink.addRelationship(rel(type, 'a', 'b', `:${type}`));
    }
    const manifest = sink.finalize();

    expect(real.relationshipCount).toBe(RETAINED_REL_TYPES.size);
    expect(manifest).toMatchObject({ totalRows: 0 });
    expect(manifest.relsByPair.size).toBe(0);
  });

  it('never streams nodes — they stay in the real graph', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    sink.addNode({
      id: fnId('a'),
      label: 'Function',
      properties: { name: 'a', filePath: 'src/a.ts', startLine: 1, endLine: 2 },
    });
    sink.finalize();

    expect(real.nodeCount).toBe(1);
    expect(fs.readdirSync(csvDir)).toEqual([]);
  });

  it('skips edges whose endpoint labels are not valid node tables', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    sink.addRelationship({
      id: 'CALLS:bogus->alsobogus',
      sourceId: 'NotATable:src/a.ts:x',
      targetId: 'NotATable:src/a.ts:y',
      type: 'CALLS',
      confidence: 1,
      reason: 'direct',
    });
    const manifest = sink.finalize();

    expect(manifest).toMatchObject({ totalRows: 0 });
    expect(real.relationshipCount).toBe(0);
  });

  it('throws rather than silently dropping an undeclared endpoint-label pair (#2769)', () => {
    // Before #2769's fix, this pair reached COPY, failed the bulk insert, and
    // the per-edge fallback swallowed the failure into `catch {}` — the run
    // still exited 0 with the edge silently missing. Both endpoints are valid
    // node tables (so the validTables gate above does not catch it); the pair
    // itself is simply absent from RELATION_SCHEMA.
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    const undeclared: GraphRelationship = {
      id: 'CALLS:Static:a->Static:b',
      sourceId: 'Static:src/a.ts:A',
      targetId: 'Static:src/a.ts:B',
      type: 'CALLS',
      confidence: 1,
      reason: 'direct',
    };
    expect(() => sink.addRelationship(undeclared)).toThrow(
      /Relationship label pair Static → Static is not declared/,
    );
  });
});

describe('GraphEmitSink arming', () => {
  it('retains everything in the graph until armed', () => {
    // The pre-parse phases are not all write-only: mapCobolToGraph scans CALLS
    // edges and removes the unresolved ones. If the sink streamed from
    // construction, that scan would see nothing and COBOL cross-program calls
    // would silently stop resolving.
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);

    sink.addRelationship(rel('CALLS', 'a', 'b'));

    expect(real.relationshipCount).toBe(1);
    expect(sink.finalize()).toMatchObject({ totalRows: 0 });
  });

  it('removal of a pre-arm CALLS edge still works (the COBOL path)', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    const unresolved = rel('CALLS', 'a', 'b');
    sink.addRelationship(unresolved);

    expect(sink.removeRelationship(unresolved.id)).toBe(true);
    expect(real.relationshipCount).toBe(0);
    sink.finalize();
  });
});

describe('GraphEmitSink dedup', () => {
  it('writes a duplicate relationship id exactly once', async () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    const duplicated = rel('CALLS', 'a', 'b');
    sink.addRelationship(duplicated);
    sink.addRelationship(duplicated);
    sink.addRelationship({ ...duplicated });
    const manifest = sink.finalize();

    // A second row would violate the relationship table's PK on COPY.
    expect(manifest).toMatchObject({ totalRows: 1 });
    expect(await dataRows(manifest.relsByPair.get('Function|Function')!.csvPath)).toHaveLength(1);
  });
});

describe('GraphEmitSink removal safety', () => {
  it('throws rather than silently forgetting an already-streamed edge', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();
    const streamed = rel('CALLS', 'a', 'b');
    sink.addRelationship(streamed);

    expect(() => sink.removeRelationship(streamed.id)).toThrow(StreamedRelationshipRemovalError);
    sink.finalize();
  });

  it('still removes a retained edge normally', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();
    const retained = rel('DEFINES', 'a', 'b');
    sink.addRelationship(retained);

    expect(sink.removeRelationship(retained.id)).toBe(true);
    expect(real.relationshipCount).toBe(0);
    sink.finalize();
  });
});

describe('GraphEmitSink reads are complete', () => {
  it('iterRelationships returns streamed edges alongside retained ones', () => {
    // This is the property that lets streaming be the default: every consumer
    // (communities, processes, taint, the pruner) reads through this and must
    // see the whole graph, not just what stayed in memory.
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    sink.addRelationship(rel('DEFINES', 'file', 'fn')); // retained
    sink.addRelationship(rel('CALLS', 'a', 'b')); // streamed
    sink.addRelationship(rel('ACCESSES', 'b', 'c')); // streamed

    const seen = [...sink.iterRelationships()];
    expect(seen.map((r) => r.type).sort()).toEqual(['ACCESSES', 'CALLS', 'DEFINES']);
    expect(sink.relationshipCount).toBe(3);
    // The real graph still holds only the retained one — the saving is real.
    expect(real.relationshipCount).toBe(1);
    sink.finalize();
  });

  it('preserves endpoints and confidence on a streamed edge', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship({ ...rel('CALLS', 'caller', 'callee'), confidence: 0.25 });

    expect([...sink.iterRelationships()]).toMatchObject([
      { sourceId: fnId('caller'), targetId: fnId('callee'), type: 'CALLS', confidence: 0.25 },
    ]);
    sink.finalize();
  });

  it('iterRelationshipsByType finds a streamed type', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CALLS', 'a', 'b'));
    sink.addRelationship(rel('ACCESSES', 'a', 'c'));

    expect([...sink.iterRelationshipsByType('CALLS')]).toHaveLength(1);
    expect([...sink.iterRelationshipsByType('ACCESSES')]).toHaveLength(1);
    expect([...sink.iterRelationshipsByType('EXTENDS')]).toEqual([]);
    sink.finalize();
  });

  it('forEachRelationship visits streamed edges too', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CALLS', 'a', 'b'));

    const visited: string[] = [];
    sink.forEachRelationship((r) => visited.push(r.type));
    expect(visited).toEqual(['CALLS']);
    sink.finalize();
  });
});

describe('GraphEmitSink IO faults', () => {
  it('surfaces a writer-open failure from finalize instead of a partial manifest', () => {
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CALLS', 'a', 'b'));

    // Destroy the CSV dir so the next pair's writer cannot be opened, the way
    // an out-of-fds (EMFILE) or disk-full run would fail mid-emit.
    fs.rmSync(csvDir, { recursive: true, force: true });
    expect(() =>
      sink.addRelationship({
        id: 'CALLS:File:src/a.ts->Function:src/a.ts:b',
        sourceId: 'File:src/a.ts',
        targetId: fnId('b'),
        type: 'CALLS',
        confidence: 1,
        reason: 'direct',
      }),
    ).toThrow();

    expect(() => sink.finalize()).toThrow(/streamed CSV writer\(s\) hit an IO error/);
  });

  it('refuses a second finalize', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.finalize();
    expect(() => sink.finalize()).toThrow(/called twice/);
  });
});

describe('dedup key exactness', () => {
  const endpoints = { sourceId: fnId('f'), targetId: fnId('g') };
  const withId = (id: string): GraphRelationship => ({
    id,
    ...endpoints,
    type: 'CALLS',
    confidence: 1,
    reason: 'direct',
  });

  it('keeps two ids that differ only in how many tail segments they carry', () => {
    // Regression: the dedup key packs the id's trailing numeric segments, and an
    // absent second segment defaults to 0. Without the segment COUNT in the key,
    // `:7` and `:7:0` collapse onto one key and the second edge is silently
    // discarded — a lost relationship with no error. Distinct ids must never
    // collapse; identical ones must (see the duplicate test above).
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();

    sink.addRelationship(withId(`rel:CALLS:${endpoints.sourceId}->${endpoints.targetId}:7`));
    sink.addRelationship(withId(`rel:CALLS:${endpoints.sourceId}->${endpoints.targetId}:7:0`));

    expect(sink.relationshipCount).toBe(2);
    expect(sink.finalize()).toMatchObject({ totalRows: 2 });
  });

  it('keeps two call sites between the same pair', () => {
    // The `:line:col` case from emit-references — same endpoints and type, so
    // identical CSV rows; only the id distinguishes them, and the whole-graph
    // emit keeps both.
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();

    sink.addRelationship(withId(`rel:CALLS:${endpoints.sourceId}->${endpoints.targetId}:10:4`));
    sink.addRelationship(withId(`rel:CALLS:${endpoints.sourceId}->${endpoints.targetId}:99:7`));

    expect(sink.relationshipCount).toBe(2);
    sink.finalize();
  });

  it('still collapses a genuinely repeated id', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    const id = `rel:CALLS:${endpoints.sourceId}->${endpoints.targetId}:10:4`;

    sink.addRelationship(withId(id));
    sink.addRelationship(withId(id));

    expect(sink.relationshipCount).toBe(1);
    sink.finalize();
  });

  it('falls back to the full id for a non-numeric tail', () => {
    // `rel:imports:...:${localName}` has a textual tail; the compact form does
    // not apply and the id must be stored verbatim rather than truncated.
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();

    sink.addRelationship(withId(`rel:IMPORTS:${endpoints.sourceId}->${endpoints.targetId}:alpha`));
    sink.addRelationship(withId(`rel:IMPORTS:${endpoints.sourceId}->${endpoints.targetId}:beta`));

    expect(sink.relationshipCount).toBe(2);
    sink.finalize();
  });
});

describe('removeRelationship contract divergence', () => {
  it('throws for an absent id once streaming has begun, by design', () => {
    // KnowledgeGraph.removeRelationship returns false for an id it does not
    // hold. The sink cannot rebuild a compact dedup key from a bare id, so it
    // refuses to answer "false" for something that might already be on disk and
    // unrecallable. Pinned so the divergence stays deliberate.
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CALLS', 'a', 'b'));

    expect(() => sink.removeRelationship('rel:CALLS:never:emitted')).toThrow(
      StreamedRelationshipRemovalError,
    );
    sink.finalize();
  });

  it('returns false for an absent id before anything has streamed', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();

    expect(sink.removeRelationship('rel:CALLS:never:emitted')).toBe(false);
    sink.finalize();
  });
});

describe('field scan matches the object scan', () => {
  it('yields the same (source, target, type, confidence) tuples either way', () => {
    // Guards the five whole-graph scans converted to forEachRelationshipFields:
    // a divergence between the two forms would silently skew community
    // detection, process extraction and the pruner.
    const real = createKnowledgeGraph();
    const sink = new GraphEmitSink(real, csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('DEFINES', 'file', 'fn'));
    sink.addRelationship(rel('CALLS', 'a', 'b'));
    sink.addRelationship({ ...rel('ACCESSES', 'b', 'c'), confidence: 0.5 });

    const viaObjects = [...sink.iterRelationships()]
      .map((r) => `${r.sourceId}|${r.targetId}|${r.type}|${r.confidence}`)
      .sort();
    const viaFields: string[] = [];
    sink.forEachRelationshipFields((s, t, ty, c) => viaFields.push(`${s}|${t}|${ty}|${c}`));

    expect(viaFields.sort()).toEqual(viaObjects);
    sink.finalize();
  });
});

/**
 * STRUCTURAL SUBTOTAL (#2899 regression).
 *
 * `totalRows` is a buffer-pool size hint and counts every streamed row. The
 * graph-write-collapse check reused it as its expectation while measuring
 * STRUCTURAL rows on the other side — and PDG edges stream through this very
 * sink, so on a `--pdg` run it compared ~200k against ~65k and declared a
 * complete index INCOMPLETE. The stamp then forced a rebuild next run, which
 * repeated it.
 *
 * A pair key cannot separate the two: it is `From|To` NODE LABELS, and a `CFG`
 * edge shares `Function|Function` with `CALLS`. Only this write path sees
 * `relationship.type`, so the split has to be counted here.
 */
describe('GraphEmitSink structural subtotal', () => {
  it('counts a structural row in BOTH totals', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CALLS', 'a', 'b'));
    expect(sink.finalize()).toMatchObject({ totalRows: 1, structuralRows: 1 });
  });

  it('excludes a PDG row from structuralRows but not from totalRows', () => {
    // `totalRows` must keep counting it — it still sizes the buffer pool.
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CFG', 'a', 'b'));
    expect(sink.finalize()).toMatchObject({ totalRows: 1, structuralRows: 0 });
  });

  it('splits a MIXED stream, which is the shape a --pdg run produces', () => {
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('CALLS', 'a', 'b'));
    sink.addRelationship(rel('CFG', 'a', 'b'));
    sink.addRelationship(rel('REACHING_DEF', 'a', 'b'));
    sink.addRelationship(rel('CALLS', 'b', 'c'));
    expect(sink.finalize()).toMatchObject({ totalRows: 4, structuralRows: 2 });
  });

  it('counts TAINT_PATH as structural', () => {
    // Deliberately NOT in PDG_EDGE_TYPES: a whole-program Function->Function
    // edge persisted by the normal emit, so it is structural and must stay
    // counted on both sides of the collapse check.
    const sink = new GraphEmitSink(createKnowledgeGraph(), csvDir);
    sink.beginStreaming();
    sink.addRelationship(rel('TAINT_PATH', 'a', 'b'));
    expect(sink.finalize()).toMatchObject({ totalRows: 1, structuralRows: 1 });
  });
});
