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

import { db } from '@/lib/db';
import { files, documents } from '@/lib/db/schema/documents';
import { expenses } from '@/lib/db/schema/financial';
import { berthMaintenanceLog } from '@/lib/db/schema/berths';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { getStorageBackend, presignDownloadUrl } from '@/lib/storage';
import { buildListQuery } from '@/lib/db/query-builder';
import { env } from '@/lib/env';
import {
  ALLOWED_MIME_TYPES,
  MAX_FILE_SIZE,
  PREVIEWABLE_MIMES,
  bufferMatchesMime,
} from '@/lib/constants/file-validation';
import { getAllBerthMooringsForInterests } from '@/lib/services/interest-berths.service';
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
import { documentFolders } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { interests } from '@/lib/db/schema/interests';
import type { EntityType } from '@/lib/services/document-folders.service';

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

interface UploadFileParams {
  buffer: Buffer;
  originalName: string;
  mimeType: string;
  size: number;
}

// ─── Upload ───────────────────────────────────────────────────────────────────

export async function uploadFile(
  portId: string,
  portSlug: string,
  file: UploadFileParams,
  data: UploadFileInput,
  meta: AuditMeta,
) {
  if (!ALLOWED_MIME_TYPES.has(file.mimeType)) {
    throw new ValidationError(`File type '${file.mimeType}' is not allowed`);
  }

  if (file.size > MAX_FILE_SIZE) {
    throw new ValidationError('File exceeds maximum size of 50MB');
  }

  // Magic-byte verification - without this, the browser-declared MIME is
  // attacker-controlled and a malicious uploader could ship arbitrary
  // bytes through the ALLOWED_MIME_TYPES allow-list (auditor-E3 §27).
  // Berth-PDF and brochure paths already do this; the generic uploader
  // matches their guarantee here.
  if (!bufferMatchesMime(file.buffer, file.mimeType)) {
    throw new ValidationError(`File contents do not match the declared type '${file.mimeType}'`);
  }

  // Image normalisation (asset-auditor C1+C2+H1+H3): re-encode via
  // sharp to strip EXIF (incl. GPS), drop polyglot trailing bytes, cap
  // dimensions against decompression-bomb PNGs, and freeze animated
  // GIFs to a single frame. Skips when sharp isn't available or the
  // declared MIME isn't an image; failures fall back to the original
  // buffer with a warning.
  let normalizedBuffer = file.buffer;
  let normalizedSize = file.size;
  if (file.mimeType.startsWith('image/')) {
    try {
      const { normalizeImage } = await import('@/lib/services/image-normalize');
      const normalized = await normalizeImage(file.buffer, file.mimeType);
      normalizedBuffer = normalized.buffer;
      normalizedSize = normalized.buffer.byteLength;
    } catch (err) {
      const { logger } = await import('@/lib/logger');
      logger.warn(
        { err, mimeType: file.mimeType },
        'image normalization failed; storing original (EXIF retained)',
      );
    }
  }

  const entity = data.entityType ?? 'general';
  const entityId = data.entityId ?? portId;
  const storagePath = generateStorageKey(portSlug, entity, entityId, file.mimeType);
  const sanitizedOriginal = sanitizeFilename(file.originalName);
  const sanitizedFilename = sanitizeFilename(data.filename);

  const backend = await getStorageBackend();
  await backend.put(storagePath, normalizedBuffer, {
    contentType: file.mimeType,
    sizeBytes: normalizedSize,
  });

  // Derive the entity FK from (entityType, entityId) when the caller
  // didn't pass it explicitly. Without this, an interest-tab upload that
  // sets `entityType='client'` + `entityId=<UUID>` lands with
  // `client_id=NULL` - the Attachments list filters on `clientId` and
  // the file vanishes from the interest's Documents tab.
  const derivedClientId =
    data.clientId ?? (data.entityType === 'client' ? (data.entityId ?? null) : null);
  const derivedCompanyId =
    data.companyId ?? (data.entityType === 'company' ? (data.entityId ?? null) : null);
  const derivedYachtId =
    data.yachtId ?? (data.entityType === 'yacht' ? (data.entityId ?? null) : null);

  // E8: auto-set entity FK from system-managed folder when the rep uploads
  // directly into a client/company/yacht folder. No-op for non-system folders.
  const enrichedValues = await applyEntityFkFromFolder(portId, {
    portId,
    clientId: derivedClientId,
    yachtId: derivedYachtId,
    companyId: derivedCompanyId,
    folderId: data.folderId ?? null,
    filename: sanitizedFilename,
    originalName: sanitizedOriginal,
    mimeType: file.mimeType,
    sizeBytes: String(normalizedSize),
    storagePath,
    storageBucket: env.MINIO_BUCKET,
    category: data.category ?? null,
    uploadedBy: meta.userId,
  });

  const [record] = await db.insert(files).values(enrichedValues).returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'file',
    entityId: record!.id,
    newValue: { filename: record!.filename, mimeType: file.mimeType, size: file.size },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'file:uploaded', {
    fileId: record!.id,
    filename: record!.filename,
  });

  return record!;
}

// ─── Download / Preview URLs ──────────────────────────────────────────────────

export async function getDownloadUrl(id: string, portId: string) {
  const file = await getFileById(id, portId);
  // Pass the canonical filename through to the presign so MinIO/S3
  // returns Content-Disposition with the original name. Without the
  // override the file lands with the bare storage-key UUID (no
  // extension) in every browser.
  const url = await presignDownloadUrl(file.storagePath, 900, file.filename);
  return { url, filename: file.filename };
}

export async function getPreviewUrl(id: string, portId: string) {
  const file = await getFileById(id, portId);

  if (!file.mimeType || !PREVIEWABLE_MIMES.has(file.mimeType)) {
    throw new ValidationError('This file type cannot be previewed');
  }

  const url = await presignDownloadUrl(file.storagePath);
  return { url, mimeType: file.mimeType };
}

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

/**
 * Categories that gate system surfaces and must never be settable through the
 * general (validated) update path. `branding` is the public-stream gate on
 * `/api/public/files/[id]`; `avatar` is system-managed. The upload/update Zod
 * schemas (`userFileCategorySchema`) already reject these, so this is
 * belt-and-suspenders for any future non-HTTP caller (M24).
 *
 * NB: `uploadFile` is intentionally NOT guarded — the admin branding writer
 * (`admin/settings/image` route) and the avatar writer (`me/avatar` route)
 * legitimately call the service with `category: 'branding' | 'avatar'`,
 * constructing the payload inline and bypassing the Zod schema. Guarding the
 * upload service would break those legitimate system writers. `updateFile`,
 * by contrast, has a single schema-validated caller and no legitimate
 * reserved-category use, so it is safe to harden here.
 */
const RESERVED_FILE_CATEGORIES = new Set(['branding', 'avatar']);

export async function updateFile(
  id: string,
  portId: string,
  data: UpdateFileInput,
  meta: AuditMeta,
) {
  const existing = await getFileById(id, portId);

  if (data.category !== undefined && RESERVED_FILE_CATEGORIES.has(data.category)) {
    throw new ValidationError(`Category '${data.category}' is reserved and cannot be set`);
  }

  const updates: { filename?: string; category?: string } = {};
  if (data.filename !== undefined) updates.filename = sanitizeFilename(data.filename);
  if (data.category !== undefined) updates.category = data.category;

  const [updated] = await db
    .update(files)
    .set(updates)
    .where(and(eq(files.id, id), eq(files.portId, portId)))
    .returning();

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'file',
    entityId: id,
    oldValue: { filename: existing.filename, category: existing.category },
    newValue: updates,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'file:updated', { fileId: id });

  return updated!;
}

// ─── Delete (BR-091) ──────────────────────────────────────────────────────────

export async function deleteFile(id: string, portId: string, meta: AuditMeta) {
  const existing = await getFileById(id, portId);

  // BR-091: check references before deleting
  const [docRefs, expenseRefs, maintenanceRefs] = await Promise.all([
    db
      .select({ id: documents.id })
      .from(documents)
      .where(
        and(
          eq(documents.portId, portId),
          or(eq(documents.fileId, id), eq(documents.signedFileId, id)),
        ),
      )
      .limit(1),
    db
      .select({ id: expenses.id })
      .from(expenses)
      .where(and(eq(expenses.portId, portId), arrayContains(expenses.receiptFileIds, [id])))
      .limit(1),
    db
      .select({ id: berthMaintenanceLog.id })
      .from(berthMaintenanceLog)
      .where(
        and(
          eq(berthMaintenanceLog.portId, portId),
          arrayContains(berthMaintenanceLog.photoFileIds, [id]),
        ),
      )
      .limit(1),
  ]);

  if (docRefs.length > 0 || expenseRefs.length > 0 || maintenanceRefs.length > 0) {
    throw new ConflictError('File cannot be deleted because it is referenced by other records');
  }

  // Delete the blob first, then DB. The storage backend's delete is
  // idempotent, so a partial replay (worker crashed mid-delete) does not
  // throw on the missing-object retry.
  await (await getStorageBackend()).delete(existing.storagePath);

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

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'delete',
    entityType: 'file',
    entityId: id,
    oldValue: { filename: existing.filename },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'file:deleted', { fileId: id });
}

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

export async function listFiles(portId: string, query: ListFilesInput) {
  const {
    page,
    limit,
    sort,
    order,
    search,
    clientId,
    yachtId,
    companyId,
    interestId,
    category,
    folderId,
  } = query;

  const filters = [];

  if (clientId) {
    filters.push(eq(files.clientId, clientId));
  }
  if (yachtId) {
    filters.push(eq(files.yachtId, yachtId));
  }
  if (companyId) {
    filters.push(eq(files.companyId, companyId));
  }
  if (interestId) {
    filters.push(eq(files.interestId, interestId));
  }
  if (category) {
    filters.push(eq(files.category, category));
  }
  // folderId === null sentinel is the root folder (no parent); a UUID
  // narrows to that specific folder. `undefined` returns files across
  // every folder for the port (existing legacy behaviour).
  if (folderId !== undefined) {
    filters.push(folderId === null ? isNull(files.folderId) : eq(files.folderId, folderId));
  }

  const sortColumn =
    sort === 'filename' ? files.filename : sort === 'sizeBytes' ? files.sizeBytes : files.createdAt;

  const base = await buildListQuery({
    table: files,
    portIdColumn: files.portId,
    portId,
    idColumn: files.id,
    updatedAtColumn: files.createdAt, // no updatedAt on files
    searchColumns: [files.filename, files.originalName],
    searchTerm: search,
    filters,
    sort: sort ? { column: sortColumn, direction: order } : undefined,
    page,
    pageSize: limit,
    // no archivedAtColumn - files are immutable records
  });

  // Enrichment pass: resolve folder name + display name for any entity
  // FK present on the row. Drives the HubRoot recent-files context
  // (folder breadcrumb + entity badge) without forcing each caller to
  // chase separate queries. Batched so adding 20 files = 5 queries max.
  const rows = base.data as Array<{
    id: string;
    folderId: string | null;
    clientId: string | null;
    yachtId: string | null;
    companyId: string | null;
    interestId: string | null;
  }>;
  const folderIds = Array.from(
    new Set(rows.map((r) => r.folderId).filter((v): v is string => !!v)),
  );
  const clientIds = Array.from(
    new Set(rows.map((r) => r.clientId).filter((v): v is string => !!v)),
  );
  const yachtIds = Array.from(new Set(rows.map((r) => r.yachtId).filter((v): v is string => !!v)));
  const companyIds = Array.from(
    new Set(rows.map((r) => r.companyId).filter((v): v is string => !!v)),
  );
  const interestIds = Array.from(
    new Set(rows.map((r) => r.interestId).filter((v): v is string => !!v)),
  );

  const [folderRows, clientRows, yachtRows, companyRows, interestRows] = await Promise.all([
    folderIds.length > 0
      ? db
          .select({ id: documentFolders.id, name: documentFolders.name })
          .from(documentFolders)
          .where(inArray(documentFolders.id, folderIds))
      : Promise.resolve([]),
    clientIds.length > 0
      ? db
          .select({ id: clients.id, name: clients.fullName })
          .from(clients)
          .where(inArray(clients.id, clientIds))
      : Promise.resolve([]),
    yachtIds.length > 0
      ? db
          .select({ id: yachts.id, name: yachts.name })
          .from(yachts)
          .where(inArray(yachts.id, yachtIds))
      : Promise.resolve([]),
    companyIds.length > 0
      ? db
          .select({ id: companies.id, name: companies.legalName })
          .from(companies)
          .where(inArray(companies.id, companyIds))
      : Promise.resolve([]),
    interestIds.length > 0
      ? db
          .select({
            id: interests.id,
            clientId: interests.clientId,
            pipelineStage: interests.pipelineStage,
          })
          .from(interests)
          .where(inArray(interests.id, interestIds))
      : Promise.resolve([]),
  ]);
  const folderMap = new Map(folderRows.map((r) => [r.id, r.name]));
  const clientMap = new Map(clientRows.map((r) => [r.id, r.name]));
  const yachtMap = new Map(yachtRows.map((r) => [r.id, r.name]));
  const companyMap = new Map(companyRows.map((r) => [r.id, r.name]));
  const interestMap = new Map(interestRows.map((r) => [r.id, r]));

  const enriched = rows.map((r) => ({
    ...r,
    folderName: r.folderId ? (folderMap.get(r.folderId) ?? null) : null,
    clientName: r.clientId ? (clientMap.get(r.clientId) ?? null) : null,
    yachtName: r.yachtId ? (yachtMap.get(r.yachtId) ?? null) : null,
    companyName: r.companyId ? (companyMap.get(r.companyId) ?? null) : null,
    interestSummary: r.interestId
      ? (() => {
          const i = interestMap.get(r.interestId);
          if (!i) return null;
          const cName = i.clientId ? (clientMap.get(i.clientId) ?? null) : null;
          return { stage: i.pipelineStage, clientName: cName };
        })()
      : null,
  }));
  return { ...base, data: enriched };
}

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

export async function getFileById(id: string, portId: string) {
  const file = await db.query.files.findFirst({
    where: eq(files.id, id),
  });

  if (!file || file.portId !== portId) {
    throw new NotFoundError('File');
  }

  return file;
}

// ─── Aggregated Projection ────────────────────────────────────────────────────

/**
 * Row shape returned by the aggregated projection. Note this intentionally
 * omits `storagePath` and `storageBucket` - those are internal storage
 * implementation details and must not leak out of the API to rep clients.
 * Callers that need to download a file must use the documents/file
 * download endpoint, which presigns from the bucket using the id, not the
 * raw path.
 */
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
  signedFromDocumentId: string | null;
  /** When the file is tagged to an interest, the multi-berth label of
   *  that interest (e.g. "A1-A3, B5") - or null. Resolved in
   *  listFilesAggregated* via one batched mooring lookup per request.
   *  Lets EntityFolderView render a per-row "which deal" badge so reps
   *  can tell apart files that all look identical on a multi-deal
   *  client. */
  interestBerthLabel: string | null;
};

export interface AggregatedFileGroup {
  label: string;
  source: 'direct' | 'client' | 'company' | 'yacht' | 'this_deal' | 'other_deals';
  files: AggregatedFileRow[];
  total: number;
}

interface AggregatedFilesResult {
  groups: AggregatedFileGroup[];
}

const GROUP_LIMIT = 20;

/**
 * Walk the relationship graph from the requested entity and return
 * files grouped by source. Symmetric reach.
 *
 * Source of truth: each file's snapshotted entity FKs.
 * Defense-in-depth: port_id at every entity / membership / yacht / file join.
 */
async function listFilesAggregatedForInterest(
  portId: string,
  interestId: string,
  clientId: string | null,
): Promise<AggregatedFilesResult> {
  const groups: AggregatedFileGroup[] = [];

  // "THIS DEAL" — files explicitly tagged with this interest_id.
  const thisDeal = await fetchGroupRows(portId, eq(files.interestId, interestId), GROUP_LIMIT);
  if (thisDeal.rows.length > 0) {
    groups.push({
      label: 'THIS DEAL',
      source: 'this_deal',
      files: thisDeal.rows,
      total: thisDeal.total,
    });
  }

  if (clientId) {
    // "FROM CLIENT" — files on the same client that aren't tagged to
    // this interest. NULL interest_id = client-level, non-deal-specific.
    // A different interest id = another deal's docs, still useful to
    // surface so the rep can pull from history without leaving the
    // current detail page.
    const fromClient = await fetchGroupRows(
      portId,
      and(
        eq(files.clientId, clientId),
        or(isNull(files.interestId), sql`${files.interestId} <> ${interestId}`),
      )!,
      GROUP_LIMIT,
    );
    if (fromClient.rows.length > 0) {
      groups.push({
        label: 'FROM CLIENT',
        source: 'other_deals',
        files: fromClient.rows,
        total: fromClient.total,
      });
    }

    // Symmetric reach via the client's related companies + yachts.
    const related = await collectRelatedEntities(portId, 'client', clientId);
    for (const { id, name } of related.companies) {
      const g = await fetchGroupRows(portId, eq(files.companyId, id), GROUP_LIMIT);
      if (g.rows.length === 0) continue;
      groups.push({
        label: `FROM COMPANY: ${name.toUpperCase()}`,
        source: 'company',
        files: g.rows,
        total: g.total,
      });
    }
    for (const { id, name } of related.yachts) {
      const g = await fetchGroupRows(portId, eq(files.yachtId, id), GROUP_LIMIT);
      if (g.rows.length === 0) continue;
      groups.push({
        label: `FROM YACHT: ${name.toUpperCase()}`,
        source: 'yacht',
        files: g.rows,
        total: g.total,
      });
    }
  }

  return { groups };
}

export async function listFilesAggregatedByEntity(
  portId: string,
  entityType: EntityType,
  entityId: string,
): Promise<AggregatedFilesResult> {
  const entityExists = await assertEntityInPort(portId, entityType, entityId);
  if (!entityExists) return { groups: [] };

  // Interest view: split direct-attached files into "this deal" (files
  // explicitly tagged with this interestId) + "from client" (files on
  // the same client but no interestId OR a different interest). The
  // client-level companies/yachts walk is reused via the underlying
  // client.
  if (entityType === 'interest') {
    const interest = await db.query.interests.findFirst({
      where: and(eq(interests.id, entityId), eq(interests.portId, portId)),
      columns: { id: true, clientId: true },
    });
    if (!interest) return { groups: [] };
    const result = await listFilesAggregatedForInterest(
      portId,
      interest.id,
      interest.clientId ?? null,
    );
    await attachInterestBerthLabels(result.groups);
    return result;
  }

  const related = await collectRelatedEntities(portId, entityType, entityId);
  const groups: AggregatedFileGroup[] = [];

  const directColumn =
    entityType === 'client'
      ? files.clientId
      : entityType === 'company'
        ? files.companyId
        : files.yachtId;

  const direct = await fetchGroupRows(portId, eq(directColumn, entityId), GROUP_LIMIT);
  if (direct.rows.length > 0) {
    groups.push({
      label: 'DIRECTLY ATTACHED',
      source: 'direct',
      files: direct.rows,
      total: direct.total,
    });
  }

  for (const { id, name } of related.companies) {
    const g = await fetchGroupRows(portId, eq(files.companyId, id), GROUP_LIMIT);
    if (g.rows.length === 0) continue;
    groups.push({
      label: `FROM COMPANY: ${name.toUpperCase()}`,
      source: 'company',
      files: g.rows,
      total: g.total,
    });
  }

  for (const { id, name } of related.yachts) {
    const g = await fetchGroupRows(portId, eq(files.yachtId, id), GROUP_LIMIT);
    if (g.rows.length === 0) continue;
    groups.push({
      label: `FROM YACHT: ${name.toUpperCase()}`,
      source: 'yacht',
      files: g.rows,
      total: g.total,
    });
  }

  for (const { id, name } of related.clients) {
    const g = await fetchGroupRows(portId, eq(files.clientId, id), GROUP_LIMIT);
    if (g.rows.length === 0) continue;
    groups.push({
      label: `FROM CLIENT: ${name.toUpperCase()}`,
      source: 'client',
      files: g.rows,
      total: g.total,
    });
  }

  await attachInterestBerthLabels(groups);

  return { groups };
}

export async function assertEntityInPort(
  portId: string,
  entityType: EntityType,
  entityId: string,
): Promise<boolean> {
  if (entityType === 'client') {
    const c = await db.query.clients.findFirst({
      where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
      columns: { id: true },
    });
    return Boolean(c);
  }
  if (entityType === 'company') {
    const c = await db.query.companies.findFirst({
      where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
      columns: { id: true },
    });
    return Boolean(c);
  }
  if (entityType === 'interest') {
    const i = await db.query.interests.findFirst({
      where: and(eq(interests.id, entityId), eq(interests.portId, portId)),
      columns: { id: true },
    });
    return Boolean(i);
  }
  const y = await db.query.yachts.findFirst({
    where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
    columns: { id: true },
  });
  return Boolean(y);
}

export interface RelatedEntities {
  clients: Array<{ id: string; name: string }>;
  companies: Array<{ id: string; name: string }>;
  yachts: Array<{ id: string; name: string }>;
}

/**
 * Walk the relationship graph and collect related entity ids per
 * source bucket. Symmetric reach. Every join carries port_id.
 *
 * Note: clients schema has fullName only (no firstName/lastName).
 */
export async function collectRelatedEntities(
  portId: string,
  entityType: EntityType,
  entityId: string,
): Promise<RelatedEntities> {
  if (entityType === 'client') {
    const memberCompanies = await db
      .select({ id: companies.id, name: companies.name })
      .from(companyMemberships)
      .innerJoin(
        companies,
        and(eq(companies.id, companyMemberships.companyId), eq(companies.portId, portId)),
      )
      .where(and(eq(companyMemberships.clientId, entityId), isNull(companyMemberships.endDate)));

    const directYachts = await db
      .select({ id: yachts.id, name: yachts.name })
      .from(yachts)
      .where(
        and(
          eq(yachts.portId, portId),
          eq(yachts.currentOwnerType, 'client'),
          eq(yachts.currentOwnerId, entityId),
        ),
      );

    let companyYachts: Array<{ id: string; name: string }> = [];
    if (memberCompanies.length > 0) {
      companyYachts = await db
        .select({ id: yachts.id, name: yachts.name })
        .from(yachts)
        .where(
          and(
            eq(yachts.portId, portId),
            eq(yachts.currentOwnerType, 'company'),
            inArray(
              yachts.currentOwnerId,
              memberCompanies.map((c) => c.id),
            ),
          ),
        );
    }

    return {
      clients: [],
      companies: memberCompanies,
      yachts: dedupeBy([...directYachts, ...companyYachts], (y) => y.id),
    };
  }

  if (entityType === 'company') {
    // Adapted: use fullName not firstName/lastName.
    const memberClients = await db
      .select({ id: clients.id, fullName: clients.fullName })
      .from(companyMemberships)
      .innerJoin(
        clients,
        and(eq(clients.id, companyMemberships.clientId), eq(clients.portId, portId)),
      )
      .where(and(eq(companyMemberships.companyId, entityId), isNull(companyMemberships.endDate)));

    const ownedYachts = await db
      .select({ id: yachts.id, name: yachts.name })
      .from(yachts)
      .where(
        and(
          eq(yachts.portId, portId),
          eq(yachts.currentOwnerType, 'company'),
          eq(yachts.currentOwnerId, entityId),
        ),
      );

    return {
      clients: memberClients.map((c) => ({ id: c.id, name: c.fullName })),
      companies: [],
      yachts: ownedYachts,
    };
  }

  // yacht view
  const yacht = await db.query.yachts.findFirst({
    where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
  });
  if (!yacht) return { clients: [], companies: [], yachts: [] };

  if (yacht.currentOwnerType === 'client') {
    const owner = await db.query.clients.findFirst({
      where: and(eq(clients.id, yacht.currentOwnerId), eq(clients.portId, portId)),
      columns: { id: true, fullName: true },
    });
    return {
      clients: owner ? [{ id: owner.id, name: owner.fullName }] : [],
      companies: [],
      yachts: [],
    };
  }

  const owner = await db.query.companies.findFirst({
    where: and(eq(companies.id, yacht.currentOwnerId), eq(companies.portId, portId)),
    columns: { id: true, name: true },
  });
  return {
    clients: [],
    companies: owner ? [{ id: owner.id, name: owner.name }] : [],
    yachts: [],
  };
}

async function fetchGroupRows(
  portId: string,
  predicate: ReturnType<typeof eq>,
  limit: number,
): Promise<{
  rows: AggregatedFileRow[];
  total: number;
}> {
  // A3: keep the LEFT JOIN's ON clause minimal so the planner can use the
  // point-lookup index `idx_docs_signed_file_id` on the join, and apply the
  // port_id residual in the WHERE (with the `OR d.id IS NULL` clause so the
  // LEFT-JOIN semantics still preserve unjoined file rows). With port_id in
  // the ON we used to fall back to `idx_docs_port` which is a wide-range scan.
  const rows = await db
    .select({
      id: files.id,
      portId: files.portId,
      clientId: files.clientId,
      yachtId: files.yachtId,
      companyId: files.companyId,
      interestId: files.interestId,
      folderId: files.folderId,
      filename: files.filename,
      originalName: files.originalName,
      mimeType: files.mimeType,
      sizeBytes: files.sizeBytes,
      // storagePath + storageBucket intentionally omitted - see AggregatedFileRow doc.
      category: files.category,
      uploadedBy: files.uploadedBy,
      createdAt: files.createdAt,
      // Reverse-link: if any document row has this file as its signed_file_id,
      // surface that document's id.
      signedFromDocumentId: documents.id,
      // interestBerthLabel is filled post-fetch via a single batched
      // getAllBerthMooringsForInterests call at the entry-point level so
      // we don't N+1 the moorings join inside each group.
    })
    .from(files)
    .leftJoin(documents, eq(documents.signedFileId, files.id))
    .where(
      and(
        eq(files.portId, portId),
        predicate,
        // Defense-in-depth: keep the cross-port-leakage guard on the joined
        // doc row but allow unjoined files (id IS NULL).
        or(eq(documents.portId, portId), isNull(documents.id)),
      ),
    )
    .orderBy(desc(files.createdAt))
    .limit(limit);

  const [countRow] = await db
    .select({ count: sql<number>`count(*)::int` })
    .from(files)
    .where(and(eq(files.portId, portId), predicate));

  // interestBerthLabel filled by the entry-point post-pass (see
  // attachInterestBerthLabels below); default to null inside this row.
  const rowsWithDefault: AggregatedFileRow[] = rows.map((r) => ({
    ...r,
    interestBerthLabel: null,
  }));

  return { rows: rowsWithDefault, total: Number(countRow?.count ?? 0) };
}

/**
 * Mutate the rows of every group in-place to fill `interestBerthLabel`
 * from a single batched mooring lookup. Called by both interest- and
 * entity-aggregation entry-points so EntityFolderView gets the "which
 * deal" badge without an N+1 join.
 */
async function attachInterestBerthLabels(groups: AggregatedFileGroup[]): Promise<void> {
  const interestIds = new Set<string>();
  for (const g of groups) {
    for (const f of g.files) {
      if (f.interestId) interestIds.add(f.interestId);
    }
  }
  if (interestIds.size === 0) return;
  const mooringsMap = await getAllBerthMooringsForInterests(Array.from(interestIds));
  for (const g of groups) {
    for (const f of g.files) {
      if (!f.interestId) continue;
      f.interestBerthLabel = deriveInterestBerthLabel(mooringsMap.get(f.interestId));
    }
  }
}

function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
  const seen = new Set<K>();
  const out: T[] = [];
  for (const item of items) {
    const k = key(item);
    if (seen.has(k)) continue;
    seen.add(k);
    out.push(item);
  }
  return out;
}

// ─── E8: applyEntityFkFromFolder ─────────────────────────────────────────────

/**
 * E8: when a rep manually uploads a file into a system-managed entity
 * subfolder, auto-set the matching entity FK on the file row from the
 * folder's entityType + entityId. Custom (non-system) folders →
 * returns the input unchanged.
 */
export async function applyEntityFkFromFolder<
  T extends {
    clientId?: string | null;
    companyId?: string | null;
    yachtId?: string | null;
    folderId?: string | null;
  },
>(portId: string, payload: T): Promise<T> {
  if (!payload.folderId) return payload;

  const folder = await db.query.documentFolders.findFirst({
    where: and(eq(documentFolders.id, payload.folderId), eq(documentFolders.portId, portId)),
    columns: { systemManaged: true, entityType: true, entityId: true },
  });

  if (!folder || !folder.systemManaged || !folder.entityType || !folder.entityId) {
    return payload;
  }

  if (folder.entityType === 'client' && !payload.clientId) {
    return { ...payload, clientId: folder.entityId };
  }
  if (folder.entityType === 'company' && !payload.companyId) {
    return { ...payload, companyId: folder.entityId };
  }
  if (folder.entityType === 'yacht' && !payload.yachtId) {
    return { ...payload, yachtId: folder.entityId };
  }

  return payload;
}
