import {
  createEntry,
  isExpired,
  MemoryDerivedCache,
  toBlob,
  validateCacheKey,
  validatePutOptions,
  type DerivedCache,
  type DerivedCacheEntry,
  type DerivedCachePutOptions,
  type DerivedCacheValue,
} from './cache';

const INDEX_FILE = '.index.json';
const CACHE_DIRECTORY = 'av-tools-derived-cache-v1';
const CACHE_KINDS = new Set([
  'waveform-peaks',
  'waveform-image',
  'preview-proxy',
  'thumbnail',
  'contact-sheet',
  'project',
  'retained-output',
]);

export interface OpfsStorageManager {
  getDirectory(): Promise<FileSystemDirectoryHandle>;
}

export interface OpfsDerivedCacheOptions {
  readonly storage: OpfsStorageManager;
  readonly now?: () => Date;
  readonly directoryName?: string;
}

interface CacheIndex {
  readonly schemaVersion: 1;
  readonly entries: readonly DerivedCacheEntry[];
}

export class OpfsDerivedCache implements DerivedCache {
  readonly mode = 'opfs' as const;
  readonly #storage: OpfsStorageManager;
  readonly #now: () => Date;
  readonly #directoryName: string;
  #directory?: Promise<FileSystemDirectoryHandle>;
  #entries?: Map<string, DerivedCacheEntry>;

  constructor(options: OpfsDerivedCacheOptions) {
    this.#storage = options.storage;
    this.#now = options.now ?? (() => new Date());
    this.#directoryName = options.directoryName ?? CACHE_DIRECTORY;
    validateCacheKey(this.#directoryName);
  }

  async initialize(): Promise<void> {
    await this.#loadEntries();
    await this.pruneExpired();
  }

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

  async get(key: string): Promise<DerivedCacheValue | undefined> {
    validateCacheKey(key);
    const entries = await this.#loadEntries();
    const entry = entries.get(key);
    if (!entry) {
      return undefined;
    }
    if (isExpired(entry, this.#now())) {
      await this.delete(key);
      return undefined;
    }
    try {
      const fileHandle = await (
        await this.#getDirectory()
      ).getFileHandle(fileName(key));
      const data = await fileHandle.getFile();
      const updated = Object.freeze({
        ...entry,
        lastAccessedAt: this.#now().toISOString(),
      });
      entries.set(key, updated);
      await this.#writeIndex(entries);
      return { entry: updated, data };
    } catch (error) {
      if (isNotFound(error)) {
        entries.delete(key);
        await this.#writeIndex(entries);
        return undefined;
      }
      throw error;
    }
  }

  async put(
    options: DerivedCachePutOptions,
    data: Blob | Uint8Array
  ): Promise<DerivedCacheEntry> {
    validatePutOptions(options);
    const directory = await this.#getDirectory();
    const blob = toBlob(data, options.mediaType);
    const fileHandle = await directory.getFileHandle(fileName(options.key), {
      create: true,
    });
    const writable = await fileHandle.createWritable();
    try {
      await writable.write(blob);
      await writable.close();
    } catch (error) {
      await writable.abort(error).catch(() => undefined);
      throw error;
    }
    const entries = await this.#loadEntries();
    const previous = entries.get(options.key);
    const timestamp = this.#now().toISOString();
    const entry = Object.freeze({
      ...createEntry(options, blob, timestamp),
      ...(previous ? { createdAt: previous.createdAt } : {}),
    });
    entries.set(options.key, entry);
    await this.#writeIndex(entries);
    return entry;
  }

  async delete(key: string): Promise<boolean> {
    validateCacheKey(key);
    const entries = await this.#loadEntries();
    const existed = entries.delete(key);
    try {
      await (await this.#getDirectory()).removeEntry(fileName(key));
    } catch (error) {
      if (!isNotFound(error)) {
        throw error;
      }
    }
    if (existed) {
      await this.#writeIndex(entries);
    }
    return existed;
  }

  async clear(): Promise<void> {
    const directory = await this.#getDirectory();
    for await (const name of directory.keys()) {
      await directory.removeEntry(name, { recursive: true });
    }
    this.#entries = new Map();
  }

  async clearProject(projectId: string): Promise<number> {
    const entries = await this.#loadEntries();
    const keys = [...entries.values()]
      .filter((entry) => entry.projectId === projectId)
      .map((entry) => entry.key);
    for (const key of keys) {
      await this.delete(key);
    }
    return keys.length;
  }

  async pruneExpired(now = this.#now()): Promise<number> {
    const entries = await this.#loadEntries();
    const keys = [...entries.values()]
      .filter((entry) => isExpired(entry, now))
      .map((entry) => entry.key);
    for (const key of keys) {
      await this.delete(key);
    }
    return keys.length;
  }

  async sizeBytes(): Promise<number> {
    return (await this.list()).reduce((sum, entry) => sum + entry.sizeBytes, 0);
  }

  async #getDirectory(): Promise<FileSystemDirectoryHandle> {
    return (this.#directory ??= this.#storage
      .getDirectory()
      .then((root) =>
        root.getDirectoryHandle(this.#directoryName, { create: true })
      ));
  }

  async #loadEntries(): Promise<Map<string, DerivedCacheEntry>> {
    if (this.#entries) {
      return this.#entries;
    }
    const entries = new Map<string, DerivedCacheEntry>();
    try {
      const handle = await (
        await this.#getDirectory()
      ).getFileHandle(INDEX_FILE);
      const file = await handle.getFile();
      const parsed = JSON.parse(await file.text()) as unknown;
      if (!isCacheIndex(parsed)) {
        throw new TypeError('OPFS cache index is invalid');
      }
      for (const entry of parsed.entries) {
        entries.set(entry.key, Object.freeze({ ...entry }));
      }
    } catch (error) {
      if (!isNotFound(error)) {
        throw error;
      }
    }
    this.#entries = entries;
    return entries;
  }

  async #writeIndex(entries: Map<string, DerivedCacheEntry>): Promise<void> {
    const handle = await (
      await this.#getDirectory()
    ).getFileHandle(INDEX_FILE, {
      create: true,
    });
    const writable = await handle.createWritable();
    const index: CacheIndex = {
      schemaVersion: 1,
      entries: [...entries.values()].sort((left, right) =>
        left.key.localeCompare(right.key)
      ),
    };
    try {
      await writable.write(`${JSON.stringify(index)}\n`);
      await writable.close();
    } catch (error) {
      await writable.abort(error).catch(() => undefined);
      throw error;
    }
  }
}

export class ResilientDerivedCache implements DerivedCache {
  #active: DerivedCache;
  readonly #fallback: DerivedCache;
  #fallbackReason?: string;

  constructor(
    primary: DerivedCache,
    fallback: DerivedCache = new MemoryDerivedCache()
  ) {
    this.#active = primary;
    this.#fallback = fallback;
  }

  get mode(): 'opfs' | 'memory' {
    return this.#active.mode;
  }

  get fallbackReason(): string | undefined {
    return this.#fallbackReason;
  }

  list(): Promise<readonly DerivedCacheEntry[]> {
    return this.#run((cache) => cache.list());
  }

  get(key: string): Promise<DerivedCacheValue | undefined> {
    return this.#run((cache) => cache.get(key));
  }

  put(
    options: DerivedCachePutOptions,
    data: Blob | Uint8Array
  ): Promise<DerivedCacheEntry> {
    return this.#run((cache) => cache.put(options, data));
  }

  delete(key: string): Promise<boolean> {
    return this.#run((cache) => cache.delete(key));
  }

  clear(): Promise<void> {
    return this.#run((cache) => cache.clear());
  }

  clearProject(projectId: string): Promise<number> {
    return this.#run((cache) => cache.clearProject(projectId));
  }

  pruneExpired(now?: Date): Promise<number> {
    return this.#run((cache) => cache.pruneExpired(now));
  }

  sizeBytes(): Promise<number> {
    return this.#run((cache) => cache.sizeBytes());
  }

  async #run<T>(operation: (cache: DerivedCache) => Promise<T>): Promise<T> {
    try {
      return await operation(this.#active);
    } catch (error) {
      if (this.#active === this.#fallback) {
        throw error;
      }
      this.#fallbackReason =
        error instanceof Error ? error.message : 'OPFS cache operation failed';
      this.#active = this.#fallback;
      return operation(this.#active);
    }
  }
}

export async function openDerivedCache(
  options: {
    readonly storage?: OpfsStorageManager;
    readonly memoryMaxBytes?: number;
  } = {}
): Promise<ResilientDerivedCache> {
  const fallback = new MemoryDerivedCache({
    ...(options.memoryMaxBytes !== undefined
      ? { maxBytes: options.memoryMaxBytes }
      : {}),
  });
  const storage =
    options.storage ??
    (typeof navigator !== 'undefined' &&
    'storage' in navigator &&
    'getDirectory' in navigator.storage
      ? (navigator.storage as OpfsStorageManager)
      : undefined);
  if (!storage) {
    return new ResilientDerivedCache(fallback, fallback);
  }
  const primary = new OpfsDerivedCache({ storage });
  const resilient = new ResilientDerivedCache(primary, fallback);
  await resilient.pruneExpired();
  return resilient;
}

function fileName(key: string): string {
  validateCacheKey(key);
  return `${key}.bin`;
}

function isNotFound(error: unknown): boolean {
  return error instanceof DOMException
    ? error.name === 'NotFoundError'
    : error instanceof Error && error.name === 'NotFoundError';
}

function isCacheIndex(value: unknown): value is CacheIndex {
  if (
    typeof value !== 'object' ||
    value === null ||
    (value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
    !Array.isArray((value as { entries?: unknown }).entries)
  ) {
    return false;
  }
  return (value as { entries: unknown[] }).entries.every((entry) => {
    if (typeof entry !== 'object' || entry === null) {
      return false;
    }
    const candidate = entry as Partial<DerivedCacheEntry>;
    try {
      if (typeof candidate.key !== 'string') {
        return false;
      }
      validateCacheKey(candidate.key);
      if (candidate.projectId !== undefined) {
        validateCacheKey(candidate.projectId);
      }
    } catch {
      return false;
    }
    return (
      typeof candidate.kind === 'string' &&
      CACHE_KINDS.has(candidate.kind) &&
      typeof candidate.mediaType === 'string' &&
      /^[a-z0-9.+-]+\/[a-z0-9.+-]+$/iu.test(candidate.mediaType) &&
      Number.isSafeInteger(candidate.sizeBytes) &&
      (candidate.sizeBytes ?? -1) >= 0 &&
      typeof candidate.createdAt === 'string' &&
      Number.isFinite(Date.parse(candidate.createdAt)) &&
      typeof candidate.lastAccessedAt === 'string' &&
      Number.isFinite(Date.parse(candidate.lastAccessedAt)) &&
      (candidate.expiresAt === undefined ||
        (typeof candidate.expiresAt === 'string' &&
          Number.isFinite(Date.parse(candidate.expiresAt))))
    );
  });
}
