import { Database } from 'bun:sqlite';
import {
  ConflictError,
  PreconditionFailedError,
  toBase64,
  fromBase64,
} from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from '@shade/inbox-server';

/**
 * SQLite-backed BlobStore for the V4.9 encrypted-blob primitive.
 *
 * Single-table layout: each slot is a row keyed on the 64-hex slotId,
 * with the AEAD ciphertext base64-encoded inline. The relay never
 * decrypts the blob — it only enforces auth + CAS. ETag is a
 * monotonic per-process counter clamped against `Date.now()` so the
 * value is unique across writes and useful as a wall-clock hint.
 *
 * Docker usage: same volume as the inbox DB by convention; set
 * `SHADE_BLOB_DB_PATH` (falls back to `/data/shade-blob.db`).
 */
export class SqliteBlobStore implements BlobStore {
  private db: Database;
  private stmts!: {
    get: ReturnType<Database['prepare']>;
    insert: ReturnType<Database['prepare']>;
    update: ReturnType<Database['prepare']>;
    delete: ReturnType<Database['prepare']>;
    maxEtag: ReturnType<Database['prepare']>;
  };
  private seq = 0;

  constructor(dbPath?: string) {
    const path = dbPath ?? process.env.SHADE_BLOB_DB_PATH ?? '/data/shade-blob.db';
    this.db = new Database(path, { create: true });
    this.db.exec('PRAGMA journal_mode=WAL');
    this.ensureTables();
    this.prepareStatements();
    this.bootstrapSeq();
  }

  private ensureTables() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS shade_blob_slots (
        slot_id      TEXT PRIMARY KEY,
        owner_pubkey TEXT NOT NULL,
        blob         TEXT NOT NULL,
        etag         INTEGER NOT NULL,
        updated_at   INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_shade_blob_updated
        ON shade_blob_slots(updated_at);
    `);
  }

  private prepareStatements() {
    this.stmts = {
      get: this.db.prepare(
        'SELECT slot_id, owner_pubkey, blob, etag, updated_at FROM shade_blob_slots WHERE slot_id = ?',
      ),
      insert: this.db.prepare(
        'INSERT INTO shade_blob_slots (slot_id, owner_pubkey, blob, etag, updated_at) VALUES (?, ?, ?, ?, ?)',
      ),
      update: this.db.prepare(
        'UPDATE shade_blob_slots SET blob = ?, etag = ?, updated_at = ? WHERE slot_id = ?',
      ),
      delete: this.db.prepare('DELETE FROM shade_blob_slots WHERE slot_id = ?'),
      maxEtag: this.db.prepare('SELECT MAX(etag) AS max FROM shade_blob_slots'),
    };
  }

  private bootstrapSeq() {
    const row = this.stmts.maxEtag.get() as { max: number | null };
    this.seq = Math.max(row?.max ?? 0, Date.now());
  }

  close() {
    this.db.close();
  }

  async get(slotId: string): Promise<BlobSlotRecord | null> {
    const row = this.stmts.get.get(slotId) as
      | {
          slot_id: string;
          owner_pubkey: string;
          blob: string;
          etag: number;
          updated_at: number;
        }
      | undefined;
    if (!row) return null;
    return {
      slotId: row.slot_id,
      ownerPubkey: fromBase64(row.owner_pubkey),
      blob: fromBase64(row.blob),
      etag: row.etag,
      updatedAt: row.updated_at,
    };
  }

  async put(args: {
    slotId: string;
    blob: Uint8Array;
    ownerPubkey: Uint8Array;
    expectedEtag: number | '*' | undefined;
    now: number;
  }): Promise<PutBlobResult> {
    // Read-then-write inside a write transaction so concurrent
    // CAS attempts can't both observe the same etag.
    const tx = this.db.transaction((): PutBlobResult => {
      const existing = this.stmts.get.get(args.slotId) as
        | { etag: number }
        | undefined;

      if (!existing) {
        if (args.expectedEtag !== undefined) {
          throw new PreconditionFailedError(
            `Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
          );
        }
        this.seq = Math.max(this.seq + 1, args.now);
        const etag = this.seq;
        this.stmts.insert.run(
          args.slotId,
          toBase64(args.ownerPubkey),
          toBase64(args.blob),
          etag,
          args.now,
        );
        return { created: true, etag, updatedAt: args.now };
      }

      if (args.expectedEtag === undefined) {
        throw new ConflictError(
          `Slot ${args.slotId} already exists; supply ifMatch to update`,
        );
      }
      if (args.expectedEtag !== '*' && args.expectedEtag !== existing.etag) {
        throw new PreconditionFailedError(
          `Slot ${args.slotId} etag mismatch: expected=${args.expectedEtag} actual=${existing.etag}`,
        );
      }

      this.seq = Math.max(this.seq + 1, args.now);
      const etag = this.seq;
      this.stmts.update.run(toBase64(args.blob), etag, args.now, args.slotId);
      return { created: false, etag, updatedAt: args.now };
    });
    return tx();
  }

  async delete(slotId: string): Promise<boolean> {
    const result = this.stmts.delete.run(slotId);
    return result.changes > 0;
  }
}
