import { and, eq, gte, inArray, isNull, lte, sql, type SQL } from 'drizzle-orm';

import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { ports } from '@/lib/db/schema/ports';
import { systemSettings } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import {
  PIPELINE_STAGES,
  STAGE_WEIGHTS,
  canonicalizeStage,
  type PipelineStage,
} from '@/lib/constants';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { convert as convertCurrency } from '@/lib/services/currency';

/**
 * User-driven filter set for the detail tables on the Sales report.
 * Aggregate KPIs and charts intentionally ignore these — those tiles
 * are the macro view. Filters drill into the detail surfaces.
 */
export interface SalesFilters {
  stages?: PipelineStage[];
  leadCategories?: string[];
  outcomes?: string[];
  /** Filter to interests assigned to these user IDs (the "rep" filter). */
  assignedTo?: string[];
  /** Filter to interests with these `source` values (website / referral
   *  / broker / manual / other). */
  sources?: string[];
}

/**
 * Builds an optional SQL conjunct from a SalesFilters object. Returns
 * undefined when nothing applies, so callers can pass-through to
 * existing single-predicate WHEREs without re-wrapping.
 *
 * The `applies` set lets a caller declare which filter axes are
 * meaningful for its query (e.g. lostReasonBreakdown ignores `stages`
 * because every row is terminal). Anything not listed is silently
 * dropped.
 */
function buildSalesFiltersWhere(
  filters: SalesFilters | undefined,
  applies: ReadonlyArray<keyof SalesFilters>,
): SQL | undefined {
  if (!filters) return undefined;
  const conds: SQL[] = [];
  if (applies.includes('stages') && filters.stages && filters.stages.length > 0) {
    conds.push(inArray(interests.pipelineStage, filters.stages));
  }
  if (
    applies.includes('leadCategories') &&
    filters.leadCategories &&
    filters.leadCategories.length > 0
  ) {
    conds.push(inArray(interests.leadCategory, filters.leadCategories));
  }
  if (applies.includes('outcomes') && filters.outcomes && filters.outcomes.length > 0) {
    conds.push(inArray(interests.outcome, filters.outcomes));
  }
  if (applies.includes('assignedTo') && filters.assignedTo && filters.assignedTo.length > 0) {
    conds.push(inArray(interests.assignedTo, filters.assignedTo));
  }
  if (applies.includes('sources') && filters.sources && filters.sources.length > 0) {
    conds.push(inArray(interests.source, filters.sources));
  }
  if (conds.length === 0) return undefined;
  return and(...conds) as SQL;
}

/**
 * Service layer for the Sales Performance report.
 *
 * Locked spec: `docs/reports-content-spec.md` § "Report 01 — Sales
 * performance". Every KPI / chart / table here corresponds to a
 * decision recorded there. Don't change shapes without updating that
 * spec and the API consumer.
 *
 * Range semantics: a "period" is a [from, to) window. KPIs that count
 * point-in-time facts (Active interests, Pipeline value) ignore the
 * range. KPIs about events in the window (Won, Lost, New leads,
 * Avg time-to-close) clamp by the relevant timestamp column.
 */

interface DateRange {
  from: Date;
  to: Date;
}

export interface SalesKpis {
  /** Active = not archived, no outcome set. Point-in-time, ignores range. */
  activeInterests: number;
  /** Closed-won in the window, by `outcomeAt`. */
  wonInWindow: number;
  /** Closed-lost or cancelled in the window. Includes both losses and
   *  cancellations; `lossBreakdown` splits them out for the chip UI. */
  lostInWindow: number;
  /** Per-outcome counts for the Lost breakdown chip:
   *  `lost_other_marina`, `lost_unqualified`, `lost_no_response`,
   *  `cancelled`. Reasons with 0 count are omitted. */
  lossBreakdown: Array<{ outcome: string; count: number }>;
  /**
   * Win rate as a fraction [0,1] or null when (won + lost-*) = 0.
   * Cancellations are excluded from the denominator (locked decision
   * 2026-05-27): a cancellation isn't a loss to a competitor, so
   * counting it tanks the rate misleadingly.
   */
  winRate: number | null;
  /**
   * Forecasted pipeline value: Σ for each active interest of
   * (primary berth.price ?? depositExpectedAmount ?? 0) × stage weight.
   * Currency normalised to port-default. Point-in-time, ignores range.
   * Reports `excludedInterestsNoValue` so the UI can footnote
   * "X of Y interests have no value and aren't included."
   */
  pipelineValue: number;
  pipelineValueCurrency: string;
  pipelineValueExcludedCount: number;
  pipelineValueTotalActiveCount: number;
  /**
   * Median days from `dateFirstContact` to `outcomeAt` for won deals
   * in the window. Null when fewer than 3 qualifying deals (per
   * locked decision — small samples mislead).
   */
  medianTimeToCloseDays: number | null;
  /** Sample size used for the median above (informational footnote). */
  timeToCloseSampleSize: number;
  /** New interests opened in the window (incl. archived per locked
   *  decision — archive shouldn't silently change the trend line). */
  newLeadsInWindow: number;
  /** Source split of the new-leads count for the breakdown chip. */
  newLeadsBySource: Array<{ source: string; count: number }>;
}

/**
 * Resolve port-level pipeline weights (admin-tuneable) falling back to
 * the registry defaults declared in `lib/constants`.
 */
async function resolveStageWeights(portId: string): Promise<Record<string, number>> {
  const row = await db.query.systemSettings.findFirst({
    where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)),
  });
  if (row?.value && typeof row.value === 'object') {
    const merged: Record<string, number> = { ...STAGE_WEIGHTS };
    for (const [k, v] of Object.entries(row.value as Record<string, unknown>)) {
      const n = typeof v === 'number' ? v : Number(v);
      if (Number.isFinite(n)) merged[k] = n;
    }
    return merged;
  }
  return { ...STAGE_WEIGHTS };
}

/**
 * Port's default currency for money normalisation. Falls back to USD
 * when the column is null (legacy ports).
 */
async function resolvePortCurrency(portId: string): Promise<string> {
  const [row] = await db
    .select({ defaultCurrency: ports.defaultCurrency })
    .from(ports)
    .where(eq(ports.id, portId));
  return row?.defaultCurrency ?? 'USD';
}

/**
 * Top-level KPI strip query. One call returns all 7 tiles' data so the
 * UI can render the strip without 7 round-trips. Internally fans out
 * queries in parallel for latency.
 */
export async function getSalesKpis(portId: string, range: DateRange): Promise<SalesKpis> {
  const [
    activeInterests,
    outcomesInWindow,
    pipelineRows,
    timeToCloseResult,
    newLeadsResult,
    targetCurrency,
    weights,
  ] = await Promise.all([
    countActiveInterests(portId),
    fetchOutcomesInWindow(portId, range),
    fetchPipelineRows(portId),
    medianTimeToClose(portId, range),
    fetchNewLeads(portId, range),
    resolvePortCurrency(portId),
    resolveStageWeights(portId),
  ]);

  // Pipeline value: convert each row's amount to port currency, sum.
  let pipelineValue = 0;
  let excludedCount = 0;
  for (const r of pipelineRows) {
    const weight = weights[r.stage] ?? 0;
    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    if (baseAmount === null) {
      excludedCount += 1;
      continue;
    }
    const sourceCurrency = (
      r.berthPriceCurrency ??
      r.depositCurrency ??
      targetCurrency
    ).toUpperCase();
    let normalised = baseAmount;
    if (sourceCurrency !== targetCurrency.toUpperCase()) {
      const converted = await convertCurrency(baseAmount, sourceCurrency, targetCurrency);
      // Missing rate: degrade to raw amount (UI can flag if needed).
      normalised = converted?.result ?? baseAmount;
    }
    pipelineValue += normalised * weight;
  }

  // Win rate: cancelled excluded from denominator (locked decision).
  const won = outcomesInWindow.byOutcome.won ?? 0;
  const lostAny = sumLossesOnly(outcomesInWindow.byOutcome);
  const winRate = won + lostAny > 0 ? won / (won + lostAny) : null;

  // Lost breakdown: include cancellations (UI chip wants them) but
  // omit zero-count rows so the chip stays short.
  const lossBreakdown = Object.entries(outcomesInWindow.byOutcome)
    .filter(([k, v]) => v > 0 && (k.startsWith('lost_') || k === 'cancelled'))
    .map(([outcome, count]) => ({ outcome, count }));

  return {
    activeInterests,
    wonInWindow: won,
    lostInWindow: lossBreakdown.reduce((acc, r) => acc + r.count, 0),
    lossBreakdown,
    winRate,
    pipelineValue,
    pipelineValueCurrency: targetCurrency,
    pipelineValueExcludedCount: excludedCount,
    pipelineValueTotalActiveCount: pipelineRows.length,
    medianTimeToCloseDays: timeToCloseResult.medianDays,
    timeToCloseSampleSize: timeToCloseResult.sampleSize,
    newLeadsInWindow: newLeadsResult.total,
    newLeadsBySource: newLeadsResult.bySource,
  };
}

// ─── Pipeline funnel (Chart 1) ───────────────────────────────────────────────

export interface PipelineFunnelRow {
  stage: PipelineStage;
  count: number;
  /** Drop-off vs prior stage as a fraction [-1, +∞). Null for the
   *  first stage. */
  dropoffFromPrior: number | null;
}

/**
 * Funnel counts per stage for active interests. Output ordered by
 * canonical stage order (enquiry → contract). All 7 stages always
 * returned even when count = 0 so the chart axes are stable.
 */
export async function getPipelineFunnel(portId: string): Promise<PipelineFunnelRow[]> {
  const rows = await db
    .select({
      stage: interests.pipelineStage,
      count: sql<number>`count(*)::int`,
    })
    .from(interests)
    .where(activeInterestsWhere(portId))
    .groupBy(interests.pipelineStage);

  const countsByStage = Object.fromEntries(rows.map((r) => [r.stage, Number(r.count)]));

  return PIPELINE_STAGES.map((stage, i) => {
    const count = countsByStage[stage] ?? 0;
    const priorStage = PIPELINE_STAGES[i - 1];
    const priorCount = priorStage ? (countsByStage[priorStage] ?? 0) : 0;
    const dropoff = i === 0 || priorCount === 0 ? null : (count - priorCount) / priorCount;
    return { stage, count, dropoffFromPrior: dropoff };
  });
}

// ─── Stage velocity (Chart 2) ────────────────────────────────────────────────

export interface StageVelocityRow {
  stage: PipelineStage;
  /** Median days spent in this stage across observed transitions out. */
  medianDays: number | null;
  /** p90 days (rendered as a faint mark on the bar). */
  p90Days: number | null;
  /** Number of transitions out of this stage observed in the data
   *  source. UI uses this for the "based on N transitions" tooltip
   *  + to grey-out stages with too-small a sample. */
  transitions: number;
}

/**
 * Median + p90 days spent in each pipeline stage, derived from the
 * stage-change audit log. For each interest we walk consecutive
 * `update` audit rows whose `new_value` carries a `pipelineStage`;
 * the duration in the *prior* stage is `current.created_at -
 * previous.created_at`.
 *
 * Legacy stage names from the pre-migration 9-stage enum are
 * canonicalised so the chart axes use the current 7-stage labels.
 *
 * Includes ALL historical transitions (not range-clamped) — the
 * locked spec describes this as a "how long does a deal sit in each
 * stage" structural metric, not a period metric. Range clamping
 * could be added if we want per-quarter velocity comparisons later.
 */
export async function getStageVelocity(portId: string): Promise<StageVelocityRow[]> {
  const rows = await db.execute<{ stage: string; days_in_stage: number }>(
    sql`
      WITH stage_changes AS (
        SELECT
          entity_id AS interest_id,
          created_at,
          new_value->>'pipelineStage' AS to_stage,
          LAG(created_at) OVER (
            PARTITION BY entity_id ORDER BY created_at
          ) AS prev_at,
          LAG(new_value->>'pipelineStage') OVER (
            PARTITION BY entity_id ORDER BY created_at
          ) AS prev_stage
        FROM audit_logs
        WHERE entity_type = 'interest'
          AND action = 'update'
          AND port_id = ${portId}
          AND new_value ? 'pipelineStage'
      )
      SELECT
        prev_stage AS stage,
        EXTRACT(EPOCH FROM (created_at - prev_at)) / 86400 AS days_in_stage
      FROM stage_changes
      WHERE prev_stage IS NOT NULL
        AND prev_at IS NOT NULL
    `,
  );

  // Bucket per canonical stage in JS so legacy names (e.g. eoi_signed
  // → eoi) collapse into their modern equivalents. Post-bucket compute
  // percentiles.
  const byStage = new Map<PipelineStage, number[]>();
  for (const r of rows as unknown as Array<{ stage: string; days_in_stage: number }>) {
    const stage = canonicalizeStage(r.stage);
    const days = Number(r.days_in_stage);
    if (!Number.isFinite(days) || days < 0) continue;
    const bucket = byStage.get(stage) ?? [];
    bucket.push(days);
    byStage.set(stage, bucket);
  }

  return PIPELINE_STAGES.map((stage) => {
    const samples = byStage.get(stage) ?? [];
    if (samples.length === 0) {
      return { stage, medianDays: null, p90Days: null, transitions: 0 };
    }
    const sorted = samples.slice().sort((a, b) => a - b);
    return {
      stage,
      medianDays: percentile(sorted, 0.5),
      p90Days: percentile(sorted, 0.9),
      transitions: sorted.length,
    };
  });
}

function percentile(sorted: number[], p: number): number {
  if (sorted.length === 0) return 0;
  if (sorted.length === 1) return sorted[0]!;
  const idx = (sorted.length - 1) * p;
  const lower = Math.floor(idx);
  const upper = Math.ceil(idx);
  if (lower === upper) return sorted[lower]!;
  const weight = idx - lower;
  return sorted[lower]! * (1 - weight) + sorted[upper]! * weight;
}

// ─── Win rate over time (Chart 3) ────────────────────────────────────────────

export type WinRateBucket = 'week' | 'month' | 'quarter';

export interface WinRatePoint {
  /** ISO-truncated bucket label (e.g. '2026-W18' for week, '2026-04' for
   *  month, '2026-Q2' for quarter). The UI formats this for display. */
  bucket: string;
  /** Number of deals closed-won in the bucket. */
  won: number;
  /** Number of deals closed-lost (lost_* only; cancellations excluded
   *  per locked decision). */
  lost: number;
  /** Win rate as a fraction [0,1] or null when (won+lost)=0. */
  winRate: number | null;
}

export interface WinRateOverTime {
  /** Bucket granularity that was auto-picked for this range. */
  granularity: WinRateBucket;
  points: WinRatePoint[];
}

/**
 * Win rate over time, bucketed by week/month/quarter depending on the
 * range length (locked decision: weekly ≤6mo, monthly ≤2yr, quarterly
 * beyond).
 *
 * Cancellations are excluded from both numerator + denominator so the
 * chart matches the KPI strip's Win rate tile.
 *
 * Buckets with 0 closed deals render as null winRate (UI shows as a
 * gap, not a 0% point, to avoid the line falsely dropping).
 */
export async function getWinRateOverTime(
  portId: string,
  range: DateRange,
): Promise<WinRateOverTime> {
  const granularity = pickGranularity(range);
  const truncFn = granularity === 'week' ? 'week' : granularity === 'month' ? 'month' : 'quarter';
  const formatFn =
    granularity === 'week'
      ? sql`to_char(date_trunc(${truncFn}::text, ${interests.outcomeAt}), 'IYYY-"W"IW')`
      : granularity === 'month'
        ? sql`to_char(date_trunc(${truncFn}::text, ${interests.outcomeAt}), 'YYYY-MM')`
        : sql`to_char(date_trunc(${truncFn}::text, ${interests.outcomeAt}), 'YYYY-"Q"Q')`;

  const fromIso = range.from.toISOString();
  const toIso = range.to.toISOString();

  const rows = await db.execute<{ bucket: string; outcome: string; n: number }>(
    sql`
      SELECT
        ${formatFn} AS bucket,
        ${interests.outcome} AS outcome,
        COUNT(*)::int AS n
      FROM ${interests}
      WHERE ${interests.portId} = ${portId}
        AND ${interests.outcome} IS NOT NULL
        AND ${interests.outcomeAt} >= ${fromIso}::timestamptz
        AND ${interests.outcomeAt} <= ${toIso}::timestamptz
      GROUP BY bucket, ${interests.outcome}
      ORDER BY bucket ASC
    `,
  );

  // Pivot per bucket → { won, lost }
  const byBucket = new Map<string, { won: number; lost: number }>();
  for (const r of rows as unknown as Array<{ bucket: string; outcome: string; n: number }>) {
    const acc = byBucket.get(r.bucket) ?? { won: 0, lost: 0 };
    if (r.outcome === 'won') acc.won += Number(r.n);
    else if (r.outcome?.startsWith('lost_')) acc.lost += Number(r.n);
    // Cancellations explicitly skipped (locked decision: not a loss).
    byBucket.set(r.bucket, acc);
  }

  // Generate full bucket sequence across the range so the chart shows
  // gaps for empty periods instead of jumping over them.
  const allBuckets = generateBuckets(range, granularity);
  const points: WinRatePoint[] = allBuckets.map((bucket) => {
    const data = byBucket.get(bucket) ?? { won: 0, lost: 0 };
    const total = data.won + data.lost;
    return {
      bucket,
      won: data.won,
      lost: data.lost,
      winRate: total > 0 ? data.won / total : null,
    };
  });

  return { granularity, points };
}

function pickGranularity(range: DateRange): WinRateBucket {
  const days = (range.to.getTime() - range.from.getTime()) / 86400000;
  if (days <= 180) return 'week';
  if (days <= 730) return 'month';
  return 'quarter';
}

function generateBuckets(range: DateRange, granularity: WinRateBucket): string[] {
  const buckets: string[] = [];
  const cursor = new Date(range.from);
  const end = range.to;

  if (granularity === 'week') {
    // Walk by ISO weeks. Format `IYYY-"W"IW` in postgres uses the
    // ISO week number; we mirror that here.
    cursor.setDate(cursor.getDate() - ((cursor.getDay() + 6) % 7)); // back to Monday
    while (cursor <= end) {
      buckets.push(isoWeekLabel(cursor));
      cursor.setDate(cursor.getDate() + 7);
    }
  } else if (granularity === 'month') {
    cursor.setDate(1);
    while (cursor <= end) {
      buckets.push(`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`);
      cursor.setMonth(cursor.getMonth() + 1);
    }
  } else {
    cursor.setDate(1);
    cursor.setMonth(Math.floor(cursor.getMonth() / 3) * 3);
    while (cursor <= end) {
      const quarter = Math.floor(cursor.getMonth() / 3) + 1;
      buckets.push(`${cursor.getFullYear()}-Q${quarter}`);
      cursor.setMonth(cursor.getMonth() + 3);
    }
  }
  return buckets;
}

function isoWeekLabel(date: Date): string {
  // ISO-8601 week-numbering year + week-of-year.
  const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
  const dayNum = target.getUTCDay() || 7;
  target.setUTCDate(target.getUTCDate() + 4 - dayNum);
  const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
  const week = Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
  return `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
}

// ─── Source → win conversion (Chart 4) ───────────────────────────────────────

export type SourceOutcomeBucket = 'won' | 'lost' | 'cancelled' | 'in_flight';

export interface SourceConversionRow {
  /** Lead source ('website' / 'referral' / 'broker' / 'manual' / 'unknown'). */
  source: string;
  counts: Record<SourceOutcomeBucket, number>;
  /** Sum across all buckets — useful for sort + percentage labels. */
  total: number;
}

/**
 * For each lead source, count how interests are distributed across
 * outcomes. Used to fuel the stacked horizontal bar showing where each
 * source's leads end up. Includes BOTH closed deals (won / lost /
 * cancelled) AND still-in-flight deals — the latter weren't in the
 * KPI strip's win-rate denominator but matter for source attribution.
 *
 * "In flight" = archivedAt IS NULL AND outcome IS NULL.
 * "Lost" lumps the four lost_* variants together; cancelled is its
 * own bucket (matches locked decision in win-rate KPI).
 * Source NULL → bucketed as 'unknown'.
 */
export async function getSourceConversion(portId: string): Promise<SourceConversionRow[]> {
  const rows = await db
    .select({
      source: interests.source,
      outcome: interests.outcome,
      archivedAt: interests.archivedAt,
      n: sql<number>`count(*)::int`,
    })
    .from(interests)
    .where(eq(interests.portId, portId))
    .groupBy(interests.source, interests.outcome, interests.archivedAt);

  const bySource = new Map<string, SourceConversionRow>();
  for (const r of rows) {
    const source = r.source ?? 'unknown';
    const entry =
      bySource.get(source) ??
      ({
        source,
        counts: { won: 0, lost: 0, cancelled: 0, in_flight: 0 },
        total: 0,
      } satisfies SourceConversionRow);
    const count = Number(r.n);
    if (r.archivedAt !== null) {
      // Archived without outcome ⇒ skip (don't pollute attribution
      // with archived noise). Archived WITH outcome counts under that
      // outcome bucket.
      if (r.outcome === null) {
        bySource.set(source, entry);
        continue;
      }
    }
    if (r.outcome === 'won') entry.counts.won += count;
    else if (r.outcome?.startsWith('lost_')) entry.counts.lost += count;
    else if (r.outcome === 'cancelled') entry.counts.cancelled += count;
    else if (r.outcome === null && r.archivedAt === null) entry.counts.in_flight += count;
    entry.total =
      entry.counts.won + entry.counts.lost + entry.counts.cancelled + entry.counts.in_flight;
    bySource.set(source, entry);
  }

  return Array.from(bySource.values())
    .filter((r) => r.total > 0)
    .sort((a, b) => b.total - a.total);
}

// ─── Rep leaderboard (Chart 5) ───────────────────────────────────────────────

export interface RepLeaderboardRow {
  /** User id from interests.assignedTo. May be null when a deal has
   *  been left unassigned (rare; surfaces as "Unassigned" in the UI). */
  userId: string | null;
  /** Display name resolved via userProfiles. Falls back to email if
   *  no display name set, then to a placeholder. */
  displayName: string;
  /** New deals created in window AND assigned to this rep (current
   *  assignee per locked decision; reassignment history surfaced in
   *  the UI tooltip). */
  newDeals: number;
  won: number;
  lost: number;
  /** Active = archivedAt IS NULL AND outcome IS NULL. Point-in-time. */
  inFlight: number;
  /** Pipeline value (port-default currency, weighted by stage). */
  pipelineValue: number;
  pipelineValueCurrency: string;
  /** Fraction or null when (won+lost)=0 in window. */
  winRate: number | null;
  /** Median days to close in window or null when <3 won deals. */
  medianTimeToCloseDays: number | null;
}

/**
 * Per-rep aggregate stats for the window. Joined to userProfiles for
 * display names (falls back to email). Returned sorted by pipeline
 * value descending so the leaderboard reads top-down.
 */
export async function getRepLeaderboard(
  portId: string,
  range: DateRange,
): Promise<RepLeaderboardRow[]> {
  const targetCurrency = await resolvePortCurrency(portId);
  const weights = await resolveStageWeights(portId);
  const fromIso = range.from.toISOString();
  const toIso = range.to.toISOString();

  // Two-pass query — one for "in window" metrics (new/won/lost/median),
  // one for "point-in-time" metrics (active + pipeline value). Both
  // grouped by assignedTo. We pivot in JS rather than fight SQL pivots.

  // 1. Window-clamped counts per rep
  const windowRows = await db.execute<{
    user_id: string | null;
    new_deals: number;
    won: number;
    lost: number;
    median_close: number | null;
    sample_size: number;
  }>(sql`
    SELECT
      ${interests.assignedTo} AS user_id,
      COUNT(*) FILTER (WHERE ${interests.createdAt} >= ${fromIso}::timestamptz AND ${interests.createdAt} <= ${toIso}::timestamptz)::int AS new_deals,
      COUNT(*) FILTER (WHERE ${interests.outcome} = 'won' AND ${interests.outcomeAt} >= ${fromIso}::timestamptz AND ${interests.outcomeAt} <= ${toIso}::timestamptz)::int AS won,
      COUNT(*) FILTER (WHERE ${interests.outcome} LIKE 'lost_%' AND ${interests.outcomeAt} >= ${fromIso}::timestamptz AND ${interests.outcomeAt} <= ${toIso}::timestamptz)::int AS lost,
      percentile_cont(0.5) WITHIN GROUP (
        ORDER BY EXTRACT(EPOCH FROM (${interests.outcomeAt} - ${interests.dateFirstContact})) / 86400
      ) FILTER (WHERE ${interests.outcome} = 'won' AND ${interests.outcomeAt} >= ${fromIso}::timestamptz AND ${interests.outcomeAt} <= ${toIso}::timestamptz AND ${interests.dateFirstContact} IS NOT NULL) AS median_close,
      COUNT(*) FILTER (WHERE ${interests.outcome} = 'won' AND ${interests.outcomeAt} >= ${fromIso}::timestamptz AND ${interests.outcomeAt} <= ${toIso}::timestamptz AND ${interests.dateFirstContact} IS NOT NULL)::int AS sample_size
    FROM ${interests}
    WHERE ${interests.portId} = ${portId}
    GROUP BY ${interests.assignedTo}
  `);

  // 2. Active + pipeline (point-in-time per rep)
  const activeRows = await db
    .select({
      userId: interests.assignedTo,
      stage: interests.pipelineStage,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(activeInterestsWhere(portId));

  // Aggregate active counts + pipeline value per rep
  const activeByRep = new Map<string | null, { inFlight: number; pipelineValue: number }>();
  for (const r of activeRows) {
    const key = r.userId;
    const acc = activeByRep.get(key) ?? { inFlight: 0, pipelineValue: 0 };
    acc.inFlight += 1;
    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    if (baseAmount !== null) {
      const sourceCurrency = (
        r.berthPriceCurrency ??
        r.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      let normalised = baseAmount;
      if (sourceCurrency !== targetCurrency.toUpperCase()) {
        const converted = await convertCurrency(baseAmount, sourceCurrency, targetCurrency);
        normalised = converted?.result ?? baseAmount;
      }
      acc.pipelineValue += normalised * (weights[r.stage ?? 'enquiry'] ?? 0);
    }
    activeByRep.set(key, acc);
  }

  // Combine the two views + resolve display names
  const allUserIds = new Set<string | null>();
  for (const r of windowRows as unknown as Array<{ user_id: string | null }>) {
    allUserIds.add(r.user_id);
  }
  for (const key of activeByRep.keys()) allUserIds.add(key);

  const userIdsToResolve = Array.from(allUserIds).filter((u): u is string => !!u);
  const profiles =
    userIdsToResolve.length > 0
      ? await db
          .select({
            userId: userProfiles.userId,
            displayName: userProfiles.displayName,
          })
          .from(userProfiles)
          .where(inArray(userProfiles.userId, userIdsToResolve))
      : [];
  const profileById = new Map(profiles.map((p) => [p.userId, p.displayName ?? 'Unknown']));

  const result: RepLeaderboardRow[] = [];
  for (const userId of allUserIds) {
    const windowRow = (
      windowRows as unknown as Array<{
        user_id: string | null;
        new_deals: number;
        won: number;
        lost: number;
        median_close: number | null;
        sample_size: number;
      }>
    ).find((r) => r.user_id === userId);
    const active = activeByRep.get(userId) ?? { inFlight: 0, pipelineValue: 0 };

    const newDeals = Number(windowRow?.new_deals ?? 0);
    const won = Number(windowRow?.won ?? 0);
    const lost = Number(windowRow?.lost ?? 0);
    const sample = Number(windowRow?.sample_size ?? 0);
    const winRate = won + lost > 0 ? won / (won + lost) : null;
    const median =
      sample >= 3 && windowRow?.median_close !== null ? Number(windowRow!.median_close) : null;

    // Skip reps with no activity at all in window AND no active deals —
    // a stale unassigned row would otherwise pollute the leaderboard.
    if (newDeals === 0 && won === 0 && lost === 0 && active.inFlight === 0) continue;

    result.push({
      userId,
      displayName: userId ? (profileById.get(userId) ?? 'Unknown') : 'Unassigned',
      newDeals,
      won,
      lost,
      inFlight: active.inFlight,
      pipelineValue: active.pipelineValue,
      pipelineValueCurrency: targetCurrency,
      winRate,
      medianTimeToCloseDays: median,
    });
  }

  return result.sort((a, b) => b.pipelineValue - a.pipelineValue);
}

// ─── Rep filter options ──────────────────────────────────────────────────────

export interface RepFilterOption {
  userId: string;
  displayName: string;
}

/**
 * The distinct set of reps that have at least one interest assigned in
 * the port — the option list for the Sales report's "Assigned to" (rep)
 * filter. Deliberately independent of the active window and the other
 * filters so the dropdown stays stable as the user narrows the report.
 * Unassigned interests are excluded (the inner join drops null
 * `assigned_to`); the filter targets named reps.
 */
export async function getRepFilterOptions(portId: string): Promise<RepFilterOption[]> {
  const rows = await db
    .selectDistinct({
      userId: interests.assignedTo,
      displayName: userProfiles.displayName,
    })
    .from(interests)
    .innerJoin(userProfiles, eq(userProfiles.userId, interests.assignedTo))
    .where(eq(interests.portId, portId))
    .orderBy(userProfiles.displayName);

  const options: RepFilterOption[] = [];
  for (const r of rows) {
    if (!r.userId) continue; // belt-and-braces: inner join already excludes nulls
    options.push({ userId: r.userId, displayName: r.displayName ?? 'Unknown' });
  }
  return options;
}

// ─── Deal heat (Section between leaderboard and detail tables) ───────────────

export type HeatBucket = 'hot' | 'warm' | 'cold';

export interface DealHeatSummary {
  distribution: Record<HeatBucket, number>;
  /** Top 5 hottest active deals, ordered by stage rank then recency. */
  topDeals: Array<{
    id: string;
    clientName: string;
    mooringNumber: string | null;
    stage: PipelineStage;
    bucket: HeatBucket;
    daysSinceLastContact: number | null;
    pipelineValue: number;
    pipelineValueCurrency: string;
  }>;
}

/**
 * Distribution of active deals into hot / warm / cold buckets plus the
 * top-5 hottest deals.
 *
 * Bucket heuristic (matches the dashboard's pulse semantics):
 *  - hot: contract, deposit_paid, reservation (committed money on
 *    the table)
 *  - warm: eoi, qualified (active negotiation)
 *  - cold: enquiry, nurturing (early or parked)
 */
export async function getDealHeat(portId: string): Promise<DealHeatSummary> {
  const targetCurrency = await resolvePortCurrency(portId);

  const rows = await db
    .select({
      id: interests.id,
      stage: interests.pipelineStage,
      clientName: clients.fullName,
      mooring: berths.mooringNumber,
      lastContact: interests.dateLastContact,
      updatedAt: interests.updatedAt,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .innerJoin(clients, eq(interests.clientId, clients.id))
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(activeInterestsWhere(portId));

  const distribution: Record<HeatBucket, number> = { hot: 0, warm: 0, cold: 0 };
  const scored: Array<{
    id: string;
    clientName: string;
    mooringNumber: string | null;
    stage: PipelineStage;
    bucket: HeatBucket;
    daysSinceLastContact: number | null;
    pipelineValue: number;
    pipelineValueCurrency: string;
    score: number;
  }> = [];
  const now = Date.now();

  for (const r of rows) {
    const stage = (r.stage as PipelineStage) ?? 'enquiry';
    const bucket = heatBucketForStage(stage);
    distribution[bucket] += 1;

    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    let value = 0;
    if (baseAmount !== null) {
      const sourceCurrency = (
        r.berthPriceCurrency ??
        r.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      if (sourceCurrency === targetCurrency.toUpperCase()) {
        value = baseAmount;
      } else {
        const converted = await convertCurrency(baseAmount, sourceCurrency, targetCurrency);
        value = converted?.result ?? baseAmount;
      }
    }

    const daysSinceLastContact =
      r.lastContact !== null ? Math.floor((now - r.lastContact.getTime()) / 86400000) : null;
    // Score = stage-rank-weighted (max 7) × recency boost. Recent
    // contact pushes it up; stale contact pushes it down.
    const stageRank = stageRankOf(stage);
    const recencyBoost =
      daysSinceLastContact === null
        ? 0
        : daysSinceLastContact <= 3
          ? 2
          : daysSinceLastContact <= 7
            ? 1
            : daysSinceLastContact >= 30
              ? -2
              : 0;
    const score = stageRank * 10 + recencyBoost + value / 1_000_000;

    scored.push({
      id: r.id,
      clientName: r.clientName,
      mooringNumber: r.mooring,
      stage,
      bucket,
      daysSinceLastContact,
      pipelineValue: value,
      pipelineValueCurrency: targetCurrency,
      score,
    });
  }

  scored.sort((a, b) => b.score - a.score);
  const topDeals = scored.slice(0, 5).map(({ score: _score, ...rest }) => {
    void _score;
    return rest;
  });

  return { distribution, topDeals };
}

function heatBucketForStage(stage: PipelineStage): HeatBucket {
  if (stage === 'contract' || stage === 'deposit_paid' || stage === 'reservation') return 'hot';
  if (stage === 'eoi' || stage === 'qualified') return 'warm';
  return 'cold';
}

function stageRankOf(stage: PipelineStage): number {
  const ranks: Record<PipelineStage, number> = {
    contract: 7,
    deposit_paid: 6,
    reservation: 5,
    eoi: 4,
    qualified: 3,
    nurturing: 2,
    enquiry: 1,
  };
  return ranks[stage];
}

// ─── Detail tables (Tables 1-5) ──────────────────────────────────────────────

export interface OpenDealRow {
  id: string;
  clientName: string;
  primaryBerth: string | null;
  stage: PipelineStage;
  stageValue: number;
  /** ISO-4217 code the stageValue is denominated in. Service converts
   *  every row into the port's reporting currency, so this is the same
   *  string across the result set — but exposing it per-row keeps the
   *  detail-table render sites self-describing (no need to thread the
   *  port currency separately). */
  stageValueCurrency: string;
  daysInStage: number | null;
  lastContact: string | null;
}

export interface RepPerformanceDetailRow extends RepLeaderboardRow {
  /** Open deals assigned to this rep, sorted by stage value desc. */
  openDeals: OpenDealRow[];
}

/**
 * Same per-rep aggregates as the leaderboard PLUS an inline list of
 * each rep's open deals (sorted by stage value desc). On web this
 * renders collapsed-by-default with an expand chevron; in the PDF it
 * always renders inline (no expander affordance possible in print).
 *
 * The query reuses getRepLeaderboard for the aggregates and then
 * fetches open deals per rep in a single grouped query to keep the
 * round-trips low.
 */
export async function getRepPerformanceDetail(
  portId: string,
  range: DateRange,
  filters?: SalesFilters,
): Promise<RepPerformanceDetailRow[]> {
  const aggregates = await getRepLeaderboard(portId, range);
  if (aggregates.length === 0) return [];

  // One query for all open deals across all reps; bucket in JS.
  const targetCurrency = await resolvePortCurrency(portId);
  const now = Date.now();
  const filterWhere = buildSalesFiltersWhere(filters, [
    'stages',
    'leadCategories',
    'assignedTo',
    'sources',
  ]);
  const dealRows = await db
    .select({
      id: interests.id,
      assignedTo: interests.assignedTo,
      clientName: clients.fullName,
      mooring: berths.mooringNumber,
      stage: interests.pipelineStage,
      lastContact: interests.dateLastContact,
      updatedAt: interests.updatedAt,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .innerJoin(clients, eq(interests.clientId, clients.id))
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(
      filterWhere ? and(activeInterestsWhere(portId), filterWhere) : activeInterestsWhere(portId),
    );

  const byRep = new Map<string | null, OpenDealRow[]>();
  for (const d of dealRows) {
    const baseAmount = pickAmount(d.berthPrice, d.depositExpected);
    let value = 0;
    if (baseAmount !== null) {
      const sourceCurrency = (
        d.berthPriceCurrency ??
        d.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      value =
        sourceCurrency === targetCurrency.toUpperCase()
          ? baseAmount
          : ((await convertCurrency(baseAmount, sourceCurrency, targetCurrency))?.result ??
            baseAmount);
    }
    const daysInStage =
      d.updatedAt !== null ? Math.floor((now - d.updatedAt.getTime()) / 86400000) : null;
    const open: OpenDealRow = {
      id: d.id,
      clientName: d.clientName,
      primaryBerth: d.mooring,
      stage: (d.stage as PipelineStage) ?? 'enquiry',
      stageValue: value,
      stageValueCurrency: targetCurrency,
      daysInStage,
      lastContact: d.lastContact ? d.lastContact.toISOString() : null,
    };
    const list = byRep.get(d.assignedTo) ?? [];
    list.push(open);
    byRep.set(d.assignedTo, list);
  }

  return aggregates.map((agg) => {
    const openDeals = (byRep.get(agg.userId) ?? []).sort((a, b) => b.stageValue - a.stageValue);
    return { ...agg, openDeals };
  });
}

/** Stage-aware stalled thresholds per locked decision (2026-05-27). */
const STALLED_THRESHOLD_DAYS: Record<PipelineStage, number> = {
  enquiry: 21,
  qualified: 14,
  nurturing: 60,
  eoi: 10,
  reservation: 7,
  deposit_paid: 7,
  contract: 5,
};

export interface StalledDealRow {
  id: string;
  clientName: string;
  stage: PipelineStage;
  daysSinceLastContact: number | null;
  daysInStage: number | null;
  stageValue: number;
  /** ISO-4217 code the stageValue is denominated in (= port currency). */
  stageValueCurrency: string;
  rep: string;
  primaryBerth: string | null;
}

/**
 * Active interests where the days-since-last-contact exceeds the
 * stage-specific threshold. Null lastContact → treated as stalled
 * (never been contacted; needs first-contact attention).
 */
export async function getStalledDeals(
  portId: string,
  filters?: SalesFilters,
): Promise<StalledDealRow[]> {
  const now = Date.now();
  const targetCurrency = await resolvePortCurrency(portId);

  const filterWhere = buildSalesFiltersWhere(filters, [
    'stages',
    'leadCategories',
    'assignedTo',
    'sources',
  ]);
  const rows = await db
    .select({
      id: interests.id,
      stage: interests.pipelineStage,
      clientName: clients.fullName,
      mooring: berths.mooringNumber,
      assignedTo: interests.assignedTo,
      lastContact: interests.dateLastContact,
      updatedAt: interests.updatedAt,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .innerJoin(clients, eq(interests.clientId, clients.id))
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(
      filterWhere ? and(activeInterestsWhere(portId), filterWhere) : activeInterestsWhere(portId),
    );

  // Resolve rep display names (one pass)
  const repIds = Array.from(new Set(rows.map((r) => r.assignedTo).filter((u): u is string => !!u)));
  const profiles =
    repIds.length > 0
      ? await db
          .select({ userId: userProfiles.userId, displayName: userProfiles.displayName })
          .from(userProfiles)
          .where(inArray(userProfiles.userId, repIds))
      : [];
  const profileById = new Map(profiles.map((p) => [p.userId, p.displayName ?? 'Unknown']));

  const result: StalledDealRow[] = [];
  for (const r of rows) {
    const stage = (r.stage as PipelineStage) ?? 'enquiry';
    const threshold = STALLED_THRESHOLD_DAYS[stage];
    const daysSinceLastContact =
      r.lastContact !== null ? Math.floor((now - r.lastContact.getTime()) / 86400000) : null;
    // Null last contact → always stalled (locked decision).
    const isStalled = daysSinceLastContact === null || daysSinceLastContact >= threshold;
    if (!isStalled) continue;

    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    let value = 0;
    if (baseAmount !== null) {
      const sourceCurrency = (
        r.berthPriceCurrency ??
        r.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      value =
        sourceCurrency === targetCurrency.toUpperCase()
          ? baseAmount
          : ((await convertCurrency(baseAmount, sourceCurrency, targetCurrency))?.result ??
            baseAmount);
    }

    const daysInStage =
      r.updatedAt !== null ? Math.floor((now - r.updatedAt.getTime()) / 86400000) : null;

    result.push({
      id: r.id,
      clientName: r.clientName,
      stage,
      daysSinceLastContact,
      daysInStage,
      stageValue: value,
      stageValueCurrency: targetCurrency,
      rep: r.assignedTo ? (profileById.get(r.assignedTo) ?? 'Unknown') : 'Unassigned',
      primaryBerth: r.mooring,
    });
  }

  return result.sort((a, b) => b.stageValue - a.stageValue);
}

export interface ClosingThisMonthRow {
  id: string;
  clientName: string;
  stage: PipelineStage;
  stageValue: number;
  /** ISO-4217 code the stageValue is denominated in (= port currency). */
  stageValueCurrency: string;
  daysInStage: number | null;
  rep: string;
  primaryBerth: string | null;
}

/**
 * Late-stage active deals: reservation / deposit_paid / contract.
 * Sorted by stage value desc — the "don't drop these" list.
 */
export async function getClosingThisMonth(
  portId: string,
  filters?: SalesFilters,
): Promise<ClosingThisMonthRow[]> {
  const targetCurrency = await resolvePortCurrency(portId);
  const now = Date.now();

  // Stage is intrinsically constrained to {reservation, deposit_paid,
  // contract} by spec — but a user-set stages filter is allowed to
  // further narrow within that subset.
  const userStages = filters?.stages?.filter((s) =>
    (['reservation', 'deposit_paid', 'contract'] as const).includes(s as never),
  );
  const stageList: PipelineStage[] =
    userStages && userStages.length > 0 ? userStages : ['reservation', 'deposit_paid', 'contract'];
  const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories', 'assignedTo', 'sources']);

  const rows = await db
    .select({
      id: interests.id,
      stage: interests.pipelineStage,
      clientName: clients.fullName,
      mooring: berths.mooringNumber,
      assignedTo: interests.assignedTo,
      updatedAt: interests.updatedAt,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .innerJoin(clients, eq(interests.clientId, clients.id))
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(
      and(
        activeInterestsWhere(portId),
        inArray(interests.pipelineStage, stageList),
        ...(filterWhere ? [filterWhere] : []),
      ),
    );

  const repIds = Array.from(new Set(rows.map((r) => r.assignedTo).filter((u): u is string => !!u)));
  const profiles =
    repIds.length > 0
      ? await db
          .select({ userId: userProfiles.userId, displayName: userProfiles.displayName })
          .from(userProfiles)
          .where(inArray(userProfiles.userId, repIds))
      : [];
  const profileById = new Map(profiles.map((p) => [p.userId, p.displayName ?? 'Unknown']));

  const result: ClosingThisMonthRow[] = [];
  for (const r of rows) {
    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    let value = 0;
    if (baseAmount !== null) {
      const sourceCurrency = (
        r.berthPriceCurrency ??
        r.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      value =
        sourceCurrency === targetCurrency.toUpperCase()
          ? baseAmount
          : ((await convertCurrency(baseAmount, sourceCurrency, targetCurrency))?.result ??
            baseAmount);
    }
    const daysInStage =
      r.updatedAt !== null ? Math.floor((now - r.updatedAt.getTime()) / 86400000) : null;
    result.push({
      id: r.id,
      clientName: r.clientName,
      stage: (r.stage as PipelineStage) ?? 'reservation',
      stageValue: value,
      stageValueCurrency: targetCurrency,
      daysInStage,
      rep: r.assignedTo ? (profileById.get(r.assignedTo) ?? 'Unknown') : 'Unassigned',
      primaryBerth: r.mooring,
    });
  }

  return result.sort((a, b) => b.stageValue - a.stageValue);
}

export interface RecentWinRow {
  id: string;
  clientName: string;
  primaryBerth: string | null;
  finalValue: number;
  currency: string;
  daysToClose: number | null;
  rep: string;
  outcomeAt: string;
}

/** Last 5 won deals across all time (locked spec: celebratory strip). */
export async function getRecentWins(
  portId: string,
  filters?: SalesFilters,
): Promise<RecentWinRow[]> {
  const targetCurrency = await resolvePortCurrency(portId);
  const filterWhere = buildSalesFiltersWhere(filters, ['leadCategories', 'assignedTo', 'sources']);
  const rows = await db
    .select({
      id: interests.id,
      clientName: clients.fullName,
      mooring: berths.mooringNumber,
      assignedTo: interests.assignedTo,
      outcomeAt: interests.outcomeAt,
      dateFirstContact: interests.dateFirstContact,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .innerJoin(clients, eq(interests.clientId, clients.id))
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(
      and(
        eq(interests.portId, portId),
        eq(interests.outcome, 'won'),
        ...(filterWhere ? [filterWhere] : []),
      ),
    )
    .orderBy(sql`${interests.outcomeAt} DESC NULLS LAST`)
    .limit(5);

  const repIds = Array.from(new Set(rows.map((r) => r.assignedTo).filter((u): u is string => !!u)));
  const profiles =
    repIds.length > 0
      ? await db
          .select({ userId: userProfiles.userId, displayName: userProfiles.displayName })
          .from(userProfiles)
          .where(inArray(userProfiles.userId, repIds))
      : [];
  const profileById = new Map(profiles.map((p) => [p.userId, p.displayName ?? 'Unknown']));

  const result: RecentWinRow[] = [];
  for (const r of rows) {
    if (!r.outcomeAt) continue;
    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    let value = 0;
    if (baseAmount !== null) {
      const sourceCurrency = (
        r.berthPriceCurrency ??
        r.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      value =
        sourceCurrency === targetCurrency.toUpperCase()
          ? baseAmount
          : ((await convertCurrency(baseAmount, sourceCurrency, targetCurrency))?.result ??
            baseAmount);
    }
    const daysToClose =
      r.dateFirstContact !== null
        ? Math.floor((r.outcomeAt.getTime() - r.dateFirstContact.getTime()) / 86400000)
        : null;
    result.push({
      id: r.id,
      clientName: r.clientName,
      primaryBerth: r.mooring,
      finalValue: value,
      currency: targetCurrency,
      daysToClose,
      rep: r.assignedTo ? (profileById.get(r.assignedTo) ?? 'Unknown') : 'Unassigned',
      outcomeAt: r.outcomeAt.toISOString(),
    });
  }
  return result;
}

export interface LostReasonRow {
  outcome: string;
  count: number;
  totalValueLost: number;
  currency: string;
  avgDaysFromFirstContactToLoss: number | null;
}

/** Per-outcome breakdown of losses + cancellations in window. */
export async function getLostReasonBreakdown(
  portId: string,
  range: DateRange,
  filters?: SalesFilters,
): Promise<LostReasonRow[]> {
  const targetCurrency = await resolvePortCurrency(portId);
  // outcomes filter: intersect with the LIKE 'lost_%' OR 'cancelled'
  // set. If the user picks 'won', the result is empty (correct — the
  // breakdown is for losses + cancellations only).
  const lossOutcomes = filters?.outcomes?.filter((o) => o.startsWith('lost_') || o === 'cancelled');
  const filterWhere = buildSalesFiltersWhere({ ...filters, outcomes: lossOutcomes }, [
    'leadCategories',
    'outcomes',
    'assignedTo',
    'sources',
  ]);
  const rows = await db
    .select({
      outcome: interests.outcome,
      dateFirstContact: interests.dateFirstContact,
      outcomeAt: interests.outcomeAt,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(
      and(
        eq(interests.portId, portId),
        sql`${interests.outcome} IS NOT NULL`,
        sql`(${interests.outcome} LIKE 'lost_%' OR ${interests.outcome} = 'cancelled')`,
        gte(interests.outcomeAt, range.from),
        lte(interests.outcomeAt, range.to),
        ...(filterWhere ? [filterWhere] : []),
      ),
    );

  const byOutcome = new Map<
    string,
    { count: number; totalValue: number; daysSum: number; daysN: number }
  >();
  for (const r of rows) {
    if (!r.outcome) continue;
    const acc = byOutcome.get(r.outcome) ?? {
      count: 0,
      totalValue: 0,
      daysSum: 0,
      daysN: 0,
    };
    acc.count += 1;

    const baseAmount = pickAmount(r.berthPrice, r.depositExpected);
    if (baseAmount !== null) {
      const sourceCurrency = (
        r.berthPriceCurrency ??
        r.depositCurrency ??
        targetCurrency
      ).toUpperCase();
      const normalised =
        sourceCurrency === targetCurrency.toUpperCase()
          ? baseAmount
          : ((await convertCurrency(baseAmount, sourceCurrency, targetCurrency))?.result ??
            baseAmount);
      acc.totalValue += normalised;
    }

    if (r.dateFirstContact && r.outcomeAt) {
      const days = (r.outcomeAt.getTime() - r.dateFirstContact.getTime()) / 86400000;
      if (Number.isFinite(days) && days >= 0) {
        acc.daysSum += days;
        acc.daysN += 1;
      }
    }

    byOutcome.set(r.outcome, acc);
  }

  return Array.from(byOutcome.entries())
    .map(([outcome, agg]) => ({
      outcome,
      count: agg.count,
      totalValueLost: agg.totalValue,
      currency: targetCurrency,
      avgDaysFromFirstContactToLoss: agg.daysN > 0 ? agg.daysSum / agg.daysN : null,
    }))
    .sort((a, b) => b.totalValueLost - a.totalValueLost);
}

// ─── Internals ───────────────────────────────────────────────────────────────

async function countActiveInterests(portId: string): Promise<number> {
  const [row] = await db
    .select({ value: sql<number>`count(*)::int` })
    .from(interests)
    .where(activeInterestsWhere(portId));
  return row?.value ?? 0;
}

/**
 * Group outcomes finished in the window by outcome value. Returns a
 * `{ [outcome]: count }` map for downstream win-rate + breakdown
 * derivation.
 */
async function fetchOutcomesInWindow(
  portId: string,
  range: DateRange,
): Promise<{ byOutcome: Record<string, number> }> {
  const rows = await db
    .select({
      outcome: interests.outcome,
      count: sql<number>`count(*)::int`,
    })
    .from(interests)
    .where(
      and(
        eq(interests.portId, portId),
        sql`${interests.outcome} IS NOT NULL`,
        gte(interests.outcomeAt, range.from),
        lte(interests.outcomeAt, range.to),
      ),
    )
    .groupBy(interests.outcome);

  const byOutcome: Record<string, number> = {};
  for (const r of rows) {
    if (!r.outcome) continue;
    byOutcome[r.outcome] = Number(r.count);
  }
  return { byOutcome };
}

function sumLossesOnly(byOutcome: Record<string, number>): number {
  // Locked decision: cancellation is NOT a loss for win-rate purposes
  // (client withdrew, we didn't lose to a competitor). Only count the
  // four `lost_*` variants.
  return Object.entries(byOutcome)
    .filter(([k]) => k.startsWith('lost_'))
    .reduce((acc, [, v]) => acc + v, 0);
}

interface PipelineRow {
  stage: string;
  berthPrice: string | null;
  berthPriceCurrency: string | null;
  depositExpected: string | null;
  depositCurrency: string | null;
}

/**
 * Per-interest amount feed for the Pipeline value KPI. Primary-berth
 * price is preferred (locked decision); fall back to the interest's
 * `depositExpectedAmount`.
 */
async function fetchPipelineRows(portId: string): Promise<PipelineRow[]> {
  const rows = await db
    .select({
      stage: interests.pipelineStage,
      berthPrice: berths.price,
      berthPriceCurrency: berths.priceCurrency,
      depositExpected: interests.depositExpectedAmount,
      depositCurrency: interests.depositExpectedCurrency,
    })
    .from(interests)
    .leftJoin(
      interestBerths,
      and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
    )
    .leftJoin(berths, eq(interestBerths.berthId, berths.id))
    .where(activeInterestsWhere(portId));
  return rows.map((r) => ({
    stage: r.stage ?? 'enquiry',
    berthPrice: r.berthPrice ?? null,
    berthPriceCurrency: r.berthPriceCurrency ?? null,
    depositExpected: r.depositExpected ?? null,
    depositCurrency: r.depositCurrency ?? null,
  }));
}

function pickAmount(berthPrice: string | null, depositExpected: string | null): number | null {
  const fromBerth = parseAmount(berthPrice);
  if (fromBerth !== null) return fromBerth;
  return parseAmount(depositExpected);
}

function parseAmount(raw: string | null): number | null {
  if (raw === null || raw === '') return null;
  const n = parseFloat(raw);
  return Number.isFinite(n) && n > 0 ? n : null;
}

interface MedianResult {
  medianDays: number | null;
  sampleSize: number;
}

/**
 * Median days from `dateFirstContact` to `outcomeAt` for won deals
 * closed in the window. Uses postgres `percentile_cont` so the
 * calculation stays in SQL and we don't ship a row-by-row median to
 * Node. Null when sample size < 3 (locked decision: small samples
 * are misleading; UI shows "—" with a hint).
 */
async function medianTimeToClose(portId: string, range: DateRange): Promise<MedianResult> {
  // Raw sql`` template literals (unlike `gte()` / `lte()` helpers) do
  // NOT auto-coerce Date instances - the postgres.js driver receives
  // them verbatim and rejects with "Received an instance of Date" when
  // binding parameters. Pass ISO strings + cast to timestamptz so
  // postgres parses them server-side, matching how Drizzle's other
  // operators serialise dates.
  const fromIso = range.from.toISOString();
  const toIso = range.to.toISOString();
  const [row] = await db.execute<{ median_days: number | null; sample_size: number }>(
    sql`
      SELECT
        percentile_cont(0.5) WITHIN GROUP (
          ORDER BY EXTRACT(EPOCH FROM (${interests.outcomeAt} - ${interests.dateFirstContact})) / 86400
        ) AS median_days,
        COUNT(*)::int AS sample_size
      FROM ${interests}
      WHERE
        ${interests.portId} = ${portId}
        AND ${interests.outcome} = 'won'
        AND ${interests.outcomeAt} >= ${fromIso}::timestamptz
        AND ${interests.outcomeAt} <= ${toIso}::timestamptz
        AND ${interests.dateFirstContact} IS NOT NULL
    `,
  );
  const sample = Number(row?.sample_size ?? 0);
  if (sample < 3) {
    return { medianDays: null, sampleSize: sample };
  }
  const median = row?.median_days;
  return {
    medianDays: median !== null && median !== undefined ? Number(median) : null,
    sampleSize: sample,
  };
}

/**
 * New leads opened in the window — including archived ones (locked
 * decision: archives shouldn't silently change the trend). Breakdown
 * by `source` for the chip.
 */
async function fetchNewLeads(
  portId: string,
  range: DateRange,
): Promise<{ total: number; bySource: Array<{ source: string; count: number }> }> {
  const rows = await db
    .select({
      source: interests.source,
      count: sql<number>`count(*)::int`,
    })
    .from(interests)
    .where(
      and(
        eq(interests.portId, portId),
        gte(interests.createdAt, range.from),
        lte(interests.createdAt, range.to),
      ),
    )
    .groupBy(interests.source);

  let total = 0;
  const bySource: Array<{ source: string; count: number }> = [];
  for (const r of rows) {
    const c = Number(r.count);
    total += c;
    // Null source rendered as "unknown" so the chip doesn't drop the
    // bar — historical interests created before `source` was a required
    // field still appear in the trend.
    bySource.push({ source: r.source ?? 'unknown', count: c });
  }
  // Sort desc so the chip reads largest → smallest.
  bySource.sort((a, b) => b.count - a.count);
  return { total, bySource };
}

/**
 * Window-independent existence check: does this port have any interest at
 * all? Drives the Sales report-level empty state.
 */
export async function salesHasData(portId: string): Promise<boolean> {
  const rows = await db
    .select({ one: sql<number>`1` })
    .from(interests)
    .where(eq(interests.portId, portId))
    .limit(1);
  return rows.length > 0;
}

// Re-export so the active-interest helper is available to callers that
// want to add their own constraints layered onto the same predicate.
export { activeInterestsWhere };

// Internal type re-export for testing.
export type { DateRange };

// Avoid unused-import lint for isNull (used by activeInterestsWhere transitively).
void isNull;
