/**
 * Layer 3: Storage — Encrypted multi-document IndexedDB store.
 *
 * Extends the single-document OfflineStore pattern into a multi-document,
 * per-document encrypted store. Each document is AES-256-GCM encrypted
 * at rest using keys derived from the user's passkey (DocCrypto).
 *
 * IndexedDB database: "rspace-docs"
 *   Object store "docs":   { docId, data (EncryptedBlob packed), updatedAt }
 *   Object store "meta":   { docId, module, collection, version, createdAt, updatedAt }
 *   Object store "sync":   { key: "{docId}:{peerId}", state: Uint8Array }
 *   Index on "meta" by [module] for listByModule queries
 */

import type { DocumentId } from './document';
import { DocCrypto, type EncryptedBlob } from './crypto';

// ============================================================================
// TYPES
// ============================================================================

interface StoredDoc {
  docId: string;
  /** Packed encrypted blob (nonce + ciphertext) — or raw bytes if encryption disabled */
  data: Uint8Array;
  updatedAt: number;
  encrypted: boolean;
}

interface StoredMeta {
  docId: string;
  module: string;
  collection: string;
  version: number;
  createdAt: number;
  updatedAt: number;
}

interface StoredSyncState {
  key: string; // "{docId}\0{peerId}"
  state: Uint8Array;
}

// ============================================================================
// EncryptedDocStore
// ============================================================================

export class EncryptedDocStore {
  #db: IDBDatabase | null = null;
  #dbName = 'rspace-docs';
  #version = 1;
  #crypto: DocCrypto | null = null;
  #spaceId: string;

  // Debounce infrastructure (same pattern as OfflineStore)
  #saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
  #pendingSaves = new Map<string, { docId: DocumentId; data: Uint8Array }>();
  #saveDebounceMs = 2000;

  constructor(spaceId: string, docCrypto?: DocCrypto) {
    this.#spaceId = spaceId;
    this.#crypto = docCrypto ?? null;
  }

  /**
   * Open the IndexedDB database. Must be called before any other method.
   */
  async open(): Promise<void> {
    if (this.#db) return;

    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.#dbName, this.#version);

      request.onupgradeneeded = () => {
        const db = request.result;

        if (!db.objectStoreNames.contains('docs')) {
          db.createObjectStore('docs', { keyPath: 'docId' });
        }

        if (!db.objectStoreNames.contains('meta')) {
          const metaStore = db.createObjectStore('meta', { keyPath: 'docId' });
          metaStore.createIndex('by_module', 'module', { unique: false });
          metaStore.createIndex('by_module_collection', ['module', 'collection'], { unique: false });
        }

        if (!db.objectStoreNames.contains('sync')) {
          db.createObjectStore('sync', { keyPath: 'key' });
        }
      };

      request.onsuccess = () => {
        this.#db = request.result;
        resolve();
      };

      request.onerror = () => {
        console.error('[EncryptedDocStore] Failed to open IndexedDB:', request.error);
        reject(request.error);
      };
    });
  }

  /**
   * Save a document (debounced). Encrypts if DocCrypto is configured.
   */
  save(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): void {
    this.#pendingSaves.set(docId, { docId, data: plaintext });

    const existing = this.#saveTimers.get(docId);
    if (existing) clearTimeout(existing);

    this.#saveTimers.set(
      docId,
      setTimeout(() => {
        this.#saveTimers.delete(docId);
        this.#pendingSaves.delete(docId);
        this.#writeDoc(docId, plaintext, meta).catch((e) => {
          console.error('[EncryptedDocStore] Failed to save doc:', e);
        });
      }, this.#saveDebounceMs)
    );
  }

  /**
   * Save a document immediately (bypasses debounce). Use before page unload.
   */
  async saveImmediate(docId: DocumentId, plaintext: Uint8Array, meta?: { module: string; collection: string; version: number }): Promise<void> {
    const existing = this.#saveTimers.get(docId);
    if (existing) {
      clearTimeout(existing);
      this.#saveTimers.delete(docId);
    }
    this.#pendingSaves.delete(docId);

    await this.#writeDoc(docId, plaintext, meta);
  }

  /**
   * Load a document. Decrypts if encrypted.
   */
  async load(docId: DocumentId): Promise<Uint8Array | null> {
    if (!this.#db) return null;

    const stored = await this.#getDoc(docId);
    if (!stored) return null;

    if (stored.encrypted && this.#crypto) {
      const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId);
      const blob = DocCrypto.unpack(stored.data);
      return this.#crypto.decrypt(docKey, blob);
    }

    return stored.data;
  }

  /**
   * Load multiple documents in parallel. Returns a Map of docId → decrypted bytes.
   */
  async loadMany(docIds: DocumentId[]): Promise<Map<DocumentId, Uint8Array>> {
    const results = new Map<DocumentId, Uint8Array>();
    if (!this.#db || docIds.length === 0) return results;

    const entries = await Promise.all(docIds.map(id => this.load(id).then(data => [id, data] as const)));
    for (const [id, data] of entries) {
      if (data) results.set(id, data);
    }
    return results;
  }

  /**
   * Load raw stored bytes for a document (without decrypting).
   * Used by the backup manager to upload already-encrypted blobs.
   */
  async loadRaw(docId: DocumentId): Promise<Uint8Array | null> {
    if (!this.#db) return null;

    const stored = await this.#getDoc(docId);
    if (!stored) return null;

    return stored.data;
  }

  /**
   * Delete a document and its metadata.
   */
  async delete(docId: DocumentId): Promise<void> {
    if (!this.#db) return;

    const tx = this.#db.transaction(['docs', 'meta'], 'readwrite');
    tx.objectStore('docs').delete(docId);
    tx.objectStore('meta').delete(docId);

    await this.#txComplete(tx);
  }

  /**
   * List all document IDs for a space and module.
   */
  async listByModule(module: string, collection?: string): Promise<DocumentId[]> {
    if (!this.#db) return [];

    return new Promise((resolve, reject) => {
      const tx = this.#db!.transaction('meta', 'readonly');
      const store = tx.objectStore('meta');

      let request: IDBRequest;
      if (collection) {
        const index = store.index('by_module_collection');
        request = index.getAllKeys(IDBKeyRange.only([module, collection]));
      } else {
        const index = store.index('by_module');
        request = index.getAllKeys(IDBKeyRange.only(module));
      }

      request.onsuccess = () => {
        // Filter to only docs in this space
        const all = request.result as string[];
        const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`));
        resolve(filtered as DocumentId[]);
      };
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * List all stored document IDs.
   */
  async listAll(): Promise<DocumentId[]> {
    if (!this.#db) return [];

    return new Promise((resolve, reject) => {
      const tx = this.#db!.transaction('meta', 'readonly');
      const request = tx.objectStore('meta').getAllKeys();

      request.onsuccess = () => {
        const all = request.result as string[];
        const filtered = all.filter((id) => id.startsWith(`${this.#spaceId}:`));
        resolve(filtered as DocumentId[]);
      };
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * Save sync state for a (docId, peerId) pair.
   */
  async saveSyncState(docId: DocumentId, peerId: string, state: Uint8Array): Promise<void> {
    if (!this.#db) return;

    const entry: StoredSyncState = {
      key: `${docId}\0${peerId}`,
      state,
    };

    const tx = this.#db.transaction('sync', 'readwrite');
    tx.objectStore('sync').put(entry);
    await this.#txComplete(tx);
  }

  /**
   * Load sync state for a (docId, peerId) pair.
   */
  async loadSyncState(docId: DocumentId, peerId: string): Promise<Uint8Array | null> {
    if (!this.#db) return null;

    return new Promise((resolve, reject) => {
      const tx = this.#db!.transaction('sync', 'readonly');
      const request = tx.objectStore('sync').get(`${docId}\0${peerId}`);

      request.onsuccess = () => {
        const entry = request.result as StoredSyncState | undefined;
        resolve(entry?.state ?? null);
      };
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * Clear all sync state for a document.
   */
  async clearSyncState(docId: DocumentId): Promise<void> {
    if (!this.#db) return;

    // Have to iterate since there's no compound index
    const tx = this.#db.transaction('sync', 'readwrite');
    const store = tx.objectStore('sync');
    const request = store.openCursor();

    return new Promise((resolve, reject) => {
      request.onsuccess = () => {
        const cursor = request.result;
        if (!cursor) { resolve(); return; }

        const key = cursor.key as string;
        if (key.startsWith(`${docId}\0`)) {
          cursor.delete();
        }
        cursor.continue();
      };
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * Get metadata for a document.
   */
  async getMeta(docId: DocumentId): Promise<StoredMeta | null> {
    if (!this.#db) return null;

    return new Promise((resolve, reject) => {
      const tx = this.#db!.transaction('meta', 'readonly');
      const request = tx.objectStore('meta').get(docId);

      request.onsuccess = () => resolve(request.result ?? null);
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * Flush all pending debounced saves immediately.
   */
  async flush(): Promise<void> {
    const promises: Promise<void>[] = [];

    for (const [docId, { data }] of this.#pendingSaves) {
      const timer = this.#saveTimers.get(docId);
      if (timer) clearTimeout(timer);
      this.#saveTimers.delete(docId);
      promises.push(this.#writeDoc(docId as DocumentId, data));
    }
    this.#pendingSaves.clear();

    await Promise.all(promises);
  }

  // ---------- Private helpers ----------

  async #writeDoc(
    docId: DocumentId,
    plaintext: Uint8Array,
    meta?: { module: string; collection: string; version: number }
  ): Promise<void> {
    if (!this.#db) return;

    const now = Date.now();
    let data: Uint8Array;
    let encrypted = false;

    if (this.#crypto?.isInitialized) {
      const docKey = await this.#crypto.deriveDocKeyDirect(this.#spaceId, docId);
      const blob = await this.#crypto.encrypt(docKey, plaintext);
      data = DocCrypto.pack(blob);
      encrypted = true;
    } else {
      data = plaintext;
    }

    const tx = this.#db.transaction(['docs', 'meta'], 'readwrite');

    const storedDoc: StoredDoc = { docId, data, updatedAt: now, encrypted };
    tx.objectStore('docs').put(storedDoc);

    if (meta) {
      // Read existing meta from the SAME transaction to avoid auto-commit
      const existingMeta = await new Promise<StoredMeta | null>((resolve) => {
        const request = tx.objectStore('meta').get(docId);
        request.onsuccess = () => resolve(request.result ?? null);
        request.onerror = () => resolve(null);
      });
      const storedMeta: StoredMeta = {
        docId,
        module: meta.module,
        collection: meta.collection,
        version: meta.version,
        createdAt: existingMeta?.createdAt ?? now,
        updatedAt: now,
      };
      tx.objectStore('meta').put(storedMeta);
    }

    await this.#txComplete(tx);
  }

  #getDoc(docId: DocumentId): Promise<StoredDoc | null> {
    if (!this.#db) return Promise.resolve(null);

    return new Promise((resolve, reject) => {
      const tx = this.#db!.transaction('docs', 'readonly');
      const request = tx.objectStore('docs').get(docId);

      request.onsuccess = () => resolve(request.result ?? null);
      request.onerror = () => reject(request.error);
    });
  }

  #txComplete(tx: IDBTransaction): Promise<void> {
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}
