import {
  approveEstimateVersion,
  cloneEstimate,
  createEstimateExport,
  createEstimate,
  createEstimatePlanningHandoff,
  createEstimateRevision,
  getEstimateById,
  listEstimates,
  submitEstimateVersion,
  updateEstimateDraft,
} from "@capakraken/application";
import type { Prisma } from "@capakraken/db";
import {
  normalizeEstimateDemandLine,
  summarizeEstimateDemandLines,
  generateWeekRange,
  distributeHoursToWeeks,
  aggregateWeeklyToMonthly,
  aggregateWeeklyByChapter,
} from "@capakraken/engine";
import {
  ApproveEstimateVersionSchema,
  CloneEstimateSchema,
  CommercialTermsSchema,
  CreateEstimateExportSchema,
  CreateEstimatePlanningHandoffSchema,
  CreateEstimateSchema,
  CreateEstimateRevisionSchema,
  EstimateListFiltersSchema,
  GenerateWeeklyPhasingSchema,
  PermissionKey,
  SubmitEstimateVersionSchema,
  UpdateCommercialTermsSchema,
  UpdateEstimateDraftSchema,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { lookupRate } from "../lib/rate-card-lookup.js";
import {
  controllerProcedure,
  createTRPCRouter,
  managerProcedure,
  protectedProcedure,
  requirePermission,
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";

type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED";

type EstimateRouterErrorRule = {
  code: EstimateRouterErrorCode;
  messages?: readonly string[];
  predicates?: readonly ((message: string) => boolean)[];
};

function rethrowEstimateRouterError(
  error: unknown,
  rules: readonly EstimateRouterErrorRule[],
): never {
  if (!(error instanceof Error)) {
    throw error;
  }

  const matchingRule = rules.find(
    (rule) =>
      rule.messages?.includes(error.message) === true ||
      rule.predicates?.some((predicate) => predicate(error.message)) === true,
  );

  if (matchingRule) {
    throw new TRPCError({
      code: matchingRule.code,
      message: error.message,
    });
  }

  throw error;
}

function buildComputedMetrics(
  demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
  const summary = summarizeEstimateDemandLines(demandLines);

  return [
    {
      key: "total_hours",
      label: "Total Hours",
      metricGroup: "summary",
      valueDecimal: summary.totalHours,
      metadata: {},
    },
    {
      key: "total_cost",
      label: "Total Cost",
      metricGroup: "summary",
      valueDecimal: summary.totalCostCents / 100,
      valueCents: summary.totalCostCents,
      currency: demandLines[0]?.currency ?? "EUR",
      metadata: {},
    },
    {
      key: "total_price",
      label: "Total Price",
      metricGroup: "summary",
      valueDecimal: summary.totalPriceCents / 100,
      valueCents: summary.totalPriceCents,
      currency: demandLines[0]?.currency ?? "EUR",
      metadata: {},
    },
    {
      key: "margin",
      label: "Margin",
      metricGroup: "summary",
      valueDecimal: summary.marginCents / 100,
      valueCents: summary.marginCents,
      currency: demandLines[0]?.currency ?? "EUR",
      metadata: {},
    },
    {
      key: "margin_percent",
      label: "Margin %",
      metricGroup: "summary",
      valueDecimal: summary.marginPercent,
      metadata: {},
    },
  ];
}

function normalizeDemandLines<
  T extends {
    demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
    resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
  },
>(input: T, baseCurrency: string) {
  const snapshotsByResourceId = new Map(
    input.resourceSnapshots
      .filter(
        (snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
          resourceId: string;
        } => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
      )
      .map((snapshot) => [snapshot.resourceId, snapshot]),
  );

  return input.demandLines.map((line) =>
    normalizeEstimateDemandLine(line, {
      resourceSnapshot:
        line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
      defaultCurrency: baseCurrency,
    }),
  );
}

function withComputedMetrics<
  T extends {
    demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
    resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
    metrics: z.infer<typeof CreateEstimateSchema>["metrics"];
  },
>(input: T, baseCurrency: string) {
  const normalizedDemandLines = normalizeDemandLines(input, baseCurrency);
  const computedMetrics = buildComputedMetrics(normalizedDemandLines);
  const computedKeys = new Set(computedMetrics.map((metric) => metric.key));

  return {
    ...input,
    demandLines: normalizedDemandLines,
    metrics: [
      ...input.metrics.filter((metric) => !computedKeys.has(metric.key)),
      ...computedMetrics,
    ],
  };
}

/**
 * Auto-fill rate card rates into demand lines that have default (zero) rates.
 * A line is eligible for auto-fill when both costRateCents and billRateCents
 * are 0 (the Zod default) and rateSource is not explicitly set.
 *
 * Returns the enriched demand lines and a list of line indices that were auto-filled.
 */
async function autoFillDemandLineRates(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  db: any,
  demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
  projectId?: string | null,
): Promise<{
  demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
  autoFilledIndices: number[];
}> {
  // Resolve clientId from the linked project
  let clientId: string | null = null;
  if (projectId) {
    const project = await db.project.findUnique({
      where: { id: projectId },
      select: { clientId: true },
    });
    clientId = project?.clientId ?? null;
  }

  const autoFilledIndices: number[] = [];

  const enriched = await Promise.all(
    demandLines.map(async (line, index) => {
      // Only auto-fill if both rates are at default (0) and no explicit rateSource
      const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0;
      const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0;

      if (!isDefaultRate || hasExplicitSource) return line;

      const result = await lookupRate(db, {
        clientId,
        chapter: line.chapter ?? null,
        roleId: line.roleId ?? null,
      });

      if (!result) return line;

      autoFilledIndices.push(index);

      const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
      return {
        ...line,
        costRateCents: result.costRateCents,
        billRateCents: result.billRateCents,
        currency: result.currency,
        rateSource: `rate-card:${result.rateCardId}`,
        metadata: {
          ...existingMetadata,
          autoAppliedRateCard: {
            rateCardId: result.rateCardId,
            rateCardLineId: result.rateCardLineId,
            rateCardName: result.rateCardName,
            appliedAt: new Date().toISOString(),
          },
        },
      };
    }),
  );

  return { demandLines: enriched, autoFilledIndices };
}

export const estimateRouter = createTRPCRouter({
  list: protectedProcedure
    .input(EstimateListFiltersSchema.default({}))
    .query(async ({ ctx, input }) =>
      listEstimates(
        ctx.db as unknown as Parameters<typeof listEstimates>[0],
        input,
      )),

  getById: controllerProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      const estimate = await findUniqueOrThrow(
        getEstimateById(
          ctx.db as unknown as Parameters<typeof getEstimateById>[0],
          input.id,
        ),
        "Estimate",
      );

      return estimate;
    }),

  listVersions: controllerProcedure
    .input(z.object({ estimateId: z.string() }))
    .query(async ({ ctx, input }) => {
      const estimate = await findUniqueOrThrow(
        ctx.db.estimate.findUnique({
          where: { id: input.estimateId },
          select: {
            id: true,
            name: true,
            status: true,
            latestVersionNumber: true,
            versions: {
              orderBy: { versionNumber: "desc" },
              select: {
                id: true,
                versionNumber: true,
                label: true,
                status: true,
                notes: true,
                lockedAt: true,
                createdAt: true,
                updatedAt: true,
                _count: {
                  select: {
                    assumptions: true,
                    scopeItems: true,
                    demandLines: true,
                    resourceSnapshots: true,
                    exports: true,
                  },
                },
              },
            },
          },
        }),
        "Estimate",
      );

      return estimate;
    }),

  getVersionSnapshot: controllerProcedure
    .input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      const estimate = await ctx.db.estimate.findUnique({
        where: { id: input.estimateId },
        select: {
          id: true,
          name: true,
          status: true,
          baseCurrency: true,
          versions: {
            ...(input.versionId
              ? { where: { id: input.versionId } }
              : { orderBy: { versionNumber: "desc" as const }, take: 1 }),
            select: {
              id: true,
              versionNumber: true,
              label: true,
              status: true,
              notes: true,
              lockedAt: true,
              createdAt: true,
              updatedAt: true,
              assumptions: {
                select: { id: true, category: true, key: true, label: true },
              },
              scopeItems: {
                select: { id: true, scopeType: true, sequenceNo: true, name: true },
                orderBy: [{ sequenceNo: "asc" }, { name: "asc" }],
              },
              demandLines: {
                select: {
                  id: true,
                  name: true,
                  chapter: true,
                  hours: true,
                  costTotalCents: true,
                  priceTotalCents: true,
                  currency: true,
                },
              },
              resourceSnapshots: {
                select: {
                  id: true,
                  displayName: true,
                  chapter: true,
                  currency: true,
                  lcrCents: true,
                  ucrCents: true,
                },
              },
              exports: {
                select: {
                  id: true,
                  format: true,
                  fileName: true,
                  createdAt: true,
                },
                orderBy: { createdAt: "desc" },
              },
            },
          },
        },
      });

      if (!estimate || estimate.versions.length === 0) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
      }

      const version = estimate.versions[0]!;
      const demandSummary = summarizeEstimateDemandLines(version.demandLines);

      const chapterTotals = version.demandLines.reduce<Record<string, {
        lineCount: number;
        hours: number;
        costTotalCents: number;
        priceTotalCents: number;
        currency: string;
      }>>((acc, line) => {
        const key = line.chapter ?? "Unassigned";
        const current = acc[key] ?? {
          lineCount: 0,
          hours: 0,
          costTotalCents: 0,
          priceTotalCents: 0,
          currency: line.currency,
        };
        current.lineCount += 1;
        current.hours += line.hours;
        current.costTotalCents += line.costTotalCents;
        current.priceTotalCents += line.priceTotalCents;
        acc[key] = current;
        return acc;
      }, {});

      const scopeTypeTotals = version.scopeItems.reduce<Record<string, number>>((acc, item) => {
        acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1;
        return acc;
      }, {});

      const assumptionCategoryTotals = version.assumptions.reduce<Record<string, number>>((acc, assumption) => {
        acc[assumption.category] = (acc[assumption.category] ?? 0) + 1;
        return acc;
      }, {});

      return {
        estimate: {
          id: estimate.id,
          name: estimate.name,
          status: estimate.status,
          baseCurrency: estimate.baseCurrency,
        },
        version: {
          id: version.id,
          versionNumber: version.versionNumber,
          label: version.label,
          status: version.status,
          notes: version.notes,
          lockedAt: version.lockedAt,
          createdAt: version.createdAt,
          updatedAt: version.updatedAt,
        },
        counts: {
          assumptions: version.assumptions.length,
          scopeItems: version.scopeItems.length,
          demandLines: version.demandLines.length,
          resourceSnapshots: version.resourceSnapshots.length,
          exports: version.exports.length,
        },
        totals: {
          hours: demandSummary.totalHours,
          costTotalCents: demandSummary.totalCostCents,
          priceTotalCents: demandSummary.totalPriceCents,
          marginCents: demandSummary.marginCents,
          marginPercent: demandSummary.marginPercent,
        },
        chapterBreakdown: Object.entries(chapterTotals)
          .sort((left, right) => right[1].hours - left[1].hours)
          .map(([chapter, totals]) => ({
            chapter,
            ...totals,
          })),
        scopeTypeBreakdown: Object.entries(scopeTypeTotals)
          .sort((left, right) => right[1] - left[1])
          .map(([scopeType, count]) => ({ scopeType, count })),
        assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals)
          .sort((left, right) => right[1] - left[1])
          .map(([category, count]) => ({ category, count })),
        exports: version.exports,
      };
    }),

  create: managerProcedure
    .input(CreateEstimateSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      if (input.projectId) {
        await findUniqueOrThrow(
          ctx.db.project.findUnique({
            where: { id: input.projectId },
            select: { id: true },
          }),
          "Project",
        );
      }

      // Auto-fill rates from rate cards for demand lines with default (zero) rates
      const { demandLines: enrichedLines, autoFilledIndices } =
        await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId);
      const enrichedInput = { ...input, demandLines: enrichedLines };

      const estimate = await createEstimate(
        ctx.db as unknown as Parameters<typeof createEstimate>[0],
        withComputedMetrics(enrichedInput, input.baseCurrency),
      );

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "CREATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              name: estimate.name,
              status: estimate.status,
              projectId: estimate.projectId,
              latestVersionNumber: estimate.latestVersionNumber,
              autoFilledRateCardLines: autoFilledIndices.length,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  clone: managerProcedure
    .input(CloneEstimateSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      let estimate;
      try {
        estimate = await cloneEstimate(
          ctx.db as unknown as Parameters<typeof cloneEstimate>[0],
          input,
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: ["Source estimate not found", "Source estimate has no versions"],
          },
        ]);
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "CREATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              name: estimate.name,
              clonedFrom: input.sourceEstimateId,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  updateDraft: managerProcedure
    .input(UpdateEstimateDraftSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      if (input.projectId) {
        await findUniqueOrThrow(
          ctx.db.project.findUnique({
            where: { id: input.projectId },
            select: { id: true },
          }),
          "Project",
        );
      }

      // Auto-fill rates from rate cards for demand lines with default (zero) rates
      // Resolve projectId: explicit input or existing estimate's projectId
      let effectiveProjectId = input.projectId;
      if (!effectiveProjectId) {
        const existing = await ctx.db.estimate.findUnique({
          where: { id: input.id },
          select: { projectId: true },
        });
        effectiveProjectId = existing?.projectId ?? undefined;
      }
      const { demandLines: enrichedLines, autoFilledIndices } =
        await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId);
      const enrichedInput = { ...input, demandLines: enrichedLines };

      let estimate;
      try {
        estimate = await updateEstimateDraft(
          ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
          withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"),
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: ["Estimate not found"],
          },
          {
            code: "PRECONDITION_FAILED",
            messages: ["Estimate has no working version"],
          },
        ]);
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              name: estimate.name,
              status: estimate.status,
              latestVersionNumber: estimate.latestVersionNumber,
              workingVersionId: estimate.versions.find(
                (version) => version.status === "WORKING",
              )?.id,
              autoFilledRateCardLines: autoFilledIndices.length,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  submitVersion: managerProcedure
    .input(SubmitEstimateVersionSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      let estimate;
      try {
        estimate = await submitEstimateVersion(
          ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
          input,
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: ["Estimate not found", "Estimate version not found"],
          },
          {
            code: "PRECONDITION_FAILED",
            messages: [
              "Estimate has no working version",
              "Only working versions can be submitted",
            ],
          },
        ]);
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              status: estimate.status,
              submittedVersionId: estimate.versions.find(
                (version) => version.status === "SUBMITTED",
              )?.id,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  approveVersion: managerProcedure
    .input(ApproveEstimateVersionSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      let estimate;
      try {
        estimate = await approveEstimateVersion(
          ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
          input,
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: ["Estimate not found", "Estimate version not found"],
          },
          {
            code: "PRECONDITION_FAILED",
            messages: [
              "Estimate has no submitted version",
              "Only submitted versions can be approved",
            ],
          },
        ]);
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              status: estimate.status,
              approvedVersionId: estimate.versions.find(
                (version) => version.status === "APPROVED",
              )?.id,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  createRevision: managerProcedure
    .input(CreateEstimateRevisionSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      let estimate;
      try {
        estimate = await createEstimateRevision(
          ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
          input,
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: ["Estimate not found", "Estimate version not found"],
          },
          {
            code: "PRECONDITION_FAILED",
            messages: [
              "Estimate already has a working version",
              "Estimate has no locked version to revise",
              "Source version must be locked before creating a revision",
            ],
          },
        ]);
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              status: estimate.status,
              latestVersionNumber: estimate.latestVersionNumber,
              workingVersionId: estimate.versions.find(
                (version) => version.status === "WORKING",
              )?.id,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  createExport: managerProcedure
    .input(CreateEstimateExportSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      let estimate;
      try {
        estimate = await createEstimateExport(
          ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
          input,
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: [
              "Estimate not found",
              "Estimate version not found",
              "Estimate has no version to export",
            ],
          },
        ]);
      }

      const exportedVersion = input.versionId
        ? estimate.versions.find((version) => version.id === input.versionId)
        : estimate.versions[0];

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              id: estimate.id,
              exportFormat: input.format,
              exportCount: exportedVersion?.exports.length ?? null,
              versionId: exportedVersion?.id ?? null,
            },
          } as Prisma.InputJsonValue,
        },
      });

      return estimate;
    }),

  createPlanningHandoff: managerProcedure
    .input(CreateEstimatePlanningHandoffSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);

      let result;
      try {
        result = await createEstimatePlanningHandoff(
          ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
          input,
        );
      } catch (error) {
        rethrowEstimateRouterError(error, [
          {
            code: "NOT_FOUND",
            messages: [
              "Estimate not found",
              "Estimate version not found",
              "Linked project not found",
            ],
          },
          {
            code: "PRECONDITION_FAILED",
            messages: [
              "Estimate has no approved version",
              "Only approved versions can be handed off to planning",
              "Estimate must be linked to a project before planning handoff",
              "Planning handoff already exists for this approved version",
              "Linked project has an invalid date range",
            ],
            predicates: [
              (message) =>
                message.startsWith("Project window has no working days for demand line"),
            ],
          },
        ]);
      }

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: result.estimateId,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            after: {
              planningHandoff: {
                versionId: result.estimateVersionId,
                versionNumber: result.estimateVersionNumber,
                projectId: result.projectId,
                createdCount: result.createdCount,
                assignedCount: result.assignedCount,
                placeholderCount: result.placeholderCount,
                fallbackPlaceholderCount: result.fallbackPlaceholderCount,
              },
            },
          } as Prisma.InputJsonValue,
        },
      });

      for (const allocation of result.allocations) {
        emitAllocationCreated({
          id: allocation.id,
          projectId: allocation.projectId,
          resourceId: allocation.resourceId ?? null,
        });
      }

      return result;
    }),

  generateWeeklyPhasing: managerProcedure
    .input(GenerateWeeklyPhasingSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      const estimate = await findUniqueOrThrow(
        getEstimateById(
          ctx.db as unknown as Parameters<typeof getEstimateById>[0],
          input.estimateId,
        ),
        "Estimate",
      );

      const workingVersion = estimate.versions.find(
        (v) => v.status === "WORKING",
      );

      if (!workingVersion) {
        throw new TRPCError({
          code: "PRECONDITION_FAILED",
          message: "Estimate has no working version",
        });
      }

      const pattern = input.pattern ?? "even";

      // Distribute hours for each demand line and update DB
      const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];

      for (const line of workingVersion.demandLines) {
        const result = distributeHoursToWeeks({
          totalHours: line.hours,
          startDate: input.startDate,
          endDate: input.endDate,
          pattern,
        });

        const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);

        const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
        const metadata = {
          ...existingMetadata,
          weeklyPhasing: {
            startDate: input.startDate,
            endDate: input.endDate,
            pattern,
            weeklyHours: result.weeklyHours,
            generatedAt: new Date().toISOString(),
          },
        };

        updates.push({ id: line.id, monthlySpread, metadata });
      }

      // Batch update all demand lines
      await Promise.all(
        updates.map((update) =>
          ctx.db.estimateDemandLine.update({
            where: { id: update.id },
            data: {
              monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
              metadata: update.metadata as Prisma.InputJsonValue,
            },
          }),
        ),
      );

      return {
        estimateId: input.estimateId,
        versionId: workingVersion.id,
        linesUpdated: updates.length,
        startDate: input.startDate,
        endDate: input.endDate,
        pattern,
      };
    }),

  getWeeklyPhasing: controllerProcedure
    .input(z.object({ estimateId: z.string() }))
    .query(async ({ ctx, input }) => {
      const estimate = await findUniqueOrThrow(
        getEstimateById(
          ctx.db as unknown as Parameters<typeof getEstimateById>[0],
          input.estimateId,
        ),
        "Estimate",
      );

      // Get the latest version (first in the sorted array)
      const version = estimate.versions[0];

      if (!version) {
        throw new TRPCError({
          code: "PRECONDITION_FAILED",
          message: "Estimate has no versions",
        });
      }

      // Extract weekly phasing from each demand line's metadata
      type WeeklyPhasingMeta = {
        startDate: string;
        endDate: string;
        pattern: string;
        weeklyHours: Record<string, number>;
        generatedAt: string;
      };

      const linesWithPhasing: Array<{
        id: string;
        name: string;
        chapter: string | null;
        hours: number;
        weeklyHours: Record<string, number>;
      }> = [];

      let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;

      for (const line of version.demandLines) {
        const meta = (line.metadata ?? {}) as Record<string, unknown>;
        const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;

        if (phasing) {
          if (!phasingConfig) {
            phasingConfig = {
              startDate: phasing.startDate,
              endDate: phasing.endDate,
              pattern: phasing.pattern,
            };
          }

          linesWithPhasing.push({
            id: line.id,
            name: line.name,
            chapter: line.chapter ?? null,
            hours: line.hours,
            weeklyHours: phasing.weeklyHours,
          });
        }
      }

      if (!phasingConfig || linesWithPhasing.length === 0) {
        return {
          estimateId: input.estimateId,
          versionId: version.id,
          hasPhasing: false as const,
          config: null,
          weeks: [],
          lines: [],
          chapterAggregation: {},
        };
      }

      const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
      const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);

      return {
        estimateId: input.estimateId,
        versionId: version.id,
        hasPhasing: true as const,
        config: phasingConfig,
        weeks,
        lines: linesWithPhasing,
        chapterAggregation,
      };
    }),

  // ─── Commercial Terms ───────────────────────────────────────────────────

  getCommercialTerms: controllerProcedure
    .input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
    .query(async ({ ctx, input }) => {
      const estimate = await ctx.db.estimate.findUnique({
        where: { id: input.estimateId },
        include: {
          versions: {
            ...(input.versionId
              ? { where: { id: input.versionId } }
              : { orderBy: { versionNumber: "desc" as const }, take: 1 }),
            select: { id: true, commercialTerms: true },
          },
        },
      });

      if (!estimate || estimate.versions.length === 0) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
      }

      const version = estimate.versions[0]!;
      const raw = version.commercialTerms;

      // Parse stored JSON through Zod for type safety, fall back to defaults
      const terms = raw
        ? CommercialTermsSchema.parse(raw)
        : CommercialTermsSchema.parse({});

      return { versionId: version.id, terms };
    }),

  updateCommercialTerms: managerProcedure
    .input(UpdateCommercialTermsSchema)
    .mutation(async ({ ctx, input }) => {
      requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);

      const estimate = await ctx.db.estimate.findUnique({
        where: { id: input.estimateId },
        include: {
          versions: {
            ...(input.versionId
              ? { where: { id: input.versionId } }
              : { where: { status: "WORKING" }, take: 1 }),
            select: { id: true, status: true },
          },
        },
      });

      if (!estimate || estimate.versions.length === 0) {
        throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
      }

      const version = estimate.versions[0]!;

      if (version.status !== "WORKING") {
        throw new TRPCError({
          code: "PRECONDITION_FAILED",
          message: "Commercial terms can only be edited on working versions",
        });
      }

      const validated = CommercialTermsSchema.parse(input.terms);

      await ctx.db.estimateVersion.update({
        where: { id: version.id },
        data: { commercialTerms: validated as unknown as Prisma.InputJsonValue },
      });

      await ctx.db.auditLog.create({
        data: {
          entityType: "Estimate",
          entityId: estimate.id,
          action: "UPDATE",
          ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
          changes: {
            field: "commercialTerms",
            after: validated,
          } as Prisma.InputJsonValue,
        },
      });

      return { versionId: version.id, terms: validated };
    }),

  // ─── Rate Card Lookup for Demand Lines ──────────────────────────────────

  lookupDemandLineRate: controllerProcedure
    .input(z.object({
      projectId: z.string().optional(),
      clientId: z.string().optional(),
      roleId: z.string().optional(),
      chapter: z.string().optional(),
      seniority: z.string().optional(),
      location: z.string().optional(),
      workType: z.string().optional(),
      effectiveDate: z.coerce.date().optional(),
    }))
    .query(async ({ ctx, input }) => {
      // Resolve clientId from project if not provided directly
      let clientId = input.clientId ?? null;
      if (!clientId && input.projectId) {
        const project = await ctx.db.project.findUnique({
          where: { id: input.projectId },
          select: { clientId: true },
        });
        clientId = project?.clientId ?? null;
      }

      const result = await lookupRate(ctx.db, {
        clientId,
        chapter: input.chapter ?? null,
        roleId: input.roleId ?? null,
        seniority: input.seniority ?? null,
        location: input.location ?? null,
        workType: input.workType ?? null,
        effectiveDate: input.effectiveDate ?? null,
      });

      if (!result) return { found: false as const };

      return {
        found: true as const,
        costRateCents: result.costRateCents,
        billRateCents: result.billRateCents,
        currency: result.currency,
        rateCardId: result.rateCardId,
        rateCardLineId: result.rateCardLineId,
        rateCardName: result.rateCardName,
      };
    }),
});
