/**
 * Row classification — the heart of both the dry-run preview and the commit.
 *
 * For one mapped row: validate (required + per-field zod) → resolve FKs →
 * dedup by natural key → resolve to an outcome under the chosen conflict
 * policy. Pure with respect to writes; the commit step acts on the outcome.
 */
import { applyMapping } from './mapping';
import type {
  ClassifiedRow,
  ConflictPolicy,
  ImportAdapter,
  ImportCtx,
  MappedRow,
  RawRow,
  RowError,
} from './types';

/** Required-field + per-field zod validation. Never throws; returns all errors. */
export function validateRow(adapter: ImportAdapter, mapped: MappedRow): RowError[] {
  const errors: RowError[] = [];
  for (const field of adapter.targetFields) {
    const value = mapped[field.key];
    if (value === undefined) {
      if (field.required) errors.push({ field: field.key, message: `${field.label} is required` });
      continue;
    }
    const res = field.zod.safeParse(value);
    if (!res.success) {
      const first = res.error.issues[0];
      errors.push({ field: field.key, message: first?.message ?? 'Invalid value' });
    }
  }
  if (errors.length === 0 && adapter.extraValidate) errors.push(...adapter.extraValidate(mapped));
  return errors;
}

export async function classifyRow(
  adapter: ImportAdapter,
  raw: RawRow,
  mapping: Record<string, string>,
  rowNumber: number,
  policy: ConflictPolicy,
  ctx: ImportCtx,
  /**
   * Match keys already emitted by *earlier rows of the same file*. When set,
   * an insert whose natural key was already seen in-file is re-routed through
   * the same conflict branch a DB match would take — so the dry-run preview
   * reflects what the sequential commit actually does (audit M25: previously
   * two file rows sharing one brand-new email both classified `insert`, but the
   * commit turns row 2 into a skip/update/error once row 1 is live). Mutated by
   * this function: the row's resolved key is added on classification.
   */
  inFileSeen?: Set<string>,
): Promise<ClassifiedRow> {
  const mapped = applyMapping(raw, mapping);

  const errors = validateRow(adapter, mapped);
  if (errors.length > 0) return { rowNumber, outcome: 'error', errors };

  // Foreign keys (by natural key) — resolution failures are row errors.
  let resolved: Record<string, string> = {};
  if (adapter.resolveForeignKeys) {
    const fk = await adapter.resolveForeignKeys(mapped, ctx);
    if (!fk.ok) return { rowNumber, outcome: 'error', errors: fk.errors };
    resolved = fk.resolved;
  }

  // Dedup by natural key — first against the DB, then (M25) against earlier
  // rows of this same file so the preview matches the commit's sequential
  // classify-then-insert ordering.
  const key = adapter.matchKey(mapped);
  const existing = key ? await adapter.findExisting(ctx.portId, key) : null;
  const matchedInFile = !existing && key != null && (inFileSeen?.has(key) ?? false);

  if (existing || matchedInFile) {
    // existingId is the DB row's id when one exists; for an in-file predecessor
    // there is no id yet at dry-run time (it gets created earlier in the same
    // commit pass), so it's left undefined.
    const existingId = existing?.id;
    if (policy === 'error-on-match') {
      return {
        rowNumber,
        outcome: 'error',
        existingId,
        errors: [{ field: '*', message: 'Matches an existing record' }],
      };
    }
    if (policy === 'update-matches' && adapter.update) {
      return { rowNumber, outcome: 'update', existingId, mapped, resolved };
    }
    // skip-matches, or update requested on an insert-only adapter.
    return { rowNumber, outcome: 'skip', existingId, mapped, resolved };
  }

  // First sighting of this key in-file → record it so a later duplicate row
  // classifies as a match above.
  if (key != null) inFileSeen?.add(key);

  return { rowNumber, outcome: 'insert', mapped, resolved };
}

export interface DryRunSummary {
  total: number;
  insert: number;
  update: number;
  skip: number;
  error: number;
  rows: ClassifiedRow[];
}

/** Classify a whole file. Sequential to keep FK lookups + dedup ordering
 *  deterministic (migration files are small). */
export async function classifyRows(
  adapter: ImportAdapter,
  rawRows: RawRow[],
  mapping: Record<string, string>,
  policy: ConflictPolicy,
  ctx: ImportCtx,
): Promise<DryRunSummary> {
  const rows: ClassifiedRow[] = [];
  const summary = { total: rawRows.length, insert: 0, update: 0, skip: 0, error: 0 };
  // Tracks natural keys already classified as inserts so a later in-file
  // duplicate previews as a skip/update/error (M25), mirroring the commit.
  const inFileSeen = new Set<string>();
  for (let i = 0; i < rawRows.length; i++) {
    const c = await classifyRow(adapter, rawRows[i]!, mapping, i + 1, policy, ctx, inFileSeen);
    rows.push(c);
    summary[c.outcome] += 1;
  }
  return { ...summary, rows };
}
