/**
 * KimiAdapter unit tests.
 *
 * No live Foundry calls — `fetchImpl` is injected so every test
 * verifies a specific request/response shape in isolation. This is
 * deliberately exhaustive on request-body construction: every knob
 * we discovered empirically (reasoning_effort, image_detail, sampling
 * defaults, seed, max_tokens) gets a regression-locking test.
 */

import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import KimiAdapter from '../KimiAdapter.js';
import {
  ReasoningEffort,
  ImageDetail,
  ErrorCode,
  SamplingDefaults,
  TokenBudget,
  Foundry,
  ModelId,
} from '../constants.js';

const TINY_PNG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);

/**
 * Build a successful Foundry response shape that mirrors what the
 * live Kimi-K2.6 deployment returns. Used by tests that focus on
 * the request side without caring about response parsing.
 */
function okResponse(overrides = {}) {
  return {
    id: 'resp-123',
    model: 'Kimi-K2.6',
    choices: [{
      index: 0,
      message: {
        role: 'assistant',
        content: 'x=100, y=200',
        reasoning_content: 'the button is at the top-right…',
      },
      finish_reason: 'stop',
    }],
    usage: { prompt_tokens: 50, completion_tokens: 200, total_tokens: 250 },
    ...overrides,
  };
}

/**
 * Build a mock fetch that records all calls and returns a configurable
 * response. Returns the spy + a helper to set the next response.
 */
function makeFetchSpy(initial = okResponse()) {
  let nextStatus  = 200;
  let nextPayload = initial;
  const calls = [];
  const fetchImpl = jest.fn(async (url, opts) => {
    calls.push({ url, opts });
    return {
      ok:     nextStatus >= 200 && nextStatus < 300,
      status: nextStatus,
      text:   async () => JSON.stringify(nextPayload),
    };
  });
  return {
    fetchImpl,
    calls,
    setResponse: (payload, status = 200) => { nextPayload = payload; nextStatus = status; },
  };
}

function makeAdapter(extra = {}) {
  return new KimiAdapter({
    endpoint: 'https://loxia-ai-2-resource.services.ai.azure.com/',
    apiKey:   'fake-key-for-tests',
    deployment: ModelId.KIMI_K2_6,
    ...extra,
  });
}

// ── constructor + capabilities ──────────────────────────────────────

describe('KimiAdapter — construction', () => {
  test('throws on missing endpoint', () => {
    expect(() => new KimiAdapter({ apiKey: 'k' }))
      .toThrow(/endpoint is required/);
  });

  test('throws on missing apiKey', () => {
    expect(() => new KimiAdapter({ endpoint: 'https://example/' }))
      .toThrow(/apiKey is required/);
  });

  test('normalises endpoint trailing-slash', () => {
    const a = new KimiAdapter({ endpoint: 'https://x.example', apiKey: 'k' });
    expect(a.endpoint).toBe('https://x.example/');
    const b = new KimiAdapter({ endpoint: 'https://x.example/', apiKey: 'k' });
    expect(b.endpoint).toBe('https://x.example/');
  });

  test('capabilities() reports vision + tool-calling + grounding', () => {
    const a = makeAdapter();
    const c = a.capabilities();
    expect(c.pixelGrounding).toBe(true);
    expect(c.toolCalling).toBe(true);
    expect(c.visionInput).toBe(true);
  });

  test('static metadata advertises display name + model id', () => {
    expect(KimiAdapter.displayName).toMatch(/Kimi/);
    expect(KimiAdapter.modelId).toBe(ModelId.KIMI_K2_6);
  });
});

// ── supportedKnobs schema ───────────────────────────────────────────

describe('KimiAdapter — supportedKnobs', () => {
  const knobs = makeAdapter().supportedKnobs();

  test('exposes every empirically-validated knob', () => {
    const ids = knobs.map(k => k.id).sort();
    expect(ids).toEqual([
      'image_detail', 'max_tokens', 'reasoning_effort', 'seed', 'temperature', 'top_p',
    ]);
  });

  test('reasoning_effort default is "low" (the bench sweet spot)', () => {
    const k = knobs.find(k => k.id === 'reasoning_effort');
    expect(k.default).toBe(ReasoningEffort.LOW);
    expect(k.options.map(o => o.value)).toEqual(['none', 'low', 'medium', 'high']);
  });

  test('temperature default matches Moonshot thinking-on recommendation', () => {
    const k = knobs.find(k => k.id === 'temperature');
    expect(k.default).toBe(SamplingDefaults.THINKING_ON.temperature);
  });
});

// ── validateKnobs ───────────────────────────────────────────────────

describe('KimiAdapter — validateKnobs', () => {
  const a = makeAdapter();

  test('accepts the empty object (use all defaults)', () => {
    expect(a.validateKnobs({})).toEqual({ valid: true, errors: {} });
  });

  test('accepts every documented reasoning_effort value', () => {
    for (const v of ['none', 'low', 'medium', 'high']) {
      expect(a.validateKnobs({ reasoning_effort: v }).valid).toBe(true);
    }
  });

  test('rejects unknown reasoning_effort', () => {
    const r = a.validateKnobs({ reasoning_effort: 'minimal' });
    expect(r.valid).toBe(false);
    expect(r.errors.reasoning_effort).toMatch(/must be one of/);
  });

  test('rejects unknown top-level knobs', () => {
    const r = a.validateKnobs({ unknown_knob: 1 });
    expect(r.valid).toBe(false);
    expect(r.errors.unknown_knob).toMatch(/unknown knob/);
  });

  test('rejects out-of-range temperature / top_p', () => {
    expect(a.validateKnobs({ temperature: -1   }).valid).toBe(false);
    expect(a.validateKnobs({ temperature:  2.5 }).valid).toBe(false);
    expect(a.validateKnobs({ top_p: 1.1 }).valid).toBe(false);
  });

  test('rejects non-integer max_tokens', () => {
    expect(a.validateKnobs({ max_tokens: 1.5 }).valid).toBe(false);
    expect(a.validateKnobs({ max_tokens: 0   }).valid).toBe(false);
  });

  test('rejects unknown image_detail enum value', () => {
    expect(a.validateKnobs({ image_detail: 'ultra' }).valid).toBe(false);
  });
});

// ── ground() — request construction ─────────────────────────────────

describe('KimiAdapter — ground() request body', () => {
  let spy;
  let adapter;
  beforeEach(() => {
    spy = makeFetchSpy();
    adapter = makeAdapter({ fetchImpl: spy.fetchImpl });
  });

  test('URL targets the Foundry chat-completions path + locked API version', async () => {
    await adapter.ground({ screenshot: TINY_PNG, intent: 'click x' });
    const { url } = spy.calls[0];
    expect(url).toContain(Foundry.CHAT_COMPLETIONS_PATH);
    expect(url).toContain(`api-version=${Foundry.API_VERSION}`);
    expect(url.startsWith('https://loxia-ai-2-resource.services.ai.azure.com/')).toBe(true);
  });

  test('Authorization Bearer header carries the apiKey', async () => {
    await adapter.ground({ screenshot: TINY_PNG, intent: 'x' });
    const { opts } = spy.calls[0];
    expect(opts.headers['Authorization']).toBe('Bearer fake-key-for-tests');
    expect(opts.headers['Content-Type']).toBe('application/json');
  });

  test('uses ReasoningEffort.LOW + thinking-on sampling by default', async () => {
    await adapter.ground({ screenshot: TINY_PNG, intent: 'x' });
    const body = JSON.parse(spy.calls[0].opts.body);
    expect(body.reasoning_effort).toBe(ReasoningEffort.LOW);
    expect(body.temperature).toBe(SamplingDefaults.THINKING_ON.temperature);
    expect(body.top_p).toBe(SamplingDefaults.THINKING_ON.top_p);
    expect(body.max_tokens).toBe(TokenBudget.THINKING_DEFAULT_MAX_TOKENS);
  });

  test('reasoning_effort=none flips to thinking-off sampling defaults', async () => {
    await adapter.ground({
      screenshot: TINY_PNG, intent: 'x',
      knobs: { reasoning_effort: ReasoningEffort.NONE },
    });
    const body = JSON.parse(spy.calls[0].opts.body);
    expect(body.reasoning_effort).toBe('none');
    expect(body.temperature).toBe(SamplingDefaults.THINKING_OFF.temperature);
    expect(body.top_p).toBe(SamplingDefaults.THINKING_OFF.top_p);
  });

  test('explicit knob values override sampling defaults', async () => {
    await adapter.ground({
      screenshot: TINY_PNG, intent: 'x',
      knobs: { temperature: 0.2, top_p: 0.5, max_tokens: 1000, seed: 42 },
    });
    const body = JSON.parse(spy.calls[0].opts.body);
    expect(body.temperature).toBe(0.2);
    expect(body.top_p).toBe(0.5);
    expect(body.max_tokens).toBe(1000);
    expect(body.seed).toBe(42);
  });

  test('image is sent as data-URL with the requested detail level', async () => {
    await adapter.ground({
      screenshot: TINY_PNG, intent: 'x',
      knobs: { image_detail: ImageDetail.HIGH },
    });
    const body = JSON.parse(spy.calls[0].opts.body);
    const imgPart = body.messages[0].content.find(c => c.type === 'image_url');
    expect(imgPart.image_url.url).toMatch(/^data:image\/png;base64,/);
    expect(imgPart.image_url.detail).toBe('high');
  });

  test('prompt is prefixed with image dimensions when imageSize is passed', async () => {
    await adapter.ground({
      screenshot: TINY_PNG, intent: 'find Save button',
      imageSize: { width: 1920, height: 1080 },
    });
    const body = JSON.parse(spy.calls[0].opts.body);
    const textPart = body.messages[0].content.find(c => c.type === 'text');
    expect(textPart.text).toContain('1920x1080');
    expect(textPart.text).toContain('find Save button');
  });

  test('intent without imageSize: no dim prefix', async () => {
    await adapter.ground({ screenshot: TINY_PNG, intent: 'find Save button' });
    const body = JSON.parse(spy.calls[0].opts.body);
    const textPart = body.messages[0].content.find(c => c.type === 'text');
    expect(textPart.text).toBe('find Save button');
  });

  test('tools array is forwarded with tool_choice=auto', async () => {
    const tools = [{ type: 'function', function: {
      name: 'click', description: 'click', parameters: { type: 'object', properties: {} },
    }}];
    await adapter.ground({ screenshot: TINY_PNG, intent: 'x', tools });
    const body = JSON.parse(spy.calls[0].opts.body);
    expect(body.tools).toEqual(tools);
    expect(body.tool_choice).toBe('auto');
  });

  test('no tool_choice emitted when tools omitted', async () => {
    await adapter.ground({ screenshot: TINY_PNG, intent: 'x' });
    const body = JSON.parse(spy.calls[0].opts.body);
    expect(body.tools).toBeUndefined();
    expect(body.tool_choice).toBeUndefined();
  });
});

// ── ground() — response parsing ─────────────────────────────────────

describe('KimiAdapter — ground() response parsing', () => {
  let spy, adapter;
  beforeEach(() => {
    spy = makeFetchSpy();
    adapter = makeAdapter({ fetchImpl: spy.fetchImpl });
  });

  test('extracts coords from x=, y= answer + carries reasoning trace', async () => {
    spy.setResponse(okResponse());
    const r = await adapter.ground({ screenshot: TINY_PNG, intent: 'x' });
    expect(r.coords).toEqual({ x: 100, y: 200 });
    expect(r.answer).toBe('x=100, y=200');
    expect(r.reasoning_content).toMatch(/top-right/);
    expect(r.modelId).toBe(ModelId.KIMI_K2_6);
    expect(r.usage.input_tokens).toBe(50);
    expect(r.usage.output_tokens).toBe(200);
    expect(r.elapsed_ms).toBeGreaterThanOrEqual(0);
  });

  test('emits no coords field when answer has no parseable coords', async () => {
    spy.setResponse(okResponse({
      choices: [{
        index: 0,
        message: { role: 'assistant', content: 'I cannot find the button.' },
        finish_reason: 'stop',
      }],
    }));
    const r = await adapter.ground({ screenshot: TINY_PNG, intent: 'x' });
    expect(r.coords).toBeUndefined();
    expect(r.answer).toMatch(/cannot find/);
  });

  test('carries tool_calls when model emits them', async () => {
    spy.setResponse(okResponse({
      choices: [{
        index: 0,
        message: { role: 'assistant', content: '', tool_calls: [
          { id: 'c1', type: 'function', function: { name: 'click', arguments: '{"x":1,"y":2}' } },
        ]},
        finish_reason: 'tool_calls',
      }],
    }));
    const r = await adapter.ground({ screenshot: TINY_PNG, intent: 'x' });
    expect(r.tool_calls).toHaveLength(1);
    expect(r.tool_calls[0].function.name).toBe('click');
  });

  test('UNPARSEABLE_RESPONSE when choices[0] missing', async () => {
    spy.setResponse({ choices: [] });
    await expect(adapter.ground({ screenshot: TINY_PNG, intent: 'x' }))
      .rejects.toMatchObject({ code: ErrorCode.UNPARSEABLE_RESPONSE });
  });
});

// ── ground() — error paths ──────────────────────────────────────────

describe('KimiAdapter — ground() errors', () => {
  let spy, adapter;
  beforeEach(() => {
    spy = makeFetchSpy();
    adapter = makeAdapter({ fetchImpl: spy.fetchImpl });
  });

  test('INVALID_REQUEST when screenshot is not a Buffer', async () => {
    await expect(adapter.ground({ screenshot: 'oops', intent: 'x' }))
      .rejects.toMatchObject({ code: ErrorCode.INVALID_REQUEST });
  });

  test('INVALID_REQUEST when intent is empty', async () => {
    await expect(adapter.ground({ screenshot: TINY_PNG, intent: '   ' }))
      .rejects.toMatchObject({ code: ErrorCode.INVALID_REQUEST });
  });

  test('UNSUPPORTED_KNOB when knobs fail validation', async () => {
    await expect(adapter.ground({
      screenshot: TINY_PNG, intent: 'x',
      knobs: { reasoning_effort: 'minimal' },
    })).rejects.toMatchObject({ code: ErrorCode.UNSUPPORTED_KNOB });
  });

  test('AUTH_FAILED on 401 from Foundry', async () => {
    spy.setResponse({ error: { code: 'Unauthorized', message: 'bad key' } }, 401);
    await expect(adapter.ground({ screenshot: TINY_PNG, intent: 'x' }))
      .rejects.toMatchObject({ code: ErrorCode.AUTH_FAILED });
  });

  test('PROVIDER_ERROR on 500 from Foundry', async () => {
    spy.setResponse({ error: { message: 'oops' } }, 500);
    await expect(adapter.ground({ screenshot: TINY_PNG, intent: 'x' }))
      .rejects.toMatchObject({ code: ErrorCode.PROVIDER_ERROR });
  });
});

// ── proxy mode ──────────────────────────────────────────────────────

describe('KimiAdapter — proxy mode', () => {
  function makeProxyAdapter(extra = {}) {
    return new KimiAdapter({
      mode: 'proxy',
      backendUrl: 'https://api.loxia.ai',
      userApiKey: 'user-tok-123',
      ...extra,
    });
  }

  // Proxy mode returns the backend's contract shape, not the Foundry one.
  function proxyOk(overrides = {}) {
    return {
      answer:    'x=300, y=400',
      reasoning: 'computed from layout',
      tool_calls: [],
      model: 'kimi-k2.6',
      usage: { inputTokens: 42, outputTokens: 17 },
      ...overrides,
    };
  }

  test('throws on missing backendUrl', () => {
    expect(() => new KimiAdapter({ mode: 'proxy', userApiKey: 'k' }))
      .toThrow(/backendUrl is required/);
  });

  test('throws on missing userApiKey', () => {
    expect(() => new KimiAdapter({ mode: 'proxy', backendUrl: 'https://x' }))
      .toThrow(/userApiKey is required/);
  });

  test('rejects unknown mode', () => {
    expect(() => new KimiAdapter({ mode: 'magic', endpoint: 'x', apiKey: 'k' }))
      .toThrow(/unknown mode/);
  });

  test('strips trailing slashes on backendUrl', () => {
    const a = makeProxyAdapter({ backendUrl: 'https://api.loxia.ai///' });
    expect(a.backendUrl).toBe('https://api.loxia.ai');
  });

  test('POSTs to <backendUrl>/llm/grounding with user bearer token', async () => {
    const spy = makeFetchSpy(proxyOk());
    const a = makeProxyAdapter({ fetchImpl: spy.fetchImpl });
    await a.ground({ screenshot: TINY_PNG, intent: 'click X' });
    expect(spy.calls).toHaveLength(1);
    expect(spy.calls[0].url).toBe('https://api.loxia.ai/llm/grounding');
    expect(spy.calls[0].opts.headers.Authorization).toBe('Bearer user-tok-123');
  });

  test('proxy body carries model + messages + knobs (no Foundry key leak)', async () => {
    const spy = makeFetchSpy(proxyOk());
    const a = makeProxyAdapter({ fetchImpl: spy.fetchImpl });
    await a.ground({
      screenshot: TINY_PNG,
      intent: 'click X',
      knobs: { reasoning_effort: ReasoningEffort.LOW, seed: 7 },
    });
    const sent = JSON.parse(spy.calls[0].opts.body);
    expect(sent.model).toBe(ModelId.KIMI_K2_6);
    expect(sent.messages).toHaveLength(1);
    expect(sent.reasoning_effort).toBe(ReasoningEffort.LOW);
    expect(sent.seed).toBe(7);
    expect(sent.max_tokens).toBeGreaterThan(0);
    expect(JSON.stringify(sent)).not.toContain('fake-key-for-tests'); // no foundry key in body
  });

  test('coerces proxy response back into a parsed GroundResult', async () => {
    const spy = makeFetchSpy(proxyOk({ answer: 'x=88, y=99', usage: { inputTokens: 5, outputTokens: 3 } }));
    const a = makeProxyAdapter({ fetchImpl: spy.fetchImpl });
    const r = await a.ground({ screenshot: TINY_PNG, intent: 'where' });
    expect(r.answer).toBe('x=88, y=99');
    expect(r.coords).toEqual({ x: 88, y: 99 });
    expect(r.usage).toMatchObject({ input_tokens: 5, output_tokens: 3 });
    expect(r.reasoning_content).toBe('computed from layout');
  });

  test('PROVIDER_ERROR when backend returns 500', async () => {
    const spy = makeFetchSpy({ error: 'boom' });
    spy.setResponse({ error: 'boom' }, 500);
    const a = makeProxyAdapter({ fetchImpl: spy.fetchImpl });
    await expect(a.ground({ screenshot: TINY_PNG, intent: 'x' }))
      .rejects.toMatchObject({ code: ErrorCode.PROVIDER_ERROR });
  });

  test('AUTH_FAILED when backend returns 401', async () => {
    const spy = makeFetchSpy({});
    spy.setResponse({ error: 'unauthorised' }, 401);
    const a = makeProxyAdapter({ fetchImpl: spy.fetchImpl });
    await expect(a.ground({ screenshot: TINY_PNG, intent: 'x' }))
      .rejects.toMatchObject({ code: ErrorCode.AUTH_FAILED });
  });
});
