/**
 * Unit & integration tests for the skill file generator.
 *
 * Tests generateSkillFiles() — the only public export from cli/skill-gen.ts.
 * Validates return values (skill metadata), aggregation logic, edge cases,
 * and the on-disk SKILL.md files produced.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { generateSkillFiles } from '../../src/cli/skill-gen.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { GraphNode, GraphRelationship, KnowledgeGraph } from '../../src/core/graph/types.js';
import type {
  CommunityNode,
  CommunityMembership,
  CommunityDetectionResult,
} from '../../src/core/ingestion/community-processor.js';
import type {
  ProcessNode,
  ProcessDetectionResult,
} from '../../src/core/ingestion/process-processor.js';
import type { PipelineResult } from '../../src/types/pipeline.js';

// ============================================================================
// FIXTURE HELPERS
// ============================================================================

/** Create a GraphNode with commonly-needed properties */
function makeNode(
  id: string,
  name: string,
  label: GraphNode['label'],
  filePath: string,
  startLine: number,
  isExported: boolean,
): GraphNode {
  return {
    id,
    label,
    properties: { name, filePath, startLine, endLine: startLine + 10, isExported },
  };
}

/** Create a GraphRelationship between two nodes */
function makeRel(
  id: string,
  sourceId: string,
  targetId: string,
  type: GraphRelationship['type'],
): GraphRelationship {
  return { id, sourceId, targetId, type, confidence: 1.0, reason: '' };
}

/** Create a CommunityNode with default cohesion */
function makeCommunity(
  id: string,
  label: string,
  symbolCount: number,
  cohesion: number = 0.75,
): CommunityNode {
  return { id, label, heuristicLabel: label, cohesion, symbolCount };
}

/** Create a membership record linking a node to a community */
function makeMembership(nodeId: string, communityId: string): CommunityMembership {
  return { nodeId, communityId };
}

/** Create a ProcessNode for testing execution flows */
function makeProcess(
  id: string,
  label: string,
  communities: string[],
  stepCount: number,
): ProcessNode {
  return {
    id,
    label,
    heuristicLabel: label,
    processType: communities.length > 1 ? 'cross_community' : 'intra_community',
    stepCount,
    communities,
    entryPointId: '',
    terminalId: '',
    trace: [],
  };
}

/**
 * Assemble a full PipelineResult from individual pieces.
 * Only graph is required; community and process data default to empty.
 */
function buildPipelineResult(opts: {
  graph: KnowledgeGraph;
  repoPath: string;
  communities?: CommunityNode[];
  memberships?: CommunityMembership[];
  processes?: ProcessNode[];
}): PipelineResult {
  const communityResult: CommunityDetectionResult = {
    communities: opts.communities ?? [],
    memberships: opts.memberships ?? [],
    stats: {
      totalCommunities: (opts.communities ?? []).length,
      modularity: 0.5,
      nodesProcessed: (opts.memberships ?? []).length,
    },
  };

  const processResult: ProcessDetectionResult | undefined = opts.processes
    ? {
        processes: opts.processes,
        steps: [],
        stats: {
          totalProcesses: opts.processes.length,
          crossCommunityCount: opts.processes.filter((p) => p.processType === 'cross_community')
            .length,
          avgStepCount:
            opts.processes.length > 0
              ? opts.processes.reduce((s, p) => s + p.stepCount, 0) / opts.processes.length
              : 0,
          entryPointsFound: 0,
        },
      }
    : undefined;

  return {
    graph: opts.graph,
    repoPath: opts.repoPath,
    totalFileCount: 0,
    communityResult,
    processResult,
  };
}

// ============================================================================
// TESTS — RETURN VALUES
// ============================================================================

describe('generateSkillFiles — return values', () => {
  let tmpDir: string;

  beforeEach(async () => {
    tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-skill-test-'));
    vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(async () => {
    vi.restoreAllMocks();
    try {
      await fs.rm(tmpDir, { recursive: true, force: true });
    } catch {
      /* best-effort */
    }
  });

  /**
   * When memberships array is empty, there is nothing to group into skills.
   * Should return an empty skills array and the expected output path.
   */
  it('returns empty skills when memberships is empty', async () => {
    const graph = createKnowledgeGraph();
    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities: [],
        memberships: [],
      }),
    );

    expect(result.skills).toEqual([]);
    expect(result.outputPath).toBe(path.join(tmpDir, '.claude', 'skills', 'generated'));
  });

  /**
   * Communities with fewer than 3 symbols are filtered out.
   * Three communities each with 2 symbols should all be excluded.
   */
  it('returns empty skills when all communities are below threshold', async () => {
    const graph = createKnowledgeGraph();
    // Add 6 nodes — 2 per community
    for (let i = 0; i < 6; i++) {
      graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/f${i}.ts`, 1, false));
    }

    const communities = [
      makeCommunity('c1', 'Small1', 2),
      makeCommunity('c2', 'Small2', 2),
      makeCommunity('c3', 'Small3', 2),
    ];
    const memberships = [
      makeMembership('fn:n0', 'c1'),
      makeMembership('fn:n1', 'c1'),
      makeMembership('fn:n2', 'c2'),
      makeMembership('fn:n3', 'c2'),
      makeMembership('fn:n4', 'c3'),
      makeMembership('fn:n5', 'c3'),
    ];

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toEqual([]);
  });

  /**
   * A single valid community with 5 nodes across 2 files, some exported.
   * Should return exactly 1 skill with correct metadata.
   */
  it('returns 1 skill for a single valid community', async () => {
    const graph = createKnowledgeGraph();
    graph.addNode(makeNode('fn:a', 'alpha', 'Function', `${tmpDir}/src/auth/login.ts`, 1, true));
    graph.addNode(makeNode('fn:b', 'beta', 'Function', `${tmpDir}/src/auth/login.ts`, 20, false));
    graph.addNode(makeNode('fn:c', 'gamma', 'Class', `${tmpDir}/src/auth/session.ts`, 1, true));
    graph.addNode(
      makeNode('fn:d', 'delta', 'Function', `${tmpDir}/src/auth/session.ts`, 40, false),
    );
    graph.addNode(
      makeNode('fn:e', 'epsilon', 'Function', `${tmpDir}/src/auth/session.ts`, 60, true),
    );

    const communities = [makeCommunity('c1', 'Auth', 5, 0.8)];
    const memberships = ['fn:a', 'fn:b', 'fn:c', 'fn:d', 'fn:e'].map((id) =>
      makeMembership(id, 'c1'),
    );

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);
    expect(result.skills[0].label).toBe('Auth');
    expect(result.skills[0].symbolCount).toBe(5);
    expect(result.skills[0].fileCount).toBe(2);
    expect(result.skills[0].name).toBe('auth');
  });

  /**
   * Two communities with the same heuristicLabel should be aggregated
   * into one skill with summed symbolCount.
   */
  it('aggregates communities with same label into one skill', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 8; i++) {
      graph.addNode(
        makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/auth/f${i}.ts`, 1, false),
      );
    }

    const communities = [makeCommunity('c1', 'Auth', 4, 0.7), makeCommunity('c2', 'Auth', 4, 0.9)];
    const memberships = [
      ...['fn:n0', 'fn:n1', 'fn:n2', 'fn:n3'].map((id) => makeMembership(id, 'c1')),
      ...['fn:n4', 'fn:n5', 'fn:n6', 'fn:n7'].map((id) => makeMembership(id, 'c2')),
    ];

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);
    expect(result.skills[0].label).toBe('Auth');
    expect(result.skills[0].symbolCount).toBe(8);
  });

  /**
   * The generator caps output at 20 skills regardless of how many
   * communities pass the threshold.
   */
  it('caps skills at 20 even with more valid communities', async () => {
    const graph = createKnowledgeGraph();
    const communities: CommunityNode[] = [];
    const memberships: CommunityMembership[] = [];

    for (let i = 0; i < 25; i++) {
      const commId = `c${i}`;
      communities.push(makeCommunity(commId, `Area${i}`, 4));
      for (let j = 0; j < 4; j++) {
        const nodeId = `fn:c${i}_n${j}`;
        graph.addNode(
          makeNode(
            nodeId,
            `func_${i}_${j}`,
            'Function',
            `${tmpDir}/src/area${i}/f${j}.ts`,
            1,
            false,
          ),
        );
        memberships.push(makeMembership(nodeId, commId));
      }
    }

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(20);
  });

  /**
   * Skills should be sorted by symbolCount descending so the most
   * significant community appears first.
   */
  it('sorts skills by symbol count descending', async () => {
    const graph = createKnowledgeGraph();
    const sizes = [10, 5, 3];
    const communities: CommunityNode[] = [];
    const memberships: CommunityMembership[] = [];

    for (let ci = 0; ci < 3; ci++) {
      const commId = `c${ci}`;
      communities.push(makeCommunity(commId, `Area${ci}`, sizes[ci]));
      for (let ni = 0; ni < sizes[ci]; ni++) {
        const nodeId = `fn:c${ci}_n${ni}`;
        graph.addNode(
          makeNode(
            nodeId,
            `func_${ci}_${ni}`,
            'Function',
            `${tmpDir}/src/area${ci}/f${ni}.ts`,
            1,
            false,
          ),
        );
        memberships.push(makeMembership(nodeId, commId));
      }
    }

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(3);
    expect(result.skills[0].symbolCount).toBe(10);
    expect(result.skills[1].symbolCount).toBe(5);
    expect(result.skills[2].symbolCount).toBe(3);
  });

  /**
   * When the communities array is empty but memberships exist with nodes
   * in an "auth/" folder, the fallback builder should derive a label from
   * the most common parent directory.
   */
  it('uses fallback builder when communities array is empty', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:n${i}`, `authFunc${i}`, 'Function', `${tmpDir}/src/auth/file${i}.ts`, 1, true),
      );
    }

    const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:n${i}`, 'comm_0'));

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities: [],
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);
    expect(result.skills[0].label).toBe('Auth');
  });

  /**
   * When processResult is undefined, the generator should still work
   * without crashing — it simply has no execution flows.
   */
  it('does not crash when processResult is undefined', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:n${i}`, `func${i}`, 'Function', `${tmpDir}/src/core/f${i}.ts`, 1, false),
      );
    }

    const communities = [makeCommunity('c1', 'Core', 4)];
    const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:n${i}`, 'c1'));

    const pipeline: PipelineResult = {
      graph,
      repoPath: tmpDir,
      totalFileCount: 0,
      communityResult: {
        communities,
        memberships,
        stats: { totalCommunities: 1, modularity: 0.5, nodesProcessed: 4 },
      },
      processResult: undefined,
    };

    const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline);
    expect(result.skills).toHaveLength(1);
  });

  /**
   * Memberships that reference node IDs not present in the graph
   * should be silently skipped without crashing.
   */
  it('does not crash when memberships reference missing nodes', async () => {
    const graph = createKnowledgeGraph();
    // Only add 2 real nodes but membership references 4
    graph.addNode(makeNode('fn:real1', 'real1', 'Function', `${tmpDir}/src/mod/a.ts`, 1, false));
    graph.addNode(makeNode('fn:real2', 'real2', 'Function', `${tmpDir}/src/mod/b.ts`, 1, false));

    const communities = [makeCommunity('c1', 'Mod', 4)];
    const memberships = [
      makeMembership('fn:real1', 'c1'),
      makeMembership('fn:real2', 'c1'),
      makeMembership('fn:ghost1', 'c1'),
      makeMembership('fn:ghost2', 'c1'),
    ];

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    // Community has symbolCount=4 which passes threshold, but only 2 real nodes resolve
    expect(result.skills).toHaveLength(1);
    expect(result.skills[0].fileCount).toBe(2);
  });

  /**
   * When the same nodeId appears in two raw community IDs that get
   * aggregated into the same label, it should not be double-counted
   * in the file output.
   */
  it('does not double-count nodes shared across aggregated communities', async () => {
    const graph = createKnowledgeGraph();
    graph.addNode(
      makeNode('fn:shared', 'shared', 'Function', `${tmpDir}/src/data/shared.ts`, 1, true),
    );
    graph.addNode(makeNode('fn:a', 'a', 'Function', `${tmpDir}/src/data/a.ts`, 1, false));
    graph.addNode(makeNode('fn:b', 'b', 'Function', `${tmpDir}/src/data/b.ts`, 1, false));

    // Two raw communities both named "Data", both containing fn:shared
    const communities = [makeCommunity('c1', 'Data', 2, 0.8), makeCommunity('c2', 'Data', 2, 0.7)];
    const memberships = [
      makeMembership('fn:shared', 'c1'),
      makeMembership('fn:a', 'c1'),
      makeMembership('fn:shared', 'c2'),
      makeMembership('fn:b', 'c2'),
    ];

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);
    // fileCount should be 3 (shared.ts, a.ts, b.ts) — not 4
    expect(result.skills[0].fileCount).toBe(3);
  });
});

// ============================================================================
// TESTS — FILE OUTPUT
// ============================================================================

describe('generateSkillFiles — file output', () => {
  let tmpDir: string;

  beforeEach(async () => {
    tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-skill-out-'));
    vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(async () => {
    vi.restoreAllMocks();
    try {
      await fs.rm(tmpDir, { recursive: true, force: true });
    } catch {
      /* best-effort */
    }
  });

  /** Helper: create a standard 2-community setup for file-output tests */
  function twoCommSetup() {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(
          `fn:a${i}`,
          `alphaFn${i}`,
          'Function',
          `${tmpDir}/src/alpha/f${i}.ts`,
          i * 10 + 1,
          i < 2,
        ),
      );
    }
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(
          `fn:b${i}`,
          `betaFn${i}`,
          'Function',
          `${tmpDir}/src/beta/f${i}.ts`,
          i * 10 + 1,
          i < 2,
        ),
      );
    }

    const communities = [
      makeCommunity('cA', 'Alpha', 4, 0.85),
      makeCommunity('cB', 'Beta', 4, 0.6),
    ];
    const memberships = [
      ...[0, 1, 2, 3].map((i) => makeMembership(`fn:a${i}`, 'cA')),
      ...[0, 1, 2, 3].map((i) => makeMembership(`fn:b${i}`, 'cB')),
    ];

    return { graph, communities, memberships };
  }

  /**
   * Verify that each community produces a directory under generated/
   * containing a SKILL.md file.
   */
  it('creates generated/{name}/SKILL.md for each community', async () => {
    const { graph, communities, memberships } = twoCommSetup();

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
    const alphaSkill = await fs.readFile(path.join(outputDir, 'alpha', 'SKILL.md'), 'utf-8');
    const betaSkill = await fs.readFile(path.join(outputDir, 'beta', 'SKILL.md'), 'utf-8');
    expect(alphaSkill.length).toBeGreaterThan(0);
    expect(betaSkill.length).toBeGreaterThan(0);
  });

  /**
   * SKILL.md files should start with YAML frontmatter containing
   * name and description fields.
   */
  it('starts with frontmatter containing name and description', async () => {
    const { graph, communities, memberships } = twoCommSetup();

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    const content = await fs.readFile(
      path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
      'utf-8',
    );
    expect(content.startsWith('---')).toBe(true);
    expect(content).toContain('name:');
    expect(content).toContain('description:');
  });

  /**
   * The "How to Explore" section must reference MCP tools by their registered
   * (unprefixed) names — the server registers `context`/`query`, not
   * `gitnexus_context`/`gitnexus_query`, so the prefixed form points agents at
   * tools that do not exist (#2059).
   */
  it('references MCP tools by their registered (unprefixed) names (#2059)', async () => {
    const { graph, communities, memberships } = twoCommSetup();

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    const content = await fs.readFile(
      path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
      'utf-8',
    );
    expect(content).not.toMatch(/gitnexus_(context|query|impact|detect_changes|rename|cypher)/);
    expect(content).toContain('context({name:');
    expect(content).toContain('query({query:');
  });

  /**
   * A community with exported symbols, processes, and cross-community
   * CALLS edges should have all optional sections rendered.
   */
  it('includes Entry Points, Execution Flows, Connected Areas when data exists', async () => {
    const graph = createKnowledgeGraph();
    // Community A: exported symbols
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:a${i}`, `alphaFn${i}`, 'Function', `${tmpDir}/src/alpha/f${i}.ts`, 1, true),
      );
    }
    // Community B: target of cross-community calls
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:b${i}`, `betaFn${i}`, 'Function', `${tmpDir}/src/beta/f${i}.ts`, 1, false),
      );
    }
    // Cross-community CALLS edge: A -> B
    graph.addRelationship(makeRel('r1', 'fn:a0', 'fn:b0', 'CALLS'));

    const communities = [
      makeCommunity('cA', 'Alpha', 4, 0.85),
      makeCommunity('cB', 'Beta', 4, 0.6),
    ];
    const memberships = [
      ...[0, 1, 2, 3].map((i) => makeMembership(`fn:a${i}`, 'cA')),
      ...[0, 1, 2, 3].map((i) => makeMembership(`fn:b${i}`, 'cB')),
    ];

    const processes = [makeProcess('p1', 'AlphaFlow', ['cA'], 5)];

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
        processes,
      }),
    );

    const content = await fs.readFile(
      path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
      'utf-8',
    );

    expect(content).toContain('## Entry Points');
    expect(content).toContain('## Execution Flows');
    expect(content).toContain('## Connected Areas');
  });

  /**
   * A community with no exports, no processes, and no cross-community
   * calls should omit the optional sections entirely.
   */
  it('omits Entry Points, Execution Flows, Connected Areas when absent', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:n${i}`, `func${i}`, 'Function', `${tmpDir}/src/isolated/f${i}.ts`, 1, false),
      );
    }

    const communities = [makeCommunity('c1', 'Isolated', 4)];
    const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:n${i}`, 'c1'));

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
        processes: [],
      }),
    );

    const content = await fs.readFile(
      path.join(tmpDir, '.claude', 'skills', 'generated', 'isolated', 'SKILL.md'),
      'utf-8',
    );

    expect(content).not.toContain('## Entry Points');
    expect(content).not.toContain('## Execution Flows');
    expect(content).not.toContain('## Connected Areas');
  });

  /**
   * Running generateSkillFiles twice with different communities should
   * clean up the first run's output directories.
   */
  it('cleans up previous run output on re-run', async () => {
    const graph1 = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph1.addNode(
        makeNode(`fn:x${i}`, `xFunc${i}`, 'Function', `${tmpDir}/src/first/f${i}.ts`, 1, false),
      );
    }

    // First run
    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph: graph1,
        repoPath: tmpDir,
        communities: [makeCommunity('c1', 'First', 4)],
        memberships: [0, 1, 2, 3].map((i) => makeMembership(`fn:x${i}`, 'c1')),
      }),
    );

    const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
    const firstRunDirs = await fs.readdir(outputDir);
    expect(firstRunDirs).toContain('first');

    // Second run with different community
    const graph2 = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph2.addNode(
        makeNode(`fn:y${i}`, `yFunc${i}`, 'Function', `${tmpDir}/src/second/f${i}.ts`, 1, false),
      );
    }

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph: graph2,
        repoPath: tmpDir,
        communities: [makeCommunity('c2', 'Second', 4)],
        memberships: [0, 1, 2, 3].map((i) => makeMembership(`fn:y${i}`, 'c2')),
      }),
    );

    const secondRunDirs = await fs.readdir(outputDir);
    expect(secondRunDirs).toContain('second');
    expect(secondRunDirs).not.toContain('first');
  });

  /**
   * The rendered SKILL.md should contain a stats line matching the
   * community's symbol count, file count, and cohesion percentage.
   */
  it('contains stats line with correct symbol count, file count, cohesion', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 5; i++) {
      graph.addNode(
        makeNode(`fn:s${i}`, `statsFn${i}`, 'Function', `${tmpDir}/src/stats/f${i}.ts`, 1, false),
      );
    }

    const communities = [makeCommunity('c1', 'Stats', 5, 0.82)];
    const memberships = [0, 1, 2, 3, 4].map((i) => makeMembership(`fn:s${i}`, 'c1'));

    await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    const content = await fs.readFile(
      path.join(tmpDir, '.claude', 'skills', 'generated', 'stats', 'SKILL.md'),
      'utf-8',
    );

    expect(content).toContain('5 symbols | 5 files | Cohesion: 82%');
  });

  /**
   * Labels with special characters (like "C++ Core") should be converted
   * to a valid kebab-case directory name without crashing.
   */
  it('handles special characters in label for directory name', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:cpp${i}`, `cppFunc${i}`, 'Function', `${tmpDir}/src/cpp/f${i}.ts`, 1, false),
      );
    }

    const communities = [makeCommunity('c1', 'C++ Core', 4)];
    const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:cpp${i}`, 'c1'));

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);
    // The kebab name should only contain lowercase alphanumerics and dashes
    expect(result.skills[0].name).toMatch(/^[a-z0-9-]+$/);

    const skillPath = path.join(
      tmpDir,
      '.claude',
      'skills',
      'generated',
      result.skills[0].name,
      'SKILL.md',
    );
    const content = await fs.readFile(skillPath, 'utf-8');
    expect(content.length).toBeGreaterThan(0);
  });

  /**
   * Nodes with no filePath should not crash the generator.
   * The skill should still be generated with fileCount 0.
   */
  it('handles nodes with no filePath', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(makeNode(`fn:nf${i}`, `nofileFunc${i}`, 'Function', '', 0, false));
    }

    const communities = [makeCommunity('c1', 'NoFile', 4)];
    const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:nf${i}`, 'c1'));

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);
    expect(result.skills[0].fileCount).toBe(0);
  });

  /**
   * Node filePaths containing Windows-style backslashes should be
   * normalized to forward slashes in the Key Files table (which uses
   * toRelativePath). The Key Symbols table renders raw filePath as-is,
   * so we only check the Key Files section for normalization.
   */
  it('normalizes Windows backslash paths in Key Files output', async () => {
    const graph = createKnowledgeGraph();
    for (let i = 0; i < 4; i++) {
      graph.addNode(
        makeNode(`fn:w${i}`, `winFunc${i}`, 'Function', `${tmpDir}\\src\\win\\f${i}.ts`, 1, false),
      );
    }

    const communities = [makeCommunity('c1', 'Win', 4)];
    const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:w${i}`, 'c1'));

    const result = await generateSkillFiles(
      tmpDir,
      'TestProject',
      buildPipelineResult({
        graph,
        repoPath: tmpDir,
        communities,
        memberships,
      }),
    );

    expect(result.skills).toHaveLength(1);

    const content = await fs.readFile(
      path.join(tmpDir, '.claude', 'skills', 'generated', 'win', 'SKILL.md'),
      'utf-8',
    );

    // Extract the Key Files section between "## Key Files" and the next "##"
    const keyFilesMatch = content.match(/## Key Files\n([\s\S]*?)(?=\n##)/);
    expect(keyFilesMatch).not.toBeNull();
    const keyFilesSection = keyFilesMatch![1];
    // Key Files section should use forward slashes only
    expect(keyFilesSection).not.toMatch(/\\/);
    // Verify it actually has file paths
    expect(keyFilesSection).toContain('src/win/f0.ts');
  });
});
