/**
 * Tests for FoundryWebSearchTool.
 *
 * The tool wraps a single aiService call. We stub aiService and verify:
 *   1. parseParameters accepts both JSON and XML invocation forms
 *   2. execute requires aiService injection + a non-empty query
 *   3. successful search formats answer + citations + usage into a
 *      readable `output` string for the agent
 *   4. 503 (not provisioned) returns a friendly fallback message
 *      with `notProvisioned: true` so callers can branch
 *   5. webSearches=0 produces a warning so the agent knows to retry
 *      with stronger wording
 *   6. idempotency key is stable for same (agent, turn, query) and
 *      different across agents / turns / queries
 *   7. Errors don't leak the raw stack to the agent
 */

import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import { createMockLogger, createMockConfig } from '../../__test-utils__/mockFactories.js';

jest.unstable_mockModule('../../utilities/tagParser.js', () => ({
  default: {
    extractContent: jest.fn((content, tag) => {
      const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
      const matches = [];
      let match;
      while ((match = regex.exec(content)) !== null) matches.push(match[1]);
      return matches;
    }),
  },
}));

const { default: FoundryWebSearchTool } = await import('../foundryWebSearchTool.js');

function makeTool({ aiService } = {}) {
  const tool = new FoundryWebSearchTool(createMockConfig(), createMockLogger());
  if (aiService !== undefined) tool.setAIService(aiService);
  return tool;
}

const SAMPLE_OK_RESPONSE = {
  answer: 'Node.js 22 is the current LTS as of May 2026.',
  citations: [
    { url: 'https://nodejs.org/releases', title: 'Releases', start: 0, end: 31 },
    { url: 'https://github.com/nodejs/release', title: 'release schedule', start: 5, end: 12 },
  ],
  model: 'gpt-4o',
  usage: { inputTokens: 22, outputTokens: 87, webSearches: 1 },
};

describe('FoundryWebSearchTool — constructor + metadata', () => {
  test('id, name, version, async flag', () => {
    const t = makeTool();
    expect(t.id).toBe('foundry_web_search');
    expect(t.name).toBe('Foundry Web Search (Grounded)');
    expect(t.version).toBe('1.0.0');
    expect(t.isAsync).toBe(true);
    expect(t.requiresProject).toBe(false);
  });

  test('aiService starts null until setAIService is called', () => {
    const t = makeTool();
    expect(t.aiService).toBeNull();
    const ai = { foundryWebSearch: jest.fn() };
    t.setAIService(ai);
    expect(t.aiService).toBe(ai);
  });

  test('getDescription mentions citations + cost + the alternative tool', () => {
    const d = makeTool().getDescription();
    expect(d).toMatch(/citation/i);
    expect(d).toMatch(/cost/i);
    expect(d).toMatch(/\bweb\b/);          // mentions the puppeteer `web` tool as alternative
    expect(d).toMatch(/webSearches/);       // documents the usage field
  });
});

describe('FoundryWebSearchTool — parseParameters', () => {
  test('JSON form: { query, instructions, model }', () => {
    const t = makeTool();
    const out = t.parseParameters(JSON.stringify({
      query: 'latest node version', model: 'gpt-4o', instructions: 'force search',
    }));
    expect(out).toEqual({ query: 'latest node version', model: 'gpt-4o', instructions: 'force search' });
  });

  test('JSON form nested in `parameters` key (standard agent shape)', () => {
    const t = makeTool();
    const out = t.parseParameters(JSON.stringify({
      parameters: { query: 'hello world' },
    }));
    expect(out.query).toBe('hello world');
  });

  test('XML form: <query>…</query>', () => {
    const t = makeTool();
    const out = t.parseParameters('<query>find python</query>');
    expect(out.query).toBe('find python');
  });

  test('XML form: includes optional model + instructions', () => {
    const t = makeTool();
    const out = t.parseParameters(
      '<query>x</query><model>gpt-4o-mini</model><instructions>be brief</instructions>'
    );
    expect(out).toMatchObject({ query: 'x', model: 'gpt-4o-mini', instructions: 'be brief' });
  });

  test('missing query → null (execute will reject with a clear error)', () => {
    const t = makeTool();
    expect(t.parseParameters('<other>foo</other>').query).toBeNull();
    expect(t.parseParameters('{}').query).toBeNull();
  });

  test('whitespace-only query is normalised to a trimmed empty (then null at exec time)', () => {
    const t = makeTool();
    expect(t.parseParameters('<query>   </query>').query).toBe('');
  });
});

describe('FoundryWebSearchTool — execute success path', () => {
  test('formats answer + citations + usage into output', async () => {
    const aiService = { foundryWebSearch: jest.fn().mockResolvedValue(SAMPLE_OK_RESPONSE) };
    const t = makeTool({ aiService });
    const r = await t.execute({ query: 'node lts' }, { agentId: 'a1', sessionId: 's1' });
    expect(r.success).toBe(true);
    expect(r.output).toContain('Node.js 22 is the current LTS');
    expect(r.output).toContain('Sources:');
    expect(r.output).toContain('https://nodejs.org/releases');
    expect(r.output).toContain('searches=1');
    expect(r.answer).toBe(SAMPLE_OK_RESPONSE.answer);
    expect(r.citations).toEqual(SAMPLE_OK_RESPONSE.citations);
    expect(r.usage).toEqual(SAMPLE_OK_RESPONSE.usage);
    expect(r.warning).toBeUndefined();
  });

  test('forwards instructions + model + sessionId + idempotencyKey to aiService', async () => {
    const aiService = { foundryWebSearch: jest.fn().mockResolvedValue(SAMPLE_OK_RESPONSE) };
    const t = makeTool({ aiService });
    await t.execute(
      { query: 'q', instructions: 'always search', model: 'gpt-4o-mini' },
      { agentId: 'a1', sessionId: 'sess-x' },
    );
    const callArgs = aiService.foundryWebSearch.mock.calls[0];
    expect(callArgs[0]).toBe('q');
    expect(callArgs[1]).toMatchObject({
      instructions: 'always search',
      model: 'gpt-4o-mini',
      sessionId: 'sess-x',
    });
    expect(typeof callArgs[1].idempotencyKey).toBe('string');
    expect(callArgs[1].idempotencyKey.length).toBeGreaterThan(0);
  });

  test('webSearches=0 adds a `warning` field so the agent knows to retry', async () => {
    const aiService = {
      foundryWebSearch: jest.fn().mockResolvedValue({
        ...SAMPLE_OK_RESPONSE,
        usage: { ...SAMPLE_OK_RESPONSE.usage, webSearches: 0 },
      }),
    };
    const t = makeTool({ aiService });
    const r = await t.execute({ query: 'easy q' }, { agentId: 'a1' });
    expect(r.success).toBe(true);
    expect(r.warning).toMatch(/did not invoke|stronger wording|search the web/i);
  });

  test('empty citations renders "(No sources cited.)" in output', async () => {
    const aiService = {
      foundryWebSearch: jest.fn().mockResolvedValue({ ...SAMPLE_OK_RESPONSE, citations: [] }),
    };
    const r = await makeTool({ aiService }).execute({ query: 'q' }, { agentId: 'a1' });
    expect(r.output).toMatch(/No sources cited/);
  });
});

describe('FoundryWebSearchTool — execute error paths', () => {
  test('no aiService injected → returns initialisation error (does not throw)', async () => {
    const t = new FoundryWebSearchTool(createMockConfig(), createMockLogger());
    const r = await t.execute({ query: 'x' }, {});
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/aiService reference missing/i);
  });

  test('missing query → returns 400-style error', async () => {
    const aiService = { foundryWebSearch: jest.fn() };
    const r = await makeTool({ aiService }).execute({}, { agentId: 'a1' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/query is required/);
    expect(aiService.foundryWebSearch).not.toHaveBeenCalled();
  });

  test('503 from backend → friendly fallback message + notProvisioned: true', async () => {
    const err = Object.assign(
      new Error('Web search isn\'t enabled on this Loxia deployment.'),
      { status: 503 }
    );
    const aiService = { foundryWebSearch: jest.fn().mockRejectedValue(err) };
    const r = await makeTool({ aiService }).execute({ query: 'q' }, { agentId: 'a1' });
    expect(r.success).toBe(false);
    expect(r.notProvisioned).toBe(true);
    expect(r.output).toMatch(/web` tool/);  // suggests the puppeteer fallback
  });

  test('generic backend error → user-friendly output, no stack trace leak', async () => {
    const aiService = {
      foundryWebSearch: jest.fn().mockRejectedValue(new Error('upstream went sideways')),
    };
    const r = await makeTool({ aiService }).execute({ query: 'q' }, { agentId: 'a1' });
    expect(r.success).toBe(false);
    expect(r.output).toMatch(/Web search failed:/);
    expect(r.output).not.toMatch(/at Object\.|\bnode:internal\b/);
  });
});

describe('FoundryWebSearchTool — idempotency key derivation', () => {
  test('same (agentId, turnId, query) produces identical key', () => {
    const t = makeTool();
    const ctx = { agentId: 'a1', turnId: 'turn-99' };
    const k1 = t._buildIdempotencyKey(ctx, 'hello world');
    const k2 = t._buildIdempotencyKey(ctx, 'hello world');
    expect(k1).toBe(k2);
    expect(k1).toMatch(/^fws-a1-/);
  });

  test('different agents → different keys', () => {
    const t = makeTool();
    const k1 = t._buildIdempotencyKey({ agentId: 'a1', turnId: 't' }, 'q');
    const k2 = t._buildIdempotencyKey({ agentId: 'a2', turnId: 't' }, 'q');
    expect(k1).not.toBe(k2);
  });

  test('different turns → different keys (refresh between turns)', () => {
    const t = makeTool();
    const k1 = t._buildIdempotencyKey({ agentId: 'a1', turnId: 't1' }, 'q');
    const k2 = t._buildIdempotencyKey({ agentId: 'a1', turnId: 't2' }, 'q');
    expect(k1).not.toBe(k2);
  });

  test('different queries → different keys', () => {
    const t = makeTool();
    const k1 = t._buildIdempotencyKey({ agentId: 'a', turnId: 't' }, 'q1');
    const k2 = t._buildIdempotencyKey({ agentId: 'a', turnId: 't' }, 'q2');
    expect(k1).not.toBe(k2);
  });

  test('falls back to "unknown" agent + timestamp turn when missing', () => {
    const t = makeTool();
    const k = t._buildIdempotencyKey({}, 'q');
    expect(k).toMatch(/^fws-unknown-/);
  });
});
