import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import * as npm from './npm-registry.js';

const originalFetch = globalThis.fetch;
const originalDateNow = Date.now;

let fetchMock;

function jsonResponse(body, status = 200) {
  return Promise.resolve(new Response(JSON.stringify(body), {
    status,
    headers: { 'content-type': 'application/json' },
  }));
}

describe('npm registry client', () => {
  beforeEach(() => {
    npm.clearCache();
    Date.now = originalDateNow;
    fetchMock = mock(() => jsonResponse({}));
    globalThis.fetch = fetchMock;
  });

  afterEach(() => {
    npm.clearCache();
    globalThis.fetch = originalFetch;
    Date.now = originalDateNow;
  });

  test('200 success returns latest versions and dist tags', async () => {
    fetchMock.mockImplementation(() => jsonResponse({
      'dist-tags': { latest: '1.2.0' },
      versions: { '1.0.0': {}, '1.2.0': {} },
    }));

    const result = await npm.lookupNpmPackage('foo');

    expect(result).toEqual({
      ok: true,
      latest: '1.2.0',
      versions: ['1.0.0', '1.2.0'],
      distTags: { latest: '1.2.0' },
    });
  });

  test('200 success handles missing dist-tags and versions', async () => {
    fetchMock.mockImplementation(() => jsonResponse({}));

    const result = await npm.lookupNpmPackage('foo');

    expect(result).toEqual({ ok: true, latest: null, versions: [], distTags: {} });
  });

  test('404 returns package not found', async () => {
    fetchMock.mockImplementation(() => jsonResponse({}, 404));

    const result = await npm.lookupNpmPackage('missing');

    expect(result).toEqual({ ok: false, status: 404, error: 'Package not found' });
  });

  test('500 returns registry error', async () => {
    fetchMock.mockImplementation(() => jsonResponse({}, 500));

    const result = await npm.lookupNpmPackage('foo');

    expect(result).toEqual({ ok: false, status: 500, error: 'Registry returned 500' });
  });

  test('network error returns network status', async () => {
    fetchMock.mockImplementation(() => Promise.reject(new Error('socket closed')));

    const result = await npm.lookupNpmPackage('foo');

    expect(result.ok).toBe(false);
    expect(result.status).toBe('network');
    expect(result.error).toBe('socket closed');
  });

  test('timeout plumbs AbortSignal to fetch', async () => {
    fetchMock.mockImplementation((_url, init) => {
      expect(init.signal).toBeInstanceOf(AbortSignal);
      return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
    });

    const result = await npm.lookupNpmPackage('foo');

    expect(result.ok).toBe(false);
    expect(result.status).toBe('network');
    expect(result.error).toContain('aborted');
  });

  test('cache hit reuses definitive success', async () => {
    fetchMock.mockImplementation(() => jsonResponse({ 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': {} } }));

    const first = await npm.getNpmInfo('foo');
    const second = await npm.getNpmInfo('foo');

    expect(first).toEqual(second);
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  test('cache miss after ttl fetches again', async () => {
    let now = 1_000;
    Date.now = mock(() => now);
    fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));

    await npm.getNpmInfo('foo');
    now += 3_600_001;
    await npm.getNpmInfo('foo');

    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  test('forceRefresh bypasses cache', async () => {
    fetchMock.mockImplementation(() => jsonResponse({ versions: {} }));

    await npm.getNpmInfo('foo');
    await npm.getNpmInfo('foo', { forceRefresh: true });

    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  test('in-flight requests dedup by package name', async () => {
    let release;
    const wait = new Promise((resolve) => {
      release = resolve;
    });
    fetchMock.mockImplementation(async () => {
      await wait;
      return new Response(JSON.stringify({ versions: { '1.0.0': {} } }), { status: 200 });
    });

    const requests = Promise.all([
      npm.getNpmInfo('foo'),
      npm.getNpmInfo('foo'),
      npm.getNpmInfo('foo'),
    ]);
    release();
    const results = await requests;

    expect(results.every((result) => result.ok)).toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  test('network failure is not cached', async () => {
    fetchMock
      .mockImplementationOnce(() => Promise.reject(new Error('down')))
      .mockImplementationOnce(() => jsonResponse({ versions: {} }));

    const first = await npm.getNpmInfo('foo');
    const second = await npm.getNpmInfo('foo');

    expect(first.status).toBe('network');
    expect(second.ok).toBe(true);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  test('404 is cached', async () => {
    fetchMock.mockImplementation(() => jsonResponse({}, 404));

    await npm.getNpmInfo('missing');
    await npm.getNpmInfo('missing');

    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  test('scoped names encode slash in registry url', async () => {
    await npm.getNpmInfo('@scope/pkg');

    expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@scope%2Fpkg');
  });

  test('user-agent header is present', async () => {
    await npm.getNpmInfo('foo');

    expect(fetchMock.mock.calls[0][1].headers['User-Agent']).toMatch(/^openchamber-server\//);
  });
});
