import { computeNextBackoffInterval } from '@ide/backoff';
import * as Application from 'expo-application';
import { CodedError, Platform, UnavailabilityError } from 'expo-modules-core';

import ServerRegistrationModule from '../ServerRegistrationModule';
import { DevicePushToken } from '../Tokens.types';

const updateDevicePushTokenUrl = 'https://exp.host/--/api/v2/push/updateDeviceToken';

export async function updateDevicePushTokenAsync(signal: AbortSignal, token: DevicePushToken) {
  const doUpdateDevicePushTokenAsync = async (retry: () => void) => {
    const [development, deviceId] = await Promise.all([
      shouldUseDevelopmentNotificationService(),
      getDeviceIdAsync(),
    ]);
    const body = {
      deviceId: deviceId.toLowerCase(),
      development,
      deviceToken: token.data,
      appId: Application.applicationId,
      type: getTypeOfToken(token),
    };

    try {
      const response = await fetch(updateDevicePushTokenUrl, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
        },
        body: JSON.stringify(body),
        signal,
      });

      // Help debug erroring servers
      if (!response.ok) {
        console.debug(
          '[expo-notifications] Error encountered while updating the device push token with the server:',
          await response.text()
        );
      }

      // Retry if request failed
      if (!response.ok) {
        retry();
      }
    } catch (e) {
      // Error returned if the request is aborted should be an 'AbortError'. In
      // React Native fetch is polyfilled using `whatwg-fetch` which:
      // - creates `AbortError`s like this
      //   https://github.com/github/fetch/blob/75d9455d380f365701151f3ac85c5bda4bbbde76/fetch.js#L505
      // - which creates exceptions like
      //   https://github.com/github/fetch/blob/75d9455d380f365701151f3ac85c5bda4bbbde76/fetch.js#L490-L494
      if (e.name === 'AbortError') {
        // We don't consider AbortError a failure, it's a sign somewhere else the
        // request is expected to succeed and we don't need this one, so let's
        // just return.
        return;
      }

      console.warn(
        '[expo-notifications] Error thrown while updating the device push token with the server:',
        e
      );

      retry();
    }
  };

  let shouldTry = true;
  const retry = () => {
    shouldTry = true;
  };

  let retriesCount = 0;
  const initialBackoff = 500; // 0.5 s
  const backoffOptions = {
    maxBackoff: 2 * 60 * 1000, // 2 minutes
  };
  let nextBackoffInterval = computeNextBackoffInterval(
    initialBackoff,
    retriesCount,
    backoffOptions
  );

  while (shouldTry && !signal.aborted) {
    // Will be set to true by `retry` if it's called
    shouldTry = false;
    await doUpdateDevicePushTokenAsync(retry);

    // Do not wait if we won't retry
    if (shouldTry && !signal.aborted) {
      nextBackoffInterval = computeNextBackoffInterval(
        initialBackoff,
        retriesCount,
        backoffOptions
      );
      retriesCount += 1;
      await new Promise((resolve) => setTimeout(resolve, nextBackoffInterval));
    }
  }
}

// Same as in getExpoPushTokenAsync
async function getDeviceIdAsync() {
  try {
    if (!ServerRegistrationModule.getInstallationIdAsync) {
      throw new UnavailabilityError('ExpoServerRegistrationModule', 'getInstallationIdAsync');
    }

    return await ServerRegistrationModule.getInstallationIdAsync();
  } catch (e) {
    throw new CodedError(
      'ERR_NOTIFICATIONS_DEVICE_ID',
      `Could not fetch the installation ID of the application: ${e}.`
    );
  }
}

// Same as in getExpoPushTokenAsync
function getTypeOfToken(devicePushToken: DevicePushToken) {
  switch (devicePushToken.type) {
    case 'ios':
      return 'apns';
    case 'android':
      return 'fcm';
    // This probably will error on server, but let's make this function future-safe.
    default:
      return devicePushToken.type;
  }
}

// Same as in getExpoPushTokenAsync
async function shouldUseDevelopmentNotificationService() {
  if (Platform.OS === 'ios') {
    try {
      const notificationServiceEnvironment =
        await Application.getIosPushNotificationServiceEnvironmentAsync();
      if (notificationServiceEnvironment === 'development') {
        return true;
      }
    } catch {
      // We can't do anything here, we'll fallback to false then.
    }
  }

  return false;
}
