/**
 * P4-8 — persistent encrypted-search cache (raw IndexedDB, no new deps).
 *
 * The homeserver cannot search E2EE message content, so encrypted-room search
 * only ever covers what the client has paginated + decrypted this session. This
 * module persists a local plaintext index so coverage survives reloads.
 *
 * PRIVACY: this stores decrypted plaintext at rest. It is opt-in (default OFF),
 * clearable, and wiped on logout via `deleteSearchCacheDatabase()`.
 *
 * Resilience contract: every entry point swallows IndexedDB errors and behaves
 * as a cache-miss. Nothing here ever throws to the UI.
 */

const DB_NAME = 'lotus-search-cache';
const DB_VERSION = 1;
const MESSAGES_STORE = 'messages';
const COVERAGE_STORE = 'coverage';

// Cap cached rows per room so the on-disk index can't grow unbounded over a
// long-lived session. When a room exceeds this, the oldest rows (by ts) are
// evicted on write. ~5k small rows/room is generous search history; the coverage
// window is intentionally left claiming the evicted tail so we don't re-fetch +
// re-evict it forever (Clear cached index / logout still wipe everything).
const MAX_ROWS_PER_ROOM = 5000;

/** How many of a room's rows to evict to bring it back to the cap (0 if under). */
export const evictCount = (currentCount: number, max = MAX_ROWS_PER_ROOM): number =>
  Math.max(0, currentCount - max);
const ROOM_TS_INDEX = 'roomTs';

/** A single cached, decrypted message row. Keyed on `[roomId, eventId]`. */
export type SearchCacheRow = {
  roomId: string;
  eventId: string;
  ts: number;
  sender: string;
  body: string;
  formattedBody?: string;
  pollText?: string;
};

/** Per-room coverage stats for the "X / Y cached" UI counters. */
export type SearchCacheCoverage = {
  roomId: string;
  oldestTs: number;
  newestTs: number;
  count: number;
};

// A key range that matches every `[roomId, *]` entry in a composite-key store
// or `[roomId, ts]` index: an empty array sorts after all other key types, so
// `[roomId]` .. `[roomId, []]` brackets the whole room partition.
const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [roomId, []]);

let dbPromise: Promise<IDBDatabase | null> | null = null;

const openDb = (): Promise<IDBDatabase | null> => {
  if (dbPromise) return dbPromise;
  dbPromise = new Promise<IDBDatabase | null>((resolve) => {
    try {
      if (typeof indexedDB === 'undefined') {
        resolve(null);
        return;
      }
      const req = indexedDB.open(DB_NAME, DB_VERSION);
      req.onupgradeneeded = () => {
        const db = req.result;
        if (!db.objectStoreNames.contains(MESSAGES_STORE)) {
          const store = db.createObjectStore(MESSAGES_STORE, {
            keyPath: ['roomId', 'eventId'],
          });
          store.createIndex(ROOM_TS_INDEX, ['roomId', 'ts']);
        }
        if (!db.objectStoreNames.contains(COVERAGE_STORE)) {
          db.createObjectStore(COVERAGE_STORE, { keyPath: 'roomId' });
        }
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => {
        dbPromise = null; // allow a later retry
        resolve(null);
      };
      req.onblocked = () => {
        dbPromise = null;
        resolve(null);
      };
    } catch {
      dbPromise = null;
      resolve(null);
    }
  });
  return dbPromise;
};

/** Resolve once a write transaction commits (or reject/abort → caller swallows). */
const awaitTx = (tx: IDBTransaction): Promise<void> =>
  new Promise<void>((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });

/**
 * Within an open readwrite tx, delete the oldest rows of `roomId` (ascending
 * `[roomId, ts]` index) until it's back under the cap. Self-chains IDB requests
 * so the transaction stays alive — never awaits a non-IDB promise mid-tx (which
 * would let the transaction auto-commit early).
 */
const pruneRoom = (store: IDBObjectStore, roomId: string): void => {
  const index = store.index(ROOM_TS_INDEX);
  const countReq = index.count(roomRange(roomId));
  countReq.onsuccess = () => {
    let remaining = evictCount(countReq.result);
    if (remaining <= 0) return;
    const cursorReq = index.openCursor(roomRange(roomId), 'next'); // oldest first
    cursorReq.onsuccess = () => {
      const cursor = cursorReq.result;
      if (!cursor || remaining <= 0) return;
      cursor.delete();
      remaining -= 1;
      cursor.continue();
    };
  };
};

/** Upsert message rows. No-op on empty input or when IDB is unavailable. */
export const putRows = async (rows: SearchCacheRow[]): Promise<void> => {
  if (rows.length === 0) return;
  const db = await openDb();
  if (!db) return;
  try {
    const tx = db.transaction(MESSAGES_STORE, 'readwrite');
    const store = tx.objectStore(MESSAGES_STORE);
    rows.forEach((row) => store.put(row));
    // Bound growth: prune each room this batch touched back to the cap.
    new Set(rows.map((row) => row.roomId)).forEach((roomId) => pruneRoom(store, roomId));
    await awaitTx(tx);
  } catch {
    // Cache write failures must never surface to the UI.
  }
};

/** All cached rows for a room, ordered oldest→newest by the `[roomId, ts]` index. */
export const queryRoom = async (roomId: string): Promise<SearchCacheRow[]> => {
  const db = await openDb();
  if (!db) return [];
  try {
    return await new Promise<SearchCacheRow[]>((resolve, reject) => {
      const tx = db.transaction(MESSAGES_STORE, 'readonly');
      const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
      const req = index.getAll(roomRange(roomId));
      req.onsuccess = () => resolve((req.result as SearchCacheRow[]) ?? []);
      req.onerror = () => reject(req.error);
    });
  } catch {
    return [];
  }
};

/** Cursor variant: stream a room's rows through a matcher, collecting hits. */
export const searchRoom = async (
  roomId: string,
  matcher: (row: SearchCacheRow) => boolean,
): Promise<SearchCacheRow[]> => {
  const db = await openDb();
  if (!db) return [];
  try {
    return await new Promise<SearchCacheRow[]>((resolve, reject) => {
      const hits: SearchCacheRow[] = [];
      const tx = db.transaction(MESSAGES_STORE, 'readonly');
      const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
      const req = index.openCursor(roomRange(roomId));
      req.onsuccess = () => {
        const cursor = req.result;
        if (!cursor) {
          resolve(hits);
          return;
        }
        const row = cursor.value as SearchCacheRow;
        if (matcher(row)) hits.push(row);
        cursor.continue();
      };
      req.onerror = () => reject(req.error);
    });
  } catch {
    return [];
  }
};

/** Number of cached rows for a room. */
export const countRoom = async (roomId: string): Promise<number> => {
  const db = await openDb();
  if (!db) return 0;
  try {
    return await new Promise<number>((resolve, reject) => {
      const tx = db.transaction(MESSAGES_STORE, 'readonly');
      const index = tx.objectStore(MESSAGES_STORE).index(ROOM_TS_INDEX);
      const req = index.count(roomRange(roomId));
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  } catch {
    return 0;
  }
};

export const getCoverage = async (roomId: string): Promise<SearchCacheCoverage | null> => {
  const db = await openDb();
  if (!db) return null;
  try {
    return await new Promise<SearchCacheCoverage | null>((resolve, reject) => {
      const tx = db.transaction(COVERAGE_STORE, 'readonly');
      const req = tx.objectStore(COVERAGE_STORE).get(roomId);
      req.onsuccess = () => resolve((req.result as SearchCacheCoverage) ?? null);
      req.onerror = () => reject(req.error);
    });
  } catch {
    return null;
  }
};

export const putCoverage = async (coverage: SearchCacheCoverage): Promise<void> => {
  const db = await openDb();
  if (!db) return;
  try {
    const tx = db.transaction(COVERAGE_STORE, 'readwrite');
    tx.objectStore(COVERAGE_STORE).put(coverage);
    await awaitTx(tx);
  } catch {
    // ignore
  }
};

/**
 * Pure helper: fold a batch of rows into a coverage record, widening the
 * `oldestTs`/`newestTs` window against any previous coverage. `count` is
 * supplied by the caller (authoritative store count) so dedup across sessions
 * is handled correctly. Exported for testing without IDB.
 */
export const computeCoverage = (
  roomId: string,
  rows: ReadonlyArray<Pick<SearchCacheRow, 'ts'>>,
  count: number,
  previous?: SearchCacheCoverage | null,
): SearchCacheCoverage => {
  let oldestTs = previous?.oldestTs ?? Number.POSITIVE_INFINITY;
  let newestTs = previous?.newestTs ?? Number.NEGATIVE_INFINITY;
  rows.forEach((row) => {
    if (row.ts < oldestTs) oldestTs = row.ts;
    if (row.ts > newestTs) newestTs = row.ts;
  });
  if (!Number.isFinite(oldestTs)) oldestTs = 0;
  if (!Number.isFinite(newestTs)) newestTs = 0;
  return { roomId, oldestTs, newestTs, count };
};

/**
 * Convenience persist path used by the search hook: upsert a batch of rows for
 * a room, then recompute + store the room's coverage from the authoritative
 * store count. Fire-and-forget; never throws.
 */
export const saveRoomIndex = async (roomId: string, rows: SearchCacheRow[]): Promise<void> => {
  if (rows.length === 0) return;
  await putRows(rows);
  const [count, previous] = await Promise.all([countRoom(roomId), getCoverage(roomId)]);
  await putCoverage(computeCoverage(roomId, rows, count, previous));
};

/**
 * Pure helper: merge in-memory result items with cache-derived result items,
 * deduping by `event.event_id` (in-memory wins), sorted by `origin_server_ts`
 * descending. Generic over the minimal shape it reads so it is fully testable
 * without matrix-js-sdk types. Exported for testing.
 */
export const mergeSearchResults = <
  T extends { event: { event_id: string; origin_server_ts?: number } },
>(
  memory: ReadonlyArray<T>,
  cached: ReadonlyArray<T>,
): T[] => {
  const byId = new Map<string, T>();
  // Seed with cached, then let in-memory overwrite so in-memory always wins.
  cached.forEach((item) => byId.set(item.event.event_id, item));
  memory.forEach((item) => byId.set(item.event.event_id, item));
  return Array.from(byId.values()).sort(
    (a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0),
  );
};

export const clearRoom = async (roomId: string): Promise<void> => {
  const db = await openDb();
  if (!db) return;
  try {
    const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite');
    tx.objectStore(MESSAGES_STORE).delete(roomRange(roomId));
    tx.objectStore(COVERAGE_STORE).delete(roomId);
    await awaitTx(tx);
  } catch {
    // ignore
  }
};

export const clearAll = async (): Promise<void> => {
  const db = await openDb();
  if (!db) return;
  try {
    const tx = db.transaction([MESSAGES_STORE, COVERAGE_STORE], 'readwrite');
    tx.objectStore(MESSAGES_STORE).clear();
    tx.objectStore(COVERAGE_STORE).clear();
    await awaitTx(tx);
  } catch {
    // ignore
  }
};

/**
 * Drop the entire on-disk database. Wired into the logout path by the
 * coordinator (initMatrix) so no decrypted plaintext lingers after sign-out.
 * Closes any open handle first so the delete is not blocked. Never throws.
 */
export const deleteSearchCacheDatabase = async (): Promise<void> => {
  try {
    const existing = dbPromise ? await dbPromise : null;
    if (existing) existing.close();
  } catch {
    // ignore
  }
  dbPromise = null;
  return new Promise<void>((resolve) => {
    try {
      if (typeof indexedDB === 'undefined') {
        resolve();
        return;
      }
      const req = indexedDB.deleteDatabase(DB_NAME);
      let settled = false;
      const done = () => {
        if (!settled) {
          settled = true;
          resolve();
        }
      };
      req.onsuccess = done;
      req.onerror = done;
      req.onblocked = () => {
        // Another tab still holds the DB open, so the delete is QUEUED, not done —
        // resolving now would report a wipe that hasn't happened (plaintext still
        // on disk). Wait for the real onsuccess (fires once the other tab closes;
        // cross-tab logout reloads it shortly), but cap the wait so logout can't
        // hang forever if a tab never releases.
        setTimeout(done, 3000);
      };
    } catch {
      resolve();
    }
  });
};
