/**
 * Permanent client deletion with email-code confirmation.
 *
 * Flow:
 *   1. Operator presses "Permanently delete" on an archived client.
 *   2. requestHardDeleteCode() generates a 4-digit code, stores it in
 *      Redis under a per-{user, client} key with a 10-minute TTL, and
 *      emails the code to the operator's account address.
 *   3. Operator types both the code AND the client's full name into the
 *      confirmation dialog.
 *   4. hardDeleteClient() validates code (timing-safe) + name (case-
 *      insensitive trim equality), then deletes the client.
 *
 * Hard-delete is gated on:
 *   - permission `admin.permanently_delete_clients`
 *   - the client must already be archived (defense-in-depth: forces
 *     operators through the smart-archive flow first).
 *
 * The DB cascade story:
 *   - cascade FKs handle: companies, addresses, contacts, notes, tags,
 *     portal users, GDPR records - see ON DELETE CASCADE on the FK
 *     definitions in src/lib/db/schema/clients.ts.
 *   - non-cascade nullable FKs (files, documents, form_submissions,
 *     email_messages, reminders, document_sends) get cleared inline so
 *     audit history is preserved without blocking the delete.
 *   - non-cascade non-nullable FKs (interests, reservations, surviving
 *     row in client_merge_log) are deleted explicitly inside the tx.
 *   - the `clients.merged_into_client_id` self-FK is ON DELETE SET NULL
 *     (migration 0042). If THIS client was a merge winner, any archived
 *     loser whose `merged_into_client_id` points here has that pointer
 *     auto-NULLed by the cascade when this row is deleted. That silently
 *     severs the loser's redirect breadcrumb (the loser is no longer
 *     resolvable to a surviving record) but is benign: no FK violation,
 *     no orphaned/cross-tenant data, and the loser stays archived. We do
 *     NOT proactively re-home those pointers — the winner is gone, so
 *     there is nothing valid left to redirect to.
 */

import { timingSafeEqual } from 'node:crypto';

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

import { db } from '@/lib/db';
import { clients, clientContacts, clientMergeLog } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { berthTenancies } from '@/lib/db/schema/tenancies';
import { files, documents, formSubmissions } from '@/lib/db/schema/documents';
import { documentSends } from '@/lib/db/schema/brochures';
import { emailThreads, emailMessages } from '@/lib/db/schema/email';
import { reminders } from '@/lib/db/schema/operations';
import { scratchpadNotes } from '@/lib/db/schema/system';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { user as authUser } from '@/lib/db/schema/users';
import { redis } from '@/lib/redis';
import { sendEmail } from '@/lib/email';
import { logger } from '@/lib/logger';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { demoteSystemFolderOnEntityDelete } from '@/lib/services/document-folders.service';
import { getStorageBackend } from '@/lib/storage';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';

const ERASED_SENTINEL = '[erased]';

const CODE_TTL_SECONDS = 10 * 60;

function codeKey(userId: string, clientId: string): string {
  return `client-hard-delete-code:${userId}:${clientId}`;
}

function generateCode(): string {
  // 4-digit zero-padded numeric code. Math.random is sufficient for a
  // short-TTL one-time confirmation code that's already gated by an
  // authenticated session AND a permission flag.
  return Math.floor(Math.random() * 10000)
    .toString()
    .padStart(4, '0');
}

function safeEqualStr(a: string, b: string): boolean {
  const ab = Buffer.from(a, 'utf8');
  const bb = Buffer.from(b, 'utf8');
  if (ab.length !== bb.length) return false;
  return timingSafeEqual(ab, bb);
}

export async function requestHardDeleteCode(args: {
  clientId: string;
  portId: string;
  requesterUserId: string;
  meta: AuditMeta;
}): Promise<{ sentToMaskedEmail: string }> {
  const [client] = await db
    .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt })
    .from(clients)
    .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId)))
    .limit(1);
  if (!client) throw new NotFoundError('client');
  if (!client.archivedAt) {
    throw new ConflictError('Client must be archived before permanent deletion');
  }

  const [u] = await db
    .select({ email: authUser.email, name: authUser.name })
    .from(authUser)
    .where(eq(authUser.id, args.requesterUserId))
    .limit(1);
  if (!u) throw new NotFoundError('user');

  const code = generateCode();
  await redis.set(codeKey(args.requesterUserId, args.clientId), code, 'EX', CODE_TTL_SECONDS);

  const subject = `Confirmation code: permanently delete ${client.fullName}`;
  const html = `
    <p>Hello ${u.name},</p>
    <p>You requested to permanently delete the archived client
       <strong>${escapeHtml(client.fullName)}</strong>.</p>
    <p>Enter this code in the confirmation dialog to proceed:</p>
    <p style="font-size:28px; font-weight:bold; letter-spacing:6px; padding:14px 0;">${code}</p>
    <p>This code expires in 10 minutes. If you didn&rsquo;t request this,
       you can safely ignore this email &mdash; no action will be taken.</p>
  `;
  const text = [
    `Hello ${u.name},`,
    '',
    `You requested to permanently delete the archived client "${client.fullName}".`,
    '',
    `Confirmation code: ${code}`,
    `(expires in 10 minutes)`,
    '',
    `If you didn't request this, you can safely ignore this email.`,
  ].join('\n');

  try {
    await sendEmail(u.email, subject, html, undefined, text, args.portId);
  } catch (err) {
    // Wipe the cached code so a failed send doesn't leave a usable code
    // in Redis without the operator ever seeing it.
    await redis.del(codeKey(args.requesterUserId, args.clientId)).catch(() => undefined);
    throw err;
  }

  void createAuditLog({
    portId: args.portId,
    userId: args.requesterUserId,
    action: 'request_hard_delete_code',
    entityType: 'client',
    entityId: args.clientId,
    metadata: { sentTo: u.email },
    ipAddress: args.meta.ipAddress,
    userAgent: args.meta.userAgent,
  });

  return { sentToMaskedEmail: maskEmail(u.email) };
}

export async function hardDeleteClient(args: {
  clientId: string;
  portId: string;
  requesterUserId: string;
  code: string;
  typedName: string;
  meta: AuditMeta;
}): Promise<{ deletedClientId: string }> {
  const [client] = await db
    .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt })
    .from(clients)
    .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId)))
    .limit(1);
  if (!client) throw new NotFoundError('client');
  if (!client.archivedAt) {
    throw new ConflictError('Client must be archived before permanent deletion');
  }

  // Validate the typed name (case-insensitive, trimmed) before consuming
  // the code, so a typo doesn't cost the operator their code.
  const expected = client.fullName.trim().toLowerCase();
  const actual = args.typedName.trim().toLowerCase();
  if (expected !== actual) {
    throw new ValidationError('Typed name does not match the client');
  }

  const key = codeKey(args.requesterUserId, args.clientId);
  const stored = await redis.get(key);
  // Same error for both cases so an attacker can't distinguish "no code
  // requested" (probe to know the request endpoint window is open) from
  // "wrong code" (probe to brute-force the 4-digit space). The operator
  // has the email open and can re-request if expired.
  if (!stored || !safeEqualStr(stored, args.code.trim())) {
    throw new ValidationError('Invalid or expired confirmation code');
  }
  // Single-use: delete the code immediately so a failed delete tx
  // forces the operator to request a fresh code.
  await redis.del(key);

  // Storage keys we'll need to delete POST-commit. Collected inside the tx
  // so the read is consistent with what the tx detached. Deleting blobs
  // INSIDE the tx would block the commit on remote storage latency and
  // leave the tx hanging if S3 is slow; deleting AFTER commit means an
  // S3 outage at most leaks the blob (a known acceptable RTBF tradeoff,
  // since the DB row is detached + filename redacted, so the blob has
  // no identifying metadata and can be reaped by a future sweeper).
  const blobStorageKeys: string[] = [];

  await db.transaction(async (tx) => {
    // Lock the client row.
    const [locked] = await tx
      .select({ id: clients.id, archivedAt: clients.archivedAt })
      .from(clients)
      .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId)))
      .for('update');
    if (!locked) throw new NotFoundError('client');
    if (!locked.archivedAt) throw new ConflictError('Client must be archived');

    // Read email contacts BEFORE the cascade so we can wipe matching
    // website_submissions rows - that table has no clientId FK (raw
    // inquiry-form data, pre-promotion), matched only by email in the
    // JSONB payload. Article-17 requires removing the data subject's
    // submitted form data too.
    const emailContactRows = await tx
      .select({ value: clientContacts.value })
      .from(clientContacts)
      .where(and(eq(clientContacts.clientId, args.clientId), eq(clientContacts.channel, 'email')));
    const emailValues = emailContactRows
      .map((r) => r.value.trim().toLowerCase())
      .filter((v) => v.length > 0);
    if (emailValues.length > 0) {
      await tx
        .delete(websiteSubmissions)
        .where(
          and(
            eq(websiteSubmissions.portId, args.portId),
            inArray(sql<string>`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues),
          ),
        );
    }

    // A.7 RTBF wipe - Article-17 erasure of PII-bearing fields, not just FK
    // detach. The previous code merely nullified clientId, which left:
    //   - email_messages.{body_html, body_text, subject, from/to/cc} intact
    //   - document_sends.recipient_email intact
    //   - files.{original_name, storage_path blobs} intact
    // Below we (a) collect blob storage paths so we can delete them
    // post-commit, (b) redact PII text columns to a sentinel, and only
    // then (c) detach the FKs so the audit-trail rows survive without
    // their data subject's content.

    // (a) Collect file storage paths + original filenames (which may
    // themselves contain PII like "alice-smith-passport.pdf").
    const fileRows = await tx
      .select({ id: files.id, storagePath: files.storagePath })
      .from(files)
      .where(eq(files.clientId, args.clientId));
    blobStorageKeys.push(...fileRows.map((f) => f.storagePath));

    if (fileRows.length > 0) {
      await tx
        .update(files)
        .set({
          clientId: null,
          originalName: ERASED_SENTINEL,
          filename: ERASED_SENTINEL,
        })
        .where(eq(files.clientId, args.clientId));
    }

    // (b) Redact email_messages content for threads owned by this client.
    // Threads themselves stay (we detach via clientId=null below) so the
    // audit log "a thread existed" remains; the message bodies, subjects,
    // and address arrays - all PII - get wiped.
    const threadRows = await tx
      .select({ id: emailThreads.id })
      .from(emailThreads)
      .where(eq(emailThreads.clientId, args.clientId));
    if (threadRows.length > 0) {
      const threadIds = threadRows.map((t) => t.id);
      await tx
        .update(emailMessages)
        .set({
          bodyHtml: ERASED_SENTINEL,
          bodyText: ERASED_SENTINEL,
          subject: ERASED_SENTINEL,
          fromAddress: ERASED_SENTINEL,
          toAddresses: [ERASED_SENTINEL],
          ccAddresses: null,
        })
        .where(inArray(emailMessages.threadId, threadIds));
    }

    await tx.update(documents).set({ clientId: null }).where(eq(documents.clientId, args.clientId));
    await tx
      .update(formSubmissions)
      .set({ clientId: null })
      .where(eq(formSubmissions.clientId, args.clientId));
    await tx
      .update(emailThreads)
      .set({ clientId: null })
      .where(eq(emailThreads.clientId, args.clientId));
    await tx.update(reminders).set({ clientId: null }).where(eq(reminders.clientId, args.clientId));

    // (c) document_sends - redact recipient_email when detaching. The row
    // stays (audit log "a doc was sent") but the recipient identity is wiped.
    await tx
      .update(documentSends)
      .set({ clientId: null, recipientEmail: ERASED_SENTINEL })
      .where(eq(documentSends.clientId, args.clientId));
    // G-C2: scratchpad_notes.linked_client_id is RESTRICT (default for no
    // onDelete clause). Any rep who linked a scratchpad note to this client
    // would otherwise throw an FK violation when we try to delete the
    // client row below. Nullify so the note survives the hard-delete.
    await tx
      .update(scratchpadNotes)
      .set({ linkedClientId: null })
      .where(eq(scratchpadNotes.linkedClientId, args.clientId));

    // client_merge_log.surviving_client_id has no cascade and is
    // notNull → must be deleted explicitly. Merged records remain in
    // the log because mergedClientId has no FK.
    await tx.delete(clientMergeLog).where(eq(clientMergeLog.survivingClientId, args.clientId));

    // Delete non-nullable-FK children explicitly (cascade chains
    // pick up their own children in turn).
    await tx.delete(interests).where(eq(interests.clientId, args.clientId));
    await tx.delete(berthTenancies).where(eq(berthTenancies.clientId, args.clientId));

    // Finally, the client itself.
    await tx.delete(clients).where(eq(clients.id, args.clientId));
  });

  // G-C3 / A7: demote the system-managed folder so the partial unique
  // index `uniq_document_folders_entity` releases its slot. Done as a
  // post-commit fire-and-forget - folder hygiene is non-essential to the
  // delete being durable, and we don't want a folder-table glitch to
  // un-delete the client by aborting the outer transaction.
  void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch((err) => {
    logger.error(
      { err, clientId: args.clientId, portId: args.portId },
      'hardDeleteClient: failed to demote system folder',
    );
  });

  // A.7 RTBF: delete blobs from storage post-commit. We never want a
  // storage error to abort the DB tx (PII removal must succeed durably
  // even if S3 is flaky), so this runs after commit and logs failures
  // individually. Surviving blobs without a row reference are reaped by
  // the standard orphan-blob sweeper job.
  if (blobStorageKeys.length > 0) {
    void (async () => {
      const storage = await getStorageBackend();
      let deleted = 0;
      for (const key of blobStorageKeys) {
        try {
          await storage.delete(key);
          deleted += 1;
        } catch (err) {
          logger.error(
            { err, clientId: args.clientId, storageKey: key },
            'hardDeleteClient: blob delete failed (RTBF)',
          );
        }
      }
      logger.info(
        { clientId: args.clientId, deletedBlobs: deleted, totalBlobs: blobStorageKeys.length },
        'hardDeleteClient: blob deletion complete',
      );
    })();
  }

  void createAuditLog({
    portId: args.portId,
    userId: args.requesterUserId,
    action: 'hard_delete',
    entityType: 'client',
    entityId: args.clientId,
    metadata: { fullName: client.fullName },
    ipAddress: args.meta.ipAddress,
    userAgent: args.meta.userAgent,
  });

  logger.warn(
    { clientId: args.clientId, portId: args.portId, userId: args.requesterUserId },
    'Client hard-deleted',
  );

  return { deletedClientId: args.clientId };
}

// ─── Bulk hard delete ───────────────────────────────────────────────────────

function hashIds(ids: string[]): string {
  // Stable hash so the same set always produces the same key - order
  // independent. SHA-1 is more than enough for collision-avoidance on
  // a per-user keyspace.

  const { createHash } = require('node:crypto') as typeof import('node:crypto');
  const sorted = [...ids].sort().join('|');
  return createHash('sha1').update(sorted).digest('hex');
}

function bulkCodeKey(userId: string, idsHash: string): string {
  return `client-bulk-hard-delete-code:${userId}:${idsHash}`;
}

export async function requestBulkHardDeleteCode(args: {
  clientIds: string[];
  portId: string;
  requesterUserId: string;
  meta: AuditMeta;
}): Promise<{ count: number; sentToMaskedEmail: string }> {
  if (args.clientIds.length === 0) {
    throw new ValidationError('No clients selected');
  }
  if (args.clientIds.length > 100) {
    throw new ValidationError('Maximum 100 clients per bulk hard-delete');
  }
  // Verify every client belongs to this port AND is archived. All-or-
  // nothing: refuse if any row violates either constraint.
  const rows = await db
    .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt })
    .from(clients)
    .where(eq(clients.portId, args.portId));
  const found = new Map(rows.map((r) => [r.id, r]));
  for (const id of args.clientIds) {
    const c = found.get(id);
    if (!c) throw new NotFoundError(`client ${id}`);
    if (!c.archivedAt) {
      throw new ConflictError(`Client ${c.fullName} is not archived`);
    }
  }

  const [u] = await db
    .select({ email: authUser.email, name: authUser.name })
    .from(authUser)
    .where(eq(authUser.id, args.requesterUserId))
    .limit(1);
  if (!u) throw new NotFoundError('user');

  const idsHash = hashIds(args.clientIds);
  const code = generateCode();
  await redis.set(bulkCodeKey(args.requesterUserId, idsHash), code, 'EX', CODE_TTL_SECONDS);

  const subject = `Confirmation code: permanently delete ${args.clientIds.length} clients`;
  const html = `
    <p>Hello ${u.name},</p>
    <p>You requested to permanently delete <strong>${args.clientIds.length}</strong>
       archived clients in bulk.</p>
    <p>Enter this code in the confirmation dialog to proceed:</p>
    <p style="font-size:28px; font-weight:bold; letter-spacing:6px; padding:14px 0;">${code}</p>
    <p>This code expires in 10 minutes. If you didn&rsquo;t request this,
       you can safely ignore this email &mdash; no action will be taken.</p>
  `;
  const text = [
    `Hello ${u.name},`,
    '',
    `You requested to permanently delete ${args.clientIds.length} archived clients in bulk.`,
    '',
    `Confirmation code: ${code}`,
    `(expires in 10 minutes)`,
    '',
    `If you didn't request this, you can safely ignore this email.`,
  ].join('\n');

  try {
    await sendEmail(u.email, subject, html, undefined, text, args.portId);
  } catch (err) {
    await redis.del(bulkCodeKey(args.requesterUserId, idsHash)).catch(() => undefined);
    throw err;
  }

  void createAuditLog({
    portId: args.portId,
    userId: args.requesterUserId,
    action: 'request_hard_delete_code',
    entityType: 'client',
    entityId: 'bulk',
    metadata: { count: args.clientIds.length, sentTo: u.email },
    ipAddress: args.meta.ipAddress,
    userAgent: args.meta.userAgent,
  });

  return { count: args.clientIds.length, sentToMaskedEmail: maskEmail(u.email) };
}

export interface BulkHardDeleteResult {
  deletedCount: number;
  /** Ids that were requested but not deleted, with a per-id reason
   *  (e.g. became unarchived between preflight and execute, removed by
   *  another operator, transient failure inside the inner deletion). */
  skipped: Array<{ clientId: string; reason: string }>;
}

export async function bulkHardDeleteClients(args: {
  clientIds: string[];
  portId: string;
  requesterUserId: string;
  code: string;
  typedPhrase: string;
  meta: AuditMeta;
}): Promise<BulkHardDeleteResult> {
  if (args.clientIds.length === 0) {
    throw new ValidationError('No clients selected');
  }
  // Phrase format: "DELETE N CLIENTS" (case-insensitive).
  const expectedPhrase = `delete ${args.clientIds.length} client${args.clientIds.length === 1 ? '' : 's'}`;
  if (args.typedPhrase.trim().toLowerCase() !== expectedPhrase) {
    throw new ValidationError(`Type "${expectedPhrase.toUpperCase()}" exactly to confirm`);
  }

  const idsHash = hashIds(args.clientIds);
  const key = bulkCodeKey(args.requesterUserId, idsHash);
  const stored = await redis.get(key);
  // Same error for both cases - see single-client variant for rationale.
  // Code is tied to the exact set hash so a wrong-set probe fails here too.
  if (!stored || !safeEqualStr(stored, args.code.trim())) {
    throw new ValidationError('Invalid or expired confirmation code');
  }
  await redis.del(key);

  let deleted = 0;
  const skipped: BulkHardDeleteResult['skipped'] = [];
  for (const id of args.clientIds) {
    try {
      // Reuse the single-client path so the cascade logic stays in one
      // place. We pass a synthetic per-client code that bypasses the
      // single-client redis check by writing a one-shot value.
      const singleKey = codeKey(args.requesterUserId, id);
      const oneShot = generateCode();
      await redis.set(singleKey, oneShot, 'EX', 60);
      const [c] = await db
        .select({ fullName: clients.fullName, archivedAt: clients.archivedAt })
        .from(clients)
        .where(and(eq(clients.id, id), eq(clients.portId, args.portId)))
        .limit(1);
      if (!c) {
        skipped.push({ clientId: id, reason: 'client no longer exists' });
        continue;
      }
      if (!c.archivedAt) {
        skipped.push({
          clientId: id,
          reason: 'client is no longer archived (un-archived since preflight)',
        });
        continue;
      }
      await hardDeleteClient({
        clientId: id,
        portId: args.portId,
        requesterUserId: args.requesterUserId,
        code: oneShot,
        typedName: c.fullName,
        meta: args.meta,
      });
      deleted += 1;
    } catch (err) {
      logger.error({ err, clientId: id }, 'bulk hard-delete: client failed, continuing');
      skipped.push({
        clientId: id,
        reason: err instanceof Error ? err.message : 'unknown failure',
      });
    }
  }

  return { deletedCount: deleted, skipped };
}

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

function maskEmail(email: string): string {
  const [local, domain] = email.split('@');
  if (!local || !domain) return email;
  if (local.length <= 2) return `${local[0] ?? ''}***@${domain}`;
  return `${local.slice(0, 2)}***@${domain}`;
}
