import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
  configureModelContext,
  getModelContext,
  invalidateModelContext,
} from '../model-context.js';

// ---- fixtures ---------------------------------------------------------------

const TEST_URL = 'http://llama-swap.test:8401';

function mockOkProps(n_ctx: number, total_slots = 1) {
  return new Response(
    JSON.stringify({
      default_generation_settings: { n_ctx },
      total_slots,
    }),
    { status: 200, headers: { 'Content-Type': 'application/json' } },
  );
}

beforeEach(() => {
  invalidateModelContext();
  configureModelContext({ llamaSwapUrl: TEST_URL });
});

afterEach(() => {
  vi.restoreAllMocks();
  vi.useRealTimers();
});

// ---- positive cache ---------------------------------------------------------

describe('getModelContext — positive cache', () => {
  it('returns the parsed body on a 200 with valid shape', async () => {
    const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144, 1));
    const result = await getModelContext('qwen3.6');
    expect(result).not.toBeNull();
    expect(result!.n_ctx).toBe(262_144);
    expect(result!.total_slots).toBe(1);
    expect(typeof result!.fetched_at).toBe('number');
    // Verify the URL was constructed correctly — encodes the model name in
    // case it contains characters that would break the path.
    expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(
      `${TEST_URL}/upstream/qwen3.6/props`,
      expect.objectContaining({ signal: expect.any(AbortSignal) }),
    );
  });

  it('serves the second call from cache without refetching', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(mockOkProps(262_144));
    const a = await getModelContext('qwen3.6');
    const b = await getModelContext('qwen3.6');
    expect(a).toEqual(b);
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  it('defaults total_slots to 1 when the server omits it', async () => {
    // Mirror the docstring claim — total_slots is informational and we don't
    // reject the response just because it's missing.
    vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
      new Response(JSON.stringify({ default_generation_settings: { n_ctx: 8192 } }), {
        status: 200,
      }),
    );
    const result = await getModelContext('partial-model');
    expect(result).not.toBeNull();
    expect(result!.n_ctx).toBe(8192);
    expect(result!.total_slots).toBe(1);
  });
});

// ---- negative cache (single-shot) ------------------------------------------

describe('getModelContext — negative cache (single failure modes)', () => {
  it('returns null and negative-caches when default_generation_settings is missing', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response(JSON.stringify({ total_slots: 1 }), { status: 200 }));
    const result = await getModelContext('broken');
    expect(result).toBeNull();
    // Second call within TTL must not refetch.
    const result2 = await getModelContext('broken');
    expect(result2).toBeNull();
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  it('returns null and negative-caches when n_ctx is missing inside default_generation_settings', async () => {
    const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
      new Response(JSON.stringify({ default_generation_settings: {}, total_slots: 1 }), {
        status: 200,
      }),
    );
    await getModelContext('half-broken');
    await getModelContext('half-broken');
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  it('returns null and negative-caches on non-200 (404)', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response('not found', { status: 404 }));
    const result = await getModelContext('missing-model');
    expect(result).toBeNull();
    const result2 = await getModelContext('missing-model');
    expect(result2).toBeNull();
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  it('returns null and negative-caches on network error', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockRejectedValueOnce(new TypeError('fetch failed: connect ECONNREFUSED'));
    const result = await getModelContext('down-upstream');
    expect(result).toBeNull();
    const result2 = await getModelContext('down-upstream');
    expect(result2).toBeNull();
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });
});

// ---- negative cache TTL -----------------------------------------------------

describe('getModelContext — negative cache TTL', () => {
  it('does NOT refetch when a second call lands within the 60s TTL', async () => {
    vi.useFakeTimers();
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response('boom', { status: 500 }));

    await getModelContext('flapping');
    vi.advanceTimersByTime(30_000);
    await getModelContext('flapping');
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  it('refetches when the second call lands after the 60s TTL expires', async () => {
    vi.useFakeTimers();
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response('boom', { status: 500 }))
      // Recovered upstream on the retry — we expect a positive cache hit
      // after this fires.
      .mockResolvedValueOnce(mockOkProps(8192));

    await getModelContext('flapping');
    vi.advanceTimersByTime(61_000);
    const result = await getModelContext('flapping');
    expect(result).not.toBeNull();
    expect(result!.n_ctx).toBe(8192);
    expect(fetchSpy).toHaveBeenCalledTimes(2);
  });
});

// ---- invalidateModelContext -------------------------------------------------

describe('invalidateModelContext', () => {
  it('clears a single positive entry by model name', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(mockOkProps(8192))
      .mockResolvedValueOnce(mockOkProps(8192));

    await getModelContext('cleared');
    invalidateModelContext('cleared');
    await getModelContext('cleared');
    expect(fetchSpy).toHaveBeenCalledTimes(2);
  });

  it('clears ALL entries when called with no arg', async () => {
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(mockOkProps(8192))
      .mockResolvedValueOnce(mockOkProps(16_384))
      // After the full clear, both models re-fetch.
      .mockResolvedValueOnce(mockOkProps(8192))
      .mockResolvedValueOnce(mockOkProps(16_384));

    await getModelContext('alpha');
    await getModelContext('beta');
    invalidateModelContext();
    await getModelContext('alpha');
    await getModelContext('beta');
    expect(fetchSpy).toHaveBeenCalledTimes(4);
  });

  it('clearing a positive entry also clears the matching negative entry', async () => {
    // Mixed state: first call fails (negative-caches), then we invalidate
    // explicitly and the next call should fetch again rather than serve
    // the stale negative entry.
    const fetchSpy = vi
      .spyOn(globalThis, 'fetch')
      .mockResolvedValueOnce(new Response('boom', { status: 500 }))
      .mockResolvedValueOnce(mockOkProps(4096));

    await getModelContext('formerly-broken');
    invalidateModelContext('formerly-broken');
    const result = await getModelContext('formerly-broken');
    expect(result).not.toBeNull();
    expect(result!.n_ctx).toBe(4096);
    expect(fetchSpy).toHaveBeenCalledTimes(2);
  });
});
