/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed 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 androidx.room.util;

import android.database.Cursor;
import android.os.Build;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.room.ColumnInfo;
import androidx.sqlite.db.SupportSQLiteDatabase;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

/**
 * A data class that holds the information about a table.
 * <p>
 * It directly maps to the result of {@code PRAGMA table_info(<table_name>)}. Check the
 * <a href="http://www.sqlite.org/pragma.html#pragma_table_info">PRAGMA table_info</a>
 * documentation for more details.
 * <p>
 * Even though SQLite column names are case insensitive, this class uses case sensitive matching.
 *
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
@SuppressWarnings({"WeakerAccess", "unused", "TryFinallyCanBeTryWithResources",
        "SimplifiableIfStatement"})
// if you change this class, you must change TableInfoWriter.kt
public class TableInfo {

    /**
     * Identifies from where the info object was created.
     */
    @Retention(RetentionPolicy.SOURCE)
    @IntDef(value = {CREATED_FROM_UNKNOWN, CREATED_FROM_ENTITY, CREATED_FROM_DATABASE})
    @interface CreatedFrom {
    }

    /**
     * Identifier for when the info is created from an unknown source.
     */
    public static final int CREATED_FROM_UNKNOWN = 0;

    /**
     * Identifier for when the info is created from an entity definition, such as generated code
     * by the compiler or at runtime from a schema bundle, parsed from a schema JSON file.
     */
    public static final int CREATED_FROM_ENTITY = 1;

    /**
     * Identifier for when the info is created from the database itself, reading information from a
     * PRAGMA, such as table_info.
     */
    public static final int CREATED_FROM_DATABASE = 2;

    /**
     * The table name.
     */
    public final String name;
    /**
     * Unmodifiable map of columns keyed by column name.
     */
    public final Map<String, Column> columns;

    public final Set<ForeignKey> foreignKeys;

    /**
     * Sometimes, Index information is not available (older versions). If so, we skip their
     * verification.
     */
    @Nullable
    public final Set<Index> indices;

    @SuppressWarnings("unused")
    public TableInfo(String name, Map<String, Column> columns, Set<ForeignKey> foreignKeys,
            Set<Index> indices) {
        this.name = name;
        this.columns = Collections.unmodifiableMap(columns);
        this.foreignKeys = Collections.unmodifiableSet(foreignKeys);
        this.indices = indices == null ? null : Collections.unmodifiableSet(indices);
    }

    /**
     * For backward compatibility with dbs created with older versions.
     */
    @SuppressWarnings("unused")
    public TableInfo(String name, Map<String, Column> columns, Set<ForeignKey> foreignKeys) {
        this(name, columns, foreignKeys, Collections.<Index>emptySet());
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        TableInfo tableInfo = (TableInfo) o;

        if (name != null ? !name.equals(tableInfo.name) : tableInfo.name != null) return false;
        if (columns != null ? !columns.equals(tableInfo.columns) : tableInfo.columns != null) {
            return false;
        }
        if (foreignKeys != null ? !foreignKeys.equals(tableInfo.foreignKeys)
                : tableInfo.foreignKeys != null) {
            return false;
        }
        if (indices == null || tableInfo.indices == null) {
            // if one us is missing index information, seems like we couldn't acquire the
            // information so we better skip.
            return true;
        }
        return indices.equals(tableInfo.indices);
    }

    @Override
    public int hashCode() {
        int result = name != null ? name.hashCode() : 0;
        result = 31 * result + (columns != null ? columns.hashCode() : 0);
        result = 31 * result + (foreignKeys != null ? foreignKeys.hashCode() : 0);
        // skip index, it is not reliable for comparison.
        return result;
    }

    @Override
    public String toString() {
        return "TableInfo{"
                + "name='" + name + '\''
                + ", columns=" + columns
                + ", foreignKeys=" + foreignKeys
                + ", indices=" + indices
                + '}';
    }

    /**
     * Reads the table information from the given database.
     *
     * @param database  The database to read the information from.
     * @param tableName The table name.
     * @return A TableInfo containing the schema information for the provided table name.
     */
    @SuppressWarnings("SameParameterValue")
    public static TableInfo read(SupportSQLiteDatabase database, String tableName) {
        Map<String, Column> columns = readColumns(database, tableName);
        Set<ForeignKey> foreignKeys = readForeignKeys(database, tableName);
        Set<Index> indices = readIndices(database, tableName);
        return new TableInfo(tableName, columns, foreignKeys, indices);
    }

    private static Set<ForeignKey> readForeignKeys(SupportSQLiteDatabase database,
            String tableName) {
        Set<ForeignKey> foreignKeys = new HashSet<>();
        // this seems to return everything in order but it is not documented so better be safe
        Cursor cursor = database.query("PRAGMA foreign_key_list(`" + tableName + "`)");
        try {
            final int idColumnIndex = cursor.getColumnIndex("id");
            final int seqColumnIndex = cursor.getColumnIndex("seq");
            final int tableColumnIndex = cursor.getColumnIndex("table");
            final int onDeleteColumnIndex = cursor.getColumnIndex("on_delete");
            final int onUpdateColumnIndex = cursor.getColumnIndex("on_update");

            final List<ForeignKeyWithSequence> ordered = readForeignKeyFieldMappings(cursor);
            final int count = cursor.getCount();
            for (int position = 0; position < count; position++) {
                cursor.moveToPosition(position);
                final int seq = cursor.getInt(seqColumnIndex);
                if (seq != 0) {
                    continue;
                }
                final int id = cursor.getInt(idColumnIndex);
                List<String> myColumns = new ArrayList<>();
                List<String> refColumns = new ArrayList<>();
                for (ForeignKeyWithSequence key : ordered) {
                    if (key.mId == id) {
                        myColumns.add(key.mFrom);
                        refColumns.add(key.mTo);
                    }
                }
                foreignKeys.add(new ForeignKey(
                        cursor.getString(tableColumnIndex),
                        cursor.getString(onDeleteColumnIndex),
                        cursor.getString(onUpdateColumnIndex),
                        myColumns,
                        refColumns
                ));
            }
        } finally {
            cursor.close();
        }
        return foreignKeys;
    }

    private static List<ForeignKeyWithSequence> readForeignKeyFieldMappings(Cursor cursor) {
        final int idColumnIndex = cursor.getColumnIndex("id");
        final int seqColumnIndex = cursor.getColumnIndex("seq");
        final int fromColumnIndex = cursor.getColumnIndex("from");
        final int toColumnIndex = cursor.getColumnIndex("to");
        final int count = cursor.getCount();
        List<ForeignKeyWithSequence> result = new ArrayList<>();
        for (int i = 0; i < count; i++) {
            cursor.moveToPosition(i);
            result.add(new ForeignKeyWithSequence(
                    cursor.getInt(idColumnIndex),
                    cursor.getInt(seqColumnIndex),
                    cursor.getString(fromColumnIndex),
                    cursor.getString(toColumnIndex)
            ));
        }
        Collections.sort(result);
        return result;
    }

    private static Map<String, Column> readColumns(SupportSQLiteDatabase database,
            String tableName) {
        Cursor cursor = database
                .query("PRAGMA table_info(`" + tableName + "`)");
        //noinspection TryFinallyCanBeTryWithResources
        Map<String, Column> columns = new HashMap<>();
        try {
            if (cursor.getColumnCount() > 0) {
                int nameIndex = cursor.getColumnIndex("name");
                int typeIndex = cursor.getColumnIndex("type");
                int notNullIndex = cursor.getColumnIndex("notnull");
                int pkIndex = cursor.getColumnIndex("pk");
                int defaultValueIndex = cursor.getColumnIndex("dflt_value");

                while (cursor.moveToNext()) {
                    final String name = cursor.getString(nameIndex);
                    final String type = cursor.getString(typeIndex);
                    final boolean notNull = 0 != cursor.getInt(notNullIndex);
                    final int primaryKeyPosition = cursor.getInt(pkIndex);
                    final String defaultValue = cursor.getString(defaultValueIndex);
                    columns.put(name,
                            new Column(name, type, notNull, primaryKeyPosition, defaultValue,
                                    CREATED_FROM_DATABASE));
                }
            }
        } finally {
            cursor.close();
        }
        return columns;
    }

    /**
     * @return null if we cannot read the indices due to older sqlite implementations.
     */
    @Nullable
    private static Set<Index> readIndices(SupportSQLiteDatabase database, String tableName) {
        Cursor cursor = database.query("PRAGMA index_list(`" + tableName + "`)");
        try {
            final int nameColumnIndex = cursor.getColumnIndex("name");
            final int originColumnIndex = cursor.getColumnIndex("origin");
            final int uniqueIndex = cursor.getColumnIndex("unique");
            if (nameColumnIndex == -1 || originColumnIndex == -1 || uniqueIndex == -1) {
                // we cannot read them so better not validate any index.
                return null;
            }
            HashSet<Index> indices = new HashSet<>();
            while (cursor.moveToNext()) {
                String origin = cursor.getString(originColumnIndex);
                if (!"c".equals(origin)) {
                    // Ignore auto-created indices
                    continue;
                }
                String name = cursor.getString(nameColumnIndex);
                boolean unique = cursor.getInt(uniqueIndex) == 1;
                Index index = readIndex(database, name, unique);
                if (index == null) {
                    // we cannot read it properly so better not read it
                    return null;
                }
                indices.add(index);
            }
            return indices;
        } finally {
            cursor.close();
        }
    }

    /**
     * @return null if we cannot read the index due to older sqlite implementations.
     */
    @Nullable
    private static Index readIndex(SupportSQLiteDatabase database, String name, boolean unique) {
        Cursor cursor = database.query("PRAGMA index_xinfo(`" + name + "`)");
        try {
            final int seqnoColumnIndex = cursor.getColumnIndex("seqno");
            final int cidColumnIndex = cursor.getColumnIndex("cid");
            final int nameColumnIndex = cursor.getColumnIndex("name");
            if (seqnoColumnIndex == -1 || cidColumnIndex == -1 || nameColumnIndex == -1) {
                // we cannot read them so better not validate any index.
                return null;
            }
            final TreeMap<Integer, String> results = new TreeMap<>();

            while (cursor.moveToNext()) {
                int cid = cursor.getInt(cidColumnIndex);
                if (cid < 0) {
                    // Ignore SQLite row ID
                    continue;
                }
                int seq = cursor.getInt(seqnoColumnIndex);
                String columnName = cursor.getString(nameColumnIndex);
                results.put(seq, columnName);
            }
            final List<String> columns = new ArrayList<>(results.size());
            columns.addAll(results.values());
            return new Index(name, unique, columns);
        } finally {
            cursor.close();
        }
    }

    /**
     * Holds the information about a database column.
     */
    @SuppressWarnings("WeakerAccess")
    public static class Column {
        /**
         * The column name.
         */
        public final String name;
        /**
         * The column type affinity.
         */
        public final String type;
        /**
         * The column type after it is normalized to one of the basic types according to
         * https://www.sqlite.org/datatype3.html Section 3.1.
         * <p>
         * This is the value Room uses for equality check.
         */
        @ColumnInfo.SQLiteTypeAffinity
        public final int affinity;
        /**
         * Whether or not the column can be NULL.
         */
        public final boolean notNull;
        /**
         * The position of the column in the list of primary keys, 0 if the column is not part
         * of the primary key.
         * <p>
         * This information is only available in API 20+.
         * <a href="https://www.sqlite.org/releaselog/3_7_16_2.html">(SQLite version 3.7.16.2)</a>
         * On older platforms, it will be 1 if the column is part of the primary key and 0
         * otherwise.
         * <p>
         * The {@link #equals(Object)} implementation handles this inconsistency based on
         * API levels os if you are using a custom SQLite deployment, it may return false
         * positives.
         */
        public final int primaryKeyPosition;
        /**
         * The default value of this column.
         */
        public final String defaultValue;

        @CreatedFrom
        private final int mCreatedFrom;

        /**
         * @deprecated Use {@link Column#Column(String, String, boolean, int, String, int)} instead.
         */
        @Deprecated
        public Column(String name, String type, boolean notNull, int primaryKeyPosition) {
            this(name, type, notNull, primaryKeyPosition, null, CREATED_FROM_UNKNOWN);
        }

        // if you change this constructor, you must change TableInfoWriter.kt
        public Column(String name, String type, boolean notNull, int primaryKeyPosition,
                String defaultValue, @CreatedFrom int createdFrom) {
            this.name = name;
            this.type = type;
            this.notNull = notNull;
            this.primaryKeyPosition = primaryKeyPosition;
            this.affinity = findAffinity(type);
            this.defaultValue = defaultValue;
            this.mCreatedFrom = createdFrom;
        }

        /**
         * Implements https://www.sqlite.org/datatype3.html section 3.1
         *
         * @param type The type that was given to the sqlite
         * @return The normalized type which is one of the 5 known affinities
         */
        @ColumnInfo.SQLiteTypeAffinity
        private static int findAffinity(@Nullable String type) {
            if (type == null) {
                return ColumnInfo.BLOB;
            }
            String uppercaseType = type.toUpperCase(Locale.US);
            if (uppercaseType.contains("INT")) {
                return ColumnInfo.INTEGER;
            }
            if (uppercaseType.contains("CHAR")
                    || uppercaseType.contains("CLOB")
                    || uppercaseType.contains("TEXT")) {
                return ColumnInfo.TEXT;
            }
            if (uppercaseType.contains("BLOB")) {
                return ColumnInfo.BLOB;
            }
            if (uppercaseType.contains("REAL")
                    || uppercaseType.contains("FLOA")
                    || uppercaseType.contains("DOUB")) {
                return ColumnInfo.REAL;
            }
            // sqlite returns NUMERIC here but it is like a catch all. We already
            // have UNDEFINED so it is better to use UNDEFINED for consistency.
            return ColumnInfo.UNDEFINED;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            Column column = (Column) o;
            if (Build.VERSION.SDK_INT >= 20) {
                if (primaryKeyPosition != column.primaryKeyPosition) return false;
            } else {
                if (isPrimaryKey() != column.isPrimaryKey()) return false;
            }

            if (!name.equals(column.name)) return false;
            //noinspection SimplifiableIfStatement
            if (notNull != column.notNull) return false;

            // Only validate default value if it was defined in an entity, i.e. if the info
            // from the compiler itself has it. b/136019383
            if (mCreatedFrom == CREATED_FROM_ENTITY
                    && column.mCreatedFrom == CREATED_FROM_DATABASE
                    && (defaultValue != null && !defaultValue.equals(column.defaultValue))) {
                return false;
            } else if (mCreatedFrom == CREATED_FROM_DATABASE
                    && column.mCreatedFrom == CREATED_FROM_ENTITY
                    && (column.defaultValue != null && !column.defaultValue.equals(defaultValue))) {
                return false;
            } else if (mCreatedFrom != CREATED_FROM_UNKNOWN
                    && mCreatedFrom == column.mCreatedFrom
                    && (defaultValue != null ? !defaultValue.equals(column.defaultValue)
                    : column.defaultValue != null)) {
                return false;
            }

            return affinity == column.affinity;
        }

        /**
         * Returns whether this column is part of the primary key or not.
         *
         * @return True if this column is part of the primary key, false otherwise.
         */
        public boolean isPrimaryKey() {
            return primaryKeyPosition > 0;
        }

        @Override
        public int hashCode() {
            int result = name.hashCode();
            result = 31 * result + affinity;
            result = 31 * result + (notNull ? 1231 : 1237);
            result = 31 * result + primaryKeyPosition;
            // Default value is not part of the hashcode since we conditionally check it for
            // equality which would break the equals + hashcode contract.
            // result = 31 * result + (defaultValue != null ? defaultValue.hashCode() : 0);
            return result;
        }

        @Override
        public String toString() {
            return "Column{"
                    + "name='" + name + '\''
                    + ", type='" + type + '\''
                    + ", affinity='" + affinity + '\''
                    + ", notNull=" + notNull
                    + ", primaryKeyPosition=" + primaryKeyPosition
                    + ", defaultValue='" + defaultValue + '\''
                    + '}';
        }
    }

    /**
     * Holds the information about an SQLite foreign key
     *
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public static class ForeignKey {
        @NonNull
        public final String referenceTable;
        @NonNull
        public final String onDelete;
        @NonNull
        public final String onUpdate;
        @NonNull
        public final List<String> columnNames;
        @NonNull
        public final List<String> referenceColumnNames;

        public ForeignKey(@NonNull String referenceTable, @NonNull String onDelete,
                @NonNull String onUpdate,
                @NonNull List<String> columnNames, @NonNull List<String> referenceColumnNames) {
            this.referenceTable = referenceTable;
            this.onDelete = onDelete;
            this.onUpdate = onUpdate;
            this.columnNames = Collections.unmodifiableList(columnNames);
            this.referenceColumnNames = Collections.unmodifiableList(referenceColumnNames);
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            ForeignKey that = (ForeignKey) o;

            if (!referenceTable.equals(that.referenceTable)) return false;
            if (!onDelete.equals(that.onDelete)) return false;
            if (!onUpdate.equals(that.onUpdate)) return false;
            //noinspection SimplifiableIfStatement
            if (!columnNames.equals(that.columnNames)) return false;
            return referenceColumnNames.equals(that.referenceColumnNames);
        }

        @Override
        public int hashCode() {
            int result = referenceTable.hashCode();
            result = 31 * result + onDelete.hashCode();
            result = 31 * result + onUpdate.hashCode();
            result = 31 * result + columnNames.hashCode();
            result = 31 * result + referenceColumnNames.hashCode();
            return result;
        }

        @Override
        public String toString() {
            return "ForeignKey{"
                    + "referenceTable='" + referenceTable + '\''
                    + ", onDelete='" + onDelete + '\''
                    + ", onUpdate='" + onUpdate + '\''
                    + ", columnNames=" + columnNames
                    + ", referenceColumnNames=" + referenceColumnNames
                    + '}';
        }
    }

    /**
     * Temporary data holder for a foreign key row in the pragma result. We need this to ensure
     * sorting in the generated foreign key object.
     *
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    static class ForeignKeyWithSequence implements Comparable<ForeignKeyWithSequence> {
        final int mId;
        final int mSequence;
        final String mFrom;
        final String mTo;

        ForeignKeyWithSequence(int id, int sequence, String from, String to) {
            mId = id;
            mSequence = sequence;
            mFrom = from;
            mTo = to;
        }

        @Override
        public int compareTo(@NonNull ForeignKeyWithSequence o) {
            final int idCmp = mId - o.mId;
            if (idCmp == 0) {
                return mSequence - o.mSequence;
            } else {
                return idCmp;
            }
        }
    }

    /**
     * Holds the information about an SQLite index
     *
     * @hide
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
    public static class Index {
        // should match the value in Index.kt
        public static final String DEFAULT_PREFIX = "index_";
        public final String name;
        public final boolean unique;
        public final List<String> columns;

        public Index(String name, boolean unique, List<String> columns) {
            this.name = name;
            this.unique = unique;
            this.columns = columns;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;

            Index index = (Index) o;
            if (unique != index.unique) {
                return false;
            }
            if (!columns.equals(index.columns)) {
                return false;
            }
            if (name.startsWith(Index.DEFAULT_PREFIX)) {
                return index.name.startsWith(Index.DEFAULT_PREFIX);
            } else {
                return name.equals(index.name);
            }
        }

        @Override
        public int hashCode() {
            int result;
            if (name.startsWith(DEFAULT_PREFIX)) {
                result = DEFAULT_PREFIX.hashCode();
            } else {
                result = name.hashCode();
            }
            result = 31 * result + (unique ? 1 : 0);
            result = 31 * result + columns.hashCode();
            return result;
        }

        @Override
        public String toString() {
            return "Index{"
                    + "name='" + name + '\''
                    + ", unique=" + unique
                    + ", columns=" + columns
                    + '}';
        }
    }
}
