import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { computeDedupeKey, withDedupe } from '../src/service/request/dedupe';

describe('computeDedupeKey', () => {
  it('returns null for GET method', () => {
    assert.equal(computeDedupeKey({ method: 'GET', url: '/api/list' }), null);
  });

  it('returns null for HEAD/OPTIONS method', () => {
    assert.equal(computeDedupeKey({ method: 'HEAD', url: '/api/x' }), null);
    assert.equal(computeDedupeKey({ method: 'OPTIONS', url: '/api/x' }), null);
  });

  it('returns null when dedupe is explicitly false', () => {
    assert.equal(computeDedupeKey({ method: 'POST', url: '/api/x', dedupe: false }), null);
  });

  it('returns null when data is FormData', () => {
    const fd = new FormData();
    fd.append('file', new Blob(['x']));
    assert.equal(computeDedupeKey({ method: 'POST', url: '/api/upload', data: fd }), null);
  });

  it('returns null when data is Blob', () => {
    assert.equal(computeDedupeKey({ method: 'POST', url: '/api/blob', data: new Blob(['x']) }), null);
  });

  it('returns a string fingerprint for write methods', () => {
    const key = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } });
    assert.equal(typeof key, 'string');
    assert.ok((key as string).length > 0);
  });

  it('uppercases method for canonical fingerprint', () => {
    const a = computeDedupeKey({ method: 'post', url: '/api/x', data: { a: 1 } });
    const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } });
    assert.equal(a, b);
  });

  it('produces the same fingerprint regardless of params key order', () => {
    const a = computeDedupeKey({ method: 'POST', url: '/api/x', params: { b: 2, a: 1 } });
    const b = computeDedupeKey({ method: 'POST', url: '/api/x', params: { a: 1, b: 2 } });
    assert.equal(a, b);
  });

  it('produces the same fingerprint regardless of body key order', () => {
    const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: { b: 2, a: 1 } });
    const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1, b: 2 } });
    assert.equal(a, b);
  });

  it('produces different fingerprint when body differs', () => {
    const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } });
    const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 2 } });
    assert.notEqual(a, b);
  });

  it('handles undefined body (e.g. DELETE) without throwing', () => {
    const key = computeDedupeKey({ method: 'DELETE', url: '/api/x/1' });
    assert.equal(typeof key, 'string');
  });

  it('handles primitive body (string)', () => {
    const key = computeDedupeKey({ method: 'POST', url: '/api/x', data: 'raw=text' });
    assert.equal(typeof key, 'string');
  });

  it('handles array body without key sorting (arrays keep order)', () => {
    const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: [1, 2, 3] });
    const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: [3, 2, 1] });
    assert.notEqual(a, b);
  });
});

describe('withDedupe', () => {
  const buildPending = () => {
    let resolve!: (value: unknown) => void;
    let reject!: (reason?: unknown) => void;
    const promise = new Promise((res, rej) => {
      resolve = res;
      reject = rej;
    });
    return { promise, resolve, reject };
  };

  it('returns the cached Promise for concurrent identical write requests', async () => {
    let callCount = 0;
    const pending = buildPending();
    const inner = (_config: unknown) => {
      callCount += 1;
      return pending.promise;
    };

    const wrapped = withDedupe(inner);
    const config = { method: 'POST', url: '/api/x', data: { a: 1 } };

    const p1 = wrapped(config);
    const p2 = wrapped(config);

    assert.equal(callCount, 1);
    assert.equal(p1, p2);

    pending.resolve({ data: 'ok', error: null });
    const [r1, r2] = await Promise.all([p1, p2]);
    assert.deepEqual(r1, { data: 'ok', error: null });
    assert.equal(r1, r2);
  });

  it('does not dedupe different fingerprints', async () => {
    let callCount = 0;
    const inner = async (config: any) => {
      callCount += 1;
      return { data: config.data, error: null };
    };

    const wrapped = withDedupe(inner);
    await wrapped({ method: 'POST', url: '/api/x', data: { a: 1 } });
    await wrapped({ method: 'POST', url: '/api/x', data: { a: 2 } });
    assert.equal(callCount, 2);
  });

  it('does not dedupe GET requests', async () => {
    let callCount = 0;
    const pending = buildPending();
    const inner = (_config: unknown) => {
      callCount += 1;
      return pending.promise;
    };

    const wrapped = withDedupe(inner);
    const config = { method: 'GET', url: '/api/list', params: { page: 1 } };
    wrapped(config);
    wrapped(config);
    assert.equal(callCount, 2);
    pending.resolve({ data: [], error: null });
  });

  it('does not dedupe FormData uploads', async () => {
    let callCount = 0;
    const inner = async (_config: unknown) => {
      callCount += 1;
      return { data: 'ok', error: null };
    };

    const wrapped = withDedupe(inner);
    const fd = new FormData();
    fd.append('file', new Blob(['x']));

    await wrapped({ method: 'POST', url: '/api/upload', data: fd });
    await wrapped({ method: 'POST', url: '/api/upload', data: fd });
    assert.equal(callCount, 2);
  });

  it('cleans pending entry after success, so next identical request hits the network again', async () => {
    let callCount = 0;
    const inner = async (_config: unknown) => {
      callCount += 1;
      return { data: 'ok', error: null };
    };

    const wrapped = withDedupe(inner);
    const config = { method: 'POST', url: '/api/x', data: { a: 1 } };

    await wrapped(config);
    await wrapped(config);
    assert.equal(callCount, 2);
  });

  it('cleans pending entry after failure (FlatRequest never throws, but raw rejection also clears)', async () => {
    let callCount = 0;
    const inner = async (_config: unknown) => {
      callCount += 1;
      throw new Error('boom');
    };

    const wrapped = withDedupe(inner);
    const config = { method: 'POST', url: '/api/x', data: { a: 1 } };

    await assert.rejects(wrapped(config), /boom/);
    await assert.rejects(wrapped(config), /boom/);
    assert.equal(callCount, 2);
  });

  it('treats entries past TTL as stale and re-enters', async () => {
    let callCount = 0;
    const pending = buildPending();
    const inner = (_config: unknown) => {
      callCount += 1;
      return pending.promise;
    };

    let fakeNow = 1_000_000;
    const wrapped = withDedupe(inner, { ttlMs: 30_000, now: () => fakeNow });
    const config = { method: 'POST', url: '/api/x', data: { a: 1 } };

    wrapped(config);
    assert.equal(callCount, 1);

    fakeNow += 31_000;
    wrapped(config);
    assert.equal(callCount, 2);

    pending.resolve({ data: 'ok', error: null });
  });

  it('preserves extension properties on the wrapped function (state / cancelAllRequest)', () => {
    const inner = (() => Promise.resolve('x')) as unknown as ((c: unknown) => Promise<string>) & {
      state: { foo: number };
      cancelAllRequest: () => string;
    };
    inner.state = { foo: 1 };
    inner.cancelAllRequest = () => 'cancelled';

    const wrapped = withDedupe(inner);
    assert.equal(wrapped.state.foo, 1);
    assert.equal(wrapped.cancelAllRequest(), 'cancelled');

    wrapped.state.foo = 2;
    assert.equal(inner.state.foo, 2);
  });
});
