import { eq, and, gte, lte, sql, isNull, ilike, isNotNull, desc, ne } from 'drizzle-orm';
import type { PgColumn } from 'drizzle-orm/pg-core';

import { db } from '@/lib/db';
import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial';
import { buildListQuery } from '@/lib/db/query-builder';
import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit';
import { diffEntity } from '@/lib/entity-diff';
import { softDelete, restore } from '@/lib/db/utils';
import { CodedError, NotFoundError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { convert } from '@/lib/services/currency';
import { logger } from '@/lib/logger';
import type {
  CreateExpenseInput,
  UpdateExpenseInput,
  ListExpensesInput,
} from '@/lib/validators/expenses';

export type { ListExpensesInput };

/**
 * Trim + collapse whitespace on free-text trip labels so "Palm Beach 2026"
 * and " Palm Beach 2026 " end up identical for the group-by + filter.
 * Empty / whitespace-only collapses to null.
 */
function normalizeTripLabel(input: string | null | undefined): string | null {
  if (input == null) return null;
  const trimmed = input.replace(/\s+/g, ' ').trim();
  return trimmed.length > 0 ? trimmed : null;
}

/**
 * Distinct trip labels used in this port, ordered by most-recent first
 * so the autocomplete surfaces "what reps actually used lately" rather
 * than alphabetically. Powers the `tripLabel` <Combobox> on the expense
 * form. Read-only - no mutation hooks.
 */
export async function listTripLabels(portId: string, search?: string): Promise<string[]> {
  const conditions = [
    eq(expenses.portId, portId),
    isNotNull(expenses.tripLabel),
    ne(expenses.tripLabel, ''),
    isNull(expenses.archivedAt),
  ];
  if (search && search.trim().length > 0) {
    conditions.push(ilike(expenses.tripLabel, `%${search.trim()}%`));
  }
  const rows = await db
    .selectDistinct({
      tripLabel: expenses.tripLabel,
      latest: sql<Date>`MAX(${expenses.expenseDate})`.as('latest'),
    })
    .from(expenses)
    .where(and(...conditions))
    .groupBy(expenses.tripLabel)
    .orderBy(desc(sql`MAX(${expenses.expenseDate})`))
    .limit(50);
  return rows.map((r) => r.tripLabel as string).filter((s): s is string => Boolean(s));
}

export async function listExpenses(portId: string, query: ListExpensesInput) {
  const filters = [];

  if (query.category) {
    filters.push(eq(expenses.category, query.category));
  }
  if (query.paymentStatus) {
    filters.push(eq(expenses.paymentStatus, query.paymentStatus));
  }
  if (query.currency) {
    filters.push(eq(expenses.currency, query.currency));
  }
  if (query.payer) {
    filters.push(eq(expenses.payer, query.payer));
  }
  if (query.tripLabel) {
    filters.push(eq(expenses.tripLabel, query.tripLabel));
  }
  if (query.dateFrom) {
    filters.push(gte(expenses.expenseDate, new Date(query.dateFrom)));
  }
  if (query.dateTo) {
    filters.push(lte(expenses.expenseDate, new Date(query.dateTo)));
  }

  return buildListQuery({
    table: expenses,
    portIdColumn: expenses.portId,
    portId,
    idColumn: expenses.id,
    updatedAtColumn: expenses.updatedAt,
    filters,
    page: query.page,
    pageSize: query.limit,
    searchColumns: [expenses.establishmentName, expenses.description],
    searchTerm: query.search,
    includeArchived: query.includeArchived,
    archivedAtColumn: expenses.archivedAt,
    sort: query.sort
      ? {
          column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn,
          direction: query.order,
        }
      : undefined,
  });
}

export async function getExpenseById(id: string, portId: string) {
  const expense = await db.query.expenses.findFirst({
    where: and(eq(expenses.id, id), eq(expenses.portId, portId)),
  });
  if (!expense) throw new NotFoundError('Expense');
  return expense;
}

export async function createExpense(portId: string, data: CreateExpenseInput, meta: AuditMeta) {
  let amountUsd: string | null = null;
  let exchangeRate: string | null = null;

  if (data.currency !== 'USD') {
    const conversion = await convert(data.amount, data.currency, 'USD');
    if (conversion) {
      amountUsd = String(conversion.result);
      exchangeRate = String(conversion.rate);
    } else {
      // BR-040: if rate unavailable, save without conversion + log warning
      logger.warn(
        { currency: data.currency },
        'Currency rate unavailable, saving expense without USD conversion',
      );
    }
  } else {
    amountUsd = String(data.amount);
    exchangeRate = '1';
  }

  const [expense] = await db
    .insert(expenses)
    .values({
      portId,
      establishmentName: data.establishmentName,
      amount: String(data.amount),
      currency: data.currency,
      amountUsd,
      exchangeRate,
      paymentMethod: data.paymentMethod,
      category: data.category,
      payer: data.payer,
      expenseDate: data.expenseDate,
      description: data.description,
      tripLabel: normalizeTripLabel(data.tripLabel),
      receiptFileIds: data.receiptFileIds ?? [],
      noReceiptAcknowledged: data.noReceiptAcknowledged ?? false,
      paymentStatus: data.paymentStatus,
      paymentDate: data.paymentDate ?? null,
      paymentReference: data.paymentReference ?? null,
      paymentNotes: data.paymentNotes ?? null,
      createdBy: meta.userId,
    })
    .returning();

  if (!expense)
    throw new CodedError('INSERT_RETURNING_EMPTY', {
      internalMessage: 'Expense insert returned no row',
    });

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'create',
    entityType: 'expense',
    entityId: expense.id,
    newValue: toAuditJson(expense),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'expense:created', {
    expenseId: expense.id,
    amount: Number(expense.amount),
    currency: expense.currency,
    category: expense.category ?? '',
  });

  // Schedule a duplicate-detection sweep. Best-effort - we don't want a
  // queue-side hiccup to fail the user's create.
  try {
    const { getQueue } = await import('@/lib/queue');
    await getQueue('maintenance').add(
      'expense-dedup-scan',
      { expenseId: expense.id },
      { jobId: `expense-dedup-scan:${expense.id}` },
    );
  } catch (err) {
    logger.warn({ err, expenseId: expense.id }, 'Failed to enqueue expense-dedup-scan');
  }

  return expense;
}

export async function updateExpense(
  id: string,
  portId: string,
  data: UpdateExpenseInput,
  meta: AuditMeta,
) {
  const existing = await getExpenseById(id, portId);

  // The create-time validator enforces "receipt OR no-receipt-ack" via
  // `.refine`, but `updateExpenseSchema` is `.partial()` so the rule is
  // dropped. Re-assert it here against the merged (existing + patch)
  // shape so a PATCH can't slip an unexplained receipt-less expense
  // past the create-time guard.
  const mergedReceiptIds =
    data.receiptFileIds !== undefined ? data.receiptFileIds : existing.receiptFileIds;
  const mergedAck =
    data.noReceiptAcknowledged !== undefined
      ? data.noReceiptAcknowledged
      : existing.noReceiptAcknowledged;
  const hasReceipts = Array.isArray(mergedReceiptIds) && mergedReceiptIds.length > 0;
  if (!hasReceipts && !mergedAck) {
    throw new CodedError('EXPENSES_RECEIPT_REQUIRED');
  }

  const updateData: Record<string, unknown> = { ...data, updatedAt: new Date() };
  if (data.tripLabel !== undefined) {
    updateData.tripLabel = normalizeTripLabel(data.tripLabel);
  }

  // Re-convert to USD if amount or currency changed
  const newAmount = data.amount ?? Number(existing.amount);
  const newCurrency = data.currency ?? existing.currency;

  if (data.amount !== undefined || data.currency !== undefined) {
    if (newCurrency !== 'USD') {
      const conversion = await convert(newAmount, newCurrency, 'USD');
      if (conversion) {
        updateData.amountUsd = String(conversion.result);
        updateData.exchangeRate = String(conversion.rate);
      } else {
        logger.warn(
          { currency: newCurrency },
          'Currency rate unavailable during update, clearing USD conversion',
        );
        updateData.amountUsd = null;
        updateData.exchangeRate = null;
      }
    } else {
      updateData.amountUsd = String(newAmount);
      updateData.exchangeRate = '1';
    }
  }

  if (data.amount !== undefined) updateData.amount = String(data.amount);

  const { diff } = diffEntity(toAuditJson(existing), updateData);

  const [updated] = await db
    .update(expenses)
    .set(updateData as Record<string, unknown>)
    .where(and(eq(expenses.id, id), eq(expenses.portId, portId)))
    .returning();

  if (!updated) throw new NotFoundError('Expense');

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'expense',
    entityId: id,
    oldValue: toAuditJson(existing),
    newValue: toAuditJson(updated),
    metadata: { diff },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'expense:updated', {
    expenseId: id,
    changedFields: Object.keys(diff),
  });

  return updated;
}

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

  // BR-045: Check if linked to non-draft invoice
  const linkedInvoice = await db
    .select({ invoiceId: invoiceExpenses.invoiceId })
    .from(invoiceExpenses)
    .innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
    .where(and(eq(invoiceExpenses.expenseId, id), sql`${invoices.status} != 'draft'`))
    .limit(1);

  if (linkedInvoice.length > 0) {
    throw new CodedError('EXPENSES_INVOICE_LINKED');
  }

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

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'archive',
    entityType: 'expense',
    entityId: id,
    oldValue: toAuditJson(existing),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'expense:archived', { expenseId: id });
}

export async function restoreExpense(id: string, portId: string, meta: AuditMeta) {
  await getExpenseById(id, portId);

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

  const restored = await getExpenseById(id, portId);

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'restore',
    entityType: 'expense',
    entityId: id,
    newValue: toAuditJson(restored),
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  emitToRoom(`port:${portId}`, 'expense:updated', {
    expenseId: id,
    changedFields: ['archivedAt'],
  });

  return restored;
}

export async function addReceiptFiles(
  id: string,
  portId: string,
  fileIds: string[],
  meta: AuditMeta,
) {
  await getExpenseById(id, portId);

  const [updated] = await db
    .update(expenses)
    .set({
      receiptFileIds: sql`array_cat(receipt_file_ids, ${fileIds}::text[])`,
      updatedAt: new Date(),
    } as Record<string, unknown>)
    .where(and(eq(expenses.id, id), eq(expenses.portId, portId)))
    .returning();

  if (!updated) throw new NotFoundError('Expense');

  void createAuditLog({
    userId: meta.userId,
    portId,
    action: 'update',
    entityType: 'expense',
    entityId: id,
    metadata: { addedFileIds: fileIds },
    ipAddress: meta.ipAddress,
    userAgent: meta.userAgent,
  });

  return updated;
}
