import {Stripe, StripeConstructor, ReleaseTrain} from '../types';

export type LoadStripe = (
  ...args: Parameters<StripeConstructor>
) => Promise<Stripe | null>;

export interface LoadParams {
  advancedFraudSignals: boolean;
}

// `_VERSION` will be rewritten by `@rollup/plugin-replace` as a string literal
// containing the package.json version
declare const _VERSION: string;

export const RELEASE_TRAIN: ReleaseTrain = 'endive';

const runtimeVersionToUrlVersion = (version: string | number) =>
  version === 3 ? 'v3' : version;

const ORIGIN = 'https://js.stripe.com';
const STRIPE_JS_URL = `${ORIGIN}/${RELEASE_TRAIN}/stripe.js`;

const V3_URL_REGEX = /^https:\/\/js\.stripe\.com\/v3\/?(\?.*)?$/;
const STRIPE_JS_URL_REGEX = /^https:\/\/js\.stripe\.com\/(v3|[a-z]+)\/stripe\.js(\?.*)?$/;
const EXISTING_SCRIPT_MESSAGE =
  'loadStripe.setLoadParameters was called but an existing Stripe.js script already exists in the document; existing script parameters will be used';

const isStripeJSURL = (url: string): boolean =>
  V3_URL_REGEX.test(url) || STRIPE_JS_URL_REGEX.test(url);

export const findScript = (): HTMLScriptElement | null => {
  const scripts = document.querySelectorAll<HTMLScriptElement>(
    `script[src^="${ORIGIN}"]`
  );

  for (let i = 0; i < scripts.length; i++) {
    const script = scripts[i];

    if (!isStripeJSURL(script.src)) {
      continue;
    }

    return script;
  }

  return null;
};

const injectScript = (params: null | LoadParams): HTMLScriptElement => {
  const queryString =
    params && !params.advancedFraudSignals ? '?advancedFraudSignals=false' : '';
  const script = document.createElement('script');
  script.src = `${STRIPE_JS_URL}${queryString}`;

  const headOrBody = document.head || document.body;

  if (!headOrBody) {
    throw new Error(
      'Expected document.body not to be null. Stripe.js requires a <body> element.'
    );
  }

  headOrBody.appendChild(script);

  return script;
};

const registerWrapper = (stripe: any, startTime: number): void => {
  if (!stripe || !stripe._registerWrapper) {
    return;
  }

  stripe._registerWrapper({name: 'stripe-js', version: _VERSION, startTime});
};

let stripePromise: Promise<StripeConstructor | null> | null = null;

let onErrorListener: ((cause?: unknown) => void) | null = null;
let onLoadListener: (() => void) | null = null;

const onError = (reject: (reason?: any) => void) => (cause?: unknown) => {
  reject(new Error('Failed to load Stripe.js', {cause}));
};

const onLoad = (
  resolve: (
    value: StripeConstructor | PromiseLike<StripeConstructor | null> | null
  ) => void,
  reject: (reason?: any) => void
) => () => {
  if (window.Stripe) {
    resolve(window.Stripe);
  } else {
    reject(new Error('Stripe.js not available'));
  }
};

export const loadScript = (
  params: null | LoadParams
): Promise<StripeConstructor | null> => {
  // Ensure that we only attempt to load Stripe.js at most once
  if (stripePromise !== null) {
    return stripePromise;
  }

  const load = () =>
    new Promise<StripeConstructor | null>((resolve, reject) => {
      if (typeof window === 'undefined' || typeof document === 'undefined') {
        // Resolve to null when imported server side. This makes the module
        // safe to import in an isomorphic code base.
        resolve(null);
        return;
      }

      if (window.Stripe && params) {
        console.warn(EXISTING_SCRIPT_MESSAGE);
      }

      if (window.Stripe) {
        resolve(window.Stripe);
        return;
      }

      try {
        let script = findScript();

        if (script && params) {
          console.warn(EXISTING_SCRIPT_MESSAGE);
        } else if (!script) {
          script = injectScript(params);
        } else if (
          script &&
          onLoadListener !== null &&
          onErrorListener !== null
        ) {
          // remove event listeners
          script.removeEventListener('load', onLoadListener);
          script.removeEventListener('error', onErrorListener);

          // if script exists, but we are reloading due to an error,
          // reload script to trigger 'load' event
          script.parentNode?.removeChild(script);
          script = injectScript(params);
        }

        onLoadListener = onLoad(resolve, reject);
        onErrorListener = onError(reject);
        script.addEventListener('load', onLoadListener);

        script.addEventListener('error', onErrorListener);
      } catch (error) {
        reject(error);
        return;
      }
    });

  const retryableLoad = () =>
    load().catch((error) => {
      stripePromise = null;
      return Promise.reject(error);
    });

  // Resets stripePromise on error
  return retryableLoad().catch(() => retryableLoad());
};

export const initStripe = (
  maybeStripe: StripeConstructor | null,
  args: Parameters<StripeConstructor>,
  startTime: number
): Stripe | null => {
  if (maybeStripe === null) {
    return null;
  }

  const pk = args[0];

  if (typeof pk !== 'string') {
    throw new Error(
      `Expected publishable key to be of type string, got type ${typeof pk} instead.`
    );
  }

  const isTestKey = pk.match(/^pk_test/);

  // @ts-expect-error this is not publicly typed
  const version = runtimeVersionToUrlVersion(maybeStripe.version);
  const expectedVersion = RELEASE_TRAIN;
  if (isTestKey && version !== expectedVersion) {
    console.warn(
      `Stripe.js@${version} was loaded on the page, but @stripe/stripe-js@${_VERSION} expected Stripe.js@${expectedVersion}. This may result in unexpected behavior. For more information, see https://docs.stripe.com/sdks/stripejs-versioning`
    );
  }

  const stripe = maybeStripe.apply(undefined, args);
  registerWrapper(stripe, startTime);
  return stripe;
};

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const validateLoadParams = (params: any): LoadParams => {
  const errorMessage = `invalid load parameters; expected object of shape

    {advancedFraudSignals: boolean}

but received

    ${JSON.stringify(params)}
`;

  if (params === null || typeof params !== 'object') {
    throw new Error(errorMessage);
  }

  if (
    Object.keys(params).length === 1 &&
    typeof params.advancedFraudSignals === 'boolean'
  ) {
    return params;
  }

  throw new Error(errorMessage);
};
