// Обёртки над fetch и поток событий. Форма запросов и ответов —
// docs/protocol.md: JSON в обе стороны, cookie сессии, ошибка —
// {error, message}.

// MAX_ACK — сколько идентификаторов принимает один POST /api/ack
// (docs/protocol.md, «Сообщения»).
export const MAX_ACK = 500;

// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
// retryAfter — сколько секунд просит ждать 429; у остальных ответов null.
export class ApiError extends Error {
  constructor(code, message, status, field, retryAfter = null) {
    super(message || code);
    this.name = "ApiError";
    this.code = code;
    this.status = status;
    this.field = field;
    this.retryAfter = retryAfter;
  }
}

// NetworkError — запрос не дошёл: сети нет, сервер не ответил.
// Это состояние клиента, а не код протокола.
export class NetworkError extends Error {
  constructor() {
    super("нет соединения");
    this.name = "NetworkError";
  }
}

// Тексты состояний и ошибок — docs/ui.md и ADR-028.
const TEXT = {
  invalid_credentials: "неверный ник или пароль",
  nick_taken: "ник занят",
  invalid_nick: "ник: 2–32 символа, a–z, 0–9, _",
  invite_required: "нужен инвайт-код",
  invalid_invite: "инвайт-код не подходит",
  rate_limited: "слишком часто, попробуйте позже",
  unknown_user: "такого ника нет",
  self: "нельзя писать себе",
  clock_skew: "проверьте часы на устройстве: расхождение больше 5 минут",
};

export function errorText(err) {
  if (err instanceof NetworkError) {
    return "нет соединения";
  }
  if (err instanceof ApiError && TEXT[err.code]) {
    return TEXT[err.code];
  }
  return "сервер не справился, попробуйте позже";
}

// expired вызывается, когда сервер сказал «нужен вход»: сессия истекла
// или её завершили с другого устройства. IndexedDB при этом не трогается
// (docs/ui.md, «Сеть и состояния»).
let expired = () => {};

export function onSessionExpired(handler) {
  expired = handler;
}

// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где
// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession).
// device: заголовок X-Device — он обязателен там, где важно, с какого
// устройства пришёл запрос (docs/protocol.md, «Общие правила»).
async function request(method, path, body, { quiet = false, device = null } = {}) {
  const init = { method, credentials: "same-origin", cache: "no-store" };
  const headers = {};
  if (body !== undefined) {
    headers["Content-Type"] = "application/json";
    init.body = JSON.stringify(body);
  }
  if (device) {
    headers["X-Device"] = device;
  }
  if (Object.keys(headers).length > 0) {
    init.headers = headers;
  }
  let response;
  try {
    response = await fetch(path, init);
  } catch {
    throw new NetworkError();
  }

  let data = null;
  if ((response.headers.get("Content-Type") ?? "").startsWith("application/json")) {
    data = await response.json().catch(() => null);
  }
  if (response.ok) {
    return data;
  }

  const code = typeof data?.error === "string" ? data.error : "internal";
  // Отличаем истёкшую сессию от неверного пароля: 401 invalid_credentials —
  // обычная ошибка формы входа, 401 unauthenticated — выход на экран входа.
  if (code === "unauthenticated" && !quiet) {
    expired();
  }
  throw new ApiError(code, data?.message, response.status, data?.field, retryAfter(response));
}

// retryAfter — сколько сервер просит ждать: целые секунды, не меньше одной
// (docs/protocol.md, «Общие правила»). Заголовка нет или он не число —
// null: паузу выбирает клиент.
function retryAfter(response) {
  const raw = response.headers.get("Retry-After");
  if (raw === null) {
    return null;
  }
  const seconds = Number(raw);
  return Number.isInteger(seconds) && seconds > 0 ? seconds : null;
}

export function config() {
  return request("GET", "/api/config");
}

export function kdf(nick) {
  return request("GET", `/api/kdf?nick=${encodeURIComponent(nick)}`);
}

export function register(body) {
  return request("POST", "/api/register", body);
}

export function login(nick, authKey) {
  return request("POST", "/api/login", { nick, authKey });
}

export function me() {
  return request("GET", "/api/me");
}

// dropSession — служебный выход перед повторным входом (ADR-031). Смена
// пароля и удаление аккаунта входят заново, а вход перезаписывает cookie:
// прежнюю сессию закрываем сами, пока её токен ещё при нас.
//
// 401 unauthenticated здесь означает «сессии и так нет» — это успех, а не
// конец сеанса: следующим шагом идёт login, он заведёт новую. Остальные
// отказы поднимаются наверх: при живой сессии входить заново нельзя,
// её строка осталась бы на сервере без владельца.
export async function dropSession() {
  try {
    await request("POST", "/api/logout", undefined, { quiet: true });
  } catch (err) {
    if (!(err instanceof ApiError) || err.code !== "unauthenticated") {
      throw err;
    }
  }
}

export function password(body) {
  return request("POST", "/api/password", body);
}

export function deleteMe(authKey) {
  return request("DELETE", "/api/me", { authKey });
}

export function user(nick) {
  return request("GET", `/api/users/${encodeURIComponent(nick)}`);
}

// --- устройства --------------------------------------------------------

// registerDevice — 201 при создании, 200 если устройство уже наше,
// 409 device_conflict, если идентификатор занят другим (ADR-017).
export function registerDevice(id) {
  return request("POST", "/api/devices", { id });
}

export function devices() {
  return request("GET", "/api/devices");
}

export function removeDevice(id) {
  return request("DELETE", `/api/devices/${encodeURIComponent(id)}`);
}

// setPush и clearPush — push-подписка устройства (ADR-023). Подписка
// принадлежит устройству, поэтому устройство идёт и в пути, и в заголовке:
// чужому подписку не поставить (docs/protocol.md, «Устройства»).
export function setPush(device, subscription) {
  return request("PUT", `/api/devices/${encodeURIComponent(device)}/push`, { subscription }, { device });
}

export function clearPush(device) {
  return request("DELETE", `/api/devices/${encodeURIComponent(device)}/push`, undefined, { device });
}

// --- контакты ----------------------------------------------------------

export function contacts() {
  return request("GET", "/api/contacts");
}

// addContact заводит строку списка чатов и отдаёт публичный ключ
// собеседника: 404 unknown_user, 400 self (ADR-019).
export function addContact(nick) {
  return request("POST", "/api/contacts", { nick });
}

export function removeContact(nick) {
  return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`);
}

// --- комнаты -----------------------------------------------------------

// rooms — комнаты, где мы участники, каждая с нашими завёрнутыми ключами:
// сервер отдаёт все, которые ещё держит, от старого к новому (ADR-059,
// docs/protocol.md, «Комнаты»).
export function rooms() {
  return request("GET", "/api/rooms");
}

// createRoom заводит комнату. Идентификатор генерирует клиент: ключ
// заворачивается до запроса и привязан к roomId (ADR-037). Занятый
// идентификатор — 409 room_conflict, берётся новый.
//
// X-Device передаётся, чтобы это же устройство не получило комнату ещё
// и событием: она приходит ответом (docs/protocol.md, «Комнаты»).
export function createRoom(device, body) {
  return request("POST", "/api/rooms", body, { device });
}

// changeMembers — смена состава и rekey одним запросом (ADR-018).
export function changeMembers(id, body) {
  return request("POST", `/api/rooms/${encodeURIComponent(id)}/members`, body);
}

// leaveRoom — выход из комнаты. X-Device передаётся по той же причине,
// что и при создании: комната уходит из списка здесь же, а другим
// устройствам вышедшего сервер шлёт room_left (ADR-041).
export function leaveRoom(device, id) {
  return request("POST", `/api/rooms/${encodeURIComponent(id)}/leave`, undefined, { device });
}

export function removeRoom(id) {
  return request("DELETE", `/api/rooms/${encodeURIComponent(id)}`);
}

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

// sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017).
// Ответ — 202 {id, ts}.
export function sendMessage(device, envelope) {
  return request("POST", "/api/messages", envelope, { device });
}

// ack подтверждает запись сообщений в IndexedDB: сервер убирает их
// из очереди устройства (docs/storage.md). Не больше MAX_ACK за раз.
export function ack(device, ids) {
  return request("POST", "/api/ack", { ids }, { device });
}

// --- события -----------------------------------------------------------

// stream открывает поток событий устройства (docs/protocol.md, «События»).
// Устройство передаётся в query: EventSource не умеет заголовки.
//
// Переподключение делает браузер сам. Ответ не 200 он считает
// окончательным отказом и больше не подключается — это видно
// по readyState CLOSED и передаётся в handlers.error вторым состоянием.
//
// Отдаёт функцию закрытия потока.
export function stream(device, handlers) {
  const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`);
  source.addEventListener("msg", (event) => handlers.msg(parse(event.data)));
  // room и room_left в очередь не кладутся: пропуск во время офлайна
  // чинится перечитыванием GET /api/rooms после ready (docs/protocol.md).
  source.addEventListener("room", (event) => handlers.room(parse(event.data)));
  source.addEventListener("room_left", (event) => handlers.roomLeft(parse(event.data)));
  source.addEventListener("ready", () => handlers.ready());
  source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED));
  return () => source.close();
}

function parse(data) {
  try {
    return JSON.parse(data);
  } catch {
    return null;
  }
}
