/**
 * Pure transform: NocoDB snapshot → planned new-system entities + dedup result.
 *
 * Used by the migration script's `--dry-run` (to produce the report) and
 * `--apply` (to actually write). Keeping this pure means the same code
 * runs in both modes, in tests against the frozen fixture, and in the
 * one-off CLI run against the live base.
 *
 * No side effects, no DB calls, no external services.
 */

import {
  normalizeName,
  normalizeEmail,
  normalizePhone,
  resolveCountry,
  type NormalizedPhone,
} from './normalize';
import { findClientMatches, type MatchCandidate } from './find-matches';
import type { CountryCode } from '@/lib/i18n/countries';
import type { NocoDbRow, NocoDbSnapshot } from './nocodb-source';

// ─── Plan output ────────────────────────────────────────────────────────────

export interface PlannedClient {
  /** Stable id derived from the deduped cluster's lead row. Used by the
   *  apply phase to reference newly-created clients before they exist
   *  in the DB. */
  tempId: string;
  /** Source row IDs that contributed to this client (one if no duplicates,
   *  many if dedup merged a cluster). */
  sourceIds: number[];
  fullName: string;
  surnameToken?: string;
  countryIso: CountryCode | null;
  preferredContactMethod: string | null;
  source: string | null;
  contacts: PlannedContact[];
  addresses: PlannedAddress[];
}

export interface PlannedContact {
  channel: 'email' | 'phone' | 'whatsapp' | 'other';
  value: string;
  valueE164?: string | null;
  valueCountry?: CountryCode | null;
  isPrimary: boolean;
  flagged?: string;
}

export interface PlannedAddress {
  streetAddress: string | null;
  city: string | null;
  countryIso: CountryCode | null;
  /** When confidence is low, the migration script flags the row for
   *  human review. */
  countryConfidence: 'exact' | 'fuzzy' | 'city' | 'fallback' | null;
}

export interface PlannedInterest {
  /** NocoDB row id this interest came from. */
  sourceId: number;
  /** tempId of the planned client this interest hangs off. */
  clientTempId: string;
  pipelineStage: string;
  leadCategory: string | null;
  source: string | null;
  notes: string | null;
  /** Mooring number; the apply phase resolves this to a berthId via the
   *  new-system Berths table. */
  berthMooringNumber: string | null;
  yachtName: string | null;
  /** Date stamps for milestone columns. ISO strings if parseable. */
  dateEoiSent: string | null;
  dateEoiSigned: string | null;
  dateDepositReceived: string | null;
  dateContractSent: string | null;
  dateContractSigned: string | null;
  dateLastContact: string | null;
  /** Documenso linkage carried forward when present so the document
   *  record can be stitched up downstream. */
  documensoId: string | null;
  /** Interest-level EOI signing state so the deal *displays* its EOI
   *  status (signed / awaiting signatures). Derived from the legacy
   *  `EOI Status` / `LOI-NDA Document`; the runner may override these from
   *  the live Documenso envelope status for authoritative in-flight state. */
  eoiStatus: 'signed' | 'waiting_for_signatures' | 'expired' | null;
  eoiDocStatus: 'pending' | 'sent' | 'signed' | 'declined' | 'voided' | null;
}

/**
 * EOI document derived from a legacy NocoDB Interests row that carries a
 * `documensoID`. The apply phase materializes this into one
 * `documents` row plus up to three `document_signers` rows (client / cc /
 * developer), preserving the legacy signing-link URLs and timestamps.
 *
 * Carries the same `sourceId` as the parent interest - apply uses that to
 * resolve the new `interest_id` and `client_id` via
 * `migration_source_links`.
 */
export interface PlannedDocument {
  sourceId: number;
  /** tempId of the parent client (used for client_id resolution). */
  clientTempId: string;
  documentType: 'eoi';
  title: string;
  /** new-system document.status. Mapped from the legacy `EOI Status`
   *  enum + sign-time fields. */
  status: 'draft' | 'sent' | 'partially_signed' | 'completed';
  documensoId: string;
  notes: string | null;
  signers: PlannedDocumentSigner[];
  /** Mirror of interest's dateEoiSent; useful for back-dating createdAt. */
  dateSent: string | null;
}

export interface PlannedDocumentSigner {
  signerName: string;
  signerEmail: string;
  signerRole: 'client' | 'cc' | 'developer';
  signingOrder: number;
  status: 'pending' | 'signed';
  signedAt: string | null;
  signingUrl: string | null;
  embeddedUrl: string | null;
}

/**
 * Residential lead from the legacy "Interests (Residences)" table. Pure
 * contact record (no pipeline data in legacy), so apply creates a
 * `residential_clients` row plus a default `residential_interests` row at
 * `pipeline_stage='new'` so it surfaces in the residential funnel.
 */
export interface PlannedResidentialClient {
  /** Legacy residential row id - used as the migration_source_links key. */
  sourceId: number;
  fullName: string;
  email: string | null;
  phoneE164: string | null;
  phoneCountry: CountryCode | null;
  placeOfResidence: string | null;
  placeOfResidenceCountryIso: CountryCode | null;
  source: string | null;
  notes: string | null;
  dateFirstContact: string | null;
}

/**
 * Expense from the separate legacy "Expenses" NocoDB base. Maps to the
 * `expenses` table (financial.ts). Receipt blobs are backfilled in Phase 2.
 */
export interface PlannedExpense {
  /** Legacy Expenses row id — migration_source_links key. */
  sourceId: number;
  amount: string | null;
  currency: string;
  establishmentName: string | null;
  paymentMethod: string | null;
  category: string | null;
  payer: string | null;
  /** ISO; apply falls back to now (the column is NOT NULL). */
  expenseDate: string | null;
  description: string | null;
  paymentStatus: 'unpaid' | 'paid' | 'partial';
  /** Whether the legacy row had a Receipt attachment (sets
   *  noReceiptAcknowledged when false; the blob itself comes in Phase 2). */
  hadReceipt: boolean;
}

export interface MigrationFlag {
  sourceTable: 'interests' | 'residential_interests' | 'website_interest_submissions';
  sourceId: number;
  reason: string;
  details?: Record<string, unknown>;
}

export interface MigrationPlan {
  clients: PlannedClient[];
  interests: PlannedInterest[];
  /** EOI documents derived from interest rows with a `documensoID`. */
  documents: PlannedDocument[];
  /** Residential leads - physically separate domain, simple 1:1 mapping. */
  residentialClients: PlannedResidentialClient[];
  /** Expenses from the separate "Expenses" NocoDB base. */
  expenses: PlannedExpense[];
  flags: MigrationFlag[];
  /** Pairs that the migration would auto-link (high score). */
  autoLinks: Array<{
    leadSourceId: number;
    mergedSourceIds: number[];
    score: number;
    reasons: string[];
  }>;
  /** Pairs that need human review (medium score). Each pair shows up
   *  in the migration report; the user resolves before --apply. */
  needsReview: Array<{ aSourceId: number; bSourceId: number; score: number; reasons: string[] }>;
  stats: MigrationStats;
}

export interface MigrationStats {
  inputInterestRows: number;
  inputResidentialRows: number;
  outputClients: number;
  outputInterests: number;
  outputContacts: number;
  outputAddresses: number;
  outputDocuments: number;
  outputDocumentSigners: number;
  outputResidentialClients: number;
  outputExpenses: number;
  flaggedRows: number;
  autoLinkedClusters: number;
  needsReviewPairs: number;
}

export interface TransformOptions {
  /** ISO country used when a phone has no prefix and the row has no
   *  Place of Residence. Defaults to AI (Anguilla / Port Nimara's home). */
  defaultPhoneCountry: CountryCode;
  /** Score thresholds for auto-link vs human review. Should match the
   *  per-port `system_settings` values once the runtime UI is in place. */
  thresholds: {
    autoLink: number;
    needsReview: number;
  };
}

const DEFAULT_OPTIONS: TransformOptions = {
  defaultPhoneCountry: 'AI',
  thresholds: { autoLink: 90, needsReview: 50 },
};

// ─── Stage mapping ──────────────────────────────────────────────────────────

// Updated 2026-06-01 to the current 7-stage pipeline. The prior map targeted
// the pre-(9→7)-refactor vocab (open/details_sent/eoi_sent/…) and would have
// written invalid `pipeline_stage` values. Current stages live in
// `src/lib/constants.ts` PIPELINE_STAGES.
// §4b SIGNED OFF (2026-06-02): GQI with no qualifying marker (no berth / no
// size) is a plain enquiry; GQI that named a berth/size and SQI are qualified.
// `nurturing` is NOT bulk-assigned from legacy (reserved for actively-worked
// deals). `deposit_paid` is set independently from the Deposit-10% flag below.
// Contract Signed stays in `contract` with outcome OPEN (not won).
const STAGE_MAP: Record<string, string> = {
  'General Qualified Interest': 'enquiry', // refined to 'qualified' below if a berth/size marker exists
  'Specific Qualified Interest': 'qualified',
  'EOI and NDA Sent': 'eoi',
  'Signed EOI and NDA': 'eoi',
  'Made Reservation': 'reservation',
  'Contract Negotiation': 'contract',
  'Contract Negotiations Finalized': 'contract',
  'Contract Signed': 'contract',
};

const LEAD_CATEGORY_MAP: Record<string, string> = {
  General: 'general_interest',
  'Friends and Family': 'general_interest',
};

const SOURCE_MAP: Record<string, string> = {
  portal: 'website',
  Form: 'website',
  External: 'manual',
};

// ─── Date parsing ───────────────────────────────────────────────────────────

/**
 * Parse a date the legacy NocoDB might have stored in DD-MM-YYYY,
 * DD/MM/YYYY, YYYY-MM-DD, or ISO format. Returns ISO string or null.
 */
function parseFlexibleDate(input: unknown): string | null {
  if (typeof input !== 'string' || input.trim() === '') return null;
  const s = input.trim();

  // Already ISO
  if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
    const d = new Date(s);
    return Number.isNaN(d.getTime()) ? null : d.toISOString();
  }

  // DD-MM-YYYY or DD/MM/YYYY
  const m = s.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/);
  if (m) {
    const [, day, month, year] = m;
    const iso = `${year}-${month!.padStart(2, '0')}-${day!.padStart(2, '0')}`;
    const d = new Date(iso);
    return Number.isNaN(d.getTime()) ? null : d.toISOString();
  }

  // Anything else: try Date constructor as a last resort
  const d = new Date(s);
  return Number.isNaN(d.getTime()) ? null : d.toISOString();
}

// ─── Main transform ─────────────────────────────────────────────────────────

/**
 * Run the full transform pipeline against a NocoDB snapshot. Pure
 * function - same input always produces the same plan.
 */
export function transformSnapshot(
  snapshot: NocoDbSnapshot,
  options: Partial<TransformOptions> = {},
): MigrationPlan {
  const opts = { ...DEFAULT_OPTIONS, ...options };

  const flags: MigrationFlag[] = [];
  // Build per-row candidates first so we can run dedup before assigning
  // tempIds (clients with multiple source rows merge into one tempId).
  const perRow = snapshot.interests.map((row) => rowToCandidate(row, 'interests', opts, flags));

  // Dedup pass 1: every row scored against every other row (within the
  // same pool). The blocking strategy in `findClientMatches` keeps this
  // cheap even for the full 252-row dataset.
  const clusters = clusterByDedup(perRow, opts);

  // Build the planned clients + interests from the clusters.
  const clients: PlannedClient[] = [];
  const interests: PlannedInterest[] = [];
  const documents: PlannedDocument[] = [];
  const autoLinks: MigrationPlan['autoLinks'] = [];
  const needsReview: MigrationPlan['needsReview'] = [];

  for (const cluster of clusters) {
    const lead = cluster.leadCandidate;
    const tempId = `client-${lead.row.Id}`;

    // Build the client record from the lead row, then merge in any
    // contact info / address info from the other rows in the cluster.
    const planned = buildPlannedClient(tempId, cluster, opts);
    clients.push(planned);

    // Each row in the cluster becomes its own interest record. If the
    // legacy row carried a documensoID, also emit an EOI document so the
    // /documents view in the new CRM mirrors the legacy signing state.
    for (const member of cluster.members) {
      const interest = buildPlannedInterest(member.row, tempId);
      interests.push(interest);

      const doc = buildPlannedDocument(member.row, tempId, planned.fullName);
      if (doc) documents.push(doc);
    }

    if (cluster.members.length > 1) {
      autoLinks.push({
        leadSourceId: lead.row.Id,
        mergedSourceIds: cluster.members.filter((m) => m !== lead).map((m) => m.row.Id),
        score: cluster.maxScore,
        reasons: cluster.reasons,
      });
    }

    for (const pair of cluster.reviewPairs) {
      needsReview.push(pair);
    }
  }

  // Residential leads - separate domain, no dedup needed (different team
  // sees different rows). One PlannedResidentialClient per source row.
  const residentialClients: PlannedResidentialClient[] = snapshot.residentialInterests
    .map((row) => buildPlannedResidentialClient(row, opts, flags))
    .filter((r): r is PlannedResidentialClient => r !== null);

  const expenses = (snapshot.expenses ?? []).map(buildPlannedExpense);

  return {
    clients,
    interests,
    documents,
    residentialClients,
    expenses,
    flags,
    autoLinks,
    needsReview,
    stats: {
      inputInterestRows: snapshot.interests.length,
      inputResidentialRows: snapshot.residentialInterests.length,
      outputClients: clients.length,
      outputInterests: interests.length,
      outputContacts: clients.reduce((sum, c) => sum + c.contacts.length, 0),
      outputAddresses: clients.reduce((sum, c) => sum + c.addresses.length, 0),
      outputDocuments: documents.length,
      outputDocumentSigners: documents.reduce((sum, d) => sum + d.signers.length, 0),
      outputResidentialClients: residentialClients.length,
      outputExpenses: expenses.length,
      flaggedRows: flags.length,
      autoLinkedClusters: autoLinks.length,
      needsReviewPairs: needsReview.length,
    },
  };
}

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

interface RowCandidate {
  row: NocoDbRow;
  candidate: MatchCandidate;
  /** Phone normalize result for the row's primary phone; used downstream
   *  to attach valueE164 + country to the planned contact. */
  phoneResult: NormalizedPhone | null;
  /** Country resolved from "Place of Residence". */
  countryIso: CountryCode | null;
  countryConfidence: 'exact' | 'fuzzy' | 'city' | null;
  /** Normalized email or null. */
  email: string | null;
  /** Display name from `normalizeName`. */
  displayName: string;
}

function rowToCandidate(
  row: NocoDbRow,
  sourceTable: MigrationFlag['sourceTable'],
  opts: TransformOptions,
  flags: MigrationFlag[],
): RowCandidate {
  const rawName = (row['Full Name'] as string | undefined) ?? '';
  const rawEmail = (row['Email Address'] as string | undefined) ?? '';
  const rawPhone = (row['Phone Number'] as string | undefined) ?? '';
  const rawCountry = (row['Place of Residence'] as string | undefined) ?? '';

  const normName = normalizeName(rawName);
  const email = normalizeEmail(rawEmail);
  const country = resolveCountry(rawCountry);
  const phoneCountry = country.iso ?? opts.defaultPhoneCountry;
  const phoneResult = normalizePhone(rawPhone, phoneCountry as CountryCode);

  // Surface anything weird so the report can show it.
  if (rawPhone && !phoneResult?.e164) {
    flags.push({
      sourceTable,
      sourceId: row.Id,
      reason: phoneResult?.flagged ? `phone ${phoneResult.flagged}` : 'phone unparseable',
      details: { rawPhone },
    });
  }
  if (rawEmail && !email) {
    flags.push({
      sourceTable,
      sourceId: row.Id,
      reason: 'email invalid',
      details: { rawEmail },
    });
  }
  if (rawCountry && !country.iso) {
    flags.push({
      sourceTable,
      sourceId: row.Id,
      reason: 'country unresolved',
      details: { rawCountry },
    });
  }

  const candidate: MatchCandidate = {
    id: String(row.Id),
    fullName: normName.display || null,
    surnameToken: normName.surnameToken ?? null,
    emails: email ? [email] : [],
    phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [],
    countryIso: country.iso ?? null,
  };

  return {
    row,
    candidate,
    phoneResult,
    countryIso: country.iso ?? null,
    countryConfidence: country.confidence,
    email,
    displayName: normName.display,
  };
}

interface Cluster {
  /** The cluster's "lead" row (most complete + most recent). */
  leadCandidate: RowCandidate;
  members: RowCandidate[];
  maxScore: number;
  reasons: string[];
  /** Pairs in this cluster that scored medium (need review). */
  reviewPairs: Array<{ aSourceId: number; bSourceId: number; score: number; reasons: string[] }>;
}

function clusterByDedup(rows: RowCandidate[], opts: TransformOptions): Cluster[] {
  // Use a union-find structure indexed by row id. Every pair with a
  // score >= autoLink threshold gets unioned. Pairs in [needsReview,
  // autoLink) accumulate onto the cluster's reviewPairs list - they're
  // surfaced for human triage but not auto-merged.
  const parent = new Map<string, string>();
  for (const r of rows) parent.set(r.candidate.id, r.candidate.id);
  const find = (id: string): string => {
    let cur = id;
    while (parent.get(cur) !== cur) {
      const next = parent.get(cur)!;
      parent.set(cur, parent.get(next)!); // path compression
      cur = parent.get(cur)!;
    }
    return cur;
  };
  const union = (a: string, b: string) => {
    const rootA = find(a);
    const rootB = find(b);
    if (rootA !== rootB) parent.set(rootA, rootB);
  };

  const clusterReasons = new Map<string, string[]>();
  const clusterMaxScore = new Map<string, number>();
  const clusterReviewPairs = new Map<string, Cluster['reviewPairs']>();

  // Score every candidate against every other candidate. The find-matches
  // function does its own blocking so this is cheap.
  for (let i = 0; i < rows.length; i += 1) {
    const left = rows[i]!;
    const remainingPool = rows.slice(i + 1).map((r) => r.candidate);
    if (remainingPool.length === 0) continue;
    const matches = findClientMatches(left.candidate, remainingPool, {
      highScore: opts.thresholds.autoLink,
      mediumScore: opts.thresholds.needsReview,
    });

    for (const m of matches) {
      if (m.score >= opts.thresholds.autoLink) {
        union(left.candidate.id, m.candidate.id);
        const root = find(left.candidate.id);
        clusterMaxScore.set(root, Math.max(clusterMaxScore.get(root) ?? 0, m.score));
        const existing = clusterReasons.get(root) ?? [];
        for (const reason of m.reasons) {
          if (!existing.includes(reason)) existing.push(reason);
        }
        clusterReasons.set(root, existing);
      } else if (m.score >= opts.thresholds.needsReview) {
        // Medium - track on whichever cluster `left` belongs to.
        const root = find(left.candidate.id);
        const list = clusterReviewPairs.get(root) ?? [];
        list.push({
          aSourceId: parseInt(left.candidate.id, 10),
          bSourceId: parseInt(m.candidate.id, 10),
          score: m.score,
          reasons: m.reasons,
        });
        clusterReviewPairs.set(root, list);
      }
    }
  }

  // Group rows by their cluster root.
  const byRoot = new Map<string, RowCandidate[]>();
  for (const r of rows) {
    const root = find(r.candidate.id);
    const list = byRoot.get(root) ?? [];
    list.push(r);
    byRoot.set(root, list);
  }

  // Build cluster objects, choosing the most-complete row as the lead.
  const clusters: Cluster[] = [];
  for (const [root, members] of byRoot) {
    const lead = pickLead(members);
    clusters.push({
      leadCandidate: lead,
      members,
      maxScore: clusterMaxScore.get(root) ?? 0,
      reasons: clusterReasons.get(root) ?? [],
      reviewPairs: clusterReviewPairs.get(root) ?? [],
    });
  }
  return clusters;
}

function pickLead(rows: RowCandidate[]): RowCandidate {
  // Pick the row with the most populated fields, breaking ties by
  // recency (highest Id, since NocoDB IDs are monotonic).
  return rows.reduce((best, current) => {
    const bestScore = completenessScore(best);
    const currentScore = completenessScore(current);
    if (currentScore > bestScore) return current;
    if (currentScore === bestScore && current.row.Id > best.row.Id) return current;
    return best;
  });
}

function completenessScore(r: RowCandidate): number {
  let score = 0;
  if (r.email) score += 1;
  if (r.phoneResult?.e164) score += 1;
  if (r.row['Address']) score += 0.5;
  if (r.row['Yacht Name']) score += 0.5;
  if (r.row['Source']) score += 0.25;
  if (r.row['Lead Category']) score += 0.25;
  if (r.row['Internal Notes']) score += 0.25;
  return score;
}

function buildPlannedClient(
  tempId: string,
  cluster: Cluster,
  opts: TransformOptions,
): PlannedClient {
  const lead = cluster.leadCandidate;

  // Collect distinct emails + phones from across the cluster - duplicate
  // submissions often come with different contact methods we want to
  // preserve as multiple rows in `client_contacts`.
  const seenEmails = new Set<string>();
  const seenPhones = new Set<string>();
  const contacts: PlannedContact[] = [];

  for (const member of cluster.members) {
    if (member.email && !seenEmails.has(member.email)) {
      seenEmails.add(member.email);
      contacts.push({
        channel: 'email',
        value: member.email,
        isPrimary: contacts.length === 0,
      });
    }
    if (member.phoneResult?.e164 && !seenPhones.has(member.phoneResult.e164)) {
      seenPhones.add(member.phoneResult.e164);
      const isFirstPhone = !contacts.some((c) => c.channel === 'phone');
      contacts.push({
        channel: 'phone',
        value: member.phoneResult.e164,
        valueE164: member.phoneResult.e164,
        valueCountry: member.phoneResult.country,
        isPrimary: isFirstPhone && contacts.every((c) => !c.isPrimary || c.channel === 'email'),
        flagged: member.phoneResult.flagged,
      });
    }
  }

  // Demote the email-primary if a more-completable phone exists.
  // Simpler invariant: the first contact is primary unless the row
  // explicitly preferred phone.
  const preferredMethod = (lead.row['Contact Method Preferred'] as string | undefined)
    ?.toLowerCase()
    ?.trim();

  // Address: only build if the lead row has a meaningful address text.
  const rawAddress = (lead.row['Address'] as string | undefined)?.trim();
  const addresses: PlannedAddress[] = [];
  if (rawAddress) {
    addresses.push({
      streetAddress: rawAddress,
      city: null,
      countryIso: lead.countryIso ?? opts.defaultPhoneCountry,
      countryConfidence: lead.countryConfidence ?? 'fallback',
    });
  }

  const sourceFromRow = (lead.row['Source'] as string | undefined) ?? null;
  const mappedSource = sourceFromRow ? (SOURCE_MAP[sourceFromRow] ?? 'manual') : null;

  return {
    tempId,
    sourceIds: cluster.members.map((m) => m.row.Id),
    fullName: lead.displayName,
    surnameToken: lead.candidate.surnameToken ?? undefined,
    countryIso: lead.countryIso,
    preferredContactMethod: preferredMethod ?? null,
    source: mappedSource,
    contacts,
    addresses,
  };
}

function buildPlannedInterest(row: NocoDbRow, clientTempId: string): PlannedInterest {
  const stage = (row['Sales Process Level'] as string | undefined) ?? '';
  const cat = (row['Lead Category'] as string | undefined) ?? '';
  // Deposit received overrides the mapped stage → deposit_paid (unless the deal
  // is already at contract). Default for unmapped/blank legacy stage = enquiry.
  const depositReceived =
    ((row['Deposit 10% Status'] as string | undefined) ?? '').trim() === 'Received';
  let mappedStage = STAGE_MAP[stage] ?? 'enquiry';
  // §4b marker rule: a "General Qualified Interest" that named a specific berth
  // OR a desired berth size is really a qualified lead; with no such marker it
  // stays a plain enquiry. SQI + later stages are unaffected by this.
  if (stage === 'General Qualified Interest') {
    const hasBerthMarker = !!(row['Berth Number'] as string | undefined)?.trim();
    const hasSizeMarker = !!(row['Berth Size Desired'] as string | undefined)?.trim();
    mappedStage = hasBerthMarker || hasSizeMarker ? 'qualified' : 'enquiry';
  }
  if (depositReceived && mappedStage !== 'contract') mappedStage = 'deposit_paid';

  // Interest-level EOI signing state (for display on the deal). "Signed"
  // via the EOI Status enum OR the older LOI-NDA process; "awaiting" when an
  // EOI was sent (Documenso id present or EOI Status says waiting).
  const eoiStatusRaw = (row['EOI Status'] as string | undefined)?.trim();
  const loiRaw = (row['LOI-NDA Document'] as string | undefined)?.trim() ?? '';
  const hasDocumensoId = !!(row['documensoID'] as string | undefined)?.trim();
  let eoiStatus: PlannedInterest['eoiStatus'] = null;
  let eoiDocStatus: PlannedInterest['eoiDocStatus'] = null;
  if (
    eoiStatusRaw === 'Signed' ||
    ['Signing Complete', 'Signed by Client', 'Signed by Developer'].includes(loiRaw)
  ) {
    eoiStatus = 'signed';
    eoiDocStatus = 'signed';
  } else if (eoiStatusRaw === 'Waiting for Signatures' || hasDocumensoId) {
    eoiStatus = 'waiting_for_signatures';
    eoiDocStatus = 'sent';
  }

  const notesParts: string[] = [];
  const internalNotes = row['Internal Notes'] as string | undefined;
  const extraComments = row['Extra Comments'] as string | undefined;
  if (internalNotes?.trim()) notesParts.push(internalNotes.trim());
  if (extraComments?.trim()) notesParts.push(`Extra Comments: ${extraComments.trim()}`);
  const berthSize = row['Berth Size Desired'] as string | undefined;
  if (berthSize?.trim()) notesParts.push(`Berth size desired: ${berthSize.trim()}`);

  return {
    sourceId: row.Id,
    clientTempId,
    pipelineStage: mappedStage,
    leadCategory: LEAD_CATEGORY_MAP[cat] ?? null,
    source: ((row['Source'] as string | undefined) ?? null) || null,
    notes: notesParts.join('\n\n') || null,
    berthMooringNumber: (row['Berth Number'] as string | undefined) ?? null,
    yachtName: (() => {
      const n = (row['Yacht Name'] as string | undefined)?.trim();
      // Filter placeholder values used by sales reps for "we don't know yet".
      if (!n) return null;
      if (['TBC', 'Na', 'NA', 'na', 'N/A', 'TBD', 'tbd'].includes(n)) return null;
      return n;
    })(),
    dateEoiSent: parseFlexibleDate(row['EOI Time Sent']),
    dateEoiSigned: parseFlexibleDate(row['all_signed_notified_at'] ?? row['developerSignTime']),
    dateDepositReceived: null, // not directly tracked in legacy schema
    dateContractSent: parseFlexibleDate(row['Time LOI Sent']),
    dateContractSigned: parseFlexibleDate(row['developerSignTime']),
    dateLastContact: parseFlexibleDate(row['Created At'] ?? row['Date Added']),
    documensoId: (row['documensoID'] as string | undefined) ?? null,
    eoiStatus,
    eoiDocStatus,
  };
}

// ─── EOI document builder ───────────────────────────────────────────────────

/** Status mapping from legacy `EOI Status` SingleSelect → new
 *  documents.status enum. Falls back to inferring from sign-time fields
 *  when the legacy enum is blank or set to "Awaiting Further Details"
 *  (which itself does not pin a lifecycle stage - the operator was
 *  waiting for input from the client). */
function mapDocumentStatus(row: NocoDbRow): PlannedDocument['status'] {
  const eoiStatus = (row['EOI Status'] as string | undefined)?.trim();
  if (eoiStatus === 'Signed') return 'completed';
  if (eoiStatus === 'Waiting for Signatures') return 'partially_signed';

  // "Awaiting Further Details" or blank: fall through to sign-time
  // inference. This matters because some rows have stale
  // `all_signed_notified_at` from earlier signing rounds; those rows
  // should NOT be auto-promoted to 'completed' if the latest enum value
  // says we're still waiting on the client. We only trust sign-time
  // fields when the operator hasn't explicitly set the status.
  if (eoiStatus === 'Awaiting Further Details') {
    if (row['EOI Time Sent']) return 'sent';
    return 'draft';
  }

  // Sign-time fallbacks - `all_signed_notified_at` is a strong signal the
  // document hit the completed lifecycle even on rows that pre-date the
  // EOI Status enum.
  if (row['all_signed_notified_at']) return 'completed';
  if (row['developerSignTime']) return 'completed';
  if (row['clientSignTime']) return 'partially_signed';
  if (row['EOI Time Sent']) return 'sent';
  return 'draft';
}

/**
 * Emit an EOI document plan if the legacy interest row carries a
 * `documensoID`. Returns null otherwise - interests without a documensoID
 * never had an EOI sent, so there's nothing to migrate.
 */
function buildPlannedDocument(
  row: NocoDbRow,
  clientTempId: string,
  clientFullName: string,
): PlannedDocument | null {
  const documensoId = (row['documensoID'] as string | undefined)?.trim();
  if (!documensoId) return null;

  const status = mapDocumentStatus(row);
  const dateSent = parseFlexibleDate(row['EOI Time Sent']);

  // Build signers from the three legacy slots. Each slot has its own
  // status field (sign-time present = signed). The signing/embedded URLs
  // are preserved verbatim so the legacy resume-signing links still work
  // for in-flight documents.
  const signers: PlannedDocumentSigner[] = [];

  const clientEmail = ((row['Email Address'] as string | undefined) ?? '').trim();
  if (clientEmail) {
    const clientSignedAt = parseFlexibleDate(
      row['clientSignTime'] ?? row['client_signed_notified_at'],
    );
    signers.push({
      signerName: clientFullName,
      signerEmail: clientEmail,
      signerRole: 'client',
      signingOrder: 1,
      status: clientSignedAt ? 'signed' : 'pending',
      signedAt: clientSignedAt,
      signingUrl: (row['Signature Link Client'] as string | undefined) ?? null,
      embeddedUrl: (row['EmbeddedSignatureLinkClient'] as string | undefined) ?? null,
    });
  }

  const ccLink = (row['Signature Link CC'] as string | undefined) ?? null;
  const ccEmbedded = (row['EmbeddedSignatureLinkCC'] as string | undefined) ?? null;
  const ccSignedAt = parseFlexibleDate(row['ccSignTime']);
  if (ccLink || ccEmbedded || ccSignedAt) {
    // Legacy didn't store the CC's email separately - leave a placeholder
    // and let the operator update via the UI. Keeping the row preserves
    // the link history.
    signers.push({
      signerName: 'CC (legacy migration)',
      signerEmail: 'cc-unknown@migration.local',
      signerRole: 'cc',
      signingOrder: 2,
      status: ccSignedAt ? 'signed' : 'pending',
      signedAt: ccSignedAt,
      signingUrl: ccLink,
      embeddedUrl: ccEmbedded,
    });
  }

  const devSignedAt = parseFlexibleDate(
    row['developerSignTime'] ?? row['developer_signed_notified_at'],
  );
  const devLink = (row['Signature Link Developer'] as string | undefined) ?? null;
  const devEmbedded = (row['EmbeddedSignatureLinkDeveloper'] as string | undefined) ?? null;
  if (devLink || devEmbedded || devSignedAt) {
    signers.push({
      signerName: 'Developer (legacy migration)',
      signerEmail: 'developer-unknown@migration.local',
      signerRole: 'developer',
      signingOrder: 3,
      status: devSignedAt ? 'signed' : 'pending',
      signedAt: devSignedAt,
      signingUrl: devLink,
      embeddedUrl: devEmbedded,
    });
  }

  // Guard: an EOI document with zero signers leaves the document UI in an
  // inconsistent state (status=completed but no rows in document_signers
  // means the "who signed" view has nothing to show). Skip the document
  // entirely rather than emit an orphaned record. This happens only when
  // the legacy row carries a documensoID but lacks an Email Address AND
  // has no CC/developer signature data at all - rare, but possible on
  // very old rows. The flag is added so the migration report surfaces it
  // for human review.
  if (signers.length === 0) {
    return null;
  }

  // Stash legacy S3 paths in notes so the reference isn't lost - copying
  // attachments into the new files table is a separate workflow.
  const notesParts: string[] = [];
  const s3Path = (row['S3_Documenso_Path'] as string | undefined)?.trim();
  const clientPath = (row['Client_EOI_Document_Path'] as string | undefined)?.trim();
  if (s3Path) notesParts.push(`Legacy S3: ${s3Path}`);
  if (clientPath) notesParts.push(`Legacy client copy: ${clientPath}`);
  notesParts.push(`Migrated from legacy NocoDB Interests row #${row.Id}`);

  return {
    sourceId: row.Id,
    clientTempId,
    documentType: 'eoi',
    title: `EOI - ${clientFullName}`,
    status,
    documensoId,
    notes: notesParts.join('\n'),
    signers,
    dateSent,
  };
}

// ─── Residential builder ────────────────────────────────────────────────────

function buildPlannedResidentialClient(
  row: NocoDbRow,
  opts: TransformOptions,
  flags: MigrationFlag[],
): PlannedResidentialClient | null {
  const rawName = (row['Full Name'] as string | undefined) ?? '';
  const rawEmail = (row['Email Address'] as string | undefined) ?? '';
  const rawPhone = (row['Phone Number'] as string | undefined) ?? '';
  const rawCountry = (row['Place of Residence'] as string | undefined) ?? '';

  const normName = normalizeName(rawName);
  if (!normName.display) {
    flags.push({
      sourceTable: 'residential_interests',
      sourceId: row.Id,
      reason: 'residential row has no name - skipped',
    });
    return null;
  }

  const email = normalizeEmail(rawEmail);
  const country = resolveCountry(rawCountry);
  const phoneCountry = country.iso ?? opts.defaultPhoneCountry;
  const phoneResult = normalizePhone(rawPhone, phoneCountry as CountryCode);

  if (rawPhone && !phoneResult?.e164) {
    flags.push({
      sourceTable: 'residential_interests',
      sourceId: row.Id,
      reason: phoneResult?.flagged ? `phone ${phoneResult.flagged}` : 'phone unparseable',
      details: { rawPhone },
    });
  }
  if (rawEmail && !email) {
    flags.push({
      sourceTable: 'residential_interests',
      sourceId: row.Id,
      reason: 'email invalid',
      details: { rawEmail },
    });
  }

  const sourceFromRow = (row['Source'] as string | undefined) ?? null;
  const mappedSource = sourceFromRow ? (SOURCE_MAP[sourceFromRow] ?? 'manual') : null;
  const extraComments = (row['Extra Comments'] as string | undefined)?.trim() ?? null;

  return {
    sourceId: row.Id,
    fullName: normName.display,
    email,
    phoneE164: phoneResult?.e164 ?? null,
    phoneCountry: phoneResult?.country ?? null,
    placeOfResidence: rawCountry.trim() || null,
    placeOfResidenceCountryIso: country.iso ?? null,
    source: mappedSource,
    notes: extraComments,
    dateFirstContact: parseFlexibleDate(
      row['Time Created'] ?? row['CreatedAt'] ?? row['Created At'],
    ),
  };
}

// ─── Expense builder ────────────────────────────────────────────────────────

/** Parse a legacy price string ("€1,234.50", "$1,200", "1234") into a numeric
 *  string + ISO currency. Prefers the row's own `currency` field, then the
 *  symbol, defaulting to USD. */
function parseExpenseMoney(
  priceRaw: string,
  currencyField: string,
): { amount: string | null; currency: string } {
  const t = (priceRaw ?? '').trim();
  const symbolCurrency = t.includes('€')
    ? 'EUR'
    : t.includes('$')
      ? 'USD'
      : t.includes('£')
        ? 'GBP'
        : null;
  const currency = (currencyField || '').trim().toUpperCase() || symbolCurrency || 'USD';
  const num = parseFloat(t.replace(/[^0-9.]/g, ''));
  return { amount: Number.isFinite(num) ? String(num) : null, currency };
}

function buildPlannedExpense(row: NocoDbRow): PlannedExpense {
  const { amount, currency } = parseExpenseMoney(
    (row['Price'] as string | undefined) ?? '',
    (row['currency'] as string | undefined) ?? '',
  );
  const rawStatus = ((row['payment_status'] as string | undefined) ?? '').trim().toLowerCase();
  const paymentStatus: PlannedExpense['paymentStatus'] =
    rawStatus === 'paid' ? 'paid' : rawStatus === 'partial' ? 'partial' : 'unpaid';
  const receipt = row['Receipt'];
  const hadReceipt = Array.isArray(receipt) ? receipt.length > 0 : !!receipt;
  return {
    sourceId: row.Id,
    amount,
    currency,
    establishmentName: ((row['Establishment Name'] as string | undefined) ?? '').trim() || null,
    paymentMethod: ((row['Payment Method'] as string | undefined) ?? '').trim() || null,
    category: ((row['Category'] as string | undefined) ?? '').trim() || null,
    payer: ((row['Payer'] as string | undefined) ?? '').trim() || null,
    expenseDate: parseFlexibleDate(row['Time'] ?? row['CreatedAt'] ?? row['Created At']),
    description: ((row['Contents'] as string | undefined) ?? '').trim() || null,
    paymentStatus,
    hadReceipt,
  };
}
