import { describe, test, expect, beforeEach } from 'bun:test';
import { Hono } from 'hono';
import {
  createBlobRoutes,
  MemoryBlobStore,
  type BlobStore,
} from '../src/index.js';
import { signPayload } from '@shade/server';
import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { toBase64, fromBase64 } from '@shade/core';

const crypto = new SubtleCryptoProvider();

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

function hex(bytes: Uint8Array): string {
  let s = '';
  for (const b of bytes) s += b.toString(16).padStart(2, '0');
  return s;
}

describe('Shade Blob Routes (V4.9)', () => {
  let store: BlobStore;
  let app: Hono;

  beforeEach(() => {
    store = new MemoryBlobStore();
    app = createBlobRoutes(store, crypto, { disableRateLimit: true });
  });

  async function makeOwner() {
    const seed = randBytes(32);
    const pubkey = ed25519PublicKeyFromSeed(seed);
    return { seed, pubkey };
  }

  function makeSlotId(): string {
    return hex(randBytes(32));
  }

  async function signedPut(args: {
    slotId: string;
    blob: Uint8Array;
    seed: Uint8Array;
    pubkey: Uint8Array;
    ifMatch?: string;
  }) {
    const payload: Record<string, unknown> = {
      ownerPubkey: toBase64(args.pubkey),
      blob: toBase64(args.blob),
      slotId: args.slotId,
    };
    if (args.ifMatch !== undefined) payload.ifMatch = args.ifMatch;
    const signed = await signPayload(crypto, args.seed, payload);
    const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
    return app.request(`/v1/blob/${args.slotId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(wire),
    });
  }

  async function signedDelete(args: {
    slotId: string;
    seed: Uint8Array;
  }) {
    const signed = await signPayload(crypto, args.seed, {
      slotId: args.slotId,
    });
    const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
    return app.request(`/v1/blob/${args.slotId}`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(wire),
    });
  }

  // ─── GET ─────────────────────────────────────────────────────

  test('GET on missing slot returns 404', async () => {
    const slotId = makeSlotId();
    const res = await app.request(`/v1/blob/${slotId}`);
    expect(res.status).toBe(404);
  });

  test('GET requires lowercase 64-hex slotId', async () => {
    const res = await app.request('/v1/blob/notahex');
    expect(res.status).toBe(400);
    const res2 = await app.request(`/v1/blob/${'A'.repeat(64)}`);
    expect(res2.status).toBe(400);
  });

  // ─── PUT (TOFU) ──────────────────────────────────────────────

  test('first PUT creates slot and returns etag', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    const blob = randBytes(128);
    const res = await signedPut({ slotId, blob, ...owner });
    expect(res.status).toBe(200);
    const json = (await res.json()) as { created: boolean; etag: string };
    expect(json.created).toBe(true);
    expect(typeof json.etag).toBe('string');

    const got = await app.request(`/v1/blob/${slotId}`);
    expect(got.status).toBe(200);
    const back = (await got.json()) as { blob: string; etag: string };
    expect(fromBase64(back.blob)).toEqual(blob);
    expect(back.etag).toBe(json.etag);
  });

  test('PUT without ifMatch on populated slot returns 409', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    await signedPut({ slotId, blob: randBytes(64), ...owner });
    const res = await signedPut({ slotId, blob: randBytes(64), ...owner });
    expect(res.status).toBe(409);
    const json = (await res.json()) as { code: string };
    expect(json.code).toBe('SHADE_CONFLICT');
  });

  test('PUT with stale ifMatch returns 412', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner });
    const j1 = (await r1.json()) as { etag: string };
    // Use an etag we know does not match.
    const stale = String(Number(j1.etag) - 999);
    const res = await signedPut({
      slotId,
      blob: randBytes(64),
      ...owner,
      ifMatch: stale,
    });
    expect(res.status).toBe(412);
    const json = (await res.json()) as { code: string };
    expect(json.code).toBe('SHADE_PRECONDITION_FAILED');
  });

  test('PUT with matching ifMatch updates and bumps etag', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    const r1 = await signedPut({ slotId, blob: randBytes(64), ...owner });
    const j1 = (await r1.json()) as { etag: string };
    const r2 = await signedPut({
      slotId,
      blob: randBytes(64),
      ...owner,
      ifMatch: j1.etag,
    });
    expect(r2.status).toBe(200);
    const j2 = (await r2.json()) as { created: boolean; etag: string };
    expect(j2.created).toBe(false);
    expect(Number(j2.etag)).toBeGreaterThan(Number(j1.etag));
  });

  test('PUT with ifMatch="*" unconditionally overwrites existing slot', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    await signedPut({ slotId, blob: randBytes(64), ...owner });
    const res = await signedPut({
      slotId,
      blob: randBytes(64),
      ...owner,
      ifMatch: '*',
    });
    expect(res.status).toBe(200);
  });

  test('PUT with ifMatch="*" on empty slot returns 412', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    const res = await signedPut({
      slotId,
      blob: randBytes(64),
      ...owner,
      ifMatch: '*',
    });
    expect(res.status).toBe(412);
  });

  test('PUT by a different owner key on existing slot is rejected', async () => {
    const slotId = makeSlotId();
    const ownerA = await makeOwner();
    await signedPut({ slotId, blob: randBytes(64), ...ownerA });

    const ownerB = await makeOwner();
    const res = await signedPut({
      slotId,
      blob: randBytes(64),
      ...ownerB,
      ifMatch: '*',
    });
    expect(res.status).toBe(401);
  });

  test('PUT with bad signature is rejected', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    // Sign the payload, then mutate the blob bytes — signature no
    // longer matches the canonicalized body.
    const blob = randBytes(64);
    const payload = {
      ownerPubkey: toBase64(owner.pubkey),
      blob: toBase64(blob),
      slotId,
    };
    const signed = await signPayload(crypto, owner.seed, payload);
    (signed as any).blob = toBase64(randBytes(64));
    const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
    const res = await app.request(`/v1/blob/${slotId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(wire),
    });
    expect(res.status).toBe(401);
  });

  test('PUT rejects empty blob and oversized blob', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    const empty = await signedPut({ slotId, blob: new Uint8Array(0), ...owner });
    expect(empty.status).toBe(400);
    const tooBig = await signedPut({
      slotId,
      blob: randBytes(70 * 1024),
      ...owner,
    });
    expect(tooBig.status).toBe(400);
  });

  // ─── DELETE ──────────────────────────────────────────────────

  test('DELETE clears slot and lets a fresh key TOFU re-claim', async () => {
    const slotId = makeSlotId();
    const ownerA = await makeOwner();
    await signedPut({ slotId, blob: randBytes(64), ...ownerA });

    const del = await signedDelete({ slotId, seed: ownerA.seed });
    expect(del.status).toBe(200);

    // Slot is gone.
    const gone = await app.request(`/v1/blob/${slotId}`);
    expect(gone.status).toBe(404);

    // A fresh owner can now claim it.
    const ownerB = await makeOwner();
    const claim = await signedPut({ slotId, blob: randBytes(64), ...ownerB });
    expect(claim.status).toBe(200);
  });

  test('DELETE by a different key is rejected', async () => {
    const slotId = makeSlotId();
    const ownerA = await makeOwner();
    await signedPut({ slotId, blob: randBytes(64), ...ownerA });

    const ownerB = await makeOwner();
    const res = await signedDelete({ slotId, seed: ownerB.seed });
    expect(res.status).toBe(401);
  });

  test('DELETE on missing slot returns 404', async () => {
    const slotId = makeSlotId();
    const owner = await makeOwner();
    const res = await signedDelete({ slotId, seed: owner.seed });
    expect(res.status).toBe(404);
  });

  // ─── Cross-slot replay ───────────────────────────────────────

  test('PUT signed for slot A is rejected against slot B', async () => {
    const slotA = makeSlotId();
    const slotB = makeSlotId();
    const owner = await makeOwner();
    const blob = randBytes(64);
    // Sign for slotA, send to slotB (URL).
    const payload = {
      ownerPubkey: toBase64(owner.pubkey),
      blob: toBase64(blob),
      slotId: slotA,
    };
    const signed = await signPayload(crypto, owner.seed, payload);
    const { slotId: _omit, ...wire } = signed as Record<string, unknown>;
    const res = await app.request(`/v1/blob/${slotB}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(wire),
    });
    expect(res.status).toBe(401);
  });
});
