import crypto from "crypto";
import { Router } from "express";
import { z } from "zod";
import { ensureOrganizationMembership, isSystemAdmin, requireBoardAccess, requireWorkspaceMembership } from "../lib/authz";
import { BOARD_TEMPLATES, DEFAULT_STATUSES } from "../lib/boardTemplates";
import { inviteByEmail } from "../lib/invitations";
import { cleanupAfterBoardRemoval } from "../lib/membershipCleanup";
import { notifyUser } from "../lib/notifications";
import { prisma } from "../lib/prisma";
import { emitToBoard } from "../lib/socketBus";
import { uploadBoardBackground } from "../lib/upload";
import { HttpError } from "../middleware/errorHandler";
import { requireAuth } from "../middleware/requireAuth";

export const boardsRouter = Router();
boardsRouter.use(requireAuth);

boardsRouter.get("/templates", (_req, res) => {
  res.json({ templates: Object.values(BOARD_TEMPLATES) });
});

const createBoardSchema = z.object({
  workspaceId: z.string().min(1),
  name: z.string().min(1).max(100),
  backgroundType: z.enum(["COLOR", "GRADIENT", "STOCK_IMAGE", "UPLOADED_IMAGE"]).optional(),
  backgroundValue: z.string().optional(),
  visibility: z.enum(["PRIVATE", "WORKSPACE", "PUBLIC"]).optional(),
  templateId: z.string().optional(),
});

boardsRouter.post("/", async (req, res, next) => {
  try {
    const data = createBoardSchema.parse(req.body);
    await requireWorkspaceMembership(req.userId!, data.workspaceId);

    const template = BOARD_TEMPLATES[data.templateId ?? "blank"] ?? BOARD_TEMPLATES.blank;

    const board = await prisma.board.create({
      data: {
        workspaceId: data.workspaceId,
        name: data.name,
        backgroundType: data.backgroundType ?? "GRADIENT",
        backgroundValue: data.backgroundValue ?? "gradient-sunset",
        visibility: data.visibility ?? "WORKSPACE",
        members: { create: { userId: req.userId!, role: "ADMIN" } },
        statusOptions: {
          create: DEFAULT_STATUSES.map((s, i) => ({ ...s, position: (i + 1) * 1000 })),
        },
        lists: {
          create: template.lists.map((name, i) => ({ name, position: (i + 1) * 1000 })),
        },
        labels: { create: template.labels },
      },
      include: { lists: true, statusOptions: true },
    });

    res.status(201).json({ board });
  } catch (err) {
    next(err);
  }
});

boardsRouter.get("/:id/archived", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id);

    const [cards, lists] = await Promise.all([
      prisma.card.findMany({
        where: { boardId: req.params.id, isArchived: true },
        orderBy: { updatedAt: "desc" },
        include: {
          list: { select: { id: true, name: true } },
          labels: { include: { label: true } },
          members: { include: { user: true } },
        },
      }),
      prisma.list.findMany({
        where: { boardId: req.params.id, isArchived: true },
        orderBy: { updatedAt: "desc" },
        include: { _count: { select: { cards: true } } },
      }),
    ]);

    res.json({ cards, lists });
  } catch (err) {
    next(err);
  }
});

boardsRouter.get("/:id", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id);

    const board = await prisma.board.findUnique({
      where: { id: req.params.id },
      include: {
        lists: {
          where: { isArchived: false },
          orderBy: { position: "asc" },
          include: {
            cards: {
              where: { isArchived: false },
              orderBy: { position: "asc" },
              include: {
                labels: { include: { label: true } },
                members: { include: { user: true } },
                checklists: { include: { items: true } },
                status: true,
                _count: { select: { comments: true, attachments: true } },
              },
            },
          },
        },
        labels: true,
        statusOptions: { orderBy: { position: "asc" } },
        customFields: { orderBy: { position: "asc" } },
        members: { include: { user: true } },
        workspace: { select: { id: true, name: true, organizationId: true } },
      },
    });

    if (!board) throw new HttpError(404, "Board not found");

    // "Effective" members: everyone who can actually access the board, not just people
    // with an explicit BoardMember row — workspace admins (super admins) always have
    // access, and workspace members implicitly have access to WORKSPACE-visibility
    // boards. Used for @mentions, card/checklist assignment, etc. so those aren't
    // limited to whoever was explicitly added to this specific board.
    const workspaceMembers = await prisma.workspaceMember.findMany({
      where: { workspaceId: board.workspaceId },
      include: { user: true },
    });

    const effectiveMembers = [...board.members];
    const seen = new Set(effectiveMembers.map((m) => m.userId));
    for (const wm of workspaceMembers) {
      if (seen.has(wm.userId)) continue;
      if (wm.role === "ADMIN" || board.visibility === "WORKSPACE") {
        effectiveMembers.push({
          id: `ws-${wm.id}`,
          boardId: board.id,
          userId: wm.userId,
          role: wm.role === "ADMIN" ? "ADMIN" : "MEMBER",
          joinedAt: wm.joinedAt,
          isPortalRequester: false,
          user: wm.user,
        });
        seen.add(wm.userId);
      }
    }

    // Portal requesters (people who submitted a ticket through the public request
    // portal) only see the cards they themselves submitted — unless they separately
    // have real access (workspace admin, system admin, or an upgraded board role).
    const ownMembership = board.members.find((m) => m.userId === req.userId);
    const isWorkspaceAdmin = workspaceMembers.some(
      (wm) => wm.userId === req.userId && wm.role === "ADMIN"
    );
    const viewerIsPortalRequester =
      !!ownMembership?.isPortalRequester &&
      ownMembership.role === "VIEWER" &&
      !isWorkspaceAdmin &&
      !(await isSystemAdmin(req.userId!));

    const responseBoard = { ...board, effectiveMembers, viewerIsPortalRequester };
    if (viewerIsPortalRequester) {
      responseBoard.lists = board.lists.map((list) => ({
        ...list,
        cards: list.cards.filter((card) => card.submitterId === req.userId),
      }));
    }

    res.json({ board: responseBoard });
  } catch (err) {
    next(err);
  }
});

const updateBoardSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  description: z.string().max(1000).nullable().optional(),
  visibility: z.enum(["PRIVATE", "WORKSPACE", "PUBLIC"]).optional(),
  backgroundType: z.enum(["COLOR", "GRADIENT", "STOCK_IMAGE", "UPLOADED_IMAGE"]).optional(),
  backgroundValue: z.string().optional(),
  backgroundOverlay: z.number().min(0).max(1).optional(),
  isArchived: z.boolean().optional(),
});

boardsRouter.patch("/:id", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    const data = updateBoardSchema.parse(req.body);
    const board = await prisma.board.update({ where: { id: req.params.id }, data });
    emitToBoard(req.params.id, "board:changed", {});
    res.json({ board });
  } catch (err) {
    next(err);
  }
});

boardsRouter.delete("/:id", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    await prisma.board.delete({ where: { id: req.params.id } });
    res.status(204).send();
  } catch (err) {
    next(err);
  }
});

boardsRouter.post(
  "/:id/background",
  uploadBoardBackground.single("image"),
  async (req, res, next) => {
    try {
      await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
      if (!req.file) throw new HttpError(400, "No file uploaded");

      const backgroundValue = `/uploads/board-backgrounds/${req.file.filename}`;
      const board = await prisma.board.update({
        where: { id: req.params.id },
        data: { backgroundType: "UPLOADED_IMAGE", backgroundValue },
      });
      emitToBoard(req.params.id, "board:changed", {});
      res.json({ board });
    } catch (err) {
      next(err);
    }
  }
);

const inviteBoardMemberSchema = z.object({
  email: z.string().email(),
  role: z.enum(["ADMIN", "MEMBER", "VIEWER"]).default("MEMBER"),
});

boardsRouter.post("/:id/members", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    const data = inviteBoardMemberSchema.parse(req.body);

    const board = await prisma.board.findUnique({ where: { id: req.params.id } });
    if (!board) throw new HttpError(404, "Board not found");
    const actor = await prisma.user.findUnique({ where: { id: req.userId! } });

    const user = await prisma.user.findUnique({ where: { email: data.email } });
    if (!user) {
      const workspace = await prisma.workspace.findUnique({ where: { id: board.workspaceId } });
      await inviteByEmail({
        email: data.email,
        workspaceId: board.workspaceId,
        boardId: board.id,
        role: data.role,
        invitedById: req.userId!,
        workspaceName: workspace?.name ?? "",
        boardName: board.name,
        inviterName: actor?.name ?? "Someone",
      });
      res.status(202).json({
        pending: true,
        message: "This person doesn't have an account yet — we've emailed them an invite to join.",
      });
      return;
    }

    const member = await prisma.boardMember.upsert({
      where: { boardId_userId: { boardId: req.params.id, userId: user.id } },
      update: { role: data.role },
      create: { boardId: req.params.id, userId: user.id, role: data.role },
      include: { user: true },
    });

    const boardWorkspace = await prisma.workspace.findUnique({
      where: { id: board.workspaceId },
      select: { organizationId: true },
    });
    if (boardWorkspace) {
      await ensureOrganizationMembership(user.id, boardWorkspace.organizationId);
    }

    await prisma.workspaceMember.upsert({
      where: { workspaceId_userId: { workspaceId: board.workspaceId, userId: user.id } },
      update: {},
      create: { workspaceId: board.workspaceId, userId: user.id, role: "MEMBER" },
    });

    await notifyUser({
      recipientId: user.id,
      actorId: req.userId!,
      type: "BOARD_INVITE",
      message: `${actor?.name ?? "Someone"} added you to the board "${board.name}"`,
      boardId: req.params.id,
    });

    emitToBoard(req.params.id, "board:changed", {});
    res.status(201).json({ member });
  } catch (err) {
    next(err);
  }
});

boardsRouter.delete("/:id/members/:userId", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    await prisma.boardMember.delete({
      where: { boardId_userId: { boardId: req.params.id, userId: req.params.userId } },
    });
    await cleanupAfterBoardRemoval(req.params.userId, req.params.id);
    emitToBoard(req.params.id, "board:changed", {});
    res.status(204).send();
  } catch (err) {
    next(err);
  }
});

const setMemberRoleSchema = z.object({
  role: z.enum(["ADMIN", "MEMBER", "VIEWER", "NONE"]),
});

boardsRouter.put("/:id/members/:userId", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    const { role } = setMemberRoleSchema.parse(req.body);

    if (role === "NONE") {
      await prisma.boardMember.deleteMany({
        where: { boardId: req.params.id, userId: req.params.userId },
      });
      await cleanupAfterBoardRemoval(req.params.userId, req.params.id);
    } else {
      await prisma.boardMember.upsert({
        where: { boardId_userId: { boardId: req.params.id, userId: req.params.userId } },
        update: { role },
        create: { boardId: req.params.id, userId: req.params.userId, role },
      });
    }

    emitToBoard(req.params.id, "board:changed", {});
    res.status(200).json({ role });
  } catch (err) {
    next(err);
  }
});

// ---------- Saved view (per-user, per-board) ----------

boardsRouter.get("/:id/view", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "VIEWER");
    const saved = await prisma.savedView.findUnique({
      where: { boardId_userId: { boardId: req.params.id, userId: req.userId! } },
    });
    res.json({ viewType: saved?.viewType ?? "kanban" });
  } catch (err) {
    next(err);
  }
});

const setViewSchema = z.object({
  viewType: z.enum(["kanban", "table", "calendar", "timeline"]),
});

boardsRouter.put("/:id/view", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "VIEWER");
    const { viewType } = setViewSchema.parse(req.body);

    await prisma.savedView.upsert({
      where: { boardId_userId: { boardId: req.params.id, userId: req.userId! } },
      update: { viewType },
      create: { boardId: req.params.id, userId: req.userId!, viewType },
    });

    res.json({ viewType });
  } catch (err) {
    next(err);
  }
});

// ---------- Labels ----------

const labelSchema = z.object({
  name: z.string().max(50).default(""),
  color: z.string().min(1),
});

boardsRouter.post("/:id/labels", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "MEMBER");
    const data = labelSchema.parse(req.body);
    const label = await prisma.label.create({
      data: { boardId: req.params.id, name: data.name, color: data.color },
    });
    emitToBoard(req.params.id, "board:changed", {});
    res.status(201).json({ label });
  } catch (err) {
    next(err);
  }
});

// ---------- Status options ----------

const statusOptionSchema = z.object({
  label: z.string().min(1).max(50),
  color: z.string().min(1),
});

boardsRouter.post("/:id/statuses", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "MEMBER");
    const data = statusOptionSchema.parse(req.body);
    const last = await prisma.statusOption.findFirst({
      where: { boardId: req.params.id },
      orderBy: { position: "desc" },
    });
    const status = await prisma.statusOption.create({
      data: { boardId: req.params.id, ...data, position: (last?.position ?? 0) + 1000 },
    });
    emitToBoard(req.params.id, "board:changed", {});
    res.status(201).json({ status });
  } catch (err) {
    next(err);
  }
});

// ---------- Custom fields ----------

const customFieldSchema = z.object({
  name: z.string().min(1).max(50),
  type: z.enum([
    "TEXT",
    "NUMBER",
    "STATUS",
    "PERSON",
    "DATE",
    "CHECKBOX",
    "DROPDOWN",
    "LINK",
    "FILE",
    "RATING",
  ]),
  isRequired: z.boolean().optional(),
  defaultValue: z.string().optional(),
  options: z.array(z.object({ label: z.string(), color: z.string() })).optional(),
});

boardsRouter.post("/:id/custom-fields", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    const data = customFieldSchema.parse(req.body);
    const last = await prisma.customField.findFirst({
      where: { boardId: req.params.id },
      orderBy: { position: "desc" },
    });
    const field = await prisma.customField.create({
      data: {
        boardId: req.params.id,
        name: data.name,
        type: data.type,
        isRequired: data.isRequired ?? false,
        defaultValue: data.defaultValue,
        options: data.options,
        position: (last?.position ?? 0) + 1000,
      },
    });
    emitToBoard(req.params.id, "board:changed", {});
    res.status(201).json({ field });
  } catch (err) {
    next(err);
  }
});

// ---------- Request Portal (admin configuration) ----------

const portalConfigInclude = {
  portalCategories: {
    orderBy: { position: "asc" as const },
    include: {
      requestTypes: {
        orderBy: { position: "asc" as const },
        include: { fields: { orderBy: { position: "asc" as const } } },
      },
    },
  },
};

function serializePortal(board: {
  portalEnabled: boolean;
  portalSlug: string | null;
  portalTitle: string | null;
  portalWelcomeMessage: string | null;
  portalIntakeListId: string | null;
  portalCategories: unknown;
}) {
  return {
    enabled: board.portalEnabled,
    slug: board.portalSlug,
    title: board.portalTitle,
    welcomeMessage: board.portalWelcomeMessage,
    intakeListId: board.portalIntakeListId,
    categories: board.portalCategories,
  };
}

boardsRouter.get("/:id/portal", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    const board = await prisma.board.findUnique({
      where: { id: req.params.id },
      include: portalConfigInclude,
    });
    if (!board) throw new HttpError(404, "Board not found");
    res.json({ portal: serializePortal(board) });
  } catch (err) {
    next(err);
  }
});

const portalFieldSchema = z.object({
  label: z.string().min(1).max(100),
  fieldType: z.enum(["TEXT", "TEXTAREA", "EMAIL", "ATTACHMENT"]),
  required: z.boolean().default(false),
});

const portalRequestTypeSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(300).nullable().optional(),
  icon: z.string().min(1).default("FileText"),
  instructions: z.string().max(4000).nullable().optional(),
  fields: z.array(portalFieldSchema).default([]),
});

const portalCategorySchema = z.object({
  name: z.string().min(1).max(100),
  icon: z.string().min(1).default("Layers"),
  requestTypes: z.array(portalRequestTypeSchema).default([]),
});

const portalConfigSchema = z.object({
  enabled: z.boolean(),
  title: z.string().max(150).nullable().optional(),
  welcomeMessage: z.string().max(1000).nullable().optional(),
  intakeListId: z.string().nullable().optional(),
  categories: z.array(portalCategorySchema).default([]),
});

boardsRouter.put("/:id/portal", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id, "ADMIN");
    const data = portalConfigSchema.parse(req.body);

    const existing = await prisma.board.findUnique({
      where: { id: req.params.id },
      select: { portalSlug: true },
    });
    const slug = data.enabled ? existing?.portalSlug ?? crypto.randomBytes(9).toString("base64url") : existing?.portalSlug ?? null;

    await prisma.$transaction(async (tx) => {
      await tx.board.update({
        where: { id: req.params.id },
        data: {
          portalEnabled: data.enabled,
          portalSlug: slug,
          portalTitle: data.title,
          portalWelcomeMessage: data.welcomeMessage,
          portalIntakeListId: data.intakeListId,
        },
      });

      // Full-replace on every save: simplest correct way to reconcile an arbitrarily
      // edited category/request-type/field tree. Historical PortalAnswers keep a
      // denormalized label, so they stay readable even after their field is gone.
      await tx.portalCategory.deleteMany({ where: { boardId: req.params.id } });

      for (const [ci, category] of data.categories.entries()) {
        const createdCategory = await tx.portalCategory.create({
          data: {
            boardId: req.params.id,
            name: category.name,
            icon: category.icon,
            position: (ci + 1) * 1000,
          },
        });

        for (const [ri, requestType] of category.requestTypes.entries()) {
          const createdType = await tx.portalRequestType.create({
            data: {
              categoryId: createdCategory.id,
              name: requestType.name,
              description: requestType.description ?? null,
              icon: requestType.icon,
              instructions: requestType.instructions ?? null,
              position: (ri + 1) * 1000,
            },
          });

          if (requestType.fields.length > 0) {
            await tx.portalField.createMany({
              data: requestType.fields.map((field, fi) => ({
                requestTypeId: createdType.id,
                label: field.label,
                fieldType: field.fieldType,
                required: field.required,
                position: (fi + 1) * 1000,
              })),
            });
          }
        }
      }
    });

    const board = await prisma.board.findUnique({
      where: { id: req.params.id },
      include: portalConfigInclude,
    });
    if (!board) throw new HttpError(404, "Board not found");
    res.json({ portal: serializePortal(board) });
  } catch (err) {
    next(err);
  }
});

// ---------- Task summary ----------
// A per-person "ongoing vs upcoming" digest of open tasks, grouped like a status
// report. Bucketing follows the board's own list layout (the columns people
// actually drag cards between), ordered by position: the first list = not yet
// started ("upcoming"), the last list = done (excluded), anything in between =
// actively being worked ("ongoing"). This intentionally mirrors the Kanban view
// rather than the separate Status field, since dragging a card between columns
// is how people actually signal progress.

type TaskSummaryCard = {
  id: string;
  title: string;
  note: string | null;
  listName: string | null;
  statusLabel: string | null;
  statusColor: string | null;
  dueDate: string | null;
};

type TaskSummaryPerson = {
  userId: string;
  name: string;
  avatarUrl: string | null;
  avatarColor: string;
  ongoing: TaskSummaryCard[];
  upcoming: TaskSummaryCard[];
  done: TaskSummaryCard[];
};

boardsRouter.get("/:id/task-summary", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id);

    const board = await prisma.board.findUnique({
      where: { id: req.params.id },
      include: {
        lists: {
          where: { isArchived: false },
          orderBy: { position: "asc" },
          include: {
            cards: {
              where: { isArchived: false },
              include: { status: true, members: { include: { user: true } } },
            },
          },
        },
      },
    });
    if (!board) throw new HttpError(404, "Board not found");

    const lists = board.lists;
    const firstListId = lists[0]?.id ?? null;
    const lastListId = lists.length > 1 ? lists[lists.length - 1].id : null;
    const listNameById = new Map(lists.map((l) => [l.id, l.name]));
    const allCards = lists.flatMap((l) => l.cards);

    function bucketFor(card: (typeof allCards)[number]): "ongoing" | "upcoming" | "done" {
      if (card.isCompleted || (lastListId && card.listId === lastListId)) return "done";
      if (lists.length < 2) return "ongoing";
      if (card.listId === firstListId) return "upcoming";
      return "ongoing";
    }

    const people = new Map<string, TaskSummaryPerson>();
    let unassigned: TaskSummaryPerson | null = null;

    for (const card of allCards) {
      const bucket = bucketFor(card);

      const summary: TaskSummaryCard = {
        id: card.id,
        title: card.title,
        note: card.description ? card.description.slice(0, 140) : null,
        listName: listNameById.get(card.listId) ?? null,
        statusLabel: card.status?.label ?? null,
        statusColor: card.status?.color ?? null,
        dueDate: card.dueDate ? card.dueDate.toISOString() : null,
      };

      if (card.members.length === 0) {
        if (!unassigned) {
          unassigned = {
            userId: "unassigned",
            name: "Unassigned",
            avatarUrl: null,
            avatarColor: "#94A3B8",
            ongoing: [],
            upcoming: [],
            done: [],
          };
        }
        unassigned[bucket].push(summary);
        continue;
      }

      for (const m of card.members) {
        let person = people.get(m.userId);
        if (!person) {
          person = {
            userId: m.userId,
            name: m.user.name,
            avatarUrl: m.user.avatarUrl,
            avatarColor: m.user.avatarColor,
            ongoing: [],
            upcoming: [],
            done: [],
          };
          people.set(m.userId, person);
        }
        person[bucket].push(summary);
      }
    }

    const peopleList = [...people.values()];
    if (unassigned) peopleList.push(unassigned);
    peopleList.sort((a, b) => b.ongoing.length + b.upcoming.length - (a.ongoing.length + a.upcoming.length));

    const totals = {
      ongoing: peopleList.reduce((s, p) => s + p.ongoing.length, 0),
      upcoming: peopleList.reduce((s, p) => s + p.upcoming.length, 0),
      done: peopleList.reduce((s, p) => s + p.done.length, 0),
      people: people.size,
    };

    res.json({ summary: { totals, people: peopleList } });
  } catch (err) {
    next(err);
  }
});

// ---------- Daily updates ----------
// For a chosen date, surfaces everything that happened on the board that day —
// comments posted and stage transitions (list moves, status changes, card
// creation) — grouped by the person who made the update, standup-style.

const dailyUpdatesQuerySchema = z.object({
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
});

boardsRouter.get("/:id/daily-updates", async (req, res, next) => {
  try {
    await requireBoardAccess(req.userId!, req.params.id);
    const { date } = dailyUpdatesQuerySchema.parse(req.query);

    const dayStart = new Date(`${date}T00:00:00.000Z`);
    const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);

    const [comments, activity] = await Promise.all([
      prisma.comment.findMany({
        where: { card: { boardId: req.params.id }, createdAt: { gte: dayStart, lt: dayEnd } },
        include: { author: true, card: { include: { list: true, status: true } } },
        orderBy: { createdAt: "asc" },
      }),
      prisma.activityLog.findMany({
        where: {
          boardId: req.params.id,
          createdAt: { gte: dayStart, lt: dayEnd },
          action: { in: ["card.created", "card.moved", "card.status_changed"] },
          cardId: { not: null },
        },
        include: { actor: true, card: { include: { list: true, status: true } } },
        orderBy: { createdAt: "asc" },
      }),
    ]);

    // card.moved / card.status_changed metadata stores list/status ids as they
    // were at the time — resolve current labels for those ids up front.
    const listIds = new Set<string>();
    const statusIds = new Set<string>();
    for (const a of activity) {
      const meta = (a.metadata ?? {}) as Record<string, string | null | undefined>;
      if (meta.fromListId) listIds.add(meta.fromListId);
      if (meta.toListId) listIds.add(meta.toListId);
      if (meta.fromStatusOptionId) statusIds.add(meta.fromStatusOptionId);
      if (meta.toStatusOptionId) statusIds.add(meta.toStatusOptionId);
    }
    const [lists, statusOpts] = await Promise.all([
      listIds.size ? prisma.list.findMany({ where: { id: { in: [...listIds] } } }) : Promise.resolve([]),
      statusIds.size ? prisma.statusOption.findMany({ where: { id: { in: [...statusIds] } } }) : Promise.resolve([]),
    ]);
    const listNameById = new Map(lists.map((l) => [l.id, l.name]));
    const statusLabelById = new Map(statusOpts.map((s) => [s.id, s.label]));

    type UpdateEvent = {
      id: string;
      type: "comment" | "created" | "moved" | "status_changed";
      time: string;
      cardId: string;
      cardTitle: string;
      currentListName: string;
      currentStatusLabel: string | null;
      currentStatusColor: string | null;
      text: string;
      actorId: string;
      actorName: string;
      actorAvatarUrl: string | null;
      actorAvatarColor: string;
    };

    const events: UpdateEvent[] = [];

    for (const c of comments) {
      events.push({
        id: `comment-${c.id}`,
        type: "comment",
        time: c.createdAt.toISOString(),
        cardId: c.cardId,
        cardTitle: c.card.title,
        currentListName: c.card.list.name,
        currentStatusLabel: c.card.status?.label ?? null,
        currentStatusColor: c.card.status?.color ?? null,
        text: c.bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 280),
        actorId: c.authorId,
        actorName: c.author.name,
        actorAvatarUrl: c.author.avatarUrl,
        actorAvatarColor: c.author.avatarColor,
      });
    }

    for (const a of activity) {
      if (!a.card) continue;
      const meta = (a.metadata ?? {}) as Record<string, string | null | undefined>;
      let text: string;
      let type: UpdateEvent["type"];

      if (a.action === "card.created") {
        text = "Created this task";
        type = "created";
      } else if (a.action === "card.moved") {
        const from = meta.fromListId ? listNameById.get(meta.fromListId) ?? "—" : "—";
        const to = meta.toListId ? listNameById.get(meta.toListId) ?? "—" : "—";
        text = `Moved from "${from}" to "${to}"`;
        type = "moved";
      } else {
        const from = meta.fromStatusOptionId ? statusLabelById.get(meta.fromStatusOptionId) ?? "No status" : "No status";
        const to = meta.toStatusOptionId ? statusLabelById.get(meta.toStatusOptionId) ?? "No status" : "No status";
        text = `Status changed from "${from}" to "${to}"`;
        type = "status_changed";
      }

      events.push({
        id: `activity-${a.id}`,
        type,
        time: a.createdAt.toISOString(),
        cardId: a.cardId!,
        cardTitle: a.card.title,
        currentListName: a.card.list.name,
        currentStatusLabel: a.card.status?.label ?? null,
        currentStatusColor: a.card.status?.color ?? null,
        text,
        actorId: a.actorId,
        actorName: a.actor.name,
        actorAvatarUrl: a.actor.avatarUrl,
        actorAvatarColor: a.actor.avatarColor,
      });
    }

    events.sort((x, y) => new Date(x.time).getTime() - new Date(y.time).getTime());

    type PersonGroup = {
      userId: string;
      name: string;
      avatarUrl: string | null;
      avatarColor: string;
      events: UpdateEvent[];
    };
    const people = new Map<string, PersonGroup>();
    for (const e of events) {
      let p = people.get(e.actorId);
      if (!p) {
        p = { userId: e.actorId, name: e.actorName, avatarUrl: e.actorAvatarUrl, avatarColor: e.actorAvatarColor, events: [] };
        people.set(e.actorId, p);
      }
      p.events.push(e);
    }

    const peopleList = [...people.values()].sort((a, b) => b.events.length - a.events.length);

    res.json({ date, totalEvents: events.length, people: peopleList });
  } catch (err) {
    next(err);
  }
});
