/**
 * Berth recommender (plan §4.4 + §13).
 *
 * Pure SQL ranking - no AI. The single CTE chain:
 *
 *   1. interest_input  - the desired dimensions + port for this interest
 *   2. feasible        - berths in the port that can fit the yacht
 *                        (length/width/draft >= desired) AND don't exceed
 *                        the configured max-oversize percentage
 *   3. tier_inputs     - per-berth aggregates over interest_berths +
 *                        interests: active count, lost count, max stage
 *                        among active interests, latest fall-through
 *                        timestamp, total historical interest count,
 *                        EOI-signed count
 *   4. classified      - assign tier A/B/C/D
 *
 * The feasible berths are returned to JavaScript for fit-score + heat
 * calculation - keeping that out of SQL lets per-port admins tune the
 * heat weights without a code deploy.
 *
 * Heat scoring (only relevant for tier B; berths surface back into the
 * recommender pool after a fall-through):
 *   - recency             : how recently did the last interest fall through
 *   - furthest stage       : how close did the prior interest get to closing
 *   - historical interest  : how often does this berth attract interest
 *   - historical EOI count : how often does interest convert to EOI
 *
 * Per-port settings (defaults below) drive the cap, fall-through policy,
 * heat weights, and tier-D visibility.
 */

import { and, eq, inArray, sql } from 'drizzle-orm';

import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { interests } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts';
import { CodedError } from '@/lib/errors';

// ─── Settings ──────────────────────────────────────────────────────────────

export interface RecommenderSettings {
  maxOversizePct: number;
  topNDefault: number;
  fallthroughPolicy: 'immediate_with_heat' | 'cooldown' | 'never_auto_recommend';
  fallthroughCooldownDays: number;
  heatWeightRecency: number;
  heatWeightFurthestStage: number;
  heatWeightInterestCount: number;
  heatWeightEoiCount: number;
  tierLadderHideLateStage: boolean;
}

export const DEFAULT_RECOMMENDER_SETTINGS: RecommenderSettings = {
  maxOversizePct: 30,
  topNDefault: 8,
  fallthroughPolicy: 'immediate_with_heat',
  fallthroughCooldownDays: 30,
  heatWeightRecency: 30,
  heatWeightFurthestStage: 40,
  heatWeightInterestCount: 15,
  heatWeightEoiCount: 15,
  tierLadderHideLateStage: true,
};

const SETTINGS_KEYS = {
  maxOversizePct: 'recommender_max_oversize_pct',
  topNDefault: 'recommender_top_n_default',
  fallthroughPolicy: 'fallthrough_policy',
  fallthroughCooldownDays: 'fallthrough_cooldown_days',
  heatWeightRecency: 'heat_weight_recency',
  heatWeightFurthestStage: 'heat_weight_furthest_stage',
  heatWeightInterestCount: 'heat_weight_interest_count',
  heatWeightEoiCount: 'heat_weight_eoi_count',
  tierLadderHideLateStage: 'tier_ladder_hide_late_stage',
} as const;

/**
 * Reads recommender settings for the port, layered over the defaults.
 * Per-port row wins; null portId row is the global fallback; defaults
 * cover anything still missing.
 */
export async function loadRecommenderSettings(portId: string): Promise<RecommenderSettings> {
  const rows = await db
    .select({ key: systemSettings.key, value: systemSettings.value, portId: systemSettings.portId })
    .from(systemSettings)
    .where(
      and(
        inArray(systemSettings.key, Object.values(SETTINGS_KEYS)),
        sql`(${systemSettings.portId} = ${portId} OR ${systemSettings.portId} IS NULL)`,
      ),
    );

  const portRows = new Map<string, unknown>();
  const globalRows = new Map<string, unknown>();
  for (const r of rows) {
    if (r.portId === portId) portRows.set(r.key, r.value);
    else if (r.portId === null) globalRows.set(r.key, r.value);
  }

  const pick = <T>(key: string, parse: (v: unknown) => T | null, fallback: T): T => {
    const portVal = portRows.has(key) ? parse(portRows.get(key)) : null;
    if (portVal !== null) return portVal;
    const globalVal = globalRows.has(key) ? parse(globalRows.get(key)) : null;
    if (globalVal !== null) return globalVal;
    return fallback;
  };

  const asNumber = (v: unknown): number | null => {
    if (v === null || v === undefined) return null;
    if (typeof v === 'number') return Number.isFinite(v) ? v : null;
    if (typeof v === 'string') {
      const n = parseFloat(v);
      return Number.isFinite(n) ? n : null;
    }
    return null;
  };
  const asBool = (v: unknown): boolean | null => {
    if (typeof v === 'boolean') return v;
    // Some admin UIs (or older settings rows) persist booleans as the
    // strings "true" / "false" inside the JSONB blob. Without this
    // tolerant parse, a per-port override quietly falls through to the
    // default and the admin's tuning has no effect.
    if (typeof v === 'string') {
      if (v === 'true') return true;
      if (v === 'false') return false;
    }
    return null;
  };
  const asPolicy = (v: unknown): RecommenderSettings['fallthroughPolicy'] | null => {
    if (v === 'immediate_with_heat' || v === 'cooldown' || v === 'never_auto_recommend') {
      return v;
    }
    return null;
  };

  return {
    maxOversizePct: pick(
      SETTINGS_KEYS.maxOversizePct,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.maxOversizePct,
    ),
    topNDefault: pick(
      SETTINGS_KEYS.topNDefault,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.topNDefault,
    ),
    fallthroughPolicy: pick(
      SETTINGS_KEYS.fallthroughPolicy,
      asPolicy,
      DEFAULT_RECOMMENDER_SETTINGS.fallthroughPolicy,
    ),
    fallthroughCooldownDays: pick(
      SETTINGS_KEYS.fallthroughCooldownDays,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.fallthroughCooldownDays,
    ),
    heatWeightRecency: pick(
      SETTINGS_KEYS.heatWeightRecency,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.heatWeightRecency,
    ),
    heatWeightFurthestStage: pick(
      SETTINGS_KEYS.heatWeightFurthestStage,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.heatWeightFurthestStage,
    ),
    heatWeightInterestCount: pick(
      SETTINGS_KEYS.heatWeightInterestCount,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.heatWeightInterestCount,
    ),
    heatWeightEoiCount: pick(
      SETTINGS_KEYS.heatWeightEoiCount,
      asNumber,
      DEFAULT_RECOMMENDER_SETTINGS.heatWeightEoiCount,
    ),
    tierLadderHideLateStage: pick(
      SETTINGS_KEYS.tierLadderHideLateStage,
      asBool,
      DEFAULT_RECOMMENDER_SETTINGS.tierLadderHideLateStage,
    ),
  };
}

// ─── Tier mapping ──────────────────────────────────────────────────────────

// L-001: modern 7-stage ranks own the canonical positions; legacy 9-stage
// keys map to their post-refactor equivalents so historical interests in
// the recommender stage-aware queries continue to rank correctly.
const STAGE_ORDER: Record<string, number> = {
  // modern
  enquiry: 1,
  qualified: 2,
  nurturing: 2,
  eoi: 3,
  reservation: 4,
  deposit_paid: 5,
  contract: 6,
  // legacy aliases
  open: 1,
  details_sent: 1,
  in_communication: 2,
  eoi_sent: 3,
  eoi_signed: 3,
  deposit_10pct: 5,
  contract_sent: 6,
  contract_signed: 6,
  completed: 6,
};

/** Stage at which a berth is "in late stage" (Tier D when active). */
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_paid!; // 5

/**
 * SQL `CASE` that maps `i.pipeline_stage` → the {@link STAGE_ORDER} rank,
 * defaulting to 0 for unknown stages.
 *
 * Audit L3: the recommender's SQL aggregates (`max_active_stage`,
 * `fallthrough_max_stage`) previously hard-coded their OWN 1-7 ordering
 * (`reservation=5, deposit_paid=6`) that diverged from this JS map
 * (`reservation=4, deposit_paid=5`). `classifyTier`/`computeHeat` compare those
 * SQL values against `LATE_STAGE_THRESHOLD` (derived from STAGE_ORDER), so a
 * `reservation`-stage interest (SQL 5) tripped `>= 5` and got classified Tier D
 * — suppressed under the default `tier_ladder_hide_late_stage`, a full stage
 * early. Generating the CASE from `STAGE_ORDER` makes it the single source of
 * truth so SQL and JS can never drift again.
 */
function stageRankCaseSql(column: string): ReturnType<typeof sql> {
  const whens = Object.entries(STAGE_ORDER).map(([stage, rank]) => sql`WHEN ${stage} THEN ${rank}`);
  return sql`CASE ${sql.raw(column)} ${sql.join(whens, sql` `)} ELSE 0 END`;
}

export type Tier = 'A' | 'B' | 'C' | 'D';

interface TierInputs {
  activeInterestCount: number;
  lostCount: number;
  maxActiveStage: number;
  /** Berth's status column. Reconciles against the interest_berths
   *  aggregates: a berth flagged "Under Offer" or "Sold" via the
   *  status column alone (admin-set, NocoDB import, or a stale row
   *  with no live interest_berths entry) shouldn't fall into Tier A.
   *  Optional for backcompat — pure aggregate-based callers still
   *  classify correctly when this is undefined. */
  status?: string;
}

export function classifyTier(t: TierInputs): Tier {
  // Berth status overrides the aggregate path. A sold berth is
  // effectively closed — treat it as late stage. An Under Offer
  // berth has at least one party engaged even if interest_berths
  // doesn't echo them (e.g. admin manually flipped status). Both
  // collapse the "Open · Under Offer" contradiction surfaced in UAT
  // 2026-05-26. Sold > UnderOffer > active interest aggregates.
  const normStatus = (t.status ?? '').toLowerCase();
  if (normStatus === 'sold') return 'D';
  if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D';
  // Audit L4: collapsed the dead `activeInterestCount > 0 ? 'C' : 'C'` ternary
  // and dropped the unreachable `'under offer'` (space) literal — canonical
  // status is always `under_offer`.
  if (normStatus === 'under_offer') return 'C';
  if (t.activeInterestCount > 0) return 'C';
  if (t.lostCount > 0) return 'B';
  return 'A';
}

// ─── Heat scoring ──────────────────────────────────────────────────────────

interface HeatInputs {
  latestFallthroughAt: Date | null;
  totalInterestCount: number;
  eoiSignedCount: number;
  /** Stage code at the time of the most recent fall-through. */
  fallthroughMaxStage: number;
}

export interface HeatBreakdown {
  recency: number;
  furthestStage: number;
  interestCount: number;
  eoiCount: number;
  total: number;
}

/**
 * 0..100 heat score with per-port-tunable weights. Each component is
 * normalized 0..1 before being multiplied by its weight; the totals sum
 * to 100 by construction (the four weights are expected to sum to 100,
 * but the function clamps regardless so admin-tuning errors don't
 * produce out-of-range scores).
 */
export function computeHeat(
  inputs: HeatInputs,
  weights: Pick<
    RecommenderSettings,
    | 'heatWeightRecency'
    | 'heatWeightFurthestStage'
    | 'heatWeightInterestCount'
    | 'heatWeightEoiCount'
  >,
  now: Date = new Date(),
): HeatBreakdown {
  const weightSum =
    weights.heatWeightRecency +
    weights.heatWeightFurthestStage +
    weights.heatWeightInterestCount +
    weights.heatWeightEoiCount;
  const norm = weightSum > 0 ? 100 / weightSum : 0;

  // 1.0 if fallthrough <= 30 days, decays linearly to 0 at 365 days.
  let recency = 0;
  if (inputs.latestFallthroughAt) {
    const ageDays = (now.getTime() - inputs.latestFallthroughAt.getTime()) / 86_400_000;
    if (ageDays <= 30) recency = 1;
    else if (ageDays >= 365) recency = 0;
    else recency = 1 - (ageDays - 30) / (365 - 30);
  }

  // 0 if max stage is open; 1 at signed/deposit/contract.
  const furthestStage =
    inputs.fallthroughMaxStage <= 1
      ? 0
      : Math.min(1, (inputs.fallthroughMaxStage - 1) / (LATE_STAGE_THRESHOLD - 1));

  // 0 at 0 interests, 1 at 5+ interests.
  const interestCount = Math.min(1, inputs.totalInterestCount / 5);

  // 0 at 0 EOIs, 1 at 3+ EOIs.
  const eoiCount = Math.min(1, inputs.eoiSignedCount / 3);

  const r = recency * weights.heatWeightRecency * norm;
  const f = furthestStage * weights.heatWeightFurthestStage * norm;
  const ic = interestCount * weights.heatWeightInterestCount * norm;
  const ec = eoiCount * weights.heatWeightEoiCount * norm;
  const total = Math.max(0, Math.min(100, r + f + ic + ec));

  return {
    recency: Math.round(r * 100) / 100,
    furthestStage: Math.round(f * 100) / 100,
    interestCount: Math.round(ic * 100) / 100,
    eoiCount: Math.round(ec * 100) / 100,
    total: Math.round(total * 100) / 100,
  };
}

// ─── Recommend ────────────────────────────────────────────────────────────

export interface RecommendBerthsArgs {
  interestId: string;
  portId: string;
  /** Override the per-port topNDefault. */
  topN?: number;
  /** Override the per-port maxOversizePct. */
  maxOversizePct?: number;
  /** Show late-stage (Tier D) berths even when the per-port setting hides them. */
  showLateStage?: boolean;
  /** Optional rep-supplied amenity filters. */
  amenityFilters?: {
    minPowerCapacityKw?: number;
    requiredVoltage?: number;
    requiredAccess?: string;
    requiredMooringType?: string;
    requiredCleatCapacity?: string;
  };
}

export interface Recommendation {
  berthId: string;
  mooringNumber: string;
  area: string | null;
  tier: Tier;
  fitScore: number;
  sizeBufferPct: number | null;
  heat: HeatBreakdown | null;
  reasons: {
    dimensional: string;
    pipeline: string;
    amenities?: string;
    heat?: string;
  };
  lengthFt: number | null;
  widthFt: number | null;
  draftFt: number | null;
  status: string;
  amenities: {
    powerCapacity: number | null;
    voltage: number | null;
    access: string | null;
    mooringType: string | null;
    cleatCapacity: string | null;
  };
}

interface RawRow extends Record<string, unknown> {
  berthId: string;
  mooringNumber: string;
  area: string | null;
  status: string;
  lengthFt: string | null;
  widthFt: string | null;
  draftFt: string | null;
  powerCapacity: string | null;
  voltage: string | null;
  access: string | null;
  mooringType: string | null;
  cleatCapacity: string | null;
  activeInterestCount: number;
  lostCount: number;
  maxActiveStage: number;
  latestFallthroughAt: Date | null;
  fallthroughMaxStage: number;
  totalInterestCount: number;
  eoiSignedCount: number;
}

interface InterestInput {
  desiredLengthFt: number | null;
  desiredWidthFt: number | null;
  desiredDraftFt: number | null;
  portId: string;
  /** Audit/debug aid — which dimension set the predicates were built
   *  from. Set by `loadInterestInput` based on the dual-source toggle
   *  + yacht link. Not consumed by SQL; surfaced in tests and trace
   *  logs only. */
  dimensionsSource: 'interest' | 'yacht';
}

async function loadInterestInput(interestId: string): Promise<InterestInput | null> {
  const [row] = await db
    .select({
      desiredLengthFt: interests.desiredLengthFt,
      desiredWidthFt: interests.desiredWidthFt,
      desiredDraftFt: interests.desiredDraftFt,
      portId: interests.portId,
      useYachtDimensions: interests.useYachtDimensions,
      yachtId: interests.yachtId,
    })
    .from(interests)
    .where(eq(interests.id, interestId))
    .limit(1);
  if (!row) return null;
  const toNum = (v: string | null): number | null => {
    if (v === null) return null;
    const n = parseFloat(v);
    return Number.isFinite(n) ? n : null;
  };

  // Dual-source resolution: when the toggle is on AND a yacht is
  // linked AND that yacht carries dimension data, the recommender
  // uses the yacht's dims. Falls back to the rep-entered desired*
  // columns whenever any of those preconditions fail.
  if (row.useYachtDimensions && row.yachtId) {
    const [yachtRow] = await db
      .select({
        lengthFt: yachts.lengthFt,
        widthFt: yachts.widthFt,
        draftFt: yachts.draftFt,
      })
      .from(yachts)
      .where(eq(yachts.id, row.yachtId))
      .limit(1);
    if (yachtRow) {
      const yLen = toNum(yachtRow.lengthFt);
      const yWid = toNum(yachtRow.widthFt);
      const yDrft = toNum(yachtRow.draftFt);
      // Only switch to yacht dims when at least one yacht measurement
      // is present — otherwise we'd silently downgrade an interest
      // with desired dims set to "no dims at all".
      if (yLen !== null || yWid !== null || yDrft !== null) {
        return {
          desiredLengthFt: yLen,
          desiredWidthFt: yWid,
          desiredDraftFt: yDrft,
          portId: row.portId,
          dimensionsSource: 'yacht',
        };
      }
    }
  }

  return {
    desiredLengthFt: toNum(row.desiredLengthFt),
    desiredWidthFt: toNum(row.desiredWidthFt),
    desiredDraftFt: toNum(row.desiredDraftFt),
    portId: row.portId,
    dimensionsSource: 'interest',
  };
}

/**
 * Run the recommender. Returns ranked recommendations top-N. Multi-port
 * isolation is enforced both in the CTE (`b.port_id = $portId`) and via
 * the interest's own port_id - cross-port queries fail explicitly
 * rather than leak (§14.10 critical).
 */
export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recommendation[]> {
  const settings = await loadRecommenderSettings(args.portId);
  const interestInput = await loadInterestInput(args.interestId);
  if (!interestInput) return [];
  if (interestInput.portId !== args.portId) {
    // Defensive: caller passed a port that doesn't own this interest.
    throw new CodedError('RECOMMENDER_INTEREST_PORT_MISMATCH', {
      internalMessage: `interest ${args.interestId} belongs to port ${interestInput.portId}, not ${args.portId}`,
    });
  }

  const oversizePct = args.maxOversizePct ?? settings.maxOversizePct;
  const topN = args.topN ?? settings.topNDefault;
  const showLateStage = args.showLateStage ?? !settings.tierLadderHideLateStage;

  const oversizeMultiplier = 1 + oversizePct / 100;

  const predicates: ReturnType<typeof sql>[] = [
    sql`b.port_id = ${args.portId}`,
    sql`b.status <> 'sold'`,
  ];
  if (interestInput.desiredLengthFt !== null) {
    predicates.push(sql`b.length_ft::numeric >= ${interestInput.desiredLengthFt}`);
    predicates.push(
      sql`b.length_ft::numeric <= ${interestInput.desiredLengthFt}::numeric * ${oversizeMultiplier}::numeric`,
    );
  } else if (interestInput.desiredWidthFt !== null) {
    // Width-only feasibility: cap the length using a generous L/W ratio
    // so the recommender doesn't surface a 200 ft berth for a 30 ft beam
    // request. Plan §4.4 promised an upper bound; without this branch the
    // null-length path skipped the cap entirely.
    predicates.push(
      sql`b.length_ft::numeric <= ${interestInput.desiredWidthFt}::numeric * 8::numeric * ${oversizeMultiplier}::numeric`,
    );
  }
  if (interestInput.desiredWidthFt !== null) {
    predicates.push(sql`b.width_ft::numeric >= ${interestInput.desiredWidthFt}`);
  }
  if (interestInput.desiredDraftFt !== null) {
    predicates.push(sql`b.draft_ft::numeric >= ${interestInput.desiredDraftFt}`);
  }
  if (args.amenityFilters?.minPowerCapacityKw != null) {
    predicates.push(sql`b.power_capacity::numeric >= ${args.amenityFilters.minPowerCapacityKw}`);
  }
  if (args.amenityFilters?.requiredVoltage != null) {
    predicates.push(sql`b.voltage::numeric = ${args.amenityFilters.requiredVoltage}`);
  }
  if (args.amenityFilters?.requiredAccess) {
    predicates.push(sql`b.access = ${args.amenityFilters.requiredAccess}`);
  }
  if (args.amenityFilters?.requiredMooringType) {
    predicates.push(sql`b.mooring_type = ${args.amenityFilters.requiredMooringType}`);
  }
  if (args.amenityFilters?.requiredCleatCapacity) {
    predicates.push(sql`b.cleat_capacity = ${args.amenityFilters.requiredCleatCapacity}`);
  }
  const whereClause = sql.join(predicates, sql` AND `);

  const rawRows = await db.execute<RawRow>(sql`
    WITH feasible AS (
      SELECT b.*
      FROM berths b
      WHERE ${whereClause}
    ),
    aggregates AS (
      SELECT
        f.id AS berth_id,
        -- Active = is_specific_interest=true junction rows only (matches
        -- the public-map "Under Offer" filter). An EOI-bundle-only link
        -- (is_specific_interest=false, is_in_eoi_bundle=true) is legal
        -- coverage, not a pitch, and shouldn't demote the berth.
        COUNT(*) FILTER (
          WHERE i.archived_at IS NULL
            AND i.outcome IS NULL
            AND ib.is_specific_interest = true
        ) AS active_interest_count,
        COUNT(*) FILTER (
          WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
        ) AS lost_count,
        COALESCE(
          MAX(${stageRankCaseSql('i.pipeline_stage')})
            FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL),
          0
        ) AS max_active_stage,
        MAX(i.outcome_at) FILTER (
          WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
        ) AS latest_fallthrough_at,
        COALESCE(
          MAX(${stageRankCaseSql('i.pipeline_stage')})
            FILTER (WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')),
          0
        ) AS fallthrough_max_stage,
        -- COUNT(ib.berth_id) (not COUNT(*)) so a berth with no junction
        -- rows reports 0 - the LEFT JOIN otherwise produces a single
        -- NULL-right-side row that COUNT(*) would tally as 1 and inflate
        -- the heat interest-count component for berths with no history.
        -- The FILTER also enforces port isolation defense-in-depth: an
        -- orphan junction row whose interest belongs to a different port
        -- (which the new cross-port guard now prevents but pre-existing
        -- data may carry) shouldn't inflate this count.
        COUNT(ib.berth_id) FILTER (WHERE i.id IS NOT NULL) AS total_interest_count,
        COUNT(*) FILTER (WHERE i.eoi_status = 'signed') AS eoi_signed_count
      FROM feasible f
      LEFT JOIN interest_berths ib ON ib.berth_id = f.id
      LEFT JOIN interests i ON i.id = ib.interest_id AND i.port_id = ${args.portId}
      GROUP BY f.id
    )
    SELECT
      f.id AS "berthId",
      f.mooring_number AS "mooringNumber",
      f.area,
      f.status,
      f.length_ft AS "lengthFt",
      f.width_ft AS "widthFt",
      f.draft_ft AS "draftFt",
      f.power_capacity AS "powerCapacity",
      f.voltage,
      f.access,
      f.mooring_type AS "mooringType",
      f.cleat_capacity AS "cleatCapacity",
      a.active_interest_count::int AS "activeInterestCount",
      a.lost_count::int AS "lostCount",
      a.max_active_stage::int AS "maxActiveStage",
      a.latest_fallthrough_at AS "latestFallthroughAt",
      a.fallthrough_max_stage::int AS "fallthroughMaxStage",
      a.total_interest_count::int AS "totalInterestCount",
      a.eoi_signed_count::int AS "eoiSignedCount"
    FROM feasible f
    JOIN aggregates a ON a.berth_id = f.id
  `);

  const rows = (rawRows as { rows?: RawRow[] }).rows ?? (rawRows as unknown as RawRow[]);

  // Apply tier classification + fall-through cooldown filter + heat scoring + fit ranking in JS.
  const cooldownCutoff =
    settings.fallthroughPolicy === 'cooldown'
      ? new Date(Date.now() - settings.fallthroughCooldownDays * 86_400_000)
      : null;

  const recommendations: Recommendation[] = [];
  for (const r of rows) {
    const tier = classifyTier(r);
    if (tier === 'D' && !showLateStage) continue;

    if (tier === 'B' && settings.fallthroughPolicy === 'never_auto_recommend') continue;
    if (
      tier === 'B' &&
      cooldownCutoff &&
      r.latestFallthroughAt &&
      new Date(r.latestFallthroughAt) > cooldownCutoff
    ) {
      continue;
    }

    const lengthFt = r.lengthFt === null ? null : parseFloat(r.lengthFt);
    const widthFt = r.widthFt === null ? null : parseFloat(r.widthFt);
    const draftFt = r.draftFt === null ? null : parseFloat(r.draftFt);

    const sizeBufferPct =
      interestInput.desiredLengthFt && lengthFt
        ? Math.round(
            ((lengthFt - interestInput.desiredLengthFt) / interestInput.desiredLengthFt) * 1000,
          ) / 10
        : null;

    const heat =
      tier === 'B'
        ? computeHeat(
            {
              latestFallthroughAt: r.latestFallthroughAt ? new Date(r.latestFallthroughAt) : null,
              totalInterestCount: r.totalInterestCount,
              eoiSignedCount: r.eoiSignedCount,
              fallthroughMaxStage: r.fallthroughMaxStage,
            },
            settings,
          )
        : null;

    // Fit score: tier weight + 1/(1+buffer) closeness + heat boost.
    // Tier base scores: A=80, B=60+heat, C=40, D=20. Smaller buffer adds up to 20.
    const tierBase = { A: 80, B: 60, C: 40, D: 20 }[tier];
    const closeness =
      sizeBufferPct === null
        ? 10
        : Math.max(0, 20 - Math.abs(sizeBufferPct) / Math.max(1, oversizePct / 20));
    const heatBoost = heat ? heat.total / 5 : 0;
    const fitScore = Math.max(0, Math.min(100, tierBase + closeness + heatBoost));

    recommendations.push({
      berthId: r.berthId,
      mooringNumber: r.mooringNumber,
      area: r.area,
      tier,
      fitScore: Math.round(fitScore * 10) / 10,
      sizeBufferPct,
      heat,
      reasons: {
        dimensional:
          sizeBufferPct === null
            ? 'No length specified - all feasible berths shown'
            : `Within ${Math.abs(sizeBufferPct)}% of desired length`,
        pipeline: pipelineReason(tier, r),
        ...(heat ? { heat: heatSummary(heat) } : {}),
        ...(args.amenityFilters && Object.keys(args.amenityFilters).length > 0
          ? { amenities: 'All required amenities matched' }
          : {}),
      },
      lengthFt,
      widthFt,
      draftFt,
      status: r.status,
      amenities: {
        powerCapacity: r.powerCapacity === null ? null : parseFloat(r.powerCapacity),
        voltage: r.voltage === null ? null : parseFloat(r.voltage),
        access: r.access,
        mooringType: r.mooringType,
        cleatCapacity: r.cleatCapacity,
      },
    });
  }

  recommendations.sort((a, b) => {
    const tierRank = (t: Tier): number => ({ A: 1, B: 2, C: 3, D: 4 })[t];
    if (tierRank(a.tier) !== tierRank(b.tier)) return tierRank(a.tier) - tierRank(b.tier);
    return b.fitScore - a.fitScore;
  });

  return recommendations.slice(0, topN);
}

function pipelineReason(tier: Tier, r: RawRow): string {
  switch (tier) {
    case 'A':
      return 'No prior interest history';
    case 'B':
      return `Previously fell through (${r.lostCount} time${r.lostCount === 1 ? '' : 's'})`;
    case 'C':
      return `${r.activeInterestCount} active interest${r.activeInterestCount === 1 ? '' : 's'} in early stage`;
    case 'D':
      return 'Active interest in late stage';
  }
}

function heatSummary(h: HeatBreakdown): string {
  return `Heat ${h.total.toFixed(0)}/100 (recency ${h.recency.toFixed(0)}, stage ${h.furthestStage.toFixed(0)}, count ${h.interestCount.toFixed(0)}, EOI ${h.eoiCount.toFixed(0)})`;
}
