import { Router, type IRouter } from "express";
import { eq, and, asc, desc, inArray, or, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
  notesTable,
  noteFoldersTable,
  noteLabelsTable,
  noteLabelAssignmentsTable,
  noteRecipientsTable,
  noteRepliesTable,
  noteFolderSharesTable,
  usersTable,
  notificationsTable,
} from "@workspace/db";
import { requireAuth, getUserRoles } from "../middlewares/auth";
import { sendPushToUser } from "../lib/push";

const router: IRouter = Router();

async function emitToUser(userId: number, event: string, payload?: unknown) {
  const { io } = await import("../index.js");
  io.to(`user:${userId}`).emit(event, payload);
}

async function isAdmin(userId: number): Promise<boolean> {
  const roles = await getUserRoles(userId);
  return roles.includes("admin");
}

function parseIdParam(raw: unknown): { ok: true; id: number } | { ok: false; error: string } {
  const n = Number(raw);
  if (!Number.isInteger(n) || n <= 0) return { ok: false, error: "Invalid id" };
  return { ok: true, id: n };
}

// Postgres unique-constraint violation (SQLSTATE 23505). Used to distinguish
// duplicate-name errors (→ 409) from unrelated DB failures (→ 500).
function isUniqueViolation(e: unknown): boolean {
  return (
    typeof e === "object" && e !== null && "code" in e &&
    (e as { code?: unknown }).code === "23505"
  );
}

function parseStringMax(v: unknown, max: number): string | undefined {
  if (v === undefined || v === null) return undefined;
  if (typeof v !== "string") return undefined;
  return v.slice(0, max);
}

function parseLabelIds(v: unknown): number[] | undefined {
  if (v === undefined) return undefined;
  if (!Array.isArray(v)) return undefined;
  const out: number[] = [];
  for (const x of v) {
    const n = Number(x);
    if (Number.isInteger(n) && n > 0) out.push(n);
  }
  return out;
}

type ChecklistItem = { id: string; text: string; done: boolean };

interface NoteInput {
  title?: string;
  content?: string;
  color?: string;
  kind?: "text" | "checklist";
  items?: ChecklistItem[] | null;
  isPinned?: boolean;
  isArchived?: boolean;
  labelIds?: number[];
  folderId?: number | null;
  sortOrder?: number;
}

function parseKind(v: unknown): "text" | "checklist" | undefined {
  return v === "text" || v === "checklist" ? v : undefined;
}

function parseItems(v: unknown): ChecklistItem[] | null | undefined {
  if (v === null) return null;
  if (!Array.isArray(v)) return undefined;
  if (v.length > 200) return undefined;
  const out: ChecklistItem[] = [];
  for (const it of v) {
    if (!it || typeof it !== "object") return undefined;
    const obj = it as Record<string, unknown>;
    const id = typeof obj.id === "string" && obj.id.length > 0 && obj.id.length <= 64
      ? obj.id : null;
    const text = typeof obj.text === "string" ? obj.text.slice(0, 500) : null;
    const done = typeof obj.done === "boolean" ? obj.done : false;
    if (id === null || text === null) return undefined;
    out.push({ id, text, done });
  }
  return out;
}

function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } {
  if (v === null) return { ok: true, value: null };
  const n = Number(v);
  if (!Number.isInteger(n) || n <= 0) return { ok: false };
  return { ok: true, value: n };
}

function parseFolderName(body: any): { ok: true; name: string } | { ok: false; error: string } {
  if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
  if (typeof body.name !== "string") return { ok: false, error: "Invalid name" };
  const name = body.name.trim();
  if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" };
  return { ok: true, name };
}

async function userOwnsFolder(userId: number, folderId: number): Promise<boolean> {
  const [row] = await db
    .select({ id: noteFoldersTable.id })
    .from(noteFoldersTable)
    .where(and(eq(noteFoldersTable.id, folderId), eq(noteFoldersTable.userId, userId)));
  return !!row;
}

// Folder-level access helpers.
//
// `userCanEditFolder` returns true if the caller is the owner OR has a share
// row with permission="edit" for the folder. Used by every write path that
// must accept folder editors as well as the folder owner (POST/PATCH/DELETE
// /notes inside the folder, checklist toggle, etc.). Returns the resolved
// owner id so callers can stamp newly-created notes with the folder owner's
// userId — an editor's note still belongs to the folder owner so the folder
// remains a single user_id namespace and shows up to every other recipient.
async function resolveFolderEditAccess(
  userId: number,
  folderId: number,
): Promise<{ ok: true; ownerId: number } | { ok: false }> {
  const [folder] = await db
    .select({ ownerId: noteFoldersTable.userId })
    .from(noteFoldersTable)
    .where(eq(noteFoldersTable.id, folderId));
  if (!folder) return { ok: false };
  if (folder.ownerId === userId) return { ok: true, ownerId: folder.ownerId };
  const [share] = await db
    .select({ permission: noteFolderSharesTable.permission })
    .from(noteFolderSharesTable)
    .where(
      and(
        eq(noteFolderSharesTable.folderId, folderId),
        eq(noteFolderSharesTable.recipientUserId, userId),
      ),
    );
  if (share && share.permission === "edit") return { ok: true, ownerId: folder.ownerId };
  return { ok: false };
}

// Notify the entire folder audience (owner + all current recipients except
// the actor) that a folder's note set changed. Drives live refresh of the
// shared-folder view AND the rail "Shared with me" note count.
async function emitFolderChanged(folderId: number, actorUserId: number) {
  const [folder] = await db
    .select({ ownerId: noteFoldersTable.userId })
    .from(noteFoldersTable)
    .where(eq(noteFoldersTable.id, folderId));
  if (!folder) return;
  const recipients = await db
    .select({ uid: noteFolderSharesTable.recipientUserId })
    .from(noteFolderSharesTable)
    .where(eq(noteFolderSharesTable.folderId, folderId));
  const audience = new Set<number>([folder.ownerId, ...recipients.map((r) => r.uid)]);
  audience.delete(actorUserId);
  for (const uid of audience) {
    void emitToUser(uid, "note-folder-shared", { folderId });
  }
}

function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInput } | { ok: false; error: string } {
  if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
  const data: NoteInput = {};
  if (body.title !== undefined) {
    const t = parseStringMax(body.title, 300);
    if (t === undefined) return { ok: false, error: "Invalid title" };
    data.title = t;
  } else if (isCreate) data.title = "";
  if (body.content !== undefined) {
    if (typeof body.content !== "string") return { ok: false, error: "Invalid content" };
    data.content = body.content;
  } else if (isCreate) data.content = "";
  if (body.color !== undefined) {
    const c = parseStringMax(body.color, 32);
    if (c === undefined) return { ok: false, error: "Invalid color" };
    data.color = c;
  } else if (isCreate) data.color = "default";
  if (body.kind !== undefined) {
    const k = parseKind(body.kind);
    if (!k) return { ok: false, error: "Invalid kind" };
    data.kind = k;
  } else if (isCreate) data.kind = "text";
  if (body.items !== undefined) {
    const its = parseItems(body.items);
    if (its === undefined) return { ok: false, error: "Invalid items" };
    data.items = its;
  }
  // Normalize items to match kind whenever kind is being set explicitly so
  // the column is never out of sync (e.g. checklist-mode without items, or
  // text-mode that still carries stale items from the previous shape).
  if (data.kind !== undefined) {
    if (data.kind === "checklist") {
      data.items = data.items ?? [];
    } else {
      data.items = null;
    }
  }
  if (body.isPinned !== undefined) {
    if (typeof body.isPinned !== "boolean") return { ok: false, error: "Invalid isPinned" };
    data.isPinned = body.isPinned;
  } else if (isCreate) data.isPinned = false;
  if (body.isArchived !== undefined) {
    if (typeof body.isArchived !== "boolean") return { ok: false, error: "Invalid isArchived" };
    data.isArchived = body.isArchived;
  }
  if (body.labelIds !== undefined) {
    const ids = parseLabelIds(body.labelIds);
    if (ids === undefined) return { ok: false, error: "Invalid labelIds" };
    data.labelIds = ids;
  } else if (isCreate) data.labelIds = [];
  if (body.folderId !== undefined) {
    const fid = parseFolderIdInput(body.folderId);
    if (!fid.ok) return { ok: false, error: "Invalid folderId" };
    data.folderId = fid.value;
  }
  return { ok: true, data };
}

function parseLabelName(body: any): { ok: true; name: string } | { ok: false; error: string } {
  if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" };
  if (typeof body.name !== "string") return { ok: false, error: "Invalid name" };
  const name = body.name.trim();
  if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" };
  return { ok: true, name };
}

async function loadNotesWithLabels(userId: number, archived: boolean) {
  const notes = await db
    .select()
    .from(notesTable)
    .where(and(eq(notesTable.userId, userId), eq(notesTable.isArchived, archived)))
    .orderBy(
      desc(notesTable.isPinned),
      asc(notesTable.sortOrder),
      desc(notesTable.updatedAt),
    );

  if (notes.length === 0) return [];

  const noteIds = notes.map((n) => n.id);
  const assignments = await db
    .select()
    .from(noteLabelAssignmentsTable)
    .where(inArray(noteLabelAssignmentsTable.noteId, noteIds));

  const labelsByNote = new Map<number, number[]>();
  for (const a of assignments) {
    const arr = labelsByNote.get(a.noteId) ?? [];
    arr.push(a.labelId);
    labelsByNote.set(a.noteId, arr);
  }

  return notes.map((n) => ({ ...n, labelIds: labelsByNote.get(n.id) ?? [] }));
}

async function setNoteLabels(noteId: number, userId: number, labelIds: number[]) {
  await db.delete(noteLabelAssignmentsTable).where(eq(noteLabelAssignmentsTable.noteId, noteId));
  if (labelIds.length === 0) return;
  const valid = await db
    .select({ id: noteLabelsTable.id })
    .from(noteLabelsTable)
    .where(and(eq(noteLabelsTable.userId, userId), inArray(noteLabelsTable.id, labelIds)));
  if (valid.length === 0) return;
  await db.insert(noteLabelAssignmentsTable).values(
    valid.map((l) => ({ noteId, labelId: l.id })),
  );
}

type UserSummary = {
  id: number;
  username: string;
  displayNameAr: string | null;
  displayNameEn: string | null;
};

async function loadUserSummaries(userIds: number[]): Promise<Map<number, UserSummary>> {
  const unique = Array.from(new Set(userIds));
  if (unique.length === 0) return new Map();
  const rows = await db
    .select({
      id: usersTable.id,
      username: usersTable.username,
      displayNameAr: usersTable.displayNameAr,
      displayNameEn: usersTable.displayNameEn,
    })
    .from(usersTable)
    .where(inArray(usersTable.id, unique));
  return new Map(rows.map((r) => [r.id, r]));
}

async function loadRecipientsForNote(noteId: number) {
  const rows = await db
    .select()
    .from(noteRecipientsTable)
    .where(eq(noteRecipientsTable.noteId, noteId))
    .orderBy(noteRecipientsTable.id);
  const userMap = await loadUserSummaries(rows.map((r) => r.recipientUserId));
  return rows.map((r) => ({
    id: r.id,
    noteId: r.noteId,
    recipientUserId: r.recipientUserId,
    status: r.status,
    sentAt: r.sentAt,
    readAt: r.readAt,
    archivedAt: r.archivedAt,
    title: r.title,
    content: r.content,
    color: r.color,
    kind: r.kind,
    items: r.items,
    recipient: userMap.get(r.recipientUserId) ?? null,
  }));
}

async function loadRepliesForNote(noteId: number) {
  const rows = await db
    .select()
    .from(noteRepliesTable)
    .where(eq(noteRepliesTable.noteId, noteId))
    .orderBy(noteRepliesTable.createdAt);
  const userMap = await loadUserSummaries(rows.map((r) => r.senderUserId));
  return rows.map((r) => ({
    id: r.id,
    noteId: r.noteId,
    senderUserId: r.senderUserId,
    recipientUserId: r.recipientUserId,
    content: r.content,
    createdAt: r.createdAt,
    sender: userMap.get(r.senderUserId) ?? null,
  }));
}

async function loadOne(noteId: number, userId: number) {
  const [note] = await db
    .select()
    .from(notesTable)
    .where(and(eq(notesTable.id, noteId), eq(notesTable.userId, userId)));
  if (!note) return [null];
  const assignments = await db
    .select()
    .from(noteLabelAssignmentsTable)
    .where(eq(noteLabelAssignmentsTable.noteId, noteId));
  return [{ ...note, labelIds: assignments.map((a) => a.labelId) }];
}

async function handleListMyNotes(
  req: import("express").Request,
  res: import("express").Response,
): Promise<void> {
  const userId = req.session.userId!;
  // ?sharedFolderId=:id — read-only access to the live notes inside a
  // folder shared WITH the caller. Spec alias for the same data the
  // dedicated `/note-folders/:id/shared-notes` endpoint returns; kept
  // here so clients that prefer the unified `/notes?…` shape work too.
  const sharedFolderIdRaw = req.query.sharedFolderId;
  if (typeof sharedFolderIdRaw === "string" && sharedFolderIdRaw.length > 0) {
    const parsed = Number(sharedFolderIdRaw);
    if (!Number.isInteger(parsed) || parsed <= 0) {
      res.status(400).json({ error: "Invalid sharedFolderId" });
      return;
    }
    const [share] = await db
      .select({
        folderId: noteFolderSharesTable.folderId,
        permission: noteFolderSharesTable.permission,
      })
      .from(noteFolderSharesTable)
      .where(
        and(
          eq(noteFolderSharesTable.folderId, parsed),
          eq(noteFolderSharesTable.recipientUserId, userId),
        ),
      );
    if (!share) {
      res.status(403).json({ error: "Folder is not shared with you" });
      return;
    }
    const [folder] = await db
      .select({ ownerId: noteFoldersTable.userId })
      .from(noteFoldersTable)
      .where(eq(noteFoldersTable.id, parsed));
    if (!folder) {
      res.status(404).json({ error: "Folder not found" });
      return;
    }
    const readOnly = share.permission !== "edit";
    const ownerNotes = await loadNotesWithLabels(folder.ownerId, false);
    const filtered = ownerNotes
      .filter((n) => n.folderId === parsed)
      .map((n) => ({ ...n, readOnly }));
    res.json(filtered);
    return;
  }
  const archived = req.query.archived === "true";
  const notes = await loadNotesWithLabels(userId, archived);
  res.json(notes);
}

// Backward-compatible: GET /notes lists the caller's personal notes.
router.get("/notes", requireAuth, handleListMyNotes);
// Explicit alias requested by the messaging spec.
router.get("/notes/my", requireAuth, handleListMyNotes);

router.post("/notes", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const parsed = parseNoteInput(req.body, true);
  if (!parsed.ok) {
    res.status(400).json({ error: parsed.error });
    return;
  }
  const { labelIds = [], folderId, ...data } = parsed.data;
  // Resolve who the new note's owner should be. The caller is allowed to
  // create notes either in their own folders OR in a folder that's been
  // shared with them with permission="edit"; in the editor case the note
  // is stamped with the folder owner's userId so the folder remains a
  // single user_id namespace and shows up to every other recipient.
  let stampedUserId = userId;
  if (folderId != null) {
    const access = await resolveFolderEditAccess(userId, folderId);
    if (!access.ok) {
      res.status(400).json({ error: "Invalid folderId" });
      return;
    }
    stampedUserId = access.ownerId;
  }
  const [note] = await db
    .insert(notesTable)
    .values({
      userId: stampedUserId,
      title: data.title ?? "",
      content: data.content ?? "",
      color: data.color ?? "default",
      kind: data.kind ?? "text",
      items: data.items ?? null,
      isPinned: data.isPinned ?? false,
      isArchived: data.isArchived ?? false,
      folderId: folderId ?? null,
    })
    .returning();
  // Labels are scoped to the note OWNER (stampedUserId), not necessarily
  // the caller — when an editor creates a note in someone else's folder
  // the note belongs to the folder owner, so labelIds are validated
  // against that owner's label set.
  if (labelIds.length > 0) await setNoteLabels(note.id, stampedUserId, labelIds);
  const [withLabels] = await loadOne(note.id, stampedUserId);
  // Notify every viewer of the shared folder (owner + recipients) so the
  // shared-folder view and rail counts refresh live. Fires for both the
  // editor branch and the owner-creating-in-their-own-shared-folder
  // branch — `emitFolderChanged` is a no-op when the folder isn't
  // actually shared with anyone.
  if (folderId != null) {
    void emitFolderChanged(folderId, userId);
  }
  res.status(201).json(withLabels);
});

/**
 * PATCH /notes/reorder
 *
 * Persist the user-chosen order of notes within a single bucket. A bucket
 * is the unique combination of (note owner, folderId, isPinned). The
 * client posts the full ordered list of note ids for the bucket and the
 * server stamps sort_order = 0..N-1 in one transaction.
 *
 * Body: { folderId: number | null, isPinned: boolean, orderedIds: number[] }
 *
 * Auth: each id must be a note the caller owns OR a note inside a folder
 * they have edit permission on. Foreign ids (and ids that
 * don't actually live in the named bucket) are silently skipped — that
 * keeps the endpoint safe against stale client state without blowing up
 * the whole reorder.
 */
router.patch("/notes/reorder", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const body = req.body;
  if (!body || typeof body !== "object") {
    res.status(400).json({ error: "Invalid body" });
    return;
  }
  const folderParsed = parseFolderIdInput(body.folderId);
  if (!folderParsed.ok) {
    res.status(400).json({ error: "Invalid folderId" });
    return;
  }
  const folderId = folderParsed.value;
  if (typeof body.isPinned !== "boolean") {
    res.status(400).json({ error: "Invalid isPinned" });
    return;
  }
  const isPinned = body.isPinned;
  if (!Array.isArray(body.orderedIds)) {
    res.status(400).json({ error: "Invalid orderedIds" });
    return;
  }
  const orderedIds: number[] = [];
  const seen = new Set<number>();
  for (const raw of body.orderedIds) {
    const n = Number(raw);
    if (!Number.isInteger(n) || n <= 0) continue;
    if (seen.has(n)) continue;
    seen.add(n);
    orderedIds.push(n);
  }
  if (orderedIds.length === 0) {
    res.json({ success: true, updated: 0 });
    return;
  }
  if (orderedIds.length > 500) {
    res.status(400).json({ error: "Too many ids" });
    return;
  }
  // Resolve who is allowed to write into this bucket. For folder buckets,
  // editors of a shared folder may reorder the folder's notes; for the
  // unfiled bucket (folderId === null) only the caller's own notes are
  // touchable. Either way the resolved owner gates which user_id the
  // notes must belong to.
  let bucketOwnerId = userId;
  if (folderId !== null) {
    const access = await resolveFolderEditAccess(userId, folderId);
    if (!access.ok) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    bucketOwnerId = access.ownerId;
  }
  // Validate ids against the destination bucket and write every
  // sort_order inside one transaction so concurrent reorders can't leave
  // a half-applied permutation with duplicate values.
  const updated = await db.transaction(async (tx) => {
    const bucketRows = await tx
      .select({ id: notesTable.id })
      .from(notesTable)
      .where(
        and(
          eq(notesTable.userId, bucketOwnerId),
          folderId === null
            ? sql`${notesTable.folderId} is null`
            : eq(notesTable.folderId, folderId),
          eq(notesTable.isPinned, isPinned),
          eq(notesTable.isArchived, false),
        ),
      );
    const validIds = new Set(bucketRows.map((r) => r.id));
    let count = 0;
    for (let i = 0; i < orderedIds.length; i++) {
      const noteId = orderedIds[i];
      if (!validIds.has(noteId)) continue;
      await tx
        .update(notesTable)
        .set({ sortOrder: i })
        .where(eq(notesTable.id, noteId));
      count++;
    }
    return count;
  });
  if (folderId !== null) void emitFolderChanged(folderId, userId);
  res.json({ success: true, updated });
});

router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const parsed = parseNoteInput(req.body, false);
  if (!parsed.ok) {
    res.status(400).json({ error: parsed.error });
    return;
  }
  const { labelIds, ...data } = parsed.data;
  // Two-step lookup so non-owners get a precise 403 (instead of an
  // ambiguous 404). Recipients of a shared folder may now also mutate
  // the owner's notes when their share grants permission="edit"
  //.
  const [existing] = await db
    .select()
    .from(notesTable)
    .where(eq(notesTable.id, id.id));
  if (!existing) {
    res.status(404).json({ error: "Note not found" });
    return;
  }
  let actingAsEditor = false;
  if (existing.userId !== userId) {
    if (
      existing.folderId == null ||
      !(await resolveFolderEditAccess(userId, existing.folderId)).ok
    ) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    actingAsEditor = true;
  }
  // Folder-change validation. We must guard BOTH branches:
  //   - data.folderId === null  → caller is unfiling the note. Editors
  //     don't own the note, so unfiling would yank it out of the shared
  //     folder and effectively orphan it from the owner's organization
  //     scheme; only the owner may unfile.
  //   - data.folderId !== null  → caller is moving it into a folder.
  //     Owners can pick any of their own folders; editors are limited to
  //     a folder they have edit permission on AND that's owned by the
  //     same owner (i.e. effectively the same shared folder).
  if (data.folderId !== undefined) {
    if (actingAsEditor && data.folderId === null) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    if (data.folderId !== null) {
      const access = await resolveFolderEditAccess(userId, data.folderId);
      if (!access.ok || access.ownerId !== existing.userId) {
        res.status(400).json({ error: "Invalid folderId" });
        return;
      }
    }
  }
  // When the note is being moved into a different bucket (folder change OR
  // pin/unpin flip), drop it at the top of the destination so it lands
  // where the user dragged it. We compute (min(sort_order) - 1) inside
  // the destination bucket {ownerId, folderId, isPinned, isArchived} and
  // stamp that on the row in the same UPDATE below. Without this the
  // moved note would inherit its old bucket's sort_order, which makes it
  // appear in an arbitrary slot in the new folder.
  const folderChanged =
    data.folderId !== undefined && data.folderId !== existing.folderId;
  const pinChanged =
    data.isPinned !== undefined && data.isPinned !== existing.isPinned;
  if (folderChanged || pinChanged) {
    const newFolder =
      data.folderId !== undefined ? data.folderId : existing.folderId;
    const newPinned =
      data.isPinned !== undefined ? data.isPinned : existing.isPinned;
    const newArchived =
      data.isArchived !== undefined ? data.isArchived : existing.isArchived;
    const [row] = await db
      .select({
        minSort: sql<number>`coalesce(min(${notesTable.sortOrder}), 0)::int`,
      })
      .from(notesTable)
      .where(
        and(
          eq(notesTable.userId, existing.userId),
          newFolder === null
            ? sql`${notesTable.folderId} is null`
            : eq(notesTable.folderId, newFolder),
          eq(notesTable.isPinned, newPinned),
          eq(notesTable.isArchived, newArchived),
        ),
      );
    data.sortOrder = (row?.minSort ?? 0) - 1;
  }
  // If the caller patched `items` without specifying `kind`, normalize
  // against the note's current kind so a text note can never end up
  // carrying checklist items (and vice versa). parseNoteInput handles the
  // case where `kind` is provided; this covers the items-only PATCH.
  if (data.kind === undefined && data.items !== undefined) {
    if (existing.kind === "checklist") {
      data.items = data.items ?? [];
    } else {
      data.items = null;
    }
  }
  if (Object.keys(data).length > 0) {
    await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id));
  }
  if (labelIds) await setNoteLabels(existing.id, existing.userId, labelIds);
  const [withLabels] = await loadOne(existing.id, existing.userId);
  // Live-refresh shared-folder viewers regardless of whether the caller
  // was the owner or an editor. If the move crossed folders, both the
  // source AND destination need a ping so each side's note list updates.
  const oldFolderId = existing.folderId;
  const newFolderId =
    data.folderId !== undefined ? data.folderId : existing.folderId;
  if (oldFolderId != null) void emitFolderChanged(oldFolderId, userId);
  if (newFolderId != null && newFolderId !== oldFolderId) {
    void emitFolderChanged(newFolderId, userId);
  }
  res.json(withLabels);
});

// per-recipient delete. The recipient does not own the
// underlying note, so a regular DELETE /notes/:id (which is owner-only)
// can't help them clear an unwanted incoming note. This endpoint
// removes ONLY the caller's own `note_recipients` row, leaving the
// note, the sender's copy, and every other recipient untouched. The
// caller must be the recipient — we don't honor requests from anyone
// else, even admins, because the row represents a personal inbox
// entry.
//
// Registered BEFORE the catch-all `DELETE /notes/:id` further down
// because Express matches in registration order; otherwise
// `parseIdParam("received")` would 400 the request.
router.delete(
  "/notes/received/:id",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const id = parseIdParam(req.params.id);
    if (!id.ok) {
      res.status(400).json({ error: id.error });
      return;
    }
    const result = await db
      .delete(noteRecipientsTable)
      .where(
        and(
          eq(noteRecipientsTable.noteId, id.id),
          eq(noteRecipientsTable.recipientUserId, userId),
        ),
      )
      .returning({ id: noteRecipientsTable.id });
    if (result.length === 0) {
      res.status(404).json({ error: "Not found" });
      return;
    }
    res.json({ success: true });
  },
);

// bulk variant. Accepts up to BULK_RECEIVED_DELETE_MAX ids
// in a single request and reports per-id success/notFound so the UI
// can render a precise toast even on partial misses (e.g. a note the
// sender just hard-deleted between page load and click). One SQL
// DELETE handles the whole batch — much cheaper than N round-trips.
const BULK_RECEIVED_DELETE_MAX = 500;
router.post(
  "/notes/received/bulk-delete",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const raw = (req.body && (req.body as { ids?: unknown }).ids) ?? null;
    if (!Array.isArray(raw)) {
      res.status(400).json({ error: "ids must be an array" });
      return;
    }
    const ids: number[] = [];
    const seen = new Set<number>();
    for (const x of raw) {
      const n = Number(x);
      if (Number.isInteger(n) && n > 0 && !seen.has(n)) {
        seen.add(n);
        ids.push(n);
      }
    }
    if (ids.length === 0) {
      res.json({ ok: [], notFound: [] });
      return;
    }
    if (ids.length > BULK_RECEIVED_DELETE_MAX) {
      res.status(400).json({
        error: `Too many ids (max ${BULK_RECEIVED_DELETE_MAX})`,
      });
      return;
    }
    const deleted = await db
      .delete(noteRecipientsTable)
      .where(
        and(
          inArray(noteRecipientsTable.noteId, ids),
          eq(noteRecipientsTable.recipientUserId, userId),
        ),
      )
      .returning({ noteId: noteRecipientsTable.noteId });
    const okSet = new Set<number>();
    for (const r of deleted) {
      if (typeof r.noteId === "number") okSet.add(r.noteId);
    }
    const ok = ids.filter((i) => okSet.has(i));
    const notFound = ids.filter((i) => !okSet.has(i));
    res.json({ ok, notFound });
  },
);

router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  // Two-step lookup so non-owners (e.g. shared-folder view-only recipients)
  // get a precise 403 instead of a 404 — see acceptance criteria.
  // Editors with permission="edit" on the note's folder are allowed
  //.
  const [existing] = await db
    .select({ userId: notesTable.userId, folderId: notesTable.folderId })
    .from(notesTable)
    .where(eq(notesTable.id, id.id));
  if (!existing) {
    res.status(404).json({ error: "Note not found" });
    return;
  }
  let actingAsEditor = false;
  if (existing.userId !== userId) {
    if (
      existing.folderId == null ||
      !(await resolveFolderEditAccess(userId, existing.folderId)).ok
    ) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    actingAsEditor = true;
  }
  await db.delete(notesTable).where(eq(notesTable.id, id.id));
  // Owner OR editor delete — every shared-folder viewer needs a refresh
  // so the deleted note disappears from their cached note list and the
  // rail count drops.
  if (existing.folderId != null) {
    void emitFolderChanged(existing.folderId, userId);
  }
  res.json({ success: true });
});

// ----- Sent / Received / Detail -----

router.get("/notes/sent", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const sentNoteIdRows = await db
    .selectDistinct({ noteId: noteRecipientsTable.noteId })
    .from(noteRecipientsTable)
    .where(eq(noteRecipientsTable.senderUserId, userId));
  const ids = sentNoteIdRows
    .map((r) => r.noteId)
    .filter((n): n is number => typeof n === "number");
  if (ids.length === 0) {
    res.json([]);
    return;
  }
  const notes = await db
    .select()
    .from(notesTable)
    .where(and(eq(notesTable.userId, userId), inArray(notesTable.id, ids)))
    .orderBy(desc(notesTable.updatedAt));
  type SentNoteOut = (typeof notes)[number] & {
    recipients: Awaited<ReturnType<typeof loadRecipientsForNote>>;
    replyCount: number;
  };
  const out: SentNoteOut[] = [];
  for (const n of notes) {
    const recipients = await loadRecipientsForNote(n.id);
    const replyCount = await db
      .select({ c: sql<number>`count(*)::int` })
      .from(noteRepliesTable)
      .where(eq(noteRepliesTable.noteId, n.id));
    out.push({
      ...n,
      recipients,
      replyCount: replyCount[0]?.c ?? 0,
    });
  }
  res.json(out);
});

router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const includeArchived = req.query.archived === "true";

  const recipientRows = await db
    .select()
    .from(noteRecipientsTable)
    .where(
      and(
        eq(noteRecipientsTable.recipientUserId, userId),
        includeArchived
          ? eq(noteRecipientsTable.status, "archived")
          : sql`${noteRecipientsTable.status} <> 'archived'`,
      ),
    )
    .orderBy(desc(noteRecipientsTable.sentAt));
  if (recipientRows.length === 0) {
    res.json([]);
    return;
  }
  // Recipients render their immutable snapshot, not the live note row.
  const senderMap = await loadUserSummaries(recipientRows.map((r) => r.senderUserId));
  const out = recipientRows.map((r) => ({
    id: r.noteId ?? r.id,
    title: r.title,
    content: r.content,
    color: r.color,
    kind: r.kind,
    items: r.items,
    createdAt: r.sentAt,
    updatedAt: r.readAt ?? r.sentAt,
    sender: senderMap.get(r.senderUserId) ?? null,
    recipientRowId: r.id,
    status: r.status,
    sentAt: r.sentAt,
    readAt: r.readAt,
    archivedAt: r.archivedAt,
  }));
  res.json(out);
});

/**
 * GET /notes/:id/thread — full detail of a sent/received note: note body,
 * full recipient list (sender or admin only), and full reply thread filtered
 * to what this user is allowed to see (admin/sender = all; recipient = only
 * their own thread with the sender).
 */
// GET /notes/:id — alias of /notes/:id/thread.
async function handleNoteDetail(req: import("express").Request, res: import("express").Response): Promise<void> {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const [note] = await db.select().from(notesTable).where(eq(notesTable.id, id.id));
  const [recipientRow] = await db
    .select()
    .from(noteRecipientsTable)
    .where(
      and(
        eq(noteRecipientsTable.noteId, id.id),
        eq(noteRecipientsTable.recipientUserId, userId),
      ),
    );

  const admin = await isAdmin(userId);
  const isSender = !!note && note.userId === userId;

  // Admin: 200 if any trace exists. Non-participant: 403. No trace: 404.
  const [anyRecipientRow] =
    !note && !recipientRow
      ? await db
          .select({ id: noteRecipientsTable.id })
          .from(noteRecipientsTable)
          .where(eq(noteRecipientsTable.noteId, id.id))
          .limit(1)
      : [null];

  if (!note && !recipientRow) {
    if (admin && anyRecipientRow) {
      // fall through — admin reads from recipient snapshots below
    } else if (anyRecipientRow) {
      res.status(403).json({ error: "Forbidden" });
      return;
    } else {
      res.status(404).json({ error: "Note not found" });
      return;
    }
  } else if (!admin && !isSender && !recipientRow) {
    res.status(403).json({ error: "Forbidden" });
    return;
  }

  // Admin reading a deleted note: fall back to any recipient snapshot.
  let adminFallbackRow: typeof recipientRow | null = null;
  if (admin && !note && !recipientRow) {
    const [r] = await db
      .select()
      .from(noteRecipientsTable)
      .where(eq(noteRecipientsTable.noteId, id.id))
      .orderBy(noteRecipientsTable.id)
      .limit(1);
    adminFallbackRow = r ?? null;
  }
  const snapshotRow = recipientRow ?? adminFallbackRow;

  // Recipients render their snapshot; sender/admin render the live note.
  const useSnapshot = !isSender && !admin && !!recipientRow;
  const senderUserId =
    note?.userId ?? recipientRow?.senderUserId ?? snapshotRow?.senderUserId ?? 0;
  const senderMap = await loadUserSummaries([senderUserId]);

  const recipients = admin || isSender ? await loadRecipientsForNote(id.id) : [];

  let replies = await loadRepliesForNote(id.id);
  if (!admin && !isSender && recipientRow) {
    replies = replies.filter(
      (r) =>
        (r.senderUserId === userId && r.recipientUserId === senderUserId) ||
        (r.senderUserId === senderUserId && r.recipientUserId === userId),
    );
  }

  const titleFallback = snapshotRow?.title ?? note?.title ?? "";
  const contentFallback = snapshotRow?.content ?? note?.content ?? "";
  const colorFallback = snapshotRow?.color ?? note?.color ?? "default";

  const kindFallback = snapshotRow?.kind ?? note?.kind ?? "text";
  const itemsFallback = snapshotRow?.items ?? note?.items ?? null;

  res.json({
    id: id.id,
    title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback,
    content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback,
    color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback,
    kind: useSnapshot ? recipientRow!.kind : note?.kind ?? kindFallback,
    items: useSnapshot ? recipientRow!.items : note?.items ?? itemsFallback,
    createdAt: useSnapshot
      ? recipientRow!.sentAt
      : note?.createdAt ?? snapshotRow?.sentAt ?? null,
    updatedAt: useSnapshot
      ? recipientRow!.readAt ?? recipientRow!.sentAt
      : note?.updatedAt ?? snapshotRow?.sentAt ?? null,
    senderUserId,
    sender: senderMap.get(senderUserId) ?? null,
    isOwner: isSender,
    isAdmin: admin,
    myStatus: recipientRow?.status ?? null,
    recipients,
    replies,
  });
}

router.get("/notes/:id/thread", requireAuth, handleNoteDetail);
router.get("/notes/:id", requireAuth, handleNoteDetail);

// ----- Send -----

/**
 * POST /notes/:id/send — owner sends note to recipients.
 * Self-send and dupes are filtered out. The (noteId, recipientUserId)
 * unique constraint keeps a single recipient row per (note, user), but
 * a re-send still re-emits the note_received socket event and creates
 * a fresh notification, so the recipient sees the popup again.
 */
router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const [note] = await db
    .select()
    .from(notesTable)
    .where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId)));
  if (!note) {
    res.status(404).json({ error: "Note not found" });
    return;
  }
  const raw = (req.body && req.body.recipientUserIds) || [];
  if (!Array.isArray(raw)) {
    res.status(400).json({ error: "Invalid recipientUserIds" });
    return;
  }
  const ids = Array.from(
    new Set(
      raw
        .map((x: unknown) => Number(x))
        .filter((n: number) => Number.isInteger(n) && n > 0 && n !== userId),
    ),
  );
  if (ids.length === 0) {
    res.status(400).json({ error: "No valid recipients" });
    return;
  }
  const existing = await db
    .select({ id: usersTable.id })
    .from(usersTable)
    .where(and(inArray(usersTable.id, ids), eq(usersTable.isActive, true)));
  const existingIds = new Set(existing.map((u) => u.id));
  const toSend = ids.filter((i) => existingIds.has(i));
  if (toSend.length === 0) {
    res.status(400).json({ error: "No active recipients" });
    return;
  }

  // Snapshot the note as of right now into each recipient row so that any
  // future sender edit/delete leaves delivered copies untouched. The
  // unique (noteId, recipientUserId) means a re-send to the same person
  // does NOT duplicate the row — but we still want the popup +
  // notification to fire again so the recipient sees the re-send. So:
  // 1) insert with onConflictDoNothing (gives us new rows only)
  // 2) load the existing rows for any recipients that were skipped
  // 3) emit note_received + create a fresh notification for ALL of them
  const inserted = await db
    .insert(noteRecipientsTable)
    .values(
      toSend.map((rid) => ({
        noteId: note.id,
        senderUserId: userId,
        recipientUserId: rid,
        title: note.title,
        content: note.content,
        color: note.color,
        kind: note.kind,
        items: note.items,
      })),
    )
    .onConflictDoNothing({
      target: [noteRecipientsTable.noteId, noteRecipientsTable.recipientUserId],
    })
    .returning();

  const insertedRecipientIds = new Set(inserted.map((r) => r.recipientUserId));
  const resendRecipientIds = toSend.filter((rid) => !insertedRecipientIds.has(rid));
  const existingRows =
    resendRecipientIds.length > 0
      ? await db
          .select()
          .from(noteRecipientsTable)
          .where(
            and(
              eq(noteRecipientsTable.noteId, note.id),
              inArray(noteRecipientsTable.recipientUserId, resendRecipientIds),
            ),
          )
      : [];
  const allRows = [...inserted, ...existingRows];

  const senderMap = await loadUserSummaries([userId]);
  const sender = senderMap.get(userId);
  const senderName = sender?.displayNameEn ?? sender?.username ?? "Someone";
  const senderNameAr = sender?.displayNameAr ?? senderName;
  for (const r of allRows) {
    const [notif] = await db
      .insert(notificationsTable)
      .values({
        userId: r.recipientUserId,
        titleAr: "ملاحظة جديدة",
        titleEn: "New note",
        bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
        bodyEn: `${senderName} sent you a note`,
        type: "note",
        relatedType: "note",
        relatedId: note.id,
      })
      .returning();
    await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
    void sendPushToUser(r.recipientUserId, {
      title: "ملاحظة جديدة",
      body: `${senderNameAr} أرسل لك ملاحظة`,
      titleAr: "ملاحظة جديدة",
      titleEn: "New note",
      bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
      bodyEn: `${senderName} sent you a note`,
      type: "note",
      relatedId: note.id,
      tag: `note-${note.id}`,
      url: `/notes?thread=${note.id}`,
    });
    await emitToUser(r.recipientUserId, "note_received", {
      noteId: note.id,
      recipientRowId: r.id,
      title: r.title,
      content: r.content,
      color: r.color,
      kind: r.kind,
      items: r.items,
      sentAt: r.sentAt,
      senderUserId: userId,
      sender: sender ?? null,
    });
  }

  res.json({ success: true, sent: allRows.length });
});

// ----- Read / Archive (recipient-side) -----

router.post("/notes/:id/read", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const [row] = await db
    .select()
    .from(noteRecipientsTable)
    .where(
      and(
        eq(noteRecipientsTable.noteId, id.id),
        eq(noteRecipientsTable.recipientUserId, userId),
      ),
    );
  if (!row) {
    res.status(403).json({ error: "Forbidden" });
    return;
  }
  if (row.status === "unread") {
    await db
      .update(noteRecipientsTable)
      .set({ status: "read", readAt: new Date() })
      .where(eq(noteRecipientsTable.id, row.id));
    await emitToUser(row.senderUserId, "note_status_changed", {
      noteId: id.id,
      recipientUserId: userId,
      status: "read",
    });
  }
  res.json({ success: true });
});

// collaborative checklist toggle. The owner OR any active
// (non-archived) recipient can flip an item's `done` flag; the change
// is persisted to the live `notes.items` AND mirrored to every
// `note_recipients.items` snapshot so all collaborators see the same
// shared state. Other audience members get a `note_checklist_changed`
// socket push so their open popups / threads refetch.
router.post(
  "/notes/:id/checklist/:itemId/toggle",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const id = parseIdParam(req.params.id);
    if (!id.ok) {
      res.status(400).json({ error: id.error });
      return;
    }
    const itemId = req.params.itemId;
    if (typeof itemId !== "string" || itemId.length === 0 || itemId.length > 64) {
      res.status(400).json({ error: "Invalid itemId" });
      return;
    }
    if (!req.body || typeof req.body.done !== "boolean") {
      res.status(400).json({ error: "Invalid done" });
      return;
    }
    const done: boolean = req.body.done;

    // Wrap the read-modify-write + mirror in a single transaction with
    // a row lock on the live note so concurrent toggles from multiple
    // collaborators can't lose each other's updates. Without this, two
    // people ticking different items at the same time could each read
    // the same `items` array, modify their copy, and have the slower
    // writer overwrite the faster one.
    type ToggleOutcome =
      | {
          kind: "ok";
          updated: ChecklistItem[];
          ownerUserId: number;
          recipientIds: number[];
          folderId: number | null;
        }
      | { kind: "noop"; items: ChecklistItem[] }
      | { kind: "err"; status: number; error: string };
    const outcome: ToggleOutcome = await db.transaction(async (tx) => {
      const locked = await tx.execute(
        sql`SELECT * FROM ${notesTable} WHERE ${notesTable.id} = ${id.id} FOR UPDATE`,
      );
      const lockedRows = (locked as unknown as { rows: Array<{
        id: number; user_id: number; kind: string; items: unknown;
        folder_id: number | null;
      }> }).rows;
      const note = lockedRows[0];
      if (!note) return { kind: "err", status: 404, error: "Note not found" };
      if (note.kind !== "checklist")
        return { kind: "err", status: 400, error: "Not a checklist note" };

      const isOwner = note.user_id === userId;
      if (!isOwner) {
        const [recipientRow] = await tx
          .select()
          .from(noteRecipientsTable)
          .where(
            and(
              eq(noteRecipientsTable.noteId, id.id),
              eq(noteRecipientsTable.recipientUserId, userId),
            ),
          );
        const sentToMe = !!recipientRow && recipientRow.status !== "archived";
        // also accept folder editors on a shared folder. We
        // can't call resolveFolderEditAccess here (different db handle),
        // so re-implement the lookup against `tx` for transactional read
        // consistency.
        let folderEditor = false;
        const folderId = note.folder_id;
        if (!sentToMe && folderId != null) {
          const [share] = await tx
            .select({ permission: noteFolderSharesTable.permission })
            .from(noteFolderSharesTable)
            .where(
              and(
                eq(noteFolderSharesTable.folderId, folderId),
                eq(noteFolderSharesTable.recipientUserId, userId),
              ),
            );
          if (share && share.permission === "edit") folderEditor = true;
        }
        if (!sentToMe && !folderEditor)
          return { kind: "err", status: 403, error: "Forbidden" };
      }

      const items = (note.items ?? []) as ChecklistItem[];
      const idx = items.findIndex((it) => it.id === itemId);
      if (idx === -1) return { kind: "err", status: 404, error: "Item not found" };
      if (items[idx].done === done) return { kind: "noop", items };

      const updated = items.map((it, i) => (i === idx ? { ...it, done } : it));
      await tx
        .update(notesTable)
        .set({ items: updated })
        .where(eq(notesTable.id, id.id));
      // Mirror to every recipient snapshot inside the same transaction
      // so collaborative state stays consistent (recipients render from
      // their snapshot row, not the live note).
      await tx
        .update(noteRecipientsTable)
        .set({ items: updated })
        .where(eq(noteRecipientsTable.noteId, id.id));

      const recipientRows = await tx
        .select({ recipientUserId: noteRecipientsTable.recipientUserId })
        .from(noteRecipientsTable)
        .where(eq(noteRecipientsTable.noteId, id.id));
      return {
        kind: "ok",
        updated,
        ownerUserId: note.user_id,
        recipientIds: recipientRows.map((r) => r.recipientUserId),
        folderId: note.folder_id,
      };
    });

    if (outcome.kind === "err") {
      res.status(outcome.status).json({ error: outcome.error });
      return;
    }
    if (outcome.kind === "noop") {
      // Idempotent no-op — still return current items so the client can
      // reconcile if its optimistic state was stale.
      res.json({ success: true, items: outcome.items });
      return;
    }
    const updated = outcome.updated;

    const audience = new Set<number>([
      outcome.ownerUserId,
      ...outcome.recipientIds,
    ]);
    audience.delete(userId);
    for (const uid of audience) {
      await emitToUser(uid, "note_checklist_changed", {
        noteId: id.id,
        itemId,
        done,
        items: updated,
        actorUserId: userId,
      });
    }
    // also notify shared-folder viewers (owner + every other
    // editor / viewer of the folder) so the shared-folder note list
    // refetches and shows the toggled item — the per-recipient
    // `note_checklist_changed` audience above only covers users in the
    // note's send-recipients table, which is disjoint from folder-share
    // recipients.
    if (outcome.folderId != null) {
      void emitFolderChanged(outcome.folderId, userId);
    }

    res.json({ success: true, items: updated });
  },
);

router.post("/notes/:id/archive", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const archived = req.body && req.body.archived !== false;
  const [row] = await db
    .select()
    .from(noteRecipientsTable)
    .where(
      and(
        eq(noteRecipientsTable.noteId, id.id),
        eq(noteRecipientsTable.recipientUserId, userId),
      ),
    );
  if (!row) {
    res.status(403).json({ error: "Forbidden" });
    return;
  }
  await db
    .update(noteRecipientsTable)
    .set({
      status: archived ? "archived" : (row.readAt ? "read" : "unread"),
      archivedAt: archived ? new Date() : null,
    })
    .where(eq(noteRecipientsTable.id, row.id));
  res.json({ success: true });
});

// ----- Reply -----

router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const content = typeof req.body?.content === "string" ? req.body.content.trim() : "";
  if (content.length === 0 || content.length > 5000) {
    res.status(400).json({ error: "Invalid content" });
    return;
  }
  const [liveNote] = await db
    .select()
    .from(notesTable)
    .where(eq(notesTable.id, id.id));
  const [callerRecipientRow] = await db
    .select()
    .from(noteRecipientsTable)
    .where(
      and(
        eq(noteRecipientsTable.noteId, id.id),
        eq(noteRecipientsTable.recipientUserId, userId),
      ),
    );
  const isOwner = !!liveNote && liveNote.userId === userId;
  if (!isOwner && !callerRecipientRow) {
    res.status(403).json({ error: "Forbidden" });
    return;
  }

  let ownerUserId: number;
  let otherPartyUserId: number;
  let targetRow: typeof callerRecipientRow | null = null;
  if (isOwner) {
    ownerUserId = userId;
    const requestedTarget =
      typeof req.body?.recipientUserId === "number"
        ? req.body.recipientUserId
        : null;
    const allRows = await db
      .select()
      .from(noteRecipientsTable)
      .where(eq(noteRecipientsTable.noteId, id.id));
    if (requestedTarget !== null) {
      targetRow = allRows.find((r) => r.recipientUserId === requestedTarget) ?? null;
    } else if (allRows.length === 1) {
      targetRow = allRows[0];
    }
    if (!targetRow) {
      res.status(400).json({ error: "recipientUserId required" });
      return;
    }
    otherPartyUserId = targetRow.recipientUserId;
  } else {
    targetRow = callerRecipientRow!;
    ownerUserId = callerRecipientRow!.senderUserId;
    otherPartyUserId = ownerUserId;
  }

  const [reply] = await db
    .insert(noteRepliesTable)
    .values({
      noteId: id.id,
      senderUserId: userId,
      recipientUserId: otherPartyUserId,
      content,
    })
    .returning();

  // Recipient replies bump status to "replied" + un-archive. Owner replies
  // leave recipient status untouched.
  if (!isOwner) {
    await db
      .update(noteRecipientsTable)
      .set({
        status: "replied",
        readAt: targetRow.readAt ?? new Date(),
        archivedAt: null,
      })
      .where(eq(noteRecipientsTable.id, targetRow.id));
  }

  const senderMap = await loadUserSummaries([userId]);
  const replier = senderMap.get(userId);
  const replierName = replier?.displayNameEn ?? replier?.username ?? "Someone";
  const replierNameAr = replier?.displayNameAr ?? replierName;
  const [notif] = await db
    .insert(notificationsTable)
    .values({
      userId: otherPartyUserId,
      titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
      titleEn: isOwner ? "New reply on a note" : "Reply to your note",
      bodyAr: `${replierNameAr} رد على ملاحظة`,
      bodyEn: `${replierName} replied on a note`,
      type: "note",
      relatedType: "note",
      relatedId: id.id,
    })
    .returning();
  await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
  void sendPushToUser(otherPartyUserId, {
    title: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
    body: `${replierNameAr} رد على ملاحظة`,
    titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
    titleEn: isOwner ? "New reply on a note" : "Reply to your note",
    bodyAr: `${replierNameAr} رد على ملاحظة`,
    bodyEn: `${replierName} replied on a note`,
    type: "note",
    relatedId: id.id,
    tag: `note-reply-${reply.id}`,
    url: `/notes?thread=${id.id}`,
  });
  // Enrich the realtime payload so the recipient's client can render the
  // floating reply card without an extra round-trip. We truncate the body
  // to keep the socket frame small; the full reply is still in /notes.
  const replyContentSnippet = (reply.content ?? "").slice(0, 280);
  await emitToUser(otherPartyUserId, "note_replied", {
    noteId: id.id,
    recipientUserId: isOwner ? otherPartyUserId : userId,
    replyId: reply.id,
    replyContent: replyContentSnippet,
    replier: replier ?? null,
    noteTitle: liveNote?.title ?? null,
    color: liveNote?.color ?? "default",
  });

  res.status(201).json({
    id: reply.id,
    noteId: reply.noteId,
    senderUserId: reply.senderUserId,
    recipientUserId: reply.recipientUserId,
    content: reply.content,
    createdAt: reply.createdAt,
    sender: replier ?? null,
  });
});

// ----- Labels (unchanged) -----

router.get("/note-labels", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const labels = await db
    .select()
    .from(noteLabelsTable)
    .where(eq(noteLabelsTable.userId, userId))
    .orderBy(noteLabelsTable.name);
  res.json(labels);
});

router.post("/note-labels", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const parsed = parseLabelName(req.body);
  if (!parsed.ok) {
    res.status(400).json({ error: parsed.error });
    return;
  }
  try {
    const [label] = await db
      .insert(noteLabelsTable)
      .values({ userId, name: parsed.name })
      .returning();
    res.status(201).json(label);
  } catch (e) {
    res.status(409).json({ error: "Label already exists" });
  }
});

router.patch("/note-labels/:id", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const parsed = parseLabelName(req.body);
  if (!parsed.ok) {
    res.status(400).json({ error: parsed.error });
    return;
  }
  try {
    const [label] = await db
      .update(noteLabelsTable)
      .set({ name: parsed.name })
      .where(and(eq(noteLabelsTable.id, id.id), eq(noteLabelsTable.userId, userId)))
      .returning();
    if (!label) {
      res.status(404).json({ error: "Label not found" });
      return;
    }
    res.json(label);
  } catch (e) {
    res.status(409).json({ error: "Label already exists" });
  }
});

router.delete("/note-labels/:id", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const result = await db
    .delete(noteLabelsTable)
    .where(and(eq(noteLabelsTable.id, id.id), eq(noteLabelsTable.userId, userId)))
    .returning();
  if (result.length === 0) {
    res.status(404).json({ error: "Label not found" });
    return;
  }
  res.json({ success: true });
});

// ----- Folders -----

router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  // Counts are scoped to the current tab (active vs archived) so the rail
  // matches the visible note set. `archived` query param: "true" → archived
  // notes only, otherwise active notes only.
  const archived = req.query.archived === "true";
  const noteScope = and(
    eq(notesTable.userId, userId),
    eq(notesTable.isArchived, archived),
  );
  const [folders, counts, shareCounts] = await Promise.all([
    db
      .select()
      .from(noteFoldersTable)
      .where(eq(noteFoldersTable.userId, userId))
      .orderBy(noteFoldersTable.name),
    db
      .select({
        folderId: notesTable.folderId,
        count: sql<number>`count(*)::int`,
      })
      .from(notesTable)
      .where(noteScope)
      .groupBy(notesTable.folderId),
    db
      .select({
        folderId: noteFolderSharesTable.folderId,
        count: sql<number>`count(*)::int`,
      })
      .from(noteFolderSharesTable)
      .innerJoin(
        noteFoldersTable,
        eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
      )
      .where(eq(noteFoldersTable.userId, userId))
      .groupBy(noteFolderSharesTable.folderId),
  ]);
  const countMap = new Map<number, number>();
  for (const row of counts) {
    if (row.folderId != null) countMap.set(row.folderId, Number(row.count));
  }
  const shareCountMap = new Map<number, number>();
  for (const row of shareCounts) {
    shareCountMap.set(row.folderId, Number(row.count));
  }
  res.json(
    folders.map((f) => ({
      ...f,
      noteCount: countMap.get(f.id) ?? 0,
      sharedWithCount: shareCountMap.get(f.id) ?? 0,
    })),
  );
});

router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const parsed = parseFolderName(req.body);
  if (!parsed.ok) {
    res.status(400).json({ error: parsed.error });
    return;
  }
  try {
    const [folder] = await db
      .insert(noteFoldersTable)
      .values({ userId, name: parsed.name })
      .returning();
    res.status(201).json({ ...folder, noteCount: 0, sharedWithCount: 0 });
  } catch (e) {
    if (isUniqueViolation(e)) {
      res.status(409).json({ error: "Folder already exists" });
      return;
    }
    throw e;
  }
});

router.patch("/note-folders/:id", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const parsed = parseFolderName(req.body);
  if (!parsed.ok) {
    res.status(400).json({ error: parsed.error });
    return;
  }
  // Mirror GET /note-folders: count is scoped to the current tab the caller is
  // viewing so the rail badge stays in sync after a rename.
  const archived = req.query.archived === "true";
  try {
    const [folder] = await db
      .update(noteFoldersTable)
      .set({ name: parsed.name })
      .where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId)))
      .returning();
    if (!folder) {
      res.status(404).json({ error: "Folder not found" });
      return;
    }
    const [countRow] = await db
      .select({ count: sql<number>`count(*)::int` })
      .from(notesTable)
      .where(
        and(
          eq(notesTable.userId, userId),
          eq(notesTable.isArchived, archived),
          eq(notesTable.folderId, folder.id),
        ),
      );
    const [shareRow] = await db
      .select({ count: sql<number>`count(*)::int` })
      .from(noteFolderSharesTable)
      .where(eq(noteFolderSharesTable.folderId, folder.id));
    res.json({
      ...folder,
      noteCount: Number(countRow?.count ?? 0),
      sharedWithCount: Number(shareRow?.count ?? 0),
    });
  } catch (e) {
    if (isUniqueViolation(e)) {
      res.status(409).json({ error: "Folder already exists" });
      return;
    }
    throw e;
  }
});

router.delete("/note-folders/:id", requireAuth, async (req, res): Promise<void> => {
  const userId = req.session.userId!;
  const id = parseIdParam(req.params.id);
  if (!id.ok) {
    res.status(400).json({ error: id.error });
    return;
  }
  const result = await db
    .delete(noteFoldersTable)
    .where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId)))
    .returning();
  if (result.length === 0) {
    res.status(404).json({ error: "Folder not found" });
    return;
  }
  res.json({ success: true });
});

// ---------- Folder-level live sharing ----------
//
// A folder owner can share a folder read-only with other users. Recipients see
// the owner's CURRENT notes inside the folder (live, not snapshot). They cannot
// edit, add, move, or delete anything; the per-note write routes
// (`PATCH/DELETE /notes/:id`, archive, checklist toggle for non-recipients)
// look up the row by id and explicitly return 403 when the caller is not
// the owner, so a shared-folder recipient that probes a write endpoint
// gets a clear authorization error instead of an ambiguous 404.

router.get(
  "/note-folders/shared-with-me",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const rows = await db
      .select({
        id: noteFoldersTable.id,
        name: noteFoldersTable.name,
        ownerId: noteFoldersTable.userId,
        sharedAt: noteFolderSharesTable.sharedAt,
        myPermission: noteFolderSharesTable.permission,
        ownerUsername: usersTable.username,
        ownerDisplayNameAr: usersTable.displayNameAr,
        ownerDisplayNameEn: usersTable.displayNameEn,
        // Live count of the owner's non-archived notes in this folder so
        // the recipient's rail can show "Folder name (12)" without a
        // second roundtrip per row.
        noteCount: sql<number>`(
          SELECT COUNT(*)::int FROM ${notesTable}
          WHERE ${notesTable.folderId} = ${noteFoldersTable.id}
            AND ${notesTable.userId} = ${noteFoldersTable.userId}
            AND ${notesTable.isArchived} = false
        )`.as("note_count"),
      })
      .from(noteFolderSharesTable)
      .innerJoin(
        noteFoldersTable,
        eq(noteFolderSharesTable.folderId, noteFoldersTable.id),
      )
      .innerJoin(usersTable, eq(noteFoldersTable.userId, usersTable.id))
      .where(eq(noteFolderSharesTable.recipientUserId, userId))
      .orderBy(noteFoldersTable.name);
    res.json(rows);
  },
);

router.get(
  "/note-folders/:id/shares",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const id = parseIdParam(req.params.id);
    if (!id.ok) {
      res.status(400).json({ error: id.error });
      return;
    }
    if (!(await userOwnsFolder(userId, id.id))) {
      res.status(404).json({ error: "Folder not found" });
      return;
    }
    const rows = await db
      .select({
        userId: usersTable.id,
        username: usersTable.username,
        displayNameAr: usersTable.displayNameAr,
        displayNameEn: usersTable.displayNameEn,
        sharedAt: noteFolderSharesTable.sharedAt,
        permission: noteFolderSharesTable.permission,
      })
      .from(noteFolderSharesTable)
      .innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
      .where(eq(noteFolderSharesTable.folderId, id.id))
      .orderBy(noteFolderSharesTable.sharedAt);
    res.json(rows);
  },
);

router.put(
  "/note-folders/:id/shares",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const id = parseIdParam(req.params.id);
    if (!id.ok) {
      res.status(400).json({ error: id.error });
      return;
    }
    if (!(await userOwnsFolder(userId, id.id))) {
      res.status(404).json({ error: "Folder not found" });
      return;
    }
    const body = req.body;
    // accept BOTH the legacy `recipientUserIds: number[]`
    // shape (treated as permission="view") AND the new
    // `recipients: [{ userId, permission }]` shape so older clients keep
    // working. Internally we collapse to a single Map<userId, permission>.
    const desired = new Map<number, "view" | "edit">();
    const ingest = (rawId: unknown, rawPerm: unknown): string | null => {
      const n = Number(rawId);
      if (!Number.isInteger(n) || n <= 0) return "Invalid recipient id";
      if (n === userId) return "Cannot share folder with yourself";
      let perm: "view" | "edit" = "view";
      if (rawPerm !== undefined) {
        if (rawPerm !== "view" && rawPerm !== "edit") return "Invalid permission";
        perm = rawPerm;
      }
      desired.set(n, perm);
      return null;
    };
    if (!body || typeof body !== "object") {
      res.status(400).json({ error: "Invalid body" });
      return;
    }
    if (Array.isArray(body.recipients)) {
      for (const r of body.recipients) {
        if (!r || typeof r !== "object") {
          res.status(400).json({ error: "Invalid recipients" });
          return;
        }
        const err = ingest((r as any).userId, (r as any).permission);
        if (err) {
          res.status(400).json({ error: err });
          return;
        }
      }
    } else if (Array.isArray(body.recipientUserIds)) {
      for (const x of body.recipientUserIds) {
        const err = ingest(x, undefined);
        if (err) {
          res.status(400).json({ error: err });
          return;
        }
      }
    } else {
      res.status(400).json({ error: "Invalid recipients" });
      return;
    }
    // Drop any desired ids that don't actually exist as users (silently
    // skip — same behaviour as before for unknown ids in the legacy path).
    if (desired.size > 0) {
      const valid = await db
        .select({ id: usersTable.id })
        .from(usersTable)
        .where(inArray(usersTable.id, Array.from(desired.keys())));
      const validIds = new Set(valid.map((r) => r.id));
      for (const uid of Array.from(desired.keys())) {
        if (!validIds.has(uid)) desired.delete(uid);
      }
    }

    // Diff current vs desired and apply incrementally so repeated PUTs are
    // idempotent and we only emit notifications for genuinely new
    // recipients OR permission changes.
    const current = await db
      .select({
        recipientUserId: noteFolderSharesTable.recipientUserId,
        permission: noteFolderSharesTable.permission,
      })
      .from(noteFolderSharesTable)
      .where(eq(noteFolderSharesTable.folderId, id.id));
    const currentMap = new Map(
      current.map((r) => [r.recipientUserId, r.permission as "view" | "edit"]),
    );

    const toAdd: Array<{ userId: number; permission: "view" | "edit" }> = [];
    const toRemove: number[] = [];
    const toUpdate: Array<{ userId: number; permission: "view" | "edit" }> = [];
    for (const [uid, perm] of desired) {
      const cur = currentMap.get(uid);
      if (cur === undefined) toAdd.push({ userId: uid, permission: perm });
      else if (cur !== perm) toUpdate.push({ userId: uid, permission: perm });
    }
    for (const uid of currentMap.keys()) if (!desired.has(uid)) toRemove.push(uid);

    if (toAdd.length > 0) {
      await db
        .insert(noteFolderSharesTable)
        .values(
          toAdd.map((a) => ({
            folderId: id.id,
            recipientUserId: a.userId,
            permission: a.permission,
          })),
        )
        .onConflictDoNothing();
    }
    if (toRemove.length > 0) {
      await db
        .delete(noteFolderSharesTable)
        .where(
          and(
            eq(noteFolderSharesTable.folderId, id.id),
            inArray(noteFolderSharesTable.recipientUserId, toRemove),
          ),
        );
    }
    for (const u of toUpdate) {
      await db
        .update(noteFolderSharesTable)
        .set({ permission: u.permission })
        .where(
          and(
            eq(noteFolderSharesTable.folderId, id.id),
            eq(noteFolderSharesTable.recipientUserId, u.userId),
          ),
        );
    }

    // Emit live socket events so recipients' rails refresh without polling.
    // Three distinct events so the client can tell apart "you got access",
    // "your permission level changed", and "your access was revoked":
    //   - note-folder-shared        → newly added recipient
    //   - note-folder-share-updated → existing recipient's permission flipped
    //   - note-folder-unshared      → recipient removed
    for (const a of toAdd) {
      void emitToUser(a.userId, "note-folder-shared", {
        folderId: id.id,
        permission: a.permission,
      });
    }
    for (const u of toUpdate) {
      void emitToUser(u.userId, "note-folder-share-updated", {
        folderId: id.id,
        permission: u.permission,
      });
    }
    for (const rid of toRemove) {
      void emitToUser(rid, "note-folder-unshared", { folderId: id.id });
    }

    const rows = await db
      .select({
        userId: usersTable.id,
        username: usersTable.username,
        displayNameAr: usersTable.displayNameAr,
        displayNameEn: usersTable.displayNameEn,
        sharedAt: noteFolderSharesTable.sharedAt,
        permission: noteFolderSharesTable.permission,
      })
      .from(noteFolderSharesTable)
      .innerJoin(usersTable, eq(noteFolderSharesTable.recipientUserId, usersTable.id))
      .where(eq(noteFolderSharesTable.folderId, id.id))
      .orderBy(noteFolderSharesTable.sharedAt);
    res.json(rows);
  },
);

router.delete(
  "/note-folders/:id/shares/:userId",
  requireAuth,
  async (req, res): Promise<void> => {
    const ownerId = req.session.userId!;
    const id = parseIdParam(req.params.id);
    const target = parseIdParam(req.params.userId);
    if (!id.ok || !target.ok) {
      res.status(400).json({ error: "Invalid id" });
      return;
    }
    if (!(await userOwnsFolder(ownerId, id.id))) {
      res.status(404).json({ error: "Folder not found" });
      return;
    }
    await db
      .delete(noteFolderSharesTable)
      .where(
        and(
          eq(noteFolderSharesTable.folderId, id.id),
          eq(noteFolderSharesTable.recipientUserId, target.id),
        ),
      );
    void emitToUser(target.id, "note-folder-unshared", { folderId: id.id });
    res.json({ success: true });
  },
);

/**
 * GET /note-folders/:id/shared-notes
 *
 * Recipient view of a shared folder. Returns the OWNER's current notes inside
 * the folder (live join — not a snapshot), with `readOnly: true` so the client
 * knows to disable every write affordance. 403 if the caller is not a current
 * recipient of the share.
 */
router.get(
  "/note-folders/:id/shared-notes",
  requireAuth,
  async (req, res): Promise<void> => {
    const userId = req.session.userId!;
    const id = parseIdParam(req.params.id);
    if (!id.ok) {
      res.status(400).json({ error: id.error });
      return;
    }
    const [share] = await db
      .select({
        folderId: noteFolderSharesTable.folderId,
        permission: noteFolderSharesTable.permission,
      })
      .from(noteFolderSharesTable)
      .where(
        and(
          eq(noteFolderSharesTable.folderId, id.id),
          eq(noteFolderSharesTable.recipientUserId, userId),
        ),
      );
    if (!share) {
      res.status(403).json({ error: "Forbidden" });
      return;
    }
    const myPermission = share.permission === "edit" ? "edit" : "view";
    const readOnly = myPermission !== "edit";
    const [folder] = await db
      .select()
      .from(noteFoldersTable)
      .where(eq(noteFoldersTable.id, id.id));
    if (!folder) {
      res.status(404).json({ error: "Folder not found" });
      return;
    }
    const notes = await db
      .select()
      .from(notesTable)
      .where(
        and(
          eq(notesTable.userId, folder.userId),
          eq(notesTable.folderId, folder.id),
          eq(notesTable.isArchived, false),
        ),
      )
      .orderBy(
        desc(notesTable.isPinned),
        asc(notesTable.sortOrder),
        desc(notesTable.updatedAt),
      );

    let withLabels: Array<(typeof notes)[number] & { labelIds: number[] }> = [];
    if (notes.length > 0) {
      const ids = notes.map((n) => n.id);
      const assignments = await db
        .select()
        .from(noteLabelAssignmentsTable)
        .where(inArray(noteLabelAssignmentsTable.noteId, ids));
      const map = new Map<number, number[]>();
      for (const a of assignments) {
        const arr = map.get(a.noteId) ?? [];
        arr.push(a.labelId);
        map.set(a.noteId, arr);
      }
      withLabels = notes.map((n) => ({ ...n, labelIds: map.get(n.id) ?? [] }));
    }

    const [owner] = await db
      .select({
        id: usersTable.id,
        username: usersTable.username,
        displayNameAr: usersTable.displayNameAr,
        displayNameEn: usersTable.displayNameEn,
      })
      .from(usersTable)
      .where(eq(usersTable.id, folder.userId));

    res.json({
      folder: { id: folder.id, name: folder.name, ownerId: folder.userId },
      owner: owner ?? null,
      myPermission,
      notes: withLabels.map((n) => ({ ...n, readOnly })),
    });
  },
);

export default router;
