// Must use `import *` or named imports for React's types
import {FunctionComponent} from 'react';
import * as stripeJs from '@stripe/stripe-js';

import React from 'react';

import PropTypes from 'prop-types';

import {useAttachEvent} from '../utils/useAttachEvent';
import {ElementProps} from '../types';
import {usePrevious} from '../utils/usePrevious';
import {
  extractAllowedOptionsUpdates,
  UnknownOptions,
} from '../utils/extractAllowedOptionsUpdates';
import {useElementsOrCheckoutSdkContextWithUseCase} from './CheckoutProvider';

type UnknownCallback = (...args: unknown[]) => any;

interface PrivateElementProps {
  id?: string;
  className?: string;
  onChange?: UnknownCallback;
  onBlur?: UnknownCallback;
  onFocus?: UnknownCallback;
  onEscape?: UnknownCallback;
  onReady?: UnknownCallback;
  onClick?: UnknownCallback;
  onLoadError?: UnknownCallback;
  onLoaderStart?: UnknownCallback;
  onNetworksChange?: UnknownCallback;
  onConfirm?: UnknownCallback;
  onCancel?: UnknownCallback;
  onShippingAddressChange?: UnknownCallback;
  onShippingRateChange?: UnknownCallback;
  onSavedPaymentMethodRemove?: UnknownCallback;
  onSavedPaymentMethodUpdate?: UnknownCallback;
  options?: UnknownOptions;
}

const capitalized = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);

const createElementComponent = (
  type: stripeJs.StripeElementType,
  isServer: boolean
): FunctionComponent<ElementProps> => {
  const displayName = `${capitalized(type)}Element`;

  const ClientElement: FunctionComponent<PrivateElementProps> = ({
    id,
    className,
    options = {},
    onBlur,
    onFocus,
    onReady,
    onChange,
    onEscape,
    onClick,
    onLoadError,
    onLoaderStart,
    onNetworksChange,
    onConfirm,
    onCancel,
    onShippingAddressChange,
    onShippingRateChange,
    onSavedPaymentMethodRemove,
    onSavedPaymentMethodUpdate,
  }) => {
    const ctx = useElementsOrCheckoutSdkContextWithUseCase(
      `mounts <${displayName}>`
    );
    const elements = 'elements' in ctx ? ctx.elements : null;
    const checkoutSdk = 'checkoutSdk' in ctx ? ctx.checkoutSdk : null;
    const [element, setElement] = React.useState<stripeJs.StripeElement | null>(
      null
    );
    const elementRef = React.useRef<stripeJs.StripeElement | null>(null);
    const domNode = React.useRef<HTMLDivElement | null>(null);

    // For every event where the merchant provides a callback, call element.on
    // with that callback. If the merchant ever changes the callback, removes
    // the old callback with element.off and then call element.on with the new one.
    useAttachEvent(element, 'blur', onBlur);
    useAttachEvent(element, 'focus', onFocus);
    useAttachEvent(element, 'escape', onEscape);
    useAttachEvent(element, 'click', onClick);
    useAttachEvent(element, 'loaderror', onLoadError);
    useAttachEvent(element, 'loaderstart', onLoaderStart);
    useAttachEvent(element, 'networkschange', onNetworksChange);
    useAttachEvent(element, 'confirm', onConfirm);
    useAttachEvent(element, 'cancel', onCancel);
    useAttachEvent(element, 'shippingaddresschange', onShippingAddressChange);
    useAttachEvent(element, 'shippingratechange', onShippingRateChange);
    useAttachEvent(
      element,
      'savedpaymentmethodremove',
      onSavedPaymentMethodRemove
    );
    useAttachEvent(
      element,
      'savedpaymentmethodupdate',
      onSavedPaymentMethodUpdate
    );
    useAttachEvent(element, 'change', onChange);

    let readyCallback: UnknownCallback | undefined;
    if (onReady) {
      if (type === 'expressCheckout') {
        // Passes through the event, which includes visible PM types
        readyCallback = onReady;
      } else {
        // For other Elements, pass through the Element itself.
        readyCallback = () => {
          onReady(element);
        };
      }
    }

    useAttachEvent(element, 'ready', readyCallback);

    React.useLayoutEffect(() => {
      if (
        elementRef.current === null &&
        domNode.current !== null &&
        (elements || checkoutSdk)
      ) {
        let newElement: stripeJs.StripeElement | null = null;
        if (checkoutSdk) {
          switch (type) {
            case 'payment':
              newElement = checkoutSdk.createPaymentElement(options);
              break;
            case 'address':
              if ('mode' in options) {
                const {mode, ...restOptions} = options;
                if (mode === 'shipping') {
                  newElement = checkoutSdk.createShippingAddressElement(
                    restOptions
                  );
                } else if (mode === 'billing') {
                  newElement = checkoutSdk.createBillingAddressElement(
                    restOptions
                  );
                } else {
                  throw new Error(
                    "Invalid options.mode. mode must be 'billing' or 'shipping'."
                  );
                }
              } else {
                throw new Error(
                  "You must supply options.mode. mode must be 'billing' or 'shipping'."
                );
              }
              break;
            case 'expressCheckout':
              newElement = checkoutSdk.createExpressCheckoutElement(
                options as any
              ) as stripeJs.StripeExpressCheckoutElement;
              break;
            case 'currencySelector':
              newElement = checkoutSdk.createCurrencySelectorElement();
              break;
            case 'taxId':
              newElement = checkoutSdk.createTaxIdElement(options);
              break;
            default:
              throw new Error(
                `Invalid Element type ${displayName}. You must use either the <PaymentElement />, <AddressElement options={{mode: 'shipping'}} />, <AddressElement options={{mode: 'billing'}} />, or <ExpressCheckoutElement />.`
              );
          }
        } else if (elements) {
          newElement = elements.create(type as any, options);
        }

        // Store element in a ref to ensure it's _immediately_ available in cleanup hooks in StrictMode
        elementRef.current = newElement;
        // Store element in state to facilitate event listener attachment
        setElement(newElement);

        if (newElement) {
          newElement.mount(domNode.current);
        }
      }
    }, [elements, checkoutSdk, options]);

    const prevOptions = usePrevious(options);
    React.useEffect(() => {
      if (!elementRef.current) {
        return;
      }

      const updates = extractAllowedOptionsUpdates(options, prevOptions, [
        'paymentRequest',
      ]);

      if (updates && 'update' in elementRef.current) {
        elementRef.current.update(updates);
      }
    }, [options, prevOptions]);

    React.useLayoutEffect(() => {
      return () => {
        if (
          elementRef.current &&
          typeof elementRef.current.destroy === 'function'
        ) {
          try {
            elementRef.current.destroy();
            elementRef.current = null;
          } catch (error) {
            // Do nothing
          }
        }
      };
    }, []);

    return <div id={id} className={className} ref={domNode} />;
  };

  // Only render the Element wrapper in a server environment.
  const ServerElement: FunctionComponent<PrivateElementProps> = (props) => {
    useElementsOrCheckoutSdkContextWithUseCase(`mounts <${displayName}>`);
    const {id, className} = props;
    return <div id={id} className={className} />;
  };

  const Element = isServer ? ServerElement : ClientElement;

  Element.propTypes = {
    id: PropTypes.string,
    className: PropTypes.string,
    onChange: PropTypes.func,
    onBlur: PropTypes.func,
    onFocus: PropTypes.func,
    onReady: PropTypes.func,
    onEscape: PropTypes.func,
    onClick: PropTypes.func,
    onLoadError: PropTypes.func,
    onLoaderStart: PropTypes.func,
    onNetworksChange: PropTypes.func,
    onConfirm: PropTypes.func,
    onCancel: PropTypes.func,
    onShippingAddressChange: PropTypes.func,
    onShippingRateChange: PropTypes.func,
    onSavedPaymentMethodRemove: PropTypes.func,
    onSavedPaymentMethodUpdate: PropTypes.func,
    options: PropTypes.object as any,
  };

  Element.displayName = displayName;
  (Element as any).__elementType = type;

  return Element as FunctionComponent<ElementProps>;
};

export default createElementComponent;
