import {
  calculateSAH,
  calculateAllocation,
  deriveResourceForecast,
  computeBudgetStatus,
  getMonthRange,
  DEFAULT_CALCULATION_RULES,
  summarizeEstimateDemandLines,
  computeEvenSpread,
  distributeHoursToWeeks,
  type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, type TRPCContext } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
import {
  asHolidayResolverDb,
  collectHolidayAvailability,
  getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
  calculateEffectiveAvailableHours,
  countEffectiveWorkingDays,
  loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";

// ─── Graph Types (mirrored from client for API response) ────────────────────

type Domain =
  | "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET"
  | "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD";

export interface GraphNode {
  id: string;
  label: string;
  value: number | string;
  unit: string;
  domain: Domain;
  description: string;
  formula?: string;
  level: number;
}

export interface GraphLink {
  source: string;
  target: string;
  formula: string;
  weight: number;
}

// ─── Helpers ────────────────────────────────────────────────────────────────

function n(
  id: string, label: string, value: number | string, unit: string,
  domain: Domain, description: string, level: number, formula?: string,
): GraphNode {
  return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) };
}

function l(source: string, target: string, formula: string, weight = 1): GraphLink {
  return { source, target, formula, weight };
}

function fmtPct(ratio: number): string {
  return `${(ratio * 100).toFixed(1)}%`;
}

function fmtNum(v: number, decimals = 1): string {
  return v.toFixed(decimals);
}

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 filterGraphData<
  TNode extends { id: string; domain: string },
  TLink extends { source: string; target: string },
>(input: {
  nodes: TNode[];
  links: TLink[];
  domain?: string;
  includeLinks?: boolean;
}) {
  const requestedDomain = input.domain?.trim().toUpperCase();
  const nodes = requestedDomain
    ? input.nodes.filter((node) => node.domain === requestedDomain)
    : input.nodes;
  const selectedNodeIds = new Set(nodes.map((node) => node.id));
  const links = input.includeLinks
    ? input.links.filter((link) => selectedNodeIds.has(link.source) && selectedNodeIds.has(link.target))
    : [];

  return {
    requestedDomain: requestedDomain ?? null,
    includedLinks: input.includeLinks ?? false,
    selectedNodeCount: nodes.length,
    selectedLinkCount: links.length,
    nodes,
    ...(input.includeLinks ? { links } : {}),
  };
}

function getAvailableDomains(nodes: Array<{ domain: Domain }>): Domain[] {
  return [...new Set(nodes.map((node) => node.domain))];
}

const resourceGraphInputSchema = z.object({
  resourceId: z.string(),
  month: z.string().regex(/^\d{4}-\d{2}$/),
});

const resourceGraphDetailInputSchema = resourceGraphInputSchema.extend({
  domain: z.string().trim().min(1).optional(),
  includeLinks: z.boolean().optional(),
});

const projectGraphInputSchema = z.object({
  projectId: z.string(),
});

const projectGraphDetailInputSchema = projectGraphInputSchema.extend({
  domain: z.string().trim().min(1).optional(),
  includeLinks: z.boolean().optional(),
});

// ─── Router ─────────────────────────────────────────────────────────────────

export const computationGraphRouter = createTRPCRouter({
  /**
   * Resource View: SAH, Allocation, Rules, Chargeability, Budget
   * for a single resource in a single month.
   */
  getResourceData: controllerProcedure
    .input(resourceGraphInputSchema)
    .query(({ ctx, input }) => readResourceGraphSnapshot(ctx, input)),

  getResourceDataDetail: controllerProcedure
    .input(resourceGraphDetailInputSchema)
    .query(async ({ ctx, input }) => {
      const graph = await readResourceGraphSnapshot(ctx, input);
      return formatResourceGraphDetail({
        resourceId: input.resourceId,
        graph,
        ...(input.domain ? { domain: input.domain } : {}),
        ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
      });
    }),

  /**
   * Project View: Estimate, Commercial, Experience, Effort, Spread, Budget
   */
  getProjectData: controllerProcedure
    .input(projectGraphInputSchema)
    .query(({ ctx, input }) => readProjectGraphSnapshot(ctx, input)),

  getProjectDataDetail: controllerProcedure
    .input(projectGraphDetailInputSchema)
    .query(async ({ ctx, input }) => {
      const graph = await readProjectGraphSnapshot(ctx, input);
      return formatProjectGraphDetail({
        projectId: input.projectId,
        graph,
        ...(input.domain ? { domain: input.domain } : {}),
        ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
      });
    }),
});

async function readResourceGraphSnapshot(
  ctx: { db: TRPCContext["db"] },
  input: z.infer<typeof resourceGraphInputSchema>,
) {
  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 targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);

  const avail = resource.availability as WeekdayAvailability | null;
  const weeklyAvailability: WeekdayAvailability = avail ?? {
    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,
          budgetCents: true,
          winProbability: 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 publicHolidayStrings = holidayAvailability.publicHolidayStrings;
  const absenceDateStrings = holidayAvailability.absenceDateStrings;
  const absenceDays = holidayAvailability.absenceDays;
  const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
  const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
  const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
  const publicHolidayCount = resolvedHolidays.length;
  const absenceDayEquivalent = absenceDays.reduce((sum, absence) => {
    if (absence.type === "PUBLIC_HOLIDAY") {
      return sum;
    }
    return sum + (absence.isHalfDay ? 0.5 : 1);
  }, 0);

  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 sahResult = calculateSAH({
    dailyWorkingHours: dailyHours,
    scheduleRules,
    fte: resource.fte,
    periodStart: monthStart,
    periodEnd: monthEnd,
    publicHolidays: publicHolidayStrings,
    absenceDays: absenceDateStrings,
  });
  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 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 effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
    ? effectiveAvailableHours / effectiveWorkingDays
    : 0;
  const holidayScopeSummary = [
    resource.country?.code ?? "—",
    resource.federalState ?? "—",
    resource.metroCity?.name ?? "—",
  ].join(" / ");
  const holidayExamples = resolvedHolidays.length > 0
    ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
    : "none";
  const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
    counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
    return counts;
  }, {});

  const slices: AssignmentSlice[] = [];
  const assignmentBreakdown: Array<{
    id: string;
    projectId: string;
    projectName: string;
    projectCode: string;
    status: string;
    bookedHours: number;
  }> = [];
  let totalAllocHours = 0;
  let totalAllocCostCents = 0;
  let totalChargeableHours = 0;
  let totalProjectCostCents = 0;
  let hasRulesEffect = false;

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

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

    totalAllocHours += calcResult.totalHours;
    totalAllocCostCents += calcResult.totalCostCents;
    assignmentBreakdown.push({
      id: a.id,
      projectId: a.project.id,
      projectName: a.project.name,
      projectCode: a.project.shortCode,
      status: a.status,
      bookedHours: calcResult.totalHours,
    });
    if (calcResult.totalChargeableHours !== undefined) {
      totalChargeableHours += calcResult.totalChargeableHours;
      totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents;
      hasRulesEffect = true;
    } else {
      totalChargeableHours += calcResult.totalHours;
      totalProjectCostCents += calcResult.totalCostCents;
    }

    slices.push({
      hoursPerDay: a.hoursPerDay,
      workingDays: calcResult.workingDays,
      categoryCode,
      ...(calcResult.totalChargeableHours !== undefined
        ? { totalChargeableHours: calcResult.totalChargeableHours }
        : {}),
    });
  }

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

  const budgetProject = assignments.find((a) => a.project.budgetCents != null && a.project.budgetCents > 0)?.project;
  let budgetNodes: GraphNode[] = [];
  let budgetLinks: GraphLink[] = [];
  if (budgetProject && budgetProject.budgetCents != null) {
    const projectAllocs = await ctx.db.assignment.findMany({
      where: { projectId: budgetProject.id },
      select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
    });
    const budgetStatus = computeBudgetStatus(
      budgetProject.budgetCents,
      budgetProject.winProbability,
      projectAllocs.map((pa) => ({
        status: pa.status as unknown as string,
        dailyCostCents: pa.dailyCostCents,
        startDate: pa.startDate,
        endDate: pa.endDate,
        hoursPerDay: pa.hoursPerDay,
      })) as Parameters<typeof computeBudgetStatus>[2],
      monthStart,
      monthEnd,
    );

    budgetNodes = [
      n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0),
      n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0),
      n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"),
      n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"),
      n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"),
      n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"),
      n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"),
      n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"),
    ];
    budgetLinks = [
      l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 1),
      l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
      l("budget.proposedCents", "budget.allocatedCents", "+", 2),
      l("input.budgetCents", "budget.remainingCents", "−", 2),
      l("budget.allocatedCents", "budget.remainingCents", "−", 2),
      l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
      l("input.budgetCents", "budget.utilizationPct", "÷", 1),
      l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
      l("input.winProbability", "budget.weightedCents", "×", 1),
    ];
  }

  const dailyCostCents = assignments.length > 0
    ? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents)
    : 0;
  const avgHoursPerDay = assignments.length > 0
    ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
    : 0;
  const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
    const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
    const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
    const calcResult = calculateAllocation({
      lcrCents: resource.lcrCents,
      hoursPerDay: a.hoursPerDay,
      startDate: overlapStart,
      endDate: overlapEnd,
      availability: weeklyAvailability,
      absenceDays,
      calculationRules: calcRules,
    });
    return sum + calcResult.workingDays;
  }, 0);

  const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"];
  const weekdayValues = [weeklyAvailability.monday, weeklyAvailability.tuesday, weeklyAvailability.wednesday, weeklyAvailability.thursday, weeklyAvailability.friday];
  const weeklyTotalHours = weekdayValues.reduce((s, v) => s + v, 0);
  const allSame = weekdayValues.every((v) => v === weekdayValues[0]);
  const availabilityLabel = allSame
    ? `${weekdayValues[0]}h/day`
    : weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");

  const utilizationPct = effectiveAvailableHours > 0
    ? (totalAllocHours / effectiveAvailableHours) * 100
    : 0;
  const chargeableHours = forecast.chg * effectiveAvailableHours;
  const hasScheduleRules = !!scheduleRules;

  const nodes: GraphNode[] = [
    n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", "Resource FTE factor", 0),
    n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
    n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
    n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
    n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
    n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
    n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
    ...(hasScheduleRules ? [
      n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
    ] : []),
    n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0),
    n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
    n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
    n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
    n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
    n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
    n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
    n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),

    n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
    n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
    n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
    n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
    n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
    n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
    n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
    n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
    n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
    n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
    n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),

    n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
    n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"),
    n("alloc.dailyCostCents", "Daily Cost", fmtEur(dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"),
    n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"),
    n("alloc.utilizationPct", "Utilization", `${utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"),
    ...(hasRulesEffect ? [
      n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"),
      n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"),
    ] : []),

    ...(absenceDays.length > 0 ? [
      n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1),
      n("rules.costEffect", "Cost Effect", hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"),
      n("rules.chgEffect", "Chg Effect", hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"),
      ...(hasRulesEffect ? [
        n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100"),
      ] : []),
    ] : []),

    n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
    n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
    ...(forecast.bd > 0 ? [
      n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
    ] : []),
    ...(forecast.mdi > 0 ? [
      n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
    ] : []),
    ...(forecast.mo > 0 ? [
      n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
    ] : []),
    ...(forecast.pdr > 0 ? [
      n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
    ] : []),
    ...(forecast.absence > 0 ? [
      n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
    ] : []),
    n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
    n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
    n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),

    ...budgetNodes,
  ];

  const links: GraphLink[] = [
    l("input.country", "input.holidayContext", "holiday base", 1),
    l("input.state", "input.holidayContext", "regional scope", 1),
    l("input.city", "input.holidayContext", "local scope", 1),
    l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
    l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
    l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
    l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
    l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
    l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
    l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
    ...(hasScheduleRules ? [
      l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
    ] : []),
    l("sah.calendarDays", "sah.grossWorkingDays", "− weekends", 2),
    l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
    l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
    l("input.absences", "sah.absenceDays", "∩ workdays", 1),
    l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2),
    l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
    l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
    l("sah.baseHours", "sah.sah", "start from base capacity", 2),
    l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2),
    l("sah.absenceHours", "sah.sah", "− absence hours", 2),
    l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
    l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),

    l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
    l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1),
    l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2),
    l("input.hoursPerDay", "alloc.workingDays", "per assignment", 1),
    l("input.assignmentCount", "alloc.workingDays", "× overlap", 1),
    l("alloc.workingDays", "alloc.totalHours", "× h/day", 2),
    l("input.hoursPerDay", "alloc.totalHours", "× workdays", 1),
    l("alloc.dailyCostCents", "alloc.totalCostCents", "× workdays", 2),
    l("alloc.workingDays", "alloc.totalCostCents", "×", 1),
    l("alloc.totalHours", "alloc.utilizationPct", "÷ SAH × 100", 2),
    l("sah.sah", "alloc.utilizationPct", "÷", 1),

    ...(absenceDays.length > 0 ? [
      l("input.calcRules", "rules.activeRules", "filter active", 1),
      l("input.absences", "rules.activeRules", "match trigger", 1),
      l("rules.activeRules", "rules.costEffect", "→ effect", 1),
      l("rules.activeRules", "rules.chgEffect", "→ effect", 1),
    ] : []),

    ...(hasRulesEffect ? [
      l("rules.costEffect", "alloc.projectCostCents", "apply", 2),
      l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
      l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
      l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1),
      ...(absenceDays.length > 0 ? [
        l("rules.costEffect", "rules.costReduction", "reduce %", 1),
      ] : []),
    ] : []),

    l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2),
    l("chg.chgHours", "chg.chg", "÷ SAH", 2),
    l("sah.sah", "chg.chg", "÷", 2),
    ...(forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []),
    ...(forecast.mdi > 0 ? [l("sah.sah", "chg.mdi", "÷", 1)] : []),
    ...(forecast.mo > 0 ? [l("sah.sah", "chg.mo", "÷", 1)] : []),
    ...(forecast.pdr > 0 ? [l("sah.sah", "chg.pdr", "÷", 1)] : []),
    ...(forecast.absence > 0 ? [l("sah.sah", "chg.absence", "÷", 1)] : []),
    l("sah.sah", "chg.unassigned", "− assigned ÷ SAH", 1),
    l("chg.chgHours", "chg.unassigned", "SAH − Σ", 1),
    l("input.targetPct", "chg.target", "=", 1),
    l("chg.chg", "chg.gap", "−", 2),
    l("chg.target", "chg.gap", "−", 1),

    ...budgetLinks,
  ];

  return {
    nodes,
    links,
    meta: {
      resourceName: resource.displayName,
      resourceEid: resource.eid,
      month: input.month,
      assignmentCount: assignments.length,
      countryCode: resource.country?.code ?? null,
      countryName: resource.country?.name ?? null,
      federalState: resource.federalState ?? null,
      metroCityName: resource.metroCity?.name ?? null,
      resolvedHolidays: resolvedHolidays.map((holiday) => ({
        date: holiday.date,
        name: holiday.name,
        scope: holiday.scope,
        calendarName: holiday.calendarName,
        sourceType: holiday.sourceType,
      })),
      factors: {
        fte: resource.fte,
        targetPct: targetPct * 100,
        weeklyAvailability,
        baseWorkingDays,
        effectiveWorkingDays,
        baseAvailableHours,
        effectiveAvailableHours,
        publicHolidayCount,
        publicHolidayWorkdayCount,
        publicHolidayHoursDeduction,
        absenceDayCount: absenceDateStrings.length,
        absenceDayEquivalent,
        absenceHoursDeduction,
        bookedHours: totalAllocHours,
        chargeableHours,
        chargeabilityPct: forecast.chg * 100,
        utilizationPct,
      },
      assignments: assignmentBreakdown.map((assignment) => ({
        ...assignment,
        bookedHours: Number(assignment.bookedHours.toFixed(1)),
      })),
    },
  };
}

async function readProjectGraphSnapshot(
  ctx: { db: TRPCContext["db"] },
  input: z.infer<typeof projectGraphInputSchema>,
) {
  const project = await ctx.db.project.findUniqueOrThrow({
    where: { id: input.projectId },
    select: {
      id: true,
      name: true,
      shortCode: true,
      budgetCents: true,
      winProbability: true,
      startDate: true,
      endDate: true,
    },
  });

  const estimate = await ctx.db.estimate.findFirst({
    where: { projectId: input.projectId },
    select: {
      id: true,
      versions: {
        orderBy: { versionNumber: "desc" },
        take: 1,
        select: {
          id: true,
          commercialTerms: true,
          demandLines: {
            select: {
              id: true,
              hours: true,
              costRateCents: true,
              billRateCents: true,
              costTotalCents: true,
              priceTotalCents: true,
              chapter: true,
              monthlySpread: true,
              scopeItemId: true,
              resourceId: true,
            },
          },
          scopeItems: {
            select: {
              id: true,
              name: true,
              scopeType: true,
              frameCount: true,
              itemCount: true,
              unitMode: true,
            },
          },
          resourceSnapshots: {
            select: {
              id: true,
              resourceId: true,
              displayName: true,
              chapter: true,
              lcrCents: true,
              ucrCents: true,
              location: true,
              level: true,
            },
          },
        },
      },
    },
    orderBy: { updatedAt: "desc" },
  });
  const latestVersion = estimate?.versions[0];

  let effortRuleCount = 0;
  let experienceRuleCount = 0;
  try {
    effortRuleCount = await ctx.db.effortRule.count();
    experienceRuleCount = await ctx.db.experienceMultiplierRule.count();
  } catch {
    // tables may not exist yet
  }

  const nodes: GraphNode[] = [];
  const links: GraphLink[] = [];

  const hasBudget = project.budgetCents > 0;
  const hasDateRange = !!(project.startDate && project.endDate);
  nodes.push(
    n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0),
    n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0),
    ...(hasDateRange ? [
      n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0),
      n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0),
    ] : []),
  );

  if (latestVersion && latestVersion.demandLines.length > 0) {
    const lines = latestVersion.demandLines;
    const summary = summarizeEstimateDemandLines(lines);
    const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary;

    const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
    const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0;

    const chapterMap = new Map<string, number>();
    for (const dl of lines) {
      const ch = dl.chapter ?? "(none)";
      chapterMap.set(ch, (chapterMap.get(ch) ?? 0) + dl.hours);
    }
    const chapterCount = chapterMap.size;

    const snapshotCount = latestVersion.resourceSnapshots?.length ?? 0;

    nodes.push(
      n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0),
      n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0),
      n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0),
      ...(snapshotCount > 0 ? [
        n("input.resourceSnapshots", "Res. Snapshots", `${snapshotCount}`, "count", "INPUT", "Resource rate snapshots frozen in estimate version", 0),
      ] : []),
      n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"),
      n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"),
      n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"),
      n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"),
      n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"),
      ...(chapterCount > 1 ? [
        n("est.chapters", "Chapters", `${chapterCount}`, "count", "ESTIMATE", `Demand lines grouped by ${chapterCount} chapters`, 1),
      ] : []),
    );

    links.push(
      l("input.estLines", "est.totalHours", "Σ hours", 1),
      l("input.avgCostRate", "est.totalCostCents", "× hours", 2),
      l("est.totalHours", "est.totalCostCents", "× costRate", 2),
      l("input.avgBillRate", "est.totalPriceCents", "× hours", 2),
      l("est.totalHours", "est.totalPriceCents", "× billRate", 2),
      l("est.totalPriceCents", "est.marginCents", "−", 2),
      l("est.totalCostCents", "est.marginCents", "−", 2),
      l("est.marginCents", "est.marginPercent", "÷ price × 100", 2),
      l("est.totalPriceCents", "est.marginPercent", "÷", 1),
      ...(snapshotCount > 0 ? [
        l("input.resourceSnapshots", "input.avgCostRate", "LCR snapshot", 1),
        l("input.resourceSnapshots", "input.avgBillRate", "UCR snapshot", 1),
      ] : []),
      ...(chapterCount > 1 ? [
        l("input.estLines", "est.chapters", "group by", 1),
        l("est.chapters", "est.totalHours", "Σ per chapter", 1),
      ] : []),
    );

    const scopeItems = latestVersion.scopeItems ?? [];
    if (scopeItems.length > 0) {
      const totalFrameCount = scopeItems.reduce((s, si) => s + (si.frameCount ?? 0), 0);
      const totalItemCount = scopeItems.reduce((s, si) => s + (si.itemCount ?? 0), 0);
      const scopeTypes = new Set(scopeItems.map((si) => si.scopeType));

      nodes.push(
        n("effort.scopeItems", "Scope Items", `${scopeItems.length}`, "count", "EFFORT", `${scopeItems.length} scope items across ${scopeTypes.size} type(s)`, 0),
        ...(totalFrameCount > 0 ? [
          n("effort.totalFrames", "Total Frames", `${totalFrameCount}`, "frames", "EFFORT", "Sum of frame counts across scope items", 1),
        ] : []),
        ...(totalItemCount > 0 ? [
          n("effort.totalItems", "Total Items", fmtNum(totalItemCount), "items", "EFFORT", "Sum of item counts across scope items", 1),
        ] : []),
        n("effort.effortRules", "Effort Rules", `${effortRuleCount}`, "count", "EFFORT", "Configured effort expansion rules (scopeType → discipline)", 0),
        n("effort.expandedHours", "Expanded Hours", fmtNum(totalHours), "hours", "EFFORT", "Total hours from scope-to-effort expansion (unitCount × hoursPerUnit)", 2, "Σ(unitCount × hoursPerUnit)"),
      );

      links.push(
        l("effort.scopeItems", "effort.expandedHours", "expand", 2),
        l("effort.effortRules", "effort.expandedHours", "× hoursPerUnit", 2),
        ...(totalFrameCount > 0 ? [
          l("effort.scopeItems", "effort.totalFrames", "Σ frames", 1),
          l("effort.totalFrames", "effort.expandedHours", "per_frame", 1),
        ] : []),
        ...(totalItemCount > 0 ? [
          l("effort.scopeItems", "effort.totalItems", "Σ items", 1),
          l("effort.totalItems", "effort.expandedHours", "per_item", 1),
        ] : []),
        l("effort.expandedHours", "est.totalHours", "→ demand lines", 2),
      );
    }

    if (experienceRuleCount > 0) {
      nodes.push(
        n("exp.ruleCount", "Exp. Rules", `${experienceRuleCount}`, "count", "EXPERIENCE", "Experience multiplier rules (chapter/location/level → rate adjustments)", 0),
        n("exp.costMultiplier", "Cost Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to cost rate (costRateCents × multiplier)", 1, "costRate × costMultiplier"),
        n("exp.billMultiplier", "Bill Multiplier", "per rule", "×", "EXPERIENCE", "Multiplier applied to bill rate (billRateCents × multiplier)", 1, "billRate × billMultiplier"),
        n("exp.shoringRatio", "Shoring Ratio", "per rule", "ratio", "EXPERIENCE", "Offshore/nearshore effort factor (onsiteHours + offshoreHours × (1 + additionalEffort))", 2, "onsite + offshore × (1 + addlEffort)"),
        n("exp.adjustedRates", "Adjusted Rates", "applied", "—", "EXPERIENCE", "Final cost and bill rates after experience multipliers", 2, "rate × multiplier"),
      );

      links.push(
        l("exp.ruleCount", "exp.costMultiplier", "match rule", 1),
        l("exp.ruleCount", "exp.billMultiplier", "match rule", 1),
        l("exp.ruleCount", "exp.shoringRatio", "match rule", 1),
        l("exp.costMultiplier", "exp.adjustedRates", "×", 2),
        l("exp.billMultiplier", "exp.adjustedRates", "×", 2),
        l("exp.shoringRatio", "exp.adjustedRates", "adjust hours", 1),
        l("exp.adjustedRates", "est.totalCostCents", "→ costRate", 1),
        l("exp.adjustedRates", "est.totalPriceCents", "→ billRate", 1),
      );
    }

    const terms = latestVersion.commercialTerms as {
      contingencyPercent?: number;
      discountPercent?: number;
      pricingModel?: string;
      paymentTermDays?: number;
      warrantyMonths?: number;
      paymentMilestones?: Array<{ label: string; percent: number; dueDate?: string | null }>;
    } | null;

    const hasCommercialAdjustments = terms && (terms.contingencyPercent || terms.discountPercent);
    const hasCommercialMeta = terms && (terms.pricingModel || terms.paymentTermDays || terms.warrantyMonths);

    if (hasCommercialAdjustments) {
      const contingencyPct = terms!.contingencyPercent ?? 0;
      const discountPct = terms!.discountPercent ?? 0;
      const contingencyCents = Math.round(totalCostCents * contingencyPct / 100);
      const discountCents = Math.round(totalPriceCents * discountPct / 100);
      const adjCost = totalCostCents + contingencyCents;
      const adjPrice = totalPriceCents - discountCents;
      const adjMargin = adjPrice - adjCost;
      const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0;

      nodes.push(
        n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage (risk buffer on cost)", 0),
        n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage (reduction on sell side)", 0),
        n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"),
        n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"),
        n("comm.adjustedCost", "Adj. Cost", fmtEur(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
        n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"),
        n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"),
        n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"),
      );

      links.push(
        l("est.totalCostCents", "comm.contingencyCents", "×", 1),
        l("input.contingencyPct", "comm.contingencyCents", "× %", 1),
        l("est.totalPriceCents", "comm.discountCents", "×", 1),
        l("input.discountPct", "comm.discountCents", "× %", 1),
        l("est.totalCostCents", "comm.adjustedCost", "+", 2),
        l("comm.contingencyCents", "comm.adjustedCost", "+", 2),
        l("est.totalPriceCents", "comm.adjustedPrice", "−", 2),
        l("comm.discountCents", "comm.adjustedPrice", "−", 2),
        l("comm.adjustedPrice", "comm.adjustedMargin", "−", 2),
        l("comm.adjustedCost", "comm.adjustedMargin", "−", 2),
        l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2),
        l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1),
      );
    }

    if (hasCommercialMeta || (terms?.paymentMilestones && terms.paymentMilestones.length > 0)) {
      if (terms!.pricingModel) {
        nodes.push(
          n("comm.pricingModel", "Pricing Model", terms!.pricingModel.replace(/_/g, " "), "—", "COMMERCIAL", `Pricing model: ${terms!.pricingModel}`, 0),
        );
      }
      if (terms!.paymentTermDays) {
        nodes.push(
          n("comm.paymentTermDays", "Payment Terms", `${terms!.paymentTermDays} days`, "days", "COMMERCIAL", `Net payment terms: ${terms!.paymentTermDays} days`, 0),
        );
      }
      if (terms!.warrantyMonths) {
        nodes.push(
          n("comm.warrantyMonths", "Warranty", `${terms!.warrantyMonths} mo`, "months", "COMMERCIAL", `Warranty period: ${terms!.warrantyMonths} months`, 0),
        );
      }
      const milestones = terms!.paymentMilestones ?? [];
      if (milestones.length > 0) {
        nodes.push(
          n("comm.milestones", "Milestones", `${milestones.length}`, "count", "COMMERCIAL", `${milestones.length} payment milestones (${milestones.map((m) => `${m.label}: ${m.percent}%`).join(", ")})`, 2),
          n("comm.milestoneTotalPct", "Milestone Sum", `${milestones.reduce((s, m) => s + m.percent, 0).toFixed(0)}%`, "%", "COMMERCIAL", "Sum of milestone percentages (should be 100%)", 2, "Σ(milestone.percent)"),
        );
        links.push(
          l(hasCommercialAdjustments ? "comm.adjustedPrice" : "est.totalPriceCents", "comm.milestones", "× %", 1),
        );
      }
    }

    if (hasDateRange) {
      const spreadResult = computeEvenSpread({
        totalHours,
        startDate: project.startDate!,
        endDate: project.endDate!,
      });
      const monthCount = spreadResult.months.length;

      const weeklyResult = distributeHoursToWeeks({
        totalHours,
        startDate: project.startDate!.toISOString().slice(0, 10),
        endDate: project.endDate!.toISOString().slice(0, 10),
        pattern: "even",
      });
      const weekCount = weeklyResult.weeks.length;

      const hasManualSpreads = lines.some((dl) => {
        const spread = dl.monthlySpread as Record<string, number> | null;
        return spread && Object.keys(spread).length > 0;
      });

      nodes.push(
        n("spread.monthCount", "Months", `${monthCount}`, "count", "SPREAD", `${monthCount} months in project date range`, 1),
        n("spread.weekCount", "Weeks", `${weekCount}`, "count", "SPREAD", `${weekCount} ISO weeks in project date range`, 1),
        n("spread.monthlySpread", "Monthly Spread", hasManualSpreads ? "manual + even" : "even", "—", "SPREAD", "Hours distributed across months weighted by working days", 2, "hours × (monthWorkDays / totalWorkDays)"),
        n("spread.weeklyPhasing", "Weekly Phasing", "even", "—", "SPREAD", "Hours distributed across ISO weeks (even/front/back-loaded)", 2, "totalHours / weekCount"),
        n("spread.totalDistributed", "Distributed Hours", fmtNum(weeklyResult.totalDistributedHours), "hours", "SPREAD", "Total hours after weekly distribution (should match estimate)", 3, "Σ(weeklyHours)"),
      );

      links.push(
        l("input.projectStart", "spread.monthCount", "→ range", 1),
        l("input.projectEnd", "spread.monthCount", "→ range", 1),
        l("input.projectStart", "spread.weekCount", "→ range", 1),
        l("input.projectEnd", "spread.weekCount", "→ range", 1),
        l("est.totalHours", "spread.monthlySpread", "distribute", 2),
        l("spread.monthCount", "spread.monthlySpread", "÷ by workdays", 1),
        l("est.totalHours", "spread.weeklyPhasing", "distribute", 2),
        l("spread.weekCount", "spread.weeklyPhasing", "÷ by weeks", 1),
        l("spread.weeklyPhasing", "spread.totalDistributed", "Σ", 2),
      );
    }
  }

  const projectAllocs = await ctx.db.assignment.findMany({
    where: { projectId: input.projectId },
    select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
  });

  if (projectAllocs.length > 0) {
    const budgetStatus = computeBudgetStatus(
      project.budgetCents,
      project.winProbability,
      projectAllocs.map((pa) => ({
        status: pa.status as unknown as string,
        dailyCostCents: pa.dailyCostCents,
        startDate: pa.startDate,
        endDate: pa.endDate,
        hoursPerDay: pa.hoursPerDay,
      })) as Parameters<typeof computeBudgetStatus>[2],
      project.startDate ?? new Date(),
      project.endDate ?? new Date(),
    );

    nodes.push(
      n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
      n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
      n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
      n("budget.remainingCents", "Remaining",
        hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A",
        hasBudget ? "EUR" : "—", "BUDGET",
        hasBudget ? "Remaining budget" : "Cannot compute — no budget set",
        3, hasBudget ? "budget - allocated" : "needs budget"),
      n("budget.utilizationPct", "Utilization",
        hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A",
        hasBudget ? "%" : "—", "BUDGET",
        hasBudget ? "Budget utilization" : "Cannot compute — no budget set",
        3, hasBudget ? "allocated / budget × 100" : "needs budget"),
      n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
      n("budget.allocCount", "Allocations", `${projectAllocs.length}`, "count", "BUDGET", `${projectAllocs.length} resource allocations on project`, 1),
    );

    links.push(
      l("budget.allocCount", "budget.confirmedCents", "Σ confirmed", 1),
      l("budget.allocCount", "budget.proposedCents", "Σ proposed", 1),
      l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
      l("budget.proposedCents", "budget.allocatedCents", "+", 2),
      l("input.budgetCents", "budget.remainingCents", "−", 2),
      l("budget.allocatedCents", "budget.remainingCents", "−", 2),
      l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
      l("input.budgetCents", "budget.utilizationPct", "÷", 1),
      l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
      l("input.winProbability", "budget.weightedCents", "×", 1),
    );

    if (latestVersion && latestVersion.demandLines.length > 0) {
      const estCost = latestVersion.demandLines.reduce((s, dl) => s + dl.costTotalCents, 0);
      const gapCents = budgetStatus.allocatedCents - estCost;
      nodes.push(
        n("budget.estVsActualGap", "Est. vs Actual", fmtEur(Math.abs(gapCents)), "EUR", "BUDGET",
          gapCents > 0
            ? `Actual allocations exceed estimate by ${fmtEur(gapCents)}`
            : gapCents < 0
            ? `Actual allocations under estimate by ${fmtEur(Math.abs(gapCents))}`
            : "Actual allocations match estimate",
          3, "allocated - estCost"),
      );
      links.push(
        l("budget.allocatedCents", "budget.estVsActualGap", "−", 1),
        l("est.totalCostCents", "budget.estVsActualGap", "−", 1),
      );
    }
  }

  return {
    nodes,
    links,
    meta: {
      projectName: project.name,
      projectCode: project.shortCode,
    },
  };
}

function formatResourceGraphDetail(input: {
  resourceId: string;
  graph: Awaited<ReturnType<typeof readResourceGraphSnapshot>>;
  domain?: string;
  includeLinks?: boolean;
}) {
  return {
    resource: {
      id: input.resourceId,
      eid: input.graph.meta.resourceEid,
      displayName: input.graph.meta.resourceName,
    },
    availableDomains: getAvailableDomains(input.graph.nodes),
    totalNodeCount: input.graph.nodes.length,
    totalLinkCount: input.graph.links.length,
    ...filterGraphData({
      nodes: input.graph.nodes,
      links: input.graph.links,
      ...(input.domain ? { domain: input.domain } : {}),
      ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
    }),
    meta: input.graph.meta,
  };
}

function formatProjectGraphDetail(input: {
  projectId: string;
  graph: Awaited<ReturnType<typeof readProjectGraphSnapshot>>;
  domain?: string;
  includeLinks?: boolean;
}) {
  return {
    project: {
      id: input.projectId,
      shortCode: input.graph.meta.projectCode,
      name: input.graph.meta.projectName,
    },
    availableDomains: getAvailableDomains(input.graph.nodes),
    totalNodeCount: input.graph.nodes.length,
    totalLinkCount: input.graph.links.length,
    ...filterGraphData({
      nodes: input.graph.nodes,
      links: input.graph.links,
      ...(input.domain ? { domain: input.domain } : {}),
      ...(input.includeLinks !== undefined ? { includeLinks: input.includeLinks } : {}),
    }),
    meta: input.graph.meta,
  };
}
