"use server";

import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import {
  canPerformAction,
  canReject,
  getTransition,
  type ApplicationAction,
} from "@/lib/application-pipeline";
import { getManagerRecipients } from "@/lib/requisition-service";
import { generateEmployeeId } from "@/lib/employee-number";
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";

type ActionResult = { ok: true; id?: string } | { error: string };

const appPath = (id: string) => `/crewing/applications/${id}`;

async function guard(
  permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
  if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
  const session = await auth();
  if (!session?.user) return { error: "Unauthorized" };
  if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
  return { userId: session.user.id, role: session.user.role };
}

// Load an application with the bits the actions need; null if missing.
async function loadApp(id: string) {
  return db.application.findUnique({
    where: { id },
    include: {
      requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
      crewMember: { select: { id: true, name: true, type: true } },
    },
  });
}

function revalidateApp(applicationId: string, requisitionId: string) {
  revalidatePath(appPath(applicationId));
  revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
  revalidatePath("/approvals");
}

// ── Add a candidate to a requisition's pipeline ────────────────────────────────

export async function addApplication(formData: FormData): Promise<ActionResult> {
  const g = await guard("manage_candidates");
  if ("error" in g) return g;

  const requisitionId = formData.get("requisitionId") as string;
  const crewMemberId = formData.get("crewMemberId") as string;
  if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };

  const [requisition, candidate, existing] = await Promise.all([
    db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
    db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
    db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
  ]);
  if (!requisition) return { error: "Requisition not found" };
  if (!candidate) return { error: "Candidate not found" };
  if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
    return { error: `Cannot add candidates to a ${requisition.status} requisition` };
  }
  if (existing) return { error: "This candidate is already in the pipeline for this requisition" };

  const application = await db.application.create({
    data: {
      requisitionId,
      crewMemberId,
      type: candidate.type,
      stage: "SHORTLISTED",
      actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
    },
  });

  // First candidate moves the requisition from OPEN into sourcing.
  if (requisition.status === "OPEN") {
    await db.requisition.update({
      where: { id: requisitionId },
      data: {
        status: "SHORTLISTING",
        actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
      },
    });
  }

  revalidateApp(application.id, requisitionId);
  return { ok: true, id: application.id };
}

// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
// select have dedicated actions below.

export async function advanceStage(id: string, action: ApplicationAction): Promise<ActionResult> {
  if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
    return { error: "Use the dedicated action for this step" };
  }
  const g = await guard("manage_candidates");
  if ("error" in g) return g;

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  const transition = getTransition(app.stage, action);
  if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
  if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };

  // C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
  // leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
  // gate is completed by `verify_competency`.
  if (action === "verify_competency") {
    const references = await db.referenceCheck.count({ where: { applicationId: id } });
    if (references === 0) {
      return { error: "Record at least one reference check before completing competency & references" };
    }
  }

  await db.application.update({
    where: { id },
    data: {
      stage: transition.to,
      // Completing the competency & references stage records its gate.
      ...(action === "verify_competency"
        ? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
        : {}),
      actions: {
        create: {
          actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
          actorId: g.userId,
          crewMemberId: app.crewMemberId,
          metadata: { from: app.stage, to: transition.to },
        },
      },
    },
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

const referenceSchema = z.object({
  refereeName: z.string().trim().min(1, "Referee name is required"),
  refereeContact: z.string().optional(),
  outcome: z.string().optional(),
  note: z.string().optional(),
});

export async function recordReferenceCheck(formData: FormData): Promise<ActionResult> {
  const g = await guard("record_reference_check");
  if ("error" in g) return g;

  const id = formData.get("applicationId") as string;
  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };

  const parsed = referenceSchema.safeParse({
    refereeName: formData.get("refereeName"),
    refereeContact: (formData.get("refereeContact") as string) || undefined,
    outcome: (formData.get("outcome") as string) || undefined,
    note: (formData.get("note") as string) || undefined,
  });
  if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };

  await db.referenceCheck.create({
    data: {
      applicationId: id,
      refereeName: parsed.data.refereeName,
      refereeContact: parsed.data.refereeContact ?? null,
      outcome: parsed.data.outcome ?? null,
      note: parsed.data.note ?? null,
      recordedById: g.userId,
    },
  });
  await db.crewAction.create({
    data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────

const docsSchema = z.object({
  accountName: z.string().optional(),
  accountNumber: z.string().optional(),
  ifsc: z.string().optional(),
  bankName: z.string().optional(),
  uan: z.string().optional(),
  aadhaarLast4: z.string().optional(),
  pfNumber: z.string().optional(),
  note: z.string().optional(),
});

export async function verifyDocuments(formData: FormData): Promise<ActionResult> {
  const g = await guard("manage_candidates");
  if ("error" in g) return g;

  const id = formData.get("applicationId") as string;
  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  const transition = getTransition(app.stage, "verify_docs");
  if (!transition) return { error: `Cannot verify documents from ${app.stage}` };

  const parsed = docsSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
  const d = parsed.data;
  const crewMemberId = app.crewMember.id;

  // C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
  // the seat's rank is EXPIRED.
  // Scope note (documented limitation): seafarer documents are collected on the
  // crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
  // usually has none on file, so a hard "missing document" block would stall the
  // whole funnel. We therefore gate on what is available (expiry of documents the
  // candidate already holds); the "all required documents present" check is
  // enforced post-onboarding in the verification queue (§8.11). Once careers
  // intake (A2) uploads documents pre-onboarding, tighten this to also require
  // presence of every mandatory docType.
  const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
  if (reqRank) {
    const [required, candidateDocs] = await Promise.all([
      db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
      db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
    ]);
    const requiredTypes = new Set(required.map((r) => r.docType));
    const now = new Date();
    const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
    if (expired.length > 0) {
      return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
    }
  }
  // C4 (experience check) is deferred: the Requisition has no min-experience
  // criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
  // the candidate's ExperienceRecord total against it here and flag a shortfall.

  await db.$transaction(async (tx) => {
    // Capture bank / EPF (PII — encryption deferred to Phase 4).
    await tx.bankDetail.upsert({
      where: { crewMemberId },
      update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
      create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
    });
    await tx.epfDetail.upsert({
      where: { crewMemberId },
      update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
      create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
    });
    await tx.application.update({
      where: { id },
      data: {
        stage: transition.to,
        gates: {
          create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
        },
        actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
      },
    });
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────

const salarySchema = z.object({
  rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
  basic: z.coerce.number().positive("Basic must be greater than 0"),
  victualingPerDay: z.coerce.number().min(0).default(0),
  currency: z.string().default("INR"),
});

export async function agreeSalary(formData: FormData): Promise<ActionResult> {
  const g = await guard("manage_candidates");
  if ("error" in g) return g;

  const id = formData.get("applicationId") as string;
  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };

  const parsed = salarySchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
  const d = parsed.data;

  await db.$transaction(async (tx) => {
    // One live proposed structure per application — replace any prior draft.
    await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
    await tx.salaryStructure.create({
      data: {
        applicationId: id,
        rateBasis: d.rateBasis,
        basic: d.basic,
        victualingPerDay: d.victualingPerDay,
        currency: d.currency,
      },
    });
    // Salary gate goes PENDING for the Manager's queue.
    await tx.applicationGate.upsert({
      where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
      update: { result: "PENDING", decidedById: null, note: null },
      create: { applicationId: id, gate: "SALARY", result: "PENDING" },
    });
    await tx.crewAction.create({
      data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
    });
  });

  const managers = await getManagerRecipients();
  await notifyCrew({
    event: "SALARY_FOR_APPROVAL",
    recipients: managers,
    subject: `Salary for approval — ${app.crewMember.name}`,
    body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
    link: appPath(id),
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function approveSalary(id: string): Promise<ActionResult> {
  const g = await guard("approve_salary_structure");
  if ("error" in g) return g;

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };

  await db.$transaction(async (tx) => {
    await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
    await tx.applicationGate.update({
      where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
      data: { result: "VERIFIED", decidedById: g.userId },
    });
    await tx.application.update({
      where: { id },
      data: {
        stage: "PROPOSED",
        actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
      },
    });
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function returnSalary(id: string, reason: string): Promise<ActionResult> {
  const g = await guard("approve_salary_structure");
  if ("error" in g) return g;
  if (!reason?.trim()) return { error: "A reason is required to return for revision" };
  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };

  await db.applicationGate.updateMany({
    where: { applicationId: id, gate: "SALARY" },
    data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
  });
  await db.crewAction.create({
    data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
  });
  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────

export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise<ActionResult> {
  const g = await guard("record_interview_result");
  if ("error" in g) return g;

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };

  if (!accepted) {
    // A failed interview rejects the application.
    return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
  }

  await db.$transaction(async (tx) => {
    await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
    await tx.applicationGate.upsert({
      where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
      update: { result: "VERIFIED", decidedById: g.userId },
      create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
    });
    // Selection now pending for the Manager.
    await tx.applicationGate.upsert({
      where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
      update: { result: "PENDING", decidedById: null },
      create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
    });
    await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
  });

  const managers = await getManagerRecipients();
  await notifyCrew({
    event: "SELECTION_FOR_APPROVAL",
    recipients: managers,
    subject: `Selection for approval — ${app.crewMember.name}`,
    body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
    link: appPath(id),
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function requestInterviewWaiver(id: string, note?: string): Promise<ActionResult> {
  const g = await guard("request_interview_waiver");
  if ("error" in g) return g;

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
  if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };

  await db.applicationGate.upsert({
    where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
    update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
    create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
  });
  await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });

  const managers = await getManagerRecipients();
  await notifyCrew({
    event: "WAIVER_REQUESTED",
    recipients: managers,
    subject: `Interview waiver requested — ${app.crewMember.name}`,
    body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
    link: appPath(id),
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function approveInterviewWaiver(id: string): Promise<ActionResult> {
  const g = await guard("approve_interview_waiver");
  if ("error" in g) return g;

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };

  await db.$transaction(async (tx) => {
    await tx.application.update({ where: { id }, data: { interviewWaived: true } });
    await tx.applicationGate.update({
      where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
      data: { result: "VERIFIED", decidedById: g.userId },
    });
    // Waived → selection is now pending.
    await tx.applicationGate.upsert({
      where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
      update: { result: "PENDING", decidedById: null },
      create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
    });
    await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function declineInterviewWaiver(id: string, reason: string): Promise<ActionResult> {
  const g = await guard("approve_interview_waiver");
  if ("error" in g) return g;
  if (!reason?.trim()) return { error: "A reason is required to decline" };
  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };

  await db.applicationGate.updateMany({
    where: { applicationId: id, gate: "WAIVER" },
    data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
  });
  await db.crewAction.create({
    data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
  });
  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function selectCandidate(id: string): Promise<ActionResult> {
  const g = await guard("select_candidate");
  if ("error" in g) return g;

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };

  const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
  if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
    return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
  }

  await db.$transaction(async (tx) => {
    await tx.applicationGate.upsert({
      where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
      update: { result: "VERIFIED", decidedById: g.userId },
      create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
    });
    await tx.application.update({
      where: { id },
      data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
    });
    // The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
    await tx.requisition.update({
      where: { id: app.requisition.id },
      data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
    });
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

export async function returnSelection(id: string, reason: string): Promise<ActionResult> {
  const g = await guard("select_candidate");
  if ("error" in g) return g;
  if (!reason?.trim()) return { error: "A reason is required to return" };
  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };

  await db.$transaction(async (tx) => {
    await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
    await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
    await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
  });
  revalidateApp(id, app.requisition.id);
  return { ok: true };
}

// ── Rejection (orthogonal) ─────────────────────────────────────────────────────

async function rejectApplicationInternal(
  id: string,
  crewMemberId: string,
  requisitionId: string,
  userId: string,
  reason: string
): Promise<ActionResult> {
  await db.application.update({
    where: { id },
    data: {
      stage: "REJECTED",
      rejectedReason: reason,
      rejectedAt: new Date(),
      actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
    },
  });
  revalidateApp(id, requisitionId);
  return { ok: true };
}

export async function rejectApplication(id: string, reason: string): Promise<ActionResult> {
  const g = await guard("manage_candidates");
  if ("error" in g) return g;
  if (!reason?.trim()) return { error: "A reason is required to reject" };

  const app = await loadApp(id);
  if (!app) return { error: "Application not found" };
  if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };

  return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
}

// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
// One transaction off a SELECTED application: assign the employee number, create
// the ACTIVE assignment, bind the approved salary, flip the application to
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
// Login-account creation for management ranks is a deferred follow-up.

export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
  const g = await guard("onboard_crew");
  if ("error" in g) return g;

  const id = formData.get("applicationId") as string;
  const joiningStr = formData.get("joiningDate") as string;
  if (!joiningStr) return { error: "A joining date is required" };

  const app = await db.application.findUnique({
    where: { id },
    include: {
      requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
      crewMember: { select: { id: true, name: true, email: true } },
    },
  });
  if (!app) return { error: "Application not found" };
  if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };

  // D1 (spec §8.5): onboarding is blocked until the salary structure is
  // Manager-approved. Without this guard a SELECTED application that somehow has
  // no approved structure would still "succeed" but bind zero salary rows
  // (the updateMany below would match nothing) — a silent payroll gap.
  const approvedSalary = await db.salaryStructure.findFirst({
    where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
    select: { id: true },
    orderBy: { createdAt: "desc" },
  });
  if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };

  const joiningDate = new Date(joiningStr);

  // Upload the optional contract letter BEFORE the transaction (storage I/O),
  // then persist its row INSIDE the tx so onboarding is one atomic side-effecting
  // event (spec §11). The blob key is keyed on the crew member (stable before the
  // assignment exists); if the tx fails we leave only a harmless orphan blob,
  // never a fully-onboarded crew member with no contract row.
  const file = formData.get("contract");
  let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
  if (file instanceof File && file.size > 0) {
    const key = buildStorageKey("contract", app.crewMember.id, file.name);
    await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
    contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" };
  }

  const result = await db.$transaction(async (tx) => {
    const employeeId = await generateEmployeeId(tx);
    const assignment = await tx.crewAssignment.create({
      data: {
        status: "ACTIVE",
        signOnDate: joiningDate,
        crewMemberId: app.crewMember.id,
        rankId: app.requisition.rankId,
        vesselId: app.requisition.vesselId,
        siteId: app.requisition.siteId,
        requisitionId: app.requisition.id,
      },
    });
    // Bind the Manager-approved salary structure to the new assignment.
    await tx.salaryStructure.updateMany({
      where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
      data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
    });
    if (contract) {
      await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
    }
    // D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
    await tx.application.update({
      where: { id },
      data: {
        stage: "ONBOARDED",
        actions: {
          create: {
            actionType: "CREW_ONBOARDED",
            actorId: g.userId,
            crewMemberId: app.crewMember.id,
            metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
          },
        },
      },
    });
    await tx.requisition.update({
      where: { id: app.requisition.id },
      data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
    });
    await tx.crewMember.update({
      where: { id: app.crewMember.id },
      data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
    });
    // Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
    await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
    return { assignmentId: assignment.id, employeeId };
  });

  revalidateApp(id, app.requisition.id);
  return { ok: true, id: result.employeeId };
}
