import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
import type {
  ApplicationKind,
  Bilingual,
  FamilyKind,
  FieldCondition,
  FormFieldConfig,
  FormSectionConfig,
  LicenseApplication,
  LicenseStatus,
  ValidationIssue,
} from './licensing.types';

/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
  return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
}

/**
 * Uploads a document straight to the API.
 *
 * Files go up the moment they're picked rather than being held until submit —
 * the application already exists as a draft, so there is a real owner to
 * attach them to, and nothing can be lost if the browser closes mid-wizard.
 */
export async function uploadDocument(params: {
  ownerType:
    | 'APPLICATION'
    | 'APPLICATION_STAFF'
    | 'INSPECTION'
    | 'LICENSE'
    | 'SEA_SERVICE_RECORD'
    | 'MEDICAL_CERTIFICATE'
    | 'SEAFARER_REGISTRATION';
  ownerId: string;
  documentKey: string;
  file: File;
  title?: string;
  validFrom?: string;
  validTo?: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
  const form = new FormData();
  form.append('ownerType', params.ownerType);
  form.append('ownerId', params.ownerId);
  form.append('documentKey', params.documentKey);
  if (params.title) form.append('title', params.title);
  if (params.validFrom) form.append('validFrom', params.validFrom);
  if (params.validTo) form.append('validTo', params.validTo);
  form.append('files', params.file, params.file.name);

  const token = resolveTokenFromStorage();
  const res = await fetch(`${BASE_API_URL}/attachments`, {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : {},
    body: form,
  });
  if (res.ok) return { ok: true };
  const body = await res.json().catch(() => null);
  return { ok: false, error: body?.message ?? `Upload failed (${res.status})` };
}

/** Human label for a status, in the vocabulary the user stories use. */
export const STATUS_LABELS: Record<LicenseStatus, string> = {
  DRAFT: 'Draft',
  SUBMITTED: 'Submitted',
  UNDER_REVIEW: 'Under Review',
  AWAITING_BIOMETRICS: 'Awaiting Biometrics',
  UNDER_EVALUATION: 'Under Evaluation',
  RESUBMIT_REQUIRED: 'Resubmit Required',
  INSPECTION_PENDING: 'Inspection Pending',
  REVIEW_REPORTED: 'Review Reported',
  INSPECTION_COMPLETED: 'Inspection Completed',
  INSPECTION_REPORTED: 'Inspection Reported',
  INSPECTION_FAILED: 'Inspection Failed',
  APPROVED: 'Approved',
  REJECTED: 'Rejected',
  ON_HOLD: 'On Hold',
  PAYMENT_PENDING: 'Payment Pending',
  PAID: 'Paid',
  PAYMENT_CONFIRMED: 'Preparing Certificate',
  SCHEDULED: 'Pickup Scheduled',
  CERTIFICATE_ISSUED: 'Certificate Issued',
  COMPLETED: 'Completed',
  ELIGIBILITY_PAYMENT_PENDING: 'Eligibility Fee Due',
  ELIGIBILITY_PAID: 'Eligibility Under Review',
  EXAM_PAYMENT_PENDING: 'Exam Fee Due',
  // Nobody assigns a date any more — the fee clearing is what makes the
  // candidate eligible to register for a published sitting themselves.
  EXAM_PAID: 'Eligible to Register',
  EXAM_SCHEDULED: 'Exam Scheduled',
  EXAM_PASSED: 'Exam Passed',
  EXAM_FAILED: 'Exam Not Passed',
};

/** Mantine colour per status — green progresses, orange needs the applicant. */
export const STATUS_COLORS: Record<LicenseStatus, string> = {
  DRAFT: 'gray',
  SUBMITTED: 'blue',
  UNDER_REVIEW: 'indigo',
  AWAITING_BIOMETRICS: 'indigo',
  UNDER_EVALUATION: 'indigo',
  RESUBMIT_REQUIRED: 'orange',
  INSPECTION_PENDING: 'cyan',
  // Both 'reported' states are waiting on the team leader, so they carry the
  // same tone as anything else awaiting an officer decision.
  REVIEW_REPORTED: 'orange',
  INSPECTION_COMPLETED: 'cyan',
  INSPECTION_REPORTED: 'orange',
  // Orange, not red: recoverable — a re-inspection can still pass.
  INSPECTION_FAILED: 'orange',
  APPROVED: 'teal',
  REJECTED: 'red',
  ON_HOLD: 'gray',
  PAYMENT_PENDING: 'yellow',
  PAID: 'lime',
  PAYMENT_CONFIRMED: 'teal',
  SCHEDULED: 'cyan',
  CERTIFICATE_ISSUED: 'green',
  COMPLETED: 'green',
  ELIGIBILITY_PAYMENT_PENDING: 'yellow',
  ELIGIBILITY_PAID: 'lime',
  EXAM_PAYMENT_PENDING: 'yellow',
  EXAM_PAID: 'lime',
  EXAM_SCHEDULED: 'cyan',
  EXAM_PASSED: 'teal',
  // Orange, not red: a failure is recoverable here — the candidate resits.
  EXAM_FAILED: 'orange',
};

/**
 * Roughly how far through the lifecycle a status sits, as a percentage.
 *
 * Used only to draw a progress bar. The two terminal outcomes both read as
 * finished — a rejection is the end of the road, not 60% of the way somewhere.
 */
export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
  DRAFT: 5,
  SUBMITTED: 15,
  UNDER_REVIEW: 30,
  AWAITING_BIOMETRICS: 30,
  UNDER_EVALUATION: 45,
  RESUBMIT_REQUIRED: 30,
  INSPECTION_PENDING: 55,
  REVIEW_REPORTED: 50,
  INSPECTION_COMPLETED: 65,
  INSPECTION_REPORTED: 70,
  // A re-inspection returns to the pending step, so no further along than it.
  INSPECTION_FAILED: 55,
  APPROVED: 75,
  // Parked, so it keeps the progress of wherever it was held from.
  ON_HOLD: 45,
  PAYMENT_PENDING: 80,
  PAID: 88,
  PAYMENT_CONFIRMED: 94,
  SCHEDULED: 97,
  CERTIFICATE_ISSUED: 100,
  COMPLETED: 100,
  REJECTED: 100,
  // The exam leg sits between approval and the certificate fee, so these
  // interleave with PAYMENT_PENDING (80) rather than running past it.
  ELIGIBILITY_PAYMENT_PENDING: 52,
  ELIGIBILITY_PAID: 56,
  EXAM_PAYMENT_PENDING: 64,
  EXAM_PAID: 68,
  EXAM_SCHEDULED: 72,
  EXAM_PASSED: 78,
  // A resit returns to the fee step, so this is not further along than a pass.
  EXAM_FAILED: 64,
};

/** Statuses where nothing moves until the applicant does something. */
export const APPLICANT_ACTION_STATUSES: LicenseStatus[] = [
  'DRAFT',
  'RESUBMIT_REQUIRED',
  'PAYMENT_PENDING',
  // Due the moment an examined application is submitted, before any officer
  // looks at it.
  'ELIGIBILITY_PAYMENT_PENDING',
  // Both wait on the candidate: one to pay for a sitting, one to decide to
  // sit again after a failure.
  'EXAM_PAYMENT_PENDING',
  'EXAM_FAILED',
  // Passed: the certificate fee falls due, and only the candidate can pay it.
  'EXAM_PASSED',
];

/** Statuses that are finished, whichever way they went. */
export const TERMINAL_STATUSES: LicenseStatus[] = [
  'CERTIFICATE_ISSUED',
  'COMPLETED',
  'REJECTED',
];

/** Licence types with no `companyName` — these are filed by a person, not a
 * business, so display falls back to the applicant name captured in the form. */
export const APPLICANT_NAME_TYPE_KEYS = [
  'SEAFARER_REGISTRATION',
  'CERTIFICATE_OF_COMPETENCY',
  'CERTIFICATE_OF_PROFICIENCY',
  'VESSEL_REGISTRATION',
  'VESSEL_OWNERSHIP_TRANSFER',
  'ENDORSEMENT_SEAFARER',
  'ENDORSEMENT_COC',
  'ENDORSEMENT_GOC',
];

const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
  SEAFARER_REGISTRATION: 'DOCUMENT',
  SEAMAN_BOOK: 'DOCUMENT',
  VESSEL_REGISTRATION: 'DOCUMENT',
  BTC_BASIC_TRAINING: 'CERTIFICATE',
  CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
  CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
  ENDORSEMENT_SEAFARER: 'CERTIFICATE',
  ENDORSEMENT_COC: 'CERTIFICATE',
  ENDORSEMENT_GOC: 'CERTIFICATE',
  FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
  SHIPPING_AGENT: 'LOGISTICS_LICENSE',
  COMBINED_SA_FF: 'LOGISTICS_LICENSE',
  MULTIMODAL_TRANSPORT_OPERATOR: 'LOGISTICS_LICENSE',
  JOINT_INVESTOR: 'LOGISTICS_LICENSE',
};

/**
 * `FamilyKind` for a type key, for data that predates `familyKind` being a
 * real column on the server (cached responses from before the rollout, or
 * anything that only ever had a bare key to go on). Every fresh response now
 * carries `familyKind` directly from the server's own `family_kind` column —
 * prefer that over calling this. Kept only as a fallback, and only for keys
 * this map happens to know about; falls back further to `LOGISTICS_LICENSE`
 * for anything else, reproducing today's "Licence ___" wording — the safe
 * default for an unrecognised key.
 */
export function resolveFamilyKind(licenseTypeKey: string | undefined | null): FamilyKind {
  return (licenseTypeKey && FAMILY_KIND_BY_KEY[licenseTypeKey]) || 'LOGISTICS_LICENSE';
}

export interface FamilyLabels {
  /** e.g. "Certificate", "Document", "Licence". */
  typeLabel: string;
  /** e.g. "Certificate Number", "Document Number", "Licence Number". */
  numberLabel: string;
  /** e.g. "Certificate Holder", "Document Holder", "Licence Holder". */
  holderLabel: string;
  /** e.g. "Certificate Review", "Document Review", "Licence Review". */
  reviewLabel: string;
}

const FAMILY_LABELS: Record<FamilyKind, FamilyLabels> = {
  CERTIFICATE: {
    typeLabel: 'Certificate',
    numberLabel: 'Certificate Number',
    holderLabel: 'Certificate Holder',
    reviewLabel: 'Certificate Review',
  },
  DOCUMENT: {
    typeLabel: 'Document',
    numberLabel: 'Document Number',
    holderLabel: 'Document Holder',
    reviewLabel: 'Document Review',
  },
  LOGISTICS_LICENSE: {
    typeLabel: 'Licence',
    numberLabel: 'Licence Number',
    holderLabel: 'Licence Holder',
    reviewLabel: 'Licence Review',
  },
};

export function familyLabels(familyKind: FamilyKind): FamilyLabels {
  return FAMILY_LABELS[familyKind];
}

/**
 * Company name, or applicant name for applications with no company.
 *
 * Branches on `familyKind` — the real data-model column — rather than a
 * hand-maintained key list. A certificate or document is filed by a person,
 * never a business, so it never has a `companyName` to show; a logistics
 * licence always does. This is what used to be `APPLICANT_NAME_TYPE_KEYS`, a
 * frontend array that had to be remembered and kept in sync by hand every
 * time a new certificate/document type was added — it silently missed
 * `SEAMAN_BOOK`/`BTC_BASIC_TRAINING`, which is why those rows rendered "—"
 * instead of the applicant's name in the backoffice queue. `familyKind` is
 * correct for every current and future type without a matching array update.
 */
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
  if (app.familyKind === 'LOGISTICS_LICENSE') {
    return app.companyName ?? undefined;
  }
  const applicantName = (app.formData?.account as Record<string, unknown> | undefined)
    ?.applicantName;
  return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
}

/**
 * One form answer as a person should read it back.
 *
 * The stored value is not it: a SELECT holds the option's `value`, so an
 * unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` —
 * the codes the database wants, not the words that were chosen. Shared by the
 * applicant's summary and the officer's review so the two never describe the
 * same application differently.
 *
 * `showDate` is passed in rather than imported: date display is a hook
 * (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function.
 */
export function displayFieldValue(
  field: Pick<FormFieldConfig, 'type' | 'options'>,
  raw: unknown,
  opts: {
    language?: string;
    showDate?: (value: string) => string;
    currency?: string;
  } = {},
): string {
  if (raw === null || raw === undefined || raw === '') return '';
  const { language = 'en', showDate, currency } = opts;

  switch (field.type) {
    case 'BOOLEAN':
      return raw ? 'Yes' : 'No';
    case 'DATE':
      return showDate?.(String(raw)) || String(raw);
    case 'SELECT': {
      const option = field.options?.find((o) => o.value === raw);
      // Falls back to the stored value rather than blanking: an option removed
      // from the config since this was filed still has to show what was chosen.
      return option ? localized(option.label, language) : String(raw);
    }
    case 'MONEY': {
      const amount = Number(raw);
      return Number.isFinite(amount)
        ? `${amount.toLocaleString()} ${currency ?? ''}`.trim()
        : String(raw);
    }
    default:
      return String(raw);
  }
}

/** Reads a bilingual value for the active language, falling back to English. */
export function localized(value: Bilingual | undefined, language = 'en'): string {
  if (!value) return '';
  // `||` not `??`: an empty Amharic string is "not translated", not a value —
  // editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
  //
  // Keyed by the active language rather than an en/am ternary, so a locale the
  // backend stores but the UI does not yet offer a switcher for (`om`, `so` on
  // location names) still resolves once it does. English then Amharic remain
  // the fallbacks, in that order.
  const active = value[language as keyof Bilingual];
  return active || value.en || value.am || '';
}

/**
 * Pulls the per-field issue list out of a failed submit so the wizard can say
 * exactly what is missing instead of a generic error.
 */
export function extractValidationIssues(error: unknown): ValidationIssue[] {
  const data = (error as { data?: Record<string, unknown> })?.data;
  if (!data) return [];
  const issues = data['issues'];
  return Array.isArray(issues) ? (issues as ValidationIssue[]) : [];
}

/**
 * Server error keys rendered for a human.
 *
 * The API answers with stable machine keys; showing those raw (e.g.
 * "capital_verification_required") tells an officer nothing about what to do.
 */
const ERROR_MESSAGES: Record<string, string> = {
  capital_verification_required:
    'Record the verified capital before approving — it must be checked against the bank letter.',
  capital_below_threshold:
    'The verified capital is below the minimum required for this licence type.',
  inspection_required_before_approval:
    'A physical inspection must be recorded before this application can be approved.',
  application_already_claimed: 'Another officer has already claimed this application.',
  assigned_to_another_officer:
    'This application is assigned to another officer, so only they can act on it.',
  not_application_owner: 'This application belongs to another applicant.',
  application_incomplete: 'The application is missing required information.',
  unresolved_remarks: 'Every requested correction must be addressed first.',
  adjustment_items_required: 'Flag at least one item before requesting an adjustment.',
  remark_text_required: 'Each flagged item needs a remark explaining what to fix.',
  rejection_reason_required: 'A rejection reason is required.',
  section_not_flagged_for_adjustment:
    'Only the sections the officer flagged can be changed in this round.',
  application_not_editable: 'This application can no longer be edited.',
  invalid_transition: 'That action is not available at this stage.',
  inspection_not_required_use_final_approve:
    'This licence type needs no inspection — approve it directly.',
  application_not_awaiting_inspection:
    'This application is not waiting for an inspection.',
  inspection_already_completed: 'This inspection has already been recorded.',
  inspection_not_yet_due:
    'Inspection results can be recorded after the scheduled inspection date and time.',
  inspection_not_passed:
    'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
  license_type_inactive: 'This licence type is not currently accepting applications.',

  // Licence-type configuration guards. Each of these refuses a change that
  // would leave applications already in progress unable to move.
  license_type_in_use:
    'Applications of this type are already in progress, and this setting decides the course they run. Wait until those have been decided, or change something else.',
  stage_fee_in_use:
    'Candidates are currently waiting to pay this fee. Removing it would leave them unable to pay and unable to continue — set a different amount instead.',
  form_schema_missing_protected_paths:
    'The form for this licence type does not contain the field this setting depends on. Add the field to the form first.',
};

export function extractErrorMessage(error: unknown, fallback = 'Something went wrong'): string {
  const data = (error as { data?: Record<string, unknown> })?.data;
  const raw = data?.['message'];

  // The filter passes structured errors through as `{ message: { message } }`.
  const key =
    typeof raw === 'string'
      ? raw
      : typeof (raw as { message?: unknown })?.message === 'string'
        ? ((raw as { message: string }).message)
        : undefined;

  if (key && ERROR_MESSAGES[key]) return ERROR_MESSAGES[key];
  if (typeof raw === 'string') return raw;
  if (Array.isArray(raw)) return raw.join(', ');
  return fallback;
}

/** One wizard step: either a group of form sections, or a fixed stage. */
export interface WizardStep {
  key: string;
  label: string;
  kind: 'sections' | 'staff' | 'documents' | 'review';
  sections: FormSectionConfig[];
}

/**
 * Turns the configured sections into wizard steps.
 *
 * Sections that share a `group` collapse onto one step, which is what keeps
 * the stepper short. Anything ungrouped keeps a step of its own, so a licence
 * type that has not been grouped still behaves exactly as before.
 */
/** "identitySummary" -> "Identity Summary". Last resort for an untitled group. */
function humanise(key: string): string {
  return key
    .replace(/[_-]+/g, ' ')
    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
    .replace(/\s+/g, ' ')
    .trim()
    .replace(/^./, (c) => c.toUpperCase());
}

export function buildWizardSteps(
  sections: FormSectionConfig[],
  formData: Record<string, Record<string, unknown>>,
  options?: {
    /**
     * Whether this licence type has staff-role requirements at all. Types
     * without any (seafarer registration) skip the Staff step entirely
     * instead of showing an empty page.
     */
    hasStaff?: boolean;
    /**
     * Whether this application has any document requirements to upload.
     * False for a Damaged/Reissue application, which asks nothing beyond the
     * Damage Information step — showing an empty Documents page would be a
     * page to click past for nothing.
     */
    hasDocuments?: boolean;
    /** Active UI language. Components get this from `useLocalized`; this is a
     *  pure function, so the caller passes `i18n.language` through. */
    language?: string;
    /**
     * The application's kind — NEW unless the caller is renewing or
     * reissuing. A section scoped to a different kind via
     * `applicationKinds` is left out entirely, the same as a `showWhen`
     * that never holds.
     */
    applicationKind?: ApplicationKind;
  },
): WizardStep[] {
  const kind = options?.applicationKind ?? 'NEW';
  const visible = [...sections]
    .filter((section) => sectionAppliesToKind(section, kind))
    .filter((section) => conditionHolds(section.showWhen, formData))
    .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));

  const steps: WizardStep[] = [];
  const byGroup = new Map<string, WizardStep>();

  for (const section of visible) {
    const group = section.group?.trim();
    if (!group) {
      steps.push({
        key: `section:${section.key}`,
        label: localized(section.title, options?.language),
        kind: 'sections',
        sections: [section],
      });
      continue;
    }
    const existing = byGroup.get(group);
    if (existing) {
      existing.sections.push(section);
      continue;
    }
    const step: WizardStep = {
      key: `group:${group}`,
      // A group is a config key, not a caption — showing it raw put
      // "identitySummary" and "applicant" in front of applicants. The first
      // section's own title is the readable name for the step it opens; the
      // humanised key is the fallback when a section carries no title.
      label: localized(section.title, options?.language) || humanise(group),
      kind: 'sections',
      sections: [section],
    };
    byGroup.set(group, step);
    steps.push(step);
  }

  // Honour explicit group ordering where given, keeping config order otherwise.
  const orderOf = (step: WizardStep) =>
    Math.min(...step.sections.map((s) => s.groupOrder ?? s.sortOrder ?? 0));
  steps.sort((a, b) => orderOf(a) - orderOf(b));

  // "Review" is a reserved group: those sections render on the final step
  // beside the summary rather than earning a step of their own. It is what
  // keeps a one-checkbox declaration from costing a whole page.
  const isReviewGroup = (step: WizardStep) =>
    step.sections.some((s) => s.group?.trim().toLowerCase() === 'review');
  const reviewSections = steps.filter(isReviewGroup).flatMap((s) => s.sections);
  const formSteps = steps.filter((step) => !isReviewGroup(step));

  return [
    ...formSteps,
    ...(options?.hasStaff === false
      ? []
      : [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
    ...(options?.hasDocuments === false
      ? []
      : [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
    { key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
  ];
}

/** `${sectionKey}.${fieldKey}` → message, for inline display under a field. */
export type FieldErrors = Record<string, string>;

/**
 * Checks the required fields of the given sections against the answers,
 * skipping anything a condition currently hides. Used to stop the wizard
 * advancing past an incomplete step rather than failing only at submit.
 */
export function validateSections(
  sections: FormSectionConfig[],
  formData: Record<string, Record<string, unknown>>,
  language = 'en',
): FieldErrors {
  const errors: FieldErrors = {};

  for (const section of sections) {
    if (!conditionHolds(section.showWhen, formData)) continue;
    const values = formData[section.key] ?? {};

    for (const field of section.fields ?? []) {
      if (!conditionHolds(field.showWhen, formData)) continue;
      // Read-only values are supplied by the server, not the applicant.
      if (field.readOnly && field.source) continue;

      const value = values[field.key];
      const empty =
        value === undefined ||
        value === null ||
        (typeof value === 'string' && value.trim() === '') ||
        (typeof value === 'boolean' && value === false) ||
        (Array.isArray(value) && value.length === 0);

      if (field.required && empty) {
        errors[`${section.key}.${field.key}`] =
          field.type === 'BOOLEAN'
            ? `${localized(field.label, language)} must be accepted`
            : `${localized(field.label, language)} is required`;
        continue;
      }
      if (empty) continue;

      // PhoneInput emits E.164 while typing, so a half-typed "+2519" is a
      // non-empty string that still has to be caught here.
      if (field.type === 'PHONE' && !isValidPhoneNumber(String(value))) {
        errors[`${section.key}.${field.key}`] = 'Enter a valid phone number';
        continue;
      }

      const numeric = Number(value);
      if (!Number.isNaN(numeric)) {
        if (field.min !== undefined && numeric < field.min) {
          errors[`${section.key}.${field.key}`] =
            `Must be at least ${field.min.toLocaleString()}`;
        } else if (field.max !== undefined && numeric > field.max) {
          errors[`${section.key}.${field.key}`] =
            `Must not exceed ${field.max.toLocaleString()}`;
        }
      }
    }
  }
  return errors;
}

/**
 * Evaluates a config condition against the current form answers.
 *
 * Mirrors the server's `ApplicationValidationService.conditionHolds` —
 * `anyOf` holds when any listed sub-condition holds, needed for an answer
 * that can live on one of several mutually-exclusive fields (e.g. a CoP rank
 * split by department).
 */
interface ConditionLike {
  field?: string;
  equals?: unknown;
  notEquals?: unknown;
  in?: (string | number)[];
  isSet?: boolean;
  /** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
  anyOf?: ConditionLike[];
}

/**
 * Section keys a condition reads, recursing `anyOf`.
 *
 * `FieldCondition.field` is a `sectionKey.fieldKey` path, so the prefix names
 * the section whose answer decides the condition.
 */
export function conditionSections(
  condition: FieldCondition | undefined | null,
): string[] {
  if (!condition) return [];
  if (condition.anyOf) return condition.anyOf.flatMap(conditionSections);
  if (!condition.field) return [];
  const [sectionKey] = condition.field.split('.');
  return sectionKey ? [sectionKey] : [];
}

/**
 * Sections whose visibility hangs on an answer in one of `flagged`.
 *
 * Mirrors the server's `sectionsDependingOn`: an officer flagging the section
 * that holds the vessel category is asking for an answer that decides which
 * fields in other sections are required, so those sections have to open too —
 * otherwise the applicant sees newly-required fields they cannot edit and
 * cannot resubmit.
 */
export function sectionsDependingOn(
  sections: FormSectionConfig[],
  flagged: Set<string>,
): Set<string> {
  const dependent = new Set<string>();
  if (flagged.size === 0) return dependent;

  for (const section of sections) {
    if (flagged.has(section.key)) continue;
    const reads = [
      section.showWhen,
      ...(section.fields ?? []).map((field) => field.showWhen),
    ].flatMap(conditionSections);
    if (reads.some((key) => flagged.has(key))) dependent.add(section.key);
  }
  return dependent;
}

export function conditionHolds(
  condition: FieldCondition | undefined | null,
  formData: Record<string, Record<string, unknown>>,
): boolean {
  if (!condition) return true;
  if (condition.anyOf) {
    return condition.anyOf.some((sub) => conditionHolds(sub, formData));
  }
  if (!condition.field) return true;
  const value = condition.field
    .split('.')
    .reduce<unknown>(
      (acc, key) =>
        acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[key] : undefined,
      formData as unknown,
    );
  const isEmpty = value === undefined || value === null || value === '';
  if (condition.isSet !== undefined) return condition.isSet ? !isEmpty : isEmpty;
  if (condition.equals !== undefined) return value === condition.equals;
  if (condition.notEquals !== undefined) return value !== condition.notEquals;
  if (condition.in !== undefined) return condition.in.includes(value as string | number);
  return true;
}
