import { notify, remove } from "./notifications";
import i18n from "../i18n";
import { reportError } from "./errorDialog";

export interface Me {
  id: number;
  email: string;
  display_name: string | null;
  avatar_url: string | null;
  role: string;
  is_demo: boolean;
  has_google: boolean;
  has_password: boolean;
  can_read: boolean;
  can_write: boolean;
  pending_invites: number;
  preferences: Record<string, any>;
}

export interface DemoWhitelistEntry {
  id: number;
  email: string;
  note: string | null;
  added_by: string | null;
  created_at: string | null;
}

export interface Invite {
  id: number;
  email: string;
  status: "pending" | "approved" | "denied";
  note: string | null;
  requested_at: string | null;
  decided_at: string | null;
  decided_by: string | null;
}

export interface Tag {
  id: number;
  name: string;
  color: string | null;
  category: string;
  system: boolean;
  channel_count: number;
}

export interface Video {
  id: string;
  title: string | null;
  channel_id: string;
  channel_title: string | null;
  channel_thumbnail: string | null;
  channel_url: string;
  published_at: string | null;
  thumbnail_url: string | null;
  duration_seconds: number | null;
  view_count: number | null;
  is_short: boolean;
  live_status: string;
  status: string;
  position_seconds: number;
  saved: boolean; // is the video in the user's built-in Watch later playlist
  watch_url: string;
}

export interface VideoDetail {
  id: string;
  description: string | null;
  like_count: number | null;
  in_db: boolean;
  channel_id: string | null;
  channel_title: string | null;
  published_at: string | null;
  view_count: number | null;
  duration_seconds: number | null;
}

export interface FeedResponse {
  items: Video[];
  has_more: boolean;
  offset: number;
  limit: number;
}

export interface Playlist {
  id: number;
  name: string;
  kind: string; // "user" | "watch_later"
  source: string; // "local" | "youtube"
  yt_playlist_id: string | null;
  dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
  item_count: number;
  total_duration_seconds: number;
  cover_thumbnail: string | null;
  has_video?: boolean; // only set when listed with ?contains=<video_id>
}

export interface PlaylistDetail {
  id: number;
  name: string;
  kind: string;
  source: string;
  yt_playlist_id: string | null;
  dirty: boolean;
  items: Video[];
}

export interface PushPlan {
  action: "create" | "update";
  to_insert: number;
  to_delete: number;
  to_reorder: number;
  yt_extra: number; // items on YouTube that the push would remove (divergence)
  units_estimate: number;
  remaining_today: number;
  affordable: boolean;
}

export interface PushResult {
  created: number;
  inserted: number;
  deleted: number;
  reordered: number;
  failures: string[];
  yt_playlist_id: string | null;
}

export interface FeedFilters {
  tags: number[];
  tagMode: "or" | "and";
  q: string;
  sort: string;
  // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
  // feed re-queries with a fresh order. Ignored by every other sort.
  seed?: number;
  // "my" = only your subscriptions (default); "all" = the whole shared catalog.
  scope: "my" | "all";
  includeNormal: boolean;
  includeShorts: boolean;
  includeLive: boolean;
  show: string;
  channelId?: string;
  channelName?: string;
  maxAgeDays?: number;
  minDuration?: number;
  maxDuration?: number;
  dateFrom?: string;
  dateTo?: string;
}

export interface VersionInfo {
  app_version: string;
  git_sha: string;
  build_date: string | null;
  db_revision: string | null;
}

class HttpError extends Error {
  status: number;
  detail?: string; // server-provided reason (FastAPI's `detail`), when present
  constructor(status: number, detail?: string) {
    super(detail || `HTTP ${status}`);
    this.status = status;
    this.detail = detail;
  }
}

// A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
// bursts of concurrent failures from stacking up — there's only ever one live handle.
let connectivityNotifId: number | null = null;
function markConnectivityLost(): void {
  if (connectivityNotifId !== null) return;
  connectivityNotifId = notify({
    level: "error",
    title: i18n.t("errors.offline.title"),
    message: i18n.t("errors.offline.body"),
    transient: true,
  });
}
function markConnectivityRestored(): void {
  if (connectivityNotifId === null) return;
  remove(connectivityNotifId);
  connectivityNotifId = null;
}

// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
// request was processed. Safe to retry an idempotent call once on a fresh connection.
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));

// `idempotent`: may this request be replayed after a transient gateway/network failure?
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
interface ReqConfig {
  idempotent?: boolean;
  // Suppress the global error modal for 4xx/5xx so the caller can show the error inline
  // (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
  quiet?: boolean;
}

// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in (public pages
// legitimately 401 on /api/me), so it's safe to call for every 401.
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
  onUnauthorized = fn;
}

async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
  const method = opts.method ?? "GET";
  const canRetry = cfg.idempotent ?? method === "GET";
  let attempt = 0;

  for (;;) {
    let r: Response;
    try {
      r = await fetch(url, {
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        ...opts,
      });
    } catch (e) {
      // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
      if (canRetry && attempt === 0) {
        attempt++;
        await delay(250);
        continue;
      }
      markConnectivityLost();
      throw e;
    }

    // A gateway error usually means the upstream connection was reset before our request
    // was processed — retry idempotent calls once on a fresh connection before surfacing it.
    if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
      attempt++;
      await delay(250);
      continue;
    }

    // The server answered (anything that isn't a gateway error means it's reachable) —
    // clear a standing "connection lost" status if one is showing.
    if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();

    if (!r.ok) {
      // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
      let detail: string | undefined;
      try {
        const body = await r.json();
        if (body && typeof body.detail === "string") detail = body.detail;
      } catch {
        /* no JSON body */
      }
      // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
      // not a request the app refused — treat them like a network blip with the soft,
      // self-clearing toast rather than a blocking modal the user must acknowledge.
      // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
      // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
      if (RETRIABLE_GATEWAY.has(r.status)) {
        markConnectivityLost();
      } else if (r.status === 401) {
        // Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
        // app shell decide what to do (drop to the login page if we were signed in). Not a modal.
        onUnauthorized?.();
      } else if (cfg.quiet) {
        /* caller handles the error inline — no global modal */
      } else if (r.status >= 500) {
        reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
      } else if (r.status === 400 || r.status === 409 || r.status === 422) {
        reportError(detail);
      }
      throw new HttpError(r.status, detail);
    }
    return r.status === 204 ? null : r.json();
  }
}

// The filter half of the query (everything except paging/sort), shared by the feed and
// the facet-count endpoint so both see exactly the same filter context.
function filterParams(f: FeedFilters): URLSearchParams {
  const p = new URLSearchParams();
  f.tags.forEach((t) => p.append("tags", String(t)));
  p.set("tag_mode", f.tagMode);
  if (f.q) p.set("q", f.q);
  p.set("scope", f.scope);
  p.set("show_normal", String(f.includeNormal));
  p.set("include_shorts", String(f.includeShorts));
  p.set("include_live", String(f.includeLive));
  p.set("show", f.show);
  if (f.channelId) p.set("channel_id", f.channelId);
  if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
  if (f.dateFrom) p.set("published_after", f.dateFrom);
  if (f.dateTo) p.set("published_before", f.dateTo);
  if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
  if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
  return p;
}

function feedQuery(f: FeedFilters, offset: number, limit: number): string {
  const p = filterParams(f);
  p.set("sort", f.sort);
  if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
  p.set("offset", String(offset));
  p.set("limit", String(limit));
  return p.toString();
}

export interface SyncStatus {
  subscriptions: number;
  channels_total: number;
  channels_backfilling: number;
  videos_total: number;
  pending_enrich: number;
  quota_used_today: number;
  quota_remaining_today: number;
  paused: boolean;
  is_admin: boolean;
}

export interface MyStatus {
  channels_total: number;
  channels_details_synced: number;
  channels_recent_synced: number;
  channels_deep_done: number;
  channels_recent_pending: number;
  channels_deep_pending: number;
  channels_deep_requested: number;
  deep_pending_count: number;
  deep_eta_seconds: number;
  my_videos: number;
  total_videos: number;
  quota_used_today: number;
  quota_remaining_today: number;
  paused: boolean;
  sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
}

export interface MyUsage {
  today: number;
  last_7d: number;
  last_30d: number;
  all_time: number;
  by_action: Record<string, number>;
}

export interface AdminQuotaRow {
  user_id: number | null;
  email: string;
  total: number;
  by_action: Record<string, number>;
}

export interface AdminQuota {
  range_days: number;
  rows: AdminQuotaRow[];
  daily: { day: string; total: number }[];
}

export interface ManagedChannel {
  id: string;
  title: string | null;
  handle: string | null;
  thumbnail_url: string | null;
  subscriber_count: number | null;
  video_count: number | null;
  stored_videos: number;
  last_video_at: string | null;
  total_duration_seconds: number;
  count_normal: number;
  count_short: number;
  count_live: number;
  priority: number;
  hidden: boolean;
  deep_requested: boolean;
  deep_in_queue: boolean;
  tag_ids: number[];
  details_synced: boolean;
  recent_synced: boolean;
  backfill_done: boolean;
}

// A channel that shows up in the user's playlists but that they don't subscribe to —
// surfaced in the Channel manager's Discovery tab so they can subscribe in one click.
export interface DiscoveredChannel {
  id: string;
  title: string | null;
  handle: string | null;
  thumbnail_url: string | null;
  subscriber_count: number | null;
  video_count: number | null;
  playlist_video_count: number; // how many of the user's playlist videos are from here
  playlist_count: number; // across how many of their playlists
  details_synced: boolean;
}

export interface SchedulerJob {
  id: string;
  interval_minutes: number;
  next_run: string | null;
  running: boolean;
  status: "ok" | "error" | "skipped" | null;
  last_started: string | null;
  last_finished: string | null;
  last_result: string | null;
  last_error: string | null;
  // Live progress while running (null when idle or for jobs that don't report it).
  // total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
  progress: { current: number; total: number | null; phase: string | null } | null;
}

export interface SchedulerStatus {
  running_here: boolean;
  enabled: boolean;
  paused: boolean;
  jobs: SchedulerJob[];
  queue: {
    channels_recent_pending: number;
    channels_deep_pending: number;
    deep_eta_seconds: number;
    videos_pending_enrich: number;
    videos_pending_shorts: number;
    videos_live_refresh: number;
  };
  quota: {
    used_today: number;
    remaining_today: number;
    daily_budget: number;
    backfill_reserve: number;
  };
  maintenance: {
    revalidate_batch: number;
    revalidate_batch_default: number;
    min: number;
    max: number;
  };
}

export interface Account {
  id: number;
  email: string;
  display_name: string | null;
  avatar_url: string | null;
  active: boolean;
}

// A durable, server-backed notification (the inbox center). Distinct from the client-side
// transient toast/bell, which lives only in localStorage.
export interface AppNotification {
  id: number;
  type: string;
  title: string;
  body: string | null;
  data: Record<string, any> | null;
  read: boolean;
  dismissed: boolean;
  created_at: string | null;
}

// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
  key: string;
  type: "int" | "str" | "bool";
  group: string;
  secret: boolean;
  min: number | null;
  max: number | null;
  is_set: boolean; // a DB override row exists (else using the env/config default)
  value?: string | number | boolean; // non-secret: effective value
  default?: string | number | boolean; // non-secret: env/config default
  default_is_set?: boolean; // secret: whether an env default exists
}
export interface SystemConfigData {
  groups: Record<string, ConfigItem[]>;
  secrets_manageable: boolean;
}

// Admin Users & roles page.
export interface AdminUserRow {
  id: number;
  email: string;
  display_name: string | null;
  role: string; // "user" | "admin"
  is_active: boolean;
  is_suspended: boolean;
  email_verified: boolean;
  is_demo: boolean;
  has_password: boolean;
  has_google: boolean;
  created_at: string | null;
}

export const api = {
  me: (): Promise<Me> => req("/api/me"),
  accounts: (): Promise<Account[]> => req("/api/me/accounts"),
  deleteAccount: (): Promise<{ deleted: boolean }> =>
    req("/api/me/account", { method: "DELETE" }),
  switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
    req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
  version: (): Promise<VersionInfo> => req("/api/version"),
  tags: (): Promise<Tag[]> => req("/api/tags"),
  status: (): Promise<SyncStatus> => req("/api/sync/status"),
  feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
    req(`/api/feed?${feedQuery(f, offset, limit)}`),
  feedCount: (f: FeedFilters): Promise<{ count: number }> =>
    req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
  facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
    req(`/api/facets?${filterParams(f).toString()}`),
  // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
  // recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
  setState: (id: string, status: string) =>
    req(
      `/api/videos/${id}/state`,
      { method: "POST", body: JSON.stringify({ status }) },
      { idempotent: true }
    ),
  clearState: (id: string) =>
    req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
  saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
    req(
      `/api/videos/${id}/progress`,
      {
        method: "POST",
        body: JSON.stringify({
          position_seconds: Math.floor(positionSeconds),
          duration_seconds: Math.floor(durationSeconds),
        }),
      },
      { idempotent: true }
    ),
  videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
  pauseSync: () => req("/api/sync/pause", { method: "POST" }),
  resumeSync: () => req("/api/sync/resume", { method: "POST" }),
  // Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
  savePrefs: (p: Record<string, any>) =>
    req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),

  // --- channel manager ---
  myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
  channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
  updateChannel: (
    id: string,
    patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
  ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
  resetChannelBackfill: (id: string) =>
    req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
  deepAll: (on = true) =>
    req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
  attachChannelTag: (id: string, tagId: number) =>
    req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
  detachChannelTag: (id: string, tagId: number) =>
    req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
  unsubscribeChannel: (id: string) =>
    req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
  discoveredChannels: (): Promise<DiscoveredChannel[]> =>
    req("/api/channels/discovery"),
  subscribeChannel: (id: string) =>
    req(`/api/channels/${id}/subscribe`, { method: "POST" }),
  syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),

  // --- playlists ---
  playlists: (containsVideoId?: string): Promise<Playlist[]> =>
    req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
  playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
  createPlaylist: (name: string): Promise<Playlist> =>
    req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
  renamePlaylist: (id: number, name: string): Promise<Playlist> =>
    req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
  deletePlaylist: (id: number, onYoutube = false) =>
    req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
  addToPlaylist: (id: number, videoId: string) =>
    req(`/api/playlists/${id}/items`, {
      method: "POST",
      body: JSON.stringify({ video_id: videoId }),
    }),
  removeFromPlaylist: (id: number, videoId: string) =>
    req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
  reorderPlaylist: (id: number, videoIds: string[]) =>
    req(`/api/playlists/${id}/order`, {
      method: "PUT",
      body: JSON.stringify({ video_ids: videoIds }),
    }),
  // Bookmark shortcut for the built-in Watch later playlist.
  watchLaterAdd: (videoId: string) =>
    req("/api/playlists/watch-later", {
      method: "POST",
      body: JSON.stringify({ video_id: videoId }),
    }),
  watchLaterRemove: (videoId: string) =>
    req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
  syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
    req("/api/playlists/sync-youtube", { method: "POST" }),
  revertPlaylist: (id: number): Promise<{ items: number }> =>
    req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
  playlistPushPlan: (id: number): Promise<PushPlan> =>
    req(`/api/playlists/${id}/push-plan`),
  pushPlaylist: (id: number): Promise<PushResult> =>
    req(`/api/playlists/${id}/push`, { method: "POST" }),

  // --- quota usage ---
  myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
  adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
  // --- admin: system configuration ---
  adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
  setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
    req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
  resetConfig: (key: string): Promise<SystemConfigData> =>
    req(`/api/admin/config/${key}`, { method: "DELETE" }),
  testEmail: (): Promise<{ sent: boolean; to: string }> =>
    req("/api/admin/config/test-email", { method: "POST" }),
  // --- admin: users & roles ---
  adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
  setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
    req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
  setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
    req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
  adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
    req(`/api/admin/users/${id}`, { method: "DELETE" }),
  schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
  updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
    req(`/api/admin/scheduler/jobs/${jobId}`, {
      method: "PATCH",
      body: JSON.stringify({ interval_minutes: intervalMinutes }),
    }),
  runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
    req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
  runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
    req("/api/admin/scheduler/run-all", { method: "POST" }),
  updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
    req("/api/admin/scheduler/maintenance", {
      method: "PATCH",
      body: JSON.stringify({ revalidate_batch: revalidateBatch }),
    }),

  // --- auth: email + password (errors handled inline via quiet) ---
  register: (email: string, password: string): Promise<{ status: string }> =>
    req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
  passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
    req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
  requestPasswordReset: (email: string): Promise<{ status: string }> =>
    req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
  confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
    req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
  // Set or change the signed-in account's password (errors shown inline via quiet).
  setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
    req(
      "/auth/set-password",
      { method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
      { quiet: true }
    ),
  // --- onboarding / admin ---
  requestAccess: (email: string): Promise<{ status: string }> =>
    req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
  // Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
  // sign-in. Returns whether a session was established.
  demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
    req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
  adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> =>
    req("/api/admin/demo/whitelist"),
  addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
    req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
  removeDemoWhitelist: (id: number) =>
    req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
  resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
    req("/api/admin/demo/reset", { method: "POST" }),
  adminInvites: (status?: string): Promise<Invite[]> =>
    req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
  approveInvite: (id: number) =>
    req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
  denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
  addInvite: (email: string) =>
    req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),

  // --- notification inbox (durable, server-backed) ---
  notifications: (includeDismissed = false): Promise<{ items: AppNotification[]; total: number }> =>
    req(`/api/me/notifications?include_dismissed=${includeDismissed}`),
  notificationUnreadCount: (): Promise<{ count: number }> =>
    req("/api/me/notifications/unread_count"),
  markNotificationRead: (id: number) =>
    req(`/api/me/notifications/${id}/read`, { method: "POST" }),
  markAllNotificationsRead: () =>
    req("/api/me/notifications/read_all", { method: "POST" }),
  dismissNotification: (id: number) =>
    req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
  clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),

  // --- user tags ---
  createTag: (t: { name: string; color?: string; category?: string }) =>
    req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
  updateTag: (id: number, patch: { name?: string; color?: string }) =>
    req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
  deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
};

export { HttpError };
