/*
 * 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.referencing.factory;

import java.util.Map;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Collection;
import java.util.AbstractSet;
import java.util.LinkedHashMap;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import org.opengis.util.FactoryException;
import org.opengis.util.NoSuchIdentifierException;
import org.opengis.metadata.Identifier;
import org.opengis.metadata.citation.Citation;
import org.opengis.referencing.AuthorityFactory;
import org.opengis.referencing.IdentifiedObject;
import org.opengis.referencing.NoSuchAuthorityCodeException;
import org.apache.sis.referencing.IdentifiedObjects;
import org.apache.sis.util.Localized;
import org.apache.sis.util.Classes;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.util.resources.Messages;
import org.apache.sis.util.collection.BackingStoreException;
import org.apache.sis.util.collection.CheckedContainer;


/**
 * A lazy set of {@code IdentifiedObject} instances created from their authority codes only when first needed.
 * This set delegates {@link IdentifiedObject} creation to the most appropriate {@code createFoo(String)} method
 * of the {@link AuthorityFactory} given at construction time.
 *
 * <p>Elements can be added to this collection with calls to {@link #addAuthorityCode(String)} for deferred
 * {@linkplain #createObject(String) object creation}, or to {@link #add(IdentifiedObject)} for objects
 * that are already instantiated. This collection cannot contain two {@code IdentifiedObject} instances
 * having the same identifier. However, the identifiers used by this class can be controlled by overriding
 * the {@link #getAuthorityCode(IdentifiedObject)} method.</p>
 *
 * <p>Iterations over elements in this collection preserve insertion order.</p>
 *
 * <h2>Purpose</h2>
 * {@code IdentifiedObjectSet} is useful mostly for implementers of authority factories rather than users.
 * When used as the implementation of the
 * {@linkplain GeodeticAuthorityFactory#createFromCoordinateReferenceSystemCodes(String, String)
 * set of coordinate operations between a pair of <abbr>CRS</abbr>}, {@code IdentifiedObjectSet}
 * can improve significantly the performances because some sets contain tens of entries
 * (e.g., transformations from <q>ED50</q> (EPSG:4230) to <q>WGS 84</q> (EPSG:4326)),
 * while users typically want to look for only the first entry.
 *
 * <h2>Exception handling</h2>
 * If the underlying factory failed to creates an object because of an unsupported operation method
 * ({@link NoSuchIdentifierException}), the exception is logged at {@link Level#WARNING} and the iteration continues.
 * If the operation creation failed for any other kind of reason ({@link FactoryException}), then the exception is
 * re-thrown as an unchecked {@link BackingStoreException}. This default behavior can be changed by overriding
 * the {@link #isRecoverableFailure(FactoryException)} method.
 *
 * <h2>Thread safety</h2>
 * This class is thread-safe if the underlying {@linkplain #factory} is also thread-safe.
 * However, implementers are encouraged to wrap in {@linkplain java.util.Collections#unmodifiableSet unmodifiable set}
 * if they intent to cache {@code IdentifiedObjectSet} instances.
 *
 * @author  Martin Desruisseaux (IRD, Geomatys)
 * @version 1.5
 *
 * @param <T>  the type of objects to be included in this set.
 *
 * @since 0.7
 */
public class IdentifiedObjectSet<T extends IdentifiedObject> extends AbstractSet<T> implements CheckedContainer<T>, Localized {
    /**
     * The map of object codes (keys), and the actual identified objects (values) when they have been created.
     * Each entry has a null value until the corresponding object is created.
     *
     * <p><b>Note:</b> using {@code ConcurrentHahMap} would be more efficient.
     * But the latter does not support null values and does not preserve insertion order.</p>
     */
    final Map<String,T> objects;

    /**
     * The factory to use for creating {@code IdentifiedObject}s when first needed.
     * This is the authority factory given at construction time.
     */
    protected final AuthorityFactory factory;

    /**
     * A proxy to the most specific {@linkplain #factory} method to invoke
     * for the {@linkplain #type} of objects included in this set.
     */
    private final AuthorityFactoryProxy<? super T> proxy;

    /**
     * The type of objects included in this set.
     *
     * @see #getElementType()
     */
    private final Class<T> type;

    /**
     * Creates an initially empty set. The set can be populated after construction by calls
     * to {@link #addAuthorityCode(String)} for deferred {@code IdentifiedObject} creation,
     * or to {@link #add(IdentifiedObject)} for already instantiated objects.
     *
     * @param  factory  the factory to use for deferred {@code IdentifiedObject} instances creation.
     * @param  type     the type of objects included in this set.
     */
    public IdentifiedObjectSet(final AuthorityFactory factory, final Class<T> type) {
        this.factory = Objects.requireNonNull(factory);
        this.type    = Objects.requireNonNull(type);
        this.proxy   = AuthorityFactoryProxy.getInstance(type);
        this.objects = new LinkedHashMap<>();
    }

    /**
     * Returns the locale to use for error messages and warnings.
     * The default implementation inherits the {@link #factory} locale, if any.
     *
     * @return the locale, or {@code null} if not explicitly defined.
     */
    @Override
    public Locale getLocale() {
        return (factory instanceof Localized) ? ((Localized) factory).getLocale() : null;
    }

    /**
     * Returns the type of {@code IdentifiedObject} included in this set.
     *
     * @return the type of {@code IdentifiedObject} included in this set.
     */
    @Override
    public Class<T> getElementType() {
        return type;
    }

    /**
     * Removes all of the elements from this collection.
     */
    @Override
    public void clear() {
        synchronized (objects) {
            objects.clear();
        }
    }

    /**
     * Returns the number of objects available in this set. Note that this number may decrease
     * during the iteration process if the creation of some {@code IdentifiedObject}s failed.
     *
     * @return the number of objects available in this set.
     */
    @Override
    public int size() {
        synchronized (objects) {
            return objects.size();
        }
    }

    /**
     * Returns the authority codes of all {@code IdentifiedObject}s contained in this collection, in insertion order.
     * This method does not trig the {@linkplain #createObject(String) creation} of any object.
     *
     * @return the authority codes in iteration order.
     */
    public String[] getAuthorityCodes() {
        synchronized (objects) {
            return objects.keySet().toArray(String[]::new);
        }
    }

    /**
     * Sets the content of this collection to the object identified by the given codes.
     * For any code in the given sequence, this method will preserve the corresponding {@code IdentifiedObject}
     * instance if it was already created. Otherwise, objects will be {@linkplain #createObject(String) created}
     * only when first needed.
     *
     * <h4>Use case</h4>
     * This method is typically used together with {@link #getAuthorityCodes()} for altering the iteration order
     * on the basis of authority codes. If the specified {@code codes} sequence contains the same elements as
     * the ones in the array returned by {@link #getAuthorityCodes()} but in a different order, then this method
     * just sets the new ordering.
     *
     * @param  codes the authority codes of identified objects to store in this set.
     *
     * @see #addAuthorityCode(String)
     */
    public void setAuthorityCodes(final String... codes) {
        synchronized (objects) {
            final var copy = new HashMap<String,T>(objects);
            objects.clear();
            for (final String code : codes) {
                objects.put(code, copy.get(code));
            }
        }
    }

    /**
     * Ensures that this collection contains an object for the specified authority code.
     * If this collection does not contain any element for the given code, then this method
     * will instantiate an {@code IdentifiedObject} for the given code only when first needed.
     * Otherwise, this collection is unchanged.
     *
     * @param  code  the code authority code of the {@code IdentifiedObject} to include in this set.
     */
    public void addAuthorityCode(final String code) {
        synchronized (objects) {
            objects.putIfAbsent(code, null);
        }
    }

    /**
     * Ensures that this collection contains the specified object. This collection does not allow
     * multiple objects for the same {@linkplain #getAuthorityCode(IdentifiedObject) authority code}.
     * If this collection already contains an object using the same authority code as the given object,
     * then the old object is replaced by the new one regardless of whether the objects themselves are equal or not.
     *
     * @param  object  the object to add to the set.
     * @return {@code true} if this set changed as a result of this call.
     *
     * @see #getAuthorityCode(IdentifiedObject)
     */
    @Override
    public boolean add(final T object) {
        final String code = getAuthorityCode(object);
        final T previous;
        synchronized (objects) {
            previous = objects.put(code, object);
        }
        return !Objects.equals(previous, object);
    }

    /**
     * Returns the identified object for the specified value, creating it if needed.
     *
     * @throws BackingStoreException if the object creation failed.
     *
     * @see #createObject(String)
     */
    private T get(final String code) throws BackingStoreException {
        T object;
        boolean success;
        synchronized (objects) {
            object = objects.get(code);
            success = (object != null || !objects.containsKey(code));
        }
        /*
         * If we need to create the object, it should be done outside synchronized block.
         * There is a risk that the same object is created twice in concurrent threads.
         * If this happen, we will discard the duplicated value.
         */
        if (!success) {
            try {
                object = createObject(code);
                success = true;                         // Shall be set only after above line succeed.
            } catch (FactoryException exception) {
                if (!isRecoverableFailure(exception)) {
                    throw new BackingStoreException(exception);
                }
                final LogRecord record = Messages.forLocale(getLocale()).createLogRecord(Level.WARNING,
                        Messages.Keys.CanNotInstantiateForIdentifier_3, type, code, getCause(exception));
                Logging.completeAndLog(GeodeticAuthorityFactory.LOGGER, IdentifiedObjectSet.class, "createObject", record);
            }
            synchronized (objects) {
                if (success) {
                    /*
                     * The check for `containsKey` is a paranoiac check in case the element has been removed
                     * in another thread while we were creating the object. This is likely to be unnecessary
                     * in the vast majority of cases where the set of codes is never modified after this set
                     * has been published. However, if someone decided to do such concurrent modifications,
                     * not checking for concurrent removal could be a subtle and hard-to-find bug, so we are
                     * better to be safe. Note that if a concurrent removal happened, we still return the non-null
                     * object but we do not put it in this IdentifiedObjectSet. This behavior is as if this method
                     * has been invoked before the concurrent removal happened.
                     */
                    if (objects.containsKey(code)) {        // Needed because code may be associated to null value.
                        final T c = objects.putIfAbsent(code, object);
                        if (c != null) {
                            object = c;                     // The object has been created concurrently.
                        }
                    }
                } else {                                    // Do not remove if a concurrent thread succeeded.
                    objects.remove(code, null);
                }
            }
        }
        return object;
    }

    /**
     * Returns {@code true} if this collection contains the specified {@code IdentifiedObject}.
     *
     * @param  object  the {@code IdentifiedObject} to test for presence in this set.
     * @return {@code true} if the given object is presents in this set.
     * @throws ClassCastException if the given object is non-null and not an instance of {@code <T>}.
     */
    @Override
    public boolean contains(final Object object) {
        return (object != null) && object.equals(get(getAuthorityCode(type.cast(object))));
    }

    /**
     * Removes the object for the given code.
     *
     * @param  code  the code of the object to remove.
     */
    private void removeAuthorityCode(final String code) {
        synchronized (objects) {
            objects.remove(code);
        }
    }

    /**
     * Removes the specified {@code IdentifiedObject} from this collection, if it is present.
     *
     * @param  object  the {@code IdentifiedObject} to remove from this set.
     * @return {@code true} if this set changed as a result of this call.
     * @throws ClassCastException if the given object is non-null and not an instance of {@code <T>}.
     */
    @Override
    public boolean remove(final Object object) {
        if (object != null) {
            final String code = getAuthorityCode(type.cast(object));
            final T current = get(code);
            if (object.equals(current)) {
                synchronized (objects) {
                    objects.remove(code);
                }
                return true;
            }
        }
        return false;
    }

    /**
     * Removes from this collection all of its elements that are contained in the specified collection.
     *
     * @param  collection  the {@code IdentifiedObject}s to remove from this set.
     * @return {@code true} if this set changed as a result of this call.
     * @throws ClassCastException if the given collection contains an object which is not an instance of {@code <T>}.
     */
    @Override
    @SuppressWarnings("element-type-mismatch")
    public boolean removeAll(final Collection<?> collection) {
        boolean modified = false;
        for (final Object object : collection) {
            modified |= remove(object);
        }
        return modified;
    }

    /**
     * Returns an iterator over the objects in this set. If the iteration encounter any kind of
     * {@link FactoryException} other than {@link NoSuchIdentifierException}, then the exception
     * will be re-thrown as an unchecked {@link BackingStoreException}.
     *
     * <p>This iterator is <strong>not</strong> thread safe – iteration should be done in a single thread.
     * However, the iterator is robust to concurrent changes in {@code IdentifiedObjectSet} during iteration.</p>
     *
     * @return an iterator over all {@code IdentifiedObject} instances in this set, in insertion order.
     * @throws BackingStoreException if an error occurred while creating the iterator.
     */
    @Override
    public Iterator<T> iterator() throws BackingStoreException {
        return new Iterator<T>() {
            /**
             * The keys from the underlying map, as a snapshot taken at the time the iterator has been created.
             * We need to take a snapshot because the underlying {@link IdentifiedObjectSet#objects} map may be
             * modified concurrently in other threads.
             */
            private final String[] keys = getAuthorityCodes();

            /**
             * Index of the next element to obtain by a call to {@link IdentifiedObjectSet#get(String)}.
             */
            private int index;

            /**
             * The next element to return, or {@code null} if not yet determined.
             */
            private T next;

            /**
             * {@code true} if {@link #remove()} can remove the element identified by {@code keys[index - 1]}.
             */
            private boolean canRemove;

            /**
             * Returns {@code true} if there is more elements.
             *
             * @throws BackingStoreException if the underlying factory failed to creates the {@code IdentifiedObject}.
             */
            @Override
            public boolean hasNext() throws BackingStoreException {
                while (next == null) {
                    if (index >= keys.length) {
                        return false;
                    }
                    next = get(keys[index++]);
                }
                return true;
            }

            /**
             * Returns the next element.
             *
             * @throws NoSuchElementException if there is no more {@code IdentifiedObject} to iterate.
             */
            @Override
            public T next() throws NoSuchElementException {
                if (canRemove = hasNext()) {
                    final T e = next;
                    next = null;
                    return e;
                }
                throw new NoSuchElementException();
            }

            /**
             * Removes the previous element from the underlying set.
             */
            @Override
            public void remove() {
                if (!canRemove) {
                    throw new IllegalStateException();
                }
                removeAuthorityCode(keys[index - 1]);
                canRemove = false;
            }
        };
    }

    /**
     * Ensures that the <var>n</var> first objects in this set are created. This method can be invoked for
     * making sure that the underlying {@linkplain #factory} is really capable to create at least one object.
     * {@link FactoryException} (except the ones accepted as {@linkplain #isRecoverableFailure recoverable failures})
     * are thrown as if they were never wrapped into {@link BackingStoreException}.
     *
     * @param  n  the number of object to resolve. If this number is equal or greater than {@link #size()}, then
     *            this method ensures that all {@code IdentifiedObject} instances in this collection are created.
     * @throws FactoryException if an {@linkplain #createObject(String) object creation} failed.
     */
    public void resolve(int n) throws FactoryException {
        if (n > 0) try {
            for (final Iterator<T> it=iterator(); it.hasNext(); it.next()) {
                if (--n == 0) {
                    break;
                }
            }
        } catch (BackingStoreException exception) {
            throw exception.unwrapOrRethrow(FactoryException.class);
        }
    }

    /**
     * Returns the identifier for the specified object.
     * The default implementation takes the first of the following identifier which is found:
     *
     * <ol>
     *   <li>An identifier allocated by the authority given by
     *       <code>{@linkplain #factory}.{@linkplain GeodeticAuthorityFactory#getAuthority() getAuthority()}</code>.</li>
     *   <li>The first {@linkplain org.apache.sis.referencing.AbstractIdentifiedObject#getIdentifiers() object identifier},
     *       regardless its authority.</li>
     *   <li>The first {@linkplain org.apache.sis.referencing.AbstractIdentifiedObject#getName() object name},
     *       regardless its authority.</li>
     * </ol>
     *
     * Subclasses may override this method if they want to use a different identifiers.
     *
     * @param  object  the object for which to get the authority code.
     * @return the authority code of the given identified object.
     * @throws BackingStoreException if an error occurred while fetching the authority code.
     */
    protected String getAuthorityCode(final T object) {
        final Citation authority = factory.getAuthority();
        final Identifier id = IdentifiedObjects.getIdentifier(object, authority);
        return (id != null) ? id.getCode() : IdentifiedObjects.getIdentifierOrName(object);
    }

    /**
     * Creates an object for the specified authority code.
     * This method is invoked during the iteration process if an object was not already created.
     *
     * @param  code  the code for which to create the identified object.
     * @return the identified object created from the given code.
     * @throws FactoryException if the object creation failed.
     * @throws ClassCastException if the object created from the given object is not an instance of {@code <T>}.
     */
    protected T createObject(final String code) throws FactoryException {
        return type.cast(proxy.createFromAPI(factory, code));
    }

    /**
     * Returns {@code true} if the specified exception should be handled as a recoverable failure.
     * This method is invoked during the iteration process if the factory failed to create some objects.
     * If this method returns {@code true} for the given exception, then the exception will be logged
     * at {@link Level#WARNING}. If this method returns {@code false}, then the exception will be re-thrown
     * as a {@link BackingStoreException}.
     *
     * <p>The default implementation applies the following rules:</p>
     * <ul>
     *   <li>If {@link NoSuchAuthorityCodeException}, returns {@code false} since failure to find a code declared
     *       in the collection would be an inconsistency. Note that this exception is a subtype of
     *       {@code NoSuchIdentifierException}, so it must be tested before the last case below.</li>
     *   <li>If {@link NoSuchIdentifierException}, returns {@code true} because this exception is caused
     *       by an attempt to create a parameterized transform for an unimplemented operation.</li>
     *   <li>If {@link MissingFactoryResourceException}, returns {@code true}.</li>
     *   <li>Otherwise returns {@code false}.</li>
     * </ul>
     *
     * @param  exception  the exception that occurred while creating an object.
     * @return {@code true} if the given exception should be considered recoverable,
     *         or {@code false} if it should be considered fatal.
     */
    protected boolean isRecoverableFailure(final FactoryException exception) {
        if (exception instanceof NoSuchIdentifierException) {
            return !(exception instanceof NoSuchAuthorityCodeException);
        }
        return (exception instanceof MissingFactoryResourceException);
    }

    /**
     * Returns the message to format below the logging for giving the cause of an error.
     */
    private static String getCause(Throwable cause) {
        final String lineSeparator = System.lineSeparator();
        final var trace = new StringBuilder(180);
        while (cause != null) {
            trace.append(lineSeparator).append("  • ").append(Classes.getShortClassName(cause));
            final String message = cause.getMessage();      // Prefer the local of system administrator.
            if (message != null) {
                trace.append(": ").append(message);
            }
            cause = cause.getCause();
        }
        return trace.toString();
    }
}
