import {
  buildSplitAllocationReadModel,
  createAssignment,
  findAllocationEntry,
  loadAllocationEntry,
  listAssignmentBookings,
  updateAssignment,
  updateDemandRequirement,
  updateAllocationEntry,
} from "@capakraken/application";
import { Prisma, VacationType } from "@capakraken/db";
import type { PrismaClient } from "@capakraken/db";
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
  loadProjectPlanningReadModel,
  TIMELINE_ASSIGNMENT_INCLUDE,
  PROJECT_PLANNING_DEMAND_INCLUDE,
} from "./project-planning-read-model.js";
import {
  emitAllocationCreated,
  emitAllocationUpdated,
  emitProjectShifted,
} from "../sse/event-bus.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
import { logger } from "../lib/logger.js";
import type { TRPCContext } from "../trpc.js";

type ShiftDbClient = Pick<
  PrismaClient,
  "project" | "demandRequirement" | "assignment"
>;

export type TimelineEntriesDbClient = Pick<
  PrismaClient,
  "demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
>;

export type TimelineEntriesFilters = {
  startDate: Date;
  endDate: Date;
  resourceIds?: string[] | undefined;
  projectIds?: string[] | undefined;
  clientIds?: string[] | undefined;
  chapters?: string[] | undefined;
  eids?: string[] | undefined;
  countryCodes?: string[] | undefined;
};

const TimelineWindowFiltersSchema = z.object({
  startDate: z.coerce.date(),
  endDate: z.coerce.date(),
  resourceIds: z.array(z.string()).optional(),
  projectIds: z.array(z.string()).optional(),
  clientIds: z.array(z.string()).optional(),
  chapters: z.array(z.string()).optional(),
  eids: z.array(z.string()).optional(),
  countryCodes: z.array(z.string()).optional(),
});

type TimelineWindowFiltersInput = z.infer<typeof TimelineWindowFiltersSchema>;
type TimelineSelfServiceContext = Pick<TRPCContext, "db" | "dbUser">;

export function getAssignmentResourceIds(
  readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
  return [
    ...new Set(
      readModel.assignments
        .map((assignment) => assignment.resourceId)
        .filter((resourceId): resourceId is string => resourceId !== null),
    ),
  ];
}

function fmtDate(value: Date | null | undefined): string | null {
  if (!value) {
    return null;
  }
  return value.toISOString().slice(0, 10);
}

function createUtcDate(year: number, month: number, day: number): Date {
  return new Date(Date.UTC(year, month, day, 0, 0, 0, 0));
}

function createTimelineDateRange(input: {
  startDate?: string | undefined;
  endDate?: string | undefined;
  durationDays?: number | undefined;
}): { startDate: Date; endDate: Date } {
  const now = new Date();
  const startDate = input.startDate
    ? new Date(`${input.startDate}T00:00:00.000Z`)
    : createUtcDate(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());

  if (Number.isNaN(startDate.getTime())) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: `Invalid startDate: ${input.startDate}`,
    });
  }

  const endDate = input.endDate
    ? new Date(`${input.endDate}T00:00:00.000Z`)
    : createUtcDate(
        startDate.getUTCFullYear(),
        startDate.getUTCMonth(),
        startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
      );

  if (Number.isNaN(endDate.getTime())) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: `Invalid endDate: ${input.endDate}`,
    });
  }
  if (endDate < startDate) {
    throw new TRPCError({
      code: "BAD_REQUEST",
      message: "endDate must be on or after startDate.",
    });
  }

  return { startDate, endDate };
}

function normalizeStringList(values?: string[] | undefined): string[] | undefined {
  const normalized = values
    ?.map((value) => value.trim())
    .filter((value) => value.length > 0);

  return normalized && normalized.length > 0 ? normalized : undefined;
}

function createTimelineFilters(input: {
  resourceIds?: string[] | undefined;
  projectIds?: string[] | undefined;
  clientIds?: string[] | undefined;
  chapters?: string[] | undefined;
  eids?: string[] | undefined;
  countryCodes?: string[] | undefined;
}): Omit<TimelineEntriesFilters, "startDate" | "endDate"> {
  return {
    resourceIds: normalizeStringList(input.resourceIds),
    projectIds: normalizeStringList(input.projectIds),
    clientIds: normalizeStringList(input.clientIds),
    chapters: normalizeStringList(input.chapters),
    eids: normalizeStringList(input.eids),
    countryCodes: normalizeStringList(input.countryCodes),
  };
}

function createEmptyTimelineEntriesView() {
  return buildSplitAllocationReadModel({
    demandRequirements: [],
    assignments: [],
  });
}

async function findOwnedTimelineResourceId(
  ctx: TimelineSelfServiceContext,
): Promise<string | null> {
  if (!ctx.dbUser?.id) {
    return null;
  }

  if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") {
    return null;
  }

  const resource = await ctx.db.resource.findFirst({
    where: { userId: ctx.dbUser.id },
    select: { id: true },
  });

  return resource?.id ?? null;
}

async function buildSelfServiceTimelineInput(
  ctx: TimelineSelfServiceContext,
  input: TimelineWindowFiltersInput,
): Promise<TimelineEntriesFilters | null> {
  const ownedResourceId = await findOwnedTimelineResourceId(ctx);
  if (!ownedResourceId) {
    return null;
  }

  return {
    startDate: input.startDate,
    endDate: input.endDate,
    resourceIds: [ownedResourceId],
    projectIds: normalizeStringList(input.projectIds),
    clientIds: normalizeStringList(input.clientIds),
  };
}

function summarizeTimelineEntries(readModel: {
  allocations: Array<{ projectId: string | null; resourceId: string | null }>;
  demands: Array<{ projectId: string | null }>;
  assignments: Array<{ projectId: string | null; resourceId: string | null }>;
}) {
  const projectIds = new Set<string>();
  const resourceIds = new Set<string>();

  for (const entry of [...readModel.allocations, ...readModel.demands, ...readModel.assignments]) {
    if (entry.projectId) {
      projectIds.add(entry.projectId);
    }
  }

  for (const assignment of [...readModel.allocations, ...readModel.assignments]) {
    if (assignment.resourceId) {
      resourceIds.add(assignment.resourceId);
    }
  }

  return {
    allocationCount: readModel.allocations.length,
    demandCount: readModel.demands.length,
    assignmentCount: readModel.assignments.length,
    projectCount: projectIds.size,
    resourceCount: resourceIds.size,
  };
}

function formatHolidayOverlays(
  overlays: Array<{
    id: string;
    resourceId: string;
    startDate: Date;
    endDate: Date;
    note?: string | null;
    scope?: string | null;
    calendarName?: string | null;
    sourceType?: string | null;
  }>,
) {
  return overlays.map((overlay) => ({
    id: overlay.id,
    resourceId: overlay.resourceId,
    startDate: fmtDate(overlay.startDate),
    endDate: fmtDate(overlay.endDate),
    note: overlay.note ?? null,
    scope: overlay.scope ?? null,
    calendarName: overlay.calendarName ?? null,
    sourceType: overlay.sourceType ?? null,
  }));
}

function summarizeHolidayOverlays(
  overlays: ReturnType<typeof formatHolidayOverlays>,
) {
  const resourceIds = new Set<string>();
  const byScope = new Map<string, number>();

  for (const overlay of overlays) {
    resourceIds.add(overlay.resourceId);
    const scope = overlay.scope ?? "UNKNOWN";
    byScope.set(scope, (byScope.get(scope) ?? 0) + 1);
  }

  return {
    overlayCount: overlays.length,
    holidayResourceCount: resourceIds.size,
    byScope: [...byScope.entries()]
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([scope, count]) => ({ scope, count })),
  };
}

function rangesOverlap(
  leftStart: Date,
  leftEnd: Date,
  rightStart: Date,
  rightEnd: Date,
): boolean {
  return leftStart <= rightEnd && rightStart <= leftEnd;
}

function toDate(value: Date | string): Date {
  return value instanceof Date ? value : new Date(value);
}

export async function loadTimelineEntriesReadModel(
  db: TimelineEntriesDbClient,
  input: TimelineEntriesFilters,
) {
  const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids, countryCodes } = input;

  // When resource-level filters are active (resourceIds, chapters, eids, or countryCodes),
  // resolve matching resource IDs so we can push the filter to the DB query.
  const effectiveResourceIds = await (async () => {
    if (resourceIds && resourceIds.length > 0) return resourceIds;
    const hasChapters = chapters && chapters.length > 0;
    const hasEids = eids && eids.length > 0;
    const hasCountry = countryCodes && countryCodes.length > 0;
    if (!hasChapters && !hasEids && !hasCountry) return undefined;

    const andConditions: Record<string, unknown>[] = [];
    if (hasChapters) andConditions.push({ chapter: { in: chapters } });
    if (hasEids) andConditions.push({ eid: { in: eids } });
    if (hasCountry) andConditions.push({ country: { code: { in: countryCodes } } });

    const matching = await db.resource.findMany({
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
      select: { id: true },
    });
    return matching.map((r) => r.id);
  })();

  const effectiveProjectIds = await (async () => {
    if (!clientIds || clientIds.length === 0) return projectIds;

    const matchingProjects = await db.project.findMany({
      where: { clientId: { in: clientIds } },
      select: { id: true },
    });
    const clientProjectIds = matchingProjects.map((project) => project.id);

    if (!projectIds || projectIds.length === 0) {
      return clientProjectIds;
    }

    const allowedIds = new Set(clientProjectIds);
    return projectIds.filter((projectId) => allowedIds.has(projectId));
  })();

  // When filtering by resource (either explicit resourceIds or derived from chapters),
  // demands without a resource are excluded.
  const excludeDemands = effectiveResourceIds !== undefined;

  const [demandRequirements, assignments] = await Promise.all([
    excludeDemands
      ? Promise.resolve([])
      : db.demandRequirement.findMany({
          where: {
            status: { not: "CANCELLED" },
            startDate: { lte: endDate },
            endDate: { gte: startDate },
            ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
          },
          include: PROJECT_PLANNING_DEMAND_INCLUDE,
          orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
        }),
    db.assignment.findMany({
      where: {
        status: { not: "CANCELLED" },
        startDate: { lte: endDate },
        endDate: { gte: startDate },
        ...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
        ...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
      },
      include: TIMELINE_ASSIGNMENT_INCLUDE,
      orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
    }),
  ]);

  return buildSplitAllocationReadModel({ demandRequirements, assignments });
}

export async function loadTimelineHolidayOverlays(
  db: TimelineEntriesDbClient,
  input: TimelineEntriesFilters,
) {
  const readModel = await loadTimelineEntriesReadModel(db, input);
  return loadTimelineHolidayOverlaysForReadModel(db, input, readModel);
}

async function loadTimelineHolidayOverlaysForReadModel(
  db: TimelineEntriesDbClient,
  input: TimelineEntriesFilters,
  readModel: ReturnType<typeof buildSplitAllocationReadModel>,
) {
  const resourceIds = [...new Set(
    readModel.assignments
      .map((assignment) => assignment.resourceId)
      .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
  )];

  if (input.resourceIds && input.resourceIds.length > 0) {
    for (const resourceId of input.resourceIds) {
      if (resourceId && !resourceIds.includes(resourceId)) {
        resourceIds.push(resourceId);
      }
    }
  }

  const hasResourceFilters =
    (input.chapters?.length ?? 0) > 0 ||
    (input.eids?.length ?? 0) > 0 ||
    (input.countryCodes?.length ?? 0) > 0;

  if (hasResourceFilters) {
    const andConditions: Record<string, unknown>[] = [];
    if (input.chapters && input.chapters.length > 0) {
      andConditions.push({ chapter: { in: input.chapters } });
    }
    if (input.eids && input.eids.length > 0) {
      andConditions.push({ eid: { in: input.eids } });
    }
    if (input.countryCodes && input.countryCodes.length > 0) {
      andConditions.push({ country: { code: { in: input.countryCodes } } });
    }

    const matchingResources = await db.resource.findMany({
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
      select: { id: true },
    });

    for (const resource of matchingResources) {
      if (!resourceIds.includes(resource.id)) {
        resourceIds.push(resource.id);
      }
    }
  }

  if (resourceIds.length === 0) {
    return [];
  }

  const resources = await db.resource.findMany({
    where: { id: { in: resourceIds } },
    select: {
      id: true,
      countryId: true,
      federalState: true,
      metroCityId: true,
      country: { select: { code: true } },
      metroCity: { select: { name: true } },
    },
  });

  const overlays = await Promise.all(
    resources.map(async (resource) => {
      const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
        periodStart: input.startDate,
        periodEnd: input.endDate,
        countryId: resource.countryId,
        countryCode: resource.country?.code ?? null,
        federalState: resource.federalState,
        metroCityId: resource.metroCityId,
        metroCityName: resource.metroCity?.name ?? null,
      });

      return holidays.map((holiday) => {
        const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
        return {
          id: `calendar-holiday:${resource.id}:${holiday.date}`,
          resourceId: resource.id,
          type: VacationType.PUBLIC_HOLIDAY,
          status: "APPROVED" as const,
          startDate: holidayDate,
          endDate: holidayDate,
          note: holiday.name,
          scope: holiday.scope,
          calendarName: holiday.calendarName,
          sourceType: holiday.sourceType,
        };
      });
    }),
  );

  return overlays.flat().sort((left, right) => {
    if (left.resourceId !== right.resourceId) {
      return left.resourceId.localeCompare(right.resourceId);
    }
    return left.startDate.getTime() - right.startDate.getTime();
  });
}

async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
  const [project, planningRead] = await Promise.all([
    findUniqueOrThrow(
      db.project.findUnique({
        where: { id: projectId },
        select: {
          id: true,
          budgetCents: true,
          winProbability: true,
          startDate: true,
          endDate: true,
        },
      }),
      "Project",
    ),
    loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
  ]);

  const { demandRequirements, assignments, readModel: projectReadModel } = planningRead;

  const resourceIds = getAssignmentResourceIds(projectReadModel);

  const allAssignmentWindows =
    resourceIds.length === 0
      ? []
      : (
          await listAssignmentBookings(db, {
            resourceIds,
          })
        ).map((booking) => ({
          id: booking.id,
          resourceId: booking.resourceId!,
          projectId: booking.projectId,
          startDate: booking.startDate,
          endDate: booking.endDate,
          hoursPerDay: booking.hoursPerDay,
          status: booking.status,
        }));

  const shiftPlan = buildTimelineShiftPlan({
    demandRequirements,
    assignments,
    allAssignmentWindows,
  });

  return {
    project,
    demandRequirements,
    assignments,
    shiftPlan,
  };
}

export async function loadTimelineProjectContext(db: ShiftDbClient, projectId: string) {
  const [project, planningRead] = await Promise.all([
    findUniqueOrThrow(
      db.project.findUnique({
        where: { id: projectId },
        select: {
          id: true,
          name: true,
          shortCode: true,
          orderType: true,
          budgetCents: true,
          winProbability: true,
          status: true,
          startDate: true,
          endDate: true,
          staffingReqs: true,
        },
      }),
      "Project",
    ),
    loadProjectPlanningReadModel(db, {
      projectId,
      activeOnly: true,
    }),
  ]);

  const resourceIds = getAssignmentResourceIds(planningRead.readModel);
  const allResourceAllocations =
    resourceIds.length === 0
      ? []
      : await listAssignmentBookings(db, {
          resourceIds,
        });

  return {
    project,
    allocations: planningRead.readModel.allocations,
    demands: planningRead.readModel.demands,
    assignments: planningRead.readModel.assignments,
    allResourceAllocations,
    resourceIds,
  };
}

export async function previewTimelineProjectShift(
  db: ShiftDbClient,
  input: {
    projectId: string;
    newStartDate: Date;
    newEndDate: Date;
  },
) {
  const { project, shiftPlan } = await loadProjectShiftContext(db, input.projectId);

  return validateShift({
    project: {
      id: project.id,
      budgetCents: project.budgetCents,
      winProbability: project.winProbability,
      startDate: project.startDate,
      endDate: project.endDate,
    },
    newStartDate: input.newStartDate,
    newEndDate: input.newEndDate,
    allocations: shiftPlan.validationAllocations,
  });
}

function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
  entry: T,
  directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
): T {
  if (!entry.resource) {
    return entry;
  }
  return {
    ...entry,
    resource: anonymizeResource(entry.resource, directory),
  };
}

/** Load active calculation rules from DB, falling back to defaults if none configured. */
function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean {
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    if (error.code !== "P2021") {
      return false;
    }
    const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : "";
    const message = error.message.toLowerCase();
    return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
  }

  if (typeof error !== "object" || error === null || !("code" in error)) {
    return false;
  }

  const candidate = error as {
    code?: unknown;
    message?: unknown;
    meta?: { table?: unknown };
  };
  const code = typeof candidate.code === "string" ? candidate.code : "";
  if (code !== "P2021") {
    return false;
  }
  const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : "";
  const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : "";
  return tableHints.some((hint) => table.includes(hint) || message.includes(hint));
}

async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
  const calculationRuleModel = (db as PrismaClient & {
    calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
  }).calculationRule;

  if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") {
    return DEFAULT_CALCULATION_RULES;
  }

  try {
    const rules = await calculationRuleModel.findMany({
      where: { isActive: true },
      orderBy: [{ priority: "desc" }],
    });
    if (rules.length > 0) {
      return rules as unknown as CalculationRule[];
    }
  } catch (error) {
    if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) {
      logger.error({ err: error }, "Failed to load active calculation rules for timeline");
      throw error;
    }
  }
  return DEFAULT_CALCULATION_RULES;
}

/** Build typed absence days from vacations for a resource in a date range. */
async function buildAbsenceDays(
  db: PrismaClient,
  resourceId: string,
  startDate: Date,
  endDate: Date,
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
  const absenceDays: AbsenceDay[] = [];
  const legacyVacationDates: Date[] = [];

  try {
    const vacations = await db.vacation.findMany({
      where: {
        resourceId,
        status: "APPROVED",
        startDate: { lte: endDate },
        endDate: { gte: startDate },
      },
      select: { startDate: true, endDate: true, type: true, isHalfDay: true },
    });

    for (const v of vacations) {
      const cur = new Date(v.startDate);
      cur.setHours(0, 0, 0, 0);
      const vEnd = new Date(v.endDate);
      vEnd.setHours(0, 0, 0, 0);

      // Map Prisma VacationType to AbsenceTrigger
      const triggerType = v.type === VacationType.SICK ? "SICK" as const
        : v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const
        : "VACATION" as const;

      while (cur <= vEnd) {
        absenceDays.push({
          date: new Date(cur),
          type: triggerType,
          ...(v.isHalfDay ? { isHalfDay: true } : {}),
        });
        // Also populate legacy vacation dates for backward compat
        if (triggerType === "VACATION") {
          legacyVacationDates.push(new Date(cur));
        }
        cur.setDate(cur.getDate() + 1);
      }
    }
  } catch (error) {
    if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) {
      logger.error(
        { err: error, resourceId, startDate, endDate },
        "Failed to load timeline absence days",
      );
      throw error;
    }
  }

  return { absenceDays, legacyVacationDates };
}

export const timelineRouter = createTRPCRouter({
  /**
   * Get all timeline entries (projects + allocations) for a date range.
   * Includes project startDate, endDate, staffingReqs for demand overlay.
   */
  getEntries: controllerProcedure
    .input(TimelineWindowFiltersSchema)
    .query(async ({ ctx, input }) => {
      const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
      const directory = await getAnonymizationDirectory(ctx.db);
      return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
    }),

  getEntriesView: controllerProcedure
    .input(TimelineWindowFiltersSchema)
    .query(async ({ ctx, input }) => {
      const [readModel, directory] = await Promise.all([
        loadTimelineEntriesReadModel(ctx.db, input),
        getAnonymizationDirectory(ctx.db),
      ]);

      return {
        ...readModel,
        allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
        assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
      };
    }),

  getMyEntriesView: protectedProcedure
    .input(TimelineWindowFiltersSchema)
    .query(async ({ ctx, input }) => {
      const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
      if (!selfServiceInput) {
        return createEmptyTimelineEntriesView();
      }

      const [readModel, directory] = await Promise.all([
        loadTimelineEntriesReadModel(ctx.db, selfServiceInput),
        getAnonymizationDirectory(ctx.db),
      ]);

      return {
        ...readModel,
        allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
        assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
      };
    }),

  getHolidayOverlays: controllerProcedure
    .input(TimelineWindowFiltersSchema)
    .query(async ({ ctx, input }) => loadTimelineHolidayOverlays(ctx.db, input)),

  getMyHolidayOverlays: protectedProcedure
    .input(TimelineWindowFiltersSchema)
    .query(async ({ ctx, input }) => {
      const selfServiceInput = await buildSelfServiceTimelineInput(ctx, input);
      if (!selfServiceInput) {
        return [];
      }

      return loadTimelineHolidayOverlays(ctx.db, selfServiceInput);
    }),

  getEntriesDetail: controllerProcedure
    .input(
      z.object({
        startDate: z.string().optional(),
        endDate: z.string().optional(),
        durationDays: z.number().int().min(1).max(366).optional(),
        resourceIds: z.array(z.string()).optional(),
        projectIds: z.array(z.string()).optional(),
        clientIds: z.array(z.string()).optional(),
        chapters: z.array(z.string()).optional(),
        eids: z.array(z.string()).optional(),
        countryCodes: z.array(z.string()).optional(),
      }),
    )
    .query(async ({ ctx, input }) => {
      const { startDate, endDate } = createTimelineDateRange(input);
      const filters = createTimelineFilters(input);
      const timelineInput = { ...filters, startDate, endDate };

      const [readModel, directory] = await Promise.all([
        loadTimelineEntriesReadModel(ctx.db, timelineInput),
        getAnonymizationDirectory(ctx.db),
      ]);
      const holidayOverlays = await loadTimelineHolidayOverlaysForReadModel(
        ctx.db,
        timelineInput,
        readModel,
      );
      const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);

      return {
        period: {
          startDate: fmtDate(startDate),
          endDate: fmtDate(endDate),
        },
        filters,
        summary: {
          ...summarizeTimelineEntries(readModel),
          ...summarizeHolidayOverlays(formattedHolidayOverlays),
        },
        allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
        demands: readModel.demands,
        assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
        holidayOverlays: formattedHolidayOverlays,
      };
    }),

  getHolidayOverlayDetail: controllerProcedure
    .input(
      z.object({
        startDate: z.string().optional(),
        endDate: z.string().optional(),
        durationDays: z.number().int().min(1).max(366).optional(),
        resourceIds: z.array(z.string()).optional(),
        projectIds: z.array(z.string()).optional(),
        clientIds: z.array(z.string()).optional(),
        chapters: z.array(z.string()).optional(),
        eids: z.array(z.string()).optional(),
        countryCodes: z.array(z.string()).optional(),
      }),
    )
    .query(async ({ ctx, input }) => {
      const { startDate, endDate } = createTimelineDateRange(input);
      const filters = createTimelineFilters(input);
      const holidayOverlays = await loadTimelineHolidayOverlays(ctx.db, {
        ...filters,
        startDate,
        endDate,
      });
      const formattedOverlays = formatHolidayOverlays(holidayOverlays);

      return {
        period: {
          startDate: fmtDate(startDate),
          endDate: fmtDate(endDate),
        },
        filters,
        summary: summarizeHolidayOverlays(formattedOverlays),
        overlays: formattedOverlays,
      };
    }),

  /**
   * Get full project context for a project:
   * - project with staffingReqs and budget
   * - all active planning entries on this project
   * - all assignment bookings for the same resources (for cross-project overlap display)
   * Used when: drag starts or project panel opens.
   */
  getProjectContext: controllerProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const {
        project,
        allocations,
        demands,
        assignments,
        allResourceAllocations,
        resourceIds,
      } = await loadTimelineProjectContext(ctx.db, input.projectId);
      const directory = await getAnonymizationDirectory(ctx.db);

      return {
        project,
        allocations: allocations.map((allocation) =>
          anonymizeResourceOnEntry(allocation, directory),
        ),
        demands,
        assignments: assignments.map((assignment) =>
          anonymizeResourceOnEntry(assignment, directory),
        ),
        allResourceAllocations: allResourceAllocations.map((allocation) =>
          anonymizeResourceOnEntry(allocation, directory),
        ),
        resourceIds,
      };
    }),

  getProjectContextDetail: controllerProcedure
    .input(
      z.object({
        projectId: z.string(),
        startDate: z.string().optional(),
        endDate: z.string().optional(),
        durationDays: z.number().int().min(1).max(366).optional(),
      }),
    )
    .query(async ({ ctx, input }) => {
      const projectContext = await loadTimelineProjectContext(ctx.db, input.projectId);
      const directory = await getAnonymizationDirectory(ctx.db);

      const derivedStartDate = input.startDate
        ? createTimelineDateRange({ startDate: input.startDate, durationDays: 1 }).startDate
        : projectContext.project.startDate
          ?? projectContext.assignments[0]?.startDate
          ?? projectContext.demands[0]?.startDate
          ?? createTimelineDateRange({ durationDays: 1 }).startDate;
      const derivedEndDate = input.endDate
        ? createTimelineDateRange({ startDate: fmtDate(derivedStartDate) ?? undefined, endDate: input.endDate }).endDate
        : projectContext.project.endDate
          ?? createTimelineDateRange({
              startDate: fmtDate(derivedStartDate) ?? undefined,
              durationDays: input.durationDays ?? 21,
            }).endDate;

      if (derivedEndDate < derivedStartDate) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "endDate must be on or after startDate.",
        });
      }

      const holidayOverlays = projectContext.resourceIds.length > 0
        ? await loadTimelineHolidayOverlays(ctx.db, {
            startDate: derivedStartDate,
            endDate: derivedEndDate,
            resourceIds: projectContext.resourceIds,
            projectIds: [input.projectId],
          })
        : [];
      const formattedHolidayOverlays = formatHolidayOverlays(holidayOverlays);

      const assignmentConflicts = projectContext.assignments
        .filter((assignment) => assignment.resourceId && assignment.resource)
        .map((assignment) => {
          const overlaps = projectContext.allResourceAllocations
            .filter((booking) => (
              booking.resourceId === assignment.resourceId
              && booking.id !== assignment.id
              && rangesOverlap(
                toDate(booking.startDate),
                toDate(booking.endDate),
                toDate(assignment.startDate),
                toDate(assignment.endDate),
              )
            ))
            .map((booking) => ({
              id: booking.id,
              projectId: booking.projectId,
              projectName: booking.project?.name ?? null,
              projectShortCode: booking.project?.shortCode ?? null,
              startDate: fmtDate(toDate(booking.startDate)),
              endDate: fmtDate(toDate(booking.endDate)),
              hoursPerDay: booking.hoursPerDay,
              status: booking.status,
              sameProject: booking.projectId === input.projectId,
            }));

          return {
            assignmentId: assignment.id,
            resourceId: assignment.resourceId!,
            resourceName: assignment.resource?.displayName ?? null,
            startDate: fmtDate(toDate(assignment.startDate)),
            endDate: fmtDate(toDate(assignment.endDate)),
            hoursPerDay: assignment.hoursPerDay,
            overlapCount: overlaps.length,
            crossProjectOverlapCount: overlaps.filter((booking) => !booking.sameProject).length,
            overlaps,
          };
        });

      return {
        project: projectContext.project,
        period: {
          startDate: fmtDate(derivedStartDate),
          endDate: fmtDate(derivedEndDate),
        },
        summary: {
          ...summarizeTimelineEntries({
            allocations: projectContext.allocations,
            demands: projectContext.demands,
            assignments: projectContext.assignments,
          }),
          resourceIds: projectContext.resourceIds.length,
          allResourceAllocationCount: projectContext.allResourceAllocations.length,
          conflictedAssignmentCount: assignmentConflicts.filter((item) => item.crossProjectOverlapCount > 0).length,
          ...summarizeHolidayOverlays(formattedHolidayOverlays),
        },
        allocations: projectContext.allocations.map((allocation) =>
          anonymizeResourceOnEntry(allocation, directory),
        ),
        demands: projectContext.demands,
        assignments: projectContext.assignments.map((assignment) =>
          anonymizeResourceOnEntry(assignment, directory),
        ),
        allResourceAllocations: projectContext.allResourceAllocations.map((allocation) =>
          anonymizeResourceOnEntry(allocation, directory),
        ),
        assignmentConflicts,
        holidayOverlays: formattedHolidayOverlays,
        resourceIds: projectContext.resourceIds,
      };
    }),

  /**
   * Inline update of an allocation's hours, dates, includeSaturday, or role.
   * Recalculates dailyCostCents and emits SSE.
   */
  updateAllocationInline: managerProcedure
    .input(UpdateAllocationHoursSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
      const resolved = await loadAllocationEntry(ctx.db, input.allocationId);
      const existing = resolved.entry;
      const existingResource = resolved.resourceId
        ? await ctx.db.resource.findUnique({
            where: { id: resolved.resourceId },
            select: { id: true, lcrCents: true, availability: true },
          })
        : null;

      const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay;
      const newStartDate = input.startDate ?? existing.startDate;
      const newEndDate = input.endDate ?? existing.endDate;

      if (newEndDate < newStartDate) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: "End date must be after start date",
        });
      }

      // Merge includeSaturday into metadata
      const existingMeta = (existing.metadata as Record<string, unknown>) ?? {};
      const newMeta: Record<string, unknown> = {
        ...existingMeta,
        ...(input.includeSaturday !== undefined
          ? { includeSaturday: input.includeSaturday }
          : {}),
      };
      const includeSaturday =
        input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false;

      // For placeholder allocations (no resource), dailyCostCents stays 0
      let newDailyCostCents = 0;
      if (resolved.resourceId) {
        if (!existingResource) {
          throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
        }

        const availability =
          existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability;

        // Load recurrence from merged metadata
        const recurrence = (newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined);

        // Load typed absences + calculation rules for rules-aware cost computation
        const [absenceData, calculationRules] = await Promise.all([
          buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
          loadCalculationRules(ctx.db as PrismaClient),
        ]);

        newDailyCostCents = calculateAllocation({
          lcrCents: existingResource.lcrCents,
          hoursPerDay: newHoursPerDay,
          startDate: newStartDate,
          endDate: newEndDate,
          availability,
          includeSaturday,
          ...(recurrence ? { recurrence } : {}),
          vacationDates: absenceData.legacyVacationDates,
          absenceDays: absenceData.absenceDays,
          calculationRules,
        }).dailyCostCents;
      }

      const updated = await ctx.db.$transaction(async (tx) => {
        const { allocation: updatedAllocation } = await updateAllocationEntry(
          tx as unknown as Parameters<typeof updateAllocationEntry>[0],
          {
            id: input.allocationId,
            demandRequirementUpdate: {
              hoursPerDay: newHoursPerDay,
              startDate: newStartDate,
              endDate: newEndDate,
              metadata: newMeta,
              ...(input.role !== undefined ? { role: input.role } : {}),
            },
            assignmentUpdate: {
              hoursPerDay: newHoursPerDay,
              startDate: newStartDate,
              endDate: newEndDate,
              dailyCostCents: newDailyCostCents,
              metadata: newMeta,
              ...(input.role !== undefined ? { role: input.role } : {}),
            },
          },
        );

        await tx.auditLog.create({
          data: {
            entityType: "Allocation",
            entityId: input.allocationId,
            action: "UPDATE",
            changes: {
              before: {
                id: resolved.entry.id,
                hoursPerDay: existing.hoursPerDay,
                startDate: existing.startDate,
                endDate: existing.endDate,
              },
              after: {
                id: updatedAllocation.id,
                hoursPerDay: newHoursPerDay,
                startDate: newStartDate,
                endDate: newEndDate,
                includeSaturday,
              },
            },
          },
        });

        return updatedAllocation;
      });

      emitAllocationUpdated({
        id: updated.id,
        projectId: updated.projectId,
        resourceId: updated.resourceId,
      });

      return updated;
    }),

  /**
   * Preview a project shift — validate without committing.
   * Returns cost impact, conflicts, warnings.
   */
  previewShift: controllerProcedure
    .input(ShiftProjectSchema)
    .query(async ({ ctx, input }) => previewTimelineProjectShift(ctx.db, input)),

  getShiftPreviewDetail: controllerProcedure
    .input(ShiftProjectSchema)
    .query(async ({ ctx, input }) => {
      const [project, preview] = await Promise.all([
        findUniqueOrThrow(
          ctx.db.project.findUnique({
            where: { id: input.projectId },
            select: {
              id: true,
              name: true,
              shortCode: true,
              status: true,
              responsiblePerson: true,
              startDate: true,
              endDate: true,
            },
          }),
          "Project",
        ),
        previewTimelineProjectShift(ctx.db, input),
      ]);

      return {
        project: {
          id: project.id,
          name: project.name,
          shortCode: project.shortCode,
          status: project.status,
          responsiblePerson: project.responsiblePerson,
          startDate: fmtDate(project.startDate),
          endDate: fmtDate(project.endDate),
        },
        requestedShift: {
          newStartDate: fmtDate(input.newStartDate),
          newEndDate: fmtDate(input.newEndDate),
        },
        preview,
      };
    }),

  /**
   * Apply a project shift — validate, then commit all allocation date changes.
   * Reads includeSaturday from each allocation's metadata.
   */
  applyShift: managerProcedure
    .input(ShiftProjectSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
      const { projectId, newStartDate, newEndDate } = input;
      const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext(
        ctx.db,
        projectId,
      );

      // Re-validate before committing
      const validation = validateShift({
        project: {
          id: project.id,
          budgetCents: project.budgetCents,
          winProbability: project.winProbability,
          startDate: project.startDate,
          endDate: project.endDate,
        },
        newStartDate,
        newEndDate,
        allocations: shiftPlan.validationAllocations,
      });

      if (!validation.valid) {
        throw new TRPCError({
          code: "BAD_REQUEST",
          message: `Shift validation failed: ${validation.errors.map((e) => e.message).join(", ")}`,
        });
      }

      // Pre-load calculation rules for cost recalculation
      const shiftRules = await loadCalculationRules(ctx.db as PrismaClient);

      // Apply shift in a transaction
      const updatedProject = await ctx.db.$transaction(async (tx) => {
        // Update project dates
        const proj = await tx.project.update({
          where: { id: projectId },
          data: { startDate: newStartDate, endDate: newEndDate },
        });

        for (const demandRequirement of demandRequirements) {
          await updateDemandRequirement(
            tx as unknown as Parameters<typeof updateDemandRequirement>[0],
            demandRequirement.id,
            {
              startDate: newStartDate,
              endDate: newEndDate,
            },
          );
        }

        for (const assignment of assignments) {
          const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
          const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;

          const shiftAbsenceData = await buildAbsenceDays(
            ctx.db as PrismaClient,
            assignment.resourceId!,
            newStartDate,
            newEndDate,
          );

          const newDailyCost = calculateAllocation({
            lcrCents: assignment.resource!.lcrCents,
            hoursPerDay: assignment.hoursPerDay,
            startDate: newStartDate,
            endDate: newEndDate,
            availability:
              assignment.resource!.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
            includeSaturday,
            vacationDates: shiftAbsenceData.legacyVacationDates,
            absenceDays: shiftAbsenceData.absenceDays,
            calculationRules: shiftRules,
          }).dailyCostCents;

          await updateAssignment(
            tx as unknown as Parameters<typeof updateAssignment>[0],
            assignment.id,
            {
              startDate: newStartDate,
              endDate: newEndDate,
              dailyCostCents: newDailyCost,
            },
          );
        }

        // Write audit log
        await tx.auditLog.create({
          data: {
            entityType: "Project",
            entityId: projectId,
            action: "SHIFT",
            changes: {
              before: { startDate: project.startDate, endDate: project.endDate },
              after: { startDate: newStartDate, endDate: newEndDate },
              costImpact: validation.costImpact,
            } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
          },
        });

        return proj;
      });

      // Emit SSE event for live updates
      emitProjectShifted({
        projectId,
        newStartDate: newStartDate.toISOString(),
        newEndDate: newEndDate.toISOString(),
        costDeltaCents: validation.costImpact.deltaCents,
        resourceIds: assignments.map((assignment) => assignment.resourceId),
      });

      return { project: updatedProject, validation };
    }),

  /**
   * Quick-assign a resource to a project for a date range.
   * Overbooking is intentionally allowed — no availability throw.
   * For use from the timeline drag-to-assign UI.
   */
  quickAssign: managerProcedure
    .input(
      z.object({
        resourceId: z.string(),
        projectId: z.string(),
        startDate: z.coerce.date(),
        endDate: z.coerce.date(),
        hoursPerDay: z.number().min(0.5).max(24).default(8),
        role: z.string().min(1).max(200).default("Team Member"),
        roleId: z.string().optional(),
        status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
      if (input.endDate < input.startDate) {
        throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" });
      }

      const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100));
      const metadata = { source: "quickAssign" } satisfies Record<string, unknown>;

      const allocation = await ctx.db.$transaction(async (tx) => {
        const assignment = await createAssignment(
          tx as unknown as Parameters<typeof createAssignment>[0],
          {
            resourceId: input.resourceId,
            projectId: input.projectId,
            startDate: input.startDate,
            endDate: input.endDate,
            hoursPerDay: input.hoursPerDay,
            percentage,
            role: input.role,
            roleId: input.roleId ?? undefined,
            status: input.status,
            metadata,
          },
        );

        return buildSplitAllocationReadModel({
          demandRequirements: [],
          assignments: [assignment],
        }).allocations[0]!;
      });

      emitAllocationCreated({
        id: allocation.id,
        projectId: allocation.projectId,
        resourceId: allocation.resourceId,
      });

      return allocation;
    }),

  /**
   * Batch quick-assign multiple resources to a project for a date range.
   * Used by the multi-selection floating action bar.
   */
  batchQuickAssign: managerProcedure
    .input(
      z.object({
        assignments: z
          .array(
            z.object({
              resourceId: z.string(),
              projectId: z.string(),
              startDate: z.coerce.date(),
              endDate: z.coerce.date(),
              hoursPerDay: z.number().min(0.5).max(24).default(8),
              role: z.string().min(1).max(200).default("Team Member"),
              status: z
                .nativeEnum(AllocationStatus)
                .default(AllocationStatus.PROPOSED),
            }),
          )
          .min(1)
          .max(50),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);

      // Validate all date ranges
      for (const a of input.assignments) {
        if (a.endDate < a.startDate) {
          throw new TRPCError({
            code: "BAD_REQUEST",
            message: "End date must be after start date",
          });
        }
      }

      const results = await ctx.db.$transaction(async (tx) => {
        const created = [];
        for (const a of input.assignments) {
          const percentage = Math.min(
            100,
            Math.round((a.hoursPerDay / 8) * 100),
          );
          const metadata = {
            source: "batchQuickAssign",
          } satisfies Record<string, unknown>;

          const assignment = await createAssignment(
            tx as unknown as Parameters<typeof createAssignment>[0],
            {
              resourceId: a.resourceId,
              projectId: a.projectId,
              startDate: a.startDate,
              endDate: a.endDate,
              hoursPerDay: a.hoursPerDay,
              percentage,
              role: a.role,
              status: a.status,
              metadata,
            },
          );
          created.push(assignment);
        }
        return created;
      });

      // Fire SSE events
      for (const assignment of results) {
        emitAllocationCreated({
          id: assignment.id,
          projectId: assignment.projectId,
          resourceId: assignment.resourceId,
        });
      }

      return { count: results.length };
    }),

  /**
   * Batch-shift multiple allocations by the same number of days.
   * Used by multi-select drag on the timeline.
   */
  batchShiftAllocations: managerProcedure
    .input(
      z.object({
        allocationIds: z.array(z.string()).min(1).max(100),
        daysDelta: z.number().int().min(-3650).max(3650),
        mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);

      if (input.daysDelta === 0) return { count: 0 };

      // Load all allocations
      const entries = await Promise.all(
        input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
      );
      const resolved = entries.filter(
        (e): e is NonNullable<typeof e> => e !== null,
      );

      if (resolved.length === 0) {
        throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
      }

      const results = await ctx.db.$transaction(async (tx) => {
        const updated = [];
        for (const entry of resolved) {
          const existing = entry.entry;
          const newStart = new Date(existing.startDate);
          const newEnd = new Date(existing.endDate);

          if (input.mode === "move") {
            newStart.setDate(newStart.getDate() + input.daysDelta);
            newEnd.setDate(newEnd.getDate() + input.daysDelta);
          } else if (input.mode === "resize-start") {
            newStart.setDate(newStart.getDate() + input.daysDelta);
            // Clamp: start must not exceed end
            if (newStart > newEnd) newStart.setTime(newEnd.getTime());
          } else {
            // resize-end
            newEnd.setDate(newEnd.getDate() + input.daysDelta);
            // Clamp: end must not precede start
            if (newEnd < newStart) newEnd.setTime(newStart.getTime());
          }

          const result = await updateAllocationEntry(
            tx as unknown as Parameters<typeof updateAllocationEntry>[0],
            {
              id: existing.id,
              demandRequirementUpdate: {
                startDate: newStart,
                endDate: newEnd,
              },
              assignmentUpdate: {
                startDate: newStart,
                endDate: newEnd,
              },
            },
          );
          updated.push(result.allocation);
        }

        await tx.auditLog.create({
          data: {
            entityType: "Allocation",
            entityId: input.allocationIds.join(","),
            action: "UPDATE",
            changes: {
              operation: "batchShift",
              mode: input.mode,
              daysDelta: input.daysDelta,
              count: resolved.length,
            } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
          },
        });

        return updated;
      });

      // Fire SSE events
      for (const alloc of results) {
        emitAllocationUpdated({
          id: alloc.id,
          projectId: alloc.projectId,
          resourceId: alloc.resourceId,
        });
      }

      return { count: results.length };
    }),

  /**
   * Get budget status for a project.
   */
  getBudgetStatus: controllerProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const project = await findUniqueOrThrow(
        ctx.db.project.findUnique({
          where: { id: input.projectId },
          select: {
            id: true,
            name: true,
            shortCode: true,
            budgetCents: true,
            winProbability: true,
            startDate: true,
            endDate: true,
          },
        }),
        "Project",
      );

      // Use wide date range to catch all assignments (including those extending beyond project dates)
      const bookings = await listAssignmentBookings(ctx.db, {
        projectIds: [project.id],
      });

      const budgetStatus = computeBudgetStatus(
        project.budgetCents,
        project.winProbability,
        bookings.map((booking) => ({
          status: booking.status,
          dailyCostCents: booking.dailyCostCents,
          startDate: booking.startDate,
          endDate: booking.endDate,
          hoursPerDay: booking.hoursPerDay,
        })) as unknown as Pick<import("@capakraken/shared").Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
        project.startDate,
        project.endDate,
      );

      return {
        ...budgetStatus,
        projectName: project.name,
        projectCode: project.shortCode,
        totalAllocations: bookings.length,
        budgetCents: project.budgetCents,
      };
    }),
});
