/**
 * Builds the structured payload that becomes the JSON + HTML inside a
 * GDPR client-data export. Pure read-side - no writes, no I/O outside
 * Drizzle. The worker pairs this with the actual ZIP/upload/email work.
 *
 * GDPR Article 15 (right of access) requires that we hand the data
 * subject everything we hold about them. This builder enumerates every
 * table that carries a `clientId` foreign key plus the polymorphic
 * yacht ownership rows that resolve to this client.
 */

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

import { db } from '@/lib/db';
import { NotFoundError } from '@/lib/errors';
import {
  clients,
  clientContacts,
  clientAddresses,
  clientNotes,
  clientRelationships,
  clientTags,
  clientMergeLog,
} from '@/lib/db/schema/clients';
import { tags, scratchpadNotes } from '@/lib/db/schema/system';
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { interests } from '@/lib/db/schema/interests';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { invoices } from '@/lib/db/schema/financial';
import { documents, files, formSubmissions } from '@/lib/db/schema/documents';
import { auditLogs } from '@/lib/db/schema/system';
import { portalUsers } from '@/lib/db/schema/portal';
import { emailThreads, emailMessages } from '@/lib/db/schema/email';
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
import { documentSends } from '@/lib/db/schema/brochures';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { payments } from '@/lib/db/schema/pipeline';
import { berthWaitingList } from '@/lib/db/schema/berths';
import { supplementalFormTokens } from '@/lib/db/schema/supplemental-forms';
import { interestFieldHistory } from '@/lib/db/schema/interest-field-history';

export interface GdprBundle {
  /** Bundle metadata for traceability. */
  meta: {
    generatedAt: string;
    portId: string;
    clientId: string;
    schemaVersion: 1;
  };
  client: Record<string, unknown>;
  contacts: Record<string, unknown>[];
  addresses: Record<string, unknown>[];
  tags: Array<{ id: string; name: string; color: string }>;
  relationships: Record<string, unknown>[];
  notes: Record<string, unknown>[];
  ownedYachts: Record<string, unknown>[];
  companyMemberships: Array<{
    membership: Record<string, unknown>;
    company: Record<string, unknown>;
  }>;
  interests: Record<string, unknown>[];
  interestFieldHistory: Record<string, unknown>[];
  contactLog: Record<string, unknown>[];
  tenancies: Record<string, unknown>[];
  berthWaitingList: Record<string, unknown>[];
  payments: Record<string, unknown>[];
  invoices: Record<string, unknown>[];
  documents: Record<string, unknown>[];
  files: Record<string, unknown>[];
  formSubmissions: Record<string, unknown>[];
  supplementalFormTokens: Record<string, unknown>[];
  websiteSubmissions: Record<string, unknown>[];
  documentSends: Record<string, unknown>[];
  emailThreads: Array<{
    thread: Record<string, unknown>;
    messages: Record<string, unknown>[];
  }>;
  reminders: Record<string, unknown>[];
  scratchpadNotes: Record<string, unknown>[];
  portalUsers: Record<string, unknown>[];
  mergeLog: Record<string, unknown>[];
  auditTrail: Record<string, unknown>[];
}

/**
 * Loads every row that references this client across all tenant-scoped
 * tables.
 *
 * Port-scoping: queries on tables that carry a `port_id` column add a
 * redundant `eq(..., portId)` predicate as defense-in-depth, so a stale
 * cross-tenant FK never leaks across. Tables WITHOUT a `port_id` column
 * (clientContacts, clientAddresses, clientRelationships, clientNotes,
 * clientTags, formSubmissions, scratchpadNotes, portalUsers,
 * berthWaitingList) are scoped by `clientId` ONLY — which is safe because
 * `clientId` is a globally-unique UUID and the client itself was already
 * validated against `portId` at the top of `buildClientBundle`.
 */
export async function buildClientBundle(clientId: string, portId: string): Promise<GdprBundle> {
  const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
  if (!client || client.portId !== portId) {
    throw new NotFoundError('Client');
  }

  const [
    contacts,
    addresses,
    relationships,
    notes,
    tagJoins,
    ownedYachts,
    membershipRows,
    interestRows,
    fieldHistoryRows,
    tenancyRows,
    waitingListRows,
    paymentRows,
    invoiceRows,
    documentRows,
    fileRows,
    formSubmissionRows,
    supplementalTokenRows,
    documentSendRows,
    threadRows,
    reminderRows,
    scratchpadRows,
    portalUserRows,
    mergeLogRows,
    auditRows,
  ] = await Promise.all([
    db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }),
    db.query.clientAddresses.findMany({ where: eq(clientAddresses.clientId, clientId) }),
    db.query.clientRelationships.findMany({
      where: or(
        eq(clientRelationships.clientAId, clientId),
        eq(clientRelationships.clientBId, clientId),
      ),
    }),
    db.query.clientNotes.findMany({ where: eq(clientNotes.clientId, clientId) }),
    db
      .select({
        id: tags.id,
        name: tags.name,
        color: tags.color,
      })
      .from(clientTags)
      .innerJoin(tags, eq(clientTags.tagId, tags.id))
      .where(eq(clientTags.clientId, clientId)),
    db.query.yachts.findMany({
      where: and(
        eq(yachts.portId, portId),
        eq(yachts.currentOwnerType, 'client'),
        eq(yachts.currentOwnerId, clientId),
      ),
    }),
    db
      .select({ membership: companyMemberships, company: companies })
      .from(companyMemberships)
      .innerJoin(companies, eq(companyMemberships.companyId, companies.id))
      .where(and(eq(companyMemberships.clientId, clientId), eq(companies.portId, portId))),
    db.query.interests.findMany({
      where: and(eq(interests.clientId, clientId), eq(interests.portId, portId)),
    }),
    // Field-level override history carries the denormalized clientId for
    // direct-edit and supplemental-form overrides alike. Port-scoped too,
    // since interest_field_history has a notNull port_id column.
    db.query.interestFieldHistory.findMany({
      where: and(
        eq(interestFieldHistory.portId, portId),
        eq(interestFieldHistory.clientId, clientId),
      ),
    }),
    db.query.berthTenancies.findMany({
      where: and(eq(berthTenancies.clientId, clientId), eq(berthTenancies.portId, portId)),
    }),
    // berth_waiting_list has no port_id column — scope by clientId only
    // (clientId is a global UUID, client already validated against portId).
    db.query.berthWaitingList.findMany({
      where: eq(berthWaitingList.clientId, clientId),
    }),
    db.query.payments.findMany({
      where: and(eq(payments.portId, portId), eq(payments.clientId, clientId)),
    }),
    db.query.invoices.findMany({
      where: and(
        eq(invoices.portId, portId),
        eq(invoices.billingEntityType, 'client'),
        eq(invoices.billingEntityId, clientId),
      ),
    }),
    db.query.documents.findMany({
      where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)),
    }),
    db.query.files.findMany({
      where: and(eq(files.portId, portId), eq(files.clientId, clientId)),
    }),
    db.query.formSubmissions.findMany({
      where: eq(formSubmissions.clientId, clientId),
    }),
    db.query.supplementalFormTokens.findMany({
      where: and(
        eq(supplementalFormTokens.portId, portId),
        eq(supplementalFormTokens.clientId, clientId),
      ),
    }),
    db.query.documentSends.findMany({
      where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)),
    }),
    db.query.emailThreads.findMany({
      where: and(eq(emailThreads.portId, portId), eq(emailThreads.clientId, clientId)),
    }),
    db.query.reminders.findMany({
      where: and(eq(reminders.portId, portId), eq(reminders.clientId, clientId)),
    }),
    db.query.scratchpadNotes.findMany({
      where: eq(scratchpadNotes.linkedClientId, clientId),
    }),
    db.query.portalUsers.findMany({ where: eq(portalUsers.clientId, clientId) }),
    db.query.clientMergeLog.findMany({
      where: and(
        eq(clientMergeLog.portId, portId),
        or(
          eq(clientMergeLog.survivingClientId, clientId),
          eq(clientMergeLog.mergedClientId, clientId),
        ),
      ),
    }),
    db.query.auditLogs.findMany({
      where: and(
        eq(auditLogs.portId, portId),
        eq(auditLogs.entityType, 'client'),
        eq(auditLogs.entityId, clientId),
      ),
      orderBy: (t, { desc }) => [desc(t.createdAt)],
      limit: 500,
    }),
  ]);

  // Email messages are linked through threads (no direct clientId column).
  const threadIds = threadRows.map((t) => t.id);
  const messageRows = threadIds.length
    ? await db.query.emailMessages.findMany({
        where: inArray(emailMessages.threadId, threadIds),
        orderBy: (t, { asc }) => [asc(t.sentAt)],
      })
    : [];
  const messagesByThread = new Map<string, Record<string, unknown>[]>();
  for (const m of messageRows) {
    const list = messagesByThread.get(m.threadId) ?? [];
    list.push(toJsonRow(m));
    messagesByThread.set(m.threadId, list);
  }
  const emailThreadBundle = threadRows.map((t) => ({
    thread: toJsonRow(t),
    messages: messagesByThread.get(t.id) ?? [],
  }));

  // Interest contact-log has no clientId - fetch via the client's interests.
  const interestIds = interestRows.map((i) => i.id);
  const contactLogRows = interestIds.length
    ? await db.query.interestContactLog.findMany({
        where: and(
          eq(interestContactLog.portId, portId),
          inArray(interestContactLog.interestId, interestIds),
        ),
        orderBy: (t, { desc }) => [desc(t.occurredAt)],
      })
    : [];

  // Website submissions pre-date the client record (no FK). Match by any
  // of the client's email contacts against payload->>'email' (case-
  // insensitive) so the bundle includes inquiry forms that became this
  // client.
  const emailValues = contacts
    .filter((c) => c.channel === 'email' && c.value)
    .map((c) => c.value.toLowerCase());
  const websiteSubmissionRows = emailValues.length
    ? await db
        .select()
        .from(websiteSubmissions)
        .where(
          and(
            eq(websiteSubmissions.portId, portId),
            inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
          ),
        )
    : [];

  return {
    meta: {
      generatedAt: new Date().toISOString(),
      portId,
      clientId,
      schemaVersion: 1,
    },
    // Drizzle row types contain non-`unknown` value types (Date, branded
    // strings). The bundle is exported as JSON, so we widen to plain
    // `Record<string, unknown>` here. `toJsonRow` performs the narrow → wide
    // widening in a single, locally-typed step instead of the prior
    // `as unknown as Record<string, unknown>` double-cast.
    client: toJsonRow(client),
    contacts: contacts.map(toJsonRow),
    addresses: addresses.map(toJsonRow),
    tags: tagJoins,
    relationships: relationships.map(toJsonRow),
    notes: notes.map(toJsonRow),
    ownedYachts: ownedYachts.map(toJsonRow),
    companyMemberships: membershipRows.map((row) => ({
      membership: toJsonRow(row.membership),
      company: toJsonRow(row.company),
    })),
    interests: interestRows.map(toJsonRow),
    interestFieldHistory: fieldHistoryRows.map(toJsonRow),
    contactLog: contactLogRows.map(toJsonRow),
    tenancies: tenancyRows.map(toJsonRow),
    berthWaitingList: waitingListRows.map(toJsonRow),
    payments: paymentRows.map(toJsonRow),
    invoices: invoiceRows.map(toJsonRow),
    documents: documentRows.map(toJsonRow),
    files: fileRows.map(toJsonRow),
    formSubmissions: formSubmissionRows.map(toJsonRow),
    supplementalFormTokens: supplementalTokenRows.map(toJsonRow),
    websiteSubmissions: websiteSubmissionRows.map(toJsonRow),
    documentSends: documentSendRows.map(toJsonRow),
    emailThreads: emailThreadBundle,
    reminders: reminderRows.map(toJsonRow),
    scratchpadNotes: scratchpadRows.map(toJsonRow),
    portalUsers: portalUserRows.map(toJsonRow),
    mergeLog: mergeLogRows.map(toJsonRow),
    auditTrail: auditRows.map(toJsonRow),
  };
}

/**
 * Widens a Drizzle row type to `Record<string, unknown>` for inclusion in
 * the JSON bundle. Drizzle row types are narrower than the open record
 * shape we want; this helper does the widening in one place rather than
 * scattering double-casts across the call sites.
 */
function toJsonRow<T extends object>(row: T): Record<string, unknown> {
  return row as Record<string, unknown>;
}

// ─── HTML rendering ──────────────────────────────────────────────────────────

function escapeHtml(s: unknown): string {
  if (s === null || s === undefined) return '';
  return String(s)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function tableSection(title: string, rows: Record<string, unknown>[]): string {
  if (rows.length === 0) {
    return `<section><h2>${escapeHtml(title)}</h2><p class="empty">No records.</p></section>`;
  }
  const headers = Array.from(
    rows.reduce<Set<string>>((set, r) => {
      Object.keys(r).forEach((k) => set.add(k));
      return set;
    }, new Set()),
  );
  const headerHtml = headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('');
  const bodyHtml = rows
    .map(
      (r) =>
        `<tr>${headers
          .map((h) => {
            const v = r[h];
            const cell = typeof v === 'object' && v !== null ? JSON.stringify(v) : v;
            return `<td>${escapeHtml(cell)}</td>`;
          })
          .join('')}</tr>`,
    )
    .join('');
  return `
    <section>
      <h2>${escapeHtml(title)} <small>(${rows.length})</small></h2>
      <table><thead><tr>${headerHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>
    </section>
  `;
}

/**
 * Renders the bundle as a self-contained HTML document - no external
 * resources, no JS - so it opens in any browser including offline.
 */
export function renderBundleHtml(bundle: GdprBundle): string {
  const clientName = String(bundle.client.fullName ?? bundle.meta.clientId ?? 'Unknown');
  const sections = [
    tableSection('Client', [bundle.client]),
    tableSection('Contacts', bundle.contacts),
    tableSection('Addresses', bundle.addresses),
    tableSection('Tags', bundle.tags.map(toJsonRow)),
    tableSection('Relationships', bundle.relationships),
    tableSection('Notes', bundle.notes),
    tableSection('Owned yachts', bundle.ownedYachts),
    tableSection(
      'Company memberships',
      bundle.companyMemberships.map((m) => ({
        ...m.membership,
        companyName: m.company.name,
        companyLegalName: m.company.legalName,
      })),
    ),
    tableSection('Interests', bundle.interests),
    tableSection('Interest field history (overrides)', bundle.interestFieldHistory),
    tableSection('Contact log', bundle.contactLog),
    tableSection('Tenancies', bundle.tenancies),
    tableSection('Berth waiting list', bundle.berthWaitingList),
    tableSection('Payments (deposits / balances / refunds)', bundle.payments),
    tableSection('Invoices', bundle.invoices),
    tableSection('Documents', bundle.documents),
    tableSection('Files', bundle.files),
    tableSection('Form submissions', bundle.formSubmissions),
    tableSection('Supplemental form tokens', bundle.supplementalFormTokens),
    tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions),
    tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends),
    tableSection(
      'Email threads',
      bundle.emailThreads.map((t) => ({
        ...t.thread,
        messageCount: t.messages.length,
      })),
    ),
    tableSection(
      'Email messages',
      bundle.emailThreads.flatMap((t) => t.messages.map((m) => ({ threadId: t.thread.id, ...m }))),
    ),
    tableSection('Reminders', bundle.reminders),
    tableSection('Scratchpad notes', bundle.scratchpadNotes),
    tableSection('Portal users', bundle.portalUsers),
    tableSection('Merge log', bundle.mergeLog),
    tableSection('Audit trail (last 500 events)', bundle.auditTrail),
  ].join('\n');

  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Personal data export - ${escapeHtml(clientName)}</title>
<style>
  body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; margin: 2rem; max-width: 1200px; }
  h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
  h2 { color: #1a3a5c; margin-top: 2rem; }
  small { font-weight: normal; color: #666; }
  .empty { color: #888; font-style: italic; }
  table { width: 100%; border-collapse: collapse; margin: 0.5rem 0; font-size: 12px; }
  th, td { border: 1px solid #ddd; padding: 4px 8px; vertical-align: top; word-break: break-word; }
  th { background: #f0f4f8; text-align: left; }
  tr:nth-child(even) { background: #fafbfc; }
  .meta { background: #f0f4f8; padding: 1rem; border-radius: 4px; }
</style>
</head>
<body>
  <h1>Personal data export</h1>
  <div class="meta">
    <div><strong>Client:</strong> ${escapeHtml(clientName)} <code>(${escapeHtml(bundle.meta.clientId)})</code></div>
    <div><strong>Generated:</strong> ${escapeHtml(bundle.meta.generatedAt)}</div>
    <div><strong>Schema version:</strong> ${escapeHtml(bundle.meta.schemaVersion)}</div>
  </div>
  ${sections}
</body>
</html>`;
}
