// ── Per-field validation state ──────────────────────────────────
//
// Listens to Clover's `change` and `blur` events on each mounted element,
// tracks per-field state, and emits `ValidationSnapshot` to subscribers.
//
// ── Two-layer error model ─────────────────────────────────────
//
//   raw error  ← what Clover reports (every change/blur)
//   displayed  ← what the UI shows (deferred until first blur)
//
// Why two layers: Clover fires `change` events with `error: "incomplete"`
// or similar on every keystroke. Showing a red border + shake while the
// user is typing "4242" is bad UX — they haven't finished entering the
// number yet. Mirrors Stripe Elements, Square Web Payments, Braintree.
//
// Rules driving `displayedError`:
//   1. `change` before first blur   → leave `displayedError` undefined
//   2. `blur` event                 → copy raw error to `displayedError`
//   3. `change` after first blur    → copy raw error to `displayedError`
//                                     (live error-clearing as user fixes)
//
// Positive feedback (green check) reads raw `touched && !error` directly —
// it never gets deferred. Asymmetric on purpose.
//
// ── Cardholder name / email caveat ───────────────────────────
//
// Clover does NOT distinguish empty from valid for `CARD_NAME` and
// `CARD_EMAIL_ADDRESS` (discovery 2026-05-18):
//   - cardName empty: no `error` key at all
//   - cardName valid: no `error` key at all
//   - cardEmail empty: `error: ""`
//   - cardEmail valid format: `error: ""`
// So we layer our own "empty?" detection on top, using `hadChangeEver` as a
// proxy. If the user blurs without ever firing a `change` event, the field
// is empty and — if required — we emit `displayedError: "Required"` and
// suppress the green check. We can't catch type-then-delete-to-empty (no
// access to the iframe value), so server-side validation remains the
// authoritative gate for those edge cases.
//
// ── Clover event shape ────────────────────────────────────────
//
// Each event Clover fires carries the full state map for every mounted field,
// not just the field that triggered it (verified via discovery 2026-05-18):
//
//   event.CARD_NUMBER         = { touched, error?, info }
//   event.CARD_DATE           = { touched, error?, info }
//   event.CARD_CVV            = { touched, error?, info }
//   event.CARD_POSTAL_CODE    = { touched, error?, info }
//   event.CARD_NAME           = { touched, error?, info }
//   event.CARD_EMAIL_ADDRESS  = { touched, error?, info }
//
// We reconcile the full snapshot on every event, but only credit the field
// that actually fired the event (the listener's owning fieldKey) with
// `hadChangeEver` / `hasBlurred` flips.

import type {
  CloverElement,
  CloverElementType,
  FieldKey,
  FieldValidationState,
  Unsubscribe,
  ValidationSnapshot,
} from './types';

/** Field keys that participate in validation. Payment Request Button does not. */
const VALIDATING_FIELDS: readonly FieldKey[] = [
  'cardNumber',
  'cardDate',
  'cardCvv',
  'cardPostalCode',
  'cardName',
  'cardEmail',
];

/**
 * Fields Clover doesn't enforce non-empty for — we emit "Required" ourselves
 * on blur if these are required and the user never typed anything.
 */
const REQUIRES_NON_EMPTY_OVERRIDE: ReadonlySet<FieldKey> = new Set(['cardName', 'cardEmail']);

const CLOVER_KEY_TO_FIELD_KEY: Record<string, FieldKey> = {
  CARD_NUMBER: 'cardNumber',
  CARD_DATE: 'cardDate',
  CARD_CVV: 'cardCvv',
  CARD_POSTAL_CODE: 'cardPostalCode',
  CARD_NAME: 'cardName',
  CARD_EMAIL_ADDRESS: 'cardEmail',
};

interface CloverFieldEventState {
  touched: boolean;
  error?: string;
}

type CloverEventType = 'change' | 'blur';

interface InternalFieldState {
  touched: boolean;
  error?: string;
  hasBlurred: boolean;
  hadChangeEver: boolean;
  displayedError?: string;
}

function emptyFieldState(): InternalFieldState {
  return {
    touched: false,
    error: undefined,
    hasBlurred: false,
    hadChangeEver: false,
    displayedError: undefined,
  };
}

function toPublic(s: InternalFieldState): FieldValidationState {
  return {
    touched: s.touched,
    error: s.error,
    hasBlurred: s.hasBlurred,
    hadChangeEver: s.hadChangeEver,
    displayedError: s.displayedError,
  };
}

function isMeaningfulError(err: string | undefined): boolean {
  // Clover uses `""` (empty string) for cardEmail to mean "no error". Treat it
  // the same as `undefined` so downstream callers only see real error strings.
  return err !== undefined && err !== '';
}

export class ValidationMachine {
  private readonly state = new Map<FieldKey, InternalFieldState>();
  private readonly required = new Set<FieldKey>();
  private readonly listeners = new Set<(snapshot: ValidationSnapshot) => void>();

  constructor(requiredFields: readonly FieldKey[]) {
    for (const f of requiredFields) {
      if (VALIDATING_FIELDS.includes(f)) {
        this.required.add(f);
      }
    }
  }

  /** Wire up the validation machine to a set of Clover elements. */
  attach(elements: Map<FieldKey, CloverElement>): void {
    for (const [key, el] of elements.entries()) {
      if (!VALIDATING_FIELDS.includes(key)) {
        continue;
      }
      // Each listener closes over `key`, so we know which field actually fired
      // the event — even though Clover's payload carries state for ALL fields.
      el.addEventListener('change', (event: Event) => this.onCloverEvent(event, 'change', key));
      el.addEventListener('blur', (event: Event) => this.onCloverEvent(event, 'blur', key));
    }
  }

  /**
   * Overlay tokenize-time errors onto per-field `displayedError`. Used when
   * `clover.createToken()` returns errors at submit time — we route them
   * back to the firing fields instead of joining them into a generic banner.
   *
   * For each field that errored we also set `hasBlurred = true`, so the
   * existing live-error-clearing rule kicks in: as soon as the user edits the
   * field, the next `change` event recomputes `displayedError` from Clover's
   * current state and clears the submit error.
   *
   * The Clover-side empty-vs-valid ambiguity for `CARD_NAME` / `CARD_EMAIL_ADDRESS`
   * means createToken may or may not emit errors for those fields when empty;
   * we surface whatever Clover reports and leave the rest as-is.
   */
  setSubmitErrors(errors: Partial<Record<CloverElementType, string>>): void {
    let changed = false;
    for (const [cloverKey, fieldKey] of Object.entries(CLOVER_KEY_TO_FIELD_KEY)) {
      const message = errors[cloverKey as CloverElementType];
      if (!message) {
        continue;
      }
      const prev = this.state.get(fieldKey) ?? emptyFieldState();
      const next: InternalFieldState = {
        ...prev,
        hasBlurred: true,
        displayedError: message,
      };
      if (
        prev.hasBlurred !== next.hasBlurred ||
        prev.displayedError !== next.displayedError
      ) {
        this.state.set(fieldKey, next);
        changed = true;
      }
    }
    if (changed) {
      this.emit();
    }
  }

  subscribe(listener: (snapshot: ValidationSnapshot) => void): Unsubscribe {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }

  getSnapshot(): ValidationSnapshot {
    const fields: { [K in FieldKey]?: FieldValidationState } = {};
    for (const [key, val] of this.state.entries()) {
      fields[key] = toPublic(val);
    }
    return { fields, canSubmit: this.computeCanSubmit() };
  }

  private onCloverEvent(event: Event, eventType: CloverEventType, ownerKey: FieldKey): void {
    const ev = event as unknown as Record<string, CloverFieldEventState | undefined>;
    let changed = false;
    for (const [cloverKey, fieldKey] of Object.entries(CLOVER_KEY_TO_FIELD_KEY)) {
      const cloverState = ev[cloverKey];
      if (!cloverState) {
        continue;
      }
      const prev = this.state.get(fieldKey) ?? emptyFieldState();
      const next: InternalFieldState = {
        ...prev,
        touched: cloverState.touched,
        error: isMeaningfulError(cloverState.error) ? cloverState.error : undefined,
      };
      // Only credit the field that ACTUALLY fired the event with the
      // "user did something" signal. Other fields' states in the payload are
      // passive snapshots — they shouldn't flip hadChangeEver/hasBlurred.
      const isOwnEvent = fieldKey === ownerKey;
      if (isOwnEvent && eventType === 'change') {
        next.hadChangeEver = true;
      }
      if (isOwnEvent && eventType === 'blur') {
        next.hasBlurred = true;
      }
      // Recompute displayedError.
      if (eventType === 'blur' || next.hasBlurred) {
        // Either the field just blurred, or it has blurred at least once and
        // a change is updating the live displayed error.
        next.displayedError = computeDisplayedError(fieldKey, next, this.required);
      } else {
        // Pre-first-blur change → don't surface errors yet (deferred-error rule).
        next.displayedError = undefined;
      }
      if (
        prev.touched !== next.touched ||
        prev.error !== next.error ||
        prev.hasBlurred !== next.hasBlurred ||
        prev.hadChangeEver !== next.hadChangeEver ||
        prev.displayedError !== next.displayedError
      ) {
        this.state.set(fieldKey, next);
        changed = true;
      }
    }
    if (changed) {
      this.emit();
    }
  }

  private computeCanSubmit(): boolean {
    for (const field of this.required) {
      const s = this.state.get(field);
      if (!s || !s.touched || s.error) {
        return false;
      }
      // For cardName/cardEmail, Clover reports no error when empty (it doesn't
      // enforce required for these). Layer our own check: require at least one
      // change event before we'll let the form submit.
      if (REQUIRES_NON_EMPTY_OVERRIDE.has(field) && !s.hadChangeEver) {
        return false;
      }
    }
    return true;
  }

  private emit(): void {
    const snapshot = this.getSnapshot();
    for (const listener of this.listeners) {
      listener(snapshot);
    }
  }
}

/**
 * Decide what error string (if any) the UI should render for `fieldKey`. Runs
 * after raw state is reconciled. Returns undefined when no error should show.
 */
function computeDisplayedError(
  fieldKey: FieldKey,
  state: InternalFieldState,
  required: ReadonlySet<FieldKey>,
): string | undefined {
  if (state.error) {
    return state.error;
  }
  // No Clover error reported. For cardName/cardEmail specifically — which
  // Clover doesn't validate as required — emit "Required" on blur if the
  // field is required and the user never typed.
  if (
    state.hasBlurred &&
    !state.hadChangeEver &&
    required.has(fieldKey) &&
    REQUIRES_NON_EMPTY_OVERRIDE.has(fieldKey)
  ) {
    return 'Required';
  }
  return undefined;
}
