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

import { db } from '@/lib/db';
import { clientNotes, clients } from '@/lib/db/schema/clients';
import { interestNotes, interests } from '@/lib/db/schema/interests';
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
import { companyNotes, companies, companyMemberships } from '@/lib/db/schema/companies';
import {
  residentialClients,
  residentialClientNotes,
  residentialInterests,
  residentialInterestNotes,
} from '@/lib/db/schema/residential';
import { userProfiles } from '@/lib/db/schema/users';
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';

const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes

type EntityType =
  | 'clients'
  | 'interests'
  | 'yachts'
  | 'companies'
  | 'residential_clients'
  | 'residential_interests';

// ─── Helpers ─────────────────────────────────────────────────────────────────

async function verifyParentBelongsToPort(
  entityType: EntityType,
  entityId: string,
  portId: string,
): Promise<void> {
  if (entityType === 'clients') {
    const r = await db
      .select({ id: clients.id })
      .from(clients)
      .where(and(eq(clients.id, entityId), eq(clients.portId, portId)))
      .limit(1);
    if (!r.length) throw new NotFoundError('Client');
  } else if (entityType === 'interests') {
    const r = await db
      .select({ id: interests.id })
      .from(interests)
      .where(and(eq(interests.id, entityId), eq(interests.portId, portId)))
      .limit(1);
    if (!r.length) throw new NotFoundError('Interest');
  } else if (entityType === 'yachts') {
    const r = await db
      .select({ id: yachts.id })
      .from(yachts)
      .where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
      .limit(1);
    if (!r.length) throw new NotFoundError('Yacht');
  } else if (entityType === 'companies') {
    const r = await db
      .select({ id: companies.id })
      .from(companies)
      .where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
      .limit(1);
    if (!r.length) throw new NotFoundError('Company');
  } else if (entityType === 'residential_clients') {
    const r = await db
      .select({ id: residentialClients.id })
      .from(residentialClients)
      .where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId)))
      .limit(1);
    if (!r.length) throw new NotFoundError('Residential client');
  } else {
    const r = await db
      .select({ id: residentialInterests.id })
      .from(residentialInterests)
      .where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId)))
      .limit(1);
    if (!r.length) throw new NotFoundError('Residential interest');
  }
}

// ─── Service ─────────────────────────────────────────────────────────────────

/**
 * Aggregated note timeline for a client. Unions client-level notes
 * with notes attached to ANY of the client's interests + directly-
 * owned yachts (polymorphic ownership: `owner_type='client' AND
 * owner_id=clientId`). Each row carries source metadata so the UI
 * can show "from interest E17" or "from yacht Sea Breeze" badges
 * and offer a "Group by source" view alongside chronological.
 *
 * Company-owned yachts the client is a member of are excluded -
 * those are properly the company's notes, not the client's.
 */
export interface AggregatedClientNote {
  id: string;
  content: string;
  mentions: string[] | null;
  isLocked: boolean;
  createdAt: Date;
  updatedAt: Date;
  authorId: string;
  authorName: string | null;
  source: 'client' | 'interest' | 'yacht';
  /** Origin entity id - interest_id / yacht_id / client_id. */
  sourceId: string;
  /** Human label for the source (interest's berth mooring, yacht
   *  name, or "Client" for client-level). */
  sourceLabel: string;
  /** Pipeline stage the linked interest was at when this note was
   *  authored. Only populated for interest notes (the only entity with
   *  a pipeline). Null for pre-2026-05-15 rows + non-interest sources. */
  pipelineStageAtCreation?: string | null;
}

// ─── Aggregated counts ──────────────────────────────────────────────────────
//
// Mirror the symmetric-reach unions used by the `listFor*Aggregated`
// helpers, but return scalar totals so tab badges on entity detail
// pages match what the NotesList renders below them. Each function is
// port-scoped (defense-in-depth) and tolerates zero linked-entity ids
// by short-circuiting the relevant counts to 0.

async function scalarCount(query: Promise<Array<{ count: number }>>): Promise<number> {
  const rows = await query;
  return rows[0]?.count ?? 0;
}

/**
 * Total note count visible on a client's Notes tab = direct
 * client_notes + interest_notes (interests where client_id=X) +
 * yacht_notes (yachts currently owned by this client) +
 * company_notes (companies the client has an active membership in).
 */
export async function countForClientAggregated(portId: string, clientId: string): Promise<number> {
  await verifyParentBelongsToPort('clients', clientId, portId);

  const [interestRows, yachtRows, membershipRows] = await Promise.all([
    db
      .select({ id: interests.id })
      .from(interests)
      .where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
    db
      .select({ id: yachts.id })
      .from(yachts)
      .where(
        and(
          eq(yachts.portId, portId),
          eq(yachts.currentOwnerType, 'client'),
          eq(yachts.currentOwnerId, clientId),
        ),
      ),
    db
      .select({ companyId: companyMemberships.companyId })
      .from(companyMemberships)
      .innerJoin(companies, eq(companies.id, companyMemberships.companyId))
      .where(
        and(
          eq(companyMemberships.clientId, clientId),
          isNull(companyMemberships.endDate),
          eq(companies.portId, portId),
        ),
      ),
  ]);

  const interestIds = interestRows.map((r) => r.id);
  const yachtIds = yachtRows.map((r) => r.id);
  const companyIds = Array.from(new Set(membershipRows.map((r) => r.companyId)));

  const [clientCount, interestCount, yachtCount, companyCount] = await Promise.all([
    scalarCount(
      db
        .select({ count: sql<number>`count(*)::int` })
        .from(clientNotes)
        .where(eq(clientNotes.clientId, clientId)),
    ),
    interestIds.length > 0
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(interestNotes)
            .where(inArray(interestNotes.interestId, interestIds)),
        )
      : Promise.resolve(0),
    yachtIds.length > 0
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(yachtNotes)
            .where(inArray(yachtNotes.yachtId, yachtIds)),
        )
      : Promise.resolve(0),
    companyIds.length > 0
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(companyNotes)
            .where(inArray(companyNotes.companyId, companyIds)),
        )
      : Promise.resolve(0),
  ]);

  return clientCount + interestCount + yachtCount + companyCount;
}

/**
 * Total note count visible on a yacht's Notes tab = direct
 * yacht_notes + the polymorphic owner-side notes (client_notes when
 * owner_type='client', company_notes when owner_type='company') +
 * interest_notes (interests currently linked to this yacht).
 */
export async function countForYachtAggregated(portId: string, yachtId: string): Promise<number> {
  await verifyParentBelongsToPort('yachts', yachtId, portId);

  const [yacht] = await db
    .select({
      id: yachts.id,
      ownerType: yachts.currentOwnerType,
      ownerId: yachts.currentOwnerId,
    })
    .from(yachts)
    .where(and(eq(yachts.id, yachtId), eq(yachts.portId, portId)))
    .limit(1);
  if (!yacht) throw new NotFoundError('Yacht');

  const interestRows = await db
    .select({ id: interests.id })
    .from(interests)
    .where(and(eq(interests.yachtId, yachtId), eq(interests.portId, portId)));
  const interestIds = interestRows.map((r) => r.id);

  const [yachtCount, ownerCount, interestCount] = await Promise.all([
    scalarCount(
      db
        .select({ count: sql<number>`count(*)::int` })
        .from(yachtNotes)
        .where(eq(yachtNotes.yachtId, yachtId)),
    ),
    yacht.ownerType === 'client' && yacht.ownerId
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(clientNotes)
            .where(eq(clientNotes.clientId, yacht.ownerId)),
        )
      : yacht.ownerType === 'company' && yacht.ownerId
        ? scalarCount(
            db
              .select({ count: sql<number>`count(*)::int` })
              .from(companyNotes)
              .where(eq(companyNotes.companyId, yacht.ownerId)),
          )
        : Promise.resolve(0),
    interestIds.length > 0
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(interestNotes)
            .where(inArray(interestNotes.interestId, interestIds)),
        )
      : Promise.resolve(0),
  ]);

  return yachtCount + ownerCount + interestCount;
}

/**
 * Total note count visible on a company's Notes tab = direct
 * company_notes + yacht_notes (yachts owned by this company) +
 * interest_notes (interests linked via those yachts). Member-client
 * personal notes are NOT counted — they live on the client's dossier.
 */
export async function countForCompanyAggregated(
  portId: string,
  companyId: string,
): Promise<number> {
  await verifyParentBelongsToPort('companies', companyId, portId);

  const yachtRows = await db
    .select({ id: yachts.id })
    .from(yachts)
    .where(
      and(
        eq(yachts.portId, portId),
        eq(yachts.currentOwnerType, 'company'),
        eq(yachts.currentOwnerId, companyId),
      ),
    );
  const yachtIds = yachtRows.map((r) => r.id);

  const interestRows =
    yachtIds.length > 0
      ? await db
          .select({ id: interests.id })
          .from(interests)
          .where(and(inArray(interests.yachtId, yachtIds), eq(interests.portId, portId)))
      : [];
  const interestIds = interestRows.map((r) => r.id);

  const [companyCount, yachtCount, interestCount] = await Promise.all([
    scalarCount(
      db
        .select({ count: sql<number>`count(*)::int` })
        .from(companyNotes)
        .where(eq(companyNotes.companyId, companyId)),
    ),
    yachtIds.length > 0
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(yachtNotes)
            .where(inArray(yachtNotes.yachtId, yachtIds)),
        )
      : Promise.resolve(0),
    interestIds.length > 0
      ? scalarCount(
          db
            .select({ count: sql<number>`count(*)::int` })
            .from(interestNotes)
            .where(inArray(interestNotes.interestId, interestIds)),
        )
      : Promise.resolve(0),
  ]);

  return companyCount + yachtCount + interestCount;
}

export async function listForClientAggregated(
  portId: string,
  clientId: string,
): Promise<AggregatedClientNote[]> {
  await verifyParentBelongsToPort('clients', clientId, portId);

  // Collect interest + yacht ids upfront so the note-table queries
  // can be IN-list filtered.
  const [interestRows, yachtRows] = await Promise.all([
    db
      .select({ id: interests.id })
      .from(interests)
      .where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
    db
      .select({ id: yachts.id, name: yachts.name })
      .from(yachts)
      .where(
        and(
          eq(yachts.portId, portId),
          eq(yachts.currentOwnerType, 'client'),
          eq(yachts.currentOwnerId, clientId),
        ),
      ),
  ]);
  const interestIds = interestRows.map((r) => r.id);
  const yachtIds = yachtRows.map((r) => r.id);
  const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));

  // Resolve each interest's primary-berth mooring for the source
  // label. Cheap single round-trip via the existing junction helper.
  const primaryBerthMap =
    interestIds.length > 0
      ? await (
          await import('@/lib/services/interest-berths.service')
        ).getPrimaryBerthsForInterests(interestIds)
      : new Map<string, { mooringNumber: string }>();

  // Three parallel reads against the per-entity note tables; merged
  // in JS rather than via UNION because each table has a different
  // FK column name and Drizzle's UNION syntax forces matching shapes.
  const [clientLevel, interestLevel, yachtLevel] = await Promise.all([
    db
      .select({
        id: clientNotes.id,
        content: clientNotes.content,
        mentions: clientNotes.mentions,
        isLocked: clientNotes.isLocked,
        createdAt: clientNotes.createdAt,
        updatedAt: clientNotes.updatedAt,
        authorId: clientNotes.authorId,
        authorName: userProfiles.displayName,
        sourceId: clientNotes.clientId,
      })
      .from(clientNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
      .where(eq(clientNotes.clientId, clientId)),
    interestIds.length > 0
      ? db
          .select({
            id: interestNotes.id,
            content: interestNotes.content,
            mentions: interestNotes.mentions,
            isLocked: interestNotes.isLocked,
            createdAt: interestNotes.createdAt,
            updatedAt: interestNotes.updatedAt,
            authorId: interestNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: interestNotes.interestId,
            pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
          })
          .from(interestNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
          .where(inArray(interestNotes.interestId, interestIds))
      : Promise.resolve([] as never[]),
    yachtIds.length > 0
      ? db
          .select({
            id: yachtNotes.id,
            content: yachtNotes.content,
            mentions: yachtNotes.mentions,
            isLocked: yachtNotes.isLocked,
            createdAt: yachtNotes.createdAt,
            updatedAt: yachtNotes.updatedAt,
            authorId: yachtNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: yachtNotes.yachtId,
          })
          .from(yachtNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
          .where(inArray(yachtNotes.yachtId, yachtIds))
      : Promise.resolve([] as never[]),
  ]);

  const merged: AggregatedClientNote[] = [
    ...clientLevel.map((n) => ({
      ...n,
      source: 'client' as const,
      sourceLabel: 'Client',
    })),
    ...interestLevel.map((n) => ({
      ...n,
      source: 'interest' as const,
      sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
    })),
    ...yachtLevel.map((n) => ({
      ...n,
      source: 'yacht' as const,
      sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
    })),
  ];

  merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  return merged;
}

/**
 * Generic aggregated-note row used by the yacht / company /
 * residential client aggregators. `source` identifies the kind of
 * entity the note came from; `sourceLabel` is a human label (yacht
 * name, company name, primary berth mooring, etc.) and `sourceId` is
 * the raw FK so the UI can deep-link to the source page.
 */
export interface AggregatedNote {
  id: string;
  content: string;
  mentions: string[] | null;
  isLocked: boolean;
  createdAt: Date;
  updatedAt: Date;
  authorId: string;
  authorName: string | null;
  source:
    | 'client'
    | 'interest'
    | 'yacht'
    | 'company'
    | 'residential_client'
    | 'residential_interest';
  sourceId: string;
  sourceLabel: string;
}

/**
 * Aggregated note timeline for a yacht. Unions yacht-level notes
 * with notes attached to the current owner client (when ownership
 * is polymorphic 'client') + every interest currently linked to
 * this yacht. Company-owned yachts surface their owning company's
 * notes via {@link listForCompanyAggregated} instead.
 */
export async function listForYachtAggregated(
  portId: string,
  yachtId: string,
): Promise<AggregatedNote[]> {
  await verifyParentBelongsToPort('yachts', yachtId, portId);

  const [yacht] = await db
    .select({
      id: yachts.id,
      name: yachts.name,
      ownerType: yachts.currentOwnerType,
      ownerId: yachts.currentOwnerId,
    })
    .from(yachts)
    .where(eq(yachts.id, yachtId))
    .limit(1);
  if (!yacht) throw new NotFoundError('Yacht');

  const ownerClientId = yacht.ownerType === 'client' ? yacht.ownerId : null;
  const [ownerClient] = ownerClientId
    ? await db
        .select({ id: clients.id, name: clients.fullName })
        .from(clients)
        // M-MT04: defense-in-depth port_id filter - without it a stale
        // ownerClientId persisted by a prior cross-port migration could
        // surface the wrong tenant's client name. Belt-and-braces given
        // yacht ownership is polymorphic via a non-FK pair.
        .where(and(eq(clients.id, ownerClientId), eq(clients.portId, portId)))
        .limit(1)
    : [];

  const interestRows = await db
    .select({ id: interests.id })
    .from(interests)
    .where(and(eq(interests.yachtId, yachtId), eq(interests.portId, portId)));
  const interestIds = interestRows.map((r) => r.id);

  const primaryBerthMap =
    interestIds.length > 0
      ? await (
          await import('@/lib/services/interest-berths.service')
        ).getPrimaryBerthsForInterests(interestIds)
      : new Map<string, { mooringNumber: string }>();

  const [yachtLevel, clientLevel, interestLevel] = await Promise.all([
    db
      .select({
        id: yachtNotes.id,
        content: yachtNotes.content,
        mentions: yachtNotes.mentions,
        isLocked: yachtNotes.isLocked,
        createdAt: yachtNotes.createdAt,
        updatedAt: yachtNotes.updatedAt,
        authorId: yachtNotes.authorId,
        authorName: userProfiles.displayName,
        sourceId: yachtNotes.yachtId,
      })
      .from(yachtNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
      .where(eq(yachtNotes.yachtId, yachtId)),
    ownerClientId
      ? db
          .select({
            id: clientNotes.id,
            content: clientNotes.content,
            mentions: clientNotes.mentions,
            isLocked: clientNotes.isLocked,
            createdAt: clientNotes.createdAt,
            updatedAt: clientNotes.updatedAt,
            authorId: clientNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: clientNotes.clientId,
          })
          .from(clientNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
          .where(eq(clientNotes.clientId, ownerClientId))
      : Promise.resolve([] as never[]),
    interestIds.length > 0
      ? db
          .select({
            id: interestNotes.id,
            content: interestNotes.content,
            mentions: interestNotes.mentions,
            isLocked: interestNotes.isLocked,
            createdAt: interestNotes.createdAt,
            updatedAt: interestNotes.updatedAt,
            authorId: interestNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: interestNotes.interestId,
            pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
          })
          .from(interestNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
          .where(inArray(interestNotes.interestId, interestIds))
      : Promise.resolve([] as never[]),
  ]);

  const merged: AggregatedNote[] = [
    ...yachtLevel.map((n) => ({
      ...n,
      source: 'yacht' as const,
      sourceLabel: yacht.name,
    })),
    ...clientLevel.map((n) => ({
      ...n,
      source: 'client' as const,
      sourceLabel: ownerClient?.name ?? 'Owner',
    })),
    ...interestLevel.map((n) => ({
      ...n,
      source: 'interest' as const,
      sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
    })),
  ];
  merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  return merged;
}

/**
 * Aggregated note timeline for a company. Unions company-level
 * notes with notes attached to every yacht currently owned by the
 * company (polymorphic ownership: `owner_type='company' AND
 * owner_id=companyId`) + every interest currently linked to those
 * yachts. Personal-side notes from individual company members are
 * NOT included - they belong on the client's own dossier.
 */
export async function listForCompanyAggregated(
  portId: string,
  companyId: string,
): Promise<AggregatedNote[]> {
  await verifyParentBelongsToPort('companies', companyId, portId);

  const yachtRows = await db
    .select({ id: yachts.id, name: yachts.name })
    .from(yachts)
    .where(
      and(
        eq(yachts.portId, portId),
        eq(yachts.currentOwnerType, 'company'),
        eq(yachts.currentOwnerId, companyId),
      ),
    );
  const yachtIds = yachtRows.map((r) => r.id);
  const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));

  const interestRows =
    yachtIds.length > 0
      ? await db
          .select({ id: interests.id })
          .from(interests)
          .where(and(inArray(interests.yachtId, yachtIds), eq(interests.portId, portId)))
      : [];
  const interestIds = interestRows.map((r) => r.id);

  const primaryBerthMap =
    interestIds.length > 0
      ? await (
          await import('@/lib/services/interest-berths.service')
        ).getPrimaryBerthsForInterests(interestIds)
      : new Map<string, { mooringNumber: string }>();

  const [companyLevel, yachtLevel, interestLevel] = await Promise.all([
    db
      .select({
        id: companyNotes.id,
        content: companyNotes.content,
        mentions: companyNotes.mentions,
        isLocked: companyNotes.isLocked,
        createdAt: companyNotes.createdAt,
        updatedAt: companyNotes.updatedAt,
        authorId: companyNotes.authorId,
        authorName: userProfiles.displayName,
        sourceId: companyNotes.companyId,
      })
      .from(companyNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
      .where(eq(companyNotes.companyId, companyId)),
    yachtIds.length > 0
      ? db
          .select({
            id: yachtNotes.id,
            content: yachtNotes.content,
            mentions: yachtNotes.mentions,
            isLocked: yachtNotes.isLocked,
            createdAt: yachtNotes.createdAt,
            updatedAt: yachtNotes.updatedAt,
            authorId: yachtNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: yachtNotes.yachtId,
          })
          .from(yachtNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
          .where(inArray(yachtNotes.yachtId, yachtIds))
      : Promise.resolve([] as never[]),
    interestIds.length > 0
      ? db
          .select({
            id: interestNotes.id,
            content: interestNotes.content,
            mentions: interestNotes.mentions,
            isLocked: interestNotes.isLocked,
            createdAt: interestNotes.createdAt,
            updatedAt: interestNotes.updatedAt,
            authorId: interestNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: interestNotes.interestId,
            pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
          })
          .from(interestNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
          .where(inArray(interestNotes.interestId, interestIds))
      : Promise.resolve([] as never[]),
  ]);

  const merged: AggregatedNote[] = [
    ...companyLevel.map((n) => ({
      ...n,
      source: 'company' as const,
      sourceLabel: 'Company',
    })),
    ...yachtLevel.map((n) => ({
      ...n,
      source: 'yacht' as const,
      sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
    })),
    ...interestLevel.map((n) => ({
      ...n,
      source: 'interest' as const,
      sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
    })),
  ];
  merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  return merged;
}

/**
 * Aggregated note timeline for a residential client. Unions own
 * notes with notes attached to every residential interest the
 * client has filed. Residential is single-tenant per port so no
 * polymorphic ownership / company linkage applies here.
 */
export async function listForResidentialClientAggregated(
  portId: string,
  residentialClientId: string,
): Promise<AggregatedNote[]> {
  await verifyParentBelongsToPort('residential_clients', residentialClientId, portId);

  const interestRows = await db
    .select({ id: residentialInterests.id })
    .from(residentialInterests)
    .where(
      and(
        eq(residentialInterests.residentialClientId, residentialClientId),
        eq(residentialInterests.portId, portId),
      ),
    );
  const interestIds = interestRows.map((r) => r.id);

  const [clientLevel, interestLevel] = await Promise.all([
    db
      .select({
        id: residentialClientNotes.id,
        content: residentialClientNotes.content,
        mentions: residentialClientNotes.mentions,
        isLocked: residentialClientNotes.isLocked,
        createdAt: residentialClientNotes.createdAt,
        updatedAt: residentialClientNotes.updatedAt,
        authorId: residentialClientNotes.authorId,
        authorName: userProfiles.displayName,
        sourceId: residentialClientNotes.residentialClientId,
      })
      .from(residentialClientNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
      .where(eq(residentialClientNotes.residentialClientId, residentialClientId)),
    interestIds.length > 0
      ? db
          .select({
            id: residentialInterestNotes.id,
            content: residentialInterestNotes.content,
            mentions: residentialInterestNotes.mentions,
            isLocked: residentialInterestNotes.isLocked,
            createdAt: residentialInterestNotes.createdAt,
            updatedAt: residentialInterestNotes.updatedAt,
            authorId: residentialInterestNotes.authorId,
            authorName: userProfiles.displayName,
            sourceId: residentialInterestNotes.residentialInterestId,
          })
          .from(residentialInterestNotes)
          .leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
          .where(inArray(residentialInterestNotes.residentialInterestId, interestIds))
      : Promise.resolve([] as never[]),
  ]);

  const merged: AggregatedNote[] = [
    ...clientLevel.map((n) => ({
      ...n,
      source: 'residential_client' as const,
      sourceLabel: 'Resident',
    })),
    ...interestLevel.map((n) => ({
      ...n,
      source: 'residential_interest' as const,
      sourceLabel: 'Inquiry',
    })),
  ];
  merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
  return merged;
}

export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
  await verifyParentBelongsToPort(entityType, entityId, portId);

  if (entityType === 'clients') {
    return db
      .select({
        id: clientNotes.id,
        clientId: clientNotes.clientId,
        authorId: clientNotes.authorId,
        content: clientNotes.content,
        mentions: clientNotes.mentions,
        isLocked: clientNotes.isLocked,
        createdAt: clientNotes.createdAt,
        updatedAt: clientNotes.updatedAt,
        authorName: userProfiles.displayName,
      })
      .from(clientNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
      .where(eq(clientNotes.clientId, entityId))
      .orderBy(desc(clientNotes.createdAt));
  } else if (entityType === 'interests') {
    return db
      .select({
        id: interestNotes.id,
        interestId: interestNotes.interestId,
        authorId: interestNotes.authorId,
        content: interestNotes.content,
        mentions: interestNotes.mentions,
        isLocked: interestNotes.isLocked,
        createdAt: interestNotes.createdAt,
        updatedAt: interestNotes.updatedAt,
        authorName: userProfiles.displayName,
        pipelineStageAtCreation: interestNotes.pipelineStageAtCreation,
      })
      .from(interestNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
      .where(eq(interestNotes.interestId, entityId))
      .orderBy(desc(interestNotes.createdAt));
  } else if (entityType === 'yachts') {
    return db
      .select({
        id: yachtNotes.id,
        yachtId: yachtNotes.yachtId,
        authorId: yachtNotes.authorId,
        content: yachtNotes.content,
        mentions: yachtNotes.mentions,
        isLocked: yachtNotes.isLocked,
        createdAt: yachtNotes.createdAt,
        updatedAt: yachtNotes.updatedAt,
        authorName: userProfiles.displayName,
      })
      .from(yachtNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
      .where(eq(yachtNotes.yachtId, entityId))
      .orderBy(desc(yachtNotes.createdAt));
  } else if (entityType === 'companies') {
    return db
      .select({
        id: companyNotes.id,
        companyId: companyNotes.companyId,
        authorId: companyNotes.authorId,
        content: companyNotes.content,
        mentions: companyNotes.mentions,
        isLocked: companyNotes.isLocked,
        createdAt: companyNotes.createdAt,
        updatedAt: companyNotes.updatedAt,
        authorName: userProfiles.displayName,
      })
      .from(companyNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
      .where(eq(companyNotes.companyId, entityId))
      .orderBy(desc(companyNotes.createdAt));
  } else if (entityType === 'residential_clients') {
    return db
      .select({
        id: residentialClientNotes.id,
        residentialClientId: residentialClientNotes.residentialClientId,
        authorId: residentialClientNotes.authorId,
        content: residentialClientNotes.content,
        mentions: residentialClientNotes.mentions,
        isLocked: residentialClientNotes.isLocked,
        createdAt: residentialClientNotes.createdAt,
        updatedAt: residentialClientNotes.updatedAt,
        authorName: userProfiles.displayName,
      })
      .from(residentialClientNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
      .where(eq(residentialClientNotes.residentialClientId, entityId))
      .orderBy(desc(residentialClientNotes.createdAt));
  } else {
    return db
      .select({
        id: residentialInterestNotes.id,
        residentialInterestId: residentialInterestNotes.residentialInterestId,
        authorId: residentialInterestNotes.authorId,
        content: residentialInterestNotes.content,
        mentions: residentialInterestNotes.mentions,
        isLocked: residentialInterestNotes.isLocked,
        createdAt: residentialInterestNotes.createdAt,
        updatedAt: residentialInterestNotes.updatedAt,
        authorName: userProfiles.displayName,
      })
      .from(residentialInterestNotes)
      .leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
      .where(eq(residentialInterestNotes.residentialInterestId, entityId))
      .orderBy(desc(residentialInterestNotes.createdAt));
  }
}

export async function create(
  portId: string,
  entityType: EntityType,
  entityId: string,
  authorId: string,
  data: CreateNoteInput,
) {
  await verifyParentBelongsToPort(entityType, entityId, portId);

  if (entityType === 'yachts') {
    const [note] = await db
      .insert(yachtNotes)
      .values({ yachtId: entityId, authorId, content: data.content })
      .returning();
    if (!note)
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Yacht note insert returned no row',
      });
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, authorId))
      .limit(1);
    return { ...note, authorName: profile[0]?.displayName ?? null };
  }
  if (entityType === 'companies') {
    const [note] = await db
      .insert(companyNotes)
      .values({ companyId: entityId, authorId, content: data.content })
      .returning();
    if (!note)
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Company note insert returned no row',
      });
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, authorId))
      .limit(1);
    return { ...note, authorName: profile[0]?.displayName ?? null };
  }
  if (entityType === 'clients') {
    const [note] = await db
      .insert(clientNotes)
      .values({ clientId: entityId, authorId, content: data.content })
      .returning();

    if (!note)
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Client note insert returned no row',
      });

    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, authorId))
      .limit(1);

    const authorName = profile[0]?.displayName ?? null;

    // Fire mention notifications (fire-and-forget)
    if (note.mentions && note.mentions.length > 0) {
      for (const mentionedUserId of note.mentions) {
        void import('@/lib/services/notifications.service').then(({ createNotification }) =>
          createNotification({
            portId,
            userId: mentionedUserId,
            type: 'mention',
            title: 'You were mentioned in a note',
            description: `${authorName ?? 'Someone'} mentioned you in a note`,
            link: `/clients/${entityId}`,
            entityType: 'client',
            entityId,
            dedupeKey: `note:${note.id}:mention:${mentionedUserId}`,
          }),
        );
      }
    }

    return { ...note, authorName };
  }
  if (entityType === 'interests') {
    // Snapshot the linked interest's current pipeline_stage so the note
    // carries the stage it was authored in. Powers the stage chip on the
    // NotesList timeline. Missing-interest is treated as a no-stamp
    // create rather than a hard failure.
    const interestRow = await db.query.interests.findFirst({
      where: eq(interests.id, entityId),
      columns: { pipelineStage: true },
    });
    const [note] = await db
      .insert(interestNotes)
      .values({
        interestId: entityId,
        authorId,
        content: data.content,
        pipelineStageAtCreation: interestRow?.pipelineStage ?? null,
      })
      .returning();

    if (!note)
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Interest note insert returned no row',
      });

    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, authorId))
      .limit(1);

    const authorName = profile[0]?.displayName ?? null;

    // Fire mention notifications (fire-and-forget)
    if (note.mentions && note.mentions.length > 0) {
      for (const mentionedUserId of note.mentions) {
        void import('@/lib/services/notifications.service').then(({ createNotification }) =>
          createNotification({
            portId,
            userId: mentionedUserId,
            type: 'mention',
            title: 'You were mentioned in a note',
            description: `${authorName ?? 'Someone'} mentioned you in a note`,
            link: `/interests/${entityId}`,
            entityType: 'interest',
            entityId,
            dedupeKey: `note:${note.id}:mention:${mentionedUserId}`,
          }),
        );
      }
    }

    return { ...note, authorName };
  }
  if (entityType === 'residential_clients') {
    const [note] = await db
      .insert(residentialClientNotes)
      .values({ residentialClientId: entityId, authorId, content: data.content })
      .returning();
    if (!note)
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Residential client note insert returned no row',
      });
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, authorId))
      .limit(1);
    return { ...note, authorName: profile[0]?.displayName ?? null };
  }
  if (entityType === 'residential_interests') {
    const [note] = await db
      .insert(residentialInterestNotes)
      .values({ residentialInterestId: entityId, authorId, content: data.content })
      .returning();
    if (!note)
      throw new CodedError('INSERT_RETURNING_EMPTY', {
        internalMessage: 'Residential interest note insert returned no row',
      });
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, authorId))
      .limit(1);
    return { ...note, authorName: profile[0]?.displayName ?? null };
  }
  throw new CodedError('INTERNAL', {
    internalMessage: `Unsupported entityType: ${entityType as string}`,
  });
}

export async function update(
  portId: string,
  entityType: EntityType,
  entityId: string,
  noteId: string,
  data: UpdateNoteInput,
) {
  await verifyParentBelongsToPort(entityType, entityId, portId);

  if (entityType === 'yachts') {
    const [existing] = await db
      .select()
      .from(yachtNotes)
      .where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    const [updated] = await db
      .update(yachtNotes)
      .set({ content: data.content, updatedAt: new Date() })
      // M-MT02: defense-in-depth - pin the UPDATE to the (id, parent) pair
      // so a swapped noteId can't land on a sibling yacht's note even if
      // the existing read above already validated ownership.
      .where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
      .returning();
    if (!updated) throw new NotFoundError('Note');
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, updated.authorId))
      .limit(1);
    return { ...updated, authorName: profile[0]?.displayName ?? null };
  }
  if (entityType === 'companies') {
    const [existing] = await db
      .select()
      .from(companyNotes)
      .where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    const [updated] = await db
      .update(companyNotes)
      .set({ content: data.content, updatedAt: new Date() })
      // M-MT02: pin (id, parent) for defense-in-depth.
      .where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
      .returning();
    if (!updated) throw new NotFoundError('Note');
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, updated.authorId))
      .limit(1);
    return {
      ...updated,
      authorName: profile[0]?.displayName ?? null,
    };
  }
  if (entityType === 'clients') {
    const [existing] = await db
      .select()
      .from(clientNotes)
      .where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)))
      .limit(1);

    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }

    const [updated] = await db
      .update(clientNotes)
      .set({ content: data.content, updatedAt: new Date() })
      // M-MT02: pin (id, parent) for defense-in-depth.
      .where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)))
      .returning();

    if (!updated) throw new NotFoundError('Note');

    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, updated.authorId))
      .limit(1);

    return { ...updated, authorName: profile[0]?.displayName ?? null };
  }
  if (entityType === 'residential_clients') {
    const [existing] = await db
      .select()
      .from(residentialClientNotes)
      .where(
        and(
          eq(residentialClientNotes.id, noteId),
          eq(residentialClientNotes.residentialClientId, entityId),
        ),
      )
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    const [updated] = await db
      .update(residentialClientNotes)
      .set({ content: data.content, updatedAt: new Date() })
      // M-MT02: pin (id, parent) for defense-in-depth.
      .where(
        and(
          eq(residentialClientNotes.id, noteId),
          eq(residentialClientNotes.residentialClientId, entityId),
        ),
      )
      .returning();
    if (!updated) throw new NotFoundError('Note');
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, updated.authorId))
      .limit(1);
    return { ...updated, authorName: profile[0]?.displayName ?? null };
  }
  if (entityType === 'residential_interests') {
    const [existing] = await db
      .select()
      .from(residentialInterestNotes)
      .where(
        and(
          eq(residentialInterestNotes.id, noteId),
          eq(residentialInterestNotes.residentialInterestId, entityId),
        ),
      )
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    const [updated] = await db
      .update(residentialInterestNotes)
      .set({ content: data.content, updatedAt: new Date() })
      // M-MT02: pin (id, parent) for defense-in-depth.
      .where(
        and(
          eq(residentialInterestNotes.id, noteId),
          eq(residentialInterestNotes.residentialInterestId, entityId),
        ),
      )
      .returning();
    if (!updated) throw new NotFoundError('Note');
    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, updated.authorId))
      .limit(1);
    return { ...updated, authorName: profile[0]?.displayName ?? null };
  }
  // Default: interests (the marina-side, not residential)
  {
    const [existing] = await db
      .select()
      .from(interestNotes)
      .where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)))
      .limit(1);

    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }

    const [updated] = await db
      .update(interestNotes)
      .set({ content: data.content, updatedAt: new Date() })
      // M-MT02: pin (id, parent) for defense-in-depth.
      .where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)))
      .returning();

    if (!updated) throw new NotFoundError('Note');

    const profile = await db
      .select({ displayName: userProfiles.displayName })
      .from(userProfiles)
      .where(eq(userProfiles.userId, updated.authorId))
      .limit(1);

    return { ...updated, authorName: profile[0]?.displayName ?? null };
  }
}

export async function deleteNote(
  portId: string,
  entityType: EntityType,
  entityId: string,
  noteId: string,
) {
  await verifyParentBelongsToPort(entityType, entityId, portId);

  if (entityType === 'yachts') {
    const [existing] = await db
      .select()
      .from(yachtNotes)
      .where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)))
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    // M-MT02: pin (id, parent) on the delete WHERE for defense-in-depth.
    await db
      .delete(yachtNotes)
      .where(and(eq(yachtNotes.id, noteId), eq(yachtNotes.yachtId, entityId)));
    return existing;
  }
  if (entityType === 'companies') {
    const [existing] = await db
      .select()
      .from(companyNotes)
      .where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)))
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    // M-MT02: pin (id, parent).
    await db
      .delete(companyNotes)
      .where(and(eq(companyNotes.id, noteId), eq(companyNotes.companyId, entityId)));
    return existing;
  }
  if (entityType === 'clients') {
    const [existing] = await db
      .select()
      .from(clientNotes)
      .where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)))
      .limit(1);

    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }

    // M-MT02: pin (id, parent).
    await db
      .delete(clientNotes)
      .where(and(eq(clientNotes.id, noteId), eq(clientNotes.clientId, entityId)));
    return existing;
  }
  if (entityType === 'residential_clients') {
    const [existing] = await db
      .select()
      .from(residentialClientNotes)
      .where(
        and(
          eq(residentialClientNotes.id, noteId),
          eq(residentialClientNotes.residentialClientId, entityId),
        ),
      )
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    // M-MT02: pin (id, parent).
    await db
      .delete(residentialClientNotes)
      .where(
        and(
          eq(residentialClientNotes.id, noteId),
          eq(residentialClientNotes.residentialClientId, entityId),
        ),
      );
    return existing;
  }
  if (entityType === 'residential_interests') {
    const [existing] = await db
      .select()
      .from(residentialInterestNotes)
      .where(
        and(
          eq(residentialInterestNotes.id, noteId),
          eq(residentialInterestNotes.residentialInterestId, entityId),
        ),
      )
      .limit(1);
    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }
    // M-MT02: pin (id, parent).
    await db
      .delete(residentialInterestNotes)
      .where(
        and(
          eq(residentialInterestNotes.id, noteId),
          eq(residentialInterestNotes.residentialInterestId, entityId),
        ),
      );
    return existing;
  }
  // Default: interests
  {
    const [existing] = await db
      .select()
      .from(interestNotes)
      .where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)))
      .limit(1);

    if (!existing) throw new NotFoundError('Note');
    if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
      throw new ValidationError('Note edit window has expired (15 minutes)');
    }

    // M-MT02: pin (id, parent).
    await db
      .delete(interestNotes)
      .where(and(eq(interestNotes.id, noteId), eq(interestNotes.interestId, entityId)));
    return existing;
  }
}
