/**
 * index.dispatch.test.ts — Functional tests for the tool dispatch/registry
 * invariants in src/engine/tools/index.ts.
 *
 * These exercise the REAL exports (executeTool / getToolDefs) and the canonical
 * MODULE_SPECS from tool-categories.ts. They do not reimplement dispatch logic.
 *
 * Invariants under test:
 *  1. Dispatch routing: a known tool from a module routes to that module and
 *     returns its result; an UNKNOWN name yields the well-formed unknown-tool
 *     error (not a crash).
 *  2. first-match-wins / no duplicate names: getToolDefs() exposes each tool
 *     name exactly once, and no static tool name is registered by two modules
 *     (which would make the silent Object.assign / dispatch order load-bearing).
 *  3. Load-failure safety: getToolDefs() never throws and returns a stable,
 *     non-empty catalog — the tryLoadModule swallow-on-failure contract.
 *  4. META_TOOLS drift guard: every name in index.ts's runtime META_TOOLS list
 *     is a resolvable, always-available tool, and the tool-categories META
 *     subset is ⊆ the index.ts list (the relationship the code documents).
 */
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { executeTool, getToolDefs, type ToolContext } from './index.js';
import {
  MODULE_SPECS,
  META_TOOLS as CATEGORIES_META_TOOLS,
} from './tool-categories.js';

// The runtime META_TOOLS list lives inline in index.ts:411. It is intentionally
// duplicated here (the source does not export it) so the test trips if the two
// drift apart silently. The drift-guard test below also pins it against
// getToolDefs(), so a stale copy here cannot mask a real regression.
const INDEX_META_TOOLS = [
  'ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate',
  'SearchTaskConversation', 'ReadTaskConversation',
  'GetFileProvenance', 'ListWorkspaceFiles',
  'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory',
  'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm',
  'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill',
  'ListSkills', 'InstallSkill', 'RequestTool',
];

function makeWorkspace(): string {
  return fs.mkdtempSync(path.join(tmpdir(), 'maestro-dispatch-'));
}
function makeContext(workspacePath: string, extra?: Partial<ToolContext>): ToolContext {
  return { workspacePath, editAllowed: true, ...extra } as ToolContext;
}

describe('tools/index — dispatch routing', () => {
  let workspacePath = '';
  beforeEach(() => { workspacePath = makeWorkspace(); });
  afterEach(() => {
    if (workspacePath) { fs.rmSync(workspacePath, { recursive: true, force: true }); workspacePath = ''; }
  });

  it('routes a core tool (Write) to the core module and returns its result', async () => {
    fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
    const result = await executeTool(
      'Write',
      { file_path: 'output/hello.txt', content: 'hi' },
      makeContext(workspacePath),
    );
    expect(result).not.toBeNull();
    expect(result.isError).toBe(false);
    // The side effect proves the call reached the real core executor.
    expect(fs.readFileSync(path.join(workspacePath, 'output', 'hello.txt'), 'utf8')).toBe('hi');
  });

  it('routes a checklist tool (CreateChecklist) to the checklist module', async () => {
    const result = await executeTool(
      'CreateChecklist',
      { name: 'work', items: ['a', 'b'] },
      makeContext(workspacePath),
    );
    expect(result).not.toBeNull();
    // Reached the real checklist executor: it persists a JSON file.
    expect(result.isError).toBe(false);
    const checklistPath = path.join(workspacePath, 'logs', 'checklists', 'work.json');
    expect(fs.existsSync(checklistPath)).toBe(true);
  });

  it('routes a docs tool (ReadToolDoc) past the docs module dispatch slot', async () => {
    // ReadToolDoc requires `name`; calling without it exercises the docs
    // executor's own validation (not the unknown-tool fallthrough).
    const result = await executeTool('ReadToolDoc', {}, makeContext(workspacePath));
    expect(result).not.toBeNull();
    expect(result.isError).toBe(true);
    expect(result.output).toContain('ReadToolDoc');
    // Must NOT be the generic unknown-tool error — that would mean the docs
    // dispatch slot was skipped.
    expect(result.output).not.toContain('Unknown tool');
  });

  it('returns the well-formed unknown-tool error for an unregistered name (no crash)', async () => {
    const result = await executeTool(
      'ThisToolDoesNotExist',
      {},
      makeContext(workspacePath),
    );
    expect(result).not.toBeNull();
    expect(result.isError).toBe(true);
    expect(result.output).toBe('Unknown tool: ThisToolDoesNotExist');
  });

  it('treats an unknown mcp__ name distinctly (MCP path, not unknown-tool)', async () => {
    // No aggregator is registered in the test process, so an mcp__ name hits
    // the MCP guard branch — proving the mcp__ prefix routes before the static
    // module chain rather than falling through to "Unknown tool".
    const result = await executeTool(
      'mcp__nonexistent__doThing',
      {},
      makeContext(workspacePath),
    );
    expect(result).not.toBeNull();
    expect(result.isError).toBe(true);
    expect(result.output).not.toContain('Unknown tool');
  });
});

describe('tools/index — first-match-wins / no duplicate tool names', () => {
  it('getToolDefs() exposes every tool name at most once', async () => {
    // Request a broad allow-list so as many static tools as possible surface.
    // META tools are injected regardless. Any duplicate name in the returned
    // array would mean Object.assign silently overwrote a definition.
    const broad = [
      'Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep',
      'WebSearch', 'WebFetch', 'DownloadFile', 'ReadImage', 'SQLite',
      'ReadPdf', 'ReadExcel', 'ReadDocx', 'SpawnSubTask', 'BrowseWeb',
      'XSearch', 'SearchPlaces', 'GetYouTubeTranscript', 'SearchAmazon',
      'TranscribeAudio', 'ListPieces', 'GetPiece',
    ];
    const defs = await getToolDefs(broad, true);
    const names = defs.map((d) => d.function.name);
    const seen = new Set<string>();
    const dupes = new Set<string>();
    for (const n of names) {
      if (seen.has(n)) dupes.add(n);
      seen.add(n);
    }
    expect([...dupes]).toEqual([]);
  });

  it('no static tool name is registered by two different modules', async () => {
    // Replicate the runtime merge order using the canonical MODULE_SPECS plus
    // core. For each tool name we record EVERY module that exports it; a name
    // owned by 2+ modules means dispatch precedence / Object.assign order is
    // silently load-bearing — the invariant this test guards.
    const owners = new Map<string, string[]>();

    // core first (ALL_TOOL_DEFS)
    try {
      const coreMod = (await import('./core.js')) as { ALL_TOOL_DEFS?: Record<string, unknown> };
      for (const name of Object.keys(coreMod.ALL_TOOL_DEFS ?? {})) {
        owners.set(name, ['core']);
      }
    } catch {
      // core should always load; if it doesn't, the other tests will fail loudly.
    }

    for (const { category, specifier } of MODULE_SPECS) {
      const local = specifier.replace('../engine/tools/', './');
      let mod: { TOOL_DEFS?: Record<string, unknown> } | null = null;
      try {
        mod = (await import(local)) as { TOOL_DEFS?: Record<string, unknown> };
      } catch {
        continue; // module unavailable in this env — skip (runtime does the same)
      }
      for (const name of Object.keys(mod?.TOOL_DEFS ?? {})) {
        const list = owners.get(name) ?? [];
        // Same module specifier can appear twice (e.g. ssh + ssh-console share
        // category 'ssh'); only flag genuinely DIFFERENT category owners.
        if (!list.includes(category)) list.push(category);
        owners.set(name, list);
      }
    }

    const collisions = [...owners.entries()].filter(([, mods]) => mods.length > 1);
    expect(collisions).toEqual([]);
  });
});

describe('tools/index — load-failure safety', () => {
  it('getToolDefs() never throws and returns a non-empty catalog', async () => {
    // tryLoadModule swallows import failures, so a broken optional module must
    // not break the catalog. We assert the observable invariant: the call
    // resolves with a non-empty array even with an empty allow-list (META tools
    // are always injected).
    const defs = await getToolDefs([], false);
    expect(Array.isArray(defs)).toBe(true);
    expect(defs.length).toBeGreaterThan(0);
  });

  it('getToolDefs() is stable across repeated calls (cached module loads)', async () => {
    const a = await getToolDefs([], false);
    const b = await getToolDefs([], false);
    const namesA = a.map((d) => d.function.name).sort();
    const namesB = b.map((d) => d.function.name).sort();
    expect(namesA).toEqual(namesB);
  });

  it('a known core tool still dispatches even though optional modules are skipped', async () => {
    // Core is loaded statically (not via tryLoadModule), so it survives any
    // optional-module load failure. Read against a missing file proves the core
    // executor ran and returned a structured error rather than crashing.
    const ws = makeWorkspace();
    try {
      const result = await executeTool('Read', { file_path: 'input/nope.txt' }, makeContext(ws));
      expect(result).not.toBeNull();
      expect(result.isError).toBe(true);
      expect(result.output).not.toContain('Unknown tool');
    } finally {
      fs.rmSync(ws, { recursive: true, force: true });
    }
  });
});

describe('tools/index — META_TOOLS drift guard', () => {
  it('every index.ts META_TOOLS name is always-available (injected without an allow-list)', async () => {
    // editAllowed:false, vlmEnabled omitted, EMPTY allowedTools — only META
    // tools (plus core defs that aren't edit-gated) should survive. Each META
    // name must resolve in the returned catalog, proving the injection wires up
    // a real registered tool (no dangling name).
    const defs = await getToolDefs([], false);
    const available = new Set(defs.map((d) => d.function.name));
    const missing = INDEX_META_TOOLS.filter((n) => !available.has(n));
    expect(missing).toEqual([]);
  });

  it('the tool-categories META subset is ⊆ the index.ts runtime META list', async () => {
    // tool-categories.ts documents its META_TOOLS as a SUBSET of index.ts's
    // (the runtime injection list). Assert that documented relationship: every
    // categories META name appears in the index.ts list. This holds even if the
    // index.ts list later grows beyond the catalog subset.
    const idx = new Set(INDEX_META_TOOLS);
    const notSubset = [...CATEGORIES_META_TOOLS].filter((n) => !idx.has(n));
    expect(notSubset).toEqual([]);
  });

  it('every tool-categories META name is also an always-available registered tool', async () => {
    // The catalog META subset tags /api/tools entries as source=meta; each must
    // correspond to a real, always-injected tool too (else the catalog tags a
    // phantom).
    const defs = await getToolDefs([], false);
    const available = new Set(defs.map((d) => d.function.name));
    const missing = [...CATEGORIES_META_TOOLS].filter((n) => !available.has(n));
    expect(missing).toEqual([]);
  });
});
