/*
 * 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.sis.internal.sql.feature;

import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.HashMap;
import java.util.Collection;
import java.util.Spliterator;
import java.util.function.Consumer;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.Statement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.lang.reflect.Array;
import org.apache.sis.internal.metadata.sql.SQLBuilder;
import org.apache.sis.storage.InternalDataStoreException;
import org.apache.sis.util.collection.BackingStoreException;
import org.apache.sis.util.collection.WeakValueHashMap;
import org.apache.sis.util.ArraysExt;

// Branch-dependent imports
import org.apache.sis.feature.AbstractFeature;
import org.apache.sis.feature.DefaultFeatureType;


/**
 * Iterator over feature instances.
 *
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.0
 * @since   1.0
 * @module
 */
final class Features implements Spliterator<AbstractFeature>, Runnable {
    /**
     * An empty array of iterators, used when there is no dependency.
     */
    private static final Features[] EMPTY = new Features[0];

    /**
     * The type of features to create.
     */
    private final DefaultFeatureType featureType;

    /**
     * Name of attributes in feature instances, excluding operations and associations to other tables.
     * Those names are in the order of columns declared in the {@code SELECT <columns} statement.
     * This array is a shared instance and shall not be modified.
     */
    private final String[] attributeNames;

    /**
     * Name of the properties where are stored associations in feature instances.
     * The length of this array shall be equal to the {@link #dependencies} array length.
     * Imported or exported features read by {@code dependencies[i]} will be stored in
     * the association named {@code associationNames[i]}.
     */
    private final String[] associationNames;

    /**
     * Name of the property where to store the association that we can not handle with other {@link #dependencies}.
     * This deferred association may exist because of circular dependency.
     */
    private final String deferredAssociation;

    /**
     * The feature sets referenced through foreigner keys, or {@link #EMPTY} if none.
     * This includes the associations inferred from both the imported and exported keys.
     * The first {@link #importCount} iterators are for imported keys, and the remaining
     * iterators are for the exported keys.
     */
    private final Features[] dependencies;

    /**
     * Number of entries in {@link #dependencies} for {@link Relation.Direction#IMPORT}.
     * The entries immediately following the first {@code importCount} entries are for
     * {@link Relation.Direction#EXPORT}.
     */
    private final int importCount;

    /**
     * One-based indices of the columns to query for each {@link #dependencies} entry.
     */
    private final int[][] foreignerKeyIndices;

    /**
     * If this iterator returns only the feature matching some condition (typically a primary key value),
     * the statement for performing that filtering. Otherwise if this iterator returns all features, then
     * this field is {@code null}.
     */
    private final PreparedStatement statement;

    /**
     * The result of executing the SQL query for a {@link Table}. If {@link #statement} is null,
     * then a single {@code ResultSet} is used for all the lifetime of this {@code Features} instance.
     * Otherwise an arbitrary amount of {@code ResultSet}s may be created from the statement.
     */
    private ResultSet result;

    /**
     * Feature instances already created, or {@code null} if the features created by this iterator are not cached.
     * This map is used when requesting a feature by identifier, not when iterating over all features (note: we
     * could perform an opportunistic check in a future SIS version). The same map may be shared by all iterators
     * on the same {@link Table}, but {@link WeakValueHashMap} already provides the required synchronizations.
     *
     * <p>This {@code Features} class does not require the identifiers to be built from primary key columns.
     * However if this map has been provided by {@link Table#instanceForPrimaryKeys()}, then the identifiers
     * need to be primary keys with columns in the exact same order for allowing the same map to be shared.</p>
     */
    private final WeakValueHashMap<?,Object> instances;

    /**
     * The component class of the keys in the {@link #instances} map, or {@code null} if the keys are not array.
     * For example if a primary key is made of two columns of type {@code String}, then this field may be set to
     * {@code String}.
     */
    private final Class<?> keyComponentClass;

    /**
     * Estimated number of rows, or {@literal <= 0} if unknown.
     */
    private final long estimatedSize;

    /**
     * Creates a new iterator over the feature instances.
     *
     * @param table             the table for which we are creating an iterator.
     * @param connection        connection to the database.
     * @param attributeNames    value of {@link Table#attributeNames}:   where to store simple values.
     * @param attributeColumns  value of {@link Table#attributeColumns}: often the same as attribute names.
     * @param importedKeys      value of {@link Table#importedKeys}:     targets of this table foreign keys.
     * @param exportedKeys      value of {@link Table#exportedKeys}:     foreigner keys of other tables.
     * @param following         the relations that we are following. Used for avoiding never ending loop.
     * @param noFollow          relation to not follow, or {@code null} if none.
     */
    Features(final Table table, final Connection connection, final String[] attributeNames, final String[] attributeColumns,
             final Relation[] importedKeys, final Relation[] exportedKeys, final List<Relation> following, final Relation noFollow)
             throws SQLException, InternalDataStoreException
    {
        this.featureType = table.featureType;
        this.attributeNames = attributeNames;
        final DatabaseMetaData metadata = connection.getMetaData();
        estimatedSize = following.isEmpty() ? table.countRows(metadata, true) : 0;
        final SQLBuilder sql = new SQLBuilder(metadata, true).append("SELECT");
        final Map<String,Integer> columnIndices = new HashMap<>();
        /*
         * Create a SELECT clause with all columns that are ordinary attributes.
         * Order matter, since 'Features' iterator will map the columns to the
         * attributes listed in the 'attributeNames' array in that order.
         */
        for (String column : attributeColumns) {
            appendColumn(sql, column, columnIndices);
        }
        /*
         * Collect information about associations in local arrays before to assign
         * them to the final fields, because some array lengths may be adjusted.
         */
        int importCount = (importedKeys != null) ? importedKeys.length : 0;
        int exportCount = (exportedKeys != null) ? exportedKeys.length : 0;
        int totalCount  = importCount + exportCount;
        if (totalCount == 0) {
            dependencies        = EMPTY;
            associationNames    = null;
            foreignerKeyIndices = null;
            deferredAssociation = null;
        } else {
            String deferredAssociation = null;
            final Features[]     dependencies = new Features[totalCount];
            final String[]   associationNames = new String  [totalCount];
            final int[][] foreignerKeyIndices = new int     [totalCount][];
            /*
             * For each foreigner key to another table, append all columns of that foreigner key
             * and the name of the single feature property where the association will be stored.
             */
            if (importCount != 0) {
                importCount = 0;                                                    // We will recount.
                for (final Relation dependency : importedKeys) {
                    if (dependency != noFollow) {
                        dependency.startFollowing(following);                       // Safety against never-ending recursivity.
                        associationNames   [importCount] = dependency.propertyName;
                        foreignerKeyIndices[importCount] = getColumnIndices(sql, dependency.getForeignerKeys(), columnIndices);
                        dependencies       [importCount] = dependency.getSearchTable().features(connection, following, noFollow);
                        dependency.endFollowing(following);
                        importCount++;
                    } else {
                        deferredAssociation = dependency.propertyName;
                    }
                }
            }
            /*
             * Create iterators for other tables that reference the primary keys of this table. For example
             * if we have a "City" feature with attributes for the city name, population, etc. and a "Parks"
             * feature referencing the city where the park is located, in order to populate the "City.parks"
             * associations we need to iterate over all "Parks" rows referencing the city.
             */
            if (exportCount != 0) {
                int i = importCount;
                for (final Relation dependency : exportedKeys) {
                    dependency.startFollowing(following);                   // Safety against never-ending recursivity.
                    final Table foreigner  = dependency.getSearchTable();
                    final Relation inverse = foreigner.getInverseOf(dependency, table.name);
                    associationNames   [i] = dependency.propertyName;
                    foreignerKeyIndices[i] = getColumnIndices(sql, dependency.getForeignerKeys(), columnIndices);
                    dependencies       [i] = foreigner.features(connection, following, inverse);
                    dependency.endFollowing(following);
                    i++;
                }
            }
            totalCount = importCount + exportCount;
            this.dependencies        = ArraysExt.resize(dependencies,        totalCount);
            this.associationNames    = ArraysExt.resize(associationNames,    totalCount);
            this.foreignerKeyIndices = ArraysExt.resize(foreignerKeyIndices, totalCount);
            this.deferredAssociation = deferredAssociation;
        }
        this.importCount = importCount;
        /*
         * Create a Statement if we don't need any condition, or a PreparedStatement if we need to add
         * a "WHERE" clause. In the later case, we will cache the features already created if there is
         * a possibility that many rows reference the same feature instance.
         */
        sql.append(" FROM ").appendIdentifier(table.name.catalog, table.name.schema, table.name.table);
        if (following.isEmpty()) {
            statement = null;
            instances = null;       // A future SIS version could use the map opportunistically if it exists.
            keyComponentClass = null;
            result = connection.createStatement().executeQuery(sql.toString());
        } else {
            final Relation componentOf = following.get(following.size() - 1);
            String separator = " WHERE ";
            for (String primaryKey : componentOf.getSearchColumns()) {
                sql.append(separator).appendIdentifier(primaryKey).append("=?");
                separator = " AND ";
            }
            statement = connection.prepareStatement(sql.toString());
            /*
             * Following assumes that the foreigner key references the primary key of this table,
             * in which case 'table.primaryKeyClass' should never be null. This assumption may not
             * hold if the relation has been defined by DatabaseMetaData.getCrossReference(…) instead.
             */
            if (componentOf.useFullKey()) {
                instances = table.instanceForPrimaryKeys();
                keyComponentClass = table.primaryKeyClass.getComponentType();
            } else {
                instances = new WeakValueHashMap<>(Object.class);       // Can not share the table cache.
                keyComponentClass = Object.class;
            }
        }
    }

    /**
     * Appends a columns in the given builder and remember the column indices.
     * An exception is thrown if the column has already been added (should never happen).
     */
    private static int appendColumn(final SQLBuilder sql, final String column,
            final Map<String,Integer> columnIndices) throws InternalDataStoreException
    {
        int columnCount = columnIndices.size();
        if (columnCount != 0) sql.append(',');
        sql.append(' ').appendIdentifier(column);
        if (columnIndices.put(column, ++columnCount) == null) return columnCount;
        throw new InternalDataStoreException(Resources.format(Resources.Keys.DuplicatedColumn_1, column));
    }

    /**
     * Computes the 1-based indices of given columns, adding the columns in the given builder if necessary.
     */
    private static int[] getColumnIndices(final SQLBuilder sql, final Collection<String> columns,
            final Map<String,Integer> columnIndices) throws InternalDataStoreException
    {
        int i = 0;
        final int[] indices = new int[columns.size()];
        for (final String column : columns) {
            final Integer pos = columnIndices.get(column);
            indices[i++] = (pos != null) ? pos : appendColumn(sql, column, columnIndices);
        }
        return indices;
    }

    /**
     * Returns an array of the given length capable to hold the identifier,
     * or {@code null} if there is no need for an array.
     */
    private Object identifierArray(final int columnCount) {
        return (columnCount > 1) ? Array.newInstance(keyComponentClass, columnCount) : null;
    }

    /**
     * Declares that this iterator never returns {@code null} elements.
     */
    @Override
    public int characteristics() {
        return NONNULL;
    }

    /**
     * Returns the estimated number of features, or {@link Long#MAX_VALUE} if unknown.
     */
    @Override
    public long estimateSize() {
        return (estimatedSize > 0) ? estimatedSize : Long.MAX_VALUE;
    }

    /**
     * Current version does not support split.
     *
     * @return always {@code null}.
     */
    @Override
    public Spliterator<AbstractFeature> trySplit() {
        return null;
    }

    /**
     * Gives the next feature to the given consumer.
     */
    @Override
    public boolean tryAdvance(final Consumer<? super AbstractFeature> action) {
        try {
            return fetch(action, false);
        } catch (SQLException e) {
            throw new BackingStoreException(e);
        }
    }

    /**
     * Gives all remaining features to the given consumer.
     */
    @Override
    public void forEachRemaining(final Consumer<? super AbstractFeature> action) {
        try {
            fetch(action, true);
        } catch (SQLException e) {
            throw new BackingStoreException(e);
        }
    }

    /**
     * Gives at least the next feature to the given consumer.
     * Gives all remaining features if {@code all} is {@code true}.
     *
     * @param  action  the action to execute for each {@link AbstractFeature} instances fetched by this method.
     * @param  all     {@code true} for reading all remaining feature instances, or {@code false} for only the next one.
     * @return {@code true} if we have read an instance and {@code all} is {@code false} (so there is maybe other instances).
     */
    private boolean fetch(final Consumer<? super AbstractFeature> action, final boolean all) throws SQLException {
        while (result.next()) {
            final AbstractFeature feature = featureType.newInstance();
            for (int i=0; i < attributeNames.length; i++) {
                final Object value = result.getObject(i+1);
                if (!result.wasNull()) {
                    feature.setPropertyValue(attributeNames[i], value);
                }
            }
            for (int i=0; i < dependencies.length; i++) {
                final Features dependency = dependencies[i];
                final int[] columnIndices = foreignerKeyIndices[i];
                final Object value;
                if (i < importCount) {
                    /*
                     * Relation.Direction.IMPORT: this table contains the foreigner keys.
                     *
                     * If the foreigner key uses only one column, we will store the foreigner key value
                     * in the 'key' variable without creating array. But if the foreigner key uses more
                     * than one column, then we need to create an array holding all values.
                     */
                    Object key = null;
                    final Object keys = dependency.identifierArray(columnIndices.length);
                    for (int p=0; p < columnIndices.length;) {
                        key = result.getObject(columnIndices[p]);
                        if (keys != null) Array.set(keys, p, key);
                        dependency.statement.setObject(++p, key);
                    }
                    if (keys != null) key = keys;
                    value = dependency.fetchReferenced(key, null);
                } else {
                    /*
                     * Relation.Direction.EXPORT: another table references this table.
                     *
                     * 'key' must stay null because we do not cache those dependencies.
                     * The reason is that this direction can return a lot of instances,
                     * contrarily to Direction.IMPORT which return only one instance.
                     * Furthermore instances fetched from Direction.EXPORT can not be
                     * shared by feature instances, so caching would be useless here.
                     */
                    for (int p=0; p < columnIndices.length;) {
                        final Object k = result.getObject(columnIndices[p]);
                        dependency.statement.setObject(++p, k);
                    }
                    value = dependency.fetchReferenced(null, feature);
                }
                feature.setPropertyValue(associationNames[i], value);
            }
            action.accept(feature);
            if (!all) return true;
        }
        return false;
    }

    /**
     * Executes the current {@link #statement} and stores all features in a list.
     * Returns {@code null} if there is no feature, or returns the feature instance
     * if there is only one such instance, or returns a list of features otherwise.
     *
     * @param  key    the key to use for referencing the feature in the cache, or {@code null} for no caching.
     * @param  owner  if the features to fetch are components of another feature, that container feature instance.
     * @return the feature as a singleton {@code Feature} or as a {@code Collection<Feature>}.
     */
    private Object fetchReferenced(final Object key, final AbstractFeature owner) throws SQLException {
        if (key != null) {
            Object existing = instances.get(key);
            if (existing != null) {
                return existing;
            }
        }
        final List<AbstractFeature> features = new ArrayList<>();
        try (ResultSet r = statement.executeQuery()) {
            result = r;
            fetch(features::add, true);
        } finally {
            result = null;
        }
        if (owner != null && deferredAssociation != null) {
            for (final AbstractFeature feature : features) {
                feature.setPropertyValue(deferredAssociation, owner);
            }
        }
        Object feature;
        switch (features.size()) {
            case 0:  feature = null; break;
            case 1:  feature = features.get(0); break;
            default: feature = features; break;
        }
        if (key != null) {
            @SuppressWarnings("unchecked")          // Check is performed by putIfAbsent(…).
            final Object previous = ((WeakValueHashMap) instances).putIfAbsent(key, feature);
            if (previous != null) {
                feature = previous;
            }
        }
        return feature;
    }

    /**
     * Closes the (pooled) connection, including the statements of all dependencies.
     */
    private void close() throws SQLException {
        /*
         * Only one of 'statement' and 'result' should be non-null. The connection should be closed
         * by the 'Features' instance having a non-null 'result' because it is the main one created
         * by 'Table.features(boolean)' method. The other 'Features' instances are dependencies.
         */
        if (statement != null) {
            statement.close();
        }
        final ResultSet r = result;
        if (r != null) {
            result = null;
            final Statement s = r.getStatement();
            try (Connection c = s.getConnection()) {
                r.close();      // Implied by s.close() according JDBC javadoc, but we are paranoiac.
                s.close();
                for (final Features dependency : dependencies) {
                    dependency.close();
                }
            }
        }
    }

    /**
     * Closes the (pooled) connection, including the statements of all dependencies.
     * This is a handler to be invoked by {@link java.util.stream.Stream#close()}.
     */
    @Override
    public void run() {
        try {
            close();
        } catch (SQLException e) {
            throw new BackingStoreException(e);
        }
    }
}
