/*
 * 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 android.accounts.Account;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import com.google.android.libraries.mobiledatadownload.file.common.MalformedUriException;
import com.google.android.libraries.mobiledatadownload.file.common.internal.LiteTransformFragments;
import com.google.android.libraries.mobiledatadownload.file.common.internal.Preconditions;
import com.google.android.libraries.mobiledatadownload.file.transforms.TransformProtos;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.mobiledatadownload.TransformProto;
import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.regex.Pattern;
import javax.annotation.Nullable;

/** Helper class for "android:" URIs. */
public final class AndroidUri {

  /**
   * Returns an android: scheme URI builder for package {@code packageName}. If no setter is called
   * before {@link Builder#build}, the resultant URI will point to the common internal app storage,
   * i.e. "android://<packageName>/files/common/shared/"
   *
   * @param context The android environment.
   */
  public static Builder builder(Context context) {
    return new Builder(context);
  }

  private AndroidUri() {}

  // Module names are non-empty strings of [a-z] with interleaved underscores
  private static final Pattern MODULE_PATTERN = Pattern.compile("[a-z]+(_[a-z]+)*");

  // Name registered for the Android backend
  static final String SCHEME_NAME = "android";

  // URI path fragments with special meaning
  static final String FILES_LOCATION = "files";
  static final String MANAGED_LOCATION = "managed";
  static final String CACHE_LOCATION = "cache";
  // See https://developer.android.com/training/articles/direct-boot.html
  static final String DIRECT_BOOT_FILES_LOCATION = "directboot-files";
  static final String DIRECT_BOOT_CACHE_LOCATION = "directboot-cache";
  static final String EXTERNAL_LOCATION = "external";

  // The "managed" location maps to a subdirectory within /files/.
  static final String MANAGED_FILES_DIR_SUBDIRECTORY = "managed";

  static final String COMMON_MODULE = "common";
  static final Account SHARED_ACCOUNT = AccountSerialization.SHARED_ACCOUNT;

  // Module names reserved for future use or that are otherwise disallowed. Note that ImmutableSet
  // is avoided in order to avoid guava dependency.
  private static final Set<String> RESERVED_MODULES =
      Collections.unmodifiableSet(
          new HashSet<>(
              Arrays.asList(
                  "default", "unused", "special", "reserved", "shared", "virtual", "managed")));

  private static final Set<String> VALID_LOCATIONS =
      Collections.unmodifiableSet(
          new HashSet<>(
              Arrays.asList(
                  FILES_LOCATION,
                  CACHE_LOCATION,
                  MANAGED_LOCATION,
                  DIRECT_BOOT_FILES_LOCATION,
                  DIRECT_BOOT_CACHE_LOCATION,
                  EXTERNAL_LOCATION)));

  /**
   * Validates the {@code location} of an Android URI path; "files" and "directboot" are the only
   * valid strings.
   */
  static void validateLocation(String location) {
    Preconditions.checkArgument(
        VALID_LOCATIONS.contains(location),
        "The only supported locations are %s: %s",
        VALID_LOCATIONS,
        location);
  }
  /**
   * Validates the {@code module} of an Android URI path. Any non-empty string of [a-z] with
   * interleaved underscores that is not listed as reserved is valid.
   */
  static void validateModule(String module) {
    Preconditions.checkArgument(
        MODULE_PATTERN.matcher(module).matches(), "Module must match [a-z]+(_[a-z]+)*: %s", module);
    Preconditions.checkArgument(
        !RESERVED_MODULES.contains(module),
        "Module name is reserved and cannot be used: %s",
        module);
  }

  /**
   * Validates the {@code unusedRelativePath} of an Android URI path. At present time this is a
   * no-op.
   *
   * @param unusedRelativePath Not used.
   */
  static void validateRelativePath(String unusedRelativePath) {
    // No-op
  }

  /** Builder for Android Uris. */
  public static class Builder {

    // URI authority; required
    private final Context context;

    // URI path components; optional
    private String packageName; // TODO: should default be ""?
    private String location = AndroidUri.FILES_LOCATION;
    private String module = AndroidUri.COMMON_MODULE;
    private Account account = AndroidUri.SHARED_ACCOUNT;
    private String relativePath = "";

    private final ImmutableList.Builder<String> encodedSpecs = ImmutableList.builder();

    private Builder(Context context) {
      Preconditions.checkArgument(context != null, "Context cannot be null");
      this.context = context;
      this.packageName = context.getPackageName();
    }

    /**
     * Sets the package to use in the android uri AUTHORITY. Default is context.getPackageName().
     */
    @CanIgnoreReturnValue
    public Builder setPackage(String packageName) {
      this.packageName = packageName;
      return this;
    }

    @CanIgnoreReturnValue
    private Builder setLocation(String location) {
      AndroidUri.validateLocation(location);
      this.location = location;
      return this;
    }

    @CanIgnoreReturnValue
    public Builder setManagedLocation() {
      return setLocation(MANAGED_LOCATION);
    }

    @CanIgnoreReturnValue
    public Builder setExternalLocation() {
      return setLocation(EXTERNAL_LOCATION);
    }

    @CanIgnoreReturnValue
    public Builder setDirectBootFilesLocation() {
      return setLocation(DIRECT_BOOT_FILES_LOCATION);
    }

    @CanIgnoreReturnValue
    public Builder setDirectBootCacheLocation() {
      return setLocation(DIRECT_BOOT_CACHE_LOCATION);
    }

    /** Internal location, aka "files", is the default location. */
    @CanIgnoreReturnValue
    public Builder setInternalLocation() {
      return setLocation(FILES_LOCATION);
    }

    @CanIgnoreReturnValue
    public Builder setCacheLocation() {
      return setLocation(CACHE_LOCATION);
    }

    @CanIgnoreReturnValue
    public Builder setModule(String module) {
      AndroidUri.validateModule(module);
      this.module = module;
      return this;
    }

    /**
     * Sets the account. AndroidUri.SHARED_ACCOUNT is the default, and it shows up as "shared" on
     * the filesystem.
     *
     * <p>This method performs some account validation. Android Account itself requires that both
     * the type and name fields be present. In addition to this requirement, this backend requires
     * that the type contain no colons (as these are the delimiter used internally for the account
     * serialization), and that neither the type nor the name include any slashes (as these are file
     * separators).
     *
     * <p>The account will be URL encoded in its URI representation (so, eg, "<internal>@gmail.com"
     * will appear as "you%40gmail.com"), but not in the file path representation used to access
     * disk.
     *
     * <p>Note the Linux filesystem accepts filenames composed of any bytes except "/" and NULL.
     *
     * @param account The account to set.
     * @return The fluent Builder.
     */
    @CanIgnoreReturnValue
    public Builder setAccount(Account account) {
      AccountSerialization.serialize(account); // performs validation internally
      this.account = account;
      return this;
    }

    /**
     * Sets the component of the path after location, module and account. A single leading slash
     * will be trimmed if present.
     */
    @CanIgnoreReturnValue
    public Builder setRelativePath(String relativePath) {
      if (relativePath.startsWith("/")) {
        relativePath = relativePath.substring(1);
      }
      AndroidUri.validateRelativePath(relativePath);
      this.relativePath = relativePath;
      return this;
    }

    /**
     * Updates builder with multiple fields from file param: location, module, account and relative
     * path. This method will fail on "managed" paths (see {@link fromFile(File, AccountManager)}).
     */
    @CanIgnoreReturnValue
    public Builder fromFile(File file) {
      return fromAbsolutePath(file.getAbsolutePath(), /* accountManager= */ null);
    }

    /**
     * Updates builder with multiple fields from file param: location, module, account and relative
     * path. A non-null {@code accountManager} is required to handle "managed" paths.
     */
    @CanIgnoreReturnValue
    public Builder fromFile(File file, @Nullable AccountManager accountManager) {
      return fromAbsolutePath(file.getAbsolutePath(), accountManager);
    }

    /**
     * Updates builder with multiple fields from absolute path param: location, module, account and
     * relative path. This method will fail on "managed" paths (see {@link fromAbsolutePath(String,
     * AccountManager)}).
     */
    @CanIgnoreReturnValue
    public Builder fromAbsolutePath(String absolutePath) {
      return fromAbsolutePath(absolutePath, /* accountManager= */ null);
    }

    /**
     * Updates builder with multiple fields from absolute path param: location, module, account and
     * relative path. A non-null {@code accountManager} is required to handle "managed" paths.
     */
    // TODO(b/129467051): remove requirement for segments after 0th (logical location)
    @CanIgnoreReturnValue
    public Builder fromAbsolutePath(String absolutePath, @Nullable AccountManager accountManager) {
      // Get the file's path within internal files, /module/account</relativePath>
      File filesDir = AndroidFileEnvironment.getFilesDirWithPreNWorkaround(context);
      String filesDirPath = filesDir.getAbsolutePath();
      String cacheDirPath = context.getCacheDir().getAbsolutePath();
      String managedDirPath = new File(filesDir, MANAGED_FILES_DIR_SUBDIRECTORY).getAbsolutePath();
      String externalDirPath = null;
      File externalFilesDir = context.getExternalFilesDir(null);
      if (externalFilesDir != null) {
        externalDirPath = externalFilesDir.getAbsolutePath();
      }
      String directBootFilesPath = null;
      String directBootCachePath = null;
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        // TODO(b/143610872): run after checking other dirs to minimize impact of new Context()'s
        File dpsDataDir = AndroidFileEnvironment.getDeviceProtectedDataDir(context);
        directBootFilesPath = new File(dpsDataDir, "files").getAbsolutePath();
        directBootCachePath = new File(dpsDataDir, "cache").getAbsolutePath();
      }

      String internalPath;
      if (absolutePath.startsWith(managedDirPath)) {
        // managedDirPath must be checked before filesDirPath because filesDirPath is a prefix.
        setLocation(AndroidUri.MANAGED_LOCATION);
        internalPath = absolutePath.substring(managedDirPath.length());
      } else if (absolutePath.startsWith(filesDirPath)) {
        setLocation(AndroidUri.FILES_LOCATION);
        internalPath = absolutePath.substring(filesDirPath.length());
      } else if (absolutePath.startsWith(cacheDirPath)) {
        setLocation(AndroidUri.CACHE_LOCATION);
        internalPath = absolutePath.substring(cacheDirPath.length());
      } else if (externalDirPath != null && absolutePath.startsWith(externalDirPath)) {
        setLocation(AndroidUri.EXTERNAL_LOCATION);
        internalPath = absolutePath.substring(externalDirPath.length());
      } else if (directBootFilesPath != null && absolutePath.startsWith(directBootFilesPath)) {
        setLocation(AndroidUri.DIRECT_BOOT_FILES_LOCATION);
        internalPath = absolutePath.substring(directBootFilesPath.length());
      } else if (directBootCachePath != null && absolutePath.startsWith(directBootCachePath)) {
        setLocation(AndroidUri.DIRECT_BOOT_CACHE_LOCATION);
        internalPath = absolutePath.substring(directBootCachePath.length());
      } else {
        throw new IllegalArgumentException(
            "Path must be in app-private files dir or external files dir: " + absolutePath);
      }

      // Extract components according to android: file layout. The 0th element of split() will be
      // an empty string preceding the first character "/"
      List<String> pathFragments = Arrays.asList(internalPath.split(File.separator));
      Preconditions.checkArgument(
          pathFragments.size() >= 3,
          "Path must be in module and account subdirectories: %s",
          absolutePath);
      setModule(pathFragments.get(1));

      String accountStr = pathFragments.get(2);
      if (MANAGED_LOCATION.equals(location) && !AccountSerialization.isSharedAccount(accountStr)) {
        int accountId;
        try {
          accountId = Integer.parseInt(accountStr);
        } catch (NumberFormatException e) {
          throw new IllegalArgumentException(e);
        }

        // Blocks on disk IO to read account table.
        // TODO(b/115940396): surface bad account as FileNotFoundException (change API signature?)
        Preconditions.checkArgument(accountManager != null, "AccountManager cannot be null");
        try {
          setAccount(accountManager.getAccount(accountId).get());
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
          throw new IllegalArgumentException(new MalformedUriException(e));
        } catch (ExecutionException e) {
          throw new IllegalArgumentException(new MalformedUriException(e.getCause()));
        }
      } else {
        setAccount(AccountSerialization.deserialize(accountStr));
      }

      setRelativePath(internalPath.substring(module.length() + accountStr.length() + 2));
      return this;
    }

    @CanIgnoreReturnValue
    public Builder withTransform(TransformProto.Transform spec) {
      encodedSpecs.add(TransformProtos.toEncodedSpec(spec));
      return this;
    }

    // TODO(b/115940396): add MalformedUriException to signature
    public Uri build() {
      String uriPath =
          "/"
              + location
              + "/"
              + module
              + "/"
              + AccountSerialization.serialize(account)
              + "/"
              + relativePath;
      String fragment = LiteTransformFragments.joinTransformSpecs(encodedSpecs.build());

      return new Uri.Builder()
          .scheme(AndroidUri.SCHEME_NAME)
          .authority(packageName)
          .path(uriPath)
          .encodedFragment(fragment)
          .build();
    }
  }
}
