/*
 * 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.drill.exec.store;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.apache.drill.common.exceptions.DrillRuntimeException;
import org.apache.drill.exec.planner.logical.DrillTable;
import org.apache.drill.exec.planner.logical.ModifyTableEntry;
import org.apache.drill.exec.record.metadata.schema.SchemaProvider;
import org.apache.drill.exec.record.metadata.schema.SchemaProviderFactory;
import org.apache.drill.exec.store.table.function.TableParamDef;
import org.apache.drill.exec.store.table.function.TableSignature;
import org.apache.drill.exec.store.table.function.WithOptionsTableMacro;
import org.apache.drill.shaded.guava.com.google.common.collect.ImmutableMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.calcite.linq4j.tree.DefaultExpression;
import org.apache.calcite.linq4j.tree.Expression;
import org.apache.calcite.rel.type.RelProtoDataType;
import org.apache.calcite.schema.Function;
import org.apache.calcite.schema.Schema;
import org.apache.calcite.schema.SchemaPlus;
import org.apache.calcite.schema.SchemaVersion;
import org.apache.calcite.schema.Table;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.drill.common.exceptions.UserException;
import org.apache.drill.exec.dotdrill.View;
import org.apache.drill.exec.planner.logical.CreateTableEntry;
import org.apache.drill.shaded.guava.com.google.common.base.Joiner;

public abstract class AbstractSchema implements Schema, SchemaPartitionExplorer, AutoCloseable {
  static final Logger logger = LoggerFactory.getLogger(AbstractSchema.class);

  private static final Expression EXPRESSION = new DefaultExpression(Object.class);

  private static final String SCHEMA_PARAMETER_NAME = "schema";

  /**
   * Schema parameter for table function which creates schema provider based on
   * given parameter value.
   */
  private static final TableParamDef SCHEMA_PARAMETER = TableParamDef.optional(
    SCHEMA_PARAMETER_NAME, String.class, (drillTable, value) -> {
      if (value == null) {
        return;
      }

      SchemaProvider schemaProvider;
      try {
        schemaProvider = SchemaProviderFactory.create(String.valueOf(value));
        // since we path schema here as table parameter
        // read schema to ensure that schema is valid before query execution
        schemaProvider.read();
      } catch (IOException | IllegalArgumentException e) {
        throw UserException.validationError(e)
          .message(e.getMessage())
          .addContext("Schema parameter value [%s]", value)
          .build(logger);
      }

      drillTable.getMetadataProviderManager().setSchemaProvider(schemaProvider);
    });

  public static final String SCHEMA_SEPARATOR = ".";

  protected final List<String> schemaPath;
  protected final String name;

  public AbstractSchema(List<String> parentSchemaPath, String name) {
    name = name == null ? null : name.toLowerCase();
    schemaPath = new ArrayList<>();
    schemaPath.addAll(parentSchemaPath);
    schemaPath.add(name);
    this.name = name;
  }

  @Override
  public Iterable<String> getSubPartitions(String table,
                                           List<String> partitionColumns,
                                           List<String> partitionValues
                                          ) throws PartitionNotFoundException {
    throw new UnsupportedOperationException(
        String.format("Schema of type: %s " +
                      "does not support retrieving sub-partition information.",
                      this.getClass().getSimpleName()));
  }

  public String getName() {
    return name;
  }

  public List<String> getSchemaPath() {
    return schemaPath;
  }

  public String getFullSchemaName() {
    return Joiner.on(SCHEMA_SEPARATOR).join(schemaPath);
  }

  @Override
  public final String toString() {
    return getFullSchemaName();
  }

  /**
   * Returns string describing schema type which shows where the schema came from.
   * Good practice here is to return json type name of storage plugin's config.
   *
   * @return schema type name
   */
  public abstract String getTypeName();

  /**
   * The schema can be a top level schema which doesn't have its own tables, but refers
   * to one of the default sub schemas for table look up.
   * <p>
   * Default implementation returns itself.
   * <p>
   * Ex. "dfs" schema refers to the tables in "default" workspace when querying for
   * tables in "dfs" schema.
   *
   * @return Return the default schema where tables are created or retrieved from.
   */
  public Schema getDefaultSchema() {
    return this;
  }

  /**
   * Create a new view given definition.
   * @param view View info including name, definition etc.
   * @return Returns true if an existing view is replaced with the given view. False otherwise.
   * @throws IOException in case of error creating a view
   */
  public boolean createView(View view) throws IOException {
    throw UserException.unsupportedError()
        .message("Creating new view is not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Drop the view with given name.
   *
   * @param viewName view name
   * @throws IOException in case of error dropping the view
   */
  public void dropView(String viewName) throws IOException {
    throw UserException.unsupportedError()
        .message("Dropping a view is supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Creates table entry using table name, list of partition columns
   * and storage strategy used to create table folder and files
   *
   * @param tableName : new table name.
   * @param partitionColumns : list of partition columns. Empty list if there is no partition columns.
   * @param storageStrategy : storage strategy used to create table folder and files
   * @return create table entry
   */
  public CreateTableEntry createNewTable(String tableName, List<String> partitionColumns, StorageStrategy storageStrategy) {
    throw UserException.unsupportedError()
        .message("Creating new tables is not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Returns table entry using table name to insert records into the table.
   *
   * @param tableName : new table name.
   * @return insert table entry
   */
  public ModifyTableEntry modifyTable(String tableName) {
    throw UserException.unsupportedError()
        .message("Modifying tables is not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Creates table entry using table name and list of partition columns if any.
   * Table folder and files will be created using persistent storage strategy.
   *
   * @param tableName : new table name.
   * @param partitionColumns : list of partition columns. Empty list if there is no partition columns.
   * @return create table entry
   */
  public CreateTableEntry createNewTable(String tableName, List<String> partitionColumns) {
    return createNewTable(tableName, partitionColumns, StorageStrategy.DEFAULT);
  }

  /**
   * Create stats table entry for given <i>tableName</i>.
   * @param tableName table name
   * @return instance of create table entry
   */
  public CreateTableEntry createStatsTable(String tableName) {
    throw UserException.unsupportedError()
        .message("Statistics tables are not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Create an append statistics table entry for given <i>tableName</i>. If there is not existing
   * statistics table, a new one is created.
   * @param tableName table name
   * @return instance of create table entry
   */
  public CreateTableEntry appendToStatsTable(String tableName) {
    throw UserException.unsupportedError()
        .message("Statistics tables are not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Get the statistics table for given <i>tableName</i>
   * @param tableName table name
   * @return instance of statistics table
   */
  public Table getStatsTable(String tableName) {
    throw UserException.unsupportedError()
        .message("Statistics tables are not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Reports whether to show items from this schema in INFORMATION_SCHEMA
   * tables.
   * (Controls ... TODO:  Doc.:  Mention what this typically controls or
   * affects.)
   * <p>
   *   This base implementation returns {@code true}.
   * </p>
   */
  public boolean showInInformationSchema() {
    return true;
  }

  /**
   * For the given table names returns list of acceptable table functions
   * which are common for all Drill schemas. When overriding this method,
   * parent functions must be included first to be evaluated first.
   * If not included, parent functions won't be taken into account when creating table instance.
   *
   * @param name table name
   * @return list of table functions
   */
  @Override
  public Collection<Function> getFunctions(String name) {
    List<TableParamDef> parameters = getFunctionParameters();
    TableSignature signature = TableSignature.of(name, parameters);
    WithOptionsTableMacro function = new WithOptionsTableMacro(signature, arguments -> {
      Table table = getTable(name);
      if (table instanceof DrillTable) {
        return applyFunctionParameters((DrillTable) table, parameters, arguments);
      } else if (table == null) {
        return null;
      }
      throw new DrillRuntimeException(String.format("Table [%s] is not of Drill table instance. " +
        "Given instance is of [%s].", name, table.getClass().getName()));
    });
    return Collections.singletonList(function);
  }

  /**
   * Returns of common table function parameters that can be used all Drill schema implementations.
   *
   * @return list of table function parameters
   */
  public List<TableParamDef> getFunctionParameters() {
    return Collections.singletonList(SCHEMA_PARAMETER);
  }

  /**
   * For the given list of parameters definitions executes action for the corresponding value.
   *
   * @param drillTable Drill table instance
   * @param paramDefs parameter definitions
   * @param values parameter values
   * @return updated Drill table instance
   */
  public DrillTable applyFunctionParameters(DrillTable drillTable, List<TableParamDef> paramDefs, List<Object> values) {
    IntStream.range(0, paramDefs.size())
      .forEach(i -> paramDefs.get(i).apply(drillTable, values.get(i)));
    return drillTable;
  }

  /**
   * Returns a map of types in this schema by name.
   *
   * <p>The implementations of {@link #getTypeNames()}
   * and {@link #getType(String)} depend on this map.
   * The default implementation of this method returns the empty map.
   * Override this method to change their behavior.</p>
   *
   * @return Map of types in this schema by name
   */
  protected Map<String, RelProtoDataType> getTypeMap() {
    return ImmutableMap.of();
  }

  @Override
  public Set<String> getTypeNames() {
    return getTypeMap().keySet();
  }

  @Override
  public RelProtoDataType getType(String name) {
    return getTypeMap().get(name);
  }

  @Override
  public Set<String> getFunctionNames() {
    return Collections.emptySet();
  }

  @Override
  public Schema getSubSchema(String name) {
    return null;
  }

  @Override
  public Set<String> getSubSchemaNames() {
    return Collections.emptySet();
  }

  @Override
  public boolean isMutable() {
    return false;
  }

  @Override
  public Table getTable(String name){
    return null;
  }

  @Override
  public Set<String> getTableNames() {
    return Collections.emptySet();
  }

  @Override
  public Expression getExpression(SchemaPlus parentSchema, String name) {
    return EXPRESSION;
  }

  @Override
  public void close() throws Exception {
    // no-op: default implementation for most implementations.
  }

  @Override
  public Schema snapshot(SchemaVersion version) {
    return this;
  }

  public void dropTable(String tableName) {
    throw UserException.unsupportedError()
        .message("Dropping tables is not supported in schema [%s]", getSchemaPath())
        .build(logger);
  }

  /**
   * Get the collection of {@link Table} tables specified in the tableNames.
   *
   * @param  tableNames the requested tables, specified by the table names
   * @return the collection of requested tables
   */
  public List<Pair<String, ? extends Table>> getTablesByNames(final Set<String> tableNames) {
    return tableNames.stream()
        .map(tableName -> Pair.of(tableName, getTable(tableName)))
        .filter(pair -> Objects.nonNull(pair.getValue())) // Schema may return NULL for table if user doesn't have permissions to load the table.
        .collect(Collectors.toList());
  }

  /**
   * Used by {@link org.apache.drill.exec.store.ischema.InfoSchemaRecordGenerator.Tables}
   * for getting all table objects along with type for every requested schema. It's desired
   * for this method to work fast because it impacts SHOW TABLES query.
   *
   * @return collection of table names and types
   */
  public Collection<Map.Entry<String, TableType>> getTableNamesAndTypes() {
    return getTablesByNames(getTableNames()).stream()
        .map(nameAndTable -> Pair.of(nameAndTable.getKey(), nameAndTable.getValue().getJdbcTableType()))
        .collect(Collectors.toList());
  }

  /**
   * Indicates if table names in schema are case sensitive. By default they are.
   * If schema implementation claims its table names are case insensitive,
   * it is responsible for making case insensitive look up by table name.
   *
   * @return true if table names are case sensitive
   */
  public boolean areTableNamesCaseSensitive() {
    return true;
  }

  /**
   * @param impersonated User whom to impersonate. Usually {@link SchemaConfig#getUserName()}
   * @param notImpersonated Regular user (table owner for Views or Drillbit process user for Tables)
   * @return endUser
   */
  public String getUser(String impersonated, String notImpersonated) {
    return notImpersonated;
  }

  /**
   * Does Drill needs to impersonate as user connected to Drill when reading data from DataSource?
   * @return True when both Drill impersonation and DataSource impersonation are enabled.
   */
  public boolean needToImpersonateReadingData() {
    return false;
  }
}
