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

import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients';
import { interestBerths, interests } from '@/lib/db/schema/interests';
import { tags } from '@/lib/db/schema/system';
import { PIPELINE_STAGES } from '@/lib/constants';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { diffEntity } from '@/lib/entity-diff';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { buildListQuery } from '@/lib/db/query-builder';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
import { sortByMooring } from '@/lib/utils/mooring-sort';
import type {
  CreateBerthInput,
  UpdateBerthInput,
  UpdateBerthStatusInput,
  UpdateBerthPriceInput,
  BulkUpdateBerthPricesInput,
  ListBerthsQuery,
  AddMaintenanceLogInput,
  UpdateMaintenanceLogInput,
  UpdateWaitingListInput,
} from '@/lib/validators/berths';

// ─── List ─────────────────────────────────────────────────────────────────────

export async function listBerths(portId: string, query: ListBerthsQuery) {
  const filters = [];

  if (query.status) {
    filters.push(eq(berths.status, query.status));
  }
  if (query.area) {
    filters.push(eq(berths.area, query.area));
  }
  if (query.minLength !== undefined) {
    filters.push(gte(berths.lengthM, String(query.minLength)));
  }
  if (query.maxLength !== undefined) {
    filters.push(lte(berths.lengthM, String(query.maxLength)));
  }
  if (query.minPrice !== undefined) {
    filters.push(gte(berths.price, String(query.minPrice)));
  }
  if (query.maxPrice !== undefined) {
    filters.push(lte(berths.price, String(query.maxPrice)));
  }
  if (query.tenureType) {
    filters.push(eq(berths.tenureType, query.tenureType));
  }

  // Tag filter: join against berthTags
  if (query.tagIds && query.tagIds.length > 0) {
    const tagIds = query.tagIds;
    const berthsWithTags = await db
      .selectDistinct({ berthId: berthTags.berthId })
      .from(berthTags)
      .where(inArray(berthTags.tagId, tagIds));
    const matchingIds = berthsWithTags.map((r) => r.berthId);
    if (matchingIds.length === 0) {
      return { data: [], total: 0 };
    }
    filters.push(inArray(berths.id, matchingIds));
  }

  // Default ordering is natural alphanumeric on mooring number
  // (A1, A2, A10, B1...) - Postgres' default lexicographic sort
  // would put A10 before A2, which is the wrong story for a marina
  // map. The mooring format is locked at `^[A-Z]+\d+$` so the regexp
  // splits are safe.
  const NATURAL_MOORING_SORT = [
    sql`regexp_replace(${berths.mooringNumber}, '[0-9]+$', '') ASC`,
    sql`(regexp_replace(${berths.mooringNumber}, '^[A-Z]+', ''))::int ASC`,
  ];

  const sortColumn = (() => {
    switch (query.sort) {
      case 'mooringNumber':
        // Honoured via customOrderBy below - caller asked for mooring
        // sort explicitly, give them the natural order.
        return null;
      case 'area':
        return berths.area;
      case 'price':
        return berths.price;
      case 'status':
        return berths.status;
      case 'lengthM':
        return berths.lengthM;
      case 'activeInterestCount':
        // Sorted via correlated subquery in customOrderBy below.
        return null;
      case 'latestInterestStage':
        // Sorted via correlated subquery in customOrderBy below - the
        // column doesn't exist on berths; it's the highest-ranked
        // active interest's pipeline stage per berth.
        return null;
      default:
        // No sort requested → natural mooring order is the friendliest
        // default for the berth grid (groups by pontoon letter).
        return null;
    }
  })();

  // Sort by active interest count via correlated subquery. Cheap at
  // marina scale (hundreds of berths × thousands of interests); revisit
  // with a LATERAL join if multi-port reporting ever hits this hot.
  const demandSort =
    query.sort === 'activeInterestCount'
      ? [
          sql`(
            SELECT COUNT(*)::int
            FROM ${interestBerths} ib
            INNER JOIN ${interests} i ON i.id = ib.interest_id
            WHERE ib.berth_id = ${berths.id}
              AND i.port_id = ${portId}
              AND i.archived_at IS NULL
              AND i.outcome IS NULL
          ) ${sql.raw(query.order === 'asc' ? 'ASC' : 'DESC')}`,
        ]
      : null;

  // Sort by highest active pipeline stage per berth. Berths with no
  // active interest get NULL; we land them at the bottom regardless of
  // direction by paired ORDER BY rank + NULLS LAST.
  const stageDirection = query.order === 'asc' ? 'ASC' : 'DESC';
  const stageSort =
    query.sort === 'latestInterestStage'
      ? [
          sql`(
            SELECT MAX(
              CASE i.pipeline_stage
                WHEN 'enquiry' THEN 1
                WHEN 'qualified' THEN 2
                WHEN 'nurturing' THEN 3
                WHEN 'eoi' THEN 4
                WHEN 'reservation' THEN 5
                WHEN 'deposit_paid' THEN 6
                WHEN 'contract' THEN 7
                ELSE 0
              END
            )
            FROM ${interestBerths} ib
            INNER JOIN ${interests} i ON i.id = ib.interest_id
            WHERE ib.berth_id = ${berths.id}
              AND i.port_id = ${portId}
              AND i.archived_at IS NULL
              AND i.outcome IS NULL
          ) ${sql.raw(stageDirection)} NULLS LAST`,
        ]
      : null;

  const result = await buildListQuery({
    table: berths,
    portIdColumn: berths.portId,
    portId,
    idColumn: berths.id,
    updatedAtColumn: berths.updatedAt,
    filters,
    sort: sortColumn ? { column: sortColumn, direction: query.order } : undefined,
    customOrderBy: stageSort ?? demandSort ?? (sortColumn ? undefined : NATURAL_MOORING_SORT),
    page: query.page,
    pageSize: query.limit,
    searchColumns: [berths.mooringNumber, berths.area],
    searchTerm: query.search,
    // berths.archivedAt + ?includeArchived flag landed in migration 0065.
    // Default the admin list to active-only; an `?includeArchived=true`
    // query string surfaces the archive bin for ops.
    archivedAtColumn: berths.archivedAt,
    includeArchived: Boolean(query.includeArchived),
  });

  // Attach tags for list items
  const berthIds = (result.data as Array<{ id: string }>).map((b) => b.id);
  const tagsByBerthId: Record<string, Array<{ id: string; name: string; color: string }>> = {};

  if (berthIds.length > 0) {
    const tagRows = await db
      .select({
        berthId: berthTags.berthId,
        id: tags.id,
        name: tags.name,
        color: tags.color,
      })
      .from(berthTags)
      .innerJoin(tags, eq(berthTags.tagId, tags.id))
      .where(inArray(berthTags.berthId, berthIds));

    for (const row of tagRows) {
      if (!tagsByBerthId[row.berthId]) tagsByBerthId[row.berthId] = [];
      tagsByBerthId[row.berthId]!.push({ id: row.id, name: row.name, color: row.color });
    }
  }

  const latestStageByBerthId = await getLatestInterestStageByBerth(berthIds, portId);
  const interestCountByBerthId = await getActiveInterestCountByBerth(berthIds, portId);

  const data = (result.data as Array<Record<string, unknown>>).map((b) => ({
    ...b,
    tags: tagsByBerthId[b.id as string] ?? [],
    latestInterestStage: latestStageByBerthId[b.id as string] ?? null,
    activeInterestCount: interestCountByBerthId[b.id as string] ?? 0,
  }));

  return { data, total: result.total };
}

/**
 * Per-berth active interest count. Mirrors the demand-sort subquery so
 * the column value and the sort key stay consistent.
 */
async function getActiveInterestCountByBerth(
  berthIds: string[],
  portId: string,
): Promise<Record<string, number>> {
  if (berthIds.length === 0) return {};
  const rows = await db
    .select({
      berthId: interestBerths.berthId,
      count: count(interests.id),
    })
    .from(interestBerths)
    .innerJoin(interests, eq(interestBerths.interestId, interests.id))
    .where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)))
    .groupBy(interestBerths.berthId);

  const map: Record<string, number> = {};
  for (const row of rows) {
    map[row.berthId] = Number(row.count);
  }
  return map;
}

/**
 * For each berth id, returns the most-advanced pipeline stage among its
 * linked active interests (outcome IS NULL, not archived). Used by the
 * berth list + detail to surface the deal furthest along on a berth so
 * reps can see at a glance whether a berth is "Reservation Sent" via
 * its connected interest, even though berth.status only tracks
 * available/under_offer/sold.
 */
async function getLatestInterestStageByBerth(
  berthIds: string[],
  portId: string,
): Promise<Record<string, string>> {
  if (berthIds.length === 0) return {};
  const rows = await db
    .select({
      berthId: interestBerths.berthId,
      pipelineStage: interests.pipelineStage,
    })
    .from(interestBerths)
    .innerJoin(interests, eq(interestBerths.interestId, interests.id))
    .where(and(activeInterestsWhere(portId), inArray(interestBerths.berthId, berthIds)));

  // Pipeline stages are an ordered enum - rank by position in PIPELINE_STAGES
  // so "contract_signed" beats "eoi_sent". Falls back to 0 for any unknown
  // legacy values so they're treated as least-advanced.
  const rankOf = (stage: string) => {
    const idx = (PIPELINE_STAGES as readonly string[]).indexOf(stage);
    return idx === -1 ? -1 : idx;
  };
  const top: Record<string, string> = {};
  for (const row of rows) {
    const current = top[row.berthId];
    if (!current || rankOf(row.pipelineStage) > rankOf(current)) {
      top[row.berthId] = row.pipelineStage;
    }
  }
  return top;
}

// ─── Get By ID ────────────────────────────────────────────────────────────────

export async function getBerthById(id: string, portId: string) {
  const berth = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
    with: {
      mapData: true,
    },
  });

  if (!berth) throw new NotFoundError('Berth');

  // Fetch tags
  const tagRows = await db
    .select({ id: tags.id, name: tags.name, color: tags.color })
    .from(berthTags)
    .innerJoin(tags, eq(berthTags.tagId, tags.id))
    .where(eq(berthTags.berthId, id));

  const latestStageMap = await getLatestInterestStageByBerth([id], portId);

  return {
    ...berth,
    tags: tagRows,
    latestInterestStage: latestStageMap[id] ?? null,
  };
}

// ─── Update ───────────────────────────────────────────────────────────────────

export async function updateBerth(
  id: string,
  portId: string,
  data: UpdateBerthInput,
  meta: AuditMeta,
) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  const { changed, diff } = diffEntity(
    existing as Record<string, unknown>,
    data as Record<string, unknown>,
  );

  if (!changed) return existing;

  // Drizzle numeric columns expect string | null - coerce numbers to strings
  const n = (v: number | undefined) => (v !== undefined ? String(v) : undefined);

  const [updated] = await db
    .update(berths)
    .set({
      area: data.area,
      lengthFt: n(data.lengthFt),
      lengthM: n(data.lengthM),
      widthFt: n(data.widthFt),
      widthM: n(data.widthM),
      draftFt: n(data.draftFt),
      draftM: n(data.draftM),
      widthIsMinimum: data.widthIsMinimum,
      nominalBoatSize: n(data.nominalBoatSize),
      nominalBoatSizeM: n(data.nominalBoatSizeM),
      waterDepth: n(data.waterDepth),
      waterDepthM: n(data.waterDepthM),
      waterDepthIsMinimum: data.waterDepthIsMinimum,
      sidePontoon: data.sidePontoon,
      powerCapacity: n(data.powerCapacity),
      voltage: n(data.voltage),
      mooringType: data.mooringType,
      cleatType: data.cleatType,
      cleatCapacity: data.cleatCapacity,
      bollardType: data.bollardType,
      bollardCapacity: data.bollardCapacity,
      access: data.access,
      price: n(data.price),
      priceCurrency: data.priceCurrency,
      bowFacing: data.bowFacing,
      berthApproved: data.berthApproved,
      tenureType: data.tenureType,
      tenureYears: data.tenureYears,
      tenureStartDate: data.tenureStartDate,
      tenureEndDate: data.tenureEndDate,
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, id), eq(berths.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth',
    entityId: id,
    oldValue: toAuditJson(diff),
    newValue: toAuditJson(data),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:updated', {
    berthId: id,
    changedFields: Object.keys(diff),
  });

  void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
    dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
  );

  return updated!;
}

// ─── Update Price (single + bulk) ─────────────────────────────────────────────

/**
 * Update a single berth's price (+ optional currency). Gated upstream by
 * the `berths.update_prices` permission so sales reps can retune prices
 * without the full `berths.edit` surface. Writes a focused audit row
 * with `field_changed='price'` and the before/after values so the audit
 * log carries a clean price-change history.
 */
export async function updateBerthPrice(
  id: string,
  portId: string,
  data: UpdateBerthPriceInput,
  meta: AuditMeta,
) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  const oldPrice = existing.price;
  const oldCurrency = existing.priceCurrency;
  const newPrice = data.price === null ? null : String(data.price);
  const newCurrency = data.priceCurrency ?? oldCurrency;

  if (oldPrice === newPrice && oldCurrency === newCurrency) {
    return existing;
  }

  const [updated] = await db
    .update(berths)
    .set({
      price: newPrice,
      priceCurrency: newCurrency,
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, id), eq(berths.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth',
    entityId: id,
    fieldChanged: 'price',
    oldValue: toAuditJson({ price: oldPrice, priceCurrency: oldCurrency }),
    newValue: toAuditJson({ price: newPrice, priceCurrency: newCurrency }),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:updated', {
    berthId: id,
    changedFields: ['price'],
  });

  void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
    dispatchWebhookEvent(portId, 'berth:updated', { berthId: id }),
  );

  return updated!;
}

/**
 * Bulk berth price update. Loads all targeted rows in one query so we
 * can validate port ownership in the same trip, then writes each change
 * with a per-row audit entry. Berths that resolve to the same price are
 * skipped (counted as `unchanged` in the response) so retries are idempotent.
 *
 * Returns counts so the UI can present "updated N, skipped M, missing K".
 */
export async function bulkUpdateBerthPrices(
  portId: string,
  data: BulkUpdateBerthPricesInput,
  meta: AuditMeta,
) {
  const targetIds = data.updates.map((u) => u.berthId);
  const existingRows = await db
    .select({
      id: berths.id,
      price: berths.price,
      priceCurrency: berths.priceCurrency,
    })
    .from(berths)
    .where(and(eq(berths.portId, portId), inArray(berths.id, targetIds)));
  const byId = new Map(existingRows.map((b) => [b.id, b]));

  const missing: string[] = [];
  const updatedIds: string[] = [];
  const unchangedIds: string[] = [];

  await db.transaction(async (tx) => {
    for (const u of data.updates) {
      const existing = byId.get(u.berthId);
      if (!existing) {
        missing.push(u.berthId);
        continue;
      }
      const oldPrice = existing.price;
      const oldCurrency = existing.priceCurrency;
      const newPrice = u.price === null ? null : String(u.price);
      const newCurrency = u.priceCurrency ?? oldCurrency;

      if (oldPrice === newPrice && oldCurrency === newCurrency) {
        unchangedIds.push(u.berthId);
        continue;
      }

      await tx
        .update(berths)
        .set({
          price: newPrice,
          priceCurrency: newCurrency,
          updatedAt: new Date(),
        })
        .where(and(eq(berths.id, u.berthId), eq(berths.portId, portId)));

      void createAuditLog({
        userId: meta.userId,
        portId,
        action: 'update',
        entityType: 'berth',
        entityId: u.berthId,
        fieldChanged: 'price',
        oldValue: toAuditJson({ price: oldPrice, priceCurrency: oldCurrency }),
        newValue: toAuditJson({ price: newPrice, priceCurrency: newCurrency }),
        metadata: { source: 'bulk_update_prices', batchSize: data.updates.length },
        ipAddress: meta.ipAddress,
        userAgent: meta.userAgent,
      });

      updatedIds.push(u.berthId);
    }
  });

  // Realtime fan-out - one event per updated berth so any open list view
  // refetches the affected rows.
  for (const id of updatedIds) {
    emitToRoom(`port:${portId}`, 'berth:updated', {
      berthId: id,
      changedFields: ['price'],
    });
  }

  if (updatedIds.length > 0) {
    void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => {
      for (const id of updatedIds) {
        dispatchWebhookEvent(portId, 'berth:updated', { berthId: id });
      }
    });
  }

  return {
    updated: updatedIds.length,
    unchanged: unchangedIds.length,
    missing: missing.length,
    missingIds: missing,
  };
}

// ─── Update Status ────────────────────────────────────────────────────────────

export async function updateBerthStatus(
  id: string,
  portId: string,
  data: UpdateBerthStatusInput,
  meta: AuditMeta,
) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  // #67 Phase 1: stamp the source of this write so the reconciliation
  // queue (and the "Manual" chip on the row) can later distinguish a
  // human-set status from a rules-engine auto-set status. The rules
  // engine sets this to 'automated' on its own write path; user-facing
  // API hits always end up here.
  const [updated] = await db
    .update(berths)
    .set({
      status: data.status,
      statusLastChangedBy: meta.userId,
      statusLastChangedReason: data.reason,
      statusLastModified: new Date(),
      statusOverrideMode: 'manual',
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, id), eq(berths.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth',
    entityId: id,
    oldValue: { status: existing.status },
    newValue: { status: data.status, reason: data.reason },
    metadata: { type: 'status_change', reason: data.reason },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:statusChanged', {
    berthId: id,
    oldStatus: existing.status,
    newStatus: data.status,
    triggeredBy: meta.userId,
  });

  void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
    dispatchWebhookEvent(portId, 'berth:statusChanged', {
      berthId: id,
      oldStatus: existing.status,
      newStatus: data.status,
    }),
  );

  // Optional: link the chosen interest as the primary holder of this
  // berth. Cross-port checks live inside the helper so a malicious
  // interestId from another port can't slip past the status PATCH.
  if (data.interestId) {
    const { setPrimaryBerth } = await import('@/lib/services/interest-berths.service');
    await setPrimaryBerth(data.interestId, id);
  }

  // Next-in-line: when a berth frees up (any status -> available), surface
  // the #1 client on its waiting list to the staff who manage the queue.
  // Fire-and-forget via dynamic import (keeps the notifications stack out of
  // this module's static graph); dedupeKey collapses rapid re-fires.
  if (existing.status !== 'available' && data.status === 'available') {
    void import('@/lib/services/next-in-line-notify.service')
      .then(({ notifyWaitlistNextInLine }) =>
        notifyWaitlistNextInLine({
          portId,
          berthId: id,
          mooringNumber: existing.mooringNumber,
        }),
      )
      .catch(() => {});
  }

  return updated!;
}

// ─── Reset Manual Override ──────────────────────────────────────────────────
//
// "Reset to automatic": clears `statusOverrideMode` so the berth stops being a
// human-pinned status and resumes derived behaviour. The base `status` is left
// as-is (the rep can change it explicitly via updateBerthStatus) - the point of
// the reset is to drop the pin so the public-map derivation (specific-interest
// links, tenancies) and the rules engine govern the berth again. For the common
// case (pinned `available` with an active specific interest) this immediately
// flips the public map to "Under Offer" because `derivePublicStatus` no longer
// sees a manual override to honour.

export async function resetBerthStatusOverride(
  id: string,
  portId: string,
  reason: string,
  meta: AuditMeta,
) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');
  if (existing.statusOverrideMode !== 'manual') {
    throw new ValidationError('Berth is not in a manual-override state');
  }

  const [updated] = await db
    .update(berths)
    .set({
      statusOverrideMode: null,
      statusLastChangedBy: meta.userId,
      statusLastChangedReason: reason,
      statusLastModified: new Date(),
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, id), eq(berths.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth',
    entityId: id,
    oldValue: { statusOverrideMode: 'manual' },
    newValue: { statusOverrideMode: null, reason },
    metadata: { type: 'reset_manual_override', reason },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  // The displayed (derived) status may change the instant the pin drops, so
  // tell the open boards to refetch - same event the status PATCH emits.
  emitToRoom(`port:${portId}`, 'berth:statusChanged', {
    berthId: id,
    oldStatus: existing.status,
    newStatus: existing.status,
    triggeredBy: meta.userId,
  });

  void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
    dispatchWebhookEvent(portId, 'berth:statusChanged', {
      berthId: id,
      oldStatus: existing.status,
      newStatus: existing.status,
    }),
  );

  return updated!;
}

// ─── Reconciliation Queue ─────────────────────────────────────────────────────
//
// #67 Phase 3: surfaces every berth whose status was set manually (i.e.
// statusOverrideMode === 'manual') AND that has no active linked interest
// backing the status change. These are the rows the catch-up wizard
// targets - a rep flipped them to under_offer / sold without ever
// creating the matching deal. Sorted by status_last_modified DESC so the
// freshest manual flips show up first.

interface ReconcileRow {
  id: string;
  mooringNumber: string;
  area: string | null;
  status: string;
  statusLastChangedBy: string | null;
  statusLastChangedReason: string | null;
  statusLastModified: Date | null;
}

export async function listManualReconcileBerths(portId: string): Promise<{
  data: ReconcileRow[];
  total: number;
}> {
  // Use a NOT EXISTS subquery against interest_berths joined with the active
  // interests predicate so a berth currently linked to any open deal drops
  // out of the queue - even if the rep set the status manually first and
  // only later created the interest, that follow-up is the catch-up.
  const activeBerthIds = db
    .select({ berthId: interestBerths.berthId })
    .from(interestBerths)
    .innerJoin(interests, eq(interestBerths.interestId, interests.id))
    .where(activeInterestsWhere(portId));

  const rows = await db
    .select({
      id: berths.id,
      mooringNumber: berths.mooringNumber,
      area: berths.area,
      status: berths.status,
      statusLastChangedBy: berths.statusLastChangedBy,
      statusLastChangedReason: berths.statusLastChangedReason,
      statusLastModified: berths.statusLastModified,
    })
    .from(berths)
    .where(
      and(
        eq(berths.portId, portId),
        eq(berths.statusOverrideMode, 'manual'),
        isNull(berths.archivedAt),
        notInArray(berths.id, activeBerthIds),
      ),
    )
    .orderBy(desc(berths.statusLastModified));

  return { data: rows, total: rows.length };
}

// ─── Reconcile Manual Override ────────────────────────────────────────────────
//
// #67 Phase 1: called by the catch-up wizard once a backing interest is in
// place. Clears `statusOverrideMode` so the berth drops out of the
// reconciliation queue, and stamps the reason with the interest id so the
// audit trail records the reconciliation event explicitly.
//
// Intentionally NOT called from setPrimaryBerth/upsertInterestBerth - those
// run on every berth-link write (including drag-drop reorders that have
// nothing to do with a manual override) and would silently clear the flag
// behind the rep's back. Only the wizard owns the clear semantics.

export async function clearBerthOverride(
  berthId: string,
  portId: string,
  reconciledInterestId: string,
  meta: AuditMeta,
): Promise<void> {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  await db
    .update(berths)
    .set({
      statusOverrideMode: null,
      statusLastChangedReason: `Reconciled via interest ${reconciledInterestId}`,
      statusLastChangedBy: meta.userId,
      statusLastModified: new Date(),
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, berthId), eq(berths.portId, portId)));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth',
    entityId: berthId,
    oldValue: { statusOverrideMode: existing.statusOverrideMode ?? null },
    newValue: { statusOverrideMode: null, reconciledInterestId },
    metadata: { type: 'reconcile_manual', reconciledInterestId },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
}

// ─── Catch-up Reconcile ───────────────────────────────────────────────────────
//
// #67 Phase 4: orchestrates "rep set the berth manually, now create the
// backing interest so the row drops out of the reconciliation queue".
//
// Intentionally a thin orchestrator over the existing client / interest
// service helpers (each of which already runs in its own transaction
// with its own audit-log emit). We pull them together here so the API
// layer has a single call to make, but the actual work stays inside the
// already-tested helpers - wrapping ALL of this in one transaction would
// require restructuring the audit-log emits to be queued + flushed at
// commit, which is out of scope for this feature.

interface ReconcileBerthInput {
  clientId?: string;
  newClient?: { fullName: string; email?: string; phone?: string };
  yachtId?: string;
  pipelineStage: string;
  outcome?: 'won' | null;
  outcomeReason?: string;
}

export async function reconcileBerthWithNewInterest(
  berthId: string,
  portId: string,
  input: ReconcileBerthInput,
  meta: AuditMeta,
): Promise<{ interestId: string; clientId: string }> {
  const berth = await db.query.berths.findFirst({
    where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
  });
  if (!berth) throw new NotFoundError('Berth');
  if (berth.statusOverrideMode !== 'manual') {
    throw new ValidationError('Berth is not in a manual-override state');
  }

  // Lazy imports so this module doesn't pull in the entire interest/client
  // service surface (and create circular import chains).
  const [{ createClient }, { createInterest }] = await Promise.all([
    import('@/lib/services/clients.service'),
    import('@/lib/services/interests.service'),
  ]);

  let clientId = input.clientId;
  if (!clientId) {
    if (!input.newClient?.fullName) {
      throw new ValidationError('Either clientId or newClient.fullName is required');
    }
    const contacts: Array<{
      channel: 'email' | 'phone' | 'whatsapp' | 'other';
      value: string;
      isPrimary: boolean;
    }> = [];
    if (input.newClient.email) {
      contacts.push({ channel: 'email', value: input.newClient.email.trim(), isPrimary: true });
    }
    if (input.newClient.phone) {
      contacts.push({
        channel: 'phone',
        value: input.newClient.phone.trim(),
        isPrimary: contacts.length === 0,
      });
    }
    const created = await createClient(
      portId,
      {
        fullName: input.newClient.fullName.trim(),
        contacts,
        tagIds: [],
      } as unknown as Parameters<typeof createClient>[1],
      meta,
    );
    clientId = created.id;
  }

  const interest = await createInterest(
    portId,
    {
      clientId,
      yachtId: input.yachtId ?? null,
      berthId,
      pipelineStage: input.pipelineStage,
      outcome: input.outcome ?? null,
      outcomeReason: input.outcomeReason ?? null,
      assignedTo: meta.userId,
      tagIds: [],
    } as unknown as Parameters<typeof createInterest>[1],
    meta,
  );

  await clearBerthOverride(berthId, portId, interest.id, meta);

  return { interestId: interest.id, clientId: clientId! };
}

// ─── Set Tags ─────────────────────────────────────────────────────────────────

export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  const result = await setEntityTags({
    joinTable: berthTags,
    entityColumn: berthTags.berthId,
    tagColumn: berthTags.tagId,
    entityId: id,
    portId,
    tagIds,
    meta,
    entityType: 'berth',
  });

  return { berthId: result.entityId, tagIds: result.tagIds };
}

// ─── Add Maintenance Log ──────────────────────────────────────────────────────

export async function addMaintenanceLog(
  id: string,
  portId: string,
  data: AddMaintenanceLogInput,
  meta: AuditMeta,
) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  const rows = await db
    .insert(berthMaintenanceLog)
    .values({
      berthId: id,
      portId,
      category: data.category,
      description: data.description,
      cost: data.cost == null ? undefined : String(data.cost),
      costCurrency: data.costCurrency,
      responsibleParty: data.responsibleParty ?? undefined,
      performedDate: data.performedDate,
      photoFileIds: data.photoFileIds,
      createdBy: meta.userId,
    })
    .returning();

  const log = rows[0]!;

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'berth_maintenance_log',
    entityId: log.id,
    metadata: { berthId: id, category: data.category },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:maintenanceAdded', {
    berthId: id,
    logEntry: log,
  });

  return log;
}

// ─── Get Maintenance Logs ─────────────────────────────────────────────────────

export async function getMaintenanceLogs(id: string, portId: string) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  return db
    .select()
    .from(berthMaintenanceLog)
    .where(and(eq(berthMaintenanceLog.berthId, id), eq(berthMaintenanceLog.portId, portId)))
    .orderBy(desc(berthMaintenanceLog.performedDate));
}

// ─── Update Maintenance Log ───────────────────────────────────────────────────

export async function updateMaintenanceLog(
  berthId: string,
  logId: string,
  portId: string,
  data: UpdateMaintenanceLogInput,
  meta: AuditMeta,
) {
  // Tenant guard: the entry must belong to a berth in this port. port_id is
  // on the row itself, so a single (id, berth_id, port_id) match is the
  // defense-in-depth scope — no foreign-tenant row can be reached.
  const existing = await db.query.berthMaintenanceLog.findFirst({
    where: and(
      eq(berthMaintenanceLog.id, logId),
      eq(berthMaintenanceLog.berthId, berthId),
      eq(berthMaintenanceLog.portId, portId),
    ),
  });
  if (!existing) throw new NotFoundError('Maintenance log entry');

  const patch: Partial<typeof berthMaintenanceLog.$inferInsert> = { updatedAt: new Date() };
  if (data.category !== undefined) patch.category = data.category;
  if (data.description !== undefined) patch.description = data.description;
  if (data.cost !== undefined) patch.cost = data.cost === null ? null : String(data.cost);
  if (data.costCurrency !== undefined) patch.costCurrency = data.costCurrency;
  if (data.responsibleParty !== undefined) patch.responsibleParty = data.responsibleParty;
  if (data.performedDate !== undefined) patch.performedDate = data.performedDate;
  if (data.photoFileIds !== undefined) patch.photoFileIds = data.photoFileIds;

  const rows = await db
    .update(berthMaintenanceLog)
    .set(patch)
    .where(
      and(
        eq(berthMaintenanceLog.id, logId),
        eq(berthMaintenanceLog.berthId, berthId),
        eq(berthMaintenanceLog.portId, portId),
      ),
    )
    .returning();
  const log = rows[0]!;

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth_maintenance_log',
    entityId: log.id,
    metadata: { berthId, category: log.category },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:maintenanceUpdated', { berthId, logEntry: log });

  return log;
}

// ─── Delete Maintenance Log ───────────────────────────────────────────────────

export async function deleteMaintenanceLog(
  berthId: string,
  logId: string,
  portId: string,
  meta: AuditMeta,
) {
  const rows = await db
    .delete(berthMaintenanceLog)
    .where(
      and(
        eq(berthMaintenanceLog.id, logId),
        eq(berthMaintenanceLog.berthId, berthId),
        eq(berthMaintenanceLog.portId, portId),
      ),
    )
    .returning({ id: berthMaintenanceLog.id });
  if (rows.length === 0) throw new NotFoundError('Maintenance log entry');

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'delete',
    entityType: 'berth_maintenance_log',
    entityId: logId,
    metadata: { berthId },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:maintenanceRemoved', { berthId, logId });

  return { id: logId };
}

// ─── Get Waiting List ─────────────────────────────────────────────────────────

export async function getWaitingList(id: string, portId: string) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  // Left-join the client so the UI can render names (the queue stores only
  // clientId). A left join keeps a row even if the client was hard-deleted.
  return db
    .select({
      id: berthWaitingList.id,
      berthId: berthWaitingList.berthId,
      clientId: berthWaitingList.clientId,
      clientName: clients.fullName,
      yachtId: berthWaitingList.yachtId,
      position: berthWaitingList.position,
      priority: berthWaitingList.priority,
      notifyPref: berthWaitingList.notifyPref,
      notes: berthWaitingList.notes,
      createdAt: berthWaitingList.createdAt,
    })
    .from(berthWaitingList)
    .leftJoin(clients, eq(clients.id, berthWaitingList.clientId))
    .where(eq(berthWaitingList.berthId, id))
    .orderBy(berthWaitingList.position);
}

// ─── Update Waiting List ──────────────────────────────────────────────────────

export async function updateWaitingList(
  id: string,
  portId: string,
  data: UpdateWaitingListInput,
  meta: AuditMeta,
) {
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Berth');

  // Validate every supplied clientId belongs to portId. Without this
  // check, a port-A admin could insert port-B clientIds into the
  // waiting list - corrupting reportable data and creating a join
  // surface that hydrates foreign-tenant client rows.
  if (data.entries.length > 0) {
    const clientIds = [...new Set(data.entries.map((e) => e.clientId))];
    const validClients = await db
      .select({ id: clients.id })
      .from(clients)
      .where(and(inArray(clients.id, clientIds), eq(clients.portId, portId)));
    if (validClients.length !== clientIds.length) {
      throw new ValidationError('One or more clients are not in this port');
    }
  }

  // Replace entire waiting list
  await db.delete(berthWaitingList).where(eq(berthWaitingList.berthId, id));

  if (data.entries.length > 0) {
    await db.insert(berthWaitingList).values(
      data.entries.map((entry) => ({
        berthId: id,
        clientId: entry.clientId,
        position: entry.position,
        priority: entry.priority ?? 'normal',
        notifyPref: entry.notifyPref ?? 'email',
        notes: entry.notes,
      })),
    );
  }

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'berth',
    entityId: id,
    metadata: { type: 'waiting_list_updated', count: data.entries.length },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'berth:waitingListChanged', {
    berthId: id,
    action: 'replaced',
    entry: data.entries,
  });

  return data.entries;
}

// ─── Create ──────────────────────────────────────────────────────────────────

export async function createBerth(portId: string, data: CreateBerthInput, meta: AuditMeta) {
  // Check mooring number uniqueness within port
  const existing = await db.query.berths.findFirst({
    where: and(eq(berths.portId, portId), eq(berths.mooringNumber, data.mooringNumber)),
  });
  if (existing) {
    throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
  }

  // Caller-specified currency wins; otherwise inherit the port's admin-
  // configured default (system_settings.berths_default_currency, USD if
  // unset). Lets a multi-currency portfolio be modelled cleanly without
  // forcing reps to pick a currency on every new-berth form.
  const resolvedCurrency = data.priceCurrency ?? (await getPortBerthsDefaultCurrency(portId));

  const [berth] = await db
    .insert(berths)
    .values({
      portId,
      mooringNumber: data.mooringNumber,
      area: data.area,
      status: data.status ?? 'available',
      lengthFt: data.lengthFt?.toString(),
      lengthM: data.lengthM?.toString(),
      widthFt: data.widthFt?.toString(),
      widthM: data.widthM?.toString(),
      draftFt: data.draftFt?.toString(),
      draftM: data.draftM?.toString(),
      price: data.price?.toString(),
      priceCurrency: resolvedCurrency,
      tenureType: data.tenureType ?? 'permanent',
      mooringType: data.mooringType,
      powerCapacity: data.powerCapacity?.toString(),
      voltage: data.voltage?.toString(),
      access: data.access,
      bowFacing: data.bowFacing,
      sidePontoon: data.sidePontoon,
    })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'berth',
    entityId: berth!.id,
    newValue: { mooringNumber: berth!.mooringNumber, area: berth!.area },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'berth:created',
    message: `Berth "${berth!.mooringNumber}" created`,
    severity: 'info',
  });

  return berth!;
}

// ─── Bulk add ───────────────────────────────────────────────────────────────

export async function bulkAddBerths(
  portId: string,
  inputs: CreateBerthInput[],
  meta: AuditMeta,
): Promise<{ inserted: number; ids: string[] }> {
  // Input-level dedup: catch fat-finger duplicates in the wizard before
  // hitting the unique index.
  const seenMoorings = new Set<string>();
  for (const row of inputs) {
    if (seenMoorings.has(row.mooringNumber)) {
      throw new ConflictError(`Duplicate mooring number "${row.mooringNumber}" in input`);
    }
    seenMoorings.add(row.mooringNumber);
  }

  const moorings = inputs.map((r) => r.mooringNumber);
  const existing = await db
    .select({ mooringNumber: berths.mooringNumber })
    .from(berths)
    .where(and(eq(berths.portId, portId), inArray(berths.mooringNumber, moorings)));
  if (existing.length > 0) {
    throw new ConflictError(
      `Mooring numbers already exist in this port: ${existing.map((r) => r.mooringNumber).join(', ')}`,
    );
  }

  const defaultCurrency = await getPortBerthsDefaultCurrency(portId);
  const values = inputs.map((row) => ({
    portId,
    mooringNumber: row.mooringNumber,
    area: row.area,
    status: row.status ?? 'available',
    lengthFt: row.lengthFt?.toString(),
    lengthM: row.lengthM?.toString(),
    widthFt: row.widthFt?.toString(),
    widthM: row.widthM?.toString(),
    draftFt: row.draftFt?.toString(),
    draftM: row.draftM?.toString(),
    price: row.price?.toString(),
    priceCurrency: row.priceCurrency ?? defaultCurrency,
    tenureType: row.tenureType ?? 'permanent',
    mooringType: row.mooringType,
    powerCapacity: row.powerCapacity?.toString(),
    voltage: row.voltage?.toString(),
    access: row.access,
    bowFacing: row.bowFacing,
    sidePontoon: row.sidePontoon,
  }));

  const inserted = await db.insert(berths).values(values).returning({ id: berths.id });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'berth',
    entityId: 'bulk',
    newValue: { count: inserted.length, mooringNumbers: moorings },
    metadata: { type: 'bulk_add' },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'berth:bulk_created',
    message: `${inserted.length} berths added`,
    severity: 'info',
  });

  return { inserted: inserted.length, ids: inserted.map((r) => r.id) };
}

// ─── Archive / Restore ─────────────────────────────────────────────────────

/**
 * Post-audit F5: soft-archive replaces hard-delete. The previous
 * `db.delete()` permanently dropped the berth row + cascade-vanished
 * interest_berths links + broke historical audit references. Now the
 * row stays; `archived_at` shields it from default queries.
 *
 * Reasoning chain:
 *   1. Block if there's an active (non-archived, no-outcome) interest
 *      still linked - archiving with deals in flight breaks reports.
 *   2. Stamp archived_at + archived_by + archive_reason in a single update.
 *   3. Audit log captures the reason so /admin/audit shows the why.
 *   4. Emit a socket alert so any open berth-detail page bounces.
 */
export async function archiveBerth(
  id: string,
  portId: string,
  input: { reason: string },
  meta: AuditMeta,
) {
  const berth = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!berth) throw new NotFoundError('Berth');
  if (berth.archivedAt) {
    throw new ConflictError('Berth is already archived');
  }

  // Block archive when an active interest still depends on the berth -
  // forces the rep to resolve the deal first instead of orphaning it.
  const activeLink = await db
    .select({ interestId: interestBerths.interestId })
    .from(interestBerths)
    .innerJoin(interests, eq(interests.id, interestBerths.interestId))
    .where(
      and(eq(interestBerths.berthId, id), isNull(interests.archivedAt), isNull(interests.outcome)),
    )
    .limit(1);
  if (activeLink.length > 0) {
    throw new ConflictError(
      'Cannot archive a berth with an active interest. Resolve or archive the interest first.',
    );
  }

  await db
    .update(berths)
    .set({
      archivedAt: new Date(),
      archivedBy: meta.userId,
      archiveReason: input.reason,
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, id), eq(berths.portId, portId)));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'archive',
    entityType: 'berth',
    entityId: id,
    oldValue: { mooringNumber: berth.mooringNumber, area: berth.area, status: berth.status },
    newValue: { reason: input.reason },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'berth:archived',
    message: `Berth "${berth.mooringNumber}" archived: ${input.reason}`,
    severity: 'info',
  });
}

/** Un-archive. Available to anyone with `berths:edit`. Audit-logged. */
export async function restoreBerth(id: string, portId: string, meta: AuditMeta) {
  const berth = await db.query.berths.findFirst({
    where: and(eq(berths.id, id), eq(berths.portId, portId)),
  });
  if (!berth) throw new NotFoundError('Berth');
  if (!berth.archivedAt) {
    throw new ConflictError('Berth is not archived');
  }

  await db
    .update(berths)
    .set({
      archivedAt: null,
      archivedBy: null,
      archiveReason: null,
      updatedAt: new Date(),
    })
    .where(and(eq(berths.id, id), eq(berths.portId, portId)));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'restore',
    entityType: 'berth',
    entityId: id,
    oldValue: { archivedAt: berth.archivedAt, archiveReason: berth.archiveReason },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'system:alert', {
    alertType: 'berth:restored',
    message: `Berth "${berth.mooringNumber}" restored`,
    severity: 'info',
  });
}

/**
 * @deprecated Use `archiveBerth` instead. Kept temporarily for callers
 * that haven't migrated. Calls archiveBerth under the hood - the
 * "hard delete" name is now a lie but we don't break the import sites
 * in a single PR.
 */
export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
  return archiveBerth(id, portId, { reason: 'Deleted via legacy delete path' }, meta);
}

// ─── Options ──────────────────────────────────────────────────────────────────

export async function getBerthOptions(portId: string) {
  // DB-side `ORDER BY mooring_number` is lexicographic (A1, A10, A11, A2…).
  // Natural-sort in JS so dropdowns surface them as reps read them: A1, A2,
  // …, A10, A11. See compareMooringNumbers for the prefix/index split.
  const rows = await db
    .select({
      id: berths.id,
      mooringNumber: berths.mooringNumber,
      area: berths.area,
      status: berths.status,
    })
    .from(berths)
    // F5: hide archived berths from option pickers; otherwise a dead berth
    // appears in the New Interest combobox and re-links itself to a deal.
    .where(and(eq(berths.portId, portId), isNull(berths.archivedAt)));
  return sortByMooring(rows, (r) => r.mooringNumber);
}
