// IndexedDB клиента — схема из docs/storage.md, «Клиент — IndexedDB».
//
// База `bare`, версия 1, все хранилища заводятся сразу: одна версия — одна
// схема, даже если часть хранилищ наполняется на следующих этапах.
// CryptoKey кладётся объектом: structured clone умеет их хранить,
// и приватный ключ остаётся non-extractable.

const NAME = "bare";
const VERSION = 1;

let opening = null;

export function open() {
  if (!opening) {
    opening = new Promise((resolve, reject) => {
      const request = indexedDB.open(NAME, VERSION);
      request.onupgradeneeded = () => create(request.result);
      request.onsuccess = () => {
        const db = request.result;
        // Соседняя вкладка стирает базу при выходе из аккаунта — отпускаем
        // соединение, иначе удаление зависнет заблокированным.
        db.onversionchange = () => {
          db.close();
          opening = null;
        };
        resolve(db);
      };
      request.onerror = () => reject(request.error);
      request.onblocked = () => reject(new Error("база занята другой вкладкой"));
    });
    opening.catch(() => {
      opening = null;
    });
  }
  return opening;
}

function create(db) {
  db.createObjectStore("meta"); // ключ — строка снаружи значения
  db.createObjectStore("chats", { keyPath: "id" });
  const messages = db.createObjectStore("messages", { keyPath: "id" });
  messages.createIndex("chat", ["chatId", "id"]);
  db.createObjectStore("roomKeys", { keyPath: ["roomId", "keyId"] });
  db.createObjectStore("peers", { keyPath: "nick" });
}

function done(tx) {
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error ?? new Error("транзакция отменена"));
  });
}

function value(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// get и put — одна запись одного хранилища. Ключ у chats, messages
// и peers лежит внутри значения (keyPath), поэтому put берёт запись целиком.
async function get(name, key) {
  const db = await open();
  return value(db.transaction(name, "readonly").objectStore(name).get(key));
}

async function put(name, record) {
  const db = await open();
  const tx = db.transaction(name, "readwrite");
  tx.objectStore(name).put(record);
  await done(tx);
}

// meta читает несколько ключей одной транзакцией.
export async function meta(keys) {
  const db = await open();
  const store = db.transaction("meta", "readonly").objectStore("meta");
  const out = {};
  await Promise.all(keys.map(async (key) => {
    out[key] = await value(store.get(key));
  }));
  return out;
}

// putMeta пишет пары «ключ — значение» одной транзакцией.
export async function putMeta(entries) {
  const db = await open();
  const tx = db.transaction("meta", "readwrite");
  const store = tx.objectStore("meta");
  for (const [key, item] of Object.entries(entries)) {
    store.put(item, key);
  }
  await done(tx);
}

// --- чаты ---------------------------------------------------------------

// PAGE — страница ленты: 50 сообщений (docs/storage.md).
export const PAGE = 50;

const DM = "dm:";
const ROOM = "room:";

// Ключ чата — "dm:<собеседник>" или "room:<roomId>" (docs/storage.md).
// Это не метка чата в AAD сообщения: там у личного чата оба ника.
export function dmChatId(peer) {
  return DM + peer;
}

export function roomChatId(roomId) {
  return ROOM + roomId;
}

// peerOf — с кем личный чат; у комнаты собеседника нет.
export function peerOf(chatId) {
  return chatId.startsWith(DM) ? chatId.slice(DM.length) : null;
}

// roomIdOf — какая комната; у личного чата комнаты нет.
export function roomIdOf(chatId) {
  return chatId.startsWith(ROOM) ? chatId.slice(ROOM.length) : null;
}

// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»:
// сигил ставит экран. Комнате имя, владелец и состав приходят из
// GET /api/rooms и события room; до этого вместо имени стоит идентификатор.
export function blankChat(id) {
  const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false };
  const peer = peerOf(id);
  if (peer !== null) {
    return { ...base, type: "dm", title: peer, peer };
  }
  const roomId = id.slice(ROOM.length);
  return { ...base, type: "room", title: roomId, roomId };
}

// chats — список чатов в порядке docs/ui.md: по lastId по убыванию.
// Скрытые («убрать из списка») не отдаются, пока их не попросят.
export async function chats({ hidden = false } = {}) {
  const db = await open();
  const store = db.transaction("chats", "readonly").objectStore("chats");
  const list = await value(store.getAll());
  return list.filter((c) => hidden || !c.hidden).sort(byLastId);
}

function byLastId(a, b) {
  if (a.lastId !== b.lastId) {
    if (!a.lastId) {
      return 1;
    }
    if (!b.lastId) {
      return -1;
    }
    return a.lastId < b.lastId ? 1 : -1;
  }
  return a.id < b.id ? -1 : 1;
}

export function chat(id) {
  return get("chats", id);
}

export function putChat(record) {
  return put("chats", record);
}

// mergeChat правит поля чата, не трогая ленту и счётчики: чтение и запись
// одной транзакцией, чтобы не разъехаться с параллельным markRead.
// Недостающая запись заводится. Отдаёт, изменилось ли что-нибудь.
export async function mergeChat(id, patch) {
  const db = await open();
  const tx = db.transaction("chats", "readwrite");
  const store = tx.objectStore("chats");
  const known = await value(store.get(id));
  const record = known ?? blankChat(id);
  // Новую запись пишем всегда: она могла совпасть с пустой по всем полям
  // патча и осталась бы ненаписанной.
  let changed = known === undefined;
  for (const [key, next] of Object.entries(patch)) {
    if (same(record[key], next)) {
      continue;
    }
    record[key] = next;
    changed = true;
  }
  if (changed) {
    store.put(record);
  }
  await done(tx);
  return changed;
}

function same(a, b) {
  if (Array.isArray(a) && Array.isArray(b)) {
    return a.length === b.length && a.every((item, i) => item === b[i]);
  }
  return a === b;
}

// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает
// к последнему сообщению. Обе величины локальные, на сервер не уходят
// (docs/storage.md).
export async function markRead(chatId) {
  const db = await open();
  const tx = db.transaction("chats", "readwrite");
  const store = tx.objectStore("chats");
  const record = await value(store.get(chatId));
  if (record) {
    record.unread = 0;
    record.lastReadId = record.lastId;
    store.put(record);
  }
  await done(tx);
  return record ?? null;
}

// hideChat прячет чат из списка или возвращает его туда. История
// не трогается: «убрать из списка» — не удаление (ADR-019).
export async function hideChat(chatId, hidden) {
  const db = await open();
  const tx = db.transaction("chats", "readwrite");
  const store = tx.objectStore("chats");
  const record = (await value(store.get(chatId))) ?? blankChat(chatId);
  record.hidden = hidden;
  store.put(record);
  await done(tx);
  return record;
}

// --- сообщения ----------------------------------------------------------

export function message(id) {
  return get("messages", id);
}

// saveMessages пишет сообщения и обновляет их чаты одной транзакцией.
// ACK серверу уходит только после успешной записи (docs/storage.md),
// поэтому лента и счётчик непрочитанных не должны расходиться.
//
// remove — идентификаторы, которые надо убрать: устаревший ULID
// неотправленного сообщения меняется на свежий, и старая запись уходит
// (ADR-036).
// me — собственный ник: свои сообщения непрочитанными не считаются.
// incoming — сообщения пришли из потока событий: известный id
// игнорируется целиком, перезаписи нет (ADR-034).
//
// Отдаёт ключи затронутых чатов.
export async function saveMessages({
  messages = [],
  remove = [],
  me = null,
  incoming = false,
} = {}) {
  if (messages.length === 0 && remove.length === 0) {
    return [];
  }
  const db = await open();
  const tx = db.transaction(["messages", "chats"], "readwrite");
  const store = tx.objectStore("messages");
  const chatStore = tx.objectStore("chats");

  for (const id of remove) {
    store.delete(id);
  }
  // Оба чтения — запросы этой же транзакции: она живёт, пока их ждут.
  const known = await Promise.all(messages.map((m) => value(store.get(m.id))));
  const ids = [...new Set(messages.map((m) => m.chatId))];
  const records = await Promise.all(ids.map((id) => value(chatStore.get(id))));

  const touched = new Map();
  ids.forEach((id, i) => touched.set(id, records[i] ?? blankChat(id)));

  // Повтор доставки не должен ни дублировать ленту, ни двигать счётчик:
  // сервер выдаёт очередь заново при каждом подключении и вправе
  // прислать конверт дважды в одной пачке (ADR-017). Дубли внутри пачки
  // видны только здесь: known собран до первого put.
  const seen = new Set();
  messages.forEach((m, i) => {
    const twice = seen.has(m.id);
    seen.add(m.id);
    // Входящее с уже известным id игнорируется целиком: id открыт
    // в конверте, и перезапись отдала бы собеседнику чужую запись
    // в истории (ADR-034). Исходящее по своему id пишется всегда —
    // это переход pending → sent/failed.
    if (twice || (incoming && known[i] !== undefined)) {
      return;
    }
    store.put(m);
    const record = touched.get(m.chatId);
    if (!record.lastId || record.lastId < m.id) {
      record.lastId = m.id;
    }
    if (known[i] !== undefined) {
      return;
    }
    if (m.from !== me && (!record.lastReadId || record.lastReadId < m.id)) {
      record.unread += 1;
    }
    // Новое сообщение возвращает скрытый чат в список.
    record.hidden = false;
  });

  for (const record of touched.values()) {
    chatStore.put(record);
  }
  await done(tx);
  return [...touched.keys()];
}

// messagesBefore — страница ленты назад от before, не включая его,
// по индексу "chat" (docs/storage.md). Отдаёт по возрастанию id.
export async function messagesBefore(chatId, before = null, limit = PAGE) {
  const db = await open();
  const store = db.transaction("messages", "readonly").objectStore("messages");
  // Ключ индекса — [chatId, id]. Массив больше любой строки, поэтому
  // [chatId, []] — верхняя граница всех сообщений чата, а [chatId] —
  // нижняя: короткий массив идёт раньше своих продолжений.
  const range = before
    ? IDBKeyRange.bound([chatId], [chatId, before], false, true)
    : IDBKeyRange.bound([chatId], [chatId, []]);
  const out = [];
  await cursor(store.index("chat").openCursor(range, "prev"), (record) => {
    out.push(record);
    return out.length < limit;
  });
  out.reverse();
  return out;
}

// pendingMessages — неотправленное по возрастанию id. Индекса по статусу
// в схеме нет (docs/storage.md), поэтому это проход курсором: он делается
// один раз при старте, дальше отправитель ведёт свой список.
export async function pendingMessages() {
  const db = await open();
  const store = db.transaction("messages", "readonly").objectStore("messages");
  const out = [];
  await cursor(store.openCursor(), (record) => {
    if (record.status === "pending") {
      out.push(record);
    }
    return true;
  });
  return out;
}

// undecryptable — всё, что не удалось расшифровать и что хранит raw для
// повторной попытки (docs/storage.md). Индекса по этому в схеме нет, значит
// проход курсором; попытка повторяется редко: при подтверждении ключа
// и при появлении недостающего ключа комнаты.
export async function undecryptable() {
  const db = await open();
  const store = db.transaction("messages", "readonly").objectStore("messages");
  const out = [];
  await cursor(store.openCursor(), (record) => {
    if (record.undecryptable && record.raw) {
      out.push(record);
    }
    return true;
  });
  return out;
}

// cursor обходит курсор, пока step не скажет «хватит».
function cursor(request, step) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => {
      const current = request.result;
      if (!current || !step(current.value)) {
        resolve();
        return;
      }
      current.continue();
    };
    request.onerror = () => reject(request.error);
  });
}

// --- ключи комнат -------------------------------------------------------

// Ключ хранилища roomKeys — [roomId, keyId]; массив больше любой строки,
// поэтому [roomId, []] — верхняя граница всех ключей комнаты, а [roomId] —
// нижняя (тот же приём, что и в индексе "chat").
function roomRange(roomId) {
  return IDBKeyRange.bound([roomId], [roomId, []]);
}

export function roomKey(roomId, keyId) {
  return get("roomKeys", [roomId, keyId]);
}

// roomKeysOf — все ключи комнаты по возрастанию receivedAt: последний
// и есть текущий (docs/storage.md, ADR-042).
export async function roomKeysOf(roomId) {
  const db = await open();
  const store = db.transaction("roomKeys", "readonly").objectStore("roomKeys");
  const list = await value(store.getAll(roomRange(roomId)));
  return list.sort((a, b) => a.receivedAt - b.receivedAt);
}

// saveRoomKey кладёт ключ комнаты, если такого ещё нет; отдаёт, случилось ли
// это. Клиент держит все ключи комнаты и расшифровывает любым известным
// (ADR-018), поэтому уже сохранённый ключ не перезаписывается.
//
// receivedAt строго больше времени всех прежних ключей комнаты: текущий ключ
// — последний полученный этим устройством, а два rekey подряд укладываются
// в одну миллисекунду (ADR-042, docs/storage.md).
export async function saveRoomKey({ roomId, keyId, key, from }) {
  const db = await open();
  const tx = db.transaction("roomKeys", "readwrite");
  const store = tx.objectStore("roomKeys");
  const list = await value(store.getAll(roomRange(roomId)));
  let receivedAt = Date.now();
  let known = false;
  for (const record of list) {
    known = known || record.keyId === keyId;
    if (record.receivedAt >= receivedAt) {
      receivedAt = record.receivedAt + 1;
    }
  }
  if (!known) {
    store.put({ roomId, keyId, key, from, receivedAt });
  }
  await done(tx);
  return !known;
}

// --- собеседники --------------------------------------------------------

// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом
// получении ключа; изменившийся ключ ложится в pending и ждёт подтверждения.
export function peer(nick) {
  return get("peers", nick);
}

export function putPeer(record) {
  return put("peers", record);
}

// --- архив --------------------------------------------------------------

// allMessages и allPeers отдают хранилище целиком: архив .bare уносит всю
// историю устройства. Какие поля в него попадают, решает export.js — база
// отдаёт записи как есть (docs/storage.md, «Экспорт .bare»).
export async function allMessages() {
  const db = await open();
  return value(db.transaction("messages", "readonly").objectStore("messages").getAll());
}

export async function allPeers() {
  const db = await open();
  return value(db.transaction("peers", "readonly").objectStore("peers").getAll());
}

// mergeArchive вливает разобранный архив одной транзакцией: половина
// импорта хуже, чем ничего.
//
// Слияние идемпотентное по id сообщений и id чатов (docs/crypto.md):
// известная запись не трогается, а запись peers добавляется только для
// ника, которого в TOFU ещё нет. Своя запись всегда права — у неё есть
// состояние отправки, которого в архиве нет (ADR-050).
//
// Счётчик непрочитанных и «убрано из списка» — местные: импорт приносит
// историю, а не показания счётчиков. Место чата в списке при этом меняется:
// lastId растёт под самое новое из добавленного, и вместе с ним уезжает
// граница «новых» — пока непрочитанного у чата нет, ей нечего отчёркивать,
// а оставшись позади, она отчеркнула бы всю привезённую переписку при
// первом же входящем (ADR-050).
//
// Отдаёт число добавленных сообщений и ключи затронутых чатов.
export async function mergeArchive({ chats: list = [], messages = [], peers = [] } = {}) {
  const db = await open();
  const tx = db.transaction(["chats", "messages", "peers"], "readwrite");
  const chatStore = tx.objectStore("chats");
  const messageStore = tx.objectStore("messages");
  const peerStore = tx.objectStore("peers");

  // Все чтения — одним заходом до первой записи: что уже лежит в базе,
  // надо знать целиком, а запросы этой же транзакции держат её живой.
  const [ids, nicks, known] = await Promise.all([
    value(messageStore.getAllKeys()),
    value(peerStore.getAllKeys()),
    value(chatStore.getAll()),
  ]);
  const seen = new Set(ids);
  const trusted = new Set(nicks);
  const records = new Map(known.map((record) => [record.id, record]));

  const touched = new Set();
  for (const chat of list) {
    if (records.has(chat.id)) {
      continue;
    }
    // Показания устройства в архив не пишутся (docs/storage.md) — у новой
    // записи они с чистого листа: место в списке считается по добавленному,
    // счётчик пуст, чат в списке виден.
    records.set(chat.id, {
      ...blankChat(chat.id),
      ...chat,
      lastId: null,
      lastReadId: null,
      unread: 0,
      hidden: false,
    });
    touched.add(chat.id);
  }

  let added = 0;
  for (const record of messages) {
    if (seen.has(record.id)) {
      continue;
    }
    seen.add(record.id);
    messageStore.put(record);
    added += 1;
    let chat = records.get(record.chatId);
    if (!chat) {
      chat = blankChat(record.chatId);
      records.set(record.chatId, chat);
    }
    if (!chat.lastId || chat.lastId < record.id) {
      chat.lastId = record.id;
    }
    touched.add(record.chatId);
  }

  for (const record of peers) {
    if (trusted.has(record.nick)) {
      continue;
    }
    trusted.add(record.nick);
    peerStore.put(record);
  }

  for (const id of touched) {
    const record = records.get(id);
    // Граница «новых» едет за лентой, пока непрочитанного нет: счётчик
    // и граница считаются от одной точки, иначе первое же входящее
    // отчеркнёт «новыми» всю привезённую переписку. У чата с непрочитанным
    // граница уже показывает на него и остаётся на месте (ADR-050).
    if (record.unread === 0) {
      record.lastReadId = record.lastId;
    }
    chatStore.put(record);
  }
  await done(tx);
  return { added, chats: [...touched] };
}

// persist просит браузер не вычищать базу: история на устройстве —
// единственная копия (docs/storage.md).
export async function persist() {
  if (!navigator.storage?.persist) {
    return false;
  }
  try {
    return await navigator.storage.persist();
  } catch {
    return false;
  }
}

// destroy стирает базу целиком: выход из аккаунта уносит историю
// (docs/storage.md, один аккаунт на браузерный профиль).
export async function destroy() {
  const db = await open().catch(() => null);
  if (db) {
    db.close();
  }
  opening = null;
  await new Promise((resolve, reject) => {
    const request = indexedDB.deleteDatabase(NAME);
    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
    // Блокировка — не ответ: соседние вкладки закрывают соединение
    // по versionchange, после чего удаление доходит до success.
    // Сказать «история удалена», не удалив её, нельзя.
    request.onblocked = () => {};
  });
}
