import { describe, test, expect } from 'bun:test';
import {
  createInboxServer,
  MemoryInboxStore,
  computeMsgId,
  InboxPruneTask,
} from '../src/index.js';
import { signPayload } from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { generateIdentityKeyPair, toBase64, fromBase64 } from '@shade/core';

const crypto = new SubtleCryptoProvider();

async function makeIdentity() {
  return generateIdentityKeyPair(crypto);
}

function randBytes(n: number): Uint8Array {
  const buf = new Uint8Array(n);
  globalThis.crypto.getRandomValues(buf);
  return buf;
}

describe('Inbox lifecycle', () => {
  test('100 messages delivered without online overlap', async () => {
    const store = new MemoryInboxStore();
    const app = createInboxServer({ crypto, store, disableRateLimit: true });

    const bob = await makeIdentity();
    const alice = await makeIdentity();

    // Bob registers, then goes "offline".
    const reg = await signPayload(crypto, bob.signingPrivateKey, {
      address: 'bob',
      signingKey: toBase64(bob.signingPublicKey),
    });
    await app.request('/v1/inbox/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(reg),
    });

    // Alice puts 100 unique blobs while Bob is offline.
    const sentMsgIds = new Set<string>();
    for (let i = 0; i < 100; i++) {
      const ct = randBytes(64 + (i % 8));
      const msgId = await computeMsgId(ct);
      sentMsgIds.add(msgId);
      const body = await signPayload(crypto, alice.signingPrivateKey, {
        senderSigningKey: toBase64(alice.signingPublicKey),
        msgId,
        ciphertext: toBase64(ct),
      });
      const r = await app.request(`/v1/inbox/bob`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      expect(r.status).toBe(200);
    }

    // Bob comes online and pulls everything in pages.
    const seen = new Set<string>();
    let cursor = 0;
    let safety = 0;
    while (safety++ < 50) {
      const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
        address: 'bob',
        sinceCursor: cursor,
      });
      const r = await app.request(`/v1/inbox/bob/fetch`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(fetchBody),
      });
      const j: any = await r.json();
      for (const b of j.blobs) seen.add(b.msgId);
      cursor = j.cursor;
      if (!j.hasMore) break;
    }
    expect(seen.size).toBe(100);
    for (const msgId of sentMsgIds) expect(seen.has(msgId)).toBe(true);
  });

  test('persistence across "restart" — same store, fresh app object', async () => {
    const store = new MemoryInboxStore();

    const bob = await makeIdentity();
    const alice = await makeIdentity();

    // Stage 1: register + put 5 blobs.
    {
      const app = createInboxServer({ crypto, store, disableRateLimit: true });
      const reg = await signPayload(crypto, bob.signingPrivateKey, {
        address: 'bob',
        signingKey: toBase64(bob.signingPublicKey),
      });
      await app.request('/v1/inbox/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(reg),
      });
      for (let i = 0; i < 5; i++) {
        const ct = randBytes(48 + i);
        const msgId = await computeMsgId(ct);
        const body = await signPayload(crypto, alice.signingPrivateKey, {
          senderSigningKey: toBase64(alice.signingPublicKey),
          msgId,
          ciphertext: toBase64(ct),
        });
        const r = await app.request(`/v1/inbox/bob`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
        expect(r.status).toBe(200);
      }
    }

    // Stage 2: simulate a restart by building a brand-new Hono app on top
    // of the same persistent store, then verify fetches still see the data.
    {
      const app = createInboxServer({ crypto, store, disableRateLimit: true });
      const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
        address: 'bob',
        sinceCursor: 0,
      });
      const r = await app.request('/v1/inbox/bob/fetch', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(fetchBody),
      });
      const j: any = await r.json();
      expect(j.blobs.length).toBe(5);
    }
  });

  test('prune removes expired blobs but keeps live ones', async () => {
    const store = new MemoryInboxStore();
    const app = createInboxServer({ crypto, store, disableRateLimit: true });
    const bob = await makeIdentity();
    const alice = await makeIdentity();

    const reg = await signPayload(crypto, bob.signingPrivateKey, {
      address: 'bob',
      signingKey: toBase64(bob.signingPublicKey),
    });
    await app.request('/v1/inbox/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(reg),
    });

    // One blob with min TTL, one with default TTL (well in future).
    const shortCt = randBytes(64);
    const shortMsgId = await computeMsgId(shortCt);
    const shortBody = await signPayload(crypto, alice.signingPrivateKey, {
      senderSigningKey: toBase64(alice.signingPublicKey),
      msgId: shortMsgId,
      ciphertext: toBase64(shortCt),
      ttlSeconds: 60,
    });
    await app.request('/v1/inbox/bob', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(shortBody),
    });

    const longCt = randBytes(64);
    const longMsgId = await computeMsgId(longCt);
    const longBody = await signPayload(crypto, alice.signingPrivateKey, {
      senderSigningKey: toBase64(alice.signingPublicKey),
      msgId: longMsgId,
      ciphertext: toBase64(longCt),
    });
    await app.request('/v1/inbox/bob', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(longBody),
    });

    // Force-expire the short blob by mutating expires_at.
    const list: any = (store as any).blobs.get('bob');
    list[0].expiresAt = Date.now() - 1000;

    const prune = new InboxPruneTask(store, { intervalMinutes: 60 });
    const removed = await prune.runOnce();
    expect(removed).toBe(1);

    const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
      address: 'bob',
      sinceCursor: 0,
    });
    const r = await app.request('/v1/inbox/bob/fetch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(fetchBody),
    });
    const j: any = await r.json();
    expect(j.blobs.length).toBe(1);
    expect(j.blobs[0].msgId).toBe(longMsgId);
  });
});

describe('Tamper resistance', () => {
  test('bit-flip on stored ciphertext is reported as decode/decrypt failure on the client', async () => {
    const store = new MemoryInboxStore();
    const app = createInboxServer({ crypto, store, disableRateLimit: true });

    const bob = await makeIdentity();
    const alice = await makeIdentity();

    const reg = await signPayload(crypto, bob.signingPrivateKey, {
      address: 'bob',
      signingKey: toBase64(bob.signingPublicKey),
    });
    await app.request('/v1/inbox/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(reg),
    });
    const ct = randBytes(64);
    const msgId = await computeMsgId(ct);
    const body = await signPayload(crypto, alice.signingPrivateKey, {
      senderSigningKey: toBase64(alice.signingPublicKey),
      msgId,
      ciphertext: toBase64(ct),
    });
    const putRes = await app.request('/v1/inbox/bob', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    expect(putRes.status).toBe(200);

    // Tamper directly in the store.
    const blobs: any = (store as any).blobs.get('bob');
    blobs[0].ciphertext[5] ^= 0x01;

    // Fetch returns the tampered blob — server is oblivious to integrity.
    const fetchBody = await signPayload(crypto, bob.signingPrivateKey, {
      address: 'bob',
      sinceCursor: 0,
    });
    const r = await app.request('/v1/inbox/bob/fetch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(fetchBody),
    });
    const j: any = await r.json();
    expect(j.blobs.length).toBe(1);

    // Recipient recomputes msgId; tampered ciphertext now hashes to a
    // value different from the stored msgId — that's the client-side
    // canary the V3.6 spec requires.
    const tampered = fromBase64(j.blobs[0].ciphertext);
    const recomputed = await computeMsgId(tampered);
    expect(recomputed).not.toBe(msgId);
  });
});
