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

import { db } from '@/lib/db';
import { berths } from '@/lib/db/schema/berths';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { clients } from '@/lib/db/schema/clients';
import { documents } from '@/lib/db/schema/documents';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import type { OperationalFilters } from './operational-filters';

/**
 * Service layer for the Operational report.
 *
 * Locked spec: `docs/reports-content-spec.md` § "Report 04 — Operational".
 * Half this report depends on the tenancies module being enabled — when
 * it's off, those KPIs/tiles/charts auto-collapse (the parent UI checks
 * `payload.tenanciesModuleEnabled` and hides the relevant sections).
 *
 * Period semantics: most operational metrics are point-in-time
 * (status mix today, active tenancies count). The few that are
 * windowed (period-comparison deltas, churn) explicitly take a range.
 */

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

/**
 * Optional berth-area WHERE-condition. Returns `undefined` when no area
 * filter is active, so it drops cleanly out of a drizzle `and(...)`
 * (which ignores undefined operands).
 */
function areaCond(filters?: OperationalFilters) {
  return filters?.areas && filters.areas.length > 0
    ? inArray(berths.area, filters.areas)
    : undefined;
}

export interface OperationalKpis {
  totalBerths: number;
  soldPct: number;
  /** % delta of sold count vs period start (audit-log derived). Null when
   *  we couldn't reconstruct a baseline. */
  soldPctDelta: number | null;
  underOfferPct: number;
  underOfferPctDelta: number | null;
  /** Tenancies module gate. When false, the next 3 tiles render as "—"
   *  with a hint. */
  tenanciesModuleEnabled: boolean;
  activeTenancies: number | null;
  avgTenancyLengthYears: number | null;
  /** Per-document-type turnaround (locked decision: per-type, not aggregate). */
  signingTurnaround: Array<{
    documentType: string;
    medianDays: number | null;
    sampleSize: number;
  }>;
  /** When > 0, surfaces a red badge. Hidden when 0 per locked decision. */
  berthsInConflict: number;
}

/**
 * Top-level KPI strip for the Operational report.
 */
export async function getOperationalKpis(
  portId: string,
  range: DateRange,
  filters?: OperationalFilters,
): Promise<OperationalKpis> {
  const [
    totalBerths,
    soldNow,
    soldAtStart,
    underOfferNow,
    underOfferAtStart,
    tenanciesEnabled,
    activeTenancies,
    avgTenancyLength,
    signingTurnaround,
    conflicts,
  ] = await Promise.all([
    countActiveBerths(portId, filters),
    countBerthsByStatusNow(portId, 'sold', filters),
    countBerthsByStatusAtTimestamp(portId, 'sold', range.from, filters),
    countBerthsByStatusNow(portId, 'under_offer', filters),
    countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from, filters),
    isTenanciesModuleEnabled(portId),
    countActiveTenancies(portId),
    medianTenancyLengthYears(portId),
    perTypeSigningTurnaround(portId),
    countBerthsInConflict(portId),
  ]);

  const soldPct = totalBerths > 0 ? (soldNow / totalBerths) * 100 : 0;
  const underOfferPct = totalBerths > 0 ? (underOfferNow / totalBerths) * 100 : 0;

  return {
    totalBerths,
    soldPct,
    soldPctDelta:
      soldAtStart !== null && totalBerths > 0 ? soldPct - (soldAtStart / totalBerths) * 100 : null,
    underOfferPct,
    underOfferPctDelta:
      underOfferAtStart !== null && totalBerths > 0
        ? underOfferPct - (underOfferAtStart / totalBerths) * 100
        : null,
    tenanciesModuleEnabled: tenanciesEnabled,
    activeTenancies: tenanciesEnabled ? activeTenancies : null,
    avgTenancyLengthYears: tenanciesEnabled ? avgTenancyLength : null,
    signingTurnaround,
    berthsInConflict: conflicts,
  };
}

// ─── Chart 1 — Berth utilisation heatmap ─────────────────────────────────────

export interface UtilisationCell {
  area: string;
  month: string; // YYYY-MM
  occupancyPct: number;
}

/**
 * Per-area, per-month occupancy % over the requested range. Defaults to
 * trailing 24 months when no range given. Reconstructs historical
 * occupancy from `audit_logs` of `berths.status` changes.
 */
export async function getUtilisationHeatmap(
  portId: string,
  months = 24,
  filters?: OperationalFilters,
): Promise<UtilisationCell[]> {
  // For each month buckets we walk all berths and compute the
  // share that was 'sold' or 'under_offer' at month-end. To keep
  // this simple + correct we read all status-change events for the
  // port, sort by created_at per berth, and apply at each month
  // boundary. The audit-log approach is the only source of truth
  // since `berths.status` is just the latest value.
  const fromIso = monthsAgoIso(months);
  const monthsList = generateMonthList(months);

  // Current snapshot of berths to know which areas exist
  const berthRows = await db
    .select({ id: berths.id, area: berths.area, status: berths.status })
    .from(berths)
    .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)));
  if (berthRows.length === 0) return [];

  const areaSet = new Set<string>();
  berthRows.forEach((b) => areaSet.add(b.area ?? '—'));
  const areas = Array.from(areaSet).sort();

  // Replay history: for each berth, get all status changes from audit
  // logs in chronological order. We construct a per-berth timeline of
  // (timestamp, status).
  const auditRows = await db.execute<{
    entity_id: string;
    created_at: string;
    new_status: string | null;
  }>(sql`
    SELECT
      entity_id,
      created_at,
      new_value->>'status' AS new_status
    FROM audit_logs
    WHERE entity_type = 'berth'
      AND port_id = ${portId}
      AND created_at >= ${fromIso}::timestamptz
      AND new_value ? 'status'
    ORDER BY entity_id, created_at ASC
  `);

  const byBerth = new Map<string, Array<{ at: Date; status: string }>>();
  for (const r of auditRows as unknown as Array<{
    entity_id: string;
    created_at: string;
    new_status: string | null;
  }>) {
    if (!r.new_status) continue;
    const list = byBerth.get(r.entity_id) ?? [];
    list.push({ at: new Date(r.created_at), status: r.new_status });
    byBerth.set(r.entity_id, list);
  }

  // For each month-end, walk berths and infer status: pick the most
  // recent change ≤ month-end. If no audit history, fall back to
  // current status.
  const result: UtilisationCell[] = [];
  for (const month of monthsList) {
    const monthEnd = new Date(`${month}-28T23:59:59.999Z`);
    // Use last-day-of-month: set to the next month's 1st, subtract 1ms
    const lastDay = new Date(monthEnd);
    lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 1);
    lastDay.setUTCMilliseconds(-1);

    // Aggregate per-area occupancy at this month's end
    const byArea = new Map<string, { occupied: number; total: number }>();
    for (const berth of berthRows) {
      const area = berth.area ?? '—';
      const acc = byArea.get(area) ?? { occupied: 0, total: 0 };
      acc.total += 1;
      // Determine status at lastDay
      const history = byBerth.get(berth.id) ?? [];
      let statusAtMonth = berth.status; // default to current if no history
      for (let i = history.length - 1; i >= 0; i--) {
        if (history[i]!.at <= lastDay) {
          statusAtMonth = history[i]!.status;
          break;
        }
      }
      // If we have any history but none ≤ lastDay, the berth didn't
      // exist yet at that month — skip
      if (history.length > 0 && history[0]!.at > lastDay) {
        acc.total -= 1;
      } else if (statusAtMonth === 'sold' || statusAtMonth === 'under_offer') {
        acc.occupied += 1;
      }
      byArea.set(area, acc);
    }

    for (const area of areas) {
      const acc = byArea.get(area);
      const occupancyPct = acc && acc.total > 0 ? (acc.occupied / acc.total) * 100 : 0;
      result.push({ area, month, occupancyPct });
    }
  }
  return result;
}

// ─── Chart 2 — Status mix over time ──────────────────────────────────────────

export interface StatusMixPoint {
  month: string;
  available: number;
  underOffer: number;
  sold: number;
}

/**
 * Port-wide status mix per month over the trailing N months. Uses the
 * same audit-log replay as the heatmap; returned in monthly buckets so
 * the UI can render either the proportional (100%-stacked) or absolute
 * view (locked decision: toggleable).
 */
export async function getStatusMixOverTime(portId: string, months = 24): Promise<StatusMixPoint[]> {
  // Reuse the heatmap query then aggregate across areas
  const cells = await getUtilisationHeatmap(portId, months);
  const byMonth = new Map<string, StatusMixPoint>();
  // Re-fetch berth + audit rows here to get exact per-status counts
  // (heatmap only reports occupancy pct, not the breakdown).
  const berthRows = await db
    .select({ id: berths.id, status: berths.status })
    .from(berths)
    .where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
  if (berthRows.length === 0)
    return cells.map((c) => ({ month: c.month, available: 0, underOffer: 0, sold: 0 }));

  const fromIso = monthsAgoIso(months);
  const auditRows = await db.execute<{
    entity_id: string;
    created_at: string;
    new_status: string | null;
  }>(sql`
    SELECT
      entity_id,
      created_at,
      new_value->>'status' AS new_status
    FROM audit_logs
    WHERE entity_type = 'berth'
      AND port_id = ${portId}
      AND created_at >= ${fromIso}::timestamptz
      AND new_value ? 'status'
    ORDER BY entity_id, created_at ASC
  `);

  const byBerth = new Map<string, Array<{ at: Date; status: string }>>();
  for (const r of auditRows as unknown as Array<{
    entity_id: string;
    created_at: string;
    new_status: string | null;
  }>) {
    if (!r.new_status) continue;
    const list = byBerth.get(r.entity_id) ?? [];
    list.push({ at: new Date(r.created_at), status: r.new_status });
    byBerth.set(r.entity_id, list);
  }

  const monthsList = generateMonthList(months);
  for (const month of monthsList) {
    const lastDay = new Date(`${month}-28T23:59:59.999Z`);
    lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 1);
    lastDay.setUTCMilliseconds(-1);

    const counts = { available: 0, underOffer: 0, sold: 0 };
    for (const berth of berthRows) {
      const history = byBerth.get(berth.id) ?? [];
      let statusAtMonth = berth.status;
      let existedAtMonth = true;
      for (let i = history.length - 1; i >= 0; i--) {
        if (history[i]!.at <= lastDay) {
          statusAtMonth = history[i]!.status;
          break;
        }
      }
      if (history.length > 0 && history[0]!.at > lastDay) {
        existedAtMonth = false;
      }
      if (!existedAtMonth) continue;
      if (statusAtMonth === 'sold') counts.sold += 1;
      else if (statusAtMonth === 'under_offer') counts.underOffer += 1;
      else counts.available += 1;
    }
    byMonth.set(month, { month, ...counts });
  }
  return Array.from(byMonth.values());
}

// ─── Chart 3 — Tenancy churn waterfall ───────────────────────────────────────

export interface TenancyChurnPoint {
  bucket: string; // YYYY-MM or YYYY-Qn
  newActive: number;
  ended: number;
  netDelta: number;
}

/**
 * Per-bucket churn: new active tenancies vs ended ones. Bucket
 * auto-picked monthly vs quarterly per locked decision (monthly if
 * avg > 2 events/month, else quarterly).
 */
export async function getTenancyChurn(
  portId: string,
  months = 24,
): Promise<{ granularity: 'month' | 'quarter'; points: TenancyChurnPoint[] }> {
  const rows = await db
    .select({
      id: berthTenancies.id,
      startDate: berthTenancies.startDate,
      endDate: berthTenancies.endDate,
    })
    .from(berthTenancies)
    .where(
      and(eq(berthTenancies.portId, portId), gte(berthTenancies.startDate, monthsAgoDate(months))),
    );

  // Count events per month → decide granularity
  const monthsList = generateMonthList(months);
  const byMonth = new Map<string, { newActive: number; ended: number }>();
  for (const month of monthsList) byMonth.set(month, { newActive: 0, ended: 0 });

  for (const r of rows) {
    if (r.startDate) {
      const m = `${r.startDate.getUTCFullYear()}-${String(r.startDate.getUTCMonth() + 1).padStart(2, '0')}`;
      const acc = byMonth.get(m);
      if (acc) acc.newActive += 1;
    }
    if (r.endDate) {
      const m = `${r.endDate.getUTCFullYear()}-${String(r.endDate.getUTCMonth() + 1).padStart(2, '0')}`;
      const acc = byMonth.get(m);
      if (acc) acc.ended += 1;
    }
  }

  const totalEvents = rows.length * 2; // start + end
  const avgPerMonth = totalEvents / monthsList.length;
  const granularity: 'month' | 'quarter' = avgPerMonth > 2 ? 'month' : 'quarter';

  if (granularity === 'month') {
    return {
      granularity,
      points: Array.from(byMonth.entries()).map(([bucket, c]) => ({
        bucket,
        newActive: c.newActive,
        ended: c.ended,
        netDelta: c.newActive - c.ended,
      })),
    };
  }

  // Aggregate to quarterly
  const byQuarter = new Map<string, { newActive: number; ended: number }>();
  for (const [month, c] of byMonth) {
    const [year, monthNum] = month.split('-');
    if (!year || !monthNum) continue;
    const quarter = Math.ceil(parseInt(monthNum) / 3);
    const key = `${year}-Q${quarter}`;
    const acc = byQuarter.get(key) ?? { newActive: 0, ended: 0 };
    acc.newActive += c.newActive;
    acc.ended += c.ended;
    byQuarter.set(key, acc);
  }
  return {
    granularity,
    points: Array.from(byQuarter.entries()).map(([bucket, c]) => ({
      bucket,
      newActive: c.newActive,
      ended: c.ended,
      netDelta: c.newActive - c.ended,
    })),
  };
}

// ─── Chart 4 — Tenure distribution ───────────────────────────────────────────

export interface TenureBucket {
  label: string;
  count: number;
}

/**
 * Histogram of ended-tenancy lengths bucketed per locked marina-tuned
 * boundaries (<1y / 1-5y / 5-10y / 10-20y / 20y+).
 */
export async function getTenureDistribution(portId: string): Promise<TenureBucket[]> {
  const rows = await db
    .select({ startDate: berthTenancies.startDate, endDate: berthTenancies.endDate })
    .from(berthTenancies)
    .where(
      and(
        eq(berthTenancies.portId, portId),
        eq(berthTenancies.status, 'ended'),
        isNotNull(berthTenancies.endDate),
      ),
    );

  const buckets: Record<string, number> = {
    '<1y': 0,
    '1-5y': 0,
    '5-10y': 0,
    '10-20y': 0,
    '20y+': 0,
  };
  for (const r of rows) {
    if (!r.startDate || !r.endDate) continue;
    const years = (r.endDate.getTime() - r.startDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
    if (years < 1) buckets['<1y']! += 1;
    else if (years < 5) buckets['1-5y']! += 1;
    else if (years < 10) buckets['5-10y']! += 1;
    else if (years < 20) buckets['10-20y']! += 1;
    else buckets['20y+']! += 1;
  }
  return Object.entries(buckets).map(([label, count]) => ({ label, count }));
}

// ─── Chart 5 — Signing turnaround box plot ──────────────────────────────────

export interface SigningBoxPlot {
  documentType: string;
  // Five-number summary (min / Q1 / median / Q3 / max) for box plot
  min: number;
  q1: number;
  median: number;
  q3: number;
  max: number;
  sampleSize: number;
}

/**
 * Per-document-type signing turnaround distribution. Box-plot ready:
 * five-number summary per type.
 */
export async function getSigningBoxPlot(portId: string): Promise<SigningBoxPlot[]> {
  const rows = await db.execute<{
    document_type: string;
    days_to_complete: number;
  }>(sql`
    WITH sent_evt AS (
      SELECT document_id, MIN(created_at) AS sent_at
      FROM document_events
      WHERE event_type = 'sent'
      GROUP BY document_id
    ),
    completed_evt AS (
      SELECT document_id, MAX(created_at) AS completed_at
      FROM document_events
      WHERE event_type = 'completed'
      GROUP BY document_id
    )
    SELECT
      d.document_type,
      EXTRACT(EPOCH FROM (ce.completed_at - se.sent_at)) / 86400 AS days_to_complete
    FROM documents d
    INNER JOIN sent_evt se ON se.document_id = d.id
    INNER JOIN completed_evt ce ON ce.document_id = d.id
    WHERE d.port_id = ${portId}
      AND d.status NOT IN ('cancelled', 'expired')
      AND ce.completed_at > se.sent_at
  `);

  const byType = new Map<string, number[]>();
  for (const r of rows as unknown as Array<{ document_type: string; days_to_complete: number }>) {
    const days = Number(r.days_to_complete);
    if (!Number.isFinite(days) || days < 0) continue;
    const list = byType.get(r.document_type) ?? [];
    list.push(days);
    byType.set(r.document_type, list);
  }

  const result: SigningBoxPlot[] = [];
  for (const [docType, samples] of byType) {
    if (samples.length === 0) continue;
    const sorted = samples.slice().sort((a, b) => a - b);
    result.push({
      documentType: docType,
      min: sorted[0]!,
      q1: percentile(sorted, 0.25),
      median: percentile(sorted, 0.5),
      q3: percentile(sorted, 0.75),
      max: sorted[sorted.length - 1]!,
      sampleSize: sorted.length,
    });
  }
  return result;
}

// ─── Chart 6 — Occupancy by area ────────────────────────────────────────────

export interface AreaOccupancyRow {
  area: string;
  available: number;
  underOffer: number;
  sold: number;
  total: number;
}

export async function getOccupancyByArea(
  portId: string,
  filters?: OperationalFilters,
): Promise<AreaOccupancyRow[]> {
  const rows = await db
    .select({
      area: berths.area,
      status: berths.status,
      n: sql<number>`count(*)::int`,
    })
    .from(berths)
    .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)))
    .groupBy(berths.area, berths.status);

  const byArea = new Map<string, AreaOccupancyRow>();
  for (const r of rows) {
    const area = r.area ?? '—';
    const acc =
      byArea.get(area) ??
      ({ area, available: 0, underOffer: 0, sold: 0, total: 0 } satisfies AreaOccupancyRow);
    const n = Number(r.n);
    if (r.status === 'sold') acc.sold += n;
    else if (r.status === 'under_offer') acc.underOffer += n;
    else acc.available += n;
    acc.total = acc.available + acc.underOffer + acc.sold;
    byArea.set(area, acc);
  }
  return Array.from(byArea.values()).sort((a, b) => b.total - a.total);
}

// ─── Chart 7 — Documents in pipeline ────────────────────────────────────────

export interface DocsPipelineRow {
  documentType: string;
  draft: number;
  sent: number;
  partiallySigned: number;
  completed: number;
  declined: number;
  voided: number;
}

export async function getDocumentsInPipeline(portId: string): Promise<DocsPipelineRow[]> {
  const rows = await db
    .select({
      documentType: documents.documentType,
      status: documents.status,
      n: sql<number>`count(*)::int`,
    })
    .from(documents)
    .where(eq(documents.portId, portId))
    .groupBy(documents.documentType, documents.status);

  const byType = new Map<string, DocsPipelineRow>();
  for (const r of rows) {
    const acc =
      byType.get(r.documentType) ??
      ({
        documentType: r.documentType,
        draft: 0,
        sent: 0,
        partiallySigned: 0,
        completed: 0,
        declined: 0,
        voided: 0,
      } satisfies DocsPipelineRow);
    const n = Number(r.n);
    if (r.status === 'draft') acc.draft += n;
    else if (r.status === 'sent') acc.sent += n;
    else if (r.status === 'partially_signed') acc.partiallySigned += n;
    else if (r.status === 'completed' || r.status === 'signed') acc.completed += n;
    else if (r.status === 'declined') acc.declined += n;
    else if (r.status === 'cancelled' || r.status === 'expired') acc.voided += n;
    byType.set(r.documentType, acc);
  }
  return Array.from(byType.values()).sort((a, b) => a.documentType.localeCompare(b.documentType));
}

// ─── Tables ─────────────────────────────────────────────────────────────────

export interface EndingSoonRow {
  id: string;
  clientName: string;
  primaryBerth: string | null;
  tenureType: string;
  endDate: string;
  daysUntilEnd: number;
}

export async function getTenanciesEndingSoon(
  portId: string,
  monthsAhead = 6,
): Promise<EndingSoonRow[]> {
  const cutoff = new Date();
  cutoff.setMonth(cutoff.getMonth() + monthsAhead);
  const now = new Date();

  const rows = await db
    .select({
      id: berthTenancies.id,
      clientName: clients.fullName,
      mooring: berths.mooringNumber,
      tenureType: berthTenancies.tenureType,
      endDate: berthTenancies.endDate,
    })
    .from(berthTenancies)
    .innerJoin(clients, eq(berthTenancies.clientId, clients.id))
    .innerJoin(berths, eq(berthTenancies.berthId, berths.id))
    .where(
      and(
        eq(berthTenancies.portId, portId),
        eq(berthTenancies.status, 'active'),
        isNotNull(berthTenancies.endDate),
        lte(berthTenancies.endDate, cutoff),
        gte(berthTenancies.endDate, now),
      ),
    )
    .orderBy(berthTenancies.endDate);

  return rows
    .filter((r) => r.endDate !== null)
    .map((r) => ({
      id: r.id,
      clientName: r.clientName,
      primaryBerth: r.mooring,
      tenureType: r.tenureType ?? 'permanent',
      endDate: r.endDate!.toISOString(),
      daysUntilEnd: Math.floor((r.endDate!.getTime() - now.getTime()) / 86400000),
    }));
}

export interface VacantBerthRow {
  id: string;
  mooring: string;
  area: string | null;
  dimensions: string;
  price: number | null;
  currency: string;
  daysAvailable: number | null;
}

export async function getVacantBerths(
  portId: string,
  minDaysAvailable = 60,
  filters?: OperationalFilters,
): Promise<VacantBerthRow[]> {
  const now = Date.now();
  const rows = await db
    .select({
      id: berths.id,
      mooring: berths.mooringNumber,
      area: berths.area,
      lengthFt: berths.lengthFt,
      widthFt: berths.widthFt,
      price: berths.price,
      currency: berths.priceCurrency,
      statusLastModified: berths.statusLastModified,
    })
    .from(berths)
    .where(
      and(
        eq(berths.portId, portId),
        eq(berths.status, 'available'),
        isNull(berths.archivedAt),
        areaCond(filters),
      ),
    )
    .orderBy(berths.mooringNumber);

  const result: VacantBerthRow[] = [];
  for (const r of rows) {
    const daysAvailable = r.statusLastModified
      ? Math.floor((now - r.statusLastModified.getTime()) / 86400000)
      : null;
    if (daysAvailable !== null && daysAvailable < minDaysAvailable) continue;
    const length = parseFloat(r.lengthFt ?? '0');
    const width = parseFloat(r.widthFt ?? '0');
    const dimensions = length && width ? `${length.toFixed(0)}'×${width.toFixed(0)}'` : '—';
    result.push({
      id: r.id,
      mooring: r.mooring,
      area: r.area,
      dimensions,
      price: r.price ? parseFloat(r.price) : null,
      currency: r.currency ?? 'USD',
      daysAvailable,
    });
  }
  return result;
}

export interface StuckSigningRow {
  id: string;
  documentType: string;
  title: string;
  clientName: string | null;
  sentAt: string;
  daysOutstanding: number;
}

const STUCK_SIGNING_THRESHOLD: Record<string, number> = {
  eoi: 10,
  reservation_agreement: 7,
  contract: 5,
};

/**
 * Documents currently 'sent' (or 'partially_signed') whose elapsed
 * time exceeds the doc-type-specific threshold. Mirrors the Sales
 * stalled-deals pattern.
 */
export async function getStuckSigning(portId: string): Promise<StuckSigningRow[]> {
  const now = Date.now();
  const rows = await db.execute<{
    id: string;
    document_type: string;
    title: string;
    sent_at: string;
    client_name: string | null;
  }>(sql`
    SELECT
      d.id,
      d.document_type,
      d.title,
      c.full_name AS client_name,
      MIN(de.created_at) AS sent_at
    FROM documents d
    INNER JOIN document_events de ON de.document_id = d.id AND de.event_type = 'sent'
    LEFT JOIN clients c ON c.id = d.client_id
    WHERE d.port_id = ${portId}
      AND d.status IN ('sent', 'partially_signed')
    GROUP BY d.id, d.document_type, d.title, c.full_name
  `);

  const result: StuckSigningRow[] = [];
  for (const r of rows as unknown as Array<{
    id: string;
    document_type: string;
    title: string;
    sent_at: string;
    client_name: string | null;
  }>) {
    const sentAt = new Date(r.sent_at);
    const daysOutstanding = Math.floor((now - sentAt.getTime()) / 86400000);
    const threshold = STUCK_SIGNING_THRESHOLD[r.document_type] ?? 10;
    if (daysOutstanding < threshold) continue;
    result.push({
      id: r.id,
      documentType: r.document_type,
      title: r.title,
      clientName: r.client_name,
      sentAt: sentAt.toISOString(),
      daysOutstanding,
    });
  }
  return result.sort((a, b) => b.daysOutstanding - a.daysOutstanding);
}

export interface HighestValueVacantRow {
  id: string;
  mooring: string;
  area: string | null;
  dimensions: string;
  price: number;
  currency: string;
  daysAvailable: number | null;
}

/** Top vacant berths by price desc. The "what's our most expensive
 *  empty inventory" sales focus list. */
export async function getHighestValueVacant(
  portId: string,
  limit = 10,
  filters?: OperationalFilters,
): Promise<HighestValueVacantRow[]> {
  const now = Date.now();
  const rows = await db
    .select({
      id: berths.id,
      mooring: berths.mooringNumber,
      area: berths.area,
      lengthFt: berths.lengthFt,
      widthFt: berths.widthFt,
      price: berths.price,
      currency: berths.priceCurrency,
      statusLastModified: berths.statusLastModified,
    })
    .from(berths)
    .where(
      and(
        eq(berths.portId, portId),
        eq(berths.status, 'available'),
        isNull(berths.archivedAt),
        isNotNull(berths.price),
        areaCond(filters),
      ),
    )
    .orderBy(desc(berths.price))
    .limit(limit);

  return rows
    .filter((r) => r.price)
    .map((r) => {
      const length = parseFloat(r.lengthFt ?? '0');
      const width = parseFloat(r.widthFt ?? '0');
      const dimensions = length && width ? `${length.toFixed(0)}'×${width.toFixed(0)}'` : '—';
      return {
        id: r.id,
        mooring: r.mooring,
        area: r.area,
        dimensions,
        price: parseFloat(r.price!),
        currency: r.currency ?? 'USD',
        daysAvailable: r.statusLastModified
          ? Math.floor((now - r.statusLastModified.getTime()) / 86400000)
          : null,
      };
    });
}

/**
 * Distinct, non-null berth areas for the Operational report's Area filter.
 * Mirrors `getRepFilterOptions` in sales.service.ts. The FilterBar hides
 * the Area control when this is empty, so ports with no areas defined never
 * see it.
 */
export async function getOperationalAreaOptions(portId: string): Promise<string[]> {
  const rows = await db
    .selectDistinct({ area: berths.area })
    .from(berths)
    .where(and(eq(berths.portId, portId), isNotNull(berths.area), isNull(berths.archivedAt)))
    .orderBy(berths.area);
  return rows.map((r) => r.area).filter((a): a is string => a !== null);
}

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

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

async function countActiveBerths(portId: string, filters?: OperationalFilters): Promise<number> {
  const [row] = await db
    .select({ value: sql<number>`count(*)::int` })
    .from(berths)
    .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)));
  return row?.value ?? 0;
}

async function countBerthsByStatusNow(
  portId: string,
  status: string,
  filters?: OperationalFilters,
): Promise<number> {
  const [row] = await db
    .select({ value: sql<number>`count(*)::int` })
    .from(berths)
    .where(
      and(
        eq(berths.portId, portId),
        isNull(berths.archivedAt),
        eq(berths.status, status),
        areaCond(filters),
      ),
    );
  return row?.value ?? 0;
}

/**
 * Reconstruct the count of berths in a given status at a historical
 * timestamp by replaying audit-log status changes. Returns null when
 * the audit history is too sparse to reconstruct (no rows before the
 * timestamp).
 */
async function countBerthsByStatusAtTimestamp(
  portId: string,
  targetStatus: string,
  at: Date,
  filters?: OperationalFilters,
): Promise<number | null> {
  const berthRows = await db
    .select({ id: berths.id, status: berths.status, createdAt: berths.createdAt })
    .from(berths)
    .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters)));

  const auditRows = await db.execute<{
    entity_id: string;
    created_at: string;
    new_status: string | null;
  }>(sql`
    SELECT
      entity_id,
      created_at,
      new_value->>'status' AS new_status
    FROM audit_logs
    WHERE entity_type = 'berth'
      AND port_id = ${portId}
      AND new_value ? 'status'
      AND created_at <= ${at.toISOString()}::timestamptz
    ORDER BY entity_id, created_at DESC
  `);

  const latestByBerth = new Map<string, string>();
  for (const r of auditRows as unknown as Array<{
    entity_id: string;
    new_status: string | null;
  }>) {
    if (!latestByBerth.has(r.entity_id) && r.new_status) {
      latestByBerth.set(r.entity_id, r.new_status);
    }
  }

  let count = 0;
  for (const b of berthRows) {
    // Skip berths that didn't exist at the target time
    if (b.createdAt > at) continue;
    const statusAt = latestByBerth.get(b.id) ?? b.status;
    if (statusAt === targetStatus) count += 1;
  }
  return count;
}

async function countActiveTenancies(portId: string): Promise<number> {
  const [row] = await db
    .select({ value: sql<number>`count(*)::int` })
    .from(berthTenancies)
    .where(and(eq(berthTenancies.portId, portId), eq(berthTenancies.status, 'active')));
  return row?.value ?? 0;
}

async function medianTenancyLengthYears(portId: string): Promise<number | null> {
  const [row] = await db.execute<{ median: number | null; n: number }>(sql`
    SELECT
      percentile_cont(0.5) WITHIN GROUP (
        ORDER BY EXTRACT(EPOCH FROM (end_date - start_date)) / (365.25 * 86400)
      ) AS median,
      COUNT(*)::int AS n
    FROM berth_tenancies
    WHERE port_id = ${portId}
      AND status = 'ended'
      AND end_date IS NOT NULL
      AND start_date IS NOT NULL
  `);
  const n = Number(row?.n ?? 0);
  if (n < 3) return null;
  const median = row?.median;
  return median !== null && median !== undefined ? Number(median) : null;
}

async function perTypeSigningTurnaround(
  portId: string,
): Promise<Array<{ documentType: string; medianDays: number | null; sampleSize: number }>> {
  const rows = await db.execute<{
    document_type: string;
    median_days: number | null;
    sample_size: number;
  }>(sql`
    WITH sent_evt AS (
      SELECT document_id, MIN(created_at) AS sent_at
      FROM document_events
      WHERE event_type = 'sent'
      GROUP BY document_id
    ),
    completed_evt AS (
      SELECT document_id, MAX(created_at) AS completed_at
      FROM document_events
      WHERE event_type = 'completed'
      GROUP BY document_id
    )
    SELECT
      d.document_type,
      percentile_cont(0.5) WITHIN GROUP (
        ORDER BY EXTRACT(EPOCH FROM (ce.completed_at - se.sent_at)) / 86400
      ) AS median_days,
      COUNT(*)::int AS sample_size
    FROM documents d
    INNER JOIN sent_evt se ON se.document_id = d.id
    INNER JOIN completed_evt ce ON ce.document_id = d.id
    WHERE d.port_id = ${portId}
      AND d.status NOT IN ('cancelled', 'expired')
      AND ce.completed_at > se.sent_at
    GROUP BY d.document_type
  `);

  return (
    rows as unknown as Array<{
      document_type: string;
      median_days: number | null;
      sample_size: number;
    }>
  ).map((r) => ({
    documentType: r.document_type,
    medianDays:
      r.median_days !== null && r.median_days !== undefined ? Number(r.median_days) : null,
    sampleSize: Number(r.sample_size),
  }));
}

async function countBerthsInConflict(portId: string): Promise<number> {
  const rows = await db.execute<{ n: number }>(sql`
    SELECT COUNT(*)::int AS n
    FROM (
      SELECT ib.berth_id
      FROM interest_berths ib
      INNER JOIN interests i ON i.id = ib.interest_id
      WHERE i.port_id = ${portId}
        AND i.archived_at IS NULL
        AND i.outcome IS NULL
        AND ib.is_specific_interest = true
      GROUP BY ib.berth_id
      HAVING COUNT(*) > 1
    ) conflicts
  `);
  return Number((rows as unknown as Array<{ n: number }>)[0]?.n ?? 0);
}

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;
}

function monthsAgoIso(months: number): string {
  const d = new Date();
  d.setMonth(d.getMonth() - months);
  return d.toISOString();
}

function monthsAgoDate(months: number): Date {
  const d = new Date();
  d.setMonth(d.getMonth() - months);
  return d;
}

function generateMonthList(months: number): string[] {
  const list: string[] = [];
  const cursor = new Date();
  cursor.setUTCDate(1);
  cursor.setUTCMonth(cursor.getUTCMonth() - months + 1);
  for (let i = 0; i < months; i++) {
    list.push(`${cursor.getUTCFullYear()}-${String(cursor.getUTCMonth() + 1).padStart(2, '0')}`);
    cursor.setUTCMonth(cursor.getUTCMonth() + 1);
  }
  return list;
}

export type { DateRange };
