/*
 * 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.polaris.core.entity;

import static org.apache.polaris.core.admin.model.StorageConfigInfo.StorageTypeEnum.AZURE;

import com.google.common.base.Preconditions;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.apache.iceberg.exceptions.BadRequestException;
import org.apache.polaris.core.admin.model.AwsStorageConfigInfo;
import org.apache.polaris.core.admin.model.AzureStorageConfigInfo;
import org.apache.polaris.core.admin.model.Catalog;
import org.apache.polaris.core.admin.model.CatalogProperties;
import org.apache.polaris.core.admin.model.ConnectionConfigInfo;
import org.apache.polaris.core.admin.model.ExternalCatalog;
import org.apache.polaris.core.admin.model.FileStorageConfigInfo;
import org.apache.polaris.core.admin.model.GcpStorageConfigInfo;
import org.apache.polaris.core.admin.model.PolarisCatalog;
import org.apache.polaris.core.admin.model.StorageConfigInfo;
import org.apache.polaris.core.config.BehaviorChangeConfiguration;
import org.apache.polaris.core.config.RealmConfig;
import org.apache.polaris.core.connection.ConnectionConfigInfoDpo;
import org.apache.polaris.core.identity.dpo.ServiceIdentityInfoDpo;
import org.apache.polaris.core.identity.provider.ServiceIdentityProvider;
import org.apache.polaris.core.secrets.SecretReference;
import org.apache.polaris.core.storage.FileStorageConfigurationInfo;
import org.apache.polaris.core.storage.PolarisStorageConfigurationInfo;
import org.apache.polaris.core.storage.aws.AwsStorageConfigurationInfo;
import org.apache.polaris.core.storage.azure.AzureStorageConfigurationInfo;
import org.apache.polaris.core.storage.gcp.GcpStorageConfigurationInfo;

/**
 * Catalog specific subclass of the {@link PolarisEntity} that handles conversion from the {@link
 * Catalog} model to the persistent entity model.
 */
public class CatalogEntity extends PolarisEntity implements LocationBasedEntity {
  public static final String CATALOG_TYPE_PROPERTY = "catalogType";

  // Specifies the object-store base location used for all Table file locations under the
  // catalog, stored in the "properties" map.
  public static final String DEFAULT_BASE_LOCATION_KEY = "default-base-location";

  /**
   * Test-only property that specifies a prefix that will be replaced with the catalog's
   * default-base-location whenever it matches a specified new table or view location.
   *
   * <p>For example, if the catalog base location is "s3://my-bucket/base/location" and the prefix
   * specified here is "file:/tmp" then any new table attempting to specify a base location of
   * "file:/tmp/ns1/ns2/table1" will be translated into
   * "s3://my-bucket/base/location/ns1/ns2/table1".
   *
   * <p><strong>WARNING:</strong> This property is intended for testing purposes only and should not
   * be used in production environments.
   */
  public static final String REPLACE_NEW_LOCATION_PREFIX_WITH_CATALOG_DEFAULT_KEY =
      "replace-new-location-prefix-with-catalog-default";

  public CatalogEntity(PolarisBaseEntity sourceEntity) {
    super(sourceEntity);
    Preconditions.checkState(
        getType() == PolarisEntityType.CATALOG, "Invalid entity type: %s", getType());
    Preconditions.checkState(
        getSubType() == PolarisEntitySubType.NULL_SUBTYPE,
        "Invalid entity sub type: %s",
        getSubType());
  }

  public static @Nullable CatalogEntity of(@Nullable PolarisBaseEntity sourceEntity) {
    if (sourceEntity != null) {
      return new CatalogEntity(sourceEntity);
    }
    return null;
  }

  public static CatalogEntity fromCatalog(RealmConfig realmConfig, Catalog catalog) {
    Builder builder =
        new Builder()
            .setName(catalog.getName())
            .setProperties(catalog.getProperties().toMap())
            .setCatalogType(catalog.getType().name());
    Map<String, String> internalProperties = new HashMap<>();
    internalProperties.put(CATALOG_TYPE_PROPERTY, catalog.getType().name());
    builder.setInternalProperties(internalProperties);
    builder.setStorageConfigurationInfo(
        realmConfig, catalog.getStorageConfigInfo(), getBaseLocation(catalog));
    return builder.build();
  }

  public Catalog asCatalog() {
    return this.asCatalog(null);
  }

  public Catalog asCatalog(ServiceIdentityProvider serviceIdentityProvider) {
    Map<String, String> internalProperties = getInternalPropertiesAsMap();
    Catalog.TypeEnum catalogType =
        Optional.ofNullable(internalProperties.get(CATALOG_TYPE_PROPERTY))
            .map(Catalog.TypeEnum::valueOf)
            .orElseGet(() -> getName().equalsIgnoreCase("ROOT") ? Catalog.TypeEnum.INTERNAL : null);
    Map<String, String> propertiesMap = getPropertiesAsMap();
    CatalogProperties catalogProps =
        CatalogProperties.builder(propertiesMap.get(DEFAULT_BASE_LOCATION_KEY))
            .putAll(propertiesMap)
            .build();

    // Right now, only external catalog may use ServiceIdentityProvider to resolve identity
    Preconditions.checkState(
        catalogType != Catalog.TypeEnum.EXTERNAL || serviceIdentityProvider != null,
        "%s catalog needs ServiceIdentityProvider to resolve service identities",
        Catalog.TypeEnum.EXTERNAL);
    return catalogType == Catalog.TypeEnum.EXTERNAL
        ? ExternalCatalog.builder()
            .setType(Catalog.TypeEnum.EXTERNAL)
            .setName(getName())
            .setProperties(catalogProps)
            .setCreateTimestamp(getCreateTimestamp())
            .setLastUpdateTimestamp(getLastUpdateTimestamp())
            .setEntityVersion(getEntityVersion())
            .setStorageConfigInfo(getStorageInfo(internalProperties))
            .setConnectionConfigInfo(getConnectionInfo(internalProperties, serviceIdentityProvider))
            .build()
        : PolarisCatalog.builder()
            .setType(Catalog.TypeEnum.INTERNAL)
            .setName(getName())
            .setProperties(catalogProps)
            .setCreateTimestamp(getCreateTimestamp())
            .setLastUpdateTimestamp(getLastUpdateTimestamp())
            .setEntityVersion(getEntityVersion())
            .setStorageConfigInfo(getStorageInfo(internalProperties))
            .build();
  }

  private StorageConfigInfo getStorageInfo(Map<String, String> internalProperties) {
    if (internalProperties.containsKey(PolarisEntityConstants.getStorageConfigInfoPropertyName())) {
      PolarisStorageConfigurationInfo configInfo = getStorageConfigurationInfo();
      if (configInfo instanceof AwsStorageConfigurationInfo awsConfig) {
        return AwsStorageConfigInfo.builder()
            .setRoleArn(awsConfig.getRoleARN())
            .setExternalId(awsConfig.getExternalId())
            .setUserArn(awsConfig.getUserARN())
            .setCurrentKmsKey(awsConfig.getCurrentKmsKey())
            .setAllowedKmsKeys(awsConfig.getAllowedKmsKeys())
            .setStorageType(StorageConfigInfo.StorageTypeEnum.S3)
            .setAllowedLocations(awsConfig.getAllowedLocations())
            .setStorageName(awsConfig.getStorageName())
            .setRegion(awsConfig.getRegion())
            .setEndpoint(awsConfig.getEndpoint())
            .setStsEndpoint(awsConfig.getStsEndpoint())
            .setPathStyleAccess(awsConfig.getPathStyleAccess())
            .setStsUnavailable(awsConfig.getStsUnavailable())
            .setEndpointInternal(awsConfig.getEndpointInternal())
            .setKmsUnavailable(awsConfig.getKmsUnavailable())
            .build();
      }
      if (configInfo instanceof AzureStorageConfigurationInfo azureConfig) {
        return AzureStorageConfigInfo.builder()
            .setTenantId(azureConfig.getTenantId())
            .setMultiTenantAppName(azureConfig.getMultiTenantAppName())
            .setConsentUrl(azureConfig.getConsentUrl())
            .setStorageType(AZURE)
            .setAllowedLocations(azureConfig.getAllowedLocations())
            .setStorageName(azureConfig.getStorageName())
            .setHierarchical(azureConfig.isHierarchical())
            .build();
      }
      if (configInfo instanceof GcpStorageConfigurationInfo gcpConfigModel) {
        return GcpStorageConfigInfo.builder()
            .setGcsServiceAccount(gcpConfigModel.getGcpServiceAccount())
            .setStorageType(StorageConfigInfo.StorageTypeEnum.GCS)
            .setAllowedLocations(gcpConfigModel.getAllowedLocations())
            .setStorageName(gcpConfigModel.getStorageName())
            .build();
      }
      if (configInfo instanceof FileStorageConfigurationInfo fileConfigModel) {
        return FileStorageConfigInfo.builder()
            .setStorageType(StorageConfigInfo.StorageTypeEnum.FILE)
            .setAllowedLocations(fileConfigModel.getAllowedLocations())
            .setStorageName(fileConfigModel.getStorageName())
            .build();
      }
      return null;
    }
    return null;
  }

  private ConnectionConfigInfo getConnectionInfo(
      Map<String, String> internalProperties, ServiceIdentityProvider serviceIdentityProvider) {
    if (internalProperties.containsKey(
        PolarisEntityConstants.getConnectionConfigInfoPropertyName())) {
      ConnectionConfigInfoDpo configInfo = getConnectionConfigInfoDpo();
      return configInfo.asConnectionConfigInfoModel(serviceIdentityProvider);
    }
    return null;
  }

  @Override
  public String getBaseLocation() {
    return getPropertiesAsMap().get(DEFAULT_BASE_LOCATION_KEY);
  }

  public String getReplaceNewLocationPrefixWithCatalogDefault() {
    return getPropertiesAsMap().get(REPLACE_NEW_LOCATION_PREFIX_WITH_CATALOG_DEFAULT_KEY);
  }

  public @Nullable PolarisStorageConfigurationInfo getStorageConfigurationInfo() {
    String configStr =
        getInternalPropertiesAsMap().get(PolarisEntityConstants.getStorageConfigInfoPropertyName());
    if (configStr != null) {
      return PolarisStorageConfigurationInfo.deserialize(configStr);
    }
    return null;
  }

  public Catalog.TypeEnum getCatalogType() {
    return Optional.ofNullable(getInternalPropertiesAsMap().get(CATALOG_TYPE_PROPERTY))
        .map(Catalog.TypeEnum::valueOf)
        .orElse(null);
  }

  public boolean isExternal() {
    return getCatalogType() == Catalog.TypeEnum.EXTERNAL;
  }

  public boolean isPassthroughFacade() {
    return getInternalPropertiesAsMap()
        .containsKey(PolarisEntityConstants.getConnectionConfigInfoPropertyName());
  }

  public boolean isStaticFacade() {
    return isExternal() && !isPassthroughFacade();
  }

  public ConnectionConfigInfoDpo getConnectionConfigInfoDpo() {
    String configStr =
        getInternalPropertiesAsMap()
            .get(PolarisEntityConstants.getConnectionConfigInfoPropertyName());
    if (configStr != null) {
      return ConnectionConfigInfoDpo.deserialize(configStr);
    }
    return null;
  }

  public static class Builder extends PolarisEntity.BaseBuilder<CatalogEntity, Builder> {
    public Builder() {
      super();
      setType(PolarisEntityType.CATALOG);
      setCatalogId(PolarisEntityConstants.getNullId());
      setParentId(PolarisEntityConstants.getRootEntityId());
    }

    public Builder(CatalogEntity original) {
      super(original);
    }

    public Builder setCatalogType(String type) {
      internalProperties.put(CATALOG_TYPE_PROPERTY, type);
      return this;
    }

    public Builder setDefaultBaseLocation(String defaultBaseLocation) {
      // Note that this member lives in the main 'properties' map rather than internalProperties.
      properties.put(DEFAULT_BASE_LOCATION_KEY, defaultBaseLocation);
      return this;
    }

    public Builder setReplaceNewLocationPrefixWithCatalogDefault(String value) {
      // Note that this member lives in the main 'properties' map rather than internalProperties.
      properties.put(REPLACE_NEW_LOCATION_PREFIX_WITH_CATALOG_DEFAULT_KEY, value);
      return this;
    }

    public Builder setStorageConfigurationInfo(
        RealmConfig realmConfig, StorageConfigInfo storageConfigModel, String defaultBaseLocation) {
      if (storageConfigModel != null) {
        PolarisStorageConfigurationInfo config;
        Set<String> allowedLocations = new HashSet<>(storageConfigModel.getAllowedLocations());

        // TODO: Reconsider whether this should actually just be a check up-front or if we
        // actually want to silently add to the allowed locations. Maybe ideally we only
        // add to the allowedLocations if allowedLocations is empty for the simple case,
        // but if the caller provided allowedLocations explicitly, then we just verify that
        // the defaultBaseLocation is at least a subpath of one of the allowedLocations.
        if (defaultBaseLocation == null) {
          throw new BadRequestException("Must specify default base location");
        }
        allowedLocations.add(defaultBaseLocation);
        validateMaxAllowedLocations(realmConfig, allowedLocations);
        switch (storageConfigModel.getStorageType()) {
          case S3:
            AwsStorageConfigInfo awsConfigModel = (AwsStorageConfigInfo) storageConfigModel;
            AwsStorageConfigurationInfo awsConfig =
                AwsStorageConfigurationInfo.builder()
                    .allowedLocations(allowedLocations)
                    .storageName(storageConfigModel.getStorageName())
                    .roleARN(awsConfigModel.getRoleArn())
                    .currentKmsKey(awsConfigModel.getCurrentKmsKey())
                    .allowedKmsKeys(awsConfigModel.getAllowedKmsKeys())
                    .externalId(awsConfigModel.getExternalId())
                    .region(awsConfigModel.getRegion())
                    .endpoint(awsConfigModel.getEndpoint())
                    .stsEndpoint(awsConfigModel.getStsEndpoint())
                    .pathStyleAccess(awsConfigModel.getPathStyleAccess())
                    .stsUnavailable(awsConfigModel.getStsUnavailable())
                    .endpointInternal(awsConfigModel.getEndpointInternal())
                    .kmsUnavailable(awsConfigModel.getKmsUnavailable())
                    .build();
            config = awsConfig;
            break;
          case AZURE:
            AzureStorageConfigInfo azureConfigModel = (AzureStorageConfigInfo) storageConfigModel;
            config =
                AzureStorageConfigurationInfo.builder()
                    .allowedLocations(allowedLocations)
                    .storageName(storageConfigModel.getStorageName())
                    .tenantId(azureConfigModel.getTenantId())
                    .multiTenantAppName(azureConfigModel.getMultiTenantAppName())
                    .consentUrl(azureConfigModel.getConsentUrl())
                    .hierarchical(azureConfigModel.getHierarchical())
                    .build();
            break;
          case GCS:
            config =
                GcpStorageConfigurationInfo.builder()
                    .allowedLocations(allowedLocations)
                    .storageName(storageConfigModel.getStorageName())
                    .gcpServiceAccount(
                        ((GcpStorageConfigInfo) storageConfigModel).getGcsServiceAccount())
                    .build();
            break;
          case FILE:
            config =
                FileStorageConfigurationInfo.builder()
                    .allowedLocations(allowedLocations)
                    .storageName(storageConfigModel.getStorageName())
                    .build();
            break;
          default:
            throw new IllegalStateException(
                "Unsupported storage type: " + storageConfigModel.getStorageType());
        }
        internalProperties.put(
            PolarisEntityConstants.getStorageConfigInfoPropertyName(), config.serialize());
      }
      return this;
    }

    /** Validate the number of allowed locations not exceeding the max value. */
    private void validateMaxAllowedLocations(
        RealmConfig realmConfig, Collection<String> allowedLocations) {
      int maxAllowedLocations =
          realmConfig.getConfig(BehaviorChangeConfiguration.STORAGE_CONFIGURATION_MAX_LOCATIONS);
      if (maxAllowedLocations != -1 && allowedLocations.size() > maxAllowedLocations) {
        throw new IllegalArgumentException(
            String.format(
                "Number of configured locations (%s) exceeds the limit of %s",
                allowedLocations.size(), maxAllowedLocations));
      }
    }

    public Builder setConnectionConfigInfoDpoWithSecrets(
        ConnectionConfigInfo connectionConfigurationModel,
        Map<String, SecretReference> secretReferences,
        ServiceIdentityInfoDpo serviceIdentityInfoDpo) {
      if (connectionConfigurationModel != null) {
        ConnectionConfigInfoDpo config =
            ConnectionConfigInfoDpo.fromConnectionConfigInfoModelWithSecrets(
                    connectionConfigurationModel, secretReferences)
                .withServiceIdentity(serviceIdentityInfoDpo);
        internalProperties.put(
            PolarisEntityConstants.getConnectionConfigInfoPropertyName(), config.serialize());
      }
      return this;
    }

    public Builder setConnectionConfigInfoDpo(
        @Nonnull ConnectionConfigInfoDpo connectionConfigInfoDpo) {
      internalProperties.put(
          PolarisEntityConstants.getConnectionConfigInfoPropertyName(),
          connectionConfigInfoDpo.serialize());
      return this;
    }

    @Override
    public CatalogEntity build() {
      return new CatalogEntity(buildBase());
    }
  }

  protected static @Nonnull String getBaseLocation(Catalog catalog) {
    return catalog.getProperties().getDefaultBaseLocation();
  }
}
