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

import { db } from '@/lib/db';
import {
  clients,
  clientContacts,
  clientRelationships,
  clientTags,
  clientAddresses,
} from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { isPortalEnabledForPort } from '@/lib/services/portal-auth.service';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { restore, withTransaction } from '@/lib/db/utils';
import { logger } from '@/lib/logger';
import {
  syncEntityFolderName,
  applyEntityArchivedSuffix,
  applyEntityRestoredSuffix,
} from '@/lib/services/document-folders.service';
import type {
  CreateClientInput,
  UpdateClientInput,
  ListClientsInput,
} from '@/lib/validators/clients';

// ─── Types ────────────────────────────────────────────────────────────────────

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

export async function listClients(portId: string, query: ListClientsInput) {
  const { page, limit, sort, order, search, includeArchived, source, nationality, tagIds } = query;

  const filters = [];

  if (source) {
    filters.push(eq(clients.source, source));
  }
  if (nationality) {
    // Filter accepts an ISO-3166-1 alpha-2 code.
    filters.push(eq(clients.nationalityIso, nationality.toUpperCase()));
  }
  if (tagIds && tagIds.length > 0) {
    const clientsWithTags = await db
      .selectDistinct({ clientId: clientTags.clientId })
      .from(clientTags)
      .where(inArray(clientTags.tagId, tagIds));
    const matchingIds = clientsWithTags.map((r) => r.clientId);
    if (matchingIds.length > 0) {
      filters.push(inArray(clients.id, matchingIds));
    } else {
      // No clients match these tags - return empty
      return { data: [], total: 0 };
    }
  }

  let sortColumn: typeof clients.fullName | typeof clients.createdAt | typeof clients.updatedAt =
    clients.updatedAt;
  if (sort === 'fullName') sortColumn = clients.fullName;
  else if (sort === 'createdAt') sortColumn = clients.createdAt;

  const result = await buildListQuery<typeof clients.$inferSelect>({
    table: clients,
    portIdColumn: clients.portId,
    portId,
    idColumn: clients.id,
    updatedAtColumn: clients.updatedAt,
    searchColumns: [clients.fullName],
    searchTerm: search,
    filters,
    sort: sort ? { column: sortColumn, direction: order } : undefined,
    page,
    pageSize: limit,
    includeArchived,
    archivedAtColumn: clients.archivedAt,
  });

  if (result.data.length === 0) return result;

  const ids = result.data.map((r) => r.id);

  const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
    await Promise.all([
      db
        .select({ ownerId: yachts.currentOwnerId, count: count() })
        .from(yachts)
        .where(
          and(
            eq(yachts.portId, portId),
            eq(yachts.currentOwnerType, 'client'),
            inArray(yachts.currentOwnerId, ids),
            isNull(yachts.archivedAt),
          ),
        )
        .groupBy(yachts.currentOwnerId),
      db
        .select({ clientId: companyMemberships.clientId, count: count() })
        .from(companyMemberships)
        .where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
        .groupBy(companyMemberships.clientId),
      // Latest interest per client + its primary-berth mooring (resolved via
      // interest_berths join, plan §3.4). The is_primary filter narrows the
      // join to ≤1 berth row per interest; non-primary links never surface
      // through this list-page derivation.
      db
        .select({
          clientId: interests.clientId,
          pipelineStage: interests.pipelineStage,
          updatedAt: interests.updatedAt,
          mooringNumber: berths.mooringNumber,
        })
        .from(interests)
        .leftJoin(
          interestBerths,
          and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
        )
        .leftJoin(berths, eq(berths.id, interestBerths.berthId))
        .where(
          and(
            eq(interests.portId, portId),
            inArray(interests.clientId, ids),
            isNull(interests.archivedAt),
          ),
        )
        .orderBy(desc(interests.updatedAt)),
      db
        .select({ clientId: interests.clientId, count: count() })
        .from(interests)
        .where(
          and(
            eq(interests.portId, portId),
            inArray(interests.clientId, ids),
            isNull(interests.archivedAt),
          ),
        )
        .groupBy(interests.clientId),
      // Pull at most ONE contact per (client_id, channel) for the page.
      // DISTINCT ON sorted by `is_primary DESC, created_at DESC` keeps
      // the picker logic identical to the in-memory version it replaced
      // while bounding the row count to ~2 per client (one email, one
      // phone) regardless of how many contacts the client has.
      db.execute<{
        clientId: string;
        channel: string;
        value: string;
        valueE164: string | null;
        isPrimary: boolean;
        createdAt: Date;
      }>(sql`
        SELECT DISTINCT ON (client_id, channel)
          client_id    AS "clientId",
          channel,
          value,
          value_e164   AS "valueE164",
          is_primary   AS "isPrimary",
          created_at   AS "createdAt"
        FROM client_contacts
        WHERE ${inArray(clientContacts.clientId, ids)}
          AND channel IN ('email', 'phone')
        ORDER BY client_id, channel, is_primary DESC, created_at DESC
      `),
      // Berths each client has interests in, with the (most-active)
      // interest's stage attached so the list-view chip can self-describe
      // ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
      // collapses (client, berth) when the client has had multiple
      // historical interests in the same berth - we keep the open-outcome
      // one if any, otherwise the most recently updated. Excludes archived
      // interests so closed deals don't crowd the chip row.
      db.execute<{
        clientId: string;
        berthId: string;
        mooringNumber: string;
        interestId: string;
        pipelineStage: string;
        outcome: string | null;
      }>(sql`
        SELECT DISTINCT ON (i.client_id, b.id)
          i.client_id     AS "clientId",
          b.id            AS "berthId",
          b.mooring_number AS "mooringNumber",
          i.id            AS "interestId",
          i.pipeline_stage AS "pipelineStage",
          i.outcome
        FROM interests i
        JOIN interest_berths ib ON ib.interest_id = i.id
        JOIN berths b ON b.id = ib.berth_id
        WHERE i.port_id = ${portId}
          AND i.client_id IN (${sql.join(
            ids.map((id) => sql`${id}`),
            sql`, `,
          )})
          AND i.archived_at IS NULL
        ORDER BY
          i.client_id,
          b.id,
          CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
          i.updated_at DESC
      `),
    ]);

  const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
  const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
  const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
  // interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
  const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
  for (const row of interestRows) {
    if (!latestInterestMap.has(row.clientId)) {
      latestInterestMap.set(row.clientId, {
        stage: row.pipelineStage,
        mooringNumber: row.mooringNumber,
      });
    }
  }

  // Pick the per-client primary email + phone. The SQL DISTINCT ON
  // returns at most one row per (clientId, channel); the result is
  // already the picker's "is_primary desc, created_at desc" choice.
  // We also keep the E.164 form of the phone so the UI can build a
  // wa.me/<digits> link that doesn't need re-parsing.
  const primaryEmailMap = new Map<string, string>();
  const primaryPhoneMap = new Map<string, string>();
  const primaryPhoneE164Map = new Map<string, string>();
  type ContactRow = {
    clientId: string;
    channel: string;
    value: string;
    valueE164: string | null;
    isPrimary: boolean;
    createdAt: Date;
  };
  const contactRowList: ContactRow[] =
    (contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
  for (const c of contactRowList) {
    if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
    else if (c.channel === 'phone') {
      primaryPhoneMap.set(c.clientId, c.value);
      if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
    }
  }

  // Aggregate berths per client, sorted so the most-action-worthy
  // interest floats to the top of the chip row. Priority:
  //   1. open outcome (active deal) before closed (won/lost/cancelled)
  //   2. within open: most progressed stage first (contract > … > enquiry)
  //   3. tie-breaker: mooring number alphabetical for stable ordering
  // The list-view UI shows the top 2 with full labels; the rest fall
  // through into a "+N more" popover.
  //
  // L-001 fix: pre-refactor this map used the 9-stage legacy names
  // (contract_signed, deposit_10pct, …) and every modern 7-stage value
  // fell through to rank 0, making the sort effectively random for any
  // post-refactor interest. Modern values now own the canonical ranks
  // and legacy keys map to their 7-stage equivalents so historical data
  // continues to sort correctly.
  const stageRank: Record<string, number> = {
    // modern (post 9→7 refactor)
    contract: 1,
    deposit_paid: 2,
    reservation: 3,
    eoi: 4,
    nurturing: 5,
    qualified: 6,
    enquiry: 7,
    // legacy aliases - kept so audit-log + soft-archive data sorts the same
    contract_signed: 1,
    contract_sent: 1,
    completed: 1,
    deposit_10pct: 2,
    eoi_signed: 4,
    eoi_sent: 4,
    in_communication: 6,
    details_sent: 7,
    open: 7,
  };
  type LinkedBerth = {
    id: string;
    mooringNumber: string;
    interestId: string;
    stage: string;
    outcome: string | null;
  };
  const linkedBerthsMap = new Map<string, LinkedBerth[]>();
  type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
  const linkedBerthList: LinkedBerthRow[] =
    (linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
    (linkedBerthRows as unknown as LinkedBerthRow[]);
  for (const r of linkedBerthList) {
    const list = linkedBerthsMap.get(r.clientId) ?? [];
    list.push({
      id: r.berthId,
      mooringNumber: r.mooringNumber,
      interestId: r.interestId,
      stage: r.pipelineStage,
      outcome: r.outcome,
    });
    linkedBerthsMap.set(r.clientId, list);
  }
  for (const list of linkedBerthsMap.values()) {
    list.sort((a, b) => {
      // Open before closed.
      const openA = a.outcome === null ? 0 : 1;
      const openB = b.outcome === null ? 0 : 1;
      if (openA !== openB) return openA - openB;
      // Within bucket, most-progressed stage first.
      const rankA = stageRank[a.stage] ?? 99;
      const rankB = stageRank[b.stage] ?? 99;
      if (rankA !== rankB) return rankA - rankB;
      return a.mooringNumber.localeCompare(b.mooringNumber);
    });
  }

  return {
    ...result,
    data: result.data.map((row) => {
      const latest = latestInterestMap.get(row.id);
      return {
        ...row,
        yachtCount: yachtCountMap.get(row.id) ?? 0,
        companyCount: companyCountMap.get(row.id) ?? 0,
        interestCount: interestCountMap.get(row.id) ?? 0,
        primaryEmail: primaryEmailMap.get(row.id) ?? null,
        primaryPhone: primaryPhoneMap.get(row.id) ?? null,
        primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
        linkedBerths: linkedBerthsMap.get(row.id) ?? [],
        latestInterest: latest
          ? {
              stage: latest.stage,
              mooringNumber: latest.mooringNumber,
            }
          : null,
      };
    }),
  };
}

// ─── Get by ID ────────────────────────────────────────────────────────────────

export async function getClientById(id: string, portId: string) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, id),
  });

  if (!client || client.portId !== portId) {
    throw new NotFoundError('Client');
  }

  const contacts = await db.query.clientContacts.findMany({
    where: eq(clientContacts.clientId, id),
    orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
  });

  const addresses = await db.query.clientAddresses.findMany({
    where: eq(clientAddresses.clientId, id),
    orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
  });

  const clientTagRows = await db
    .select({ tag: tags })
    .from(clientTags)
    .innerJoin(tags, eq(clientTags.tagId, tags.id))
    .where(eq(clientTags.clientId, id));

  const yachtRows = await db.query.yachts.findMany({
    where: and(
      eq(yachts.portId, portId),
      eq(yachts.currentOwnerType, 'client'),
      eq(yachts.currentOwnerId, id),
      isNull(yachts.archivedAt),
    ),
    columns: {
      id: true,
      name: true,
      hullNumber: true,
      registration: true,
      lengthFt: true,
      widthFt: true,
      status: true,
    },
  });

  const membershipRows = await db
    .select({
      membershipId: companyMemberships.id,
      role: companyMemberships.role,
      isPrimary: companyMemberships.isPrimary,
      startDate: companyMemberships.startDate,
      company: {
        id: companies.id,
        name: companies.name,
        legalName: companies.legalName,
        status: companies.status,
      },
    })
    .from(companyMemberships)
    .innerJoin(companies, eq(companyMemberships.companyId, companies.id))
    .where(
      and(
        eq(companyMemberships.clientId, id),
        eq(companies.portId, portId),
        isNull(companyMemberships.endDate),
      ),
    );

  // Include pending tenancies alongside active ones — a tenancy starts
  // in `pending` (auto-created from a signed Reservation Agreement, or
  // manually created via the "Create tenancy" button) and stays pending
  // until the rep confirms start date + tenure type via the
  // pending→active activation flow. Reps need to SEE pending rows on
  // the client tab to act on them; only filtering to `active` hid the
  // freshly-created tenancy entirely (UAT 2026-05-26).
  const activeTenancies = await db.query.berthTenancies.findMany({
    where: and(
      eq(berthTenancies.clientId, id),
      eq(berthTenancies.portId, portId),
      inArray(berthTenancies.status, ['pending', 'active']),
    ),
    columns: {
      id: true,
      berthId: true,
      yachtId: true,
      startDate: true,
      tenureType: true,
      status: true,
    },
  });

  const portalEnabled = await isPortalEnabledForPort(portId);

  // Counts surfaced for tab badges (Interests + Notes - Yachts/Companies/etc
  // get their counts from the corresponding row arrays we already fetched).
  const [interestCountRow] = await db
    .select({ count: count() })
    .from(interests)
    .where(
      and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
    );
  // Aggregated note count — matches what `NotesList` renders below
  // (direct client notes + interest_notes + yacht_notes for owned
  // yachts + company_notes for active memberships). Bare clientNotes
  // count would understate when the rep adds notes to linked entities.
  const { countForClientAggregated } = await import('@/lib/services/notes.service');
  const aggregatedNoteCount = await countForClientAggregated(portId, id);

  return {
    ...client,
    contacts,
    addresses,
    tags: clientTagRows.map((r) => r.tag),
    yachts: yachtRows,
    companies: membershipRows,
    activeTenancies,
    interestCount: interestCountRow?.count ?? 0,
    noteCount: aggregatedNoteCount,
    clientPortalEnabled: portalEnabled,
  };
}

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

export async function createClient(portId: string, data: CreateClientInput, meta: AuditMeta) {
  const result = await withTransaction(async (tx) => {
    const { contacts: contactsInput, tagIds, ...clientData } = data;

    const [client] = await tx
      .insert(clients)
      .values({ portId, ...clientData })
      .returning();

    if (contactsInput.length > 0) {
      await tx
        .insert(clientContacts)
        .values(contactsInput.map((c) => ({ clientId: client!.id, ...c })));
    }

    if (tagIds && tagIds.length > 0) {
      await tx.insert(clientTags).values(tagIds.map((tagId) => ({ clientId: client!.id, tagId })));
    }

    return client!;
  });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'client',
    entityId: result.id,
    newValue: { fullName: result.fullName },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'client:created', {
    clientId: result.id,
    clientName: result.fullName ?? '',
    source: result.source ?? '',
  });

  void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
    dispatchWebhookEvent(portId, 'client:created', { clientId: result.id }),
  );

  return result;
}

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

export async function updateClient(
  id: string,
  portId: string,
  data: UpdateClientInput,
  meta: AuditMeta,
) {
  const existing = await db.query.clients.findFirst({
    where: eq(clients.id, id),
  });

  if (!existing || existing.portId !== portId) {
    throw new NotFoundError('Client');
  }

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

  const [updated] = await db
    .update(clients)
    .set({ ...data, updatedAt: new Date() })
    .where(and(eq(clients.id, id), eq(clients.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'client',
    entityId: id,
    oldValue: diff as Record<string, unknown>,
    newValue: data as Record<string, unknown>,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

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

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

  if (data.fullName !== undefined) {
    await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
      logger.warn({ err, clientId: id }, 'Failed to sync client folder name');
    });
  }

  return updated;
}

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

export async function archiveClient(id: string, portId: string, meta: AuditMeta) {
  const existing = await db.query.clients.findFirst({
    where: eq(clients.id, id),
  });

  if (!existing || existing.portId !== portId) {
    throw new NotFoundError('Client');
  }

  // F10: cascade-archive the client's open interests so they don't
  // dangle in active queries with a shadowed client. Won/lost interests
  // (outcome IS NOT NULL) are kept as historical records - only IN-FLIGHT
  // deals get archived. Wrapped in a single transaction so a partial
  // archive can't leave the system half-cascaded.
  const archivedInterestIds: string[] = await db.transaction(async (tx) => {
    await tx
      .update(clients)
      .set({ archivedAt: new Date(), updatedAt: new Date() })
      .where(eq(clients.id, id));
    const cascaded = await tx
      .update(interests)
      .set({ archivedAt: new Date(), updatedAt: new Date() })
      .where(
        and(
          eq(interests.clientId, id),
          eq(interests.portId, portId),
          isNull(interests.archivedAt),
          isNull(interests.outcome),
        ),
      )
      .returning({ id: interests.id });
    return cascaded.map((r) => r.id);
  });

  // fire-and-forget: archive UI does not depend on the folder suffix
  // being stamped before the HTTP response returns. Task 5 (rename
  // hook) uses await because the rename should be visible to the
  // next read; archive does not.
  void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
    logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder');
  });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'archive',
    entityType: 'client',
    entityId: id,
    // Surface the cascade in the audit trail so /admin/audit shows
    // exactly which interests got swept up.
    newValue:
      archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  // H-07: emit per-interest archive rows so an auditor searching for a
  // specific archived interest finds it directly - the client-level row's
  // `cascadedInterestIds` array doesn't participate in audit-log FTS.
  for (const interestId of archivedInterestIds) {
    void createAuditLog({
      userId: meta.userId,
      portId,
      action: 'archive',
      entityType: 'interest',
      entityId: interestId,
      metadata: { cascadeSource: 'client_archive', clientId: id },
      ipAddress: meta.ipAddress,
      userAgent: meta.userAgent,
    });
  }

  emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
  for (const interestId of archivedInterestIds) {
    emitToRoom(`port:${portId}`, 'interest:archived', { interestId });
  }

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

export async function restoreClient(id: string, portId: string, meta: AuditMeta) {
  const existing = await db.query.clients.findFirst({
    where: eq(clients.id, id),
  });

  if (!existing || existing.portId !== portId) {
    throw new NotFoundError('Client');
  }

  await restore(clients, clients.id, id);

  void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
    logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder');
  });

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

  emitToRoom(`port:${portId}`, 'client:restored', { clientId: id });
}

// ─── Contacts ─────────────────────────────────────────────────────────────────

export async function listContacts(clientId: string, portId: string) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  return db.query.clientContacts.findMany({
    where: eq(clientContacts.clientId, clientId),
    orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
  });
}

export async function addContact(
  clientId: string,
  portId: string,
  data: {
    channel: string;
    value: string;
    valueE164?: string | null;
    valueCountry?: string | null;
    label?: string;
    isPrimary?: boolean;
    notes?: string;
  },
  meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  const [contact] = await db
    .insert(clientContacts)
    .values({ clientId, ...data })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'clientContact',
    entityId: contact!.id,
    newValue: { clientId, channel: contact!.channel },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });

  return contact!;
}

export async function updateContact(
  contactId: string,
  clientId: string,
  portId: string,
  data: Partial<{
    channel: string;
    value: string;
    valueE164: string | null;
    valueCountry: string | null;
    label: string;
    isPrimary: boolean;
    notes: string;
  }>,
  _meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  const contact = await db.query.clientContacts.findFirst({
    where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
  });
  if (!contact) throw new NotFoundError('Contact');

  const [updated] = await db
    .update(clientContacts)
    .set({ ...data, updatedAt: new Date() })
    // M-MT03: pin the WHERE to (id, clientId) for defense-in-depth.
    .where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
    .returning();

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });

  return updated;
}

/**
 * Phase 3d - promote a non-primary client_contacts row to primary,
 * demoting the prior primary for the same channel inside one
 * transaction. Throws when the contact is already primary or the row
 * does not exist on the targeted client.
 *
 * Used by the EOI dialog's "Set as default for future docs" toggle
 * (via the eoi-overrides service) and by the client-detail "[EOI] Set
 * as primary" action.
 */
export async function promoteContactToPrimary(
  contactId: string,
  clientId: string,
  portId: string,
  meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  const contact = await db.query.clientContacts.findFirst({
    where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
  });
  if (!contact) throw new NotFoundError('Contact');
  if (contact.isPrimary) {
    // No-op - return the row as-is so callers can be idempotent.
    return contact;
  }

  const updated = await withTransaction(async (tx) => {
    // Demote the prior primary for the same channel so the partial
    // unique index doesn't reject the promotion.
    await tx
      .update(clientContacts)
      .set({ isPrimary: false, updatedAt: new Date() })
      .where(
        and(
          eq(clientContacts.clientId, clientId),
          eq(clientContacts.channel, contact.channel),
          eq(clientContacts.isPrimary, true),
        ),
      );
    const [row] = await tx
      .update(clientContacts)
      .set({ isPrimary: true, updatedAt: new Date() })
      .where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)))
      .returning();
    return row!;
  });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'promote_to_primary',
    entityType: 'client_contact',
    entityId: contactId,
    newValue: { clientId, channel: contact.channel, value: contact.value },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });

  return updated;
}

export async function removeContact(
  contactId: string,
  clientId: string,
  portId: string,
  _meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  const contact = await db.query.clientContacts.findFirst({
    where: and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)),
  });
  if (!contact) throw new NotFoundError('Contact');

  // M-MT03: pin (id, clientId) for defense-in-depth.
  await db
    .delete(clientContacts)
    .where(and(eq(clientContacts.id, contactId), eq(clientContacts.clientId, clientId)));

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['contacts'] });
}

// ─── Addresses ────────────────────────────────────────────────────────────────

interface AddressInput {
  label?: string;
  streetAddress?: string | null;
  city?: string | null;
  subdivisionIso?: string | null;
  postalCode?: string | null;
  countryIso?: string | null;
  isPrimary?: boolean;
}

export async function listClientAddresses(clientId: string, portId: string) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  return db.query.clientAddresses.findMany({
    where: eq(clientAddresses.clientId, clientId),
    orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
  });
}

export async function addClientAddress(
  clientId: string,
  portId: string,
  data: AddressInput,
  meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  // The unique partial index requires us to demote any existing primary
  // before inserting a new one. We grab a row lock on the client to
  // serialize concurrent primary-toggle requests against the same client -
  // without this, two simultaneous "isPrimary=true" inserts can both
  // observe "no existing primary" and one trips the unique index with a
  // 5xx instead of being safely ordered.
  const address = await withTransaction(async (tx) => {
    await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');

    const wantsPrimary = data.isPrimary ?? false;
    if (wantsPrimary) {
      await tx
        .update(clientAddresses)
        .set({ isPrimary: false })
        .where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
    }
    const [row] = await tx
      .insert(clientAddresses)
      .values({
        clientId,
        portId,
        label: data.label ?? 'Primary',
        streetAddress: data.streetAddress ?? null,
        city: data.city ?? null,
        subdivisionIso: data.subdivisionIso ?? null,
        postalCode: data.postalCode ?? null,
        countryIso: data.countryIso ?? null,
        isPrimary: wantsPrimary,
      })
      .returning();
    return row!;
  });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'clientAddress',
    entityId: address.id,
    newValue: { clientId, label: address.label, countryIso: address.countryIso },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });

  return address;
}

export async function updateClientAddress(
  addressId: string,
  clientId: string,
  portId: string,
  data: AddressInput,
  _meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  const existing = await db.query.clientAddresses.findFirst({
    where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
  });
  if (!existing) throw new NotFoundError('Address');

  const updated = await withTransaction(async (tx) => {
    // Lock the client row to serialize primary-toggle changes - see addClientAddress.
    await tx.select({ id: clients.id }).from(clients).where(eq(clients.id, clientId)).for('update');

    if (data.isPrimary === true && !existing.isPrimary) {
      await tx
        .update(clientAddresses)
        .set({ isPrimary: false })
        .where(and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)));
    }
    const [row] = await tx
      .update(clientAddresses)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(clientAddresses.id, addressId))
      .returning();
    return row!;
  });

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });

  return updated;
}

export async function removeClientAddress(
  addressId: string,
  clientId: string,
  portId: string,
  _meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  const address = await db.query.clientAddresses.findFirst({
    where: and(eq(clientAddresses.id, addressId), eq(clientAddresses.clientId, clientId)),
  });
  if (!address) throw new NotFoundError('Address');

  await db.delete(clientAddresses).where(eq(clientAddresses.id, addressId));

  emitToRoom(`port:${portId}`, 'client:updated', { clientId, changedFields: ['addresses'] });
}

// ─── Tags ─────────────────────────────────────────────────────────────────────

export async function setClientTags(
  clientId: string,
  portId: string,
  tagIds: string[],
  meta: AuditMeta,
) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  await setEntityTags({
    joinTable: clientTags,
    entityColumn: clientTags.clientId,
    tagColumn: clientTags.tagId,
    entityId: clientId,
    portId,
    tagIds,
    meta,
    entityType: 'client',
  });
}

// ─── Relationships ────────────────────────────────────────────────────────────

export async function listRelationships(clientId: string, portId: string) {
  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  return db.query.clientRelationships.findMany({
    where: (r, { and, or, eq }) =>
      and(eq(r.portId, portId), or(eq(r.clientAId, clientId), eq(r.clientBId, clientId))),
  });
}

export async function createRelationship(
  clientId: string,
  portId: string,
  data: { clientBId: string; relationshipType: string; description?: string },
  meta: AuditMeta,
) {
  if (data.clientBId === clientId) {
    throw new ValidationError('A client cannot have a relationship to themselves');
  }

  const client = await db.query.clients.findFirst({
    where: eq(clients.id, clientId),
  });
  if (!client || client.portId !== portId) throw new NotFoundError('Client');

  // Tenant scope: clientBId arrives from the request body. Without this check
  // a port-A caller could splice a port-B client UUID onto their own client's
  // relationship row; the GET handler joins clientRelationships → clients with
  // no port filter and would surface the foreign client's name + email.
  const otherClient = await db.query.clients.findFirst({
    where: and(eq(clients.id, data.clientBId), eq(clients.portId, portId)),
  });
  if (!otherClient) throw new ValidationError('clientBId not found in this port');

  const [rel] = await db
    .insert(clientRelationships)
    .values({ portId, clientAId: clientId, ...data })
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'clientRelationship',
    entityId: rel!.id,
    newValue: { clientAId: clientId, clientBId: data.clientBId, type: data.relationshipType },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return rel!;
}

export async function deleteRelationship(
  relId: string,
  clientId: string,
  portId: string,
  meta: AuditMeta,
) {
  const rel = await db.query.clientRelationships.findFirst({
    where: eq(clientRelationships.id, relId),
  });
  if (!rel || rel.portId !== portId) throw new NotFoundError('Relationship');

  await db
    .delete(clientRelationships)
    .where(and(eq(clientRelationships.id, relId), eq(clientRelationships.portId, portId)));

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'delete',
    entityType: 'clientRelationship',
    entityId: relId,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
}

// ─── Find Duplicates ──────────────────────────────────────────────────────────

export async function findDuplicates(portId: string, fullName: string) {
  return db.query.clients.findMany({
    where: (c, { and, eq }) => and(eq(c.portId, portId), ilike(c.fullName, `%${fullName}%`)),
    limit: 5,
  });
}

// ─── Options (for comboboxes) ─────────────────────────────────────────────────

export async function listClientOptions(portId: string, search?: string) {
  // Pickers only surface active rows. Archived clients are still resolvable
  // by id (e.g. history views) but should not appear in dropdowns.
  const conditions = [eq(clients.portId, portId), isNull(clients.archivedAt)];
  if (search) {
    conditions.push(ilike(clients.fullName, `%${search}%`));
  }

  return db
    .select({ id: clients.id, fullName: clients.fullName })
    .from(clients)
    .where(and(...conditions))
    .orderBy(clients.fullName)
    .limit(50);
}
