import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import {
  createProfileNamespace,
  profilePlaintextToString,
  deriveBlobSlotId,
  deriveBlobKey,
  deriveBlobSigningSeed,
  ed25519PublicKeyFromSeed,
  slotIdToHex,
} from '../src/index.js';
import {
  createInboxServer,
  MemoryInboxStore,
  MemoryBlobStore,
} from '@shade/inbox-server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { ShadeError } from '@shade/core';

const crypto = new SubtleCryptoProvider();

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

interface ServerHandle {
  url: string;
  stop: () => void;
}

async function startServer(): Promise<ServerHandle> {
  const app = createInboxServer({
    crypto,
    store: new MemoryInboxStore(),
    blobStore: new MemoryBlobStore(),
    disableRateLimit: true,
  });
  const port = 19000 + Math.floor(Math.random() * 500);
  const handle = Bun.serve({ port, fetch: app.fetch });
  return {
    url: `http://localhost:${port}`,
    stop: () => handle.stop(true),
  };
}

describe('SDK Profile namespace (V4.9)', () => {
  let server: ServerHandle;
  let masterKey: Uint8Array;

  beforeEach(async () => {
    server = await startServer();
    masterKey = randBytes(32);
  });

  afterEach(() => {
    server.stop();
  });

  test('credential-only round trip: create, read, update, delete', async () => {
    const profile = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'test-profile-v1',
    });

    // Empty slot.
    expect(await profile.get()).toBeNull();

    // Create.
    const payload = JSON.stringify({ hosts: ['device:abc'], v: 1 });
    const created = await profile.put(payload);
    expect(created.created).toBe(true);

    // Read back.
    const got1 = await profile.get();
    expect(got1).not.toBeNull();
    expect(profilePlaintextToString(got1!)).toBe(payload);
    expect(got1!.etag).toBe(created.etag);

    // CAS update with the etag we just read.
    const next = JSON.stringify({ hosts: ['device:abc', 'device:def'], v: 2 });
    const updated = await profile.put(next, { ifMatch: got1!.etag });
    expect(updated.created).toBe(false);
    expect(Number(updated.etag)).toBeGreaterThan(Number(created.etag));

    // Stale CAS fails.
    await expect(
      profile.put(JSON.stringify({ hosts: [] }), { ifMatch: created.etag }),
    ).rejects.toThrow(ShadeError);

    // Delete.
    const removed = await profile.delete();
    expect(removed).toBe(true);
    expect(await profile.get()).toBeNull();
  });

  test('different app namespaces map to different slots', async () => {
    const a = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'app-a',
    });
    const b = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'app-b',
    });
    expect(a.slotIdHex).not.toBe(b.slotIdHex);
  });

  test('different master keys map to different slots', async () => {
    const km2 = randBytes(32);
    const a = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'shared',
    });
    const b = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey: km2,
      app: 'shared',
    });
    expect(a.slotIdHex).not.toBe(b.slotIdHex);
  });

  test('a fresh client with the same master + app reads the existing blob', async () => {
    const writer = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'shared',
    });
    await writer.put('hello world');

    // Brand-new namespace instance — simulates "log in from a new
    // device". Uses *only* the master key + app namespace; nothing
    // else carried over.
    const reader = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'shared',
    });
    const got = await reader.get();
    expect(got).not.toBeNull();
    expect(profilePlaintextToString(got!)).toBe('hello world');
  });

  test('without ifMatch on populated slot is a SHADE_CONFLICT error', async () => {
    const profile = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'conflict-test',
    });
    await profile.put('first');
    try {
      await profile.put('second');
      throw new Error('expected put to throw');
    } catch (err) {
      expect(err).toBeInstanceOf(ShadeError);
      expect((err as ShadeError).code).toBe('SHADE_CONFLICT');
    }
  });

  test('stale ifMatch is a SHADE_PRECONDITION_FAILED error', async () => {
    const profile = createProfileNamespace({
      baseUrl: server.url,
      crypto,
      masterKey,
      app: 'precondition-test',
    });
    const first = await profile.put('first');
    await profile.put('second', { ifMatch: first.etag });
    try {
      await profile.put('third', { ifMatch: first.etag });
      throw new Error('expected put to throw');
    } catch (err) {
      expect(err).toBeInstanceOf(ShadeError);
      expect((err as ShadeError).code).toBe('SHADE_PRECONDITION_FAILED');
    }
  });
});

describe('KDF helpers (V4.9)', () => {
  test('derivations are deterministic per (masterKey, app)', () => {
    const km = randBytes(32);
    const a1 = deriveBlobSlotId(km, 'x');
    const a2 = deriveBlobSlotId(km, 'x');
    expect(a1).toEqual(a2);
    expect(deriveBlobSlotId(km, 'y')).not.toEqual(a1);
    expect(deriveBlobKey(km, 'x')).not.toEqual(a1);
    expect(deriveBlobSigningSeed(km, 'x')).not.toEqual(deriveBlobKey(km, 'x'));
  });

  test('signing seed → pubkey is deterministic and 32 bytes', () => {
    const km = randBytes(32);
    const seed = deriveBlobSigningSeed(km, 'p');
    const pk1 = ed25519PublicKeyFromSeed(seed);
    const pk2 = ed25519PublicKeyFromSeed(seed);
    expect(pk1).toEqual(pk2);
    expect(pk1.length).toBe(32);
  });

  test('slotIdToHex round-trips through hex form', () => {
    const km = randBytes(32);
    const id = deriveBlobSlotId(km, 'rt');
    const hex = slotIdToHex(id);
    expect(hex.length).toBe(64);
    expect(/^[0-9a-f]{64}$/.test(hex)).toBe(true);
  });
});
