/**
 * Pre-EOI supplemental info form service.
 *
 * Three operations:
 *   1. `issueToken` - rep clicks "Request more info" → token row + email queued.
 *   2. `loadByToken` - public form fetches prefill data; rejects expired/consumed tokens.
 *   3. `applySubmission` - public form POST → diff against current data, apply
 *      updates, consume token. All inside one transaction.
 */

import { and, eq } from 'drizzle-orm';
import crypto from 'node:crypto';

import { db } from '@/lib/db';
import {
  supplementalFormTokens,
  interests,
  clients,
  clientAddresses,
  yachts,
  clientContacts,
  interestFieldHistory,
  ports,
} from '@/lib/db/schema';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
import { getPortBrandingConfig } from '@/lib/services/port-config';

const TOKEN_TTL_DAYS = 14;
const TOKEN_BYTES = 32; // 256-bit → ~43 base64url chars; brute-force infeasible.

function generateToken(): string {
  return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
}

export interface IssueTokenInput {
  interestId: string;
  portId: string;
  issuedBy: string;
}

export async function issueToken(input: IssueTokenInput): Promise<{
  token: string;
  expiresAt: Date;
  clientEmail: string | null;
  clientName: string;
}> {
  // Resolve the interest's client + at least one email contact so the
  // calling code can queue the email immediately without a second hop.
  const interest = await db.query.interests.findFirst({
    where: and(eq(interests.id, input.interestId), eq(interests.portId, input.portId)),
  });
  if (!interest) throw new NotFoundError('interest');

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

  const emailContact = await db.query.clientContacts.findFirst({
    where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')),
  });

  const token = generateToken();
  const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);

  await db.insert(supplementalFormTokens).values({
    portId: input.portId,
    interestId: interest.id,
    clientId: client.id,
    token,
    expiresAt,
    issuedBy: input.issuedBy,
  });

  return {
    token,
    expiresAt,
    clientEmail: emailContact?.value ?? null,
    clientName: client.fullName ?? client.id,
  };
}

export interface PrefillData {
  /** Token metadata so the form can disable itself when consumed. */
  token: { expiresAt: string; consumed: boolean };
  /** Port branding + name. Surfaces both as a header (so the recipient
   *  knows which marina is asking) and as logo / backdrop in the
   *  shared BrandedAuthShell. */
  port: {
    name: string;
    logoUrl: string | null;
    backgroundUrl: string | null;
  };
  client: {
    fullName: string;
    streetAddress: string | null;
    city: string | null;
    subdivisionIso: string | null;
    postalCode: string | null;
    country: string | null;
    primaryEmail: string | null;
    primaryPhone: string | null;
    primaryPhoneCountry: string | null;
  };
  yacht: {
    name: string | null;
    lengthFt: string | null;
    widthFt: string | null;
    draftFt: string | null;
  } | null;
}

/**
 * Hydrate the public form. Returns null when the token doesn't exist
 * (avoid leaking whether it's expired vs. fake); returns a payload with
 * `consumed: true` when it's already been used so the form can render
 * a friendly "already submitted" state.
 */
// ─── List + fetch by id (rep-facing history) ────────────────────────────────

export interface SupplementalTokenHistoryRow {
  id: string;
  token: string;
  createdAt: string;
  expiresAt: string;
  consumedAt: string | null;
  issuedBy: string | null;
  /** True when expiresAt is in the past and the token hasn't been consumed. */
  expired: boolean;
}

/**
 * Lists supplemental-info-request issuances for an interest, newest first.
 * Used by the rep-facing "issuance history" surface on the interest page -
 * shows when each token was generated, whether it's been consumed, and
 * lets the rep re-send the latest active token without minting a fresh
 * one.
 */
export async function listTokensForInterest(
  portId: string,
  interestId: string,
): Promise<SupplementalTokenHistoryRow[]> {
  const { desc } = await import('drizzle-orm');
  const rows = await db
    .select()
    .from(supplementalFormTokens)
    .where(
      and(
        eq(supplementalFormTokens.portId, portId),
        eq(supplementalFormTokens.interestId, interestId),
      ),
    )
    .orderBy(desc(supplementalFormTokens.createdAt))
    .limit(20);
  const now = Date.now();
  return rows.map((r) => ({
    id: r.id,
    token: r.token,
    createdAt: r.createdAt.toISOString(),
    expiresAt: r.expiresAt.toISOString(),
    consumedAt: r.consumedAt ? r.consumedAt.toISOString() : null,
    issuedBy: r.issuedBy,
    expired: !r.consumedAt && r.expiresAt.getTime() < now,
  }));
}

/**
 * Resolve a specific token id back into a snapshot suitable for re-emailing.
 * Reuses the same shape that issueToken returns so the calling route can
 * fire `sendEmail` without per-call divergence. Throws NotFoundError when
 * the token doesn't exist or is cross-port (mirrors the
 * enumeration-prevention behaviour on the issue path).
 */
export async function getTokenForResend(
  portId: string,
  interestId: string,
  tokenId: string,
): Promise<{
  token: string;
  expiresAt: Date;
  clientEmail: string | null;
  clientName: string;
}> {
  const row = await db.query.supplementalFormTokens.findFirst({
    where: and(
      eq(supplementalFormTokens.id, tokenId),
      eq(supplementalFormTokens.portId, portId),
      eq(supplementalFormTokens.interestId, interestId),
    ),
  });
  if (!row) throw new NotFoundError('supplemental token');
  const client = await db.query.clients.findFirst({ where: eq(clients.id, row.clientId) });
  if (!client) throw new NotFoundError('client');
  const emailContact = await db.query.clientContacts.findFirst({
    where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')),
  });
  return {
    token: row.token,
    expiresAt: row.expiresAt,
    clientEmail: emailContact?.value ?? null,
    clientName: client.fullName ?? client.id,
  };
}

export async function loadByToken(token: string): Promise<PrefillData | null> {
  const row = await db.query.supplementalFormTokens.findFirst({
    where: eq(supplementalFormTokens.token, token),
  });
  if (!row) return null;
  if (row.expiresAt.getTime() < Date.now()) return null;

  const client = await db.query.clients.findFirst({ where: eq(clients.id, row.clientId) });
  if (!client) return null;

  const interest = await db.query.interests.findFirst({ where: eq(interests.id, row.interestId) });
  const yacht = interest?.yachtId
    ? await db.query.yachts.findFirst({ where: eq(yachts.id, interest.yachtId) })
    : null;

  // Prefer the primary contact when one is flagged; otherwise the first
  // email/phone record. We need email + phone country code for the form's
  // i18n-aware PhoneInput.
  const contacts = await db.query.clientContacts.findMany({
    where: eq(clientContacts.clientId, client.id),
  });
  const emailContact =
    contacts.find((c) => c.channel === 'email' && c.isPrimary) ??
    contacts.find((c) => c.channel === 'email') ??
    null;
  const phoneContact =
    contacts.find((c) => (c.channel === 'phone' || c.channel === 'whatsapp') && c.isPrimary) ??
    contacts.find((c) => c.channel === 'phone' || c.channel === 'whatsapp') ??
    null;

  const primaryAddress = await db.query.clientAddresses.findFirst({
    where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
  });

  const [port, branding] = await Promise.all([
    db.query.ports.findFirst({ where: eq(ports.id, row.portId), columns: { name: true } }),
    getPortBrandingConfig(row.portId).catch(() => ({
      logoUrl: null,
      emailBackgroundUrl: null,
    })),
  ]);

  return {
    token: {
      expiresAt: row.expiresAt.toISOString(),
      consumed: !!row.consumedAt,
    },
    port: {
      name: port?.name ?? 'Port Nimara',
      logoUrl: branding.logoUrl ?? null,
      backgroundUrl: branding.emailBackgroundUrl ?? null,
    },
    client: {
      fullName: client.fullName,
      streetAddress: primaryAddress?.streetAddress ?? null,
      city: primaryAddress?.city ?? null,
      subdivisionIso: primaryAddress?.subdivisionIso ?? null,
      postalCode: primaryAddress?.postalCode ?? null,
      country: primaryAddress?.countryIso ?? null,
      primaryEmail: emailContact?.value ?? null,
      primaryPhone: phoneContact?.valueE164 ?? phoneContact?.value ?? null,
      primaryPhoneCountry: phoneContact?.valueCountry ?? null,
    },
    yacht: yacht
      ? {
          name: yacht.name ?? null,
          lengthFt: yacht.lengthFt ?? null,
          widthFt: yacht.widthFt ?? null,
          draftFt: yacht.draftFt ?? null,
        }
      : interest?.desiredLengthFt || interest?.desiredWidthFt || interest?.desiredDraftFt
        ? {
            name: null,
            lengthFt: interest.desiredLengthFt ?? null,
            widthFt: interest.desiredWidthFt ?? null,
            draftFt: interest.desiredDraftFt ?? null,
          }
        : null,
  };
}

export interface SubmissionInput {
  fullName: string;
  /** Street address (single line — multi-line entries go into this
   *  same field as `\n`-joined text). */
  address: string | null;
  city: string | null;
  /** ISO-3166-2 subdivision code (e.g. 'PL-MZ', 'US-CA'). */
  subdivisionIso: string | null;
  postalCode: string | null;
  country: string | null;
  email: string | null;
  phoneE164: string | null;
  phoneCountry: string | null;
  yachtName: string | null;
  yachtLengthFt: number | null;
  yachtWidthFt: number | null;
  yachtDraftFt: number | null;
}

/**
 * Apply a public-form submission. Diffs against current values and only
 * writes the changed fields. Consumes the token in the same transaction
 * so a retry can't double-apply.
 */
export async function applySubmission(token: string, input: SubmissionInput): Promise<void> {
  if (!input.fullName?.trim()) {
    throw new ValidationError('Name is required');
  }

  await db.transaction(async (tx) => {
    // Reusable-until-expiry: the link stays valid for repeat
    // submissions until it expires. `consumedAt` is still stamped on
    // first submit so the rep / loader can show "last submitted at
    // <time>" context, but it no longer gates the submission. The TTL
    // gate below is the sole validity check.
    const row = await tx.query.supplementalFormTokens.findFirst({
      where: eq(supplementalFormTokens.token, token),
    });
    if (!row) {
      throw new ConflictError('This link is no longer valid.');
    }
    if (row.expiresAt.getTime() < Date.now()) {
      throw new ConflictError('This link has expired.');
    }

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

    // Track every field-level override we apply this submission so we
    // can write them to interest_field_history below in a single batch.
    // The shape is { fieldPath, oldValue, newValue } - submission_id is
    // attached after we have a form_submissions row id (if/when that
    // surface lands; today supplemental-info doesn't materialise a
    // form_submissions row, so submissionId stays null).
    const overrides: Array<{
      fieldPath: string;
      oldValue: unknown;
      newValue: unknown;
    }> = [];

    // Client patch: name lives on clients; address fields live on the
    // dedicated client_addresses row. fullName is required so always sent.
    if (input.fullName.trim() !== client.fullName) {
      overrides.push({
        fieldPath: 'client.fullName',
        oldValue: client.fullName,
        newValue: input.fullName.trim(),
      });
      await tx
        .update(clients)
        .set({ fullName: input.fullName.trim() })
        .where(eq(clients.id, client.id));
    }

    const hasAnyAddressInput =
      input.address || input.city || input.subdivisionIso || input.postalCode || input.country;
    if (hasAnyAddressInput) {
      const existingAddr = await tx.query.clientAddresses.findFirst({
        where: and(eq(clientAddresses.clientId, client.id), eq(clientAddresses.isPrimary, true)),
      });
      if (!existingAddr) {
        await tx.insert(clientAddresses).values({
          clientId: client.id,
          portId: row.portId,
          label: 'Primary',
          streetAddress: input.address ?? null,
          city: input.city ?? null,
          subdivisionIso: input.subdivisionIso ?? null,
          postalCode: input.postalCode ?? null,
          countryIso: input.country ?? null,
          isPrimary: true,
        });
        // Insert-path: every populated field is a "from null → value"
        // override so the history panel surfaces the initial population
        // the same way it surfaces later edits.
        const insertOverrides: Array<[string, unknown]> = [
          ['client.address.streetAddress', input.address],
          ['client.address.city', input.city],
          ['client.address.subdivisionIso', input.subdivisionIso],
          ['client.address.postalCode', input.postalCode],
          ['client.address.countryIso', input.country],
        ];
        for (const [fieldPath, value] of insertOverrides) {
          if (value) overrides.push({ fieldPath, oldValue: null, newValue: value });
        }
      } else {
        const addrPatch: Record<string, unknown> = {};
        const updateFields: Array<[string, string | null, string | null | undefined]> = [
          ['streetAddress', existingAddr.streetAddress, input.address],
          ['city', existingAddr.city, input.city],
          ['subdivisionIso', existingAddr.subdivisionIso, input.subdivisionIso],
          ['postalCode', existingAddr.postalCode, input.postalCode],
          ['countryIso', existingAddr.countryIso, input.country],
        ];
        for (const [col, oldVal, newVal] of updateFields) {
          if (newVal && newVal !== oldVal) {
            addrPatch[col] = newVal;
            overrides.push({
              fieldPath: `client.address.${col}`,
              oldValue: oldVal,
              newValue: newVal,
            });
          }
        }
        if (Object.keys(addrPatch).length > 0) {
          await tx
            .update(clientAddresses)
            .set(addrPatch)
            .where(eq(clientAddresses.id, existingAddr.id));
        }
      }
    }

    // Email / phone go to client_contacts. Upsert if changed.
    if (input.email && input.email.trim()) {
      const existing = await tx.query.clientContacts.findFirst({
        where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'email')),
      });
      if (!existing) {
        await tx.insert(clientContacts).values({
          clientId: client.id,
          channel: 'email',
          value: input.email.trim().toLowerCase(),
          isPrimary: true,
        });
        overrides.push({
          fieldPath: 'client.primaryEmail',
          oldValue: null,
          newValue: input.email.trim().toLowerCase(),
        });
      } else if (existing.value !== input.email.trim().toLowerCase()) {
        overrides.push({
          fieldPath: 'client.primaryEmail',
          oldValue: existing.value,
          newValue: input.email.trim().toLowerCase(),
        });
        await tx
          .update(clientContacts)
          .set({ value: input.email.trim().toLowerCase() })
          .where(eq(clientContacts.id, existing.id));
      }
    }

    if (input.phoneE164 && input.phoneE164.trim()) {
      const existing = await tx.query.clientContacts.findFirst({
        where: and(eq(clientContacts.clientId, client.id), eq(clientContacts.channel, 'phone')),
      });
      if (!existing) {
        await tx.insert(clientContacts).values({
          clientId: client.id,
          channel: 'phone',
          value: input.phoneE164,
          valueE164: input.phoneE164,
          valueCountry: input.phoneCountry,
          isPrimary: true,
        });
        overrides.push({
          fieldPath: 'client.primaryPhone',
          oldValue: null,
          newValue: input.phoneE164,
        });
      } else if (existing.valueE164 !== input.phoneE164) {
        overrides.push({
          fieldPath: 'client.primaryPhone',
          oldValue: existing.valueE164 ?? existing.value,
          newValue: input.phoneE164,
        });
        await tx
          .update(clientContacts)
          .set({
            value: input.phoneE164,
            valueE164: input.phoneE164,
            valueCountry: input.phoneCountry ?? existing.valueCountry,
          })
          .where(eq(clientContacts.id, existing.id));
      }
    }

    // Yacht block: best-effort. If interest.yachtId is set, update that;
    // otherwise we don't auto-create a yacht (rep should do it explicitly).
    const interest = await tx.query.interests.findFirst({
      where: eq(interests.id, row.interestId),
    });
    if (interest?.yachtId && (input.yachtName || input.yachtLengthFt)) {
      const existingYacht = await tx.query.yachts.findFirst({
        where: eq(yachts.id, interest.yachtId),
      });
      const yachtPatch: Record<string, unknown> = {};
      if (input.yachtName && input.yachtName !== existingYacht?.name) {
        yachtPatch.name = input.yachtName;
        overrides.push({
          fieldPath: 'yacht.name',
          oldValue: existingYacht?.name ?? null,
          newValue: input.yachtName,
        });
      }
      if (input.yachtLengthFt !== null && String(input.yachtLengthFt) !== existingYacht?.lengthFt) {
        yachtPatch.lengthFt = String(input.yachtLengthFt);
        overrides.push({
          fieldPath: 'yacht.lengthFt',
          oldValue: existingYacht?.lengthFt ?? null,
          newValue: String(input.yachtLengthFt),
        });
      }
      if (input.yachtWidthFt !== null && String(input.yachtWidthFt) !== existingYacht?.widthFt) {
        yachtPatch.widthFt = String(input.yachtWidthFt);
        overrides.push({
          fieldPath: 'yacht.widthFt',
          oldValue: existingYacht?.widthFt ?? null,
          newValue: String(input.yachtWidthFt),
        });
      }
      if (input.yachtDraftFt !== null && String(input.yachtDraftFt) !== existingYacht?.draftFt) {
        yachtPatch.draftFt = String(input.yachtDraftFt);
        overrides.push({
          fieldPath: 'yacht.draftFt',
          oldValue: existingYacht?.draftFt ?? null,
          newValue: String(input.yachtDraftFt),
        });
      }
      if (Object.keys(yachtPatch).length > 0) {
        await tx.update(yachts).set(yachtPatch).where(eq(yachts.id, interest.yachtId));
      }
    }

    // Mirror yacht dimensions onto the interest's desired-dimensions
    // fields so the recommender picks up the corrected values.
    if (interest && (input.yachtLengthFt || input.yachtWidthFt || input.yachtDraftFt)) {
      const interestPatch: Record<string, unknown> = {};
      if (input.yachtLengthFt !== null) interestPatch.desiredLengthFt = String(input.yachtLengthFt);
      if (input.yachtWidthFt !== null) interestPatch.desiredWidthFt = String(input.yachtWidthFt);
      if (input.yachtDraftFt !== null) interestPatch.desiredDraftFt = String(input.yachtDraftFt);
      if (Object.keys(interestPatch).length > 0) {
        await tx.update(interests).set(interestPatch).where(eq(interests.id, interest.id));
      }
    }

    await tx
      .update(supplementalFormTokens)
      .set({ consumedAt: new Date() })
      .where(eq(supplementalFormTokens.id, row.id));

    // Write the diff log so the Field history panel on Interest +
    // Client detail can surface every override the client made via the
    // form. Empty array means nothing actually changed - the client
    // confirmed every field as-was - in which case we skip the batch.
    if (overrides.length > 0) {
      await tx.insert(interestFieldHistory).values(
        overrides.map((o) => ({
          portId: row.portId,
          interestId: row.interestId,
          clientId: row.clientId,
          fieldPath: o.fieldPath,
          oldValue: o.oldValue as unknown,
          newValue: o.newValue as unknown,
          source: 'supplemental_form' as const,
          submissionId: null,
          createdBy: null,
        })),
      );
    }
  });
}
