// ── Top-level orchestrator ──────────────────────────────────────
//
// The class hosts integrate with. Composes the loader, element registry,
// validation machine, tokenizer, and 3DS machine into a single subscribe-able
// state machine. The React + web-component layers sit on top of this.
//
// Lifecycle: construct → subscribe → mount() → tokenize() → (optional) runThreeDs() → destroy()
//
// Key invariants:
// - GPay flow is "prepare-and-wait": when Clover fires `paymentMethod`, the SDK
//   captures the token and enters `gpay_ready`. The host's Place Order click
//   triggers `tokenize()`, which returns the captured token immediately.
//   NEVER auto-submit (the form is equivalent to "card fields filled and valid").
// - `tokenize()` is idempotent: calling it again after success returns the same
//   result; calling it while in a tokenizing/submitting state will throw.

import { getBrowserInfo } from './browser-info';
import { loadClover, loadClover3DS } from './clover-loader';
import { ElementRegistry } from './element-registry';
import { compileTheme } from './theme';
import { ThreeDsMachine } from './threeds-machine';
import { createRateLimiter, tokenize, type RateLimiter } from './tokenize';
import { ValidationMachine } from './validation-machine';
import type {
  CloverElement,
  CloverInstance,
  CloverPaymentRequestData,
  FieldKey,
  MountTargets,
  PaymentFieldsConfig,
  PaymentFieldsError,
  PaymentFieldsErrorCode,
  PaymentFieldsListener,
  PaymentFieldsSnapshot,
  ThreeDSUtil,
  ThreeDsInterim,
  ThreeDsResult,
  TokenizationResult,
  Unsubscribe,
} from './types';

const GPAY_CANCEL_DEBOUNCE_MS = 1000;

/**
 * Module-level SDK-singleton caches keyed by their natural ID.
 *
 * Why: Clover's SDK keeps internal singleton state for each
 * `new Clover(pakmsKey)` and especially each `new Clover3DS({merchantUuid})`.
 * Calling either constructor a second time on the same page produces
 * "Instance already created, no new instance allowed." and (for Clover
 * itself) intermittently makes the resulting instance's `.elements()`
 * return null — the WSO-27 TypeError variant.
 *
 * This bites us because WooCommerce's classic-checkout boot replaces the
 * payment-method `<li>` block ~250ms after first render, destroying our
 * `<wcp-payment-fields>` element and instantiating a fresh one. Each
 * fresh element instance otherwise calls `new Clover()` + `new Clover3DS()`
 * from scratch.
 *
 * We do NOT cache the ElementRegistry / its CloverElements. Clover's SDK
 * also dedupes event listeners per-(element, event) and throws on a
 * duplicate `addEventListener('change', ...)` — sharing elements across
 * mounts breaks `ValidationMachine.attach()`. Each mount creates its own
 * fresh elements via `clover.elements()`; mount #1's elements just get
 * orphaned (no change from the pre-fix world).
 */
const cloverByPakms = new Map<string, CloverInstance>();
const clover3DSByMerchant = new Map<string, ThreeDSUtil>();

export class PaymentFieldsMachine {
  /** The config the machine was constructed with. Read-only after construction. */
  readonly config: PaymentFieldsConfig;
  private readonly listeners = new Set<PaymentFieldsListener>();
  private readonly rateLimiter: RateLimiter = createRateLimiter();
  private snapshot: PaymentFieldsSnapshot = {
    state: 'idle',
    validation: { fields: {}, canSubmit: false },
  };

  private mounted = false;
  private destroyed = false;
  private clover: CloverInstance | null = null;
  private threeDsUtil: ThreeDSUtil | null = null;
  private registry: ElementRegistry | null = null;
  private validation: ValidationMachine | null = null;
  private capturedGpayToken: TokenizationResult | null = null;

  constructor(config: PaymentFieldsConfig) {
    this.config = config;
  }

  // ── public API ──

  /** Subscribe to snapshot updates. The listener is called immediately with the current snapshot. */
  subscribe(listener: PaymentFieldsListener): Unsubscribe {
    this.listeners.add(listener);
    listener(this.snapshot);
    return () => {
      this.listeners.delete(listener);
    };
  }

  getSnapshot(): PaymentFieldsSnapshot {
    return this.snapshot;
  }

  /**
   * Load the Clover SDK(s), create the elements, mount them to the host's DOM
   * targets, and wire validation + GPay event handlers. Idempotent — calling
   * again is a no-op.
   */
  async mount(targets: MountTargets): Promise<void> {
    console.log('[wcp-pf] mount() called', { targets, config: { pakmsKey: this.config.pakmsKey?.slice(0, 8) + '…', merchantId: this.config.merchantId, locale: this.config.locale, cartTotal: this.config.cartTotal, currency: this.config.currency, cloverSdkUrl: this.config.cloverSdkUrl, features: this.config.features } });
    if (this.destroyed) {
      throw new Error('Machine has been destroyed.');
    }
    if (this.mounted) {
      console.log('[wcp-pf] already mounted, skipping');
      return;
    }

    try {
      const cachedClover = cloverByPakms.get(this.config.pakmsKey);
      if (cachedClover) {
        console.log('[wcp-pf] Clover already instantiated for this pakms — reusing');
        this.clover = cachedClover;
      } else {
        console.log('[wcp-pf] loading Clover SDK from', this.config.cloverSdkUrl);
        const CloverCtor = await loadClover(this.config.cloverSdkUrl);
        console.log('[wcp-pf] Clover loaded, instantiating with pakms', this.config.pakmsKey?.slice(0, 8) + '…');
        this.clover = new CloverCtor(this.config.pakmsKey);
        this.clover.options = {
          locale: this.config.locale,
          merchantId: this.config.merchantId,
        };
        console.log('[wcp-pf] Clover instantiated', { hasElements: typeof this.clover.elements });
        cloverByPakms.set(this.config.pakmsKey, this.clover);
      }
    } catch (error) {
      console.error('[wcp-pf] Clover load/instantiate FAILED', error);
      this.emitError('clover_load_failed', 'Failed to load Clover SDK.', error);
      throw error;
    }

    if (this.config.features?.threeDSecure && this.config.clover3DSSdkUrl) {
      const cached3DS = clover3DSByMerchant.get(this.config.merchantId);
      if (cached3DS) {
        this.threeDsUtil = cached3DS;
      } else {
        try {
          const Clover3DSCtor = await loadClover3DS(this.config.clover3DSSdkUrl);
          this.threeDsUtil = new Clover3DSCtor({ merchantUuid: this.config.merchantId });
          clover3DSByMerchant.set(this.config.merchantId, this.threeDsUtil);
        } catch (error) {
          // Non-fatal — manual card flow can still work without 3DS, the API will surface the error.
          this.emitError('clover_3ds_load_failed', '3DS SDK failed to load.', error);
        }
      }
    }

    const wantsGpay = this.config.features?.googlePay === true;
    const cardholderVisible = this.shouldShowCardholderFields();

    const keys: FieldKey[] = ['cardNumber', 'cardDate', 'cardCvv', 'cardPostalCode'];
    if (cardholderVisible) {
      keys.push('cardName', 'cardEmail');
    }
    if (wantsGpay) {
      keys.push('paymentRequestButton');
    }

    const styles = compileTheme(this.config.theme);
    const paymentRequestData = wantsGpay ? this.buildPaymentRequestData() : undefined;

    console.log('[wcp-pf] creating elements for keys', keys, 'with targets', targets);
    this.registry = new ElementRegistry({
      clover: this.clover!,
      styles,
      paymentRequestData,
    });
    this.registry.create(keys);
    console.log('[wcp-pf] elements created, checking targets exist in DOM:', keys.map((k) => {
      const sel = targets[k as keyof MountTargets];
      return { key: k, selector: sel, found: sel ? !!document.querySelector(sel) : 'no-selector' };
    }));
    this.registry.mount(targets);
    console.log('[wcp-pf] registry.mount() returned');

    // Validation wiring
    const elements = new Map<FieldKey, CloverElement>();
    for (const [k, el] of this.registry.entries()) {
      elements.set(k, el);
    }
    this.validation = new ValidationMachine(keys.filter((k) => k !== 'paymentRequestButton'));
    this.validation.attach(elements);
    this.validation.subscribe((vs) => {
      this.update({ validation: vs });
    });

    // GPay event wiring
    const gpayEl = this.registry.get('paymentRequestButton');
    if (gpayEl) {
      this.wireGpayEvents(gpayEl);
    }

    this.mounted = true;
  }

  /**
   * Return a tokenization result the host can POST to the API.
   *
   * Two paths:
   * - **GPay**: if we're in `gpay_ready`, return the already-captured token immediately (no second prompt).
   * - **Manual card**: call `clover.createToken()`, rate-limited.
   *
   * Throws on rate-limit, invalid card, missing token, or unmounted state.
   */
  async tokenize(): Promise<TokenizationResult> {
    if (!this.mounted) {
      const error: PaymentFieldsError = { code: 'not_mounted', message: 'mount() must complete before tokenize().' };
      this.update({ state: 'error', error });
      throw new Error(error.message);
    }

    // GPay fast path
    if (this.snapshot.state === 'gpay_ready' && this.capturedGpayToken) {
      const result = this.capturedGpayToken;
      this.update({ state: 'submitting', result });
      this.config.onTokenized?.(result);
      return result;
    }

    if (!this.clover) {
      const error: PaymentFieldsError = { code: 'not_mounted', message: 'Clover not initialized.' };
      this.update({ state: 'error', error });
      throw new Error(error.message);
    }

    this.update({ state: 'tokenizing' });

    let raw;
    try {
      raw = await tokenize(this.clover, { rateLimiter: this.rateLimiter });
    } catch (error) {
      const message = (error as Error).message;
      const code: PaymentFieldsErrorCode = message.toLowerCase().includes('rate')
        ? 'rate_limited'
        : 'tokenization_failed';
      const err: PaymentFieldsError = { code, message, cause: error };
      this.update({ state: 'error', error: err });
      this.config.onError?.(err);
      throw error;
    }

    if (raw.errors && hasErrors(raw.errors)) {
      // Route Clover's per-field errors back onto each firing field so the user
      // sees the error against the field that's actually wrong (e.g., a
      // "Required" message landing on cardName when CARD_NAME comes back empty).
      // The validation machine clears these the moment the user re-edits.
      // Clover-side empty-vs-valid ambiguity for cardName/cardEmail means
      // those fields may or may not appear in raw.errors when empty — we
      // surface whatever Clover reports.
      this.validation?.setSubmitErrors(raw.errors);
      const err: PaymentFieldsError = {
        code: 'tokenization_failed',
        message: firstError(raw.errors) ?? 'Card details invalid.',
      };
      this.update({ state: 'error', error: err });
      this.config.onError?.(err);
      throw new Error(err.message);
    }

    if (!raw.token || !raw.card) {
      const err: PaymentFieldsError = {
        code: 'tokenization_failed',
        message: 'Tokenization returned no token.',
      };
      this.update({ state: 'error', error: err });
      this.config.onError?.(err);
      throw new Error(err.message);
    }

    const result: TokenizationResult = {
      source: 'card',
      token: raw.token,
      card: {
        brand: raw.card.brand,
        last4: raw.card.last4,
        expMonth: raw.card.exp_month,
        expYear: raw.card.exp_year,
        first6: raw.card.first6,
        addressZip: raw.card.address_zip,
      },
      browserInfo: this.config.features?.threeDSecure
        ? getBrowserInfo(this.threeDsUtil)
        : undefined,
    };

    this.update({ state: 'submitting', result });
    this.config.onTokenized?.(result);
    return result;
  }

  /**
   * Drive the 3DS flow given an interim payload from the host's API response.
   * Handles method/challenge dispatch, the `executePatch` callback, the
   * finalize POST, and escalation (method ↔ challenge) internally.
   *
   * @param options.extraBody Host-specific fields merged into the finalize POST body.
   *   E.g., WC passes `{ order_id, order_key, nonce }` so the WP REST endpoint
   *   can find and update the order. Keeps WC-specific concerns out of the SDK.
   */
  async runThreeDs(
    interim: ThreeDsInterim,
    options: { readonly extraBody?: Record<string, unknown> } = {},
  ): Promise<ThreeDsResult> {
    if (!this.threeDsUtil) {
      const err: PaymentFieldsError = {
        code: 'threeds_prerequisites_missing',
        message: '3DS SDK not loaded.',
      };
      this.update({ state: 'error', error: err });
      return { kind: 'failure', message: err.message };
    }

    const finalizeUrl = this.config.endpoints?.threeDsFinalize;
    if (!finalizeUrl) {
      const err: PaymentFieldsError = {
        code: 'threeds_finalize_failed',
        message: 'No 3DS finalize URL configured.',
      };
      this.update({ state: 'error', error: err });
      return { kind: 'failure', message: err.message };
    }

    this.config.onThreeDsRequired?.(interim);

    const machine = new ThreeDsMachine({
      util: this.threeDsUtil,
      finalizeUrl,
      headers: this.config.endpoints?.headers,
      extraBody: options.extraBody,
    });

    let current = interim;
    while (true) {
      this.update({
        state: current.state === 'method_required' ? 'threeds_method' : 'threeds_challenge',
        threeDs: current,
      });
      const result = await machine.run(current);
      if (result.kind === 'escalation') {
        current = result.next;
        continue;
      }
      if (result.kind === 'success') {
        this.update({ state: 'done' });
        return result;
      }
      const err: PaymentFieldsError = { code: 'threeds_finalize_failed', message: result.message };
      this.update({ state: 'error', error: err });
      this.config.onError?.(err);
      return result;
    }
  }

  /**
   * Restore the form to idle, discarding any captured GPay token. Used by the
   * "Use card details instead" link on the GPay-ready state.
   */
  resetToIdle(): void {
    this.capturedGpayToken = null;
    this.update({
      state: 'idle',
      result: undefined,
      threeDs: undefined,
      error: undefined,
    });
  }

  destroy(): void {
    this.destroyed = true;
    this.registry?.destroy();
    this.registry = null;
    this.validation = null;
    // The Clover instance and 3DS util are module-level singletons (see the
    // cache map at the top of this file). We drop our refs but the underlying
    // SDK objects stay alive for the next mount on this pakms key.
    this.threeDsUtil = null;
    this.clover = null;
    this.capturedGpayToken = null;
    this.listeners.clear();
  }

  // ── internals ──

  private shouldShowCardholderFields(): boolean {
    const setting = this.config.features?.cardholderFields ?? 'auto';
    if (setting === 'always') return true;
    if (setting === 'hidden') return false;
    return this.config.features?.threeDSecure === true;
  }

  private buildPaymentRequestData(): CloverPaymentRequestData {
    return {
      paymentReqData: {
        total: {
          label: 'Online purchase via Clover',
          currency: this.config.currency,
          amount: this.config.cartTotal,
        },
        options: {
          button: { buttonType: 'short' },
        },
      },
    };
  }

  private wireGpayEvents(el: CloverElement): void {
    let methodFiredForCurrentFlow = false;

    el.addEventListener('paymentMethodStart', () => {
      methodFiredForCurrentFlow = false;
      this.capturedGpayToken = null;
      this.update({ state: 'gpay_opening' });
    });

    el.addEventListener('paymentMethod', (ev: Event) => {
      methodFiredForCurrentFlow = true;
      // Clover's GPay `paymentMethod` event payload is shaped completely
      // differently from manual-card tokenization — brand lives at
      // `customer.billingInfo.cardNetwork` and last4 at `cardDetails`. Expiry
      // and first6 are NOT provided by GPay.
      const evAny = ev as unknown as {
        token?: string;
        customer?: {
          email?: string;
          billingInfo?: {
            cardNetwork?: string;
            cardDetails?: string;
            billingAddress?: {
              postalCode?: string;
            };
          };
        };
      };
      const billing = evAny.customer?.billingInfo;
      const brand = billing?.cardNetwork;
      const last4 = billing?.cardDetails;
      if (!evAny.token || !brand || !last4) {
        const err: PaymentFieldsError = {
          code: 'tokenization_failed',
          message: 'Google Pay token missing required fields.',
        };
        this.update({ state: 'error', error: err });
        this.config.onError?.(err);
        return;
      }
      const result: TokenizationResult = {
        source: 'google_pay',
        token: evAny.token,
        card: {
          brand,
          last4,
          // GPay doesn't expose expMonth/expYear/first6 in the event payload.
          addressZip: billing?.billingAddress?.postalCode,
        },
        browserInfo: this.config.features?.threeDSecure
          ? getBrowserInfo(this.threeDsUtil)
          : undefined,
      };
      this.capturedGpayToken = result;
      // Critical: do NOT call onTokenized here. The host hasn't asked for the token.
      // We hold it internally; tokenize() returns it when called from Place Order.
      this.update({ state: 'gpay_ready', result });
    });

    el.addEventListener('paymentMethodEnd', () => {
      // Cancellation: if `paymentMethod` never fired within the debounce window,
      // the user closed the GPay sheet without confirming.
      window.setTimeout(() => {
        if (methodFiredForCurrentFlow) {
          return;
        }
        if (this.snapshot.state !== 'gpay_opening') {
          return;
        }
        this.update({ state: 'gpay_cancelled' });
        window.setTimeout(() => {
          if (this.snapshot.state === 'gpay_cancelled') {
            this.update({ state: 'idle' });
          }
        }, 100);
      }, GPAY_CANCEL_DEBOUNCE_MS);
    });
  }

  private update(partial: Partial<PaymentFieldsSnapshot>): void {
    this.snapshot = { ...this.snapshot, ...partial };
    for (const listener of this.listeners) {
      listener(this.snapshot);
    }
  }

  private emitError(code: PaymentFieldsErrorCode, message: string, cause?: unknown): void {
    const error: PaymentFieldsError = { code, message, cause };
    this.update({ state: 'error', error });
    this.config.onError?.(error);
  }
}

function hasErrors(errors: Record<string, string | undefined>): boolean {
  return Object.values(errors).some((v) => !!v);
}

function firstError(errors: Record<string, string | undefined>): string | undefined {
  return Object.values(errors).find((v) => !!v);
}
