/**
 * V3.9 — receiver-side thumbnail buffer.
 *
 * Holds thumbnail-stream bytes keyed by streamId so widgets can render a
 * preview as soon as the corresponding *main* stream-init arrives. The
 * cache is intentionally tiny (LRU at 32 entries / 1 MiB total) — it
 * exists for "show me a preview while I decide whether to accept this
 * file"; long-term storage is the consuming app's job.
 *
 * The cache verifies sha256 *before* surfacing bytes to consumers (the
 * declared `thumbnailHash` from the main stream's `fileMetadata` is
 * passed in via `setExpectedHash`). Bytes that don't match are
 * dropped — a hostile peer cannot make the widget render arbitrary
 * pixels by claiming "this is the preview for that other transfer".
 */

import {
  isAllowedThumbnailMime,
  sha256Once,
  THUMBNAIL_MAX_BYTES,
  type ThumbnailMime,
} from '@shade/streams';

const MAX_ENTRIES = 32;
const MAX_BYTES = 1024 * 1024; // 1 MiB total

interface CacheEntry {
  bytes: Uint8Array;
  mime: ThumbnailMime;
  /** base64 sha256 of bytes (for matching against declared hash). */
  hash: string;
  insertedAt: number;
}

export interface ThumbnailHit {
  bytes: Uint8Array;
  mime: ThumbnailMime;
}

export class ShadeThumbnailCache {
  private entries = new Map<string, CacheEntry>();
  private bytesUsed = 0;
  /** streamId → expected hash (set when the main stream-init arrives). */
  private expected = new Map<string, string>();
  private listeners = new Set<(streamId: string) => void>();

  /**
   * Store thumbnail bytes for `streamId`. No-op when:
   *  - bytes exceed `THUMBNAIL_MAX_BYTES`
   *  - mime is not in the allowlist
   *  - the main stream has already declared an `expectedHash` and the
   *    bytes don't hash to it
   *
   * Returns `true` when the bytes were accepted into the cache.
   */
  put(streamId: string, bytes: Uint8Array, mime: string): boolean {
    if (bytes.byteLength > THUMBNAIL_MAX_BYTES) return false;
    if (!isAllowedThumbnailMime(mime)) return false;
    const hash = bytesToBase64Std(sha256Once(bytes));
    const expected = this.expected.get(streamId);
    if (expected !== undefined && expected !== hash) return false;
    this.evictIfNeeded(bytes.byteLength);
    this.entries.set(streamId, {
      bytes,
      mime,
      hash,
      insertedAt: Date.now(),
    });
    this.bytesUsed += bytes.byteLength;
    for (const fn of this.listeners) {
      try {
        fn(streamId);
      } catch {
        /* listener errors are not the cache's problem */
      }
    }
    return true;
  }

  /** Inverse of `put`. Returns true when an entry was evicted. */
  delete(streamId: string): boolean {
    const ex = this.entries.get(streamId);
    if (ex === undefined) return false;
    this.entries.delete(streamId);
    this.bytesUsed -= ex.bytes.byteLength;
    return true;
  }

  /**
   * Lookup helper. When `expectedHash` is provided and doesn't match,
   * returns null *and* drops the entry (it must have been spoofed).
   */
  get(streamId: string, expectedHash?: string): ThumbnailHit | null {
    const ex = this.entries.get(streamId);
    if (ex === undefined) return null;
    if (expectedHash !== undefined && expectedHash !== ex.hash) {
      this.delete(streamId);
      return null;
    }
    return { bytes: ex.bytes, mime: ex.mime };
  }

  /**
   * Record the hash the main stream declared for the thumbnail keyed by
   * `streamId`. If a cached entry already exists with a mismatching hash,
   * it's dropped — only matching bytes ever get rendered.
   */
  setExpectedHash(streamId: string, expectedHash: string): void {
    this.expected.set(streamId, expectedHash);
    const ex = this.entries.get(streamId);
    if (ex !== undefined && ex.hash !== expectedHash) {
      this.delete(streamId);
    }
  }

  /** Subscribe to "new thumbnail available" events. */
  onChange(fn: (streamId: string) => void): () => void {
    this.listeners.add(fn);
    return () => {
      this.listeners.delete(fn);
    };
  }

  /** Number of entries currently held. */
  get size(): number {
    return this.entries.size;
  }

  /** Total bytes currently held. */
  get totalBytes(): number {
    return this.bytesUsed;
  }

  /** Drop everything. */
  clear(): void {
    this.entries.clear();
    this.expected.clear();
    this.bytesUsed = 0;
  }

  private evictIfNeeded(incomingBytes: number): void {
    while (
      this.entries.size >= MAX_ENTRIES ||
      this.bytesUsed + incomingBytes > MAX_BYTES
    ) {
      const oldestKey = this.entries.keys().next().value;
      if (oldestKey === undefined) return;
      this.delete(oldestKey);
      if (this.entries.size === 0) return;
    }
  }
}

function bytesToBase64Std(bytes: Uint8Array): string {
  let bin = '';
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
  return btoa(bin);
}
