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

import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { residentialClients, residentialInterests } from '@/lib/db/schema/residential';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { CodedError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { buildListQuery } from '@/lib/db/query-builder';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import type {
  CreateResidentialClientInput,
  CreateResidentialInterestInput,
  ListResidentialClientsInput,
  ListResidentialInterestsInput,
  UpdateResidentialClientInput,
  UpdateResidentialInterestInput,
} from '@/lib/validators/residential';
import { sendEmail } from '@/lib/email';
import { assertValidStage } from '@/lib/services/residential-stages.service';
import { SETTING_KEYS, getPortBrandingConfig, readSetting } from '@/lib/services/port-config';
import { brandingPrimaryColor, renderShell } from '@/lib/email/shell';

// ─── Residential clients ─────────────────────────────────────────────────────

export async function listResidentialClients(portId: string, query: ListResidentialClientsInput) {
  const { page, limit, sort, order, search, includeArchived, status, source } = query;

  const filters = [];
  if (status) filters.push(eq(residentialClients.status, status));
  if (source) filters.push(eq(residentialClients.source, source));

  return buildListQuery({
    table: residentialClients,
    portIdColumn: residentialClients.portId,
    portId,
    idColumn: residentialClients.id,
    updatedAtColumn: residentialClients.updatedAt,
    filters,
    sort: sort
      ? {
          column:
            (residentialClients[sort as keyof typeof residentialClients] as never) ??
            residentialClients.updatedAt,
          direction: order ?? 'desc',
        }
      : undefined,
    page,
    pageSize: limit,
    searchColumns: [
      residentialClients.fullName,
      residentialClients.email,
      residentialClients.phone,
      residentialClients.placeOfResidence,
    ],
    searchTerm: search,
    includeArchived,
    archivedAtColumn: residentialClients.archivedAt,
  });
}

export async function getResidentialClientById(id: string, portId: string) {
  const client = await db.query.residentialClients.findFirst({
    where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
  });
  if (!client) throw new NotFoundError('Residential client');

  const interests = await db.query.residentialInterests.findMany({
    where: eq(residentialInterests.residentialClientId, id),
    orderBy: (t, { desc }) => [desc(t.updatedAt)],
  });

  return { ...client, interests };
}

export async function createResidentialClient(
  portId: string,
  data: CreateResidentialClientInput,
  meta: AuditMeta,
) {
  const [row] = await db
    .insert(residentialClients)
    .values({ portId, ...data })
    .returning();
  if (!row)
    throw new CodedError('INSERT_RETURNING_EMPTY', {
      internalMessage: 'Failed to create residential client',
    });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'residential_client',
    entityId: row.id,
    newValue: { fullName: row.fullName, email: row.email ?? undefined },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_client:created', { id: row.id });

  // Best-effort auto-link to an existing main-client record. Match by
  // email (cheap, single index lookup) then by E.164 phone (next-best).
  // Failures or no-match scenarios silently leave the row unlinked -
  // reps can wire it up via the admin UI later.
  void findAndLinkMatchingMainClient(row.id, portId).catch((err) => {
    console.error('[residential] auto-link match failed', err);
  });

  return row;
}

export async function updateResidentialClient(
  id: string,
  portId: string,
  data: UpdateResidentialClientInput,
  meta: AuditMeta,
) {
  const before = await db.query.residentialClients.findFirst({
    where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
  });
  if (!before) throw new NotFoundError('Residential client');

  const [updated] = await db
    .update(residentialClients)
    .set({ ...data, updatedAt: new Date() })
    .where(and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)))
    .returning();
  if (!updated) throw new NotFoundError('Residential client');

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'residential_client',
    entityId: id,
    oldValue: diffEntity(before, updated) as Record<string, unknown>,
    newValue: data as Record<string, unknown>,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_client:updated', { id });

  return updated;
}

export async function archiveResidentialClient(id: string, portId: string, meta: AuditMeta) {
  const existing = await db.query.residentialClients.findFirst({
    where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Residential client');

  await softDelete(residentialClients, residentialClients.id, id);

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'archive',
    entityType: 'residential_client',
    entityId: id,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_client:archived', { id });
}

export async function restoreResidentialClient(id: string, portId: string, meta: AuditMeta) {
  const existing = await db.query.residentialClients.findFirst({
    where: and(eq(residentialClients.id, id), eq(residentialClients.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Residential client');

  await restore(residentialClients, residentialClients.id, id);

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'restore',
    entityType: 'residential_client',
    entityId: id,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_client:restored', { id });
}

// ─── Residential interests ───────────────────────────────────────────────────

export async function listResidentialInterests(
  portId: string,
  query: ListResidentialInterestsInput,
) {
  const {
    page,
    limit,
    sort,
    order,
    search,
    includeArchived,
    pipelineStage,
    source,
    assignedTo,
    residentialClientId,
  } = query;

  const filters = [];
  // Normalize string-or-array filter inputs into Drizzle `inArray` clauses
  // when multiple values are supplied; fall back to `eq` for the single
  // case so the validator's union shape doesn't change the SQL.
  if (pipelineStage) {
    const values = Array.isArray(pipelineStage) ? pipelineStage : [pipelineStage];
    if (values.length > 1) filters.push(inArray(residentialInterests.pipelineStage, values));
    else if (values[0]) filters.push(eq(residentialInterests.pipelineStage, values[0]));
  }
  if (source) {
    const values = Array.isArray(source) ? source : [source];
    if (values.length > 1) filters.push(inArray(residentialInterests.source, values));
    else if (values[0]) filters.push(eq(residentialInterests.source, values[0]));
  }
  if (assignedTo) filters.push(eq(residentialInterests.assignedTo, assignedTo));
  if (residentialClientId)
    filters.push(eq(residentialInterests.residentialClientId, residentialClientId));

  const result = await buildListQuery({
    table: residentialInterests,
    portIdColumn: residentialInterests.portId,
    portId,
    idColumn: residentialInterests.id,
    updatedAtColumn: residentialInterests.updatedAt,
    filters,
    sort: sort
      ? {
          column:
            (residentialInterests[sort as keyof typeof residentialInterests] as never) ??
            residentialInterests.updatedAt,
          direction: order ?? 'desc',
        }
      : undefined,
    page,
    pageSize: limit,
    searchColumns: [residentialInterests.notes, residentialInterests.preferences],
    searchTerm: search,
    includeArchived,
    archivedAtColumn: residentialInterests.archivedAt,
  });

  // Per-page client-name lookup so the list table can render the
  // residentialClient.fullName in column 1 without a second hop per row.
  // Two-pass post-fetch pattern mirrors the main interests list (latest
  // stage / tag aggregation lives there). Page size is capped by the
  // validator so this stays a single bulk IN-list query.
  // `buildListQuery` returns `data: typeof table.$inferSelect[]` so the
  // row type is `residentialInterests` - known shape, but TS infers
  // `unknown[]` through the generic helper. Cast through `unknown` once
  // here so the downstream enrichment is type-clean.
  type InterestRow = typeof residentialInterests.$inferSelect;
  const typedData = result.data as unknown as InterestRow[];
  if (typedData.length > 0) {
    const clientIds = Array.from(
      new Set(typedData.map((r) => r.residentialClientId).filter((v): v is string => Boolean(v))),
    );
    if (clientIds.length > 0) {
      const clients = await db
        .select({
          id: residentialClients.id,
          fullName: residentialClients.fullName,
        })
        .from(residentialClients)
        .where(
          and(eq(residentialClients.portId, portId), inArray(residentialClients.id, clientIds)),
        );
      const nameById = new Map(clients.map((c) => [c.id, c.fullName]));
      result.data = typedData.map((r) => ({
        ...r,
        clientName: nameById.get(r.residentialClientId) ?? null,
      })) as unknown as typeof result.data;
    }
  }

  return result;
}

export async function getResidentialInterestById(id: string, portId: string) {
  const interest = await db.query.residentialInterests.findFirst({
    where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
  });
  if (!interest) throw new NotFoundError('Residential interest');

  // The residentialInterest is already port-scoped; pin the client read
  // to the same port too so a future drift (a foreign-port residential
  // client id ever landing on the interest) cannot leak.
  const client = await db.query.residentialClients.findFirst({
    where: and(
      eq(residentialClients.id, interest.residentialClientId),
      eq(residentialClients.portId, portId),
    ),
  });

  return { ...interest, client };
}

export async function createResidentialInterest(
  portId: string,
  data: CreateResidentialInterestInput,
  meta: AuditMeta,
) {
  // Validate the residential client belongs to this port - prevents
  // cross-port linking.
  const client = await db.query.residentialClients.findFirst({
    where: and(
      eq(residentialClients.id, data.residentialClientId),
      eq(residentialClients.portId, portId),
    ),
  });
  if (!client) throw new NotFoundError('Residential client');

  const [row] = await db
    .insert(residentialInterests)
    .values({ portId, ...data })
    .returning();
  if (!row)
    throw new CodedError('INSERT_RETURNING_EMPTY', {
      internalMessage: 'Failed to create residential interest',
    });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'residential_interest',
    entityId: row.id,
    newValue: { residentialClientId: row.residentialClientId, pipelineStage: row.pipelineStage },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_interest:created', { id: row.id });

  // Fire-and-forget partner-forward email. Failures here MUST NOT block
  // the create - partner notification is a courtesy. Errors are logged
  // server-side so the operator can see them, but the API still 201s.
  void forwardResidentialInquiryToPartner({
    portId,
    client,
    interest: row,
  }).catch((err) => {
    console.error('[residential] partner forward failed', err);
  });

  return row;
}

/**
 * Sends a courtesy notification to the configured partner email
 * recipients when a new residential inquiry lands. Recipients are
 * configured per-port via the `residential_partner_recipients` system
 * setting (comma-separated list). No-ops when the setting is blank or
 * the inquiry has no usable client snapshot.
 *
 * Uses the same branded shell as the rest of the transactional emails
 * so the partner sees a port-branded notification rather than a raw
 * HTML block.
 */
async function forwardResidentialInquiryToPartner(input: {
  portId: string;
  client: typeof residentialClients.$inferSelect;
  interest: typeof residentialInterests.$inferSelect;
}): Promise<void> {
  const { portId, client, interest } = input;
  const raw = await readSetting<string>(SETTING_KEYS.residentialPartnerRecipients, portId);
  if (!raw) return;
  const recipients = raw
    .split(',')
    .map((s) => s.trim())
    .filter((s) => /^.+@.+\..+$/.test(s));
  if (recipients.length === 0) return;

  const branding = await getPortBrandingConfig(portId);
  const accent = brandingPrimaryColor({
    logoUrl: branding.logoUrl,
    backgroundUrl: branding.emailBackgroundUrl,
    primaryColor: branding.primaryColor,
    emailHeaderHtml: branding.emailHeaderHtml,
    emailFooterHtml: branding.emailFooterHtml,
  });
  const escapeHtml = (s: string): string =>
    s
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#039;');

  const subject = `New residential inquiry: ${client.fullName}`;
  const body = `
    <h1 style="font-family:Arial,sans-serif;font-size:20px;font-weight:600;color:#0f172a;margin:0 0 12px;">
      New residential inquiry
    </h1>
    <p style="font-family:Arial,sans-serif;font-size:14px;line-height:1.55;color:#334155;margin:0 0 12px;">
      A new residential inquiry was submitted via the CRM. Details below; full record lives in the
      port&apos;s CRM under Residential &gt; Interests.
    </p>
    <table role="presentation" style="font-family:Arial,sans-serif;font-size:13px;color:#334155;border-collapse:collapse;margin:8px 0 16px;">
      <tr>
        <td style="padding:4px 12px 4px 0;color:#64748b;">Name</td>
        <td style="padding:4px 0;">${escapeHtml(client.fullName)}</td>
      </tr>
      ${
        client.email
          ? `<tr>
              <td style="padding:4px 12px 4px 0;color:#64748b;">Email</td>
              <td style="padding:4px 0;">${escapeHtml(client.email)}</td>
            </tr>`
          : ''
      }
      ${
        client.phone
          ? `<tr>
              <td style="padding:4px 12px 4px 0;color:#64748b;">Phone</td>
              <td style="padding:4px 0;">${escapeHtml(client.phone)}</td>
            </tr>`
          : ''
      }
      ${
        client.placeOfResidence
          ? `<tr>
              <td style="padding:4px 12px 4px 0;color:#64748b;">Location</td>
              <td style="padding:4px 0;">${escapeHtml(client.placeOfResidence)}</td>
            </tr>`
          : ''
      }
      ${
        interest.preferences
          ? `<tr>
              <td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Preferences</td>
              <td style="padding:4px 0;white-space:pre-wrap;">${escapeHtml(interest.preferences)}</td>
            </tr>`
          : ''
      }
      ${
        interest.notes
          ? `<tr>
              <td style="padding:4px 12px 4px 0;color:#64748b;vertical-align:top;">Notes</td>
              <td style="padding:4px 0;white-space:pre-wrap;">${escapeHtml(interest.notes)}</td>
            </tr>`
          : ''
      }
    </table>
    <p style="font-family:Arial,sans-serif;font-size:12px;color:#64748b;margin:0;">
      Pipeline stage at submission: <span style="color:${accent};font-weight:600;">${escapeHtml(interest.pipelineStage)}</span>.
    </p>
  `;
  const html = renderShell({
    title: subject,
    body,
    branding: {
      logoUrl: branding.logoUrl,
      backgroundUrl: branding.emailBackgroundUrl,
      primaryColor: branding.primaryColor,
      emailHeaderHtml: branding.emailHeaderHtml,
      emailFooterHtml: branding.emailFooterHtml,
    },
  });
  await sendEmail(recipients, subject, html, undefined, undefined, portId);
}

/**
 * Best-effort matcher that links a residential client to an existing
 * main `clients` row representing the same person. Matches by:
 *   1. email (residential.email matches clients.contacts of channel='email')
 *   2. phoneE164 (residential.phone_e164 matches clients.contacts of
 *      channel='phone' or 'whatsapp')
 *
 * Match ordering is "exact email beats phone beats nothing" - the
 * first hit wins. Returns the linked main-client id when a match was
 * found, or null when no match exists.
 *
 * Caller is expected to handle errors / call best-effort (residential
 * lifecycle MUST NOT block on matching). Exported so the admin
 * backfill script can re-run the matcher across historical rows.
 */
export async function findAndLinkMatchingMainClient(
  residentialClientId: string,
  portId: string,
): Promise<string | null> {
  const row = await db.query.residentialClients.findFirst({
    where: and(
      eq(residentialClients.id, residentialClientId),
      eq(residentialClients.portId, portId),
    ),
  });
  if (!row) return null;
  if (row.linkedClientId) return row.linkedClientId;

  // Try email match first. Look for a main-client contact row in the
  // same port whose value matches case-insensitively. Pick the most-
  // recently-updated client when multiple match (rare but possible).
  let matchedClientId: string | null = null;
  if (row.email) {
    const emailMatches = await db
      .select({ clientId: clientContacts.clientId })
      .from(clientContacts)
      .innerJoin(clients, eq(clients.id, clientContacts.clientId))
      .where(
        and(
          eq(clients.portId, portId),
          eq(clientContacts.channel, 'email'),
          eq(clientContacts.value, row.email.toLowerCase()),
        ),
      )
      .limit(1);
    if (emailMatches[0]) matchedClientId = emailMatches[0].clientId;
  }

  if (!matchedClientId && row.phoneE164) {
    const phoneMatches = await db
      .select({ clientId: clientContacts.clientId })
      .from(clientContacts)
      .innerJoin(clients, eq(clients.id, clientContacts.clientId))
      .where(
        and(
          eq(clients.portId, portId),
          inArray(clientContacts.channel, ['phone', 'whatsapp']),
          eq(clientContacts.valueE164, row.phoneE164),
        ),
      )
      .limit(1);
    if (phoneMatches[0]) matchedClientId = phoneMatches[0].clientId;
  }

  if (!matchedClientId) return null;

  await db
    .update(residentialClients)
    .set({ linkedClientId: matchedClientId, updatedAt: new Date() })
    .where(
      and(eq(residentialClients.id, residentialClientId), eq(residentialClients.portId, portId)),
    );
  emitToRoom(`port:${portId}`, 'residential_client:updated', { id: residentialClientId });
  return matchedClientId;
}

export async function updateResidentialInterest(
  id: string,
  portId: string,
  data: UpdateResidentialInterestInput,
  meta: AuditMeta,
) {
  const before = await db.query.residentialInterests.findFirst({
    where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
  });
  if (!before) throw new NotFoundError('Residential interest');

  // Reject moves to a stage that isn't in this port's live stage list.
  // The validator accepts any string (so admins can add custom stages
  // without a deploy); the membership check is enforced here at write
  // time so a PATCH can't park an interest on a non-existent stage that
  // would then surface as an orphan in funnel reports.
  if (data.pipelineStage !== undefined) {
    await assertValidStage(portId, data.pipelineStage);
  }

  const [updated] = await db
    .update(residentialInterests)
    .set({ ...data, updatedAt: new Date() })
    .where(and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)))
    .returning();
  if (!updated) throw new NotFoundError('Residential interest');

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'residential_interest',
    entityId: id,
    oldValue: diffEntity(before, updated) as Record<string, unknown>,
    newValue: data as Record<string, unknown>,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_interest:updated', { id });

  return updated;
}

export async function archiveResidentialInterest(id: string, portId: string, meta: AuditMeta) {
  const existing = await db.query.residentialInterests.findFirst({
    where: and(eq(residentialInterests.id, id), eq(residentialInterests.portId, portId)),
  });
  if (!existing) throw new NotFoundError('Residential interest');

  await softDelete(residentialInterests, residentialInterests.id, id);

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'archive',
    entityType: 'residential_interest',
    entityId: id,
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });
  emitToRoom(`port:${portId}`, 'residential_interest:archived', { id });
}
