/**
 * Commit + undo for the importer.
 *
 * commitBatch streams classified rows, applies insert/update per the conflict
 * policy via the adapter (each row in its own try/catch so valid rows land),
 * records every action in import_batch_rows, and keeps live counts on the
 * batch header.
 *
 * undoBatch hard-deletes the rows a batch *inserted* (reverse order), port-
 * scoped, leaning on DB referential integrity as the guard: a row that now
 * has dependents fails its delete and is reported (with the blocking
 * constraint/table) rather than force-removed.
 *
 * IMPORTANT (audit M26) — `update-matches` is DESTRUCTIVE WITHOUT ROLLBACK.
 * undoBatch reverses ONLY `action='inserted'` rows. An `update-matches` run
 * that overwrote, say, 500 companies' taxId/billingEmail or 500 berths'
 * price/dimensions CANNOT be undone here: the ledger stores only the entity id,
 * not the pre-image, so there is nothing to restore to. A full update-undo
 * would require capturing a JSON pre-image of every updated row, which needs a
 * new column on `import_batch_rows` (e.g. `pre_image jsonb`) plus a per-row
 * SELECT-before-UPDATE in commitBatch — deferred (out of the current scope's
 * single-table reach). Until then, any UI/route offering "Undo" on an
 * update-matches batch MUST warn the operator that updates are irreversible.
 * See the explicit guard + warning emitted by undoBatch below.
 */
import { and, eq, inArray } from 'drizzle-orm';

import { db } from '@/lib/db';
import { importBatches, importBatchRows } from '@/lib/db/schema/imports';
import { companies } from '@/lib/db/schema/companies';
import { clients } from '@/lib/db/schema/clients';
import { berths } from '@/lib/db/schema/berths';

import { classifyRow } from './classify';
import type { ConflictPolicy, ImportAdapter, ImportCtx, RawRow } from './types';

/** Adapter keys whose inserted rows the importer can hard-delete on undo.
 *  Extended as the FK adapters land. */
const UNDOABLE = new Set(['companies', 'clients', 'berths']);

/**
 * Pull a human-readable "blocked by" reason out of a Postgres FK-violation
 * (code 23503) so undo's blocked-row report names the dependent table/
 * constraint instead of just a bare row number (audit M26). postgres.js
 * surfaces these on the error or its `cause`. Falls back to the raw message.
 */
function describeDeleteBlock(err: unknown): string {
  if (!err || typeof err !== 'object') return 'delete blocked';
  const e = err as {
    code?: unknown;
    table_name?: unknown;
    table?: unknown;
    constraint_name?: unknown;
    constraint?: unknown;
    detail?: unknown;
    message?: unknown;
    cause?: {
      code?: unknown;
      table_name?: unknown;
      table?: unknown;
      constraint_name?: unknown;
      constraint?: unknown;
      detail?: unknown;
    };
  };
  const code = e.code ?? e.cause?.code;
  if (code === '23503') {
    // The *referencing* (dependent) table is `table_name`; the constraint
    // names the relationship. `detail` is the most specific ("Key (id)=(…) is
    // still referenced from table \"payments\".").
    const table = e.table_name ?? e.table ?? e.cause?.table_name ?? e.cause?.table;
    const constraint =
      e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
    const detail = e.detail ?? e.cause?.detail;
    const where = table ? `dependent rows in "${String(table)}"` : 'dependent rows exist';
    const via = constraint ? ` (constraint ${String(constraint)})` : '';
    return typeof detail === 'string' && detail
      ? `blocked by ${where}${via}: ${detail}`
      : `blocked by ${where}${via}`;
  }
  return typeof e.message === 'string' && e.message ? e.message : 'delete blocked';
}

/** Hard-delete one imported entity, port-scoped. Throws on FK violation
 *  (a dependent now exists) — the caller reports that row as blocked. */
async function deleteEntity(entityType: string, entityId: string, portId: string): Promise<void> {
  switch (entityType) {
    case 'companies':
      await db
        .delete(companies)
        .where(and(eq(companies.id, entityId), eq(companies.portId, portId)));
      return;
    case 'clients':
      await db.delete(clients).where(and(eq(clients.id, entityId), eq(clients.portId, portId)));
      return;
    case 'berths':
      await db.delete(berths).where(and(eq(berths.id, entityId), eq(berths.portId, portId)));
      return;
    default:
      throw new Error(`Undo not supported for entity "${entityType}"`);
  }
}

async function recordRow(
  batchId: string,
  rowNumber: number,
  action: 'inserted' | 'updated' | 'skipped' | 'errored',
  entityId: string | null,
  error: string | null,
): Promise<void> {
  await db.insert(importBatchRows).values({ batchId, rowNumber, action, entityId, error });
}

export interface CommitResult {
  inserted: number;
  updated: number;
  skipped: number;
  errored: number;
}

export async function commitBatch(params: {
  batchId: string;
  adapter: ImportAdapter;
  rawRows: RawRow[];
  mapping: Record<string, string>;
  policy: ConflictPolicy;
  ctx: ImportCtx;
}): Promise<CommitResult> {
  const { batchId, adapter, rawRows, mapping, policy, ctx } = params;
  const counts: CommitResult = { inserted: 0, updated: 0, skipped: 0, errored: 0 };

  // Idempotency gate (audit M27): atomically claim the batch for commit, but
  // only if it's still awaiting one. The conditional WHERE + RETURNING means a
  // concurrent worker (or a re-enqueued duplicate job) that lost the race sees
  // zero rows and bails *before* writing any import_batch_rows — so undo never
  // sees two runs' worth of ledger rows and the header counts stay reconciled.
  // Pairs with the worker's pre-flight status check (which also avoids the
  // wasted file re-read); this UPDATE is the authoritative claim.
  const claimed = await db
    .update(importBatches)
    .set({ status: 'committing', totalRows: rawRows.length })
    .where(
      and(eq(importBatches.id, batchId), inArray(importBatches.status, ['dry_run', 'uploaded'])),
    )
    .returning({ id: importBatches.id });
  if (claimed.length === 0) {
    // Already committing/completed/failed/undone (or vanished). Do NOT
    // re-process — return the no-op counts so callers don't double-count.
    return counts;
  }

  for (let i = 0; i < rawRows.length; i++) {
    const rowNumber = i + 1;
    try {
      const c = await classifyRow(adapter, rawRows[i]!, mapping, rowNumber, policy, ctx);
      if (c.outcome === 'error') {
        counts.errored += 1;
        await recordRow(
          batchId,
          rowNumber,
          'errored',
          null,
          (c.errors ?? []).map((e) => `${e.field}: ${e.message}`).join('; ') || 'Invalid row',
        );
      } else if (c.outcome === 'skip') {
        counts.skipped += 1;
        await recordRow(batchId, rowNumber, 'skipped', c.existingId ?? null, null);
      } else if (c.outcome === 'update' && adapter.update) {
        await adapter.update(c.existingId!, c.mapped!, c.resolved ?? {}, ctx);
        counts.updated += 1;
        await recordRow(batchId, rowNumber, 'updated', c.existingId!, null);
      } else {
        // insert
        const { id } = await adapter.insert(c.mapped!, c.resolved ?? {}, ctx);
        counts.inserted += 1;
        await recordRow(batchId, rowNumber, 'inserted', id, null);
      }
    } catch (e) {
      counts.errored += 1;
      await recordRow(batchId, rowNumber, 'errored', null, (e as Error).message);
    }

    if (rowNumber % 25 === 0) {
      await db
        .update(importBatches)
        .set({ ...counts })
        .where(eq(importBatches.id, batchId));
    }
  }

  await db
    .update(importBatches)
    .set({ ...counts, status: 'completed', completedAt: new Date() })
    .where(eq(importBatches.id, batchId));

  return counts;
}

export interface UndoBlockedRow {
  rowNumber: number;
  /** Human-readable reason — names the blocking dependent table/constraint
   *  when the delete tripped an FK (audit M26), else the raw error. */
  reason: string;
}

export interface UndoResult {
  deleted: number;
  blocked: number;
  blockedRows: number[];
  /** Same blocked rows as `blockedRows`, with the blocking reason attached. */
  blockedDetail: UndoBlockedRow[];
  /**
   * Rows this batch *updated* under `update-matches` that undo did NOT reverse
   * (audit M26: there is no stored pre-image to restore, so updates are
   * destructive-without-rollback). Surfaced so the caller can warn the
   * operator that these mutations remain in place.
   */
  irreversibleUpdates: number;
}

/**
 * Hard-delete the rows a batch inserted (port-scoped). A delete blocked by a
 * dependent (FK) is reported (with the blocking table/constraint), not forced.
 * Marks the batch `undone` when every inserted row was removed.
 *
 * NOTE (audit M26): this reverses ONLY inserts. Any `action='updated'` rows are
 * left untouched and counted in `irreversibleUpdates` — undo cannot restore the
 * overwritten values because no pre-image is captured. Callers offering an
 * "Undo" affordance must warn when `irreversibleUpdates > 0`.
 */
export async function undoBatch(batchId: string, portId: string): Promise<UndoResult> {
  const [batch] = await db
    .select({
      id: importBatches.id,
      entityType: importBatches.entityType,
      status: importBatches.status,
    })
    .from(importBatches)
    .where(and(eq(importBatches.id, batchId), eq(importBatches.portId, portId)))
    .limit(1);
  if (!batch) throw new Error('Import batch not found');

  if (!UNDOABLE.has(batch.entityType)) {
    throw new Error(`Undo not supported for entity "${batch.entityType}"`);
  }

  const inserted = await db
    .select({
      id: importBatchRows.id,
      rowNumber: importBatchRows.rowNumber,
      entityId: importBatchRows.entityId,
    })
    .from(importBatchRows)
    .where(and(eq(importBatchRows.batchId, batchId), eq(importBatchRows.action, 'inserted')));

  // Count the update-matches mutations we can't reverse (audit M26). These stay
  // applied; the count lets the caller warn the operator.
  const updatedRows = await db
    .select({ id: importBatchRows.id })
    .from(importBatchRows)
    .where(and(eq(importBatchRows.batchId, batchId), eq(importBatchRows.action, 'updated')));

  const result: UndoResult = {
    deleted: 0,
    blocked: 0,
    blockedRows: [],
    blockedDetail: [],
    irreversibleUpdates: updatedRows.length,
  };
  for (const row of inserted) {
    if (!row.entityId) continue;
    try {
      await deleteEntity(batch.entityType, row.entityId, portId);
      // Flip the ledger row so a re-undo is a no-op.
      await db
        .update(importBatchRows)
        .set({ action: 'skipped', error: 'undone' })
        .where(eq(importBatchRows.id, row.id));
      result.deleted += 1;
    } catch (e) {
      const reason = describeDeleteBlock(e);
      result.blocked += 1;
      result.blockedRows.push(row.rowNumber);
      result.blockedDetail.push({ rowNumber: row.rowNumber, reason });
      // Persist the reason on the ledger row so the error report can explain
      // *why* a row couldn't be undone, not just that it couldn't.
      await db
        .update(importBatchRows)
        .set({ error: `undo blocked: ${reason}`.slice(0, 1000) })
        .where(eq(importBatchRows.id, row.id));
    }
  }

  await db
    .update(importBatches)
    .set({ status: result.blocked === 0 ? 'undone' : 'completed' })
    .where(eq(importBatches.id, batchId));

  return result;
}
