import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { unlinkSync } from 'fs';
import { SqliteInboxStore } from '../src/sqlite-inbox-store.js';

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

function tempDbPath(): string {
  return join(
    tmpdir(),
    `shade-inbox-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
  );
}

describe('SqliteInboxStore', () => {
  let dbPath: string;
  let store: SqliteInboxStore;

  beforeEach(() => {
    dbPath = tempDbPath();
    store = new SqliteInboxStore(dbPath);
  });

  afterEach(() => {
    store.close();
    try {
      unlinkSync(dbPath);
    } catch {}
    try {
      unlinkSync(dbPath + '-wal');
    } catch {}
    try {
      unlinkSync(dbPath + '-shm');
    } catch {}
  });

  test('owner save + get', async () => {
    const key = randBytes(32);
    await store.saveAddressOwner('bob', key);
    const got = await store.getAddressOwner('bob');
    expect(got).not.toBeNull();
    expect(got!).toEqual(key);
  });

  test('putBlob is idempotent on (address, msgId)', async () => {
    const ct = randBytes(64);
    const a = await store.putBlob({
      address: 'bob',
      msgId: 'a'.repeat(64),
      ciphertext: ct,
      expiresAt: Date.now() + 60_000,
    });
    expect(a.created).toBe(true);
    const b = await store.putBlob({
      address: 'bob',
      msgId: 'a'.repeat(64),
      ciphertext: ct,
      expiresAt: Date.now() + 60_000,
    });
    expect(b.created).toBe(false);
    expect(b.receivedAt).toBe(a.receivedAt);
  });

  test('fetchBlobs respects sinceCursor and expires_at', async () => {
    const now = Date.now();
    await store.putBlob({
      address: 'bob',
      msgId: '1'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now + 60_000,
    });
    await store.putBlob({
      address: 'bob',
      msgId: '2'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now - 1000, // already expired
    });
    await store.putBlob({
      address: 'bob',
      msgId: '3'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now + 60_000,
    });

    const all = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now,
      limit: 100,
    });
    // Expired one filtered out.
    expect(all.length).toBe(2);
    expect(all.map((r) => r.msgId).sort()).toEqual(['1'.repeat(64), '3'.repeat(64)]);

    // Cursor advances strictly.
    const half = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: all[0]!.receivedAt,
      now,
      limit: 100,
    });
    expect(half.length).toBe(1);
    expect(half[0]!.msgId).toBe(all[1]!.msgId);
  });

  test('purgeExpired removes only expired rows', async () => {
    const now = Date.now();
    await store.putBlob({
      address: 'bob',
      msgId: '1'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now - 1,
    });
    await store.putBlob({
      address: 'bob',
      msgId: '2'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now + 60_000,
    });
    const removed = await store.purgeExpired(now);
    expect(removed).toBe(1);
    const remaining = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now,
      limit: 10,
    });
    expect(remaining.length).toBe(1);
    expect(remaining[0]!.msgId).toBe('2'.repeat(64));
  });

  test('persists across reopen', async () => {
    const key = randBytes(32);
    await store.saveAddressOwner('bob', key);
    const ct = randBytes(64);
    await store.putBlob({
      address: 'bob',
      msgId: '5'.repeat(64),
      ciphertext: ct,
      expiresAt: Date.now() + 60_000,
    });
    store.close();
    store = new SqliteInboxStore(dbPath);
    const got = await store.getAddressOwner('bob');
    expect(got).toEqual(key);
    const blobs = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now: Date.now(),
      limit: 10,
    });
    expect(blobs.length).toBe(1);
    expect(blobs[0]!.ciphertext).toEqual(ct);
  });

  test('deleteAddress drops owner + blobs', async () => {
    const key = randBytes(32);
    await store.saveAddressOwner('bob', key);
    await store.putBlob({
      address: 'bob',
      msgId: '7'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: Date.now() + 60_000,
    });
    await store.deleteAddress('bob');
    expect(await store.getAddressOwner('bob')).toBeNull();
    const blobs = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now: Date.now(),
      limit: 10,
    });
    expect(blobs.length).toBe(0);
  });

  test('senderFp round-trips through put + fetch (V4.8)', async () => {
    const ct = randBytes(40);
    const fp = '0123456789abcdef';
    await store.putBlob({
      address: 'bob',
      msgId: 'a'.repeat(64),
      ciphertext: ct,
      expiresAt: Date.now() + 60_000,
      senderFp: fp,
    });
    const blobs = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now: Date.now(),
      limit: 10,
    });
    expect(blobs.length).toBe(1);
    expect(blobs[0]!.senderFp).toBe(fp);
  });

  test('senderFp omitted on put → fetched row has senderFp undefined (V4.8 backward-compat)', async () => {
    const ct = randBytes(40);
    await store.putBlob({
      address: 'bob',
      msgId: 'b'.repeat(64),
      ciphertext: ct,
      expiresAt: Date.now() + 60_000,
    });
    const blobs = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now: Date.now(),
      limit: 10,
    });
    expect(blobs.length).toBe(1);
    expect(blobs[0]!.senderFp).toBeUndefined();
  });

  test('ALTER TABLE migration adds sender_fp to a pre-4.8 schema (V4.8)', async () => {
    // Reproduce a pre-4.8 schema in a fresh DB, then reopen via
    // SqliteInboxStore which should run the idempotent ALTER without
    // dropping the existing rows.
    store.close();
    try {
      unlinkSync(dbPath);
    } catch {}
    try {
      unlinkSync(dbPath + '-wal');
    } catch {}
    try {
      unlinkSync(dbPath + '-shm');
    } catch {}

    const { Database } = await import('bun:sqlite');
    const legacy = new Database(dbPath, { create: true });
    legacy.exec(`
      CREATE TABLE inbox_blobs (
        address     TEXT NOT NULL,
        msg_id      TEXT NOT NULL,
        ciphertext  TEXT NOT NULL,
        received_at INTEGER NOT NULL,
        expires_at  INTEGER NOT NULL,
        PRIMARY KEY (address, msg_id)
      );
    `);
    legacy.prepare(
      'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)',
    ).run('bob', 'c'.repeat(64), 'AAAA', Date.now(), Date.now() + 60_000);
    legacy.close();

    store = new SqliteInboxStore(dbPath);
    const blobs = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now: Date.now(),
      limit: 10,
    });
    expect(blobs.length).toBe(1);
    expect(blobs[0]!.senderFp).toBeUndefined();

    // New writes after migration carry senderFp.
    await store.putBlob({
      address: 'bob',
      msgId: 'd'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: Date.now() + 60_000,
      senderFp: 'feedfacedeadbeef',
    });
    const after = await store.fetchBlobs({
      address: 'bob',
      sinceCursor: 0,
      now: Date.now(),
      limit: 10,
    });
    const newer = after.find((b) => b.msgId === 'd'.repeat(64));
    expect(newer?.senderFp).toBe('feedfacedeadbeef');
  });

  test('countBlobs ignores expired entries', async () => {
    const now = Date.now();
    await store.putBlob({
      address: 'bob',
      msgId: '1'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now - 1,
    });
    await store.putBlob({
      address: 'bob',
      msgId: '2'.repeat(64),
      ciphertext: randBytes(8),
      expiresAt: now + 60_000,
    });
    expect(await store.countBlobs('bob', now)).toBe(1);
  });
});
