// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.firebase.segmentation;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.google.android.gms.common.internal.Preconditions;
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.FirebaseApp;
import com.google.firebase.installations.FirebaseInstallationsApi;
import com.google.firebase.installations.InstallationTokenResult;
import com.google.firebase.segmentation.SetCustomInstallationIdException.Status;
import com.google.firebase.segmentation.local.CustomInstallationIdCache;
import com.google.firebase.segmentation.local.CustomInstallationIdCacheEntryValue;
import com.google.firebase.segmentation.remote.SegmentationServiceClient;
import com.google.firebase.segmentation.remote.SegmentationServiceClient.Code;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

/** Entry point of Firebase Segmentation SDK. */
public class FirebaseSegmentation {

  public static final String TAG = "FirebaseSegmentation";

  private final FirebaseApp firebaseApp;
  private final FirebaseInstallationsApi firebaseInstallationsApi;
  private final CustomInstallationIdCache localCache;
  private final SegmentationServiceClient backendServiceClient;
  private final Executor executor;

  FirebaseSegmentation(FirebaseApp firebaseApp, FirebaseInstallationsApi firebaseInstallationsApi) {
    this(
        firebaseApp,
        firebaseInstallationsApi,
        new CustomInstallationIdCache(firebaseApp),
        new SegmentationServiceClient(firebaseApp.getApplicationContext()));
  }

  FirebaseSegmentation(
      FirebaseApp firebaseApp,
      FirebaseInstallationsApi firebaseInstallationsApi,
      CustomInstallationIdCache localCache,
      SegmentationServiceClient backendServiceClient) {
    this.firebaseApp = firebaseApp;
    this.firebaseInstallationsApi = firebaseInstallationsApi;
    this.localCache = localCache;
    this.backendServiceClient = backendServiceClient;
    this.executor = Executors.newFixedThreadPool(4);
  }

  /**
   * Returns the {@link FirebaseSegmentation} initialized with the default {@link FirebaseApp}.
   *
   * @return a {@link FirebaseSegmentation} instance
   */
  @NonNull
  public static FirebaseSegmentation getInstance() {
    FirebaseApp defaultFirebaseApp = FirebaseApp.getInstance();
    return getInstance(defaultFirebaseApp);
  }

  /**
   * Returns the {@link FirebaseSegmentation} initialized with a custom {@link FirebaseApp}.
   *
   * @param app a custom {@link FirebaseApp}
   * @return a {@link FirebaseSegmentation} instance
   */
  @NonNull
  public static FirebaseSegmentation getInstance(@NonNull FirebaseApp app) {
    Preconditions.checkArgument(app != null, "Null is not a valid value " + "of FirebaseApp.");
    return app.get(FirebaseSegmentation.class);
  }

  @NonNull
  public synchronized Task<Void> setCustomInstallationId(@Nullable String customInstallationId) {
    if (customInstallationId == null) {
      return Tasks.call(executor, () -> clearCustomInstallationId());
    }
    return Tasks.call(executor, () -> updateCustomInstallationId(customInstallationId));
  }

  /**
   * Update custom installation id of the {@link FirebaseApp} on Firebase segmentation backend and
   * client side cache.
   *
   * <pre>
   *     The workflow is:
   *         check diff against cache or cache status is not SYNCED
   *                                 |
   *                  get Firebase instance id and token
   *                      |                       |
   *                      |      update cache with cache status PENDING_UPDATE
   *                      |                       |
   *                    send http request to backend
   *                                 |
   *              on success: set cache entry status to SYNCED
   *                                 |
   *                               return
   * </pre>
   */
  @WorkerThread
  private Void updateCustomInstallationId(String customInstallationId)
      throws SetCustomInstallationIdException {
    CustomInstallationIdCacheEntryValue cacheEntryValue = localCache.readCacheEntryValue();
    if (cacheEntryValue != null
        && cacheEntryValue.getCustomInstallationId().equals(customInstallationId)
        && cacheEntryValue.getCacheStatus() == CustomInstallationIdCache.CacheStatus.SYNCED) {
      // If the given custom installation id matches up the cached
      // value, there's no need to update.
      return null;
    }

    String fid;
    InstallationTokenResult installationTokenResult;
    try {
      fid = Tasks.await(firebaseInstallationsApi.getId());
      // No need to force refresh token.
      installationTokenResult = Tasks.await(firebaseInstallationsApi.getToken(false));
    } catch (ExecutionException | InterruptedException e) {
      throw new SetCustomInstallationIdException(
          Status.CLIENT_ERROR, "Failed to get Firebase installation ID and token");
    }

    boolean firstUpdateCacheResult =
        localCache.insertOrUpdateCacheEntry(
            CustomInstallationIdCacheEntryValue.create(
                customInstallationId, fid, CustomInstallationIdCache.CacheStatus.PENDING_UPDATE));

    if (!firstUpdateCacheResult) {
      throw new SetCustomInstallationIdException(
          Status.CLIENT_ERROR, "Failed to update client side cache");
    }

    // Start requesting backend when first cache updae is done.
    Code backendRequestResult =
        backendServiceClient.updateCustomInstallationId(
            Utils.getProjectNumberFromAppId(firebaseApp.getOptions().getApplicationId()),
            firebaseApp.getOptions().getApiKey(),
            customInstallationId,
            fid,
            installationTokenResult.getToken());

    boolean finalUpdateCacheResult;
    switch (backendRequestResult) {
      case OK:
        finalUpdateCacheResult =
            localCache.insertOrUpdateCacheEntry(
                CustomInstallationIdCacheEntryValue.create(
                    customInstallationId, fid, CustomInstallationIdCache.CacheStatus.SYNCED));
        break;
      case UNAUTHORIZED:
        localCache.clear();
        throw new SetCustomInstallationIdException(
            Status.CLIENT_ERROR, "Instance id token is invalid.");
      case CONFLICT:
        localCache.clear();
        throw new SetCustomInstallationIdException(
            Status.DUPLICATED_CUSTOM_INSTALLATION_ID,
            "The custom installation id is used by another Firebase installation in your project.");
      case HTTP_CLIENT_ERROR:
        localCache.clear();
        throw new SetCustomInstallationIdException(Status.CLIENT_ERROR, "Http client error(4xx)");
      case NETWORK_ERROR:
      case SERVER_ERROR:
      default:
        // These are considered retryable errors, so not to clean up the cache.
        throw new SetCustomInstallationIdException(Status.BACKEND_ERROR);
    }

    if (finalUpdateCacheResult) {
      return null;
    } else {
      throw new SetCustomInstallationIdException(
          Status.CLIENT_ERROR, "Failed to update client side cache");
    }
  }

  /**
   * Clear custom installation id of the {@link FirebaseApp} on Firebase segmentation backend and
   * client side cache.
   *
   * <pre>
   *     The workflow is:
   *                  get Firebase instance id and token
   *                      |                      |
   *                      |    update cache with cache status PENDING_CLEAR
   *                      |                      |
   *                    send http request to backend
   *                                  |
   *                   on success: delete cache entry
   *                                  |
   *                               return
   * </pre>
   */
  @WorkerThread
  private Void clearCustomInstallationId() throws SetCustomInstallationIdException {
    String fid;
    InstallationTokenResult installationTokenResult;
    try {
      fid = Tasks.await(firebaseInstallationsApi.getId());
      // No need to force refresh token.
      installationTokenResult = Tasks.await(firebaseInstallationsApi.getToken(false));
    } catch (ExecutionException | InterruptedException e) {
      throw new SetCustomInstallationIdException(
          Status.CLIENT_ERROR, "Failed to get Firebase installation ID and token");
    }

    boolean firstUpdateCacheResult =
        localCache.insertOrUpdateCacheEntry(
            CustomInstallationIdCacheEntryValue.create(
                "", fid, CustomInstallationIdCache.CacheStatus.PENDING_CLEAR));

    if (!firstUpdateCacheResult) {
      throw new SetCustomInstallationIdException(
          Status.CLIENT_ERROR, "Failed to update client side cache");
    }

    Code backendRequestResult =
        backendServiceClient.clearCustomInstallationId(
            Utils.getProjectNumberFromAppId(firebaseApp.getOptions().getApplicationId()),
            firebaseApp.getOptions().getApiKey(),
            fid,
            installationTokenResult.getToken());

    boolean finalUpdateCacheResult;
    switch (backendRequestResult) {
      case OK:
        finalUpdateCacheResult = localCache.clear();
        break;
      case UNAUTHORIZED:
        throw new SetCustomInstallationIdException(
            Status.CLIENT_ERROR, "Instance id token is invalid.");
      case HTTP_CLIENT_ERROR:
        throw new SetCustomInstallationIdException(Status.CLIENT_ERROR, "Http client error(4xx)");
      case NETWORK_ERROR:
      case SERVER_ERROR:
      default:
        // These are considered retryable errors, so not to clean up the cache.
        throw new SetCustomInstallationIdException(Status.BACKEND_ERROR);
    }

    if (finalUpdateCacheResult) {
      return null;
    } else {
      throw new SetCustomInstallationIdException(
          Status.CLIENT_ERROR, "Failed to update client side cache");
    }
  }
}