import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { setupFileRig, type FileTestRig } from '../integration/helpers/rig.js';
import { ConflictError, NotFoundError, type FileEntry } from '../../src/index.js';

/**
 * Replay-window: the dispatcher rejects requests where `signedAt` is more
 * than ±5 min from the server clock. Plus: idempotent retries on the same
 * mutation key produce a single side-effect even with stale signedAt.
 */

describe('Replay window + idempotent retry', () => {
  let rig: FileTestRig;
  let writeCount = 0;
  const blobs = new Map<string, Uint8Array>();

  beforeAll(async () => {
    rig = await setupFileRig({
      mkdir: async (ctx) => {
        if (blobs.has(ctx.path)) throw new ConflictError('exists');
        blobs.set(ctx.path, new Uint8Array(0));
        const e: FileEntry = {
          name: ctx.path.split('/').filter(Boolean).pop() ?? '',
          kind: 'dir', size: 0, mtime: Date.now(), metadata: {},
        };
        return { entry: e };
      },
      write: async (ctx) => {
        writeCount++;
        if (ctx.args.content.kind !== 'inline') throw new Error('inline expected');
        blobs.set(ctx.args.path, ctx.args.content.bytes);
        const e: FileEntry = {
          name: ctx.args.path.split('/').filter(Boolean).pop() ?? '',
          kind: 'file',
          size: ctx.args.content.bytes.byteLength,
          mtime: Date.now(),
          metadata: { sha256: ctx.args.content.sha256 },
        };
        return { entry: e };
      },
      stat: async (ctx) => {
        if (!blobs.has(ctx.path)) throw new NotFoundError(ctx.path);
        return {
          name: ctx.path.split('/').filter(Boolean).pop() ?? '',
          kind: 'file', size: 0, mtime: 0, metadata: {},
        };
      },
    });
  });

  afterAll(async () => {
    await rig.teardown();
  });

  test('idempotent retry: same key + same args → single side-effect', async () => {
    writeCount = 0;
    // Idempotency keys are exactly 22 chars, base64url alphabet.
    const key = 'replay_key_1234567890A';
    const data = new Uint8Array([1, 2, 3]);
    const r1 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
    const r2 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
    const r3 = await rig.fs.write('/replay.bin', data, { idempotencyKey: key });
    expect(writeCount).toBe(1);
    expect(r1.entry.size).toBe(3);
    expect(r2.entry.size).toBe(3);
    expect(r3.entry.size).toBe(3);
  });

  test('out-of-window signedAt → InvalidSignatureError (skew rejection)', async () => {
    // Build a custom client that LIES about signedAt. We hand-craft an
    // RpcRequest envelope and ship it via the underlying channel.
    const {
      ShadeFileRpcChannel,
      PendingRpcRegistry,
      attachClientRouting,
      KIND_STAT_V1,
      generateRequestId,
    } = await import('../../src/index.js');
    const aliceChannel = new ShadeFileRpcChannel(rig.alice);
    const alicePending = new PendingRpcRegistry();
    attachClientRouting(aliceChannel, alicePending);

    const requestId = generateRequestId();
    const stalePromise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
    await aliceChannel.send('bob', {
      kind: KIND_STAT_V1,
      id: requestId,
      args: { path: '/replay.bin' },
      sig: 'unsigned',
      signedAt: Date.now() - 10 * 60 * 1000, // 10 min in the past — outside ±5 min window
    });
    await expect(stalePromise).rejects.toThrow(/replay window|signature/i);
    aliceChannel.destroy();
  });

  test('signedAt far in the future → also rejected', async () => {
    const {
      ShadeFileRpcChannel,
      PendingRpcRegistry,
      attachClientRouting,
      KIND_STAT_V1,
      generateRequestId,
    } = await import('../../src/index.js');
    const aliceChannel = new ShadeFileRpcChannel(rig.alice);
    const alicePending = new PendingRpcRegistry();
    attachClientRouting(aliceChannel, alicePending);

    const requestId = generateRequestId();
    const promise = alicePending.register<unknown>(requestId, { timeoutMs: 3000 });
    await aliceChannel.send('bob', {
      kind: KIND_STAT_V1,
      id: requestId,
      args: { path: '/replay.bin' },
      sig: 'unsigned',
      signedAt: Date.now() + 10 * 60 * 1000,
    });
    await expect(promise).rejects.toThrow(/replay window|signature/i);
    aliceChannel.destroy();
  });
});
