import { createAiClient, isAiConfigured, loggedAiCall } from "../ai-client.js";
import {
  isChargeabilityActualBooking,
  isChargeabilityRelevantProject,
  listAssignmentBookings,
  recomputeResourceValueScores,
} from "@capakraken/application";
import {
  calculateAllocation,
  deriveResourceForecast,
  getMonthRange,
  DEFAULT_CALCULATION_RULES,
  type AssignmentSlice,
} from "@capakraken/engine";
import { VacationStatus } from "@capakraken/db";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, SystemRole, UpdateResourceSchema, inferStateFromPostalCode, resolvePermissions, type PermissionOverrides } from "@capakraken/shared";
import type { CalculationRule, SpainScheduleRule } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
  anonymizeResource,
  anonymizeResources,
  anonymizeSearchMatches,
  getAnonymizationDirectory,
  resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
import {
  asHolidayResolverDb,
  collectHolidayAvailability,
  getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
  calculateEffectiveAvailableHours,
  calculateEffectiveBookedHours,
  calculateEffectiveDayAvailability,
  countEffectiveWorkingDays,
  loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { fmtEur } from "../lib/format-utils.js";

export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.

Artist profile:
- Role: {role}
- Chapter: {chapter}
- Main skills: {mainSkills}
- Top skills: {topSkills}

Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.`;
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission, resourceOverviewProcedure } from "../trpc.js";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import type { TRPCContext } from "../trpc.js";

function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null {
  if (!cursor) return null;
  try {
    const decoded = JSON.parse(cursor) as { displayName?: string; id?: string };
    if (typeof decoded.displayName === "string" && typeof decoded.id === "string") {
      return { displayName: decoded.displayName, id: decoded.id };
    }
  } catch {
    return null;
  }
  return null;
}

type BookingForCapacity = {
  startDate: Date;
  endDate: Date;
  hoursPerDay: number;
};

function toIsoDate(value: Date): string {
  return value.toISOString().slice(0, 10);
}

function buildDailyBookedHoursMap(
  bookings: BookingForCapacity[],
  availability: WeekdayAvailability,
  context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
  periodStart: Date,
  periodEnd: Date,
): Map<string, number> {
  const dailyBookedHours = new Map<string, number>();
  const cursor = new Date(periodStart);
  cursor.setUTCHours(0, 0, 0, 0);
  const end = new Date(periodEnd);
  end.setUTCHours(0, 0, 0, 0);

  while (cursor <= end) {
    const isoDate = toIsoDate(cursor);
    const bookedHours = bookings.reduce(
      (sum, booking) => sum + calculateEffectiveBookedHours({
        availability,
        startDate: booking.startDate,
        endDate: booking.endDate,
        hoursPerDay: booking.hoursPerDay,
        periodStart: cursor,
        periodEnd: cursor,
        context,
      }),
      0,
    );
    dailyBookedHours.set(isoDate, bookedHours);
    cursor.setUTCDate(cursor.getUTCDate() + 1);
  }

  return dailyBookedHours;
}

function getAvailabilityHoursForDate(
  availability: WeekdayAvailability,
  date: Date,
): number {
  const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
  return availability[dayKey] ?? 0;
}

function sumAvailabilityHoursForDates(
  availability: WeekdayAvailability,
  dates: Date[],
): number {
  return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}

function formatResolvedHolidaySummary(holiday: {
  date: string;
  name: string;
  scope: string;
  calendarName: string | null;
  sourceType?: string | null;
}) {
  return {
    date: holiday.date,
    name: holiday.name,
    scope: holiday.scope,
    calendarName: holiday.calendarName ?? "Built-in",
    sourceType: holiday.sourceType ?? "system",
  };
}

const RESOURCE_SUMMARY_SELECT = {
  id: true,
  eid: true,
  displayName: true,
  chapter: true,
  isActive: true,
  areaRole: { select: { name: true } },
  country: { select: { code: true, name: true } },
  metroCity: { select: { name: true } },
  orgUnit: { select: { name: true } },
} as const;

const RESOURCE_SUMMARY_DETAIL_SELECT = {
  ...RESOURCE_SUMMARY_SELECT,
  fte: true,
  lcrCents: true,
  chargeabilityTarget: true,
} as const;

const RESOURCE_IDENTIFIER_SELECT = {
  id: true,
  eid: true,
  displayName: true,
  chapter: true,
  isActive: true,
} as const;

const RESOURCE_IDENTIFIER_DETAIL_SELECT = {
  ...RESOURCE_IDENTIFIER_SELECT,
  id: true,
  eid: true,
  displayName: true,
  email: true,
  chapter: true,
  fte: true,
  lcrCents: true,
  ucrCents: true,
  chargeabilityTarget: true,
  isActive: true,
  availability: true,
  skills: true,
  postalCode: true,
  federalState: true,
  areaRole: { select: { name: true, color: true } },
  country: { select: { code: true, name: true, dailyWorkingHours: true } },
  metroCity: { select: { name: true } },
  managementLevelGroup: { select: { name: true, targetPercentage: true } },
  orgUnit: { select: { name: true, level: true } },
  _count: { select: { assignments: true, vacations: true } },
} as const;

function mapResourceSummary(resource: {
  id: string;
  eid: string;
  displayName: string;
  chapter: string | null;
  isActive: boolean;
  areaRole: { name: string } | null;
  country: { code: string; name: string } | null;
  metroCity: { name: string } | null;
  orgUnit: { name: string } | null;
}) {
  return {
    id: resource.id,
    eid: resource.eid,
    name: resource.displayName,
    chapter: resource.chapter,
    role: resource.areaRole?.name ?? null,
    country: resource.country?.name ?? resource.country?.code ?? null,
    countryCode: resource.country?.code ?? null,
    metroCity: resource.metroCity?.name ?? null,
    orgUnit: resource.orgUnit?.name ?? null,
    active: resource.isActive,
  };
}

function mapResourceSummaryDetail(resource: {
  id: string;
  eid: string;
  displayName: string;
  chapter: string | null;
  fte: number | null;
  lcrCents: number | null;
  chargeabilityTarget: number | null;
  isActive: boolean;
  areaRole: { name: string } | null;
  country: { code: string; name: string } | null;
  metroCity: { name: string } | null;
  orgUnit: { name: string } | null;
}) {
  return {
    id: resource.id,
    eid: resource.eid,
    name: resource.displayName,
    chapter: resource.chapter,
    role: resource.areaRole?.name ?? null,
    country: resource.country?.name ?? resource.country?.code ?? null,
    countryCode: resource.country?.code ?? null,
    metroCity: resource.metroCity?.name ?? null,
    orgUnit: resource.orgUnit?.name ?? null,
    fte: resource.fte,
    lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
    chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null,
    active: resource.isActive,
  };
}

function mapResourceDetail(resource: {
  id: string;
  eid: string;
  displayName: string;
  email: string | null;
  chapter: string | null;
  fte: number | null;
  lcrCents: number | null;
  ucrCents: number | null;
  chargeabilityTarget: number | null;
  isActive: boolean;
  skills: unknown;
  postalCode: string | null;
  federalState: string | null;
  areaRole: { name: string; color: string | null } | null;
  country: { code: string; name: string; dailyWorkingHours: number | null } | null;
  metroCity: { name: string } | null;
  managementLevelGroup: { name: string; targetPercentage: number | null } | null;
  orgUnit: { name: string; level: number } | null;
  _count: { assignments: number; vacations: number };
}) {
  const skills = Array.isArray(resource.skills) ? resource.skills as { name?: string; level?: number }[] : [];
  return {
    id: resource.id,
    eid: resource.eid,
    name: resource.displayName,
    email: resource.email,
    chapter: resource.chapter,
    role: resource.areaRole?.name ?? null,
    country: resource.country?.name ?? resource.country?.code ?? null,
    countryCode: resource.country?.code ?? null,
    countryHours: resource.country?.dailyWorkingHours ?? 8,
    metroCity: resource.metroCity?.name ?? null,
    fte: resource.fte,
    lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
    ucr: resource.ucrCents != null ? fmtEur(resource.ucrCents) : null,
    chargeabilityTarget: resource.chargeabilityTarget != null ? `${resource.chargeabilityTarget}%` : null,
    managementLevel: resource.managementLevelGroup?.name ?? null,
    orgUnit: resource.orgUnit?.name ?? null,
    postalCode: resource.postalCode,
    federalState: resource.federalState,
    active: resource.isActive,
    totalAssignments: resource._count.assignments,
    totalVacations: resource._count.vacations,
    skillCount: skills.length,
    topSkills: skills.slice(0, 10).map((skill) => `${skill.name ?? "?"} (${skill.level ?? "?"})`),
  };
}

function summarizeResolvedHolidaySummary(holidays: Array<ReturnType<typeof formatResolvedHolidaySummary>>) {
  const byScope = new Map<string, number>();
  const bySourceType = new Map<string, number>();
  const byCalendar = new Map<string, number>();

  for (const holiday of holidays) {
    byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
    bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
    byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
  }

  return {
    byScope: [...byScope.entries()]
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([scope, count]) => ({ scope, count })),
    bySourceType: [...bySourceType.entries()]
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([sourceType, count]) => ({ sourceType, count })),
    byCalendar: [...byCalendar.entries()]
      .sort(([left], [right]) => left.localeCompare(right))
      .map(([calendarName, count]) => ({ calendarName, count })),
  };
}

function round1(value: number): number {
  return Math.round(value * 10) / 10;
}

function averagePerWorkingDay(totalHours: number, workingDays: number): number {
  return workingDays > 0 ? round1(totalHours / workingDays) : 0;
}

type ResourceReadContext = Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">;

function resolveResourcePermissions(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): Set<PermissionKey> {
  if (!ctx.dbUser) {
    return new Set();
  }

  return resolvePermissions(
    ctx.dbUser.systemRole as SystemRole,
    ctx.dbUser.permissionOverrides as PermissionOverrides | null,
    ctx.roleDefaults ?? undefined,
  );
}

function canReadAllResources(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): boolean {
  const permissions = resolveResourcePermissions(ctx);
  return permissions.has(PermissionKey.VIEW_ALL_RESOURCES) || permissions.has(PermissionKey.MANAGE_RESOURCES);
}

async function findOwnedResourceId(ctx: ResourceReadContext): 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 assertCanReadResource(
  ctx: ResourceReadContext,
  resourceId: string,
  message = "You can only view your own resource data",
): Promise<void> {
  if (canReadAllResources(ctx)) {
    return;
  }

  const ownedResourceId = await findOwnedResourceId(ctx);
  if (!ownedResourceId || ownedResourceId !== resourceId) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message,
    });
  }
}

function isBroadResourceLookupAllowed(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">): boolean {
  return canReadAllResources(ctx);
}

const ResourceDirectoryQuerySchema = z.object({
  chapter: z.string().optional(),
  chapters: z.array(z.string()).optional(),
  isActive: z.boolean().optional().default(true),
  search: z.string().optional(),
  eids: z.array(z.string()).optional(),
  countryIds: z.array(z.string()).optional(),
  excludedCountryIds: z.array(z.string()).optional(),
  includeWithoutCountry: z.boolean().optional().default(true),
  resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
  excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
  includeWithoutResourceType: z.boolean().optional().default(true),
  rolledOff: z.boolean().optional(),
  departed: z.boolean().optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(500).default(50),
  cursor: z.string().optional(),
});

const ResourceListQuerySchema = ResourceDirectoryQuerySchema.extend({
  includeRoles: z.boolean().optional().default(false),
  customFieldFilters: z.array(z.object({
    key: z.string(),
    value: z.string(),
    type: z.nativeEnum(FieldType),
  })).optional(),
});

async function listStaffResources(
  ctx: Pick<TRPCContext, "db">,
  input: z.infer<typeof ResourceListQuerySchema>,
) {
  const {
    chapter,
    chapters,
    isActive,
    search,
    eids,
    countryIds,
    excludedCountryIds,
    includeWithoutCountry,
    resourceTypes,
    excludedResourceTypes,
    includeWithoutResourceType,
    rolledOff,
    departed,
    page,
    limit,
    includeRoles,
    cursor,
    customFieldFilters,
  } = input;
  const parsedCursor = parseResourceCursor(cursor);

  const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
  type WhereClause = Record<string, unknown>;
  const andClauses: WhereClause[] = [];
  const chapterFilters = Array.from(
    new Set([
      ...(chapter ? [chapter] : []),
      ...(chapters ?? []),
    ]),
  );
  const directory = await getAnonymizationDirectory(ctx.db);

  if (!eids) {
    andClauses.push({ isActive });
  }
  if (eids && !directory) {
    andClauses.push({ eid: { in: eids } });
  }
  if (chapterFilters.length === 1) {
    andClauses.push({ chapter: chapterFilters[0] });
  } else if (chapterFilters.length > 1) {
    andClauses.push({ chapter: { in: chapterFilters } });
  }
  if (search && !directory) {
    andClauses.push({
      OR: [
        { displayName: { contains: search, mode: "insensitive" as const } },
        { eid: { contains: search, mode: "insensitive" as const } },
        { email: { contains: search, mode: "insensitive" as const } },
      ],
    });
  }
  if (countryIds && countryIds.length > 0) {
    const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
    if (includeWithoutCountry) {
      countryClauses.push({ countryId: null });
    }
    andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
  }
  if (excludedCountryIds && excludedCountryIds.length > 0) {
    andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
  }
  if (!includeWithoutCountry) {
    andClauses.push({ NOT: { countryId: null } });
  }
  if (resourceTypes && resourceTypes.length > 0) {
    const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
    if (includeWithoutResourceType) {
      resourceTypeClauses.push({ resourceType: null });
    }
    andClauses.push(
      resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
    );
  }
  if (excludedResourceTypes && excludedResourceTypes.length > 0) {
    andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
  }
  if (!includeWithoutResourceType) {
    andClauses.push({ NOT: { resourceType: null } });
  }
  if (rolledOff !== undefined) {
    andClauses.push({ rolledOff });
  }
  if (departed !== undefined) {
    andClauses.push({ departed });
  }
  andClauses.push(...cfConditions);

  const where = andClauses.length > 0 ? { AND: andClauses } : {};

  if (directory) {
    const rawResources = await (includeRoles
      ? ctx.db.resource.findMany({
          where,
          include: {
            resourceRoles: {
              include: { role: { select: ROLE_BRIEF_SELECT } },
            },
          },
          orderBy: [{ displayName: "asc" }, { id: "asc" }],
        })
      : ctx.db.resource.findMany({
          where,
          orderBy: [{ displayName: "asc" }, { id: "asc" }],
        }));

    const directoryResources = rawResources.map((resource) => ({
      id: resource.id,
      eid: resource.eid,
      displayName: resource.displayName,
      email: resource.email,
    }));
    const requestedIds = eids
      ? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids)
      : [];
    const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;

    const filteredResources = rawResources.filter((resource) => {
      const alias = directory.byResourceId.get(resource.id);
      if (requestedIdSet && !requestedIdSet.has(resource.id)) {
        return false;
      }
      if (eids && eids.length > 0 && requestedIds.length === 0) {
        return false;
      }
      if (search && !anonymizeSearchMatches(
        {
          id: resource.id,
          eid: resource.eid,
          displayName: resource.displayName,
          email: resource.email,
        },
        alias,
        search,
      )) {
        return false;
      }
      return true;
    });

    const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
      const displayNameCompare = left.displayName.localeCompare(right.displayName);
      if (displayNameCompare !== 0) {
        return displayNameCompare;
      }
      return left.id.localeCompare(right.id);
    });

    const total = anonymizedResources.length;
    const afterCursor = parsedCursor
      ? anonymizedResources.filter(
          (resource) =>
            resource.displayName > parsedCursor.displayName ||
            (resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
        )
      : anonymizedResources;
    const skip = cursor ? 0 : (page - 1) * limit;
    const paged = afterCursor.slice(skip, skip + limit + 1);
    const hasMore = paged.length > limit;
    const resources = hasMore ? paged.slice(0, limit) : paged;
    const nextCursor = hasMore
      ? JSON.stringify({
          displayName: resources[resources.length - 1]!.displayName,
          id: resources[resources.length - 1]!.id,
        })
      : null;

    return { resources, total, page, limit, nextCursor };
  }

  const skip = cursor ? 0 : (page - 1) * limit;
  const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
  const whereWithCursor = parsedCursor
    ? {
        AND: [
          ...((where as { AND?: WhereClause[] }).AND ?? []),
          {
            OR: [
              { displayName: { gt: parsedCursor.displayName } },
              { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
            ],
          },
        ],
      }
    : where;
  const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };

  const [rawResources, total] = await Promise.all([
    includeRoles
      ? ctx.db.resource.findMany({
          ...baseQuery,
          include: {
            resourceRoles: {
              include: { role: { select: ROLE_BRIEF_SELECT } },
            },
          },
        })
      : ctx.db.resource.findMany(baseQuery),
    ctx.db.resource.count({ where }),
  ]);

  const hasMore = rawResources.length > limit;
  const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
  const nextCursor = hasMore
    ? JSON.stringify({
        displayName: resources[resources.length - 1]!.displayName,
        id: resources[resources.length - 1]!.id,
      })
    : null;

  return { resources, total, page, limit, nextCursor };
}

function buildResourceSummaryWhere(input: {
  search?: string;
  country?: string;
  metroCity?: string;
  orgUnit?: string;
  roleName?: string;
  isActive: boolean;
}) {
  const where: Record<string, unknown> = {};
  if (input.isActive !== false) {
    where.isActive = true;
  }
  if (input.search) {
    where.OR = [
      { displayName: { contains: input.search, mode: "insensitive" as const } },
      { eid: { contains: input.search, mode: "insensitive" as const } },
      { chapter: { contains: input.search, mode: "insensitive" as const } },
    ];
  }
  if (input.country) {
    where.country = {
      OR: [
        { code: { equals: input.country, mode: "insensitive" as const } },
        { name: { contains: input.country, mode: "insensitive" as const } },
      ],
    };
  }
  if (input.metroCity) {
    where.metroCity = { name: { contains: input.metroCity, mode: "insensitive" as const } };
  }
  if (input.orgUnit) {
    where.orgUnit = { name: { contains: input.orgUnit, mode: "insensitive" as const } };
  }
  if (input.roleName) {
    where.areaRole = { name: { contains: input.roleName, mode: "insensitive" as const } };
  }

  return where;
}

function assertCanSearchResourceSummaries(ctx: Pick<TRPCContext, "dbUser" | "roleDefaults">) {
  if (!canReadAllResources(ctx)) {
    throw new TRPCError({
      code: "FORBIDDEN",
      message: "You need resource overview access to search resource summaries",
    });
  }
}

async function readResourceSummariesSnapshot(
  ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
  input: {
    search?: string;
    country?: string;
    metroCity?: string;
    orgUnit?: string;
    roleName?: string;
    isActive: boolean;
    limit: number;
  },
) {
  assertCanSearchResourceSummaries(ctx);

  return ctx.db.resource.findMany({
    where: buildResourceSummaryWhere(input),
    select: RESOURCE_SUMMARY_SELECT,
    take: input.limit,
    orderBy: { displayName: "asc" },
  });
}

async function readResourceSummaryDetailsSnapshot(
  ctx: Pick<TRPCContext, "db" | "dbUser" | "roleDefaults">,
  input: {
    search?: string;
    country?: string;
    metroCity?: string;
    orgUnit?: string;
    roleName?: string;
    isActive: boolean;
    limit: number;
  },
) {
  assertCanSearchResourceSummaries(ctx);

  return ctx.db.resource.findMany({
    where: buildResourceSummaryWhere(input),
    select: RESOURCE_SUMMARY_DETAIL_SELECT,
    take: input.limit,
    orderBy: { displayName: "asc" },
  });
}

async function resolveResourceIdentifierSnapshot(
  ctx: ResourceReadContext,
  identifier: string,
  forbiddenMessage = "You can only view your own resource unless you have staff access",
) {
  let resource = await ctx.db.resource.findUnique({
    where: { id: identifier },
    select: RESOURCE_IDENTIFIER_SELECT,
  });
  if (!resource) {
    resource = await ctx.db.resource.findUnique({
      where: { eid: identifier },
      select: RESOURCE_IDENTIFIER_SELECT,
    });
  }
  if (!resource) {
    resource = await ctx.db.resource.findFirst({
      where: { displayName: { equals: identifier, mode: "insensitive" } },
      select: RESOURCE_IDENTIFIER_SELECT,
    });
  }
  if (!resource && isBroadResourceLookupAllowed(ctx)) {
    resource = await ctx.db.resource.findFirst({
      where: { displayName: { contains: identifier, mode: "insensitive" } },
      select: RESOURCE_IDENTIFIER_SELECT,
    });
  }
  if (!resource && isBroadResourceLookupAllowed(ctx)) {
    const words = identifier.split(/[\s,._\-/]+/).filter((word) => word.length >= 2);
    if (words.length > 0) {
      const candidates = await ctx.db.resource.findMany({
        where: {
          OR: words.map((word) => ({
            displayName: { contains: word, mode: "insensitive" as const },
          })),
        },
        select: RESOURCE_IDENTIFIER_SELECT,
        take: 5,
      });
      if (candidates.length === 1) {
        resource = candidates[0]!;
      } else if (candidates.length > 1) {
        return {
          error: `Resource not found: "${identifier}". Did you mean one of these?`,
          suggestions: candidates.map((candidate) => ({
            id: candidate.id,
            eid: candidate.eid,
            name: candidate.displayName,
          })),
        } as const;
      }
    }
  }

  if (!resource) {
    return { error: `Resource not found: ${identifier}` } as const;
  }

  await assertCanReadResource(
    ctx,
    resource.id,
    forbiddenMessage,
  );

  return resource;
}

async function readResourceByIdentifierDetailSnapshot(
  ctx: ResourceReadContext,
  identifier: string,
) {
  const resource = await resolveResourceIdentifierSnapshot(ctx, identifier);
  if ("error" in resource) {
    return resource;
  }

  const detail = await ctx.db.resource.findUnique({
    where: { id: resource.id },
    select: RESOURCE_IDENTIFIER_DETAIL_SELECT,
  });

  if (!detail) {
    return { error: `Resource not found: ${identifier}` } as const;
  }

  return detail;
}

export const resourceRouter = createTRPCRouter({
  resolveByIdentifier: protectedProcedure
    .input(z.object({ identifier: z.string() }))
    .query(async ({ ctx, input }) => {
      const resource = await resolveResourceIdentifierSnapshot(
        ctx,
        input.identifier,
        "You can only resolve your own resource unless you have staff access",
      );
      if ("error" in resource) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
      }
      return resource;
    }),

  resolveResponsiblePersonName: resourceOverviewProcedure
    .input(z.object({ name: z.string() }))
    .query(async ({ ctx, input }) => {
      const exact = await ctx.db.resource.findFirst({
        where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true },
        select: { displayName: true },
      });
      if (exact) {
        return {
          status: "resolved" as const,
          displayName: exact.displayName,
        };
      }

      const candidates = await ctx.db.resource.findMany({
        where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true },
        select: { displayName: true, eid: true },
        take: 5,
      });
      if (candidates.length === 1) {
        return {
          status: "resolved" as const,
          displayName: candidates[0]!.displayName,
        };
      }
      if (candidates.length > 1) {
        return {
          status: "ambiguous" as const,
          message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`,
          candidates,
        };
      }

      return {
        status: "missing" as const,
        message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`,
        candidates: [],
      };
    }),

  getChargeabilitySummary: protectedProcedure
    .input(z.object({
      resourceId: z.string(),
      month: z.string().regex(/^\d{4}-\d{2}$/),
    }))
    .query(async ({ ctx, input }) => {
      await assertCanReadResource(
        ctx,
        input.resourceId,
        "You can only view chargeability details for your own resource unless you have staff access",
      );

      const [year, month] = input.month.split("-").map(Number) as [number, number];
      const { start: monthStart, end: monthEnd } = getMonthRange(year, month);

      const resource = await ctx.db.resource.findUniqueOrThrow({
        where: { id: input.resourceId },
        select: {
          id: true,
          displayName: true,
          eid: true,
          fte: true,
          lcrCents: true,
          chargeabilityTarget: true,
          countryId: true,
          federalState: true,
          metroCityId: true,
          availability: true,
          country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
          metroCity: { select: { id: true, name: true } },
          managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
        },
      });

      const dailyHours = resource.country?.dailyWorkingHours ?? 8;
      const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
      const targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);

      const availability = resource.availability as WeekdayAvailability | null;
      const weeklyAvailability: WeekdayAvailability = availability ?? {
        monday: dailyHours,
        tuesday: dailyHours,
        wednesday: dailyHours,
        thursday: dailyHours,
        friday: dailyHours,
        saturday: 0,
        sunday: 0,
      };

      const assignments = await ctx.db.assignment.findMany({
        where: {
          resourceId: input.resourceId,
          startDate: { lte: monthEnd },
          endDate: { gte: monthStart },
          status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] },
        },
        select: {
          id: true,
          hoursPerDay: true,
          startDate: true,
          endDate: true,
          dailyCostCents: true,
          status: true,
          project: {
            select: {
              id: true,
              name: true,
              shortCode: true,
              orderType: true,
              utilizationCategory: { select: { code: true } },
            },
          },
        },
      });

      const vacations = await ctx.db.vacation.findMany({
        where: {
          resourceId: input.resourceId,
          status: VacationStatus.APPROVED,
          startDate: { lte: monthEnd },
          endDate: { gte: monthStart },
        },
        select: { startDate: true, endDate: true, type: true, isHalfDay: true },
      });

      const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
        periodStart: monthStart,
        periodEnd: monthEnd,
        countryId: resource.countryId,
        countryCode: resource.country?.code,
        federalState: resource.federalState,
        metroCityId: resource.metroCityId,
        metroCityName: resource.metroCity?.name,
      });
      const holidayAvailability = collectHolidayAvailability({
        vacations,
        periodStart: monthStart,
        periodEnd: monthEnd,
        countryCode: resource.country?.code,
        federalState: resource.federalState,
        metroCityName: resource.metroCity?.name,
        resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
      });
      const absenceDays = holidayAvailability.absenceDays;

      const contexts = await loadResourceDailyAvailabilityContexts(
        ctx.db,
        [{
          id: resource.id,
          availability: weeklyAvailability,
          countryId: resource.countryId,
          countryCode: resource.country?.code,
          federalState: resource.federalState,
          metroCityId: resource.metroCityId,
          metroCityName: resource.metroCity?.name,
        }],
        monthStart,
        monthEnd,
      );
      const availabilityContext = contexts.get(resource.id);

      let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
      try {
        const dbRules = await ctx.db.calculationRule.findMany({
          where: { isActive: true },
          orderBy: [{ priority: "desc" }],
        });
        if (dbRules.length > 0) {
          calcRules = dbRules as unknown as CalculationRule[];
        }
      } catch {
        // table may not exist yet
      }

      const baseWorkingDays = countEffectiveWorkingDays({
        availability: weeklyAvailability,
        periodStart: monthStart,
        periodEnd: monthEnd,
        context: undefined,
      });
      const effectiveWorkingDays = countEffectiveWorkingDays({
        availability: weeklyAvailability,
        periodStart: monthStart,
        periodEnd: monthEnd,
        context: availabilityContext,
      });
      const baseAvailableHours = calculateEffectiveAvailableHours({
        availability: weeklyAvailability,
        periodStart: monthStart,
        periodEnd: monthEnd,
        context: undefined,
      });
      const effectiveAvailableHours = calculateEffectiveAvailableHours({
        availability: weeklyAvailability,
        periodStart: monthStart,
        periodEnd: monthEnd,
        context: availabilityContext,
      });

      const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
      const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
        count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
      ), 0);
      const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
        weeklyAvailability,
        publicHolidayDates,
      );
      const absenceDayEquivalent = absenceDays.reduce((sum, absence) => {
        if (absence.type === "PUBLIC_HOLIDAY") {
          return sum;
        }
        return sum + (absence.isHalfDay ? 0.5 : 1);
      }, 0);
      const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
        if (absence.type === "PUBLIC_HOLIDAY") {
          return sum;
        }
        const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
        return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
      }, 0);

      const slices: AssignmentSlice[] = [];
      const assignmentBreakdown: Array<{
        project: string;
        code: string;
        hours: number;
        status: string;
      }> = [];
      let totalBookedHours = 0;

      for (const assignment of assignments) {
        const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime()));
        const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime()));
        const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg";

        const calcResult = calculateAllocation({
          lcrCents: resource.lcrCents,
          hoursPerDay: assignment.hoursPerDay,
          startDate: overlapStart,
          endDate: overlapEnd,
          availability: weeklyAvailability,
          absenceDays,
          calculationRules: calcRules,
          orderType: assignment.project.orderType,
          projectId: assignment.project.id,
        });
        if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) {
          continue;
        }

        totalBookedHours += calcResult.totalHours;
        assignmentBreakdown.push({
          project: assignment.project.name,
          code: assignment.project.shortCode,
          hours: round1(calcResult.totalHours),
          status: assignment.status,
        });
        slices.push({
          hoursPerDay: assignment.hoursPerDay,
          workingDays: calcResult.workingDays,
          categoryCode,
          ...(calcResult.totalChargeableHours !== undefined
            ? { totalChargeableHours: calcResult.totalChargeableHours }
            : {}),
        });
      }

      const forecast = deriveResourceForecast({
        fte: resource.fte,
        targetPercentage: targetRatio,
        assignments: slices,
        sah: effectiveAvailableHours,
      });

      const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({
        ...holiday,
        calendarName: holiday.calendarName ?? "Built-in",
        sourceType: holiday.sourceType ?? "system",
      }));
      const workingDays = round1(effectiveWorkingDays);
      const baseWorkingDaysRounded = round1(baseWorkingDays);
      const baseAvailableHoursRounded = round1(baseAvailableHours);
      const availableHours = round1(effectiveAvailableHours);
      const bookedHours = round1(totalBookedHours);
      const targetPct = round1(targetRatio * 100);
      const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0;
      const chargeabilityPct = round1(forecast.chg * 100);
      const unassignedHours = round1(Math.max(0, availableHours - bookedHours));

      return {
        resource: resource.displayName,
        eid: resource.eid,
        month: input.month,
        periodStart: monthStart.toISOString().slice(0, 10),
        periodEnd: monthEnd.toISOString().slice(0, 10),
        fte: round1(resource.fte),
        target: `${targetPct}%`,
        targetPct,
        targetHours,
        workingDays,
        baseWorkingDays: baseWorkingDaysRounded,
        locationContext: {
          countryCode: resource.country?.code ?? null,
          country: resource.country?.name ?? resource.country?.code ?? null,
          federalState: resource.federalState ?? null,
          metroCity: resource.metroCity?.name ?? null,
        },
        baseAvailableHours: baseAvailableHoursRounded,
        availableHours,
        bookedHours,
        unassignedHours,
        chargeability: `${chargeabilityPct}%`,
        chargeabilityPct,
        onTarget: chargeabilityPct >= targetPct,
        holidaySummary: {
          count: formattedHolidays.length,
          workdayCount: round1(publicHolidayWorkdayCount),
          hoursDeduction: round1(publicHolidayHoursDeduction),
          holidays: formattedHolidays,
          breakdown: summarizeResolvedHolidaySummary(formattedHolidays),
        },
        absenceSummary: {
          dayEquivalent: round1(absenceDayEquivalent),
          hoursDeduction: round1(absenceHoursDeduction),
        },
        capacityBreakdown: {
          formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
          baseAvailableHours: baseAvailableHoursRounded,
          holidayHoursDeduction: round1(publicHolidayHoursDeduction),
          absenceHoursDeduction: round1(absenceHoursDeduction),
          availableHours,
        },
        averages: {
          availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays),
          bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays),
          remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays),
        },
        allocations: assignmentBreakdown,
        scheduleContext: {
          dailyWorkingHours: dailyHours,
          hasScheduleRules: Boolean(scheduleRules),
        },
      };
    }),

  listSummaries: resourceOverviewProcedure
    .input(z.object({
      search: z.string().optional(),
      country: z.string().optional(),
      metroCity: z.string().optional(),
      orgUnit: z.string().optional(),
      roleName: z.string().optional(),
      isActive: z.boolean().optional().default(true),
      limit: z.number().int().min(1).max(100).default(50),
    }))
    .query(async ({ ctx, input }) => {
      const resources = await readResourceSummariesSnapshot(ctx, {
        isActive: input.isActive,
        limit: input.limit,
        ...(input.search ? { search: input.search } : {}),
        ...(input.country ? { country: input.country } : {}),
        ...(input.metroCity ? { metroCity: input.metroCity } : {}),
        ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}),
        ...(input.roleName ? { roleName: input.roleName } : {}),
      });
      return resources.map(mapResourceSummary);
    }),

  listSummariesDetail: resourceOverviewProcedure
    .input(z.object({
      search: z.string().optional(),
      country: z.string().optional(),
      metroCity: z.string().optional(),
      orgUnit: z.string().optional(),
      roleName: z.string().optional(),
      isActive: z.boolean().optional().default(true),
      limit: z.number().int().min(1).max(100).default(50),
    }))
    .query(async ({ ctx, input }) => {
      const resources = await readResourceSummaryDetailsSnapshot(ctx, {
        isActive: input.isActive,
        limit: input.limit,
        ...(input.search ? { search: input.search } : {}),
        ...(input.country ? { country: input.country } : {}),
        ...(input.metroCity ? { metroCity: input.metroCity } : {}),
        ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}),
        ...(input.roleName ? { roleName: input.roleName } : {}),
      });
      return resources.map(mapResourceSummaryDetail);
    }),

  directory: protectedProcedure
    .input(ResourceDirectoryQuerySchema)
    .query(async ({ ctx, input }) => {
      const {
        chapter,
        chapters,
        isActive,
        search,
        eids,
        countryIds,
        excludedCountryIds,
        includeWithoutCountry,
        resourceTypes,
        excludedResourceTypes,
        includeWithoutResourceType,
        rolledOff,
        departed,
        page,
        limit,
        cursor,
      } = input;

      const parsedCursor = parseResourceCursor(cursor);
      type WhereClause = Record<string, unknown>;
      const andClauses: WhereClause[] = [];
      const chapterFilters = Array.from(
        new Set([
          ...(chapter ? [chapter] : []),
          ...(chapters ?? []),
        ]),
      );
      const directory = await getAnonymizationDirectory(ctx.db);

      if (!eids) {
        andClauses.push({ isActive });
      }
      if (eids && !directory) {
        andClauses.push({ eid: { in: eids } });
      }
      if (chapterFilters.length === 1) {
        andClauses.push({ chapter: chapterFilters[0] });
      } else if (chapterFilters.length > 1) {
        andClauses.push({ chapter: { in: chapterFilters } });
      }
      if (search && !directory) {
        andClauses.push({
          OR: [
            { displayName: { contains: search, mode: "insensitive" as const } },
            { eid: { contains: search, mode: "insensitive" as const } },
          ],
        });
      }
      if (countryIds && countryIds.length > 0) {
        const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
        if (includeWithoutCountry) {
          countryClauses.push({ countryId: null });
        }
        andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
      }
      if (excludedCountryIds && excludedCountryIds.length > 0) {
        andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
      }
      if (!includeWithoutCountry) {
        andClauses.push({ NOT: { countryId: null } });
      }
      if (resourceTypes && resourceTypes.length > 0) {
        const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
        if (includeWithoutResourceType) {
          resourceTypeClauses.push({ resourceType: null });
        }
        andClauses.push(
          resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
        );
      }
      if (excludedResourceTypes && excludedResourceTypes.length > 0) {
        andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
      }
      if (!includeWithoutResourceType) {
        andClauses.push({ NOT: { resourceType: null } });
      }
      if (rolledOff !== undefined) {
        andClauses.push({ rolledOff });
      }
      if (departed !== undefined) {
        andClauses.push({ departed });
      }

      const where = andClauses.length > 0 ? { AND: andClauses } : {};
      const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];

      if (directory) {
        const rawResources = await ctx.db.resource.findMany({
          where,
          select: {
            id: true,
            eid: true,
            displayName: true,
            chapter: true,
            isActive: true,
            email: true,
          },
          orderBy,
        });
        const requestedIds = eids
          ? resolveResourceIdsByDisplayedEids(rawResources, directory, eids)
          : [];
        const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;

        const filteredResources = rawResources.filter((resource) => {
          const alias = directory.byResourceId.get(resource.id);
          if (requestedIdSet && !requestedIdSet.has(resource.id)) {
            return false;
          }
          if (eids && eids.length > 0 && requestedIds.length === 0) {
            return false;
          }
          if (search && !anonymizeSearchMatches(
            {
              id: resource.id,
              eid: resource.eid,
              displayName: resource.displayName,
              email: resource.email,
            },
            alias,
            search,
          )) {
            return false;
          }
          return true;
        });

        const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
          const displayNameCompare = left.displayName.localeCompare(right.displayName);
          if (displayNameCompare !== 0) {
            return displayNameCompare;
          }
          return left.id.localeCompare(right.id);
        });

        const total = anonymizedResources.length;
        const afterCursor = parsedCursor
          ? anonymizedResources.filter(
              (resource) =>
                resource.displayName > parsedCursor.displayName ||
                (resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
            )
          : anonymizedResources;
        const skip = cursor ? 0 : (page - 1) * limit;
        const paged = afterCursor.slice(skip, skip + limit + 1);
        const hasMore = paged.length > limit;
        const resources = (hasMore ? paged.slice(0, limit) : paged).map((resource) => ({
          id: resource.id,
          eid: resource.eid,
          displayName: resource.displayName,
          chapter: resource.chapter,
          isActive: resource.isActive,
        }));
        const nextCursor = hasMore
          ? JSON.stringify({
              displayName: resources[resources.length - 1]!.displayName,
              id: resources[resources.length - 1]!.id,
            })
          : null;

        return { resources, total, page, limit, nextCursor };
      }

      const skip = cursor ? 0 : (page - 1) * limit;
      const whereWithCursor = parsedCursor
        ? {
            AND: [
              ...((where as { AND?: WhereClause[] }).AND ?? []),
              {
                OR: [
                  { displayName: { gt: parsedCursor.displayName } },
                  { displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
                ],
              },
            ],
          }
        : where;

      const [rawResources, total] = await Promise.all([
        ctx.db.resource.findMany({
          where: whereWithCursor,
          skip,
          take: limit + 1,
          orderBy,
          select: {
            id: true,
            eid: true,
            displayName: true,
            chapter: true,
            isActive: true,
          },
        }),
        ctx.db.resource.count({ where }),
      ]);

      const hasMore = rawResources.length > limit;
      const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
      const nextCursor = hasMore
        ? JSON.stringify({
            displayName: resources[resources.length - 1]!.displayName,
            id: resources[resources.length - 1]!.id,
          })
        : null;

      return { resources, total, page, limit, nextCursor };
    }),

  listStaff: resourceOverviewProcedure
    .input(ResourceListQuerySchema)
    .query(async ({ ctx, input }) => listStaffResources(ctx, input)),

  /** Lightweight resource card for hover tooltips on the timeline. */
  getHoverCard: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const resource = await findUniqueOrThrow(
        ctx.db.resource.findUnique({
          where: { id: input.id },
          select: {
            id: true,
            displayName: true,
            eid: true,
            email: true,
            chapter: true,
            lcrCents: true,
            ucrCents: true,
            currency: true,
            chargeabilityTarget: true,
            skills: true,
            availability: true,
            isActive: true,
            areaRole: { select: ROLE_BRIEF_SELECT },
            country: { select: { name: true, code: true } },
            managementLevel: { select: { name: true } },
            resourceType: true,
          },
        }),
        "Resource",
      );
      await assertCanReadResource(
        ctx,
        resource.id,
        "You can only view hover details for your own resource unless you have staff access",
      );
      const directory = await getAnonymizationDirectory(ctx.db);
      const anon = anonymizeResource(resource, directory);
      return {
        id: anon.id,
        displayName: anon.displayName ?? "",
        eid: anon.eid ?? "",
        chapter: resource.chapter,
        lcrCents: resource.lcrCents,
        ucrCents: resource.ucrCents,
        currency: resource.currency,
        chargeabilityTarget: resource.chargeabilityTarget,
        skills: resource.skills as Record<string, unknown>[],
        isActive: resource.isActive,
        resourceType: resource.resourceType,
        areaRole: resource.areaRole,
        country: resource.country,
        managementLevel: resource.managementLevel,
      };
    }),

  getByIdentifier: protectedProcedure
    .input(z.object({ identifier: z.string() }))
    .query(async ({ ctx, input }) => resolveResourceIdentifierSnapshot(ctx, input.identifier)),

  getByIdentifierDetail: protectedProcedure
    .input(z.object({ identifier: z.string() }))
    .query(async ({ ctx, input }) => {
      const resource = await readResourceByIdentifierDetailSnapshot(ctx, input.identifier);
      if ("error" in resource) {
        return resource;
      }
      return mapResourceDetail(resource);
    }),

  getById: protectedProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const resource = await findUniqueOrThrow(
        ctx.db.resource.findUnique({
          where: { id: input.id },
          include: {
            blueprint: true,
            resourceRoles: {
              include: { role: { select: ROLE_BRIEF_SELECT } },
            },
            areaRole: { select: { id: true, name: true } },
          },
        }),
        "Resource",
      );
      await assertCanReadResource(
        ctx,
        resource.id,
        "You can only view your own resource unless you have staff access",
      );

      const directory = await getAnonymizationDirectory(ctx.db);
      return {
        ...anonymizeResource(resource, directory),
        isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id),
      };
    }),

  getByEid: protectedProcedure
    .input(z.object({ eid: z.string() }))
    .query(async ({ ctx, input }) => {
      const directory = await getAnonymizationDirectory(ctx.db);
      let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
      if (!resource && directory) {
        const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase());
        if (resourceId) {
          resource = await ctx.db.resource.findUnique({ where: { id: resourceId } });
        }
      }
      if (!resource) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
      }
      await assertCanReadResource(
        ctx,
        resource.id,
        "You can only view your own resource unless you have staff access",
      );
      return anonymizeResource(resource, directory);
    }),

  create: managerProcedure
    .input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
      const existing = await ctx.db.resource.findFirst({
        where: { OR: [{ eid: input.eid }, { email: input.email }] },
      });

      if (existing) {
        throw new TRPCError({
          code: "CONFLICT",
          message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
        });
      }

      await assertBlueprintDynamicFields({
        db: ctx.db,
        blueprintId: input.blueprintId,
        dynamicFields: input.dynamicFields,
        target: BlueprintTarget.RESOURCE,
      });

      // Enforce max 1 primary role
      const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length;
      if (primaryCount > 1) {
        throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
      }

      const resource = await ctx.db.resource.create({
        data: {
          eid: input.eid,
          displayName: input.displayName,
          email: input.email,
          chapter: input.chapter,
          lcrCents: input.lcrCents,
          ucrCents: input.ucrCents,
          currency: input.currency,
          chargeabilityTarget: input.chargeabilityTarget,
          availability: input.availability,
          skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
          dynamicFields: input.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue,
          blueprintId: input.blueprintId,
          portfolioUrl: input.portfolioUrl || undefined,
          roleId: input.roleId || undefined,
          ...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
          ...(input.postalCode && !input.federalState
            ? { federalState: inferStateFromPostalCode(input.postalCode) }
            : input.federalState !== undefined
              ? { federalState: input.federalState }
              : {}),
          ...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
          ...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
          ...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
          ...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
          ...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
          ...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
          ...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
          ...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
          ...(input.departed !== undefined ? { departed: input.departed } : {}),
          ...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
          ...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
          ...(input.fte !== undefined ? { fte: input.fte } : {}),
          resourceRoles: input.roles?.length
            ? {
                create: input.roles.map((r) => ({
                  roleId: r.roleId,
                  isPrimary: r.isPrimary,
                })),
              }
            : undefined,
        } as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
        include: {
          resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
        },
      });

      await ctx.db.auditLog.create({
        data: {
          entityType: "Resource",
          entityId: resource.id,
          action: "CREATE",
          userId: ctx.dbUser?.id,
          changes: { after: resource },
        } as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"],
      });

      return resource;
    }),

  update: managerProcedure
    .input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
      const existing = await findUniqueOrThrow(
        ctx.db.resource.findUnique({ where: { id: input.id } }),
        "Resource",
      );

      const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
      const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;

      await assertBlueprintDynamicFields({
        db: ctx.db,
        blueprintId: nextBlueprintId,
        dynamicFields: nextDynamicFields,
        target: BlueprintTarget.RESOURCE,
      });

      // Enforce max 1 primary role
      if (input.data.roles !== undefined) {
        const primaryCount = input.data.roles.filter((r) => r.isPrimary).length;
        if (primaryCount > 1) {
          throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
        }
      }

      const updated = await ctx.db.resource.update({
        where: { id: input.id },
        data: {
          ...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
          ...(input.data.email !== undefined ? { email: input.data.email } : {}),
          ...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
          ...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
          ...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
          ...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
          ...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
          ...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
          ...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
          ...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}),
          ...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
          ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
          ...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
          ...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
          ...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
          ...(input.data.postalCode && !input.data.federalState
            ? { federalState: inferStateFromPostalCode(input.data.postalCode) }
            : input.data.federalState !== undefined
              ? { federalState: input.data.federalState }
              : {}),
          ...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
          ...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
          ...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
          ...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
          ...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
          ...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
          ...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
          ...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
          ...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
          ...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
          ...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
          ...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
        } as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
        include: {
          resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
        },
      });

      // Replace roles if provided
      if (input.data.roles !== undefined) {
        await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
        if (input.data.roles.length > 0) {
          await ctx.db.resourceRole.createMany({
            data: input.data.roles.map((r) => ({
              resourceId: input.id,
              roleId: r.roleId,
              isPrimary: r.isPrimary,
            })),
          });
        }
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Resource",
          entityId: input.id,
          action: "UPDATE",
          changes: { before: existing, after: updated },
        },
      });

      return updated;
    }),

  deactivate: managerProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
      const resource = await ctx.db.resource.update({
        where: { id: input.id },
        data: { isActive: false },
      });

      await ctx.db.auditLog.create({
        data: {
          entityType: "Resource",
          entityId: input.id,
          action: "UPDATE",
          changes: { after: { isActive: false } },
        },
      });

      return resource;
    }),

  batchDeactivate: managerProcedure
    .input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
      const updated = await ctx.db.$transaction(
        input.ids.map((id) =>
          ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
        ),
      );

      await ctx.db.auditLog.create({
        data: {
          entityType: "Resource",
          entityId: input.ids.join(","),
          action: "UPDATE",
          changes: { after: { isActive: false, ids: input.ids } },
        },
      });

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

  chapters: protectedProcedure.query(async ({ ctx }) => {
    const resources = await ctx.db.resource.findMany({
      where: { isActive: true, chapter: { not: null } },
      select: { chapter: true },
      distinct: ["chapter"],
      orderBy: { chapter: "asc" },
    });
    return resources.map((r) => r.chapter as string);
  }),

  // ─── Skill Matrix Import ────────────────────────────────────────────────────

  importSkillMatrix: protectedProcedure
    .input(
      z.object({
        skills: z.array(SkillEntrySchema),
        employeeInfo: z
          .object({
            roleId: z.string().optional(),
            yearsOfExperience: z.number().optional(),
            portfolioUrl: z.string().url().optional().or(z.literal("")),
          })
          .optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      // Find the resource linked to this user
      const user = await findUniqueOrThrow(
        ctx.db.user.findUnique({
          where: { email: ctx.session.user?.email ?? "" },
          include: { resource: true },
        }),
        "User",
      );
      if (!user.resource) {
        throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
      }
      const resourceId = user.resource.id;

      await ctx.db.resource.update({
        where: { id: resourceId },
        data: {
          skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
          skillMatrixUpdatedAt: new Date(),
          ...(input.employeeInfo?.portfolioUrl !== undefined
            ? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
            : {}),
          ...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
        },
      });

      return { count: input.skills.length };
    }),

  importSkillMatrixForResource: managerProcedure
    .input(
      z.object({
        resourceId: z.string(),
        skills: z.array(SkillEntrySchema),
        employeeInfo: z
          .object({
            roleId: z.string().optional(),
            yearsOfExperience: z.number().optional(),
            portfolioUrl: z.string().url().optional().or(z.literal("")),
          })
          .optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
      await findUniqueOrThrow(
        ctx.db.resource.findUnique({ where: { id: input.resourceId } }),
        "Resource",
      );

      await ctx.db.resource.update({
        where: { id: input.resourceId },
        data: {
          skills: input.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
          skillMatrixUpdatedAt: new Date(),
          ...(input.employeeInfo?.portfolioUrl !== undefined
            ? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
            : {}),
          ...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
        },
      });

      return { count: input.skills.length };
    }),

  batchImportSkillMatrices: adminProcedure
    .input(
      z.object({
        entries: z.array(
          z.object({
            eid: z.string(),
            skills: z.array(SkillEntrySchema),
            employeeInfo: z
              .object({
                roleId: z.string().optional(),
                yearsOfExperience: z.number().optional(),
                portfolioUrl: z.string().url().optional().or(z.literal("")),
              })
              .optional(),
          }),
        ),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      // Single findMany to avoid N+1 (was: findUnique per entry)
      const eids = input.entries.map((e) => e.eid);
      const existing = await ctx.db.resource.findMany({
        where: { eid: { in: eids } },
        select: { id: true, eid: true },
      });
      const eidToId = new Map(existing.map((r) => [r.eid, r.id]));
      const notFound = input.entries.length - existing.length;

      const now = new Date();
      const updates = input.entries
        .filter((entry) => eidToId.has(entry.eid))
        .map((entry) =>
          ctx.db.resource.update({
            where: { id: eidToId.get(entry.eid)! },
            data: {
              skills: entry.skills as unknown as import("@capakraken/db").Prisma.InputJsonValue,
              skillMatrixUpdatedAt: now,
              ...(entry.employeeInfo?.portfolioUrl !== undefined
                ? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
                : {}),
              ...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
            },
          }),
        );

      await ctx.db.$transaction(updates);
      return { updated: updates.length, notFound };
    }),

  // ─── AI Summary ─────────────────────────────────────────────────────────────

  generateAiSummary: managerProcedure
    .input(z.object({ resourceId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const [resource, settings] = await Promise.all([
        findUniqueOrThrow(
          ctx.db.resource.findUnique({
            where: { id: input.resourceId },
            include: { areaRole: { select: { name: true } } },
          }),
          "Resource",
        ),
        ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
      ]);

      if (!isAiConfigured(settings)) {
        throw new TRPCError({
          code: "PRECONDITION_FAILED",
          message: "AI is not configured. Please set credentials in Admin → Settings.",
        });
      }

      type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
      const skills = (resource.skills as unknown as SkillRow[]) ?? [];
      const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill);
      const top10 = [...skills]
        .sort((a, b) => b.proficiency - a.proficiency)
        .slice(0, 10)
        .map((s) => `${s.skill} (${s.proficiency}/5)`);

      const vars = {
        role: resource.areaRole?.name ?? "Not specified",
        chapter: resource.chapter ?? "Not specified",
        mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
        topSkills: top10.join(", "),
      };

      const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
      const prompt = templateStr
        .replace("{role}", vars.role)
        .replace("{chapter}", vars.chapter)
        .replace("{mainSkills}", vars.mainSkills)
        .replace("{topSkills}", vars.topSkills);

      const client = createAiClient(settings!);
      const model = settings!.azureOpenAiDeployment!;
      const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
      const temperature = settings!.aiTemperature ?? 1;

      const provider = settings!.aiProvider ?? "openai";
      async function callChatCompletions(withTemperature: boolean) {
        return loggedAiCall(provider, model, prompt.length, () =>
          client.chat.completions.create({
            messages: [{ role: "user", content: prompt }],
            max_completion_tokens: maxTokens,
            model,
            ...(withTemperature && temperature !== 1 ? { temperature } : {}),
          }),
        );
      }

      let summary = "";
      try {
        let completion;
        try {
          completion = await callChatCompletions(true);
          console.log("[generateAiSummary] chat.completions response:", JSON.stringify({
            choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })),
          }));
        } catch (tempErr) {
          const status = (tempErr as { status?: number }).status;
          const msg = (tempErr as Error).message ?? "";
          console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200));
          if (status === 400 && msg.includes("temperature")) {
            completion = await callChatCompletions(false);
          } else if (status === 404) {
            console.log("[generateAiSummary] falling back to responses API");
            const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
            console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100));
            summary = resp.output_text?.trim() ?? "";
            completion = null;
          } else {
            throw tempErr;
          }
        }
        if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? "";
      } catch (e) {
        throw e;
      }

      await ctx.db.resource.update({
        where: { id: input.resourceId },
        data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
      });

      return { summary };
    }),

  // ─── Skills Analytics ───────────────────────────────────────────────────────

  getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
    const resources = await ctx.db.resource.findMany({
      where: { isActive: true },
      select: { id: true, displayName: true, chapter: true, skills: true },
    });

    type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };

    // Aggregate: { skillName, category, count, totalProficiency, chapters }
    const skillMap = new Map<
      string,
      { skill: string; category: string; count: number; totalProficiency: number; chapters: Set<string> }
    >();

    for (const resource of resources) {
      const skills = (resource.skills as unknown as SkillRow[]) ?? [];
      for (const s of skills) {
        const key = s.skill;
        if (!skillMap.has(key)) {
          skillMap.set(key, {
            skill: s.skill,
            category: s.category ?? "Uncategorized",
            count: 0,
            totalProficiency: 0,
            chapters: new Set(),
          });
        }
        const entry = skillMap.get(key)!;
        entry.count++;
        entry.totalProficiency += s.proficiency;
        if (resource.chapter) entry.chapters.add(resource.chapter);
      }
    }

    const aggregated = Array.from(skillMap.values())
      .map((e) => ({
        skill: e.skill,
        category: e.category,
        count: e.count,
        avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
        chapters: Array.from(e.chapters),
      }))
      .sort((a, b) => b.count - a.count);

    const categories = [...new Set(aggregated.map((e) => e.category))].sort();
    const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[];

    return {
      totalResources: resources.length,
      totalSkillEntries: aggregated.length,
      aggregated,
      categories,
      allChapters,
    };
  }),

  searchBySkills: controllerProcedure
    .input(
      z.object({
        rules: z.array(
          z.object({
            skill: z.string().min(1),
            minProficiency: z.number().int().min(1).max(5).default(1),
          }),
        ),
        chapter: z.string().optional(),
        operator: z.enum(["AND", "OR"]).default("AND"),
      }),
    )
    .query(async ({ ctx, input }) => {
      const { rules, chapter, operator } = input;

      const resources = await ctx.db.resource.findMany({
        where: { isActive: true, ...(chapter ? { chapter } : {}) },
        select: { id: true, eid: true, displayName: true, chapter: true, skills: true },
      });

      type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };

      const results = resources
        .map((r) => {
          const skills = (r.skills as unknown as SkillRow[]) ?? [];

          const matchFn = (rule: { skill: string; minProficiency: number }) => {
            const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase()));
            return s && s.proficiency >= rule.minProficiency ? s : null;
          };

          const matched = rules.map(matchFn);
          const passes =
            operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);

          if (!passes) return null;

          return {
            id: r.id,
            eid: r.eid,
            displayName: r.displayName,
            chapter: r.chapter,
            matchedSkills: rules
              .map((rule, i) => {
                const s = matched[i];
                return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null;
              })
              .filter((s): s is { skill: string; proficiency: number; category: string } => s !== null),
          };
        })
        .filter((r): r is NonNullable<typeof r> => r !== null)
        .sort((a, b) => a.displayName.localeCompare(b.displayName));

      const directory = await getAnonymizationDirectory(ctx.db);
      return anonymizeResources(results, directory);
    }),

  // ─── Self-service ────────────────────────────────────────────────────────────

  /** Get the resource linked to the current user (for self-service pages). */
  getMyResource: protectedProcedure.query(async ({ ctx }) => {
    const email = ctx.session.user?.email;
    if (!email) return null;
    const user = await ctx.db.user.findUnique({
      where: { email },
      select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
    });
    const directory = await getAnonymizationDirectory(ctx.db);
    return user?.resource ? anonymizeResource(user.resource, directory) : null;
  }),

  // ─── Value Score ─────────────────────────────────────────────────────────────

  getValueScores: protectedProcedure
    .input(
      z.object({
        isActive: z.boolean().optional().default(true),
        limit: z.number().int().min(1).max(500).default(100),
      }),
    )
    .query(async ({ ctx, input }) => {
      const permissions = resolveResourcePermissions(ctx);
      requirePermission({ permissions }, PermissionKey.VIEW_SCORES);

      const resources = await ctx.db.resource.findMany({
        where: { isActive: input.isActive },
        select: {
          id: true,
          eid: true,
          displayName: true,
          chapter: true,
          lcrCents: true,
          valueScore: true,
          valueScoreBreakdown: true,
          valueScoreUpdatedAt: true,
        },
        orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
        take: input.limit,
      });

      const directory = await getAnonymizationDirectory(ctx.db);
      return anonymizeResources(resources, directory);
    }),

  recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
    return recomputeResourceValueScores(ctx.db);
  }),

  listWithUtilization: controllerProcedure
    .input(
      z.object({
        startDate: z.string().datetime().optional(),
        endDate: z.string().datetime().optional(),
        chapter: z.string().optional(),
        includeProposed: z.boolean().default(false),
        limit: z.number().int().min(1).max(500).default(100),
      }),
    )
    .query(async ({ ctx, input }) => {
      const now = new Date();
      const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
      const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);

      const resources = await ctx.db.resource.findMany({
        where: {
          isActive: true,
          ...(input.chapter ? { chapter: input.chapter } : {}),
        },
        take: input.limit,
        orderBy: { displayName: "asc" },
        select: {
          id: true,
          eid: true,
          displayName: true,
          email: true,
          chapter: true,
          lcrCents: true,
          ucrCents: true,
          currency: true,
          chargeabilityTarget: true,
          availability: true,
          skills: true,
          dynamicFields: true,
          blueprintId: true,
          isActive: true,
          createdAt: true,
          updatedAt: true,
          roleId: true,
          portfolioUrl: true,
          postalCode: true,
          federalState: true,
          countryId: true,
          metroCityId: true,
          valueScore: true,
          valueScoreBreakdown: true,
          valueScoreUpdatedAt: true,
          userId: true,
          country: { select: { code: true } },
          metroCity: { select: { name: true } },
        },
      });
      const bookings = await listAssignmentBookings(ctx.db, {
        startDate: start,
        endDate: end,
        resourceIds: resources.map((resource) => resource.id),
      });
      const bookingsByResourceId = new Map<string, typeof bookings>();
      for (const booking of bookings) {
        if (!booking.resourceId) {
          continue;
        }
        const items = bookingsByResourceId.get(booking.resourceId) ?? [];
        items.push(booking);
        bookingsByResourceId.set(booking.resourceId, items);
      }
      const contexts = await loadResourceDailyAvailabilityContexts(
        ctx.db,
        resources.map((resource) => ({
          id: resource.id,
          availability: resource.availability as unknown as WeekdayAvailability,
          countryId: resource.countryId,
          countryCode: resource.country?.code,
          federalState: resource.federalState,
          metroCityId: resource.metroCityId,
          metroCityName: resource.metroCity?.name,
        })),
        start,
        end,
      );
      const directory = await getAnonymizationDirectory(ctx.db);

      return resources.map((r) => {
        const availability = r.availability as unknown as WeekdayAvailability;
        const context = contexts.get(r.id);
        const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
          (booking) =>
            booking.resourceId === r.id &&
            (input.includeProposed || booking.status !== "PROPOSED"),
        );
        const availableHours = calculateEffectiveAvailableHours({
          availability,
          periodStart: start,
          periodEnd: end,
          context,
        });
        const bookedHours = resourceBookings.reduce(
          (sum, booking) => sum + calculateEffectiveBookedHours({
            availability,
            startDate: booking.startDate,
            endDate: booking.endDate,
            hoursPerDay: booking.hoursPerDay,
            periodStart: start,
            periodEnd: end,
            context,
          }),
          0,
        );
        const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
        const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
          const date = new Date(`${isoDate}T00:00:00.000Z`);
          const dayCapacity = calculateEffectiveDayAvailability({
            availability,
            date,
            context,
          });
          return dayCapacity > 0 && hours > dayCapacity;
        });

        const utilizationPercent =
          availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;

        return anonymizeResource({
          ...r,
          bookingCount: resourceBookings.length,
          bookedHours: Math.round(bookedHours),
          availableHours: Math.round(availableHours),
          utilizationPercent,
          isOverbooked,
        }, directory);
      });
    }),

  getChargeabilityStats: controllerProcedure
    .input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      const now = new Date();
      const start = new Date(now.getFullYear(), now.getMonth(), 1);
      const end   = new Date(now.getFullYear(), now.getMonth() + 1, 0);

      const resources = await ctx.db.resource.findMany({
        where: {
          isActive: true,
          ...(input.resourceId ? { id: input.resourceId } : {}),
        },
        select: {
          id: true,
          eid: true,
          displayName: true,
          chapter: true,
          chargeabilityTarget: true,
          availability: true,
          countryId: true,
          federalState: true,
          metroCityId: true,
          country: { select: { code: true } },
          metroCity: { select: { name: true } },
        },
      });
      const bookings = await listAssignmentBookings(ctx.db, {
        startDate: start,
        endDate: end,
        resourceIds: resources.map((resource) => resource.id),
      });
      const contexts = await loadResourceDailyAvailabilityContexts(
        ctx.db,
        resources.map((resource) => ({
          id: resource.id,
          availability: resource.availability as unknown as WeekdayAvailability,
          countryId: resource.countryId,
          countryCode: resource.country?.code,
          federalState: resource.federalState,
          metroCityId: resource.metroCityId,
          metroCityName: resource.metroCity?.name,
        })),
        start,
        end,
      );
      const directory = await getAnonymizationDirectory(ctx.db);

      return resources.map((r) => {
        const avail = r.availability as unknown as WeekdayAvailability;
        const context = contexts.get(r.id);
        const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);

        const actualAllocs = resourceBookings.filter((booking) =>
          isChargeabilityActualBooking(booking, input.includeProposed),
        );

        const expectedAllocs = resourceBookings.filter((booking) =>
          isChargeabilityRelevantProject(booking.project, true),
        );

        const availableHours = calculateEffectiveAvailableHours({
          availability: avail,
          periodStart: start,
          periodEnd: end,
          context,
        });
        const actualBookedHours = actualAllocs.reduce(
          (sum, booking) => sum + calculateEffectiveBookedHours({
            availability: avail,
            startDate: booking.startDate,
            endDate: booking.endDate,
            hoursPerDay: booking.hoursPerDay,
            periodStart: start,
            periodEnd: end,
            context,
          }),
          0,
        );
        const expectedBookedHours = expectedAllocs.reduce(
          (sum, booking) => sum + calculateEffectiveBookedHours({
            availability: avail,
            startDate: booking.startDate,
            endDate: booking.endDate,
            hoursPerDay: booking.hoursPerDay,
            periodStart: start,
            periodEnd: end,
            context,
          }),
          0,
        );
        const actualChargeability = availableHours > 0
          ? Math.round((actualBookedHours / availableHours) * 100)
          : 0;
        const expectedChargeability = availableHours > 0
          ? Math.round((expectedBookedHours / availableHours) * 100)
          : 0;

        return anonymizeResource({
          id:                      r.id,
          eid:                     r.eid,
          displayName:             r.displayName,
          chapter:                 r.chapter,
          chargeabilityTarget:     r.chargeabilityTarget,
          actualChargeability,
          expectedChargeability,
          availableHours:          Math.round(availableHours),
        }, directory);
      });
    }),

  /**
   * Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys).
   */
  batchUpdateCustomFields: managerProcedure
    .input(z.object({
      ids: z.array(z.string()).min(1).max(100),
      fields: z.record(z.string(), z.unknown()),
    }))
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);

      await ctx.db.$transaction(
        input.ids.map((id) =>
          ctx.db.$executeRaw`
            UPDATE "Resource"
            SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
            WHERE id = ${id}
          `,
        ),
      );

      await ctx.db.auditLog.create({
        data: {
          entityType: "Resource",
          entityId: input.ids.join(","),
          action: "UPDATE",
          changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@capakraken/db").Prisma.InputJsonValue,
        },
      });

      return { updated: input.ids.length };
    }),

  // ─── Skill Marketplace ────────────────────────────────────────────────────

  getSkillMarketplace: controllerProcedure
    .input(
      z.object({
        // Section 1: Skill search
        searchSkill: z.string().optional(),
        minProficiency: z.number().int().min(1).max(5).optional().default(1),
        availableOnly: z.boolean().optional().default(false),
      }),
    )
    .query(async ({ ctx, input }) => {
      const now = new Date();
      const today = new Date(now);
      today.setUTCHours(0, 0, 0, 0);
      const thirtyDaysFromNow = new Date(today);
      thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);

      type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };

      // ── Fetch all active resources with skills ──
      const resources = await ctx.db.resource.findMany({
        where: { isActive: true },
        select: {
          id: true,
          displayName: true,
          eid: true,
          chapter: true,
          skills: true,
          availability: true,
          chargeabilityTarget: true,
          countryId: true,
          federalState: true,
          metroCityId: true,
          country: { select: { code: true } },
          metroCity: { select: { name: true } },
        },
      });

      // ── Fetch current assignments for utilization calc ──
      const allResourceIds = resources.map((r) => r.id);
      const assignments = await ctx.db.assignment.findMany({
        where: {
          resourceId: { in: allResourceIds },
          status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
          endDate: { gte: today },
          startDate: { lte: thirtyDaysFromNow },
        },
        select: {
          resourceId: true,
          startDate: true,
          endDate: true,
          hoursPerDay: true,
        },
      });
      const contexts = await loadResourceDailyAvailabilityContexts(
        ctx.db,
        resources.map((resource) => ({
          id: resource.id,
          availability: resource.availability as unknown as WeekdayAvailability,
          countryId: resource.countryId,
          countryCode: resource.country?.code,
          federalState: resource.federalState,
          metroCityId: resource.metroCityId,
          metroCityName: resource.metroCity?.name,
        })),
        today,
        thirtyDaysFromNow,
      );
      const assignmentsByResourceId = new Map<string, typeof assignments>();
      for (const assignment of assignments) {
        const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
        items.push(assignment);
        assignmentsByResourceId.set(assignment.resourceId, items);
      }

      // Build utilization map with holiday-aware daily capacity over the next 30 days.
      const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
      for (const r of resources) {
        const availability = r.availability as unknown as WeekdayAvailability;
        const context = contexts.get(r.id);
        const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
        const todayAvailableHours = calculateEffectiveAvailableHours({
          availability,
          periodStart: today,
          periodEnd: today,
          context,
        });
        const todayBookedHours = resourceAssignments.reduce(
          (sum, assignment) => sum + calculateEffectiveBookedHours({
            availability,
            startDate: assignment.startDate,
            endDate: assignment.endDate,
            hoursPerDay: assignment.hoursPerDay,
            periodStart: today,
            periodEnd: today,
            context,
          }),
          0,
        );
        const utilizationPercent = todayAvailableHours > 0
          ? Math.round((todayBookedHours / todayAvailableHours) * 100)
          : 0;
        const dailyBookedHours = buildDailyBookedHoursMap(
          resourceAssignments,
          availability,
          context,
          today,
          thirtyDaysFromNow,
        );

        let earliestAvailableDate: Date | null = null;
        const checkDate = new Date(today);
        for (let i = 0; i < 30; i++) {
          const dayAvailableHours = calculateEffectiveDayAvailability({
            availability,
            date: checkDate,
            context,
          });
          if (dayAvailableHours > 0) {
            const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
            if (dayBookedHours < dayAvailableHours * 0.8) {
              earliestAvailableDate = new Date(checkDate);
              break;
            }
          }
          checkDate.setUTCDate(checkDate.getUTCDate() + 1);
        }

        utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
      }

      // ── Section 1: Skill Search ──
      let searchResults: Array<{
        id: string;
        displayName: string;
        chapter: string | null;
        skillProficiency: number;
        skillName: string;
        utilizationPercent: number;
        availableFrom: string | null;
      }> = [];

      if (input.searchSkill && input.searchSkill.trim().length > 0) {
        const needle = input.searchSkill.toLowerCase();
        for (const r of resources) {
          const skills = (r.skills as unknown as SkillRow[]) ?? [];
          const match = skills.find(
            (s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency,
          );
          if (!match) continue;

          const util = utilizationMap.get(r.id);
          if (input.availableOnly && !util?.earliestAvailableDate) continue;

          searchResults.push({
            id: r.id,
            displayName: r.displayName,
            chapter: r.chapter,
            skillProficiency: match.proficiency,
            skillName: match.skill,
            utilizationPercent: util?.utilizationPercent ?? 0,
            availableFrom: util?.earliestAvailableDate?.toISOString() ?? null,
          });
        }
        searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent);
      }

      // ── Section 2: Skill Gap Heat Map ──
      // Demand: from unfilled DemandRequirements + project staffingReqs skills
      const unfilled = await ctx.db.demandRequirement.findMany({
        where: {
          endDate: { gte: now },
          assignments: { none: {} },
        },
        select: {
          id: true,
          role: true,
          roleId: true,
          headcount: true,
          project: {
            select: { staffingReqs: true },
          },
        },
      });

      // Collect demanded skills from project staffingReqs
      const demandSkillCounts = new Map<string, number>();
      for (const demand of unfilled) {
        const staffingReqs = (demand.project.staffingReqs as unknown as Array<{
          role?: string;
          roleId?: string;
          requiredSkills?: string[];
        }>) ?? [];

        // Match demand to staffing req by role or roleId
        const matchedReq = staffingReqs.find(
          (sr) =>
            (demand.roleId && sr.roleId === demand.roleId) ||
            (demand.role && sr.role === demand.role),
        );

        if (matchedReq?.requiredSkills) {
          for (const skill of matchedReq.requiredSkills) {
            demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount);
          }
        }
      }

      // Supply: count resources with skill at proficiency >= 3
      const supplySkillCounts = new Map<string, number>();
      const allSkillCounts = new Map<string, number>();
      for (const r of resources) {
        const skills = (r.skills as unknown as SkillRow[]) ?? [];
        for (const s of skills) {
          allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1);
          if (s.proficiency >= 3) {
            supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1);
          }
        }
      }

      // Merge all skill names from both demand and supply
      const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]);
      const gapData = Array.from(allGapSkills)
        .map((skill) => {
          const supply = supplySkillCounts.get(skill) ?? 0;
          const demand = demandSkillCounts.get(skill) ?? 0;
          return { skill, supply, demand, gap: demand - supply };
        })
        .sort((a, b) => b.gap - a.gap);

      // ── Section 3: Distribution (top 20 by resource count) ──
      const aggregated = Array.from(
        (() => {
          const map = new Map<string, { skill: string; count: number; totalProficiency: number }>();
          for (const r of resources) {
            const skills = (r.skills as unknown as SkillRow[]) ?? [];
            for (const s of skills) {
              const entry = map.get(s.skill);
              if (entry) {
                entry.count++;
                entry.totalProficiency += s.proficiency;
              } else {
                map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency });
              }
            }
          }
          return map;
        })().values(),
      )
        .map((e) => ({
          skill: e.skill,
          count: e.count,
          avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
        }))
        .sort((a, b) => b.count - a.count)
        .slice(0, 20);

      const directory = await getAnonymizationDirectory(ctx.db);

      return {
        searchResults: anonymizeResources(searchResults, directory),
        gapData,
        distribution: aggregated,
        totalResources: resources.length,
      };
    }),
});
