/**
 * Catalog-level contract tests for OPENAI_FUNCTION_SCHEMAS.
 *
 * Why this exists: on the Responses API path, the model only sees the
 * native function schemas declared here — `getCombinedToolsDescription`
 * skips the text fallback when the tool id is in NATIVE_SCHEMA_TOOL_NAMES
 * (see baseTool.js:774). If a tool is registered in src/index.js but
 * missing a schema, the agent CAN'T call it on the Responses API path.
 *
 * This file pins three classes of regression:
 *   1. Catalog coverage   — every registered tool has a schema, and there
 *      are no stale schemas pointing at tools that no longer exist.
 *   2. Per-schema shape   — every schema is a well-formed OpenAI
 *      function-call declaration the Responses API will accept.
 *   3. Envelope structure — actions[] schemas use the canonical
 *      `type`-or-`action` discriminator on a required field. Flat-params
 *      schemas advertise the same field names the tools actually read.
 */

import { describe, test, expect } from '@jest/globals';
import {
  OPENAI_FUNCTION_SCHEMAS,
  NATIVE_SCHEMA_TOOL_NAMES,
} from '../openaiFunctionSchemas.js';

/**
 * Snapshot of every tool registered in src/index.js via
 * ToolsRegistry.registerTool, keyed by the tool's `this.id`.
 *
 * Add a new id here when registering a new tool. The contract is:
 *   tools registered in index.js MUST have a native schema in
 *   openaiFunctionSchemas.js, or Responses-API agents lose access.
 *
 * Verified against the grep of `this.id =` across src/tools/*.js
 * plus the BaseTool default (`constructor.name.toLowerCase().replace('tool', '')`).
 */
const REGISTERED_TOOL_IDS = new Set([
  'agentcommunication',
  'agentdelay',
  'clonedetection',
  'code-map',
  'composio',
  'dependency-resolver',
  'desktop',
  'doc',
  'file-content-replace',
  'file-tree',
  'filesystem',
  'foundry_web_search',
  'help',
  'image-gen',
  'import-analyzer',
  'jobdone',
  'memory',
  'office',
  'pdf',
  'platformcontrol',
  'seek',
  'skills',
  'spreadsheet',
  'staticanalysis',
  'taskmanager',
  'terminal',
  'userprompt',
  'video-gen',
  'vision',
  'visual-editor',
  'web',
  'whatsapp',
]);

describe('OPENAI_FUNCTION_SCHEMAS — catalog coverage', () => {
  test('every registered tool has a native schema (no Responses-API gaps)', () => {
    const schemaNames = new Set(OPENAI_FUNCTION_SCHEMAS.map(s => s.name));
    const missing = [];
    for (const id of REGISTERED_TOOL_IDS) {
      if (!schemaNames.has(id)) missing.push(id);
    }
    expect(missing).toEqual([]);
  });

  test('every schema corresponds to a registered tool (no stale schemas)', () => {
    const stale = OPENAI_FUNCTION_SCHEMAS
      .map(s => s.name)
      .filter(n => !REGISTERED_TOOL_IDS.has(n));
    expect(stale).toEqual([]);
  });

  test('schema name set has no duplicates', () => {
    const seen = new Set();
    const dupes = [];
    for (const s of OPENAI_FUNCTION_SCHEMAS) {
      if (seen.has(s.name)) dupes.push(s.name);
      seen.add(s.name);
    }
    expect(dupes).toEqual([]);
  });

  test('NATIVE_SCHEMA_TOOL_NAMES is derived from the schemas list', () => {
    // baseTool.js uses this Set to decide which tools get the structured
    // schema vs. the text fallback. If it drifts from the schemas array,
    // the text-fallback skipping logic gets the wrong answer.
    expect(NATIVE_SCHEMA_TOOL_NAMES.size).toBe(OPENAI_FUNCTION_SCHEMAS.length);
    for (const s of OPENAI_FUNCTION_SCHEMAS) {
      expect(NATIVE_SCHEMA_TOOL_NAMES.has(s.name.toLowerCase())).toBe(true);
    }
  });
});

describe('OPENAI_FUNCTION_SCHEMAS — per-schema shape', () => {
  test.each(OPENAI_FUNCTION_SCHEMAS.map(s => [s.name, s]))(
    '%s: matches the OpenAI Responses-API function-call shape',
    (name, schema) => {
      // Responses API requires: { type: 'function', name, description, parameters }
      expect(schema.type).toBe('function');
      expect(typeof schema.name).toBe('string');
      expect(schema.name.length).toBeGreaterThan(0);
      expect(typeof schema.description).toBe('string');
      // Stub descriptions are useless to the model — require non-trivial copy.
      expect(schema.description.length).toBeGreaterThanOrEqual(20);
      // parameters MUST be a JSON Schema object with `properties` keyed.
      expect(schema.parameters).toBeDefined();
      expect(schema.parameters.type).toBe('object');
      expect(typeof schema.parameters.properties).toBe('object');
    },
  );

  test.each(OPENAI_FUNCTION_SCHEMAS.map(s => [s.name, s]))(
    '%s: when using the actions[] envelope, the discriminator is required',
    (name, schema) => {
      const actions = schema.parameters.properties.actions;
      const topRequired = schema.parameters.required || [];
      // Only enforce the envelope contract when `actions` is the TOP-LEVEL
      // discriminator. `web` advertises `actions` as a sub-parameter for
      // its `interactive` operation (browser-action chain) and uses
      // `operation` as its discriminator — that path is exempt.
      if (!actions || !topRequired.includes('actions')) return;
      expect(actions.type).toBe('array');
      // Canonical envelope shape: per-action object lives under .items.
      expect(actions.items?.type).toBe('object');
      expect(typeof actions.items.properties).toBe('object');
      // Discriminator: every envelope schema must declare either `type`
      // (modern convention) or `action` (legacy jobdone/pdf/doc/spreadsheet).
      const hasType = !!actions.items.properties.type;
      const hasAction = !!actions.items.properties.action;
      expect(hasType || hasAction).toBe(true);
      // …and the discriminator must be required so the model can't omit it.
      const itemRequired = actions.items.required || [];
      expect(itemRequired.includes('type') || itemRequired.includes('action')).toBe(true);
    },
  );
});

/**
 * Convention locks for the schemas added in Batch 3. Tests below each pin
 * a decision so a future "while I'm here" refactor can't quietly move the
 * goalposts without a test failure flagging it.
 */
describe('Batch 3 convention locks', () => {
  test('composio: actions[] + type discriminator + 5 ops', () => {
    const composio = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'composio');
    expect(composio).toBeDefined();
    const types = composio.parameters.properties.actions.items.properties.type.enum;
    expect(types).toEqual(expect.arrayContaining([
      'list-toolkits', 'list-tools', 'connect', 'connection-status', 'execute',
    ]));
  });

  test('platformcontrol: enum covers schedules, agents, teams, and flows', () => {
    const pc = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'platformcontrol');
    expect(pc).toBeDefined();
    const types = pc.parameters.properties.actions.items.properties.type.enum;
    // Sample one action from each surface.
    expect(types).toContain('list-capabilities');
    expect(types).toContain('create-schedule');
    expect(types).toContain('schedule-self-resume');
    expect(types).toContain('create-agent');
    expect(types).toContain('create-team');
    expect(types).toContain('add-team-member');
    expect(types).toContain('create-flow');
    expect(types).toContain('execute-flow');
    expect(types).toContain('dry-run-flow');
  });

  test('desktop: flat params with action enum + (x,y)|intent union', () => {
    const desktop = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'desktop');
    expect(desktop).toBeDefined();
    expect(desktop.parameters.properties.action.enum).toEqual(expect.arrayContaining([
      'screenshot', 'click', 'type', 'key_press', 'scroll', 'drag',
      'list_windows', 'focus_window', 'describe_capabilities',
    ]));
    // Both halves of the (x,y) | intent union are documented.
    expect(desktop.parameters.properties.intent).toBeDefined();
    expect(desktop.parameters.properties.x).toBeDefined();
    expect(desktop.parameters.properties.y).toBeDefined();
    // Drag's separate from/to coords + fromIntent/toIntent pair.
    expect(desktop.parameters.properties.from).toBeDefined();
    expect(desktop.parameters.properties.to).toBeDefined();
    expect(desktop.parameters.properties.fromIntent).toBeDefined();
    expect(desktop.parameters.properties.toIntent).toBeDefined();
    // action is the only required field.
    expect(desktop.parameters.required).toEqual(['action']);
  });

  test('import-analyzer: flat params, modes are { full, quick, fix }', () => {
    const ia = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'import-analyzer');
    expect(ia).toBeDefined();
    expect(ia.parameters.properties.actions).toBeUndefined();
    expect(ia.parameters.properties.mode.enum).toEqual(['full', 'quick', 'fix']);
    expect(ia.parameters.properties.output.enum).toEqual(['summary', 'detailed', 'json']);
  });

  test('dependency-resolver: flat params, modes are { check, fix, auto }', () => {
    const dr = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'dependency-resolver');
    expect(dr).toBeDefined();
    expect(dr.parameters.properties.actions).toBeUndefined();
    expect(dr.parameters.properties.mode.enum).toEqual(['check', 'fix', 'auto']);
    expect(dr.parameters.properties.includeDev.type).toBe('boolean');
  });

  test('staticanalysis: every supported action is in the enum', () => {
    const sa = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'staticanalysis');
    expect(sa).toBeDefined();
    const types = sa.parameters.properties.actions.items.properties.type.enum;
    // 11 actions — surface is wide but heterogeneous.
    expect(types).toEqual(expect.arrayContaining([
      'analyze', 'analyze-project', 'fix', 'format',
      'security-scan', 'security-scan-project',
      'sparrow-scan', 'sparrow-scan-project',
      'validate-config', 'validate-config-directory',
      'validate-structured',
    ]));
  });

  test('clonedetection: requires { type, directory } on the action', () => {
    const cd = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'clonedetection');
    expect(cd).toBeDefined();
    const required = cd.parameters.properties.actions.items.required;
    expect(required).toContain('type');
    expect(required).toContain('directory');
  });

  test('foundry_web_search: flat params, query is the only required field', () => {
    const fws = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'foundry_web_search');
    expect(fws).toBeDefined();
    expect(fws.parameters.properties.actions).toBeUndefined();
    expect(fws.parameters.required).toEqual(['query']);
    // Optional fields are advertised so the model knows they exist.
    expect(fws.parameters.properties.instructions).toBeDefined();
    expect(fws.parameters.properties.model).toBeDefined();
  });

  test('legacy doc/spreadsheet: use `action` (not `type`) discriminator', () => {
    // These predate the type-discriminator convention; their tools parse
    // {action,...} not {type,...}. Lock the schema to match.
    const doc = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'doc');
    const sheet = OPENAI_FUNCTION_SCHEMAS.find(s => s.name === 'spreadsheet');
    expect(doc).toBeDefined();
    expect(sheet).toBeDefined();
    expect(doc.parameters.properties.actions.items.properties.action).toBeDefined();
    expect(doc.parameters.properties.actions.items.required).toContain('action');
    expect(sheet.parameters.properties.actions.items.properties.action).toBeDefined();
    expect(sheet.parameters.properties.actions.items.required).toContain('action');
    // Description should call out the deprecation so the model prefers `office`.
    expect(doc.description.toLowerCase()).toMatch(/legacy|prefer/);
    expect(sheet.description.toLowerCase()).toMatch(/legacy|prefer/);
  });
});
