import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
  ActionIcon,
  Alert,
  Badge,
  Box,
  Button,
  Card,
  Container,
  Divider,
  Group,
  Modal,
  NumberInput,
  Paper,
  Stack,
  Stepper,
  Table,
  Text,
  TextInput,
  Title,
} from "@mantine/core";
import {
  IconAlertTriangle,
  IconPencil,
  IconCheck,
  IconInfoCircle,
  IconPlus,
  IconTrash,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import {
  buildWizardSteps,
  conditionHolds,
  conditionSections,
  sectionsDependingOn,
  extractErrorMessage,
  extractValidationIssues,
  useLocalized,
  validateSections,
  STATUS_COLORS,
  STATUS_LABELS,
  useAddStaffMutation,
  useCreateApplicationMutation,
  useGetApplicationQuery,
  useFillApplicationDocumentsFromVaultMutation,
  useGetAttachmentsQuery,
  useGetLicenseTypeRequirementsQuery,
  useGetMyVesselsQuery,
  usePatchSectionMutation,
  useRemoveStaffMutation,
  useResolveRemarkMutation,
  useResubmitApplicationMutation,
  useSubmitApplicationMutation,
  type FieldErrors,
  type FormFieldConfig,
  type ValidationIssue,
  type Vessel,
} from "@ema-platform/api";
import {
  getCountryCode,
  getCountryName,
  ModalFooter,
  splitPersonName,
} from "@ema-platform/ui";
import {
  LICENSE_PERMISSIONS,
  PORTAL_PERMISSIONS,
  RequirePermission,
  useCurrentProfile,
  usePermissions,
} from "@ema-platform/auth";
import { ApplicationSummary } from "../components/ApplicationSummary";
import {
  ConfigDrivenSection,
  fillFromVessel,
} from "../components/ConfigDrivenSection";
import { DocumentSlots } from "../components/DocumentSlots";
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks";
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
/** Resolves a dot path (e.g. "profile.address.nationality") against a plain object. */
function readSourcePath(
  context: Record<string, unknown>,
  path: string,
): unknown {
  return path
    .split(".")
    .reduce<unknown>(
      (acc, key) =>
        acc && typeof acc === "object"
          ? (acc as Record<string, unknown>)[key]
          : undefined,
      context,
    );
}

// Name fields predate the generic `source` metadata in some persisted form
// schemas. Keep their profile mapping here so existing applications/configs
// receive the same prefill as newly seeded schemas.
const LEGACY_PROFILE_SOURCES: Record<string, string> = {
  firstName: "profile.firstName",
  middleName: "profile.middleName",
  lastName: "profile.lastName",
};

/**
 * The applicant wizard, rendered entirely from the license type's
 * configuration. The same page serves every license type — the route's
 * `typeCode` decides which configuration is loaded.
 */
export function LicenseApplicationPage() {
  const { typeCode = "FREIGHT_FORWARDER", applicationId } = useParams();
  const navigate = useNavigate();
  const { t, i18n } = useTranslation();
  const localized = useLocalized();
  const accountUser = useAppSelector((state) => state.auth.user);

  const { data: config, isLoading: loadingConfig } =
    useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
  const { profile } = useCurrentProfile();
  const { can: hasPermission, known: permissionsKnown } = usePermissions();
  // Only the vessel-select field (ConfigDrivenSection) reads this — fetched
  // here rather than deeper down since it's the shared source of draft state.
  // Skipped for accounts without VIEW_OWN_VESSELS (e.g. freight forwarders):
  // the API 403s for them, since vessels belong to VESSEL_OWNER accounts.
  const { data: vessels } = useGetMyVesselsQuery(undefined, {
    skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
  });
  const [createApplication] = useCreateApplicationMutation();
  const [appId, setAppId] = useState<string | undefined>(applicationId);

  // Create (or resume) the draft up front, so uploads have a real owner to
  // attach to and nothing is lost if the browser is closed mid-wizard.
  // For a one-shot registration (e.g. seafarer) already submitted or further
  // along, the API returns that existing application instead of a new draft —
  // "Apply" reopens it rather than erroring, the same way it reopens a DRAFT.
  useEffect(() => {
    if (appId || !config) return;
    createApplication({ licenseType: typeCode })
      .unwrap()
      .then((app) => setAppId(app.id))
      .catch((err) =>
        notifications.show({
          color: "red",
          title: "Could not start application",
          message: extractErrorMessage(err),
        }),
      );
  }, [appId, config, createApplication, typeCode]);

  const { data: detail, refetch } = useGetApplicationQuery(appId as string, {
    skip: !appId,
  });
  const { data: attachments = [], refetch: refetchAttachments } =
    useGetAttachmentsQuery(
      { ownerType: "APPLICATION", ownerId: appId as string },
      { skip: !appId },
    );

  const [patchSection] = usePatchSectionMutation();
  const [submitApplication, { isLoading: submitting }] =
    useSubmitApplicationMutation();
  const [resubmitApplication, { isLoading: resubmitting }] =
    useResubmitApplicationMutation();
  const [resolveRemark] = useResolveRemarkMutation();
  const [addStaff] = useAddStaffMutation();
  const [removeStaff] = useRemoveStaffMutation();

  const [active, setActive] = useState(0);
  // A submitted (or otherwise non-draft) application opens straight to a
  // read-only summary — status up top, everything the applicant filled below
  // — instead of the entry step of a stepper there is nothing left to step
  // through. RESUBMIT_REQUIRED is still summary-first, but "Edit details"
  // drops into the ordinary wizard on the flagged sections.
  const [viewingSummary, setViewingSummary] = useState(true);
  const [draft, setDraft] = useState<Record<string, Record<string, unknown>>>(
    {},
  );
  const [issues, setIssues] = useState<ValidationIssue[]>([]);
  const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
  const [staffModal, setStaffModal] = useState<string | null>(null);
  const [newStaff, setNewStaff] = useState({
    fullName: "",
    position: "",
    yearsOfExperience: 0,
  });

  // Seed local edits from the server copy once it arrives.
  useEffect(() => {
    if (detail?.application?.formData) setDraft(detail.application.formData);
  }, [detail?.application?.id, detail?.application?.adjustmentRound]);

  // Nationality and National ID (Fayda) number are already on file from the
  // profile's Address tab — carry them into whichever section the form
  // config puts those fields in, rather than asking again. Only fills a
  // blank; a value already on the draft (the applicant's own edit, or one
  // the server saved) is left alone.
  useEffect(() => {
    const address = profile?.address;
    if (!address || !config) return;

    setDraft((prev) => {
      let next = prev;
      const fill = (
        matchField: (label: string, key: string) => boolean,
        toFieldValue: (field: FormFieldConfig) => unknown,
      ) => {
        for (const section of config.licenseType.formSchema.sections) {
          // English-pinned: matched against English substrings below ('nationality', 'fayda').
          const field = section.fields.find((f) =>
            matchField((f.label.en ?? "").toLowerCase(), f.key),
          );
          if (!field) continue;
          if (next[section.key]?.[field.key]) return; // already set — leave it
          next = {
            ...next,
            [section.key]: {
              ...next[section.key],
              [field.key]: toFieldValue(field),
            },
          };
          return;
        }
      };

      if (address.nationality) {
        // Profile stores the full country name; the field always renders as
        // a CountrySelect (see ConfigDrivenSection), which takes alpha-2
        // codes regardless of the backend's configured field type.
        fill(
          (label, key) =>
            key === "nationality" || label.includes("nationality"),
          () => getCountryCode(address.nationality) ?? address.nationality,
        );
      }
      if (address.idType === "NID" && address.idNumber) {
        fill(
          (label, key) =>
            key === "idNumber" ||
            key === "nationalId" ||
            key === "faydaNumber" ||
            label.includes("fayda") ||
            label.includes("national id"),
          () => address.idNumber,
        );
      }
      return next;
    });
    // Also re-run after the server seed effect (above) replaces `draft`
    // wholesale — that effect can resolve after this one, wiping the
    // prefill back out since the server's own draft has none of this yet.
  }, [
    profile?.address,
    config,
    detail?.application?.id,
    detail?.application?.formData,
  ]);

  // Generic fill for every field the config gives a `source` — the profile
  // value the applicant would otherwise retype. Seafarer registration's
  // Identity Details step is the case that drives this: it collects name,
  // gender, DOB and national ID *in the wizard* rather than sending the
  // applicant to `/profile` first, so those fields are editable and this is a
  // prefill, not a display.
  //
  // Editable sourced fields are filled only while still blank. Re-running
  // this effect (a refetched profile, a saved draft) must not overwrite what
  // the applicant has since typed — for a `readOnly` field the profile stays
  // authoritative, so those keep tracking it.
  useEffect(() => {
    if (!profile || !config) return;
    // `profile.firstName/middleName/lastName` stay blank until the applicant
    // saves the Maritime Profile tab once — a fresh signup arrives here
    // without ever having done that. Fall back to splitting the account's
    // `name.en` (the same name signup collected) so this step still
    // prefills instead of opening blank.
    const accountName = accountUser?.name ?? profile.user?.name;
    const nameFallback = accountName?.en
      ? splitPersonName(accountName.en)
      : null;
    const firstName = profile.firstName || nameFallback?.firstName || "";
    const middleName = profile.middleName || nameFallback?.middleName || "";
    const lastName = profile.lastName || nameFallback?.lastName || "";
    const context = {
      user: accountUser ?? profile.user,
      profile: {
        ...profile,
        firstName,
        middleName,
        lastName,
        // The profile has no single "full name" column — it's first/middle/last.
        fullName: [firstName, middleName, lastName].filter(Boolean).join(" "),
      },
    };

    setDraft((prev) => {
      let changed = false;
      const next = { ...prev };
      for (const section of config.licenseType.formSchema.sections) {
        for (const field of section.fields) {
          const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
          if (!source) continue;
          const current = next[section.key]?.[field.key];
          const untouched =
            current === undefined || current === null || current === "";
          if (!field.readOnly && !untouched) continue;
          const raw = readSourcePath(context, source);
          // Profile dates arrive as ISO datetimes; a DATE field's picker wants
          // yyyy-MM-dd. Seafarer registration's `profile.dob` source hits the
          // same mismatch today — fixed once here rather than per config.
          const value =
            field.type === "DATE" && typeof raw === "string" ? raw.slice(0, 10) : raw;
          if (value === undefined || value === null || value === "") continue;
          if (current === value) continue;
          next[section.key] = { ...next[section.key], [field.key]: value };
          changed = true;
        }
      }
      return changed ? next : prev;
    });
  }, [
    profile,
    accountUser,
    config,
    detail?.application?.id,
    detail?.application?.formData,
  ]);

  const application = detail?.application;
  const isAdjusting = application?.status === "RESUBMIT_REQUIRED";
  const openRemarks = detail?.openRemarks ?? [];

  // The whole round's remarks, resolved or not — the server's section lock
  // (`assertSectionUnlocked`) ignores `isResolved`, and this page bulk-resolves
  // remarks right before resubmitting, so `openRemarks` would re-freeze a
  // section the moment the applicant ticked it off.
  const roundRemarks = useMemo(
    () =>
      (detail?.remarks ?? []).filter(
        (r) => r.roundNumber === detail?.application?.adjustmentRound,
      ),
    [detail?.remarks, detail?.application?.adjustmentRound],
  );

  const flaggedSections = useMemo(
    () =>
      Object.fromEntries(
        roundRemarks
          .filter((r) => r.targetType === "FORM_SECTION")
          .map((r) => [r.targetKey, r.remark]),
      ),
    [roundRemarks],
  );
  const flaggedDocuments = useMemo(
    () =>
      Object.fromEntries(
        roundRemarks
          .filter((r) => r.targetType === "DOCUMENT")
          .map((r) => [r.targetKey, r.remark]),
      ),
    [roundRemarks],
  );
  const hasStaffRemarks = roundRemarks.some((r) => r.targetType === "STAFF");
  // Nothing at all came back for this round (a detail response that predates
  // the remarks, say) — lock nothing rather than freeze the whole application
  // with no way forward. Any remark present means the round is itemised, so
  // only what the officer flagged opens: a documents-only round leaves every
  // form section frozen, and a sections-only round leaves every document as
  // filed.
  const roundIsItemised = isAdjusting && roundRemarks.length > 0;

  // An answer the officer flagged can decide which fields *other* sections
  // require — the vessel category is the live example. Freeze those and the
  // applicant is shown newly-required fields they cannot fill, and cannot
  // resubmit; the server unlocks them the same way.
  const cascadeUnlocked = useMemo(
    () =>
      sectionsDependingOn(
        config?.licenseType?.formSchema?.sections ?? [],
        new Set(Object.keys(flaggedSections)),
      ),
    [config, flaggedSections],
  );
  const unlockedDocuments = useMemo(
    () =>
      (config?.documentRequirements ?? [])
        .filter((requirement) =>
          conditionSections(requirement.conditionExpression).some(
            (sectionKey) => sectionKey in flaggedSections,
          ),
        )
        .map((requirement) => requirement.key),
    [config, flaggedSections],
  );

  const isSectionLocked = (sectionKey: string) =>
    roundIsItemised &&
    !flaggedSections[sectionKey] &&
    !cascadeUnlocked.has(sectionKey);
  const staffLocked = roundIsItemised && !hasStaffRemarks;

  // Sections that share a group collapse onto one step, so the stepper stays
  // short instead of showing a page per section. A Damaged/Reissue
  // application skips Staff and Documents outright — it asks nothing beyond
  // the Damage Information step, regardless of what the licence type
  // otherwise requires for a new application or renewal.
  const isReissue = application?.kind === 'REISSUE';
  const steps = useMemo(
    () =>
      buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
        hasStaff: isReissue ? false : (config?.staffRoleRequirements?.length ?? 0) > 0,
        hasDocuments: !isReissue,
        language: i18n.language,
        applicationKind: application?.kind,
      }),
    [config, draft, i18n.language, application?.kind, isReissue],
  );
  const sections = useMemo(
    () => steps.flatMap((step) => step.sections),
    [steps],
  );

  // A section-level showWhen can remove a step while the wizard is open
  // (ENDORSEMENT_SEAFARER's certificate sections follow the chosen scope).
  // Clamp so `steps[active]` can never go out of bounds if a seed ever lets
  // a later answer hide an earlier step.
  useEffect(() => {
    if (active > steps.length - 1) setActive(Math.max(0, steps.length - 1));
  }, [active, steps.length]);

  /**
   * Fills the document slots from the applicant's own vault the first time
   * they reach that step.
   *
   * Someone who already keeps their passport under My Documents should find it
   * here rather than being asked to go and fetch the same file again. The
   * server only fills empty slots, so this cannot overwrite anything they
   * uploaded, and calling it twice costs one query.
   */
  const [fillFromVault] = useFillApplicationDocumentsFromVaultMutation();
  const filledForApplication = useRef<string | null>(null);
  useEffect(() => {
    const status = application?.status;
    const editable =
      status === "DRAFT" ||
      status === "RESUBMIT_REQUIRED" ||
      (status === "SUBMITTED" && !application?.assignedOfficerId);
    if (steps[active]?.kind !== "documents" || !appId || !editable) return;
    if (filledForApplication.current === appId) return;
    filledForApplication.current = appId;

    fillFromVault(appId)
      .unwrap()
      .then((result) => {
        // Nothing was copied: no need to disturb the queries.
        if (result.filled.length) refetchAttachments();
      })
      // A vault that cannot be read is not a reason to block the form — the
      // applicant can still upload by hand.
      .catch(() => undefined);
  }, [
    steps,
    active,
    appId,
    application?.status,
    application?.assignedOfficerId,
    fillFromVault,
    refetchAttachments,
  ]);

  if (loadingConfig || !config || !appId || !application) {
    return <PageLoader label={t('licenseApplication.loading', 'Loading Application…')} height={400} />;
  }

  // A submitted application stays editable until an officer takes it, which
  // mirrors the server's own rule (`assertEditable`): an applicant who spots
  // their own mistake can fix it instead of waiting to be sent back for it.
  // Once claimed it locks — the officer reading it must not have the form move
  // underneath them.
  const editableWhileSubmitted =
    application.status === "SUBMITTED" && !application.assignedOfficerId;
  const readOnly =
    !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
    !editableWhileSubmitted;
  // A DRAFT has nothing worth summarising yet, so it always opens straight
  // into the wizard; every later status (including RESUBMIT_REQUIRED) opens
  // to the summary first.
  const showSummary = application.status !== "DRAFT" && viewingSummary;

  // The applicant reads "Vessel Particulars", not "vesselParticulars" — and a
  // staff remark is keyed by a uuid, which reads as nothing at all.
  function remarkLabel(remark: (typeof openRemarks)[number]): string {
    if (remark.targetType === "FORM_SECTION") {
      const section = config?.licenseType.formSchema.sections.find(
        (s) => s.key === remark.targetKey,
      );
      return section ? localized(section.title) : remark.targetKey;
    }
    if (remark.targetType === "DOCUMENT") {
      const requirement = config?.documentRequirements.find(
        (r) => r.key === remark.targetKey,
      );
      return requirement ? localized(requirement.name) : remark.targetKey;
    }
    return t("licenseApplication.staffMember", "Staff member");
  }

  // Vessel Information and Current Ownership are separate form sections, so
  // ConfigDrivenSection (one instance per section) can't fill both itself —
  // it reports the pick up here and this fans it out across every section.
  function handleVesselSelected(vessel: Vessel) {
    for (const section of config?.licenseType.formSchema.sections ?? []) {
      fillFromVessel(vessel, section.fields, (key, value) => {
        setDraft((prev) => ({
          ...prev,
          [section.key]: { ...(prev[section.key] ?? {}), [key]: value },
        }));
      });
    }
  }

  async function saveSection(sectionKey: string) {
    // During an adjustment round only flagged sections are editable, so don't
    // even attempt a write the server would reject.
    if (isSectionLocked(sectionKey)) return;
    const values = { ...(draft[sectionKey] ?? {}) };
    // The picker works in alpha-2 codes (CountrySelect); the backend, like
    // the profile Address endpoint, stores the full country name.
    const nationalityField = config?.licenseType.formSchema.sections
      .find((s) => s.key === sectionKey)
      // English-pinned: same reasoning as the fill() matcher above.
      ?.fields.find(
        (f) =>
          f.key === "nationality" ||
          (f.label.en ?? "").toLowerCase().includes("nationality"),
      );
    if (nationalityField && values[nationalityField.key]) {
      values[nationalityField.key] =
        getCountryName(values[nationalityField.key] as string) ||
        values[nationalityField.key];
    }
    try {
      await patchSection({
        id: appId as string,
        sectionKey,
        values,
      }).unwrap();
    } catch (err) {
      notifications.show({
        color: "red",
        title: "Could not save",
        message: extractErrorMessage(err),
      });
    }
  }

  async function handleSubmit() {
    setIssues([]);
    if (!readOnly && currentStep?.sections?.length) {
      const errors = validateSections(
        currentStep.sections,
        draft,
        i18n.language,
      );
      setFieldErrors(errors);
      if (Object.keys(errors).length) {
        notifications.show({
          color: "red",
          title: "Incomplete",
          message: "Complete the highlighted fields before submitting.",
        });
        return;
      }
    }
    for (const section of sections) await saveSection(section.key);
    try {
      if (isAdjusting) {
        for (const remark of openRemarks) {
          await resolveRemark({
            id: appId as string,
            remarkId: remark.id,
          }).unwrap();
        }
        await resubmitApplication(appId as string).unwrap();
        notifications.show({
          color: "teal",
          title: "Resubmitted",
          message: "Your corrections were sent back to the reviewing officer.",
        });
        navigate("/licensing/applications");
      } else {
        await submitApplication(appId as string).unwrap();
        notifications.show({
          color: "teal",
          title: "Application submitted",
          message: "You will be notified as it progresses.",
        });
        // Stays on the application rather than dropping the applicant into a
        // list: they have just filled a long form and the useful next screen is
        // what they submitted, with its status and — while it is still
        // unclaimed — the means to correct it.
        setViewingSummary(true);
      }
    } catch (err) {
      const found = extractValidationIssues(err);
      setIssues(found);
      notifications.show({
        color: "red",
        title: "Application incomplete",
        message: found.length
          ? t('licenseApplication.notifications.applicationIncomplete.itemsNeedAttention', { count: found.length })
          : extractErrorMessage(err),
      });
    }
  }

  const currentStep = steps[active];


  /**
   * Checks one step before moving past it.
   *
   * The server rejects an incomplete application anyway, but only at submit —
   * by then the applicant has walked through every step and has to hunt for
   * what was missing. Validating per step points at the field directly.
   *
   * Takes the step rather than reading `currentStep`, so a jump ahead can
   * check each step it passes over instead of only the one being left.
   */
  async function validateStep(index: number): Promise<boolean> {
    const step = steps[index];
    // The wizard does not render until the configuration has loaded, but this
    // is declared above that guard, so narrow it here too.
    if (!step || !config) return true;

    if (step.kind === "sections") {
      const errors = validateSections(step.sections, draft, i18n.language);
      setFieldErrors(errors);
      const count = Object.keys(errors).length;
      if (count > 0) {
        notifications.show({
          color: "red",
          title: "Incomplete",
          message: `Complete ${count} required field${count > 1 ? "s" : ""} to continue.`,
        });
        return false;
      }
      return true;
    }

    if (step.kind === "staff") {
      const missing = config.staffRoleRequirements
        .filter(
          (role) =>
            (detail?.staff ?? []).filter((m) => m.roleKey === role.roleKey)
              .length < role.minCount,
        )
        .map((role) =>
          t('licenseApplication.notifications.staffIncomplete.roleRequired', {
            name: localized(role.name),
            count: role.minCount,
          }),
        );
      if (missing.length) {
        notifications.show({
          color: "red",
          title: "Staff incomplete",
          message: `Still needed: ${missing.join(", ")}.`,
        });
        return false;
      }
      return true;
    }

    if (step.kind === "documents") {
      const supplied = new Set(
        attachments.filter((a) => a.files?.length).map((a) => a.documentKey),
      );
      const missing = config.documentRequirements
        .filter(
          (req) =>
            req.mode === "ALWAYS" ||
            (req.mode === "CONDITIONAL" &&
              conditionHolds(req.conditionExpression, draft)),
        )
        .filter((req) => !supplied.has(req.key))
        .map((req) => localized(req.name));
      if (missing.length) {
        const shown = missing.slice(0, 3).join(', ');
        const extra =
          missing.length > 3
            ? ` ${t('licenseApplication.notifications.documentsMissing.andMore', {
                count: missing.length - 3,
              })}`
            : '';
        notifications.show({
          color: "red",
          title: "Documents missing",
          message: `Upload: ${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ` and ${missing.length - 3} more` : ""}.`,
        });
        return false;
      }
      return true;
    }

    return true;
  }

  /** The step the applicant is on — what `Continue` validates. */
  async function validateCurrentStep(): Promise<boolean> {
    return validateStep(active);
  }

  async function handleContinue() {
    // A locked step during an adjustment round has nothing to validate.
    if (!readOnly && !(await validateCurrentStep())) return;
    if (currentStep?.kind === "sections") {
      for (const section of currentStep.sections)
        await saveSection(section.key);
    }
    setFieldErrors({});
    setActive((s) => Math.min(steps.length - 1, s + 1));
  }

  /**
   * Going back is always allowed; going forward validates every step passed.
   *
   * `target` used to be discarded on the forward path — the handler validated
   * the current step and then advanced by exactly one, so clicking "4" from
   * step 1 landed on step 2. Two steps then showed the same content one click
   * apart, which reads as a broken wizard rather than a refused jump, and made
   * the later sections look absent entirely.
   *
   * Each step between here and `target` is validated and saved in order, so a
   * jump ahead cannot skip a required field the way a plain `setActive` would.
   * The walk stops at the first step that fails, leaving the applicant on it
   * with its errors showing.
   */
  async function goToStep(target: number) {
    if (target <= active) {
      setActive(target);
      return;
    }

    for (let step = active; step < target; step++) {
      if (!readOnly && !(await validateStep(step))) {
        setActive(step);
        return;
      }
      const passed = steps[step];
      if (passed?.kind === "sections") {
        for (const section of passed.sections) await saveSection(section.key);
      }
    }

    setFieldErrors({});
    setActive(target);
  }

  return (
    <Container size="lg" py="md">
      <Group justify="space-between" mb="xs" align="flex-start">
        <div>
          <Title order={3}>{localized(config.licenseType.name)}</Title>
          <Group gap="xs" mt={4}>
            <Text size="sm" c="dimmed">
              {application.applicationNumber}
            </Text>
            <Badge
              size="sm"
              variant="light"
              color={STATUS_COLORS[application.status]}
            >
              {STATUS_LABELS[application.status]}
            </Badge>
            {detail?.issuedLicenseStatus === "SUPERSEDED" && (
              <Badge size="sm" variant="light" color="gray">
                {t("licensing.certificateSuperseded", "Certificate superseded")}
              </Badge>
            )}
          </Group>
        </div>
        <Group gap="md" align="center">
          <Text size="sm" c="dimmed">
            Fee: {config.fee ?? "—"} {config.feeCurrency}
          </Text>
          {showSummary && !readOnly && (
            <Button
              size="xs"
              variant="default"
              leftSection={<IconPencil size={14} />}
              onClick={() => setViewingSummary(false)}
            >
              Edit details
            </Button>
          )}
        </Group>
      </Group>

      {isAdjusting && (
        <Alert
          color="orange"
          icon={<IconAlertTriangle size={16} />}
          title={t('licenseApplication.correctionsRequested.title')}
          mb="md"
        >
          <Stack gap={4}>
            {openRemarks.map((remark) => (
              <Text size="sm" key={remark.id}>
                <b>{remarkLabel(remark)}</b>: {remark.remark}
              </Text>
            ))}
            <Text size="xs" c="dimmed" mt={4}>
              {t('licenseApplication.correctionsRequested.onlyListed')}
            </Text>
          </Stack>
        </Alert>
      )}

      {config.licenseType.requiresIssuanceScheduling &&
        (application.status === "PAYMENT_CONFIRMED" ||
          application.status === "SCHEDULED") && (
          <Box mb="md">
            <PickupSchedulingPanel
              scheduledDate={application.scheduledIssuanceDate}
              scheduledPeriod={application.scheduledIssuancePeriod}
            />
          </Box>
        )}

      {showSummary && editableWhileSubmitted && (
        <Alert
          color="blue"
          icon={<IconInfoCircle size={16} />}
          title="Submitted — still correctable"
          mb="md"
        >
          Your application is in the queue. You can still change any detail
          until a reviewing officer picks it up; after that, corrections happen
          only if they ask for them.
        </Alert>
      )}

      {issues.length > 0 && (
        <Alert
          color="red"
          icon={<IconAlertTriangle size={16} />}
          title="Still missing"
          mb="md"
        >
          <Stack gap={2}>
            {issues.map((issue, i) => (
              <Text size="sm" key={i}>
                • {issue.message}
              </Text>
            ))}
          </Stack>
        </Alert>
      )}

      {showSummary && (
        <ApplicationSummary
          sections={sections}
          formData={application.formData}
          localized={localized}
          config={config}
          attachments={attachments}
          applicationId={appId as string}
        />
      )}

      {!showSummary && (
        <Paper withBorder p="lg" radius="md">
          <Stepper active={active} onStepClick={goToStep} size="sm" mb="lg">
            {steps.map((step) => (
              <Stepper.Step key={step.key} label={step.label} />
            ))}
          </Stepper>

          {currentStep?.kind === "sections" && (
            <Stack gap="lg">
              {currentStep.sections.map((section, index) => {
                const locked = isSectionLocked(section.key);
                return (
                  <div key={section.key}>
                    {index > 0 && <Divider mb="lg" />}
                    <Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb={section.description ? 4 : "sm"}>
                      {localized(section.title)}
                    </Text>
                    {section.description && (
                      <Text fz="xs" c="dimmed" mb="sm">
                        {localized(section.description)}
                      </Text>
                    )}
                    {locked && (
                      <Alert
                        color="gray"
                        icon={<IconInfoCircle size={16} />}
                        mb="md"
                      >
                        This section was accepted and is locked for this round.
                      </Alert>
                    )}
                    <ConfigDrivenSection
                      section={section}
                      values={draft[section.key] ?? {}}
                      formData={draft}
                      onVesselSelected={handleVesselSelected}
                      errors={fieldErrors}
                      vessels={vessels}
                      disabled={readOnly || locked}
                      onChange={(key, value) => {
                        setDraft((prev) => ({
                          ...prev,
                          [section.key]: {
                            ...(prev[section.key] ?? {}),
                            [key]: value,
                          },
                        }));
                        // Clear the error as soon as the applicant addresses it.
                        setFieldErrors((prev) => {
                          const next = { ...prev };
                          delete next[`${section.key}.${key}`];
                          return next;
                        });
                      }}
                    />
                  </div>
                );
              })}
            </Stack>
          )}

          {currentStep?.kind === "staff" && (
            <Stack>
              {config.staffRoleRequirements.map((role) => {
                const members = (detail?.staff ?? []).filter(
                  (s) => s.roleKey === role.roleKey,
                );
                return (
                  <Card withBorder key={role.roleKey} padding="md">
                    <Group justify="space-between" mb="xs">
                      <div>
                        <Text fw={600} size="sm">
                          {localized(role.name)}
                        </Text>
                        <Text size="xs" c="dimmed">
                          {members.length} of {role.minCount} required
                          {role.requiredEvidence.length > 0 &&
                            ` · each needs ${role.requiredEvidence
                              .filter((e) => e.mandatory)
                              .map((e) => localized(e.label))
                              .join(", ")}`}
                        </Text>
                      </div>
                      <Group gap="xs">
                        {members.length >= role.minCount && (
                          <Badge
                            color="teal"
                            size="sm"
                            leftSection={<IconCheck size={10} />}
                          >
                            complete
                          </Badge>
                        )}
                        {!readOnly && !staffLocked && (
                          <Button
                            size="xs"
                            variant="light"
                            leftSection={<IconPlus size={14} />}
                            onClick={() => setStaffModal(role.roleKey)}
                          >
                            Add
                          </Button>
                        )}
                      </Group>
                    </Group>

                    <Stack gap="xs">
                      {members.map((member) => (
                        <Card
                          withBorder
                          key={member.id}
                          padding="sm"
                          radius="sm"
                        >
                          <Group
                            justify="space-between"
                            mb={member.id ? "xs" : 0}
                          >
                            <div>
                              <Text size="sm" fw={500}>
                                {member.fullName}
                              </Text>
                              <Text size="xs" c="dimmed">
                                {member.position ?? "—"}
                                {member.yearsOfExperience
                                  ? ` · ${member.yearsOfExperience} yrs`
                                  : ""}
                              </Text>
                            </div>
                            {!readOnly && !staffLocked && (
                              <ActionIcon
                                variant="subtle"
                                color="red"
                                onClick={async () => {
                                  await removeStaff({
                                    id: appId,
                                    staffId: member.id,
                                  });
                                  refetch();
                                }}
                              >
                                <IconTrash size={16} />
                              </ActionIcon>
                            )}
                          </Group>
                          <StaffEvidence
                            staffId={member.id}
                            evidence={role.requiredEvidence}
                            readOnly={readOnly || staffLocked}
                            onUploaded={refetch}
                          />
                        </Card>
                      ))}
                    </Stack>
                  </Card>
                );
              })}
            </Stack>
          )}

          {currentStep?.kind === "documents" && (
            <DocumentSlots
              requirements={config.documentRequirements}
              attachments={attachments}
              formData={draft}
              ownerType="APPLICATION"
              ownerId={appId}
              flagged={flaggedDocuments}
              restrictToFlagged={roundIsItemised}
              alsoUnlocked={unlockedDocuments}
              readOnly={readOnly}
              onUploaded={() => {
                refetchAttachments();
                refetch();
              }}
            />
          )}

          {currentStep?.kind === "review" && (
            <Stack>
              {currentStep.sections.map((section) => (
                <div key={section.key}>
                  <Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
                    {localized(section.title)}
                  </Text>
                  <ConfigDrivenSection
                    section={section}
                    onVesselSelected={handleVesselSelected}
                    values={draft[section.key] ?? {}}
                    formData={draft}
                    errors={fieldErrors}
                    vessels={vessels}
                    // Same lock as the earlier steps — without it this step
                    // looked editable during an adjustment round while
                    // saveSection silently dropped the changes.
                    disabled={readOnly || isSectionLocked(section.key)}
                    onChange={(key, value) => {
                      setDraft((prev) => ({
                        ...prev,
                        [section.key]: {
                          ...(prev[section.key] ?? {}),
                          [key]: value,
                        },
                      }));
                      setFieldErrors((prev) => {
                        const next = { ...prev };
                        delete next[`${section.key}.${key}`];
                        return next;
                      });
                    }}
                  />
                  <Divider my="lg" />
                </div>
              ))}
              <Title order={5}>Review</Title>
              {sections.map((section) => (
                <div key={section.key}>
                  <Text fw={600} size="sm" mb={4}>
                    {localized(section.title)}
                  </Text>
                  <Table withTableBorder withColumnBorders>
                    <Table.Tbody>
                      {(section.fields ?? [])
                        .filter((f) => conditionHolds(f.showWhen, draft))
                        .map((field) => (
                          <Table.Tr key={field.key}>
                            <Table.Td w="45%">
                              <Text size="xs" c="dimmed">
                                {localized(field.label)}
                              </Text>
                            </Table.Td>
                            <Table.Td>
                              <Text size="sm">
                                {String(draft[section.key]?.[field.key] ?? "—")}
                              </Text>
                            </Table.Td>
                          </Table.Tr>
                        ))}
                    </Table.Tbody>
                  </Table>
                  <Divider my="sm" />
                </div>
              ))}
            </Stack>
          )}

          <Group justify="space-between" mt="xl">
            <Button
              variant="default"
              onClick={() => setActive((s) => Math.max(0, s - 1))}
              disabled={active === 0}
            >
              Back
            </Button>
            {active < steps.length - 1 ? (
              <Button onClick={handleContinue}>Continue</Button>
            ) : (
              <RequirePermission
                anyOf={
                  isAdjusting
                    ? [PORTAL_PERMISSIONS.RESUBMIT_APPLICATION]
                    : [LICENSE_PERMISSIONS.SUBMIT_APPLICATION]
                }
                hideOnly
              >
                <Button
                  color="teal"
                  loading={submitting || resubmitting}
                  disabled={readOnly}
                  onClick={handleSubmit}
                >
                  {isAdjusting ? "Resubmit corrections" : "Submit application"}
                </Button>
              </RequirePermission>
            )}
          </Group>
        </Paper>
      )}

      <Modal
        opened={Boolean(staffModal)}
        onClose={() => setStaffModal(null)}
        title={t('licenseApplication.staff.addStaffMember')}
      >
        <Stack>
          <TextInput
            label={t('licenseApplication.staff.fullName')}
            withAsterisk
            value={newStaff.fullName}
            onChange={(e) =>
              setNewStaff({ ...newStaff, fullName: e.currentTarget.value })
            }
          />
          <TextInput
            label={t('licenseApplication.staff.position')}
            value={newStaff.position}
            onChange={(e) =>
              setNewStaff({ ...newStaff, position: e.currentTarget.value })
            }
          />
          <NumberInput
            label={t('licenseApplication.staff.yearsOfExperience')}
            value={newStaff.yearsOfExperience}
            onChange={(v) =>
              setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })
            }
            min={0}
          />
          <ModalFooter>
            <Button
              onClick={async () => {
                if (!newStaff.fullName.trim() || !staffModal) return;
                await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
                setNewStaff({
                  fullName: "",
                  position: "",
                  yearsOfExperience: 0,
                });
                setStaffModal(null);
                refetch();
              }}
            >
              {t('licenseApplication.staff.add')}
            </Button>
          </ModalFooter>
        </Stack>
      </Modal>
    </Container>
  );
}

export default LicenseApplicationPage;
