/*
 * Copyright 2022 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.android.libraries.mobiledatadownload.file.backends;

import static java.util.concurrent.TimeUnit.SECONDS;

import android.annotation.SuppressLint;
import android.app.blob.BlobHandle;
import android.app.blob.BlobStoreManager;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Pair;
import com.google.android.libraries.mobiledatadownload.file.common.LimitExceededException;
import com.google.android.libraries.mobiledatadownload.file.spi.Backend;
import com.google.common.util.concurrent.MoreExecutors;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import javax.annotation.Nullable;

/**
 * Backend for accessing the Android blob Sharing Service.
 *
 * <p>For more details see {@link <internal>}.
 *
 * <p>Supports reading, writing, deleting and exists; every other operation provided by the {@link
 * Backend} interface will throw {@link UnsupportedFileStorageOperation}.
 *
 * <p>Only available to Android SDK >= R.
 */
@SuppressLint({"NewApi", "WrongConstant"})
@SuppressWarnings("AndroidJdkLibsChecker")
public final class BlobStoreBackend implements Backend {
  private static final String SCHEME = "blobstore";
  // TODO(b/149260496): accept a custom label once available in the file config.
  private static final String LABEL = "The file is shared to provide a better user experience";
  // TODO(b/149260496): accept a custom tag once available in the file config.
  private static final String TAG = "File downloaded through MDDLib";
  // ExpiryDate set to 0 will be treated as expiryDate non-existent.
  private static final long EXPIRY_DATE = 0;

  private final BlobStoreManager blobStoreManager;

  public BlobStoreBackend(Context context) {
    this.blobStoreManager = (BlobStoreManager) context.getSystemService(Context.BLOB_STORE_SERVICE);
  }

  @Override
  public String name() {
    return SCHEME;
  }

  /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */
  @Override
  public boolean exists(Uri uri) throws IOException {
    boolean exists = false;
    try (ParcelFileDescriptor pfd = openForReadInternal(uri)) {
      if (pfd != null && pfd.getFileDescriptor().valid()) {
        exists = true;
      }
    } catch (SecurityException e) {
      // A SecurityException is thrown when the blob does not exist or the caller does not have
      // access to it.
    }
    return exists;
  }

  /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */
  @Override
  public InputStream openForRead(Uri uri) throws IOException {
    return new ParcelFileDescriptor.AutoCloseInputStream(openForReadInternal(uri));
  }

  /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */
  @Override
  public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException {
    return FileDescriptorUri.fromParcelFileDescriptor(openForReadInternal(uri));
  }

  private ParcelFileDescriptor openForReadInternal(Uri uri) throws IOException {
    BlobUri.validateUri(uri);
    byte[] checksum = BlobUri.getChecksum(uri.getPath());
    // TODO(b/149260496): add option to set a custom expiryDate in the uri.
    BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
    return blobStoreManager.openBlob(blobHandle);
  }

  /**
   * Two possible URIs are accepted:
   *
   * <ul>
   *   <li>"blobstore://<package_name>/<non_empty_checksum>". A new blob will be written in the blob
   *       storage.
   *   <li>"blobstore://<package_name>/<non_empty_checksum>.lease?expiryDateSecs=<expiryDateSecs>.".
   *       A lease will be acquired on the blob specified by the encoded checksum.
   * </ul>
   *
   * @throws MalformedUriException when the {@code uri} is malformed.
   * @throws LimitExceededException when the caller is trying to create too many sessions, acquire
   *     too many leases or acquire leases on too much data.
   * @throws IOException when there is an I/O error while writing the blob/lease.
   */
  @Override
  public @Nullable OutputStream openForWrite(Uri uri) throws IOException {
    BlobUri.validateUri(uri);
    byte[] checksum = BlobUri.getChecksum(uri.getPath());
    try {
      if (BlobUri.isLeaseUri(uri.getPath())) {
        // TODO(b/149260496): pass blob size from MDD to the backend so that the backend can check
        // it against the remaining quota.
        if (blobStoreManager.getRemainingLeaseQuotaBytes() <= 0) {
          throw new LimitExceededException(
              "The caller is trying to acquire a lease on too much data.");
        }
        long expiryDateMillis = SECONDS.toMillis(BlobUri.getExpiryDateSecs(uri));
        acquireLease(checksum, expiryDateMillis);
        return null;
      }

      BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
      long sessionId = blobStoreManager.createSession(blobHandle);
      BlobStoreManager.Session session = blobStoreManager.openSession(sessionId);
      session.allowPublicAccess();
      return new SuperFirstAutoCloseOutputStream(session.openWrite(0, -1), session);
    } catch (android.os.LimitExceededException e) {
      throw new LimitExceededException(e);
    } catch (IllegalStateException e) {
      throw new IOException("Failed to write into BlobStoreManager", e);
    }
  }

  /**
   * Releases the lease(s) on the blob(s) specified through the {@code uri}.
   *
   * <p>Two possible URIs are accepted:
   *
   * <ul>
   *   <li>"blobstore://<package_name>/<non_empty_checksum>". The lease on the blob with checksum
   *       <non_empty_checksum> will be released.
   *   <li>"blobstore://<package_name>/*.lease.". All leases owned by calling package in the blob
   *       shared storage will be released.
   * </ul>
   */
  @Override
  public void deleteFile(Uri uri) throws IOException {
    BlobUri.validateUri(uri);
    if (BlobUri.isAllLeasesUri(uri.getPath())) {
      releaseAllLeases();
      return;
    }
    byte[] checksum = BlobUri.getChecksum(uri.getPath());
    releaseLease(checksum);
  }

  private void releaseAllLeases() throws IOException {
    List<BlobHandle> blobHandles = blobStoreManager.getLeasedBlobs();
    for (BlobHandle blobHandle : blobHandles) {
      releaseLease(blobHandle.getSha256Digest());
    }
  }

  @Override
  public boolean isDirectory(Uri uri) {
    return false;
  }

  private void acquireLease(byte[] checksum, long expiryDateMillis) throws IOException {
    BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
    // TODO(b/149260496): remove hardcoded description.
    // NOTE: The lease description is meant for specifying why the app needs the data blob and
    // should be geared towards end users.
    blobStoreManager.acquireLease(
        blobHandle,
        "String description needed for providing a better user experience",
        expiryDateMillis);
  }

  private void releaseLease(byte[] checksum) throws IOException {
    BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG);
    try {
      blobStoreManager.releaseLease(blobHandle);
    } catch (SecurityException | IllegalStateException | IllegalArgumentException e) {
      throw new IOException("Failed to release the lease", e);
    }
  }

  // NOTE: ParcelFileDescriptor.AutoCloseOutput|InputStream are affected by bug b/118316956. This
  // was fixed in Android Q and this class requires Android R, so they are safe to use.
  private static class SuperFirstAutoCloseOutputStream
      extends ParcelFileDescriptor.AutoCloseOutputStream {
    private final BlobStoreManager.Session session;
    private boolean commitAttempted = false;

    public SuperFirstAutoCloseOutputStream(
        ParcelFileDescriptor pfd, BlobStoreManager.Session session) {
      super(pfd);
      this.session = session;
    }

    @Override
    public void close() throws IOException {
      try {
        super.close();
      } finally {
        closeEverything();
      }
    }

    private void closeEverything() throws IOException {
      int result = 0;
      Exception cause = null;
      if (!commitAttempted) {
        // Commit throws IllegalStateException if the session was already finalized, so avoid
        // calling it more than once. We can assume Closeable closes are idempotent.
        commitAttempted = true;
        try {
          CompletableFuture<Integer> callback = new CompletableFuture<>();
          session.commit(MoreExecutors.directExecutor(), callback::complete);
          result = callback.get();
        } catch (ExecutionException | InterruptedException | RuntimeException e) {
          result = -1;
          cause = e;
        }
      }
      try (session) {
        if (result != 0) {
          throw new IOException("Commit operation failed", cause);
        }
      }
    }
  }
}
