/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.gravitino.filesystem.hadoop;

import static org.apache.gravitino.catalog.hadoop.fs.HDFSFileSystemProvider.SCHEME_HDFS;
import static org.apache.gravitino.file.Fileset.PROPERTY_DEFAULT_LOCATION_NAME;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_CURRENT_LOCATION_NAME;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_FILESET_METADATA_CACHE_ENABLE;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_FILESET_METADATA_CACHE_ENABLE_DEFAULT;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_PATH_CONFIG_PREFIX;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemUtils.extractIdentifier;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemUtils.extractNonDefaultConfig;
import static org.apache.gravitino.filesystem.hadoop.GravitinoVirtualFileSystemUtils.getSubPathFromGvfsPath;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Scheduler;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.collect.Streams;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.io.Closeable;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Schema;
import org.apache.gravitino.audit.CallerContext;
import org.apache.gravitino.audit.FilesetAuditConstants;
import org.apache.gravitino.audit.FilesetDataOperation;
import org.apache.gravitino.audit.InternalClientType;
import org.apache.gravitino.catalog.hadoop.fs.FileSystemProvider;
import org.apache.gravitino.catalog.hadoop.fs.GravitinoFileSystemCredentialsProvider;
import org.apache.gravitino.catalog.hadoop.fs.HDFSFileSystemProxy;
import org.apache.gravitino.catalog.hadoop.fs.SupportsCredentialVending;
import org.apache.gravitino.client.GravitinoClient;
import org.apache.gravitino.credential.Credential;
import org.apache.gravitino.exceptions.CatalogNotInUseException;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.exceptions.NoSuchFilesetException;
import org.apache.gravitino.exceptions.NoSuchLocationNameException;
import org.apache.gravitino.file.Fileset;
import org.apache.gravitino.file.FilesetCatalog;
import org.apache.gravitino.storage.AzureProperties;
import org.apache.gravitino.storage.OSSProperties;
import org.apache.gravitino.storage.S3Properties;
import org.apache.gravitino.utils.FilesetUtil;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FSDataInputStream;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.permission.FsPermission;
import org.apache.hadoop.security.Credentials;
import org.apache.hadoop.security.UserGroupInformation;
import org.apache.hadoop.security.token.Token;
import org.apache.hadoop.util.Progressable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Abstract base class for Gravitino Virtual File System operations.
 *
 * <p>This class provides the core functionality for interacting with the Gravitino Virtual File
 * System, including operations for file manipulation, directory management, and credential
 * handling. It handles the mapping between virtual paths in the Gravitino namespace and actual file
 * paths in underlying storage systems.
 *
 * <p>Implementations of this class should provide the specific behaviors for each file system
 * operation.
 */
public abstract class BaseGVFSOperations implements Closeable {
  private static final Logger LOG = LoggerFactory.getLogger(BaseGVFSOperations.class);
  private static final String SLASH = "/";
  private static final Set<String> CATALOG_NECESSARY_PROPERTIES_TO_KEEP =
      Sets.newHashSet(
          OSSProperties.GRAVITINO_OSS_ENDPOINT,
          OSSProperties.GRAVITINO_OSS_REGION,
          S3Properties.GRAVITINO_S3_ENDPOINT,
          S3Properties.GRAVITINO_S3_REGION,
          AzureProperties.GRAVITINO_AZURE_STORAGE_ACCOUNT_NAME);

  private final String metalakeName;

  private final boolean enableFilesetMetadataCache;

  // Lazy initialization of FilesetCatalogCache, see getFilesetMetadataCache() for details.
  private volatile Optional<FilesetMetadataCache> filesetMetadataCache;
  private final Object filesetMetadataCacheLock = new Object();

  // Lazy initialization of GravitinoClient, see getGravitinoClient() for details.
  private volatile GravitinoClient gravitinoClient;

  private final Configuration conf;

  private final Cache<FileSystemCacheKey, FileSystem> fileSystemCache;

  /**
   * Tracks all {@link FileSystem} instances ever created by this operations instance, including
   * those already evicted from {@link #fileSystemCache}, so that {@link #close()} can close them
   * all at GVFS shutdown. See <a
   * href="https://github.com/apache/gravitino/issues/11303">#11303</a>.
   */
  private final Set<FileSystem> allCreatedFileSystems =
      Collections.newSetFromMap(new ConcurrentHashMap<>());

  private final Map<String, FileSystemProvider> fileSystemProvidersMap;

  private final String currentLocationEnvVar;

  @Nullable private final String currentLocationName;

  private final long defaultBlockSize;

  private final boolean enableCredentialVending;

  private final boolean autoCreateLocation;
  /** A key class for caching FileSystem instances based on scheme, authority, and configuration. */
  public static class FileSystemCacheKey {
    private final String scheme;
    private final String authority;
    private final UserGroupInformation ugi;

    /**
     * Constructor for FileSystemCacheKey.
     *
     * @param scheme the scheme of the filesystem
     * @param authority the authority of the filesystem
     * @param ugi the user group information
     */
    FileSystemCacheKey(String scheme, String authority, UserGroupInformation ugi) {
      this.scheme = scheme;
      this.authority = authority;
      this.ugi = ugi;
    }

    /**
     * Get the scheme of the filesystem.
     *
     * @return the scheme
     */
    public String scheme() {
      return scheme;
    }

    /**
     * Get the authority of the filesystem.
     *
     * @return the authority
     */
    public String authority() {
      return authority;
    }

    /**
     * Get the UserGroupInformation
     *
     * @return the UserGroupInformation
     */
    public UserGroupInformation ugi() {
      return ugi;
    }

    @Override
    public boolean equals(Object o) {
      if (!(o instanceof FileSystemCacheKey)) return false;
      FileSystemCacheKey that = (FileSystemCacheKey) o;
      return Objects.equals(scheme, that.scheme)
          && Objects.equals(authority, that.authority)
          && Objects.equals(ugi, that.ugi);
    }

    @Override
    public int hashCode() {
      return Objects.hash(scheme, authority, ugi);
    }
  }

  /**
   * Constructs a new {@link BaseGVFSOperations} with the given {@link Configuration}.
   *
   * @param configuration the configuration
   */
  protected BaseGVFSOperations(Configuration configuration) {
    this.metalakeName =
        configuration.get(GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_CLIENT_METALAKE_KEY);
    Preconditions.checkArgument(
        StringUtils.isNotBlank(metalakeName),
        "'%s' is not set in the configuration",
        GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_CLIENT_METALAKE_KEY);

    this.enableFilesetMetadataCache =
        configuration.getBoolean(
            FS_GRAVITINO_FILESET_METADATA_CACHE_ENABLE,
            FS_GRAVITINO_FILESET_METADATA_CACHE_ENABLE_DEFAULT);

    this.fileSystemCache = newFileSystemCache(configuration);

    this.fileSystemProvidersMap = ImmutableMap.copyOf(getFileSystemProviders());

    this.currentLocationEnvVar =
        configuration.get(
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_CURRENT_LOCATION_NAME_ENV_VAR,
            GravitinoVirtualFileSystemConfiguration
                .FS_GRAVITINO_CURRENT_LOCATION_NAME_ENV_VAR_DEFAULT);
    this.currentLocationName = initCurrentLocationName(configuration);

    this.defaultBlockSize =
        configuration.getLong(
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_BLOCK_SIZE,
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_BLOCK_SIZE_DEFAULT);

    this.enableCredentialVending =
        configuration.getBoolean(
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_ENABLE_CREDENTIAL_VENDING,
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_ENABLE_CREDENTIAL_VENDING_DEFAULT);

    this.autoCreateLocation =
        configuration.getBoolean(
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_AUTO_CREATE_LOCATION,
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_AUTO_CREATE_LOCATION_DEFAULT);

    this.conf = configuration;
  }

  /**
   * Lazy initialization of FilesetMetadataCache, see getFilesetMetadataCache() for details.
   *
   * @return the FilesetMetadataCache.
   */
  @VisibleForTesting
  protected Optional<FilesetMetadataCache> getFilesetMetadataCache() {
    if (filesetMetadataCache == null) {
      synchronized (filesetMetadataCacheLock) {
        if (filesetMetadataCache == null) {
          this.filesetMetadataCache =
              enableFilesetMetadataCache
                  ? Optional.of(new FilesetMetadataCache(getGravitinoClient()))
                  : Optional.empty();
        }
      }
    }
    return filesetMetadataCache;
  }

  @Override
  public void close() throws IOException {
    // Invalidate cache first so the removal listener can close cached FileSystems,
    // then close any remaining ones tracked in allCreatedFileSystems to avoid double-close.
    fileSystemCache.invalidateAll();

    for (FileSystem fileSystem : allCreatedFileSystems) {
      try {
        fileSystem.close();
      } catch (IOException e) {
        LOG.warn("Failed to close FileSystem during GVFS shutdown: {}", fileSystem, e);
      }
    }
    allCreatedFileSystems.clear();

    try {
      if (filesetMetadataCache != null && filesetMetadataCache.isPresent()) {
        filesetMetadataCache.get().close();
      }
    } catch (IOException e) {
      // ignore
    }

    // Close the GravitinoClient if it was initialized
    try {
      if (gravitinoClient != null) {
        gravitinoClient.close();
      }
    } catch (Exception e) {
      // ignore
    }
  }

  /**
   * Open a file for reading. Same as {@link FileSystem#open(Path, int)}.
   *
   * @param gvfsPath the virtual path of the file.
   * @param bufferSize the buffer size.
   * @return the input stream.
   * @throws IOException if an I/O error occurs.
   */
  public abstract FSDataInputStream open(Path gvfsPath, int bufferSize) throws IOException;

  /**
   * Set the working directory. Same as {@link FileSystem#setWorkingDirectory(Path)}.
   *
   * @param gvfsDir the new working directory.
   * @throws FileNotFoundException if the fileset path is not found.
   */
  public abstract void setWorkingDirectory(Path gvfsDir) throws FileNotFoundException;

  /**
   * Create a file. Same as {@link FileSystem#create(Path, FsPermission, boolean, int, short, long,
   * Progressable)}.
   *
   * @param gvfsPath the virtual path of the file.
   * @param permission the permission.
   * @param overwrite whether to overwrite the file if it exists.
   * @param bufferSize the buffer size.
   * @param replication the replication factor.
   * @param blockSize the block size.
   * @param progress the progressable.
   * @return the output stream.
   * @throws IOException if an I/O error occurs.
   */
  public abstract FSDataOutputStream create(
      Path gvfsPath,
      FsPermission permission,
      boolean overwrite,
      int bufferSize,
      short replication,
      long blockSize,
      Progressable progress)
      throws IOException;

  /**
   * Append to an existing file. Same as {@link FileSystem#append(Path, int, Progressable)}.
   *
   * @param gvfsPath the virtual path of the file.
   * @param bufferSize the buffer size.
   * @param progress the progressable.
   * @return the output stream.
   * @throws IOException if an I/O error occurs.
   */
  public abstract FSDataOutputStream append(Path gvfsPath, int bufferSize, Progressable progress)
      throws IOException;

  /**
   * Rename a file. Same as {@link FileSystem#rename(Path, Path)}.
   *
   * @param srcGvfsPath the virtual source path.
   * @param dstGvfsPath the virtual destination path.
   * @return true if the rename is successful.
   * @throws IOException if an I/O error occurs.
   */
  public abstract boolean rename(Path srcGvfsPath, Path dstGvfsPath) throws IOException;

  /**
   * Delete a file or directory. Same as {@link FileSystem#delete(Path, boolean)}.
   *
   * @param gvfsPath the virtual path of the file or directory.
   * @param recursive whether to delete the directory recursively.
   * @return true if the deletion is successful.
   * @throws IOException if an I/O error occurs.
   */
  public abstract boolean delete(Path gvfsPath, boolean recursive) throws IOException;

  /**
   * Get the file status. Same as {@link FileSystem#getFileStatus(Path)}.
   *
   * @param gvfsPath the virtual path of the file.
   * @return the file status.
   * @throws IOException if an I/O error occurs.
   */
  public abstract FileStatus getFileStatus(Path gvfsPath) throws IOException;

  /**
   * List the statuses of the files/directories in the given path. Same as {@link
   * FileSystem#listStatus(Path)}.
   *
   * @param gvfsPath the virtual path of the directory.
   * @return the array of file statuses.
   * @throws IOException if an I/O error occurs.
   */
  public abstract FileStatus[] listStatus(Path gvfsPath) throws IOException;

  /**
   * Make the given file and all non-existent parents directories. Same as {@link
   * FileSystem#mkdirs(Path, FsPermission)}.
   *
   * @param gvfsPath the virtual path of the directory.
   * @param permission the permission.
   * @return true if the directory creation is successful.
   * @throws IOException if an I/O error occurs.
   */
  public abstract boolean mkdirs(Path gvfsPath, FsPermission permission) throws IOException;

  /**
   * Get the default replication factor for the file. Same as {@link
   * FileSystem#getDefaultReplication(Path)}.
   *
   * @param gvfsPath the virtual path of the file.
   * @return the default replication factor.
   */
  public abstract short getDefaultReplication(Path gvfsPath);

  /**
   * Get the default block size for the file. Same as {@link FileSystem#getDefaultBlockSize(Path)}.
   *
   * @param gvfsPath the virtual path of the file.
   * @return the default block size.
   */
  public abstract long getDefaultBlockSize(Path gvfsPath);

  /**
   * Add delegation tokens to the credentials. Same as {@link FileSystem#addDelegationTokens(String,
   * Credentials)}.
   *
   * @param renewer the renewer.
   * @param credentials the credentials.
   * @return the array of tokens.
   */
  public abstract Token<?>[] addDelegationTokens(String renewer, Credentials credentials);

  /**
   * Add delegation tokens for all file systems in the cache.
   *
   * @param renewer the renewer.
   * @param credentials the credentials.
   * @return the array of tokens.
   */
  protected Token<?>[] addDelegationTokensForAllFS(String renewer, Credentials credentials) {
    List<Token<?>> tokenList = Lists.newArrayList();
    for (FileSystem fileSystem : fileSystemCache.asMap().values()) {
      try {
        tokenList.addAll(Arrays.asList(fileSystem.addDelegationTokens(renewer, credentials)));
      } catch (IOException e) {
        LOG.warn("Failed to add delegation tokens for filesystem: {}", fileSystem.getUri(), e);
      }
    }
    return tokenList.stream().distinct().toArray(Token[]::new);
  }

  /**
   * Get the current location name. The subclass can override this method to provide a custom
   * location name.
   *
   * @return the current location name, or null if you want to use the default location.
   */
  @Nullable
  protected String currentLocationName() {
    return currentLocationName;
  }

  /**
   * Get the metalake name.
   *
   * @return the metalake name.
   */
  protected String metalakeName() {
    return metalakeName;
  }

  /**
   * Get the default block size.
   *
   * @return the default block size.
   */
  protected long defaultBlockSize() {
    return defaultBlockSize;
  }

  /**
   * Whether to enable credential vending.
   *
   * @return true if credential vending is enabled, false otherwise.
   */
  protected boolean enableCredentialVending() {
    return enableCredentialVending;
  }

  /**
   * Get the actual file path by the given virtual path and location name.
   *
   * @param gvfsPath the virtual path.
   * @param locationName the location name.
   * @param operation the fileset data operation.
   * @return the actual file path.
   * @throws FileNotFoundException if the fileset path is not found.
   */
  protected Path getActualFilePath(
      Path gvfsPath, String locationName, FilesetDataOperation operation)
      throws FileNotFoundException {
    NameIdentifier filesetIdent = extractIdentifier(metalakeName, gvfsPath.toString());
    String subPath = getSubPathFromGvfsPath(filesetIdent, gvfsPath.toString());
    NameIdentifier catalogIdent =
        NameIdentifier.of(filesetIdent.namespace().level(0), filesetIdent.namespace().level(1));
    String fileLocation;
    try {
      FilesetCatalog filesetCatalog = getFilesetCatalog(catalogIdent);
      setCallerContextForGetFileLocation(operation);
      fileLocation =
          filesetCatalog.getFileLocation(
              NameIdentifier.of(filesetIdent.namespace().level(2), filesetIdent.name()),
              subPath,
              locationName);
    } catch (NoSuchCatalogException | CatalogNotInUseException e) {
      String message = String.format("Cannot get fileset catalog by identifier: %s", catalogIdent);
      LOG.warn(message, e);
      throw (FileNotFoundException) new FileNotFoundException(message).initCause(e);

    } catch (NoSuchFilesetException e) {
      String message =
          String.format(
              "Cannot get fileset by fileset identifier: %s, sub_path %s", filesetIdent, subPath);
      LOG.warn(message, e);
      throw (FileNotFoundException) new FileNotFoundException(message).initCause(e);

    } catch (NoSuchLocationNameException e) {
      String message =
          String.format(
              "Location name not found by fileset identifier: %s, sub_path %s, location_name %s",
              filesetIdent, subPath, locationName);
      LOG.warn(message, e);
      throw (FileNotFoundException) new FileNotFoundException(message).initCause(e);
    }

    Path actualFilePath = new Path(fileLocation);
    URI uri = actualFilePath.toUri();
    String scheme = uri.getScheme();
    Preconditions.checkArgument(
        StringUtils.isNotBlank(scheme), "Scheme of the actual file location cannot be null.");
    return new Path(fileLocation);
  }

  private void createFilesetLocationIfNeed(
      NameIdentifier filesetIdent, FileSystem fs, Path filesetPath) {
    if (!autoCreateLocation) {
      return;
    }
    NameIdentifier catalogIdent =
        NameIdentifier.of(filesetIdent.namespace().level(0), filesetIdent.namespace().level(1));
    // If the server-side filesystem ops are disabled, the fileset directory may not exist. In such
    // case the operations like create, open, list files under this directory will fail. So we
    // need to check the existence of the fileset directory beforehand.
    boolean fsOpsDisabled =
        ((Catalog) getFilesetCatalog(catalogIdent))
            .properties()
            .getOrDefault("disable-filesystem-ops", "false")
            .equalsIgnoreCase("true");
    if (fsOpsDisabled) {
      try {
        if (!fs.exists(filesetPath)) {
          fs.mkdirs(filesetPath);
          LOG.info(
              "Automatically created a directory for fileset path: {} when "
                  + "disable-filesystem-ops sets to true in the catalog properties.",
              filesetPath);
        }
      } catch (IOException e) {
        LOG.warn("Cannot create directory for fileset path: {}", filesetPath, e);
      }
    }
  }

  /**
   * Get the actual file system by the given virtual path.
   *
   * @param fileStatus the file status.
   * @param actualPrefix the actual prefix.
   * @param filesetPrefix the virtual prefix.
   * @return the file status with the converted path.
   */
  protected FileStatus convertFileStatusPathPrefix(
      FileStatus fileStatus, String actualPrefix, String filesetPrefix) {
    String filePath = fileStatus.getPath().toString();
    Preconditions.checkArgument(
        filePath.startsWith(actualPrefix),
        "Path %s doesn't start with prefix \"%s\".",
        filePath,
        actualPrefix);
    // if the storage location ends with "/",
    // we should truncate this to avoid replace issues.
    Path path =
        new Path(
            filePath.replaceFirst(
                actualPrefix.endsWith(SLASH) && !filesetPrefix.endsWith(SLASH)
                    ? actualPrefix.substring(0, actualPrefix.length() - 1)
                    : actualPrefix,
                filesetPrefix));
    fileStatus.setPath(path);

    return fileStatus;
  }

  /**
   * Get the virtual location by the given identifier.
   *
   * @param identifier the identifier.
   * @param withScheme whether to include the scheme.
   * @return the virtual location.
   */
  protected String getVirtualLocation(NameIdentifier identifier, boolean withScheme) {
    return String.format(
        "%s/%s/%s/%s",
        withScheme ? GravitinoVirtualFileSystemConfiguration.GVFS_FILESET_PREFIX : "",
        identifier.namespace().level(1),
        identifier.namespace().level(2),
        identifier.name());
  }

  /**
   * Get the actual file system corresponding to the given virtual path and location name.
   *
   * @param filesetPath the virtual path.
   * @param locationName the location name. null means the default location.
   * @return the actual file system.
   * @throws FileNotFoundException if the fileset path is not found.
   */
  protected FileSystem getActualFileSystem(Path filesetPath, String locationName)
      throws FileNotFoundException {
    NameIdentifier filesetIdent = extractIdentifier(metalakeName, filesetPath.toString());
    return getActualFileSystemByLocationName(filesetIdent, locationName);
  }

  /**
   * Get the fileset catalog by the catalog identifier from the cache or load it from the server if
   * the cache is disabled.
   *
   * @param catalogIdent the catalog identifier.
   * @return the fileset catalog.
   */
  protected FilesetCatalog getFilesetCatalog(NameIdentifier catalogIdent) {
    return getFilesetMetadataCache()
        .map(cache -> cache.getFilesetCatalog(catalogIdent))
        .orElseGet(() -> getGravitinoClient().loadCatalog(catalogIdent.name()).asFilesetCatalog());
  }

  /**
   * Get the fileset by the fileset identifier from the cache or load it from the server if the
   * cache is disabled.
   *
   * @param filesetIdent the fileset identifier.
   * @return the fileset.
   */
  protected Fileset getFileset(NameIdentifier filesetIdent) {
    return getFilesetMetadataCache()
        .map(cache -> cache.getFileset(filesetIdent))
        .orElseGet(
            () ->
                getFilesetCatalog(
                        NameIdentifier.of(
                            filesetIdent.namespace().level(0), filesetIdent.namespace().level(1)))
                    .loadFileset(
                        NameIdentifier.of(filesetIdent.namespace().level(2), filesetIdent.name())));
  }

  /**
   * Get the schema by the schema identifier from the cache or load it from the server if the cache
   * is disabled.
   *
   * @param schemaIdent the schema identifier.
   * @return the schema.
   */
  protected Schema getSchema(NameIdentifier schemaIdent) {
    return filesetMetadataCache
        .map(cache -> cache.getSchema(schemaIdent))
        .orElseGet(
            () -> {
              NameIdentifier catalogIdent =
                  NameIdentifier.of(
                      schemaIdent.namespace().level(0), schemaIdent.namespace().level(1));
              Catalog c = gravitinoClient.loadCatalog(catalogIdent.name());
              return c.asSchemas().loadSchema(schemaIdent.name());
            });
  }

  /**
   * Get the actual file system corresponding to the given fileset identifier and location name.
   *
   * @param filesetIdent the fileset identifier.
   * @param locationName the location name. null means the default location.
   * @return the actual file system.
   * @throws FileNotFoundException if the target location name is not found in the fileset.
   */
  protected FileSystem getActualFileSystemByLocationName(
      NameIdentifier filesetIdent, String locationName) throws FileNotFoundException {
    NameIdentifier catalogIdent =
        NameIdentifier.of(filesetIdent.namespace().level(0), filesetIdent.namespace().level(1));
    try {
      Fileset fileset = getFileset(filesetIdent);
      String targetLocationName =
          locationName == null
              ? fileset.properties().get(PROPERTY_DEFAULT_LOCATION_NAME)
              : locationName;

      Preconditions.checkArgument(
          fileset.storageLocations().containsKey(targetLocationName),
          "Location name: %s is not found in fileset: %s.",
          targetLocationName,
          filesetIdent);

      Path targetLocation = new Path(fileset.storageLocations().get(targetLocationName));
      Map<String, String> allProperties = getAllProperties(filesetIdent, fileset.properties());
      allProperties.putAll(
          FilesetUtil.getUserDefinedFileSystemConfigs(
              targetLocation.toUri(), allProperties, FS_GRAVITINO_PATH_CONFIG_PREFIX));

      if (enableCredentialVending()) {
        allProperties.putAll(
            getCredentialProperties(
                getFileSystemProviderByScheme(targetLocation.toUri().getScheme()),
                filesetIdent,
                locationName));
      }

      FileSystem actualFileSystem = getActualFileSystemByPath(targetLocation, allProperties);
      createFilesetLocationIfNeed(filesetIdent, actualFileSystem, targetLocation);
      return actualFileSystem;
    } catch (RuntimeException e) {
      Throwable cause = e.getCause();
      if (cause instanceof NoSuchCatalogException || cause instanceof CatalogNotInUseException) {
        String message =
            String.format("Cannot get fileset catalog by identifier: %s", catalogIdent);
        LOG.warn(message, e);
        throw (FileNotFoundException) new FileNotFoundException(message).initCause(e);
      }

      if (cause instanceof NoSuchFilesetException) {
        String message =
            String.format("Cannot get fileset by fileset identifier: %s", filesetIdent);
        LOG.warn(message, e);
        throw (FileNotFoundException) new FileNotFoundException(message).initCause(e);
      }

      if (cause instanceof NoSuchLocationNameException) {
        String message =
            String.format(
                "Location name not found by fileset identifier: %s, location_name %s",
                filesetIdent, locationName);
        LOG.warn(message, e);
        throw (FileNotFoundException) new FileNotFoundException(message).initCause(e);
      }

      throw e;
    }
  }

  @VisibleForTesting
  Cache<FileSystemCacheKey, FileSystem> internalFileSystemCache() {
    return fileSystemCache;
  }

  @VisibleForTesting
  Set<FileSystem> allCreatedFileSystems() {
    return allCreatedFileSystems;
  }

  /**
   * Lazy initialization of GravitinoClient using double-checked locking pattern. This ensures the
   * expensive client creation only happens when actually needed.
   *
   * @return the GravitinoClient
   */
  @VisibleForTesting
  GravitinoClient getGravitinoClient() {
    if (gravitinoClient == null) {
      synchronized (this) {
        if (gravitinoClient == null) {
          this.gravitinoClient = GravitinoVirtualFileSystemUtils.createClient(conf);
        }
      }
    }
    return gravitinoClient;
  }

  private void setCallerContextForGetFileLocation(FilesetDataOperation operation) {
    Map<String, String> contextMap = Maps.newHashMap();
    contextMap.put(
        FilesetAuditConstants.HTTP_HEADER_INTERNAL_CLIENT_TYPE,
        InternalClientType.HADOOP_GVFS.name());
    contextMap.put(FilesetAuditConstants.HTTP_HEADER_FILESET_DATA_OPERATION, operation.name());
    CallerContext callerContext = CallerContext.builder().withContext(contextMap).build();
    CallerContext.CallerContextHolder.set(callerContext);
  }

  /**
   * Get the actual file system by the given actual file path and properties.
   *
   * @param actualFilePath the actual file path.
   * @param allProperties the properties.
   * @return the actual file system.
   */
  protected FileSystem getActualFileSystemByPath(
      Path actualFilePath, Map<String, String> allProperties) {
    URI uri = actualFilePath.toUri();
    String scheme = uri.getScheme();
    Preconditions.checkArgument(
        StringUtils.isNotBlank(scheme), "Scheme of the actual file location cannot be null.");

    FileSystemProvider provider = getFileSystemProviderByScheme(scheme);
    String authority = provider.getFullAuthority(actualFilePath, allProperties);
    UserGroupInformation ugi;
    try {

      ugi = UserGroupInformation.getCurrentUser();
    } catch (IOException e) {
      throw new GravitinoRuntimeException(
          e, "Cannot get current user for path: %s", actualFilePath);
    }
    return fileSystemCache.get(
        new FileSystemCacheKey(scheme, authority, ugi),
        cacheKey -> {
          try {

            // Reset the FileSystem service loader to make sure the FileSystem will reload the
            // service file systems, this is a temporary solution to fix the issue
            // https://github.com/apache/gravitino/issues/5609
            resetFileSystemServiceLoader(scheme);

            FileSystem created;
            if (scheme.equals(SCHEME_HDFS)) {
              created = new HDFSFileSystemProxy(actualFilePath, allProperties).getProxy();
            } else {
              created = provider.getFileSystem(actualFilePath, allProperties);
            }
            // Track every FS we create so we can guarantee close() at GVFS shutdown,
            // even if the entry is later evicted from the cache (see #11303).
            allCreatedFileSystems.add(created);
            return created;
          } catch (IOException e) {
            throw new GravitinoRuntimeException(
                e, "Cannot get FileSystem for path: %s", actualFilePath);
          }
        });
  }

  private void resetFileSystemServiceLoader(String fsScheme) {
    try {
      Map<String, Class<? extends FileSystem>> serviceFileSystems =
          (Map<String, Class<? extends FileSystem>>)
              FieldUtils.getField(FileSystem.class, "SERVICE_FILE_SYSTEMS", true).get(null);

      if (serviceFileSystems.containsKey(fsScheme)) {
        return;
      }

      // Set this value to false so that FileSystem will reload the service file systems when
      // needed.
      FieldUtils.getField(FileSystem.class, "FILE_SYSTEMS_LOADED", true).set(null, false);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  private Cache<FileSystemCacheKey, FileSystem> newFileSystemCache(Configuration configuration) {
    int maxCapacity =
        configuration.getInt(
            GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_FILESET_CACHE_MAX_CAPACITY_KEY,
            GravitinoVirtualFileSystemConfiguration
                .FS_GRAVITINO_FILESET_CACHE_MAX_CAPACITY_DEFAULT);
    Preconditions.checkArgument(
        maxCapacity > 0,
        "'%s' should be greater than 0",
        GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_FILESET_CACHE_MAX_CAPACITY_KEY);

    long evictionMillsAfterAccess =
        configuration.getLong(
            GravitinoVirtualFileSystemConfiguration
                .FS_GRAVITINO_FILESET_CACHE_EVICTION_MILLS_AFTER_ACCESS_KEY,
            GravitinoVirtualFileSystemConfiguration
                .FS_GRAVITINO_FILESET_CACHE_EVICTION_MILLS_AFTER_ACCESS_DEFAULT);
    Preconditions.checkArgument(
        evictionMillsAfterAccess > 0,
        "'%s' should be greater than 0",
        GravitinoVirtualFileSystemConfiguration
            .FS_GRAVITINO_FILESET_CACHE_EVICTION_MILLS_AFTER_ACCESS_KEY);

    // Legacy fallback: close FS immediately on eviction (see #11303).
    final boolean closeOnEviction =
        configuration.getBoolean(
            GravitinoVirtualFileSystemConfiguration
                .FS_GRAVITINO_FILESET_CACHE_CLOSE_ON_EVICTION_KEY,
            GravitinoVirtualFileSystemConfiguration
                .FS_GRAVITINO_FILESET_CACHE_CLOSE_ON_EVICTION_DEFAULT);
    if (closeOnEviction) {
      LOG.warn(
          "'{}' is enabled; FileSystem will be closed on cache eviction (legacy behaviour, see #11303).",
          GravitinoVirtualFileSystemConfiguration.FS_GRAVITINO_FILESET_CACHE_CLOSE_ON_EVICTION_KEY);
    }

    Caffeine<Object, Object> cacheBuilder =
        Caffeine.newBuilder()
            .maximumSize(maxCapacity)
            // Since Caffeine does not ensure that removalListener will be involved after expiration
            // We use a scheduler with one thread to clean up expired fs.
            .scheduler(
                Scheduler.forScheduledExecutorService(
                    new ScheduledThreadPoolExecutor(
                        1, newDaemonThreadFactory("gvfs-filesystem-cache-cleaner"))))
            .removalListener(
                (key, value, cause) -> {
                  if (closeOnEviction) {
                    FileSystem fs = (FileSystem) value;
                    if (fs != null) {
                      // Remove from allCreatedFileSystems first to avoid double-close at shutdown.
                      allCreatedFileSystems.remove(fs);
                      try {
                        fs.close();
                      } catch (IOException e) {
                        LOG.error("Cannot close the file system for fileset: {}", key, e);
                      }
                    }
                    return;
                  }
                  // Default: do not close on eviction; close is deferred to GVFS shutdown.
                  LOG.debug("FileSystem evicted from cache (key={}, cause={})", key, cause);
                });
    cacheBuilder.expireAfterAccess(evictionMillsAfterAccess, TimeUnit.MILLISECONDS);
    return cacheBuilder.build();
  }

  private Map<String, String> getAllProperties(
      NameIdentifier filesetIdent, Map<String, String> filesetProperties) {
    Map<String, String> allProperties = new HashMap<>();
    Catalog catalog =
        (Catalog)
            getFilesetCatalog(
                NameIdentifier.of(
                    filesetIdent.namespace().level(0), filesetIdent.namespace().level(1)));
    allProperties.putAll(catalog.properties());

    Schema schema = getSchema(NameIdentifier.parse(filesetIdent.namespace().toString()));
    allProperties.putAll(schema.properties());
    allProperties.putAll(filesetProperties);
    allProperties.putAll(extractNonDefaultConfig(conf));
    return allProperties;
  }

  private Map<String, String> getNecessaryProperties(Map<String, String> properties) {
    return properties.entrySet().stream()
        .filter(property -> CATALOG_NECESSARY_PROPERTIES_TO_KEEP.contains(property.getKey()))
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
  }

  private Map<String, String> getCredentialProperties(
      FileSystemProvider fileSystemProvider,
      NameIdentifier filesetIdentifier,
      String locationName) {
    // Do not support credential vending, we do not need to add any credential properties.
    if (!(fileSystemProvider instanceof SupportsCredentialVending)) {
      return ImmutableMap.of();
    }

    ImmutableMap.Builder<String, String> mapBuilder = ImmutableMap.builder();
    try {
      Fileset fileset = getFileset(filesetIdentifier);
      GravitinoVirtualFileSystemUtils.setCallerContextForGetCredentials(locationName);
      Credential[] credentials = fileset.supportsCredentials().getCredentials();
      if (credentials.length > 0) {
        mapBuilder.put(
            GravitinoFileSystemCredentialsProvider.GVFS_CREDENTIAL_PROVIDER,
            DefaultGravitinoFileSystemCredentialsProvider.class.getCanonicalName());
        mapBuilder.put(
            GravitinoFileSystemCredentialsProvider.GVFS_NAME_IDENTIFIER,
            filesetIdentifier.toString());

        SupportsCredentialVending supportsCredentialVending =
            (SupportsCredentialVending) fileSystemProvider;
        mapBuilder.putAll(supportsCredentialVending.getFileSystemCredentialConf(credentials));
      }
    } catch (Exception e) {
      throw new RuntimeException(e);
    } finally {
      CallerContext.CallerContextHolder.remove();
    }

    return mapBuilder.build();
  }

  private FileSystemProvider getFileSystemProviderByScheme(String scheme) {
    FileSystemProvider provider = fileSystemProvidersMap.get(scheme);
    if (provider == null) {
      throw new GravitinoRuntimeException(
          "Unsupported file system scheme: %s for %s.",
          scheme, GravitinoVirtualFileSystemConfiguration.GVFS_SCHEME);
    }
    return provider;
  }

  private ThreadFactory newDaemonThreadFactory(String name) {
    return new ThreadFactoryBuilder().setDaemon(true).setNameFormat(name + "-%d").build();
  }

  private Map<String, FileSystemProvider> getFileSystemProviders() {
    Map<String, FileSystemProvider> resultMap = Maps.newHashMap();
    ServiceLoader<FileSystemProvider> allFileSystemProviders =
        ServiceLoader.load(FileSystemProvider.class);

    Streams.stream(allFileSystemProviders.iterator())
        .forEach(
            fileSystemProvider -> {
              if (resultMap.containsKey(fileSystemProvider.scheme())) {
                throw new UnsupportedOperationException(
                    String.format(
                        "File system provider: '%s' with scheme '%s' already exists in the provider list, "
                            + "please make sure the file system provider scheme is unique.",
                        fileSystemProvider.getClass().getName(), fileSystemProvider.scheme()));
              }
              resultMap.put(fileSystemProvider.scheme(), fileSystemProvider);
            });
    return resultMap;
  }

  private String initCurrentLocationName(Configuration configuration) {
    // get from configuration first, otherwise use the env variable
    // if both are not set, return null which means use the default location
    return Optional.ofNullable(configuration.get(FS_GRAVITINO_CURRENT_LOCATION_NAME))
        .orElse(System.getenv(currentLocationEnvVar));
  }
}
