export type DerivedCacheKind =
  | 'waveform-peaks'
  | 'waveform-image'
  | 'preview-proxy'
  | 'thumbnail'
  | 'contact-sheet'
  | 'project'
  | 'retained-output';

export interface DerivedCacheEntry {
  readonly key: string;
  readonly kind: DerivedCacheKind;
  readonly projectId?: string;
  readonly mediaType: string;
  readonly sizeBytes: number;
  readonly createdAt: string;
  readonly lastAccessedAt: string;
  readonly expiresAt?: string;
}

export interface DerivedCachePutOptions {
  readonly key: string;
  readonly kind: DerivedCacheKind;
  readonly projectId?: string;
  readonly mediaType?: string;
  readonly expiresAt?: string;
}

export interface DerivedCacheValue {
  readonly entry: DerivedCacheEntry;
  readonly data: Blob;
}

export interface DerivedCache {
  readonly mode: 'opfs' | 'memory';
  list(): Promise<readonly DerivedCacheEntry[]>;
  get(key: string): Promise<DerivedCacheValue | undefined>;
  put(
    options: DerivedCachePutOptions,
    data: Blob | Uint8Array
  ): Promise<DerivedCacheEntry>;
  delete(key: string): Promise<boolean>;
  clear(): Promise<void>;
  clearProject(projectId: string): Promise<number>;
  pruneExpired(now?: Date): Promise<number>;
  sizeBytes(): Promise<number>;
}

export interface MemoryDerivedCacheOptions {
  readonly maxBytes?: number;
  readonly now?: () => Date;
}

interface MemoryEntry {
  entry: DerivedCacheEntry;
  data: Blob;
}

export class MemoryDerivedCache implements DerivedCache {
  readonly mode = 'memory' as const;
  readonly #entries = new Map<string, MemoryEntry>();
  readonly #maxBytes: number;
  readonly #now: () => Date;

  constructor(options: MemoryDerivedCacheOptions = {}) {
    this.#maxBytes = options.maxBytes ?? 128 * 1024 * 1024;
    this.#now = options.now ?? (() => new Date());
    if (!Number.isSafeInteger(this.#maxBytes) || this.#maxBytes <= 0) {
      throw new RangeError(
        'Memory cache maxBytes must be a positive safe integer'
      );
    }
  }

  async list(): Promise<readonly DerivedCacheEntry[]> {
    return Object.freeze(
      [...this.#entries.values()]
        .map(({ entry }) => Object.freeze({ ...entry }))
        .sort((left, right) =>
          right.lastAccessedAt.localeCompare(left.lastAccessedAt)
        )
    );
  }

  async get(key: string): Promise<DerivedCacheValue | undefined> {
    validateCacheKey(key);
    const stored = this.#entries.get(key);
    if (!stored) {
      return undefined;
    }
    if (isExpired(stored.entry, this.#now())) {
      this.#entries.delete(key);
      return undefined;
    }
    stored.entry = Object.freeze({
      ...stored.entry,
      lastAccessedAt: this.#now().toISOString(),
    });
    return {
      entry: stored.entry,
      data: stored.data,
    };
  }

  async put(
    options: DerivedCachePutOptions,
    data: Blob | Uint8Array
  ): Promise<DerivedCacheEntry> {
    validatePutOptions(options);
    const blob = toBlob(data, options.mediaType);
    if (blob.size > this.#maxBytes) {
      throw new RangeError('Cache item exceeds the in-memory cache limit');
    }
    this.#entries.delete(options.key);
    await this.#evictFor(blob.size);
    const timestamp = this.#now().toISOString();
    const entry = createEntry(options, blob, timestamp);
    this.#entries.set(options.key, { entry, data: blob });
    return entry;
  }

  async delete(key: string): Promise<boolean> {
    validateCacheKey(key);
    return this.#entries.delete(key);
  }

  async clear(): Promise<void> {
    this.#entries.clear();
  }

  async clearProject(projectId: string): Promise<number> {
    let removed = 0;
    for (const [key, { entry }] of this.#entries) {
      if (entry.projectId === projectId) {
        this.#entries.delete(key);
        removed += 1;
      }
    }
    return removed;
  }

  async pruneExpired(now = this.#now()): Promise<number> {
    let removed = 0;
    for (const [key, { entry }] of this.#entries) {
      if (isExpired(entry, now)) {
        this.#entries.delete(key);
        removed += 1;
      }
    }
    return removed;
  }

  async sizeBytes(): Promise<number> {
    return [...this.#entries.values()].reduce(
      (sum, stored) => sum + stored.entry.sizeBytes,
      0
    );
  }

  async #evictFor(requiredBytes: number): Promise<void> {
    let size = await this.sizeBytes();
    if (size + requiredBytes <= this.#maxBytes) {
      return;
    }
    const oldest = [...this.#entries.values()].sort((left, right) =>
      left.entry.lastAccessedAt.localeCompare(right.entry.lastAccessedAt)
    );
    for (const stored of oldest) {
      this.#entries.delete(stored.entry.key);
      size -= stored.entry.sizeBytes;
      if (size + requiredBytes <= this.#maxBytes) {
        break;
      }
    }
  }
}

export function validateCacheKey(key: string): void {
  if (!/^[a-z0-9][a-z0-9._-]{0,159}$/iu.test(key)) {
    throw new TypeError('Cache keys must be safe path-independent identifiers');
  }
}

export function validatePutOptions(options: DerivedCachePutOptions): void {
  validateCacheKey(options.key);
  if (options.projectId !== undefined) {
    validateCacheKey(options.projectId);
  }
  if (
    options.expiresAt !== undefined &&
    !Number.isFinite(Date.parse(options.expiresAt))
  ) {
    throw new TypeError('Cache expiry must be an ISO-compatible timestamp');
  }
  if (
    options.mediaType !== undefined &&
    !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/iu.test(options.mediaType)
  ) {
    throw new TypeError('Cache mediaType is invalid');
  }
}

export function createEntry(
  options: DerivedCachePutOptions,
  blob: Blob,
  timestamp: string
): DerivedCacheEntry {
  return Object.freeze({
    key: options.key,
    kind: options.kind,
    ...(options.projectId ? { projectId: options.projectId } : {}),
    mediaType: options.mediaType ?? (blob.type || 'application/octet-stream'),
    sizeBytes: blob.size,
    createdAt: timestamp,
    lastAccessedAt: timestamp,
    ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
  });
}

export function isExpired(entry: DerivedCacheEntry, now: Date): boolean {
  return (
    entry.expiresAt !== undefined &&
    Date.parse(entry.expiresAt) <= now.getTime()
  );
}

export function toBlob(data: Blob | Uint8Array, mediaType?: string): Blob {
  return data instanceof Blob
    ? data
    : new Blob([data as BlobPart], {
        type: mediaType ?? 'application/octet-stream',
      });
}
