import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

export type NoteKind = "text" | "checklist";

export interface ChecklistItem {
  id: string;
  text: string;
  done: boolean;
}

export interface Note {
  id: number;
  userId: number;
  folderId: number | null;
  title: string;
  content: string;
  color: string;
  kind: NoteKind;
  items: ChecklistItem[] | null;
  isPinned: boolean;
  isArchived: boolean;
  createdAt: string;
  updatedAt: string;
  labelIds: number[];
}

export interface NoteLabel {
  id: number;
  userId: number;
  name: string;
  createdAt: string;
}

export interface NoteFolder {
  id: number;
  userId: number;
  name: string;
  noteCount: number;
  // Number of distinct recipients the owner has shared this folder with.
  // 0 when the folder is not shared. Always present on the wire (server
  // defaults missing rows to 0), so the rail can render a "shared" badge
  // unconditionally without tristate logic.
  sharedWithCount: number;
  createdAt: string;
  updatedAt: string;
}

// A folder shared WITH the current user by another user. Returned by
// GET /note-folders/shared-with-me. Distinct from `NoteFolder` because the
// caller is not the owner: there are no per-tab note counts (notes change
// live as the owner edits) and the owner's display info travels with the row
// so the rail can render "by <name>" without an extra fetch.
// Permission a recipient has on a shared folder. "view" is read-only
// (default); "edit" lets them create / update / delete notes inside the
// folder owner's namespace. Added in.
export type FolderSharePermission = "view" | "edit";

export interface SharedFolder {
  id: number;
  name: string;
  ownerId: number;
  sharedAt: string;
  // Permission this user has on the folder. The server stamps this from
  // the per-share row so the rail can render an "edit"/"view" badge
  // without an extra fetch.
  myPermission: FolderSharePermission;
  ownerUsername: string;
  ownerDisplayNameAr: string | null;
  ownerDisplayNameEn: string | null;
  // Live count of the owner's non-archived notes inside this folder. Lets
  // the rail render "Folder name (12)" without a per-row roundtrip.
  noteCount: number;
}

// One row in a folder's share list (people the owner has shared TO).
export interface FolderShareRecipient {
  userId: number;
  username: string;
  displayNameAr: string | null;
  displayNameEn: string | null;
  sharedAt: string;
  permission: FolderSharePermission;
}

export interface UserSummary {
  id: number;
  username: string;
  displayNameAr: string | null;
  displayNameEn: string | null;
  avatarUrl?: string | null;
  isActive?: boolean;
}

export type NoteRecipientStatus = "unread" | "read" | "replied" | "archived";

export interface SentNoteRecipient {
  id: number;
  noteId: number;
  recipientUserId: number;
  status: NoteRecipientStatus;
  sentAt: string;
  readAt: string | null;
  archivedAt: string | null;
  title?: string;
  content?: string;
  color?: string;
  kind?: NoteKind;
  items?: ChecklistItem[] | null;
  recipient: UserSummary | null;
}

export interface SentNote extends Note {
  recipients: SentNoteRecipient[];
  replyCount: number;
}

export interface ReceivedNote {
  id: number;
  title: string;
  content: string;
  color: string;
  kind: NoteKind;
  items: ChecklistItem[] | null;
  createdAt: string;
  updatedAt: string;
  sender: UserSummary | null;
  recipientRowId: number;
  status: NoteRecipientStatus;
  sentAt: string;
  readAt: string | null;
  archivedAt: string | null;
}

export interface NoteReply {
  id: number;
  noteId: number;
  senderUserId: number;
  recipientUserId: number;
  content: string;
  createdAt: string;
  sender: UserSummary | null;
}

export interface NoteThread {
  id: number;
  title: string;
  content: string;
  color: string;
  kind: NoteKind;
  items: ChecklistItem[] | null;
  createdAt: string;
  updatedAt: string;
  senderUserId: number;
  sender: UserSummary | null;
  isOwner: boolean;
  isAdmin: boolean;
  myStatus: NoteRecipientStatus | null;
  recipients: SentNoteRecipient[];
  replies: NoteReply[];
}

const BASE = `${import.meta.env.BASE_URL}api`.replace(/\/+/g, "/");

async function req<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    credentials: "include",
    headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
    ...init,
  });
  if (!res.ok) {
    let msg = `${res.status}`;
    try {
      const j = await res.json();
      msg = j.error ?? msg;
    } catch {}
    const err = new Error(msg) as Error & { status: number };
    err.status = res.status;
    throw err;
  }
  return res.json() as Promise<T>;
}

export const notesKey = (archived: boolean) => ["notes", { archived }] as const;
export const labelsKey = ["note-labels"] as const;
export const foldersKey = (archived: boolean = false) =>
  ["note-folders", { archived }] as const;
export const sentNotesKey = ["notes", "sent"] as const;
export const receivedNotesKey = (archived: boolean) =>
  ["notes", "received", { archived }] as const;
export const noteThreadKey = (id: number) => ["notes", "thread", id] as const;

export function useNotes(archived: boolean) {
  return useQuery({
    queryKey: notesKey(archived),
    queryFn: () => req<Note[]>(`/notes?archived=${archived}`),
  });
}

export function useSentNotes() {
  return useQuery({
    queryKey: sentNotesKey,
    queryFn: () => req<SentNote[]>(`/notes/sent`),
  });
}

export function useReceivedNotes(archived: boolean) {
  return useQuery({
    queryKey: receivedNotesKey(archived),
    queryFn: () =>
      req<ReceivedNote[]>(`/notes/received?archived=${archived}`),
  });
}

export function useNoteThread(id: number | null) {
  return useQuery({
    queryKey: id ? noteThreadKey(id) : ["notes", "thread", "none"],
    queryFn: () => req<NoteThread>(`/notes/${id}/thread`),
    enabled: id !== null,
  });
}

export function useNoteLabels() {
  return useQuery({ queryKey: labelsKey, queryFn: () => req<NoteLabel[]>("/note-labels") });
}

// shared-folder editors mutate notes that live in someone
// else's namespace. The server emits `note-folder-shared` to every
// other viewer, but the actor is excluded from that fanout, so we
// invalidate the shared-with-me list and folders rail locally too —
// otherwise the editor's own rail badge can stay stale after a
// create/delete/move they performed themselves.
function invalidateNotesAndFolders(qc: ReturnType<typeof useQueryClient>) {
  qc.invalidateQueries({ queryKey: ["notes"] });
  qc.invalidateQueries({ queryKey: ["note-folders"] });
}

export function useCreateNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (body: Partial<Note>) =>
      req<Note>("/notes", { method: "POST", body: JSON.stringify(body) }),
    onSuccess: () => invalidateNotesAndFolders(qc),
  });
}

export function useUpdateNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, ...body }: Partial<Note> & { id: number }) =>
      req<Note>(`/notes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
    onSuccess: () => invalidateNotesAndFolders(qc),
  });
}

export function useDeleteNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: number) => req<{ success: true }>(`/notes/${id}`, { method: "DELETE" }),
    onSuccess: () => invalidateNotesAndFolders(qc),
  });
}

// bulk-delete the caller's own (sent) notes by id. Reuses the
// existing per-note DELETE endpoint and fans out with bounded
// concurrency (worker pool of `BULK_DELETE_CONCURRENCY`) so we never
// open hundreds of sockets at once on large selections — each worker
// pulls the next id off a shared cursor until the queue drains. A
// single failed row never blocks the rest. Caches are invalidated once
// at the end (not per row) to avoid N refetches. Returns the partition
// of ids that succeeded vs failed so the UI can render a precise toast.
const BULK_DELETE_CONCURRENCY = 6;

export function useBulkDeleteSentNotes() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (ids: number[]) => {
      const ok: number[] = [];
      const failed: number[] = [];
      let cursor = 0;
      const worker = async () => {
        while (true) {
          const i = cursor++;
          if (i >= ids.length) return;
          const id = ids[i];
          try {
            await req<{ success: true }>(`/notes/${id}`, { method: "DELETE" });
            ok.push(id);
          } catch {
            failed.push(id);
          }
        }
      };
      const workerCount = Math.min(BULK_DELETE_CONCURRENCY, ids.length);
      await Promise.all(
        Array.from({ length: workerCount }, () => worker()),
      );
      return { ok, failed };
    },
    onSettled: () => invalidateNotesAndFolders(qc),
  });
}

export function useSendNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, recipientUserIds }: { id: number; recipientUserIds: number[] }) =>
      req<{ success: true; sent: number }>(`/notes/${id}/send`, {
        method: "POST",
        body: JSON.stringify({ recipientUserIds }),
      }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

export function useMarkNoteRead() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: number) =>
      req<{ success: true }>(`/notes/${id}/read`, { method: "POST" }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

// collaborative checklist item toggle. Anyone in the audience
// (owner + active recipients) can flip an item; we optimistically patch
// every cached `Note`/`SentNote`/`ReceivedNote`/`NoteThread` entry that
// references this noteId so all surfaces (cards, sent grid, inbox,
// thread dialog, popup) update instantly without waiting for the
// roundtrip. On error we roll the snapshots back; on success/settle we
// invalidate the umbrella `["notes"]` key so any non-snapshotted query
// (e.g. the open thread) refetches authoritative state.
export function useToggleChecklistItem() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({
      id,
      itemId,
      done,
    }: {
      id: number;
      itemId: string;
      done: boolean;
    }) =>
      req<{ success: true; items: ChecklistItem[] }>(
        `/notes/${id}/checklist/${encodeURIComponent(itemId)}/toggle`,
        { method: "POST", body: JSON.stringify({ done }) },
      ),
    onMutate: async ({ id, itemId, done }) => {
      await qc.cancelQueries({ queryKey: ["notes"] });
      const snapshots: Array<[readonly unknown[], unknown]> = [];

      const patchItems = (items: ChecklistItem[] | null | undefined) => {
        if (!Array.isArray(items)) return items ?? null;
        let changed = false;
        const next = items.map((it) => {
          if (it.id !== itemId || it.done === done) return it;
          changed = true;
          return { ...it, done };
        });
        return changed ? next : items;
      };

      // Single broad pass over every cached note-list query
      // (`["notes"]`, `["notes", "received"]`, sent grid, etc.) — react-
      // query does prefix matching on the queryKey, so this catches all
      // array-of-note caches in one place. Each entry is snapshotted
      // exactly once so rollback on error is deterministic.
      const arrayQueryKeys: ReadonlyArray<readonly unknown[]> = [
        ["notes"],
        sentNotesKey,
      ];
      const seen = new Set<string>();
      for (const qk of arrayQueryKeys) {
        for (const [key, data] of qc.getQueriesData<
          Array<Note | SentNote | ReceivedNote>
        >({ queryKey: qk })) {
          if (!Array.isArray(data)) continue;
          const sig = JSON.stringify(key);
          if (seen.has(sig)) continue;
          seen.add(sig);
          snapshots.push([key, data]);
          qc.setQueryData(
            key,
            data.map((n) =>
              n.id === id
                ? { ...n, items: patchItems(n.items) as ChecklistItem[] | null }
                : n,
            ),
          );
        }
      }
      // Open thread dialog (single object, not an array).
      const threadKey = noteThreadKey(id);
      const threadData = qc.getQueryData<NoteThread>(threadKey);
      if (threadData) {
        snapshots.push([threadKey, threadData]);
        qc.setQueryData<NoteThread>(threadKey, {
          ...threadData,
          items: patchItems(threadData.items) as ChecklistItem[] | null,
        });
      }
      return { snapshots };
    },
    onError: (_e, _v, ctx) => {
      if (!ctx) return;
      for (const [key, data] of ctx.snapshots) {
        qc.setQueryData(key, data);
      }
    },
    onSettled: (_d, _e, vars) => {
      qc.invalidateQueries({ queryKey: ["notes"] });
      qc.invalidateQueries({ queryKey: noteThreadKey(vars.id) });
    },
  });
}

export function useArchiveReceivedNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, archived }: { id: number; archived: boolean }) =>
      req<{ success: true }>(`/notes/${id}/archive`, {
        method: "POST",
        body: JSON.stringify({ archived }),
      }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

// bulk archive for the Notes Inbox. Mirrors
// `useBulkDeleteSentNotes` — fans `BULK_DELETE_CONCURRENCY` workers over
// POST /notes/:id/archive so the request count is bounded even on large
// selections, and reports per-id success/failure so the caller can show
// a partial-failure toast. The recipient does not own the underlying
// note, so we archive (their recipient row) instead of deleting.
export function useBulkArchiveReceivedNotes() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (ids: number[]) => {
      const ok: number[] = [];
      const failed: number[] = [];
      let cursor = 0;
      const worker = async () => {
        while (true) {
          const i = cursor++;
          if (i >= ids.length) return;
          const id = ids[i];
          try {
            await req<{ success: true }>(`/notes/${id}/archive`, {
              method: "POST",
              body: JSON.stringify({ archived: true }),
            });
            ok.push(id);
          } catch {
            failed.push(id);
          }
        }
      };
      const workerCount = Math.min(BULK_DELETE_CONCURRENCY, ids.length);
      await Promise.all(
        Array.from({ length: workerCount }, () => worker()),
      );
      return { ok, failed };
    },
    onSettled: () => invalidateNotesAndFolders(qc),
  });
}

// per-recipient delete. Removes only the caller's own
// recipient row — sender, note, and other recipients are untouched.
// Invalidates `["notes"]` so both the inbox and the archived view
// refresh immediately.
export function useDeleteReceivedNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: number) =>
      req<{ success: true }>(`/notes/received/${id}`, { method: "DELETE" }),
    onSettled: () => invalidateNotesAndFolders(qc),
  });
}

// bulk variant. One POST handles the whole batch on the
// server (single SQL DELETE) and reports per-id ok/notFound so the
// UI can render a precise toast even on partial misses. Cache is
// invalidated once at the end so all surfaces (inbox, archived,
// unread badges) refresh.
export function useBulkDeleteReceivedNotes() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (ids: number[]) =>
      req<{ ok: number[]; notFound: number[] }>(
        `/notes/received/bulk-delete`,
        { method: "POST", body: JSON.stringify({ ids }) },
      ),
    onSettled: () => invalidateNotesAndFolders(qc),
  });
}

export function useReplyToNote() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({
      id,
      content,
      recipientUserId,
    }: {
      id: number;
      content: string;
      recipientUserId?: number;
    }) =>
      req<NoteReply>(`/notes/${id}/reply`, {
        method: "POST",
        body: JSON.stringify(
          recipientUserId ? { content, recipientUserId } : { content },
        ),
      }),
    onSuccess: (_d, vars) => {
      qc.invalidateQueries({ queryKey: ["notes"] });
      qc.invalidateQueries({ queryKey: noteThreadKey(vars.id) });
    },
  });
}

export function useUserDirectory() {
  return useQuery({
    queryKey: ["users", "directory"],
    queryFn: () => req<UserSummary[]>("/users/directory"),
  });
}

export function useCreateLabel() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (name: string) =>
      req<NoteLabel>("/note-labels", { method: "POST", body: JSON.stringify({ name }) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: labelsKey }),
  });
}

export function useUpdateLabel() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, name }: { id: number; name: string }) =>
      req<NoteLabel>(`/note-labels/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: labelsKey });
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

export function useNoteFolders(archived: boolean = false) {
  return useQuery({
    queryKey: foldersKey(archived),
    queryFn: () =>
      req<NoteFolder[]>(`/note-folders?archived=${archived ? "true" : "false"}`),
  });
}

export function useCreateFolder() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (name: string) =>
      req<NoteFolder>("/note-folders", {
        method: "POST",
        body: JSON.stringify({ name }),
      }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["note-folders"] }),
  });
}

export function useUpdateFolder() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, name }: { id: number; name: string }) =>
      req<NoteFolder>(`/note-folders/${id}`, {
        method: "PATCH",
        body: JSON.stringify({ name }),
      }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["note-folders"] });
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

export function useDeleteFolder() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: number) =>
      req<{ success: true }>(`/note-folders/${id}`, { method: "DELETE" }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["note-folders"] });
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

export function useMoveNoteToFolder() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({ id, folderId }: { id: number; folderId: number | null }) =>
      req<Note>(`/notes/${id}`, {
        method: "PATCH",
        body: JSON.stringify({ folderId }),
      }),
    onMutate: async ({ id, folderId }) => {
      await qc.cancelQueries({ queryKey: ["notes"] });
      await qc.cancelQueries({ queryKey: ["note-folders"] });
      const noteSnapshots: Array<[unknown, Note[] | undefined]> = [];
      const folderSnapshots: Array<[unknown, NoteFolder[] | undefined]> = [];

      // Resolve the previous folderId from any cached notes list so we can
      // adjust the source folder badge optimistically too.
      let previousFolderId: number | null | undefined;
      for (const [, data] of qc.getQueriesData<Note[]>({ queryKey: ["notes"] })) {
        if (!Array.isArray(data)) continue;
        const hit = data.find((n) => n.id === id);
        if (hit) {
          previousFolderId = hit.folderId;
          break;
        }
      }

      const noteQueries = qc.getQueriesData<Note[]>({ queryKey: ["notes"] });
      for (const [key, data] of noteQueries) {
        noteSnapshots.push([key, data]);
        if (Array.isArray(data)) {
          qc.setQueryData<Note[]>(
            key,
            data.map((n) => (n.id === id ? { ...n, folderId } : n)),
          );
        }
      }

      // Optimistically adjust folder badge counts: -1 on the source, +1 on
      // the destination. `null` (Unfiled) has no badge in the folders list.
      const folderQueries = qc.getQueriesData<NoteFolder[]>({
        queryKey: ["note-folders"],
      });
      for (const [key, data] of folderQueries) {
        folderSnapshots.push([key, data]);
        if (Array.isArray(data)) {
          qc.setQueryData<NoteFolder[]>(
            key,
            data.map((f) => {
              let next = f.noteCount;
              if (previousFolderId === f.id) next -= 1;
              if (folderId === f.id) next += 1;
              return next === f.noteCount ? f : { ...f, noteCount: Math.max(0, next) };
            }),
          );
        }
      }

      return { noteSnapshots, folderSnapshots };
    },
    onError: (_err, _vars, ctx) => {
      if (!ctx) return;
      for (const [key, data] of ctx.noteSnapshots) {
        qc.setQueryData(key as readonly unknown[], data);
      }
      for (const [key, data] of ctx.folderSnapshots) {
        qc.setQueryData(key as readonly unknown[], data);
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["notes"] });
      qc.invalidateQueries({ queryKey: ["note-folders"] });
    },
  });
}

// Persist a manual reorder of notes within a single bucket. A "bucket" is
// the unique combination of (note owner, folderId, isPinned). The
// optimistic update reshuffles in place: every cached `notes` list keeps
// the same set of notes, but the ordered ids you pass in are stitched
// back into the array in their new order so the UI reflects the drop
// before the server roundtrip lands. Mirrors the optimistic pattern of
// `useMoveNoteToFolder` so the two drag flows (move-to-folder vs
// reorder-within-folder) share a single mental model.
export function useReorderNotes() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({
      folderId,
      isPinned,
      orderedIds,
    }: {
      folderId: number | null;
      isPinned: boolean;
      orderedIds: number[];
    }) =>
      req<{ success: true; updated: number }>(`/notes/reorder`, {
        method: "PATCH",
        body: JSON.stringify({ folderId, isPinned, orderedIds }),
      }),
    onMutate: async ({ folderId, isPinned, orderedIds }) => {
      await qc.cancelQueries({ queryKey: ["notes"] });
      const noteSnapshots: Array<[unknown, Note[] | undefined]> = [];
      const orderIndex = new Map<number, number>();
      orderedIds.forEach((id, i) => orderIndex.set(id, i));
      const noteQueries = qc.getQueriesData<Note[]>({ queryKey: ["notes"] });
      for (const [key, data] of noteQueries) {
        noteSnapshots.push([key, data]);
        if (!Array.isArray(data)) continue;
        // Pull bucket members out, sort them by the new order, then
        // stitch them back into the same slots they used to occupy in
        // the source array. Non-bucket notes keep their original index
        // so unrelated sections don't reflow.
        const sortedBucket = data
          .filter(
            (n) =>
              n.folderId === folderId &&
              n.isPinned === isPinned &&
              orderIndex.has(n.id),
          )
          .sort(
            (a, b) =>
              (orderIndex.get(a.id) ?? 0) - (orderIndex.get(b.id) ?? 0),
          );
        let bIdx = 0;
        const next = data.map((n) => {
          if (
            n.folderId === folderId &&
            n.isPinned === isPinned &&
            orderIndex.has(n.id)
          ) {
            return sortedBucket[bIdx++] ?? n;
          }
          return n;
        });
        qc.setQueryData<Note[]>(key, next);
      }
      return { noteSnapshots };
    },
    onError: (_err, _vars, ctx) => {
      if (!ctx) return;
      for (const [key, data] of ctx.noteSnapshots) {
        qc.setQueryData(key as readonly unknown[], data);
      }
    },
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

// ---------- Folder sharing ----------

export const folderSharesKey = (folderId: number) =>
  ["note-folder-shares", folderId] as const;
export const sharedWithMeKey = ["note-folders", "shared-with-me"] as const;
export const sharedFolderNotesKey = (folderId: number) =>
  ["notes", "shared-folder", folderId] as const;

// People the OWNER has shared a given folder with. 404s for non-owners.
export function useFolderShares(folderId: number | null) {
  return useQuery({
    queryKey: folderSharesKey(folderId ?? 0),
    queryFn: () =>
      req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`),
    enabled: folderId != null,
  });
}

// Replace the recipient set on a folder. Server diffs idempotently so this
// can be called repeatedly with the desired final set. Each recipient
// carries an explicit "view" | "edit" permission.
export function useUpdateFolderShares() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: ({
      folderId,
      recipients,
    }: {
      folderId: number;
      recipients: { userId: number; permission: FolderSharePermission }[];
    }) =>
      req<FolderShareRecipient[]>(`/note-folders/${folderId}/shares`, {
        method: "PUT",
        body: JSON.stringify({ recipients }),
      }),
    onSuccess: (_data, vars) => {
      qc.invalidateQueries({ queryKey: folderSharesKey(vars.folderId) });
      // Owner's folder list shows the "shared with N" badge — refresh it.
      qc.invalidateQueries({ queryKey: ["note-folders"] });
    },
  });
}

// Folders shared WITH the current user.
export function useSharedWithMeFolders() {
  return useQuery({
    queryKey: sharedWithMeKey,
    queryFn: () => req<SharedFolder[]>(`/note-folders/shared-with-me`),
  });
}

// Note returned inside a shared-folder view. Carries the readOnly flag the
// server stamps on every record so the UI doesn't have to recompute access.
// `readOnly` is `false` for notes inside an editor-permission folder
//.
export interface SharedFolderNote extends Note {
  readOnly: boolean;
}

export interface SharedFolderView {
  folder: { id: number; name: string; ownerId: number };
  owner: UserSummary | null;
  myPermission: FolderSharePermission;
  notes: SharedFolderNote[];
}

// Notes inside a folder shared with the caller. Live (owner's current
// notes), not a snapshot.
export function useSharedFolderNotes(folderId: number | null) {
  return useQuery({
    queryKey: sharedFolderNotesKey(folderId ?? 0),
    queryFn: () =>
      req<SharedFolderView>(`/note-folders/${folderId}/shared-notes`),
    enabled: folderId != null,
  });
}

export function useDeleteLabel() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (id: number) =>
      req<{ success: true }>(`/note-labels/${id}`, { method: "DELETE" }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: labelsKey });
      qc.invalidateQueries({ queryKey: ["notes"] });
    },
  });
}

export const NOTE_COLORS: {
  id: string;
  bg: string;
  ring: string;
  accent: string;
  // each color also carries the literal class strings used by
  // the incoming-note popup so the popup's outer ring, the avatar's
  // background + ring, and the animated ping pulse all match the note's
  // own color family instead of being a fixed amber tint. Keeping these
  // as literal strings (not interpolated) preserves Tailwind v4's
  // build-time scanning, same as the existing `accent-*` pattern above.
  ringStrong: string; // ring color for the popup card outline (4px ring)
  avatarBg: string; // avatar background + text when there's no photo
  avatarRing: string; // 2px ring around the avatar circle
  ping: string; // animated pulse behind the avatar
}[] = [
  // Tailwind v4 scans these literal class strings at build time, so each
  // accent-* utility below ends up in the generated CSS even though we
  // pick the right one at runtime via colorAccent(). The "default"
  // (white/card) note uses a neutral slate accent so the checked box
  // reads as on-brand against the neutral surface in both light and
  // dark mode; every other color points at its own family's *-500
  // accent so the checked tint belongs to the card instead of clashing
  // with it. colorAccent() falls back to this default for unknown ids.
  { id: "default", bg: "bg-white dark:bg-card", ring: "ring-slate-300", accent: "accent-slate-500", ringStrong: "ring-slate-400/70", avatarBg: "bg-slate-200 text-slate-900", avatarRing: "ring-slate-400", ping: "bg-slate-400/60" },
  { id: "red", bg: "bg-red-100 dark:bg-red-900/40", ring: "ring-red-400", accent: "accent-red-500", ringStrong: "ring-red-400/70", avatarBg: "bg-red-200 text-red-900", avatarRing: "ring-red-400", ping: "bg-red-400/60" },
  { id: "orange", bg: "bg-orange-100 dark:bg-orange-900/40", ring: "ring-orange-400", accent: "accent-orange-500", ringStrong: "ring-orange-400/70", avatarBg: "bg-orange-200 text-orange-900", avatarRing: "ring-orange-400", ping: "bg-orange-400/60" },
  { id: "yellow", bg: "bg-yellow-100 dark:bg-yellow-900/40", ring: "ring-yellow-400", accent: "accent-yellow-500", ringStrong: "ring-yellow-400/70", avatarBg: "bg-yellow-200 text-yellow-900", avatarRing: "ring-yellow-400", ping: "bg-yellow-400/60" },
  { id: "green", bg: "bg-green-100 dark:bg-green-900/40", ring: "ring-green-400", accent: "accent-green-500", ringStrong: "ring-green-400/70", avatarBg: "bg-green-200 text-green-900", avatarRing: "ring-green-400", ping: "bg-green-400/60" },
  { id: "teal", bg: "bg-teal-100 dark:bg-teal-900/40", ring: "ring-teal-400", accent: "accent-teal-500", ringStrong: "ring-teal-400/70", avatarBg: "bg-teal-200 text-teal-900", avatarRing: "ring-teal-400", ping: "bg-teal-400/60" },
  { id: "blue", bg: "bg-sky-100 dark:bg-sky-900/40", ring: "ring-sky-400", accent: "accent-sky-500", ringStrong: "ring-sky-400/70", avatarBg: "bg-sky-200 text-sky-900", avatarRing: "ring-sky-400", ping: "bg-sky-400/60" },
  { id: "purple", bg: "bg-purple-100 dark:bg-purple-900/40", ring: "ring-purple-400", accent: "accent-purple-500", ringStrong: "ring-purple-400/70", avatarBg: "bg-purple-200 text-purple-900", avatarRing: "ring-purple-400", ping: "bg-purple-400/60" },
  { id: "pink", bg: "bg-pink-100 dark:bg-pink-900/40", ring: "ring-pink-400", accent: "accent-pink-500", ringStrong: "ring-pink-400/70", avatarBg: "bg-pink-200 text-pink-900", avatarRing: "ring-pink-400", ping: "bg-pink-400/60" },
  { id: "gray", bg: "bg-slate-200 dark:bg-slate-800/60", ring: "ring-slate-400", accent: "accent-slate-500", ringStrong: "ring-slate-400/70", avatarBg: "bg-slate-300 text-slate-900", avatarRing: "ring-slate-400", ping: "bg-slate-400/60" },
];

export function colorBg(id: string): string {
  return NOTE_COLORS.find((c) => c.id === id)?.bg ?? NOTE_COLORS[0].bg;
}

export function colorAccent(id: string | null | undefined): string {
  if (!id) return NOTE_COLORS[0].accent;
  return NOTE_COLORS.find((c) => c.id === id)?.accent ?? NOTE_COLORS[0].accent;
}

function colorEntry(id: string | null | undefined) {
  if (!id) return NOTE_COLORS[0];
  return NOTE_COLORS.find((c) => c.id === id) ?? NOTE_COLORS[0];
}

export function colorRingStrong(id: string | null | undefined): string {
  return colorEntry(id).ringStrong;
}

export function colorAvatarBg(id: string | null | undefined): string {
  return colorEntry(id).avatarBg;
}

export function colorAvatarRing(id: string | null | undefined): string {
  return colorEntry(id).avatarRing;
}

export function colorPing(id: string | null | undefined): string {
  return colorEntry(id).ping;
}

export function userDisplayName(
  u: UserSummary | null | undefined,
  lang: string,
): string {
  if (!u) return "?";
  if (lang === "ar") return u.displayNameAr ?? u.displayNameEn ?? u.username;
  return u.displayNameEn ?? u.displayNameAr ?? u.username;
}
