/*
 * Copyright 2004-2022 H2 Group. Multiple-Licensed under the MPL 2.0,
 * and the EPL 1.0 (https://h2database.com/html/license.html).
 * Initial Developer: H2 Group
 */
package org.h2.schema;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.h2.api.ErrorCode;
import org.h2.command.ddl.CreateSynonymData;
import org.h2.command.ddl.CreateTableData;
import org.h2.constraint.Constraint;
import org.h2.engine.Database;
import org.h2.engine.DbObject;
import org.h2.engine.DbSettings;
import org.h2.engine.Right;
import org.h2.engine.RightOwner;
import org.h2.engine.SessionLocal;
import org.h2.engine.SysProperties;
import org.h2.index.Index;
import org.h2.message.DbException;
import org.h2.message.Trace;
import org.h2.table.MetaTable;
import org.h2.table.Table;
import org.h2.table.TableLink;
import org.h2.table.TableSynonym;
import org.h2.util.Utils;

/**
 * A schema as created by the SQL statement
 * CREATE SCHEMA
 */
public class Schema extends DbObject {

    private RightOwner owner;
    private final boolean system;
    private ArrayList<String> tableEngineParams;

    private final ConcurrentHashMap<String, Table> tablesAndViews;
    private final ConcurrentHashMap<String, Domain> domains;
    private final ConcurrentHashMap<String, TableSynonym> synonyms;
    private final ConcurrentHashMap<String, Index> indexes;
    private final ConcurrentHashMap<String, Sequence> sequences;
    private final ConcurrentHashMap<String, TriggerObject> triggers;
    private final ConcurrentHashMap<String, Constraint> constraints;
    private final ConcurrentHashMap<String, Constant> constants;
    private final ConcurrentHashMap<String, UserDefinedFunction> functionsAndAggregates;

    /**
     * The set of returned unique names that are not yet stored. It is used to
     * avoid returning the same unique name twice when multiple threads
     * concurrently create objects.
     */
    private final HashSet<String> temporaryUniqueNames = new HashSet<>();

    /**
     * Create a new schema object.
     *
     * @param database the database
     * @param id the object id
     * @param schemaName the schema name
     * @param owner the owner of the schema
     * @param system if this is a system schema (such a schema can not be
     *            dropped)
     */
    public Schema(Database database, int id, String schemaName, RightOwner owner, boolean system) {
        super(database, id, schemaName, Trace.SCHEMA);
        tablesAndViews = database.newConcurrentStringMap();
        domains = database.newConcurrentStringMap();
        synonyms = database.newConcurrentStringMap();
        indexes = database.newConcurrentStringMap();
        sequences = database.newConcurrentStringMap();
        triggers = database.newConcurrentStringMap();
        constraints = database.newConcurrentStringMap();
        constants = database.newConcurrentStringMap();
        functionsAndAggregates = database.newConcurrentStringMap();
        this.owner = owner;
        this.system = system;
    }

    /**
     * Check if this schema can be dropped. System schemas can not be dropped.
     *
     * @return true if it can be dropped
     */
    public boolean canDrop() {
        return !system;
    }

    @Override
    public String getCreateSQL() {
        if (system) {
            return null;
        }
        StringBuilder builder = new StringBuilder("CREATE SCHEMA IF NOT EXISTS ");
        getSQL(builder, DEFAULT_SQL_FLAGS).append(" AUTHORIZATION ");
        owner.getSQL(builder, DEFAULT_SQL_FLAGS);
        return builder.toString();
    }

    @Override
    public int getType() {
        return DbObject.SCHEMA;
    }

    /**
     * Return whether is this schema is empty (does not contain any objects).
     *
     * @return {@code true} if this schema is empty, {@code false} otherwise
     */
    public boolean isEmpty() {
        return tablesAndViews.isEmpty() && domains.isEmpty() && synonyms.isEmpty() && indexes.isEmpty()
                && sequences.isEmpty() && triggers.isEmpty() && constraints.isEmpty() && constants.isEmpty()
                && functionsAndAggregates.isEmpty();
    }

    @Override
    public ArrayList<DbObject> getChildren() {
        ArrayList<DbObject> children = Utils.newSmallArrayList();
        ArrayList<Right> rights = database.getAllRights();
        for (Right right : rights) {
            if (right.getGrantedObject() == this) {
                children.add(right);
            }
        }
        return children;
    }

    @Override
    public void removeChildrenAndResources(SessionLocal session) {
        removeChildrenFromMap(session, triggers);
        removeChildrenFromMap(session, constraints);
        // There can be dependencies between tables e.g. using computed columns,
        // so we might need to loop over them multiple times.
        boolean modified = true;
        while (!tablesAndViews.isEmpty()) {
            boolean newModified = false;
            for (Table obj : tablesAndViews.values()) {
                if (obj.getName() != null) {
                    // Database.removeSchemaObject() removes the object from
                    // the map too, but it is safe for ConcurrentHashMap.
                    Table dependentTable = database.getDependentTable(obj, obj);
                    if (dependentTable == null) {
                        database.removeSchemaObject(session, obj);
                        newModified = true;
                    } else if (dependentTable.getSchema() != this) {
                        throw DbException.get(ErrorCode.CANNOT_DROP_2, //
                                obj.getTraceSQL(), dependentTable.getTraceSQL());
                    } else if (!modified) {
                        dependentTable.removeColumnExpressionsDependencies(session);
                        dependentTable.setModified();
                        database.updateMeta(session, dependentTable);
                    }
                }
            }
            modified = newModified;
        }
        removeChildrenFromMap(session, domains);
        removeChildrenFromMap(session, indexes);
        removeChildrenFromMap(session, sequences);
        removeChildrenFromMap(session, constants);
        removeChildrenFromMap(session, functionsAndAggregates);
        for (Right right : database.getAllRights()) {
            if (right.getGrantedObject() == this) {
                database.removeDatabaseObject(session, right);
            }
        }
        database.removeMeta(session, getId());
        owner = null;
        invalidate();
    }

    private void removeChildrenFromMap(SessionLocal session, ConcurrentHashMap<String, ? extends SchemaObject> map) {
        if (!map.isEmpty()) {
            for (SchemaObject obj : map.values()) {
                /*
                 * Referential constraints are dropped when unique or PK
                 * constraint is dropped, but iterator may return already
                 * removed objects in some cases.
                 */
                if (obj.isValid()) {
                    // Database.removeSchemaObject() removes the object from
                    // the map too, but it is safe for ConcurrentHashMap.
                    database.removeSchemaObject(session, obj);
                }
            }
        }
    }

    /**
     * Get the owner of this schema.
     *
     * @return the owner
     */
    public RightOwner getOwner() {
        return owner;
    }

    /**
     * Get table engine params of this schema.
     *
     * @return default table engine params
     */
    public ArrayList<String> getTableEngineParams() {
        return tableEngineParams;
    }

    /**
     * Set table engine params of this schema.
     * @param tableEngineParams default table engine params
     */
    public void setTableEngineParams(ArrayList<String> tableEngineParams) {
        this.tableEngineParams = tableEngineParams;
    }

    @SuppressWarnings("unchecked")
    private Map<String, SchemaObject> getMap(int type) {
        Map<String, ? extends SchemaObject> result;
        switch (type) {
        case DbObject.TABLE_OR_VIEW:
            result = tablesAndViews;
            break;
        case DbObject.DOMAIN:
            result = domains;
            break;
        case DbObject.SYNONYM:
            result = synonyms;
            break;
        case DbObject.SEQUENCE:
            result = sequences;
            break;
        case DbObject.INDEX:
            result = indexes;
            break;
        case DbObject.TRIGGER:
            result = triggers;
            break;
        case DbObject.CONSTRAINT:
            result = constraints;
            break;
        case DbObject.CONSTANT:
            result = constants;
            break;
        case DbObject.FUNCTION_ALIAS:
        case DbObject.AGGREGATE:
            result = functionsAndAggregates;
            break;
        default:
            throw DbException.getInternalError("type=" + type);
        }
        return (Map<String, SchemaObject>) result;
    }

    /**
     * Add an object to this schema.
     * This method must not be called within CreateSchemaObject;
     * use Database.addSchemaObject() instead
     *
     * @param obj the object to add
     */
    public void add(SchemaObject obj) {
        if (obj.getSchema() != this) {
            throw DbException.getInternalError("wrong schema");
        }
        String name = obj.getName();
        Map<String, SchemaObject> map = getMap(obj.getType());
        if (map.putIfAbsent(name, obj) != null) {
            throw DbException.getInternalError("object already exists: " + name);
        }
        freeUniqueName(name);
    }

    /**
     * Rename an object.
     *
     * @param obj the object to rename
     * @param newName the new name
     */
    public void rename(SchemaObject obj, String newName) {
        int type = obj.getType();
        Map<String, SchemaObject> map = getMap(type);
        if (SysProperties.CHECK) {
            if (!map.containsKey(obj.getName()) && !(obj instanceof MetaTable)) {
                throw DbException.getInternalError("not found: " + obj.getName());
            }
            if (obj.getName().equals(newName) || map.containsKey(newName)) {
                throw DbException.getInternalError("object already exists: " + newName);
            }
        }
        obj.checkRename();
        map.remove(obj.getName());
        freeUniqueName(obj.getName());
        obj.rename(newName);
        map.put(newName, obj);
        freeUniqueName(newName);
    }

    /**
     * Try to find a table or view with this name. This method returns null if
     * no object with this name exists. Local temporary tables are also
     * returned. Synonyms are not returned or resolved.
     *
     * @param session the session
     * @param name the object name
     * @return the object or null
     */
    public Table findTableOrView(SessionLocal session, String name) {
        Table table = tablesAndViews.get(name);
        if (table == null && session != null) {
            table = session.findLocalTempTable(name);
        }
        return table;
    }

    /**
     * Try to find a table or view with this name. This method returns null if
     * no object with this name exists. Local temporary tables are also
     * returned. If a synonym with this name exists, the backing table of the
     * synonym is returned
     *
     * @param session the session
     * @param name the object name
     * @return the object or null
     */
    public Table resolveTableOrView(SessionLocal session, String name) {
        Table table = findTableOrView(session, name);
        if (table == null) {
            TableSynonym synonym = synonyms.get(name);
            if (synonym != null) {
                return synonym.getSynonymFor();
            }
        }
        return table;
    }

    /**
     * Try to find a synonym with this name. This method returns null if
     * no object with this name exists.
     *
     * @param name the object name
     * @return the object or null
     */
    public TableSynonym getSynonym(String name) {
        return synonyms.get(name);
    }

    /**
     * Get the domain if it exists, or null if not.
     *
     * @param name the name of the domain
     * @return the domain or null
     */
    public Domain findDomain(String name) {
        return domains.get(name);
    }

    /**
     * Try to find an index with this name. This method returns null if
     * no object with this name exists.
     *
     * @param session the session
     * @param name the object name
     * @return the object or null
     */
    public Index findIndex(SessionLocal session, String name) {
        Index index = indexes.get(name);
        if (index == null) {
            index = session.findLocalTempTableIndex(name);
        }
        return index;
    }

    /**
     * Try to find a trigger with this name. This method returns null if
     * no object with this name exists.
     *
     * @param name the object name
     * @return the object or null
     */
    public TriggerObject findTrigger(String name) {
        return triggers.get(name);
    }

    /**
     * Try to find a sequence with this name. This method returns null if
     * no object with this name exists.
     *
     * @param sequenceName the object name
     * @return the object or null
     */
    public Sequence findSequence(String sequenceName) {
        return sequences.get(sequenceName);
    }

    /**
     * Try to find a constraint with this name. This method returns null if no
     * object with this name exists.
     *
     * @param session the session
     * @param name the object name
     * @return the object or null
     */
    public Constraint findConstraint(SessionLocal session, String name) {
        Constraint constraint = constraints.get(name);
        if (constraint == null) {
            constraint = session.findLocalTempTableConstraint(name);
        }
        return constraint;
    }

    /**
     * Try to find a user defined constant with this name. This method returns
     * null if no object with this name exists.
     *
     * @param constantName the object name
     * @return the object or null
     */
    public Constant findConstant(String constantName) {
        return constants.get(constantName);
    }

    /**
     * Try to find a user defined function with this name. This method returns
     * null if no object with this name exists.
     *
     * @param functionAlias the object name
     * @return the object or null
     */
    public FunctionAlias findFunction(String functionAlias) {
        UserDefinedFunction userDefinedFunction = findFunctionOrAggregate(functionAlias);
        return userDefinedFunction instanceof FunctionAlias ? (FunctionAlias) userDefinedFunction : null;
    }

    /**
     * Get the user defined aggregate function if it exists. This method returns
     * null if no object with this name exists.
     *
     * @param name the name of the user defined aggregate function
     * @return the aggregate function or null
     */
    public UserAggregate findAggregate(String name) {
        UserDefinedFunction userDefinedFunction = findFunctionOrAggregate(name);
        return userDefinedFunction instanceof UserAggregate ? (UserAggregate) userDefinedFunction : null;
    }

    /**
     * Try to find a user defined function or aggregate function with the
     * specified name. This method returns null if no object with this name
     * exists.
     *
     * @param name
     *            the object name
     * @return the object or null
     */
    public UserDefinedFunction findFunctionOrAggregate(String name) {
        return functionsAndAggregates.get(name);
    }

    /**
     * Reserve a unique object name.
     *
     * @param name the object name
     */
    public void reserveUniqueName(String name) {
        if (name != null) {
            synchronized (temporaryUniqueNames) {
                temporaryUniqueNames.add(name);
            }
        }
    }

    /**
     * Release a unique object name.
     *
     * @param name the object name
     */
    public void freeUniqueName(String name) {
        if (name != null) {
            synchronized (temporaryUniqueNames) {
                temporaryUniqueNames.remove(name);
            }
        }
    }

    private String getUniqueName(DbObject obj, Map<String, ? extends SchemaObject> map, String prefix) {
        StringBuilder nameBuilder = new StringBuilder(prefix);
        String hash = Integer.toHexString(obj.getName().hashCode());
        synchronized (temporaryUniqueNames) {
            for (int i = 0, len = hash.length(); i < len; i++) {
                char c = hash.charAt(i);
                String name = nameBuilder.append(c >= 'a' ? (char) (c - 0x20) : c).toString();
                if (!map.containsKey(name) && temporaryUniqueNames.add(name)) {
                    return name;
                }
            }
            int nameLength = nameBuilder.append('_').length();
            for (int i = 0;; i++) {
                String name = nameBuilder.append(i).toString();
                if (!map.containsKey(name) && temporaryUniqueNames.add(name)) {
                    return name;
                }
                nameBuilder.setLength(nameLength);
            }
        }
    }

    /**
     * Create a unique constraint name.
     *
     * @param session the session
     * @param table the constraint table
     * @return the unique name
     */
    public String getUniqueConstraintName(SessionLocal session, Table table) {
        Map<String, Constraint> tableConstraints;
        if (table.isTemporary() && !table.isGlobalTemporary()) {
            tableConstraints = session.getLocalTempTableConstraints();
        } else {
            tableConstraints = constraints;
        }
        return getUniqueName(table, tableConstraints, "CONSTRAINT_");
    }

    /**
     * Create a unique constraint name.
     *
     * @param session the session
     * @param domain the constraint domain
     * @return the unique name
     */
    public String getUniqueDomainConstraintName(SessionLocal session, Domain domain) {
        return getUniqueName(domain, constraints, "CONSTRAINT_");
    }

    /**
     * Create a unique index name.
     *
     * @param session the session
     * @param table the indexed table
     * @param prefix the index name prefix
     * @return the unique name
     */
    public String getUniqueIndexName(SessionLocal session, Table table, String prefix) {
        Map<String, Index> tableIndexes;
        if (table.isTemporary() && !table.isGlobalTemporary()) {
            tableIndexes = session.getLocalTempTableIndexes();
        } else {
            tableIndexes = indexes;
        }
        return getUniqueName(table, tableIndexes, prefix);
    }

    /**
     * Get the table or view with the given name.
     * Local temporary tables are also returned.
     *
     * @param session the session
     * @param name the table or view name
     * @return the table or view
     * @throws DbException if no such object exists
     */
    public Table getTableOrView(SessionLocal session, String name) {
        Table table = tablesAndViews.get(name);
        if (table == null) {
            if (session != null) {
                table = session.findLocalTempTable(name);
            }
            if (table == null) {
                throw DbException.get(ErrorCode.TABLE_OR_VIEW_NOT_FOUND_1, name);
            }
        }
        return table;
    }

    /**
     * Get the domain with the given name.
     *
     * @param name the domain name
     * @return the domain
     * @throws DbException if no such object exists
     */
    public Domain getDomain(String name) {
        Domain domain = domains.get(name);
        if (domain == null) {
            throw DbException.get(ErrorCode.DOMAIN_NOT_FOUND_1, name);
        }
        return domain;
    }

    /**
     * Get the index with the given name.
     *
     * @param name the index name
     * @return the index
     * @throws DbException if no such object exists
     */
    public Index getIndex(String name) {
        Index index = indexes.get(name);
        if (index == null) {
            throw DbException.get(ErrorCode.INDEX_NOT_FOUND_1, name);
        }
        return index;
    }

    /**
     * Get the constraint with the given name.
     *
     * @param name the constraint name
     * @return the constraint
     * @throws DbException if no such object exists
     */
    public Constraint getConstraint(String name) {
        Constraint constraint = constraints.get(name);
        if (constraint == null) {
            throw DbException.get(ErrorCode.CONSTRAINT_NOT_FOUND_1, name);
        }
        return constraint;
    }

    /**
     * Get the user defined constant with the given name.
     *
     * @param constantName the constant name
     * @return the constant
     * @throws DbException if no such object exists
     */
    public Constant getConstant(String constantName) {
        Constant constant = constants.get(constantName);
        if (constant == null) {
            throw DbException.get(ErrorCode.CONSTANT_NOT_FOUND_1, constantName);
        }
        return constant;
    }

    /**
     * Get the sequence with the given name.
     *
     * @param sequenceName the sequence name
     * @return the sequence
     * @throws DbException if no such object exists
     */
    public Sequence getSequence(String sequenceName) {
        Sequence sequence = sequences.get(sequenceName);
        if (sequence == null) {
            throw DbException.get(ErrorCode.SEQUENCE_NOT_FOUND_1, sequenceName);
        }
        return sequence;
    }

    /**
     * Get all objects.
     *
     * @param addTo
     *                  list to add objects to, or {@code null} to allocate a new
     *                  list
     * @return the specified list with added objects, or a new (possibly empty) list
     *         with all objects
     */
    public ArrayList<SchemaObject> getAll(ArrayList<SchemaObject> addTo) {
        if (addTo == null) {
            addTo = Utils.newSmallArrayList();
        }
        addTo.addAll(tablesAndViews.values());
        addTo.addAll(domains.values());
        addTo.addAll(synonyms.values());
        addTo.addAll(sequences.values());
        addTo.addAll(indexes.values());
        addTo.addAll(triggers.values());
        addTo.addAll(constraints.values());
        addTo.addAll(constants.values());
        addTo.addAll(functionsAndAggregates.values());
        return addTo;
    }

    /**
     * Get all objects of the given type.
     *
     * @param type
     *                  the object type
     * @param addTo
     *                  list to add objects to
     */
    public void getAll(int type, ArrayList<SchemaObject> addTo) {
        addTo.addAll(getMap(type).values());
    }

    public Collection<Domain> getAllDomains() {
        return domains.values();
    }

    public Collection<Constraint> getAllConstraints() {
        return constraints.values();
    }

    public Collection<Constant> getAllConstants() {
        return constants.values();
    }

    public Collection<Sequence> getAllSequences() {
        return sequences.values();
    }

    public Collection<TriggerObject> getAllTriggers() {
        return triggers.values();
    }

    /**
     * Get all tables and views.
     *
     * @param session the session, {@code null} to exclude meta tables
     * @return a (possible empty) list of all objects
     */
    public Collection<Table> getAllTablesAndViews(SessionLocal session) {
        return tablesAndViews.values();
    }

    public Collection<Index> getAllIndexes() {
        return indexes.values();
    }

    public Collection<TableSynonym> getAllSynonyms() {
        return synonyms.values();
    }

    public Collection<UserDefinedFunction> getAllFunctionsAndAggregates() {
        return functionsAndAggregates.values();
    }

    /**
     * Get the table with the given name, if any.
     *
     * @param session the session
     * @param name the table name
     * @return the table or null if not found
     */
    public Table getTableOrViewByName(SessionLocal session, String name) {
        return tablesAndViews.get(name);
    }

    /**
     * Remove an object from this schema.
     *
     * @param obj the object to remove
     */
    public void remove(SchemaObject obj) {
        String objName = obj.getName();
        Map<String, SchemaObject> map = getMap(obj.getType());
        if (map.remove(objName) == null) {
            throw DbException.getInternalError("not found: " + objName);
        }
        freeUniqueName(objName);
    }

    /**
     * Add a table to the schema.
     *
     * @param data the create table information
     * @return the created {@link Table} object
     */
    public Table createTable(CreateTableData data) {
        synchronized (database) {
            if (!data.temporary || data.globalTemporary) {
                database.lockMeta(data.session);
            }
            data.schema = this;
            String tableEngine = data.tableEngine;
            if (tableEngine == null) {
                DbSettings s = database.getSettings();
                tableEngine = s.defaultTableEngine;
                if (tableEngine == null) {
                    return database.getStore().createTable(data);
                }
                data.tableEngine = tableEngine;
            }
            if (data.tableEngineParams == null) {
                data.tableEngineParams = this.tableEngineParams;
            }
            return database.getTableEngine(tableEngine).createTable(data);
        }
    }

    /**
     * Add a table synonym to the schema.
     *
     * @param data the create synonym information
     * @return the created {@link TableSynonym} object
     */
    public TableSynonym createSynonym(CreateSynonymData data) {
        synchronized (database) {
            database.lockMeta(data.session);
            data.schema = this;
            return new TableSynonym(data);
        }
    }

    /**
     * Add a linked table to the schema.
     *
     * @param id the object id
     * @param tableName the table name of the alias
     * @param driver the driver class name
     * @param url the database URL
     * @param user the user name
     * @param password the password
     * @param originalSchema the schema name of the target table
     * @param originalTable the table name of the target table
     * @param emitUpdates if updates should be emitted instead of delete/insert
     * @param force create the object even if the database can not be accessed
     * @return the {@link TableLink} object
     */
    public TableLink createTableLink(int id, String tableName, String driver,
            String url, String user, String password, String originalSchema,
            String originalTable, boolean emitUpdates, boolean force) {
        synchronized (database) {
            return new TableLink(this, id, tableName,
                    driver, url, user, password,
                    originalSchema, originalTable, emitUpdates, force);
        }
    }

}
