import { ConflictError, PreconditionFailedError } from '@shade/core';
import type { BlobStore, BlobSlotRecord, PutBlobResult } from './blob-store.js';

/**
 * In-memory BlobStore — used in tests and as the default fallback when
 * no SQLite/Postgres URL is configured. Rows are kept in a single Map.
 *
 * Etag is a strictly-monotonic per-process counter — guarantees a total
 * order across writes even when many land in the same millisecond. (We
 * could scope it per-slot, but a global counter keeps the implementation
 * trivial and the etag values still uniquely identify the write that
 * produced them, which is all CAS needs.)
 */
export class MemoryBlobStore implements BlobStore {
  private slots = new Map<string, BlobSlotRecord>();
  private nextEtag = 0;

  async get(slotId: string): Promise<BlobSlotRecord | null> {
    const r = this.slots.get(slotId);
    if (!r) return null;
    return {
      slotId: r.slotId,
      blob: new Uint8Array(r.blob),
      ownerPubkey: new Uint8Array(r.ownerPubkey),
      etag: r.etag,
      updatedAt: r.updatedAt,
    };
  }

  async put(args: {
    slotId: string;
    blob: Uint8Array;
    ownerPubkey: Uint8Array;
    expectedEtag: number | '*' | undefined;
    now: number;
  }): Promise<PutBlobResult> {
    const existing = this.slots.get(args.slotId);

    if (!existing) {
      // Empty slot. `ifMatch: '*'` per RFC 7232 still fails — there is
      // no entity to match. A numeric etag also fails (we have nothing
      // to compare against).
      if (args.expectedEtag !== undefined) {
        throw new PreconditionFailedError(
          `Slot ${args.slotId} does not exist; cannot match ifMatch=${String(args.expectedEtag)}`,
        );
      }
      this.nextEtag = Math.max(this.nextEtag + 1, args.now);
      const etag = this.nextEtag;
      this.slots.set(args.slotId, {
        slotId: args.slotId,
        blob: new Uint8Array(args.blob),
        ownerPubkey: new Uint8Array(args.ownerPubkey),
        etag,
        updatedAt: args.now,
      });
      return { created: true, etag, updatedAt: args.now };
    }

    // Slot exists. Pubkey check is the route layer's job — by the time
    // we're here the signature has already been verified against
    // `existing.ownerPubkey`.
    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.nextEtag = Math.max(this.nextEtag + 1, args.now);
    const etag = this.nextEtag;
    existing.blob = new Uint8Array(args.blob);
    existing.etag = etag;
    existing.updatedAt = args.now;
    return { created: false, etag, updatedAt: args.now };
  }

  async delete(slotId: string): Promise<boolean> {
    return this.slots.delete(slotId);
  }
}
