/**
 * keyProbe.test.js — locks the behaviour of WebServer._probeLoxiaKey
 * (the upstream Loxia-API-key validator that powers the new `keyValid`
 * field on /api/keys/:sessionId).
 *
 * Why this is critical: prior to this change, the front-end's auto-
 * restore path treated `hasLoxiaKey: true` as "the platform key works,
 * skip pushing the user's localStorage key". A stale/revoked key on
 * disk made that branch silently fire every session — every agent
 * message then 401'd in the streaming layer with no recovery. The
 * probe is the single source of truth that lets the front-end decide
 * whether to trust the server's stored key.
 *
 * We mock global.fetch rather than spin up an HTTP harness because
 * the probe's contract IS the fetch behaviour we want to lock in.
 */
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';

const SILENT_LOGGER = { info() {}, warn() {}, error() {}, debug() {} };

/**
 * Build a minimal WebServer instance with just the bits _probeLoxiaKey
 * needs. We can't easily import the full WebServer class without
 * dragging in 60+ other deps, so we extract the method via the same
 * code path with a small shim that mirrors the structure.
 *
 * This shim MUST be kept in sync with WebServer._probeLoxiaKey in
 * src/interfaces/webServer.js — when the production method changes,
 * update this shim in the same commit.
 */
function makeProber({ baseUrl = 'https://upstream.test/api', logger = SILENT_LOGGER } = {}) {
  const self = {
    config: { backend: { baseUrl } },
    logger,
    _keyProbeCache: new Map(),
  };

  self._probeLoxiaKey = async function (apiKey) {
    if (!apiKey || typeof apiKey !== 'string') {
      return { ok: false, reason: 'no-key' };
    }
    const KEY_PROBE_TTL_MS = 60_000;
    const fp = apiKey.slice(0, 12);
    const now = Date.now();
    const cached = this._keyProbeCache.get(fp);
    if (cached && (now - cached.at) < KEY_PROBE_TTL_MS) return cached.result;

    const baseUrl = this.config.backend?.baseUrl;
    let result;
    try {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 5000);
      const response = await fetch(`${baseUrl}/llm/models`, {
        method: 'GET',
        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
        signal: controller.signal,
      });
      clearTimeout(timeout);
      if (response.ok) result = { ok: true, reason: 'ok' };
      else if (response.status === 401 || response.status === 403) result = { ok: false, reason: 'unauthorised' };
      else result = { ok: false, reason: 'probe-failed' };
    } catch {
      result = { ok: false, reason: 'probe-failed' };
    }

    this._keyProbeCache.set(fp, { at: now, result });
    return result;
  };

  return self;
}

let originalFetch;
beforeEach(() => {
  originalFetch = global.fetch;
});
afterEach(() => {
  global.fetch = originalFetch;
});

describe('_probeLoxiaKey — input validation', () => {
  it('reports `no-key` immediately when the key is falsy or non-string', async () => {
    const p = makeProber();
    expect(await p._probeLoxiaKey(null)).toEqual({ ok: false, reason: 'no-key' });
    expect(await p._probeLoxiaKey(undefined)).toEqual({ ok: false, reason: 'no-key' });
    expect(await p._probeLoxiaKey('')).toEqual({ ok: false, reason: 'no-key' });
    expect(await p._probeLoxiaKey({})).toEqual({ ok: false, reason: 'no-key' });
  });

  it('does not call fetch for a falsy key — no upstream traffic', async () => {
    const p = makeProber();
    global.fetch = jest.fn();
    await p._probeLoxiaKey('');
    expect(global.fetch).not.toHaveBeenCalled();
  });
});

describe('_probeLoxiaKey — upstream classification', () => {
  it('200 from upstream → { ok: true, reason: "ok" }', async () => {
    global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 200 }));
    const p = makeProber();
    expect(await p._probeLoxiaKey('lx_valid_key_123')).toEqual({ ok: true, reason: 'ok' });
    // Verifies the probe hit the right endpoint with bearer auth.
    expect(global.fetch).toHaveBeenCalledWith(
      'https://upstream.test/api/llm/models',
      expect.objectContaining({
        method: 'GET',
        headers: expect.objectContaining({ Authorization: 'Bearer lx_valid_key_123' }),
      }),
    );
  });

  it('401 from upstream → { ok: false, reason: "unauthorised" }', async () => {
    global.fetch = jest.fn().mockResolvedValue(new Response('{"error":"Invalid API key"}', { status: 401 }));
    const p = makeProber();
    expect(await p._probeLoxiaKey('lx_revoked_key')).toEqual({ ok: false, reason: 'unauthorised' });
  });

  it('403 from upstream → also classified as unauthorised', async () => {
    global.fetch = jest.fn().mockResolvedValue(new Response('Forbidden', { status: 403 }));
    const p = makeProber();
    expect(await p._probeLoxiaKey('lx_forbidden_key')).toEqual({ ok: false, reason: 'unauthorised' });
  });

  it('500 from upstream → { ok: false, reason: "probe-failed" } — inconclusive, NOT a key problem', async () => {
    // The distinction matters: a real auth failure should make the
    // front-end push the user's localStorage key. An upstream 5xx
    // should NOT, because the stored key might be perfectly fine.
    global.fetch = jest.fn().mockResolvedValue(new Response('boom', { status: 500 }));
    const p = makeProber();
    const r = await p._probeLoxiaKey('lx_key_during_outage');
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('probe-failed');
  });

  it('network/timeout error → { ok: false, reason: "probe-failed" }', async () => {
    global.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
    const p = makeProber();
    expect(await p._probeLoxiaKey('lx_offline_key')).toEqual({ ok: false, reason: 'probe-failed' });
  });
});

describe('_probeLoxiaKey — caching', () => {
  it('second probe of the same key within 60s returns the cached result without hitting fetch', async () => {
    global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 200 }));
    const p = makeProber();
    const r1 = await p._probeLoxiaKey('lx_cacheable_key_abc');
    const r2 = await p._probeLoxiaKey('lx_cacheable_key_abc');
    expect(r1).toEqual({ ok: true, reason: 'ok' });
    expect(r2).toEqual({ ok: true, reason: 'ok' });
    expect(global.fetch).toHaveBeenCalledTimes(1);
  });

  it('cache key is the FINGERPRINT (first 12 chars) — collisions yield same result', async () => {
    // Two keys sharing the first 12 chars are treated as the same
    // logical key by the cache. This is a deliberate trade-off: we
    // never want the full key in any in-memory cache structure that
    // gets logged or debugged.
    global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 200 }));
    const p = makeProber();
    await p._probeLoxiaKey('lx_sharedstart_AAAAAA');
    await p._probeLoxiaKey('lx_sharedstart_BBBBBB');
    expect(global.fetch).toHaveBeenCalledTimes(1);
  });

  it('different fingerprints probe separately', async () => {
    global.fetch = jest.fn()
      .mockResolvedValueOnce(new Response('{}', { status: 200 }))
      .mockResolvedValueOnce(new Response('nope', { status: 401 }));
    const p = makeProber();
    expect(await p._probeLoxiaKey('lx_aaaaaaaaaaaa')).toEqual({ ok: true, reason: 'ok' });
    expect(await p._probeLoxiaKey('lx_zzzzzzzzzzzz')).toEqual({ ok: false, reason: 'unauthorised' });
    expect(global.fetch).toHaveBeenCalledTimes(2);
  });

  it('caches negative results too — prevents hammering the backend with a known-bad key', async () => {
    // Without this, every visit to Settings would issue an upstream
    // 401 — gives the user a worse experience and gives the upstream
    // a worse signal/noise ratio on their auth metrics.
    global.fetch = jest.fn().mockResolvedValue(new Response('bad', { status: 401 }));
    const p = makeProber();
    await p._probeLoxiaKey('lx_stale_key_abcd');
    await p._probeLoxiaKey('lx_stale_key_abcd');
    await p._probeLoxiaKey('lx_stale_key_abcd');
    expect(global.fetch).toHaveBeenCalledTimes(1);
  });
});
