import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { generateCodeMap } from "../src/indexer/code-map.js";
import { getPluginForFile, loadPlugins } from "../src/indexer/plugin-loader.js";
import { buildProjectIndex } from "../src/indexer/project-index.js";
import { typescriptPlugin } from "../src/indexer/plugins/typescript.js";
import { normalizeIndexedSymbols } from "../src/indexer/symbol-names.js";
const SAMPLE_TS = `
export interface Foo {
  bar: string;
}

export function hello(name: string): string {
  return \`Hello, \${name}\`;
}

export class CodingAgent {
  constructor(params: { config: unknown }) {}

  async runTurn(input: string): Promise<string> {
    return input;
  }
}

const arrowFn = (x: number) => x + 1;
`;
test("TypeScript plugin extracts functions, classes, interfaces", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const names = symbols.map((s) => s.qualifiedName);
    assert.ok(names.includes("Foo"), "should extract interface Foo");
    assert.ok(names.includes("hello"), "should extract function hello");
    assert.ok(names.includes("CodingAgent"), "should extract class CodingAgent");
    assert.ok(names.includes("CodingAgent.runTurn"), "should extract method runTurn");
    assert.ok(names.includes("arrowFn"), "should extract arrow function");
});
test("TypeScript plugin returns correct line numbers", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const hello = symbols.find((s) => s.name === "hello");
    assert.ok(hello, "should find hello");
    assert.ok(hello.startLine >= 6 && hello.startLine <= 10);
    assert.ok(hello.endLine > hello.startLine);
});
test("TypeScript plugin handles arrow functions assigned to const", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const arrow = symbols.find((s) => s.name === "arrowFn");
    assert.ok(arrow, "should find arrowFn");
    assert.equal(arrow.kind, "function");
});
test("Plugin loader returns the built-in TypeScript plugin", async () => {
    const plugins = await loadPlugins("/tmp");
    assert.ok(plugins.length >= 1);
    assert.ok(plugins.some((p) => p.name === "typescript"));
});
test("getPluginForFile routes .ts files to TypeScript plugin", async () => {
    const plugins = await loadPlugins("/tmp");
    const plugin = getPluginForFile("src/agent/agent.ts", plugins);
    assert.ok(plugin);
    assert.equal(plugin.name, "typescript");
});
test("verify-index fixture exercises full indexing pipeline", async () => {
    const root = path.resolve(import.meta.dirname, "..");
    const fixtureRoot = path.join(root, "test-programs", "verify-index");
    const index = await buildProjectIndex(fixtureRoot);
    assert.ok(index.getSymbol("Processor"), "Processor class");
    assert.ok(index.getSymbol("Processor.run"), "Processor.run method");
    assert.ok(index.getSymbol("parse"), "parse function");
    assert.ok(index.getSymbol("Task"), "Task interface");
    const taskRefs = index.dependencyEdges.filter((e) => e.to === "Task");
    assert.ok(taskRefs.length >= 2, "Task should have multiple references");
    const implementsEdges = index.dependencyEdges.filter((e) => e.kind === "implements" && e.from === "Processor");
    assert.ok(implementsEdges.length >= 1, "Processor should implement TaskRunner");
    const codeMap = index.getCodeMap();
    assert.ok(codeMap.text.includes("Processor"));
    assert.ok(codeMap.text.includes("parseAndProcess"));
});
test("Code map generator produces expected format", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const byFile = new Map([["sample.ts", symbols]]);
    const result = generateCodeMap(byFile);
    assert.ok(result.text.includes("# Project Code Map"));
    assert.ok(result.text.includes("sample.ts"));
    assert.ok(result.text.includes("CodingAgent"));
    assert.ok(result.text.includes("runTurn"));
});
test("Code map respects token budget", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const byFile = new Map([["sample.ts", symbols]]);
    const result = generateCodeMap(byFile, 50);
    assert.ok(result.text.length < 300);
    assert.ok(result.text.includes("... and") || result.text.length > 0, "should truncate or fit within budget");
});
test("buildProjectIndex works on minicode src/", async () => {
    const root = path.resolve(import.meta.dirname, "..");
    const index = await buildProjectIndex(root);
    assert.ok(index.symbols.size > 0);
    assert.ok(index.files.size > 0);
    const configSymbols = index.getSymbolsInFile("src/agent/config.ts");
    assert.ok(configSymbols.length >= 1);
    assert.ok(configSymbols.some((s) => s.name === "loadAgentConfig"));
    const codeMap = index.getCodeMap();
    assert.ok(codeMap.text.includes("CodingAgent"));
    assert.ok(codeMap.totalCount > 0);
});
test("getPluginForFile routes .tsx, .js, .jsx to TypeScript plugin", async () => {
    const plugins = await loadPlugins("/tmp");
    const tsx = getPluginForFile("src/component.tsx", plugins);
    const js = getPluginForFile("src/legacy.js", plugins);
    const jsx = getPluginForFile("src/component.jsx", plugins);
    assert.ok(tsx);
    assert.ok(js);
    assert.ok(jsx);
    assert.equal(tsx.name, "typescript");
});
test("TypeScript plugin extracts constructor", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const ctor = symbols.find((s) => s.qualifiedName === "CodingAgent.constructor");
    assert.ok(ctor, "should extract constructor");
    assert.equal(ctor.kind, "method");
});
test("TypeScript plugin extracts type alias", () => {
    const code = `export type UserId = string;`;
    const symbols = typescriptPlugin.indexFile("types.ts", code);
    const typeSym = symbols.find((s) => s.name === "UserId");
    assert.ok(typeSym, "should extract type alias");
    assert.equal(typeSym.kind, "type");
});
test("TypeScript plugin returns empty array for empty file", () => {
    const symbols = typescriptPlugin.indexFile("empty.ts", "");
    assert.equal(symbols.length, 0);
});
test("TypeScript plugin handles comments-only file", () => {
    const symbols = typescriptPlugin.indexFile("comments.ts", "// comment only\n/* block */");
    assert.equal(symbols.length, 0);
});
test("TypeScript plugin handles malformed syntax", () => {
    const symbols = typescriptPlugin.indexFile("bad.ts", "function { broken");
    assert.ok(Array.isArray(symbols));
});
test("ProjectIndex getSymbol finds by qualifiedName and by name", async () => {
    const root = path.resolve(import.meta.dirname, "..");
    const index = await buildProjectIndex(root);
    const byQualified = index.getSymbol("loadAgentConfig");
    assert.ok(byQualified);
    assert.equal(byQualified.qualifiedName, "loadAgentConfig");
    const byName = index.getSymbol("formatConfigForDisplay");
    assert.ok(byName, "getSymbol should find by name when unique");
});
test("ProjectIndex getSymbolsInFile returns empty for non-existent file", async () => {
    const root = path.resolve(import.meta.dirname, "..");
    const index = await buildProjectIndex(root);
    const symbols = index.getSymbolsInFile("src/nonexistent.ts");
    assert.ok(Array.isArray(symbols));
    assert.equal(symbols.length, 0);
});
test("ProjectIndex getDependencyCone returns target and dependencies", async () => {
    const root = path.resolve(import.meta.dirname, "..");
    const index = await buildProjectIndex(root);
    const cone = index.getDependencyCone("loadAgentConfig", 1);
    assert.ok(Array.isArray(cone));
    assert.ok(cone.length >= 1, "should include target symbol");
    assert.ok(cone.some((s) => s.qualifiedName === "loadAgentConfig"), "should include loadAgentConfig");
});
test("Code map handles empty symbols map", () => {
    const result = generateCodeMap(new Map());
    assert.ok(result.text.includes("# Project Code Map"));
    assert.ok(result.text.length < 100);
});
test("Code map honors MINICODE_CODE_MAP_FORMAT=full opt-out", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const byFile = new Map([["sample.ts", symbols]]);
    const prev = process.env.MINICODE_CODE_MAP_FORMAT;
    process.env.MINICODE_CODE_MAP_FORMAT = "full";
    try {
        const result = generateCodeMap(byFile);
        // Full format prefixes each non-method symbol with the bare kind keyword
        // (e.g. "class CodingAgent") and emits a separate signature line.
        assert.ok(result.text.includes("class CodingAgent"), "full format prefixes class keyword");
    }
    finally {
        if (prev === undefined)
            delete process.env.MINICODE_CODE_MAP_FORMAT;
        else
            process.env.MINICODE_CODE_MAP_FORMAT = prev;
    }
});
test("Code map nests methods under class", () => {
    const symbols = typescriptPlugin.indexFile("sample.ts", SAMPLE_TS);
    const byFile = new Map([["sample.ts", symbols]]);
    const result = generateCodeMap(byFile);
    assert.ok(result.text.includes("CodingAgent (class)"));
    assert.ok(result.text.includes("runTurn"));
    const runTurnLine = result.text.split("\n").find((l) => l.includes("runTurn"));
    assert.ok(runTurnLine?.startsWith("    "), "method should be indented under class");
});
test("reindexFile updates symbols and code map after file change", async () => {
    const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-reindex-"));
    const samplePath = path.join(workspaceRoot, "sample.ts");
    const initialContent = `export function greet(name: string): string {
  return \`Hello, \${name}\`;
}
`;
    await writeFile(samplePath, initialContent, "utf8");
    const index = await buildProjectIndex(workspaceRoot);
    const sym = index.getSymbol("greet");
    assert.ok(sym, "should find greet");
    assert.ok(sym.signature.includes("name: string"), "initial signature");
    const updatedContent = `export function greet(name: string, title?: string): string {
  return title ? \`Hello, \${title} \${name}\` : \`Hello, \${name}\`;
}
`;
    await index.reindexFile("sample.ts", updatedContent);
    const updatedSym = index.getSymbol("greet");
    assert.ok(updatedSym, "should still find greet");
    assert.ok(updatedSym.signature.includes("title?: string"), "signature should reflect updated params");
    // Verify the code map is regenerated after reindex. The default named
    // format doesn't include signatures, so we opt into the full format
    // here to assert the signature change flows through.
    const prev = process.env.MINICODE_CODE_MAP_FORMAT;
    process.env.MINICODE_CODE_MAP_FORMAT = "full";
    try {
        const codeMap = index.getCodeMap();
        assert.ok(codeMap.text.includes("title?: string"), "code map should reflect new signature");
    }
    finally {
        if (prev === undefined)
            delete process.env.MINICODE_CODE_MAP_FORMAT;
        else
            process.env.MINICODE_CODE_MAP_FORMAT = prev;
    }
});
test("buildProjectIndex preserves colliding top-level symbols with distinct display names", async () => {
    const workspaceRoot = await mkdtemp(path.join(tmpdir(), "minicode-colliding-symbols-"));
    const samplePath = path.join(workspaceRoot, "sample.ts");
    const content = `export interface Employee {
  id: string;
}

export class Employee {
  constructor(public id: string) {}
}
`;
    await writeFile(samplePath, content, "utf8");
    const index = await buildProjectIndex(workspaceRoot);
    const fileSymbols = index.getSymbolsInFile("sample.ts");
    assert.equal(fileSymbols.length, 3, "should preserve interface, class, and constructor");
    const employeeSymbols = fileSymbols.filter((symbol) => symbol.name === "Employee");
    assert.equal(employeeSymbols.length, 2, "should keep both colliding Employee symbols");
    assert.notEqual(employeeSymbols[0].qualifiedName, employeeSymbols[1].qualifiedName, "colliding symbols should have unique internal qualified names");
    assert.ok(employeeSymbols.some((symbol) => symbol.displayName === "Employee (interface)"));
    assert.ok(employeeSymbols.some((symbol) => symbol.displayName === "Employee (class)"));
    const employeeClass = index.getSymbol("Employee (class)");
    const employeeInterface = index.getSymbol("Employee (interface)");
    assert.ok(employeeClass, "should resolve colliding class by display name");
    assert.ok(employeeInterface, "should resolve colliding interface by display name");
    assert.equal(employeeClass.kind, "class");
    assert.equal(employeeInterface.kind, "interface");
    const codeMap = index.getCodeMap();
    assert.ok(codeMap.text.includes("Employee (interface)"));
    assert.ok(codeMap.text.includes("Employee (class)"));
    const resolved = index.getSymbol("Employee");
    assert.ok(resolved, "bare lookup should still resolve one of the colliding symbols");
    assert.ok(resolved.displayName === "Employee (class)" || resolved.displayName === "Employee (interface)", "resolved symbol should use a disambiguated display name");
    const matches = index.getSymbolMatches("Employee");
    assert.equal(matches.length, 2, "should list every colliding symbol for ambiguous lookups");
    assert.deepEqual(matches.map((symbol) => symbol.displayName).sort(), ["Employee (class)", "Employee (interface)"].sort());
    assert.deepEqual(matches.map((symbol) => symbol.qualifiedName).sort(), [employeeClass.qualifiedName, employeeInterface.qualifiedName].sort());
    assert.deepEqual(index.getSymbolMatches("Employee (class)").map((symbol) => symbol.qualifiedName), [employeeClass.qualifiedName]);
    assert.deepEqual(index.getSymbolMatches("Employee#class").map((symbol) => symbol.qualifiedName), [employeeClass.qualifiedName]);
});
test("TypeScript plugin indexes class expressions assigned to variables", () => {
    const code = `const MyClass = class MyClass {
  constructor(name) {
    this.name = name;
  }

  greet() {
    return "hello " + this.name;
  }
};`;
    const symbols = typescriptPlugin.indexFile("class-expr.js", code);
    const names = symbols.map((s) => s.qualifiedName);
    assert.ok(names.includes("MyClass"), "should extract class expression MyClass");
    assert.ok(names.includes("MyClass.constructor"), "should extract constructor");
    assert.ok(names.includes("MyClass.greet"), "should extract method greet");
    const cls = symbols.find((s) => s.qualifiedName === "MyClass");
    assert.equal(cls.kind, "class");
});
test("TypeScript plugin indexes anonymous class expressions using variable name", () => {
    const code = `const Widget = class {
  render() {
    return "<div/>";
  }
};`;
    const symbols = typescriptPlugin.indexFile("anon-class.js", code);
    const names = symbols.map((s) => s.qualifiedName);
    assert.ok(names.includes("Widget"), "should extract anonymous class as Widget");
    assert.ok(names.includes("Widget.render"), "should extract method with class prefix");
    const cls = symbols.find((s) => s.qualifiedName === "Widget");
    assert.equal(cls.kind, "class");
});
test("TypeScript plugin indexes exported class expressions", () => {
    const code = `export const Service = class Service {
  start() {}
  stop() {}
};`;
    const symbols = typescriptPlugin.indexFile("service.js", code);
    const cls = symbols.find((s) => s.qualifiedName === "Service");
    assert.ok(cls, "should extract exported class expression");
    assert.equal(cls.exported, true, "should mark as exported");
    assert.equal(cls.kind, "class");
    const names = symbols.map((s) => s.qualifiedName);
    assert.ok(names.includes("Service.start"));
    assert.ok(names.includes("Service.stop"));
});
test("resolveDependencies tracks new expressions as calls edges", () => {
    const code = `
class Logger {
  log(msg) {}
}

function setup() {
  const logger = new Logger();
  logger.log("ready");
}
`;
    const symbols = typescriptPlugin.indexFile("new-expr.js", code);
    const projectFiles = new Map([["new-expr.js", code]]);
    const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
    const newCallEdge = edges.find((e) => e.from === "setup" && e.to === "Logger" && e.kind === "calls");
    assert.ok(newCallEdge, "new Logger() should produce a calls edge from setup to Logger");
});
test("AST cache avoids double-parsing: resolveDependencies reuses cached ASTs from indexFile", () => {
    const code = `
export interface Task {
  id: string;
  name: string;
}

export function createTask(id: string, name: string): Task {
  return { id, name };
}

export class TaskManager {
  run(task: Task) {
    return createTask(task.id, task.name);
  }
}
`;
    // indexFile populates the internal AST cache
    const symbols = typescriptPlugin.indexFile("cached.ts", code);
    assert.ok(symbols.length > 0, "should extract symbols");
    // resolveDependencies should reuse the cached AST (no re-parse)
    const projectFiles = new Map([["cached.ts", code]]);
    const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
    // Verify edges are still correctly produced (cache didn't break resolution)
    const callEdge = edges.find((e) => e.from === "TaskManager.run" && e.to === "createTask" && e.kind === "calls");
    assert.ok(callEdge, "should find calls edge from TaskManager.run to createTask");
    const refEdge = edges.find((e) => e.from === "createTask" && e.to === "Task" && e.kind === "references");
    assert.ok(refEdge, "should find references edge from createTask to Task");
});
test("AST cache is cleared after resolveDependencies completes", () => {
    const code = `export function foo(): void {}`;
    // First pass: indexFile caches the AST
    typescriptPlugin.indexFile("clear-test.ts", code);
    // resolveDependencies should clear the cache when done
    typescriptPlugin.resolveDependencies(typescriptPlugin.indexFile("clear-test.ts", code), new Map([["clear-test.ts", code]]));
    // Second pass with different content on same path — should parse fresh, not stale cache
    const newCode = `export function bar(): string { return "bar"; }`;
    const symbols = typescriptPlugin.indexFile("clear-test.ts", newCode);
    const names = symbols.map((s) => s.qualifiedName);
    assert.ok(names.includes("bar"), "should extract bar from fresh parse");
    assert.ok(!names.includes("foo"), "should not have stale foo from cache");
});
test("resolveDependencies works without prior indexFile (cache miss)", () => {
    const code = `
function alpha() { beta(); }
function beta() {}
`;
    // Call resolveDependencies without indexFile first — should still work via fallback parse
    const symbols = [
        { name: "alpha", qualifiedName: "alpha", kind: "function", filePath: "no-cache.ts", startLine: 2, endLine: 2, signature: "function alpha()", exported: false, dependencies: [] },
        { name: "beta", qualifiedName: "beta", kind: "function", filePath: "no-cache.ts", startLine: 3, endLine: 3, signature: "function beta()", exported: false, dependencies: [] },
    ];
    const projectFiles = new Map([["no-cache.ts", code]]);
    const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
    const callEdge = edges.find((e) => e.from === "alpha" && e.to === "beta" && e.kind === "calls");
    assert.ok(callEdge, "should resolve dependencies even without cached AST");
});
test("resolveDependencies prefers same-file symbols over same-named helpers in other files", () => {
    const projectIndexCode = `
async function collectSourceFiles(dir: string): Promise<void> {
  await collectSourceFiles(dir + "/nested");
}

export async function buildProjectIndex(): Promise<void> {
  await collectSourceFiles("src");
}
`;
    const cacheCode = `
async function collectSourceFiles(dir: string): Promise<void> {
  await collectSourceFiles(dir + "/cached");
}

export async function computeFileHashes(): Promise<void> {
  await collectSourceFiles("src");
}
`;
    const byFile = new Map([
        ["src/indexer/project-index.ts", typescriptPlugin.indexFile("src/indexer/project-index.ts", projectIndexCode)],
        ["src/indexer/cache.ts", typescriptPlugin.indexFile("src/indexer/cache.ts", cacheCode)],
    ]);
    const symbols = [...normalizeIndexedSymbols(byFile).values()];
    const projectFiles = new Map([
        ["src/indexer/project-index.ts", projectIndexCode],
        ["src/indexer/cache.ts", cacheCode],
    ]);
    const edges = typescriptPlugin.resolveDependencies(symbols, projectFiles);
    const projectHelper = symbols.find((symbol) => symbol.name === "collectSourceFiles" && symbol.filePath === "src/indexer/project-index.ts");
    const cacheHelper = symbols.find((symbol) => symbol.name === "collectSourceFiles" && symbol.filePath === "src/indexer/cache.ts");
    assert.ok(projectHelper, "project-index helper should be uniquely preserved after normalization");
    assert.ok(cacheHelper, "cache helper should be uniquely preserved after normalization");
    const projectSelfEdge = edges.find((edge) => edge.from === projectHelper?.qualifiedName
        && edge.to === projectHelper?.qualifiedName
        && edge.kind === "calls");
    const cacheSelfEdge = edges.find((edge) => edge.from === cacheHelper?.qualifiedName
        && edge.to === cacheHelper?.qualifiedName
        && edge.kind === "calls");
    const crossFileEdge = edges.find((edge) => edge.from === projectHelper?.qualifiedName
        && edge.to === cacheHelper?.qualifiedName
        && edge.kind === "calls");
    assert.ok(projectSelfEdge, "project-index helper should resolve recursive calls to itself");
    assert.ok(cacheSelfEdge, "cache helper should resolve recursive calls to itself");
    assert.ok(!crossFileEdge, "same-named helpers in other files should not be linked by default");
});
