package com.avaje.ebean.dbmigration.model;

import com.avaje.ebean.dbmigration.migration.AddColumn;
import com.avaje.ebean.dbmigration.migration.AddHistoryTable;
import com.avaje.ebean.dbmigration.migration.AddTableComment;
import com.avaje.ebean.dbmigration.migration.AlterColumn;
import com.avaje.ebean.dbmigration.migration.Column;
import com.avaje.ebean.dbmigration.migration.CreateTable;
import com.avaje.ebean.dbmigration.migration.DropColumn;
import com.avaje.ebean.dbmigration.migration.DropHistoryTable;
import com.avaje.ebean.dbmigration.migration.DropTable;
import com.avaje.ebean.dbmigration.migration.IdentityType;
import com.avaje.ebean.dbmigration.migration.UniqueConstraint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Holds the logical model for a given Table and everything associated to it.
 * <p>
 * This effectively represents a table, its columns and all associated
 * constraints, foreign keys and indexes.
 * </p>
 * <p>
 * Migrations can be applied to this such that it represents the state
 * of a given table after various migrations have been applied.
 * </p>
 * <p>
 * This table model can also be derived from the EbeanServer bean descriptor
 * and associated properties.
 * </p>
 */
public class MTable {

  private static final Logger logger = LoggerFactory.getLogger(MTable.class);

  /**
   * Table name.
   */
  private final String name;

  /**
   * The associated draft table.
   */
  private MTable draftTable;

  /**
   * Marked true for draft tables. These need to have their FK references adjusted
   * after all the draft tables have been identified.
   */
  private boolean draft;

  /**
   * Primary key name.
   */
  private String pkName;

  /**
   * Table comment.
   */
  private String comment;

  /**
   * Tablespace to use.
   */
  private String tablespace;

  /**
   * Tablespace to use for indexes on this table.
   */
  private String indexTablespace;

  /**
   * If set then this overrides the platform default so for UUID generated values
   * or DB's supporting both sequences and autoincrement.
   */
  private IdentityType identityType;

  /**
   * DB sequence name.
   */
  private String sequenceName;
  private int sequenceInitial;
  private int sequenceAllocate;

  /**
   * If set to true this table should has history support.
   */
  private boolean withHistory;

  /**
   * The columns on the table.
   */
  private Map<String, MColumn> columns = new LinkedHashMap<String, MColumn>();

  /**
   * Compound unique constraints.
   */
  private List<MCompoundUniqueConstraint> uniqueConstraints = new ArrayList<MCompoundUniqueConstraint>();

  /**
   * Compound foreign keys.
   */
  private List<MCompoundForeignKey> compoundKeys = new ArrayList<MCompoundForeignKey>();

  /**
   * Column name for the 'When created' column. This can be used for the initial effective start date when adding
   * history to an existing table and maps to a @WhenCreated or @CreatedTimestamp column.
   */
  private String whenCreatedColumn;

  /**
   * Temporary - holds addColumn settings.
   */
  private AddColumn addColumn;

  private List<String> droppedColumns = new ArrayList<String>();

  /**
   * Create a copy of this table structure as a 'draft' table.
   *
   * Note that both tables contain @DraftOnly MColumns and these are filtered out
   * later when creating the CreateTable object.
   */
  public MTable createDraftTable() {

    draftTable = new MTable(name + "_draft");
    draftTable.draft = true;
    draftTable.whenCreatedColumn = whenCreatedColumn;
    // compoundKeys
    // compoundUniqueConstraints
    draftTable.identityType = identityType;

    for (MColumn col : allColumns()) {
      draftTable.addColumn(col.copyForDraft());
    }

    return draftTable;
  }

  /**
   * Construct for migration.
   */
  public MTable(CreateTable createTable) {
    this.name = createTable.getName();
    this.pkName = createTable.getPkName();
    this.comment = createTable.getComment();
    this.tablespace = createTable.getTablespace();
    this.indexTablespace = createTable.getIndexTablespace();
    this.withHistory = Boolean.TRUE.equals(createTable.isWithHistory());
    this.draft = Boolean.TRUE.equals(createTable.isDraft());
    this.sequenceName = createTable.getSequenceName();
    this.sequenceInitial = toInt(createTable.getSequenceInitial());
    this.sequenceAllocate = toInt(createTable.getSequenceAllocate());
    List<Column> cols = createTable.getColumn();
    for (Column column : cols) {
      addColumn(column);
    }
  }


  /**
   * Construct typically from EbeanServer meta data.
   */
  public MTable(String name) {
    this.name = name;
  }

  /**
   * Return the DropTable migration for this table.
   */
  public DropTable dropTable() {
    DropTable dropTable = new DropTable();
    dropTable.setName(name);
    return dropTable;
  }

  /**
   * Return the CreateTable migration for this table.
   */
  public CreateTable createTable() {

    CreateTable createTable = new CreateTable();
    createTable.setName(name);
    createTable.setPkName(pkName);
    createTable.setComment(comment);
    createTable.setTablespace(tablespace);
    createTable.setIndexTablespace(indexTablespace);
    createTable.setSequenceName(sequenceName);
    createTable.setSequenceInitial(toBigInteger(sequenceInitial));
    createTable.setSequenceAllocate(toBigInteger(sequenceAllocate));
    createTable.setIdentityType(identityType);
    if (withHistory) {
      createTable.setWithHistory(Boolean.TRUE);
    }
    if (draft) {
      createTable.setDraft(Boolean.TRUE);
    }

    for (MColumn column : allColumns()) {
      // filter out draftOnly columns from the base table
      if (draft || !column.isDraftOnly()) {
        createTable.getColumn().add(column.createColumn());
      }
    }

    for (MCompoundForeignKey compoundKey : compoundKeys) {
      createTable.getForeignKey().add(compoundKey.createForeignKey());
    }

    for (MCompoundUniqueConstraint constraint : uniqueConstraints) {
      UniqueConstraint uq = new UniqueConstraint();
      uq.setName(constraint.getName());
      String[] columns = constraint.getColumns();
      StringBuilder sb = new StringBuilder();
      for (int i = 0; i < columns.length; i++) {
        if (i > 0) {
          sb.append(",");
        }
        sb.append(columns[i]);
      }
      uq.setColumnNames(sb.toString());
      createTable.getUniqueConstraint().add(uq);
    }

    return createTable;
  }

  /**
   * Compare to another version of the same table to perform a diff.
   */
  public void compare(ModelDiff modelDiff, MTable newTable) {

    if (withHistory != newTable.withHistory) {
      if (withHistory) {
        DropHistoryTable dropHistoryTable = new DropHistoryTable();
        dropHistoryTable.setBaseTable(name);
        modelDiff.addDropHistoryTable(dropHistoryTable);

      } else {
        AddHistoryTable addHistoryTable = new AddHistoryTable();
        addHistoryTable.setBaseTable(name);
        modelDiff.addAddHistoryTable(addHistoryTable);
      }
    }

    addColumn = null;

    Map<String, MColumn> newColumnMap = newTable.getColumns();

    // compare newColumns to existing columns (look for new and diff columns)
    for (MColumn newColumn : newColumnMap.values()) {
      MColumn localColumn = getColumn(newColumn.getName());
      if (localColumn == null) {
        // can ignore if draftOnly column and non-draft table
        if (!newColumn.isDraftOnly() || draft) {
          diffNewColumn(newColumn);
        }
      } else {
        localColumn.compare(modelDiff, this, newColumn);
      }
    }

    // compare existing columns (look for dropped columns)
    for (MColumn existingColumn : allColumns()) {
      MColumn newColumn = newColumnMap.get(existingColumn.getName());
      if (newColumn == null) {
        diffDropColumn(modelDiff, existingColumn);
      } else if (newColumn.isDraftOnly() && !draft) {
        // effectively a drop column (draft only column on a non-draft table)
        logger.trace("... drop column {} from table {} as now draftOnly", newColumn.getName(), name);
        diffDropColumn(modelDiff, existingColumn);
      }
    }

    if (addColumn != null) {
      modelDiff.addAddColumn(addColumn);
    }

    if (MColumn.different(comment, newTable.comment)) {
      AddTableComment addTableComment = new AddTableComment();
      addTableComment.setName(name);
      addTableComment.setComment(newTable.comment);
      modelDiff.addTableComment(addTableComment);
    }
  }

  /**
   * Apply AddColumn migration.
   */
  public void apply(AddColumn addColumn) {
    checkTableName(addColumn.getTableName());
    for (Column column : addColumn.getColumn()) {
      addColumn(column);
    }
  }

  /**
   * Apply AddColumn migration.
   */
  public void apply(AlterColumn alterColumn) {
    checkTableName(alterColumn.getTableName());
    String columnName = alterColumn.getColumnName();
    MColumn existingColumn = getColumn(columnName);
    if (existingColumn == null) {
      throw new IllegalStateException("Column [" + columnName + "] does not exist for AlterColumn change?");
    }
    existingColumn.apply(alterColumn);
  }

  /**
   * Apply DropColumn migration.
   */
  public void apply(DropColumn dropColumn) {
    checkTableName(dropColumn.getTableName());
    MColumn removed = columns.remove(dropColumn.getColumnName());
    if (removed == null) {
      throw new IllegalStateException("Column [" + dropColumn.getColumnName() + "] does not exist for DropColumn change on table [" + dropColumn.getTableName() + "]?");
    }
  }

  public String getName() {
    return name;
  }

  /**
   * Return true if this table is a 'Draft' table.
   */
  public boolean isDraft() {
    return draft;
  }

  public void setPkName(String pkName) {
    this.pkName = pkName;
  }

  public String getComment() {
    return comment;
  }

  public void setComment(String comment) {
    this.comment = comment;
  }

  public String getTablespace() {
    return tablespace;
  }

  public String getIndexTablespace() {
    return indexTablespace;
  }

  public boolean isWithHistory() {
    return withHistory;
  }

  public MTable setWithHistory(boolean withHistory) {
    this.withHistory = withHistory;
    return this;
  }

  public List<String> allHistoryColumns(boolean includeDropped) {

    List<String> columnNames = new ArrayList<String>(columns.size());
    for (MColumn column : columns.values()) {
      if (column.isIncludeInHistory()) {
        columnNames.add(column.getName());
      }
    }
    if (includeDropped && !droppedColumns.isEmpty()) {
      for (String droppedColumn : droppedColumns) {
        columnNames.add(droppedColumn);
      }
    }
    return columnNames;
  }

  /**
   * Return all the columns (excluding columns marked as dropped).
   */
  public Collection<MColumn> allColumns() {

    return columns.values();
  }

  /**
   * Return the column by name.
   */
  public MColumn getColumn(String name) {
    return columns.get(name);
  }

  private Map<String, MColumn> getColumns() {
    return columns;
  }

  public List<MCompoundUniqueConstraint> getUniqueConstraints() {
    return uniqueConstraints;
  }

  public List<MCompoundForeignKey> getCompoundKeys() {
    return compoundKeys;
  }

  public void setSequenceName(String sequenceName) {
    this.sequenceName = sequenceName;
  }

  public void setSequenceInitial(int sequenceInitial) {
    this.sequenceInitial = sequenceInitial;
  }

  public void setSequenceAllocate(int sequenceAllocate) {
    this.sequenceAllocate = sequenceAllocate;
  }

  public void setWhenCreatedColumn(String whenCreatedColumn) {
    this.whenCreatedColumn = whenCreatedColumn;
  }

  public String getWhenCreatedColumn() {
    return whenCreatedColumn;
  }

  /**
   * Set the identity type to use for this table.
   * <p>
   * If set then this overrides the platform default so for UUID generated values
   * or DB's supporting both sequences and autoincrement.
   */
  public void setIdentityType(IdentityType identityType) {
    this.identityType = identityType;
  }

  /**
   * Return the list of columns that make the primary key.
   */
  public List<MColumn> primaryKeyColumns() {
    List<MColumn> pk = new ArrayList<MColumn>(3);
    for (MColumn column : allColumns()) {
      if (column.isPrimaryKey()) {
        pk.add(column);
      }
    }
    return pk;
  }

  private void checkTableName(String tableName) {
    if (!name.equals(tableName)) {
      throw new IllegalArgumentException("addColumn tableName [" + tableName + "] does not match [" + name + "]");
    }
  }

  /**
   * Add a column via migration data.
   */
  private void addColumn(Column column) {
    columns.put(column.getName(), new MColumn(column));
  }

  /**
   * Add a model column (typically from EbeanServer meta data).
   */
  public void addColumn(MColumn column) {
    columns.put(column.getName(), column);
  }

  /**
   * Add a unique constraint.
   */
  public void addUniqueConstraint(String[] columns, boolean oneToOne, String constraintName) {
    uniqueConstraints.add(new MCompoundUniqueConstraint(columns, oneToOne, constraintName));
  }

  /**
   * Add a unique constraint.
   */
  public void addUniqueConstraint(List<MColumn> columns, boolean oneToOne, String constraintName) {
    String[] cols = new String[columns.size()];
    for (int i = 0; i < columns.size(); i++) {
      cols[i] = columns.get(i).getName();
    }
    addUniqueConstraint(cols, oneToOne, constraintName);
  }

  /**
   * Add a compound foreign key.
   */
  public void addForeignKey(MCompoundForeignKey compoundKey) {
    compoundKeys.add(compoundKey);
  }

  /**
   * Add a column checking if it already exists and if so return the existing column.
   * Sometimes the case for a primaryKey that is also a foreign key.
   */
  public MColumn addColumn(String dbCol, String columnDefn, boolean notnull) {

    MColumn existingColumn = getColumn(dbCol);
    if (existingColumn != null) {
      if (notnull) {
        existingColumn.setNotnull(true);
      }
      return existingColumn;
    }

    MColumn newCol = new MColumn(dbCol, columnDefn, notnull);
    addColumn(newCol);
    return newCol;
  }

  /**
   * Add a 'new column' to the AddColumn migration object.
   */
  private void diffNewColumn(MColumn newColumn) {

    if (addColumn == null) {
      addColumn = new AddColumn();
      addColumn.setTableName(name);
      if (withHistory) {
        // These addColumns need to occur on the history
        // table as well as the base table
        addColumn.setWithHistory(Boolean.TRUE);
      }
    }

    addColumn.getColumn().add(newColumn.createColumn());
  }

  /**
   * Add a 'drop column' to the diff.
   */
  private void diffDropColumn(ModelDiff modelDiff, MColumn existingColumn) {

    DropColumn dropColumn = new DropColumn();
    dropColumn.setTableName(name);
    dropColumn.setColumnName(existingColumn.getName());
    if (withHistory && !existingColumn.isHistoryExclude()) {
      // These dropColumns should occur on the history
      // table as well as the base table
      dropColumn.setWithHistory(Boolean.TRUE);
    }

    modelDiff.addDropColumn(dropColumn);
  }

  /**
   * Register a pending un-applied drop column.
   * <p>
   * This means this column still needs to be included in history views/triggers etc even
   * though it is not part of the current model.
   */
  public void registerPendingDropColumn(String columnName) {
    droppedColumns.add(columnName);
  }

  private int toInt(BigInteger value) {
    return (value == null) ? 0 : value.intValue();
  }

  private BigInteger toBigInteger(int value) {
    return (value == 0) ? null : BigInteger.valueOf(value);
  }

  /**
   * Check if there are duplicate foreign keys.
   * <p>
   * This can occur when an ManyToMany relates back to itself.
   * </p>
   */
  public void checkDuplicateForeignKeys() {

    if (hasDuplicateForeignKeys()) {
      int counter = 1;
      for (MCompoundForeignKey fk : compoundKeys) {
        fk.addNameSuffix(counter++);
      }
    }
  }

  /**
   * Return true if the foreign key names are not unique.
   */
  private boolean hasDuplicateForeignKeys() {
    Set<String> fkNames = new HashSet<String>();
    for (MCompoundForeignKey fk : compoundKeys) {
      if (!fkNames.add(fk.getName())) {
        return true;
      }
    }
    return false;
  }

  /**
   * Adjust the references (FK) if it should relate to a draft table.
   */
  public void adjustReferences(ModelContainer modelContainer) {

    Collection<MColumn> cols = allColumns();
    for (MColumn col : cols) {
      String references = col.getReferences();
      if (references != null) {
        String baseTable = extractBaseTable(references);
        MTable refBaseTable = modelContainer.getTable(baseTable);
        if (refBaseTable.draftTable != null) {
          // change references to another associated 'draft' table
          String newReferences = deriveReferences(references, refBaseTable.draftTable.getName());
          col.setReferences(newReferences);
        }
      }
    }
  }

  /**
   * Return the base table name from references (table.column).
   */
  private String extractBaseTable(String references) {
    int lastDot = references.lastIndexOf('.');
    return references.substring(0, lastDot);
  }

  /**
   * Return the new references using the given draftTableName.
   * (The referenced column is the same as before).
   */
  private String deriveReferences(String references, String draftTableName) {
    int lastDot = references.lastIndexOf('.');
    return draftTableName + "." + references.substring(lastDot + 1);
  }

}
