/*
 * 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.storage.geotiff;

import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.logging.LogRecord;
import java.net.URI;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import org.opengis.util.NameSpace;
import org.opengis.util.NameFactory;
import org.opengis.util.GenericName;
import org.opengis.metadata.Metadata;
import org.opengis.metadata.maintenance.ScopeCode;
import org.opengis.parameter.ParameterValueGroup;
import org.apache.sis.setup.OptionKey;
import org.apache.sis.storage.Aggregate;
import org.apache.sis.storage.GridCoverageResource;
import org.apache.sis.storage.DataStore;
import org.apache.sis.storage.DataStoreProvider;
import org.apache.sis.storage.StorageConnector;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.storage.DataStoreClosedException;
import org.apache.sis.storage.IllegalNameException;
import org.apache.sis.storage.event.StoreEvent;
import org.apache.sis.storage.event.StoreListener;
import org.apache.sis.storage.event.StoreListeners;
import org.apache.sis.storage.event.WarningEvent;
import org.apache.sis.io.stream.ChannelDataInput;
import org.apache.sis.io.stream.IOUtilities;
import org.apache.sis.storage.base.MetadataBuilder;
import org.apache.sis.storage.base.StoreUtilities;
import org.apache.sis.storage.base.URIDataStore;
import org.apache.sis.storage.geotiff.spi.SchemaModifier;
import org.apache.sis.util.ArgumentChecks;
import org.apache.sis.util.internal.Constants;
import org.apache.sis.util.internal.ListOfUnknownSize;
import org.apache.sis.metadata.iso.DefaultMetadata;
import org.apache.sis.metadata.sql.MetadataStoreException;
import org.apache.sis.util.collection.BackingStoreException;
import org.apache.sis.util.collection.TreeTable;
import org.apache.sis.util.iso.DefaultNameSpace;
import org.apache.sis.util.resources.Errors;


/**
 * A data store backed by GeoTIFF files.
 *
 * @author  Rémi Maréchal (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @author  Thi Phuong Hao Nguyen (VNSC)
 * @author  Alexis Manin (Geomatys)
 * @version 1.4
 * @since   0.8
 */
public class GeoTiffStore extends DataStore implements Aggregate {
    /**
     * The encoding of strings in the metadata. The TIFF specification said that is shall be US-ASCII,
     * but Apache SIS nevertheless let the user specifies an alternative encoding if needed.
     */
    final Charset encoding;

    /**
     * The GeoTIFF reader implementation, or {@code null} if the store has been closed.
     *
     * @see #reader()
     */
    private volatile Reader reader;

    /**
     * The {@link GeoTiffStoreProvider#LOCATION} parameter value, or {@code null} if none.
     * This is used for information purpose only, not for actual reading operations.
     *
     * @see #getOpenParameters()
     */
    private final URI location;

    /**
     * Same value than {@link #location} but as a path, or {@code null} if none.
     * Stored separately because conversion from path to URI back to path is not
     * looseness (relative paths become absolutes).
     *
     * @todo May become an array later if we want to handle TFW and PRJ file here.
     */
    final Path path;

    /**
     * The data store identifier created from the filename, or {@code null} if none.
     * Defined as a namespace for use as the scope of children resources (the images).
     * This is created when first needed.
     *
     * <h4>Design note</h4>
     * We do not create this field in the constructor because its creation invokes
     * the user-overrideable {@link #customize(int, GenericName)} method.
     *
     * @see #namespace()
     */
    private NameSpace namespace;

    /**
     * Whether {@link #namespace} has been determined.
     * Note that the resulting namespace may still be null.
     *
     * @see #namespace()
     */
    private boolean isNamespaceSet;

    /**
     * The metadata, or {@code null} if not yet created.
     *
     * @see #getMetadata()
     */
    private Metadata metadata;

    /**
     * The native metadata, or {@code null} if not yet created.
     *
     * @see #getNativeMetadata()
     */
    private TreeTable nativeMetadata;

    /**
     * Description of images in this GeoTIFF files. This collection is created only when first needed.
     *
     * @see #components()
     */
    private List<GridCoverageResource> components;

    /**
     * Whether this {@code GeotiffStore} will be hidden. If {@code true}, then some metadata that would
     * normally be provided in this {@code GeoTiffStore} will be provided by individual components instead.
     */
    final boolean hidden;

    /**
     * The user-specified method for customizing the band definitions. Never {@code null}.
     */
    final SchemaModifier customizer;

    /**
     * Creates a new GeoTIFF store from the given file, URL or stream object.
     * This constructor invokes {@link StorageConnector#closeAllExcept(Object)},
     * keeping open only the needed resource.
     *
     * @param  provider   the factory that created this {@code DataStore} instance, or {@code null} if unspecified.
     * @param  connector  information about the storage (URL, stream, <i>etc</i>).
     * @throws DataStoreException if an error occurred while opening the GeoTIFF file.
     */
    public GeoTiffStore(final GeoTiffStoreProvider provider, final StorageConnector connector) throws DataStoreException {
        this(null, provider, connector, false);
    }

    /**
     * Creates a new GeoTIFF store as a component of a larger data store.
     * If the {@code hidden} parameter is {@code true}, some metadata that would normally be
     * provided in this {@code GeoTiffStore} will be provided by individual components instead.
     *
     * <h4>Example</h4>
     * A Landsat data set is a collection of files in a directory or ZIP file,
     * which includes more than 10 GeoTIFF files (one image per band or product for a scene).
     * {@link org.apache.sis.storage.landsat.LandsatStore} is a data store opening the Landsat
     * metadata file as the main file, then opening each band/product using a GeoTIFF data store.
     * Those bands/products are components of the Landsat data store.
     *
     * @param  parent     the parent that contains this new GeoTIFF store component, or {@code null} if none.
     * @param  provider   the factory that created this {@code DataStore} instance, or {@code null} if unspecified.
     * @param  connector  information about the storage (URL, stream, <i>etc</i>).
     * @param  hidden     {@code true} if this GeoTIFF store will not be directly accessible from the parent.
     *                    It is the case if the parent store will expose only some {@linkplain #components()
     *                    components} instead of the GeoTIFF store itself.
     * @throws DataStoreException if an error occurred while opening the GeoTIFF file.
     *
     * @since 1.1
     */
    public GeoTiffStore(final DataStore parent, final DataStoreProvider provider, final StorageConnector connector,
                        final boolean hidden) throws DataStoreException
    {
        super(parent, provider, connector, hidden);
        this.hidden = hidden;

        final SchemaModifier customizer = connector.getOption(SchemaModifier.OPTION);
        this.customizer = (customizer != null) ? customizer : SchemaModifier.DEFAULT;

        final Charset encoding = connector.getOption(OptionKey.ENCODING);
        this.encoding = (encoding != null) ? encoding : StandardCharsets.US_ASCII;

        location = connector.getStorageAs(URI.class);
        path = connector.getStorageAs(Path.class);
        final ChannelDataInput input = connector.commit(ChannelDataInput.class, Constants.GEOTIFF);
        try {
            reader = new Reader(this, input);
        } catch (IOException e) {
            throw new DataStoreException(e);
        }
        if (getClass() == GeoTiffStore.class) {
            listeners.useReadOnlyEvents();
        }
    }

    /**
     * Returns the namespace to use in identifier of components, or {@code null} if none.
     * This method must be invoked inside a block synchronized on {@code this}.
     */
    final NameSpace namespace() {
        final Reader reader = this.reader;
        if (!isNamespaceSet && reader != null) {
            final NameFactory f = reader.nameFactory;
            GenericName name = null;
            /*
             * We test `location != null` because if the location was not convertible to URI,
             * then the string representation is probably a class name, which is not useful.
             */
            if (location != null) {
                String filename = IOUtilities.filenameWithoutExtension(reader.input.filename);
                name = f.createLocalName(null, filename);
            }
            name = customizer.customize(-1, name);
            if (name != null) {
                namespace = f.createNameSpace(name, null);
            }
            isNamespaceSet = true;
        }
        return namespace;
    }

    /**
     * Opens access to listeners for {@link ImageFileDirectory}.
     *
     * @see #warning(LogRecord)
     */
    final StoreListeners listeners() {
        return listeners;
    }

    /**
     * Returns the parameters used to open this GeoTIFF data store.
     * The parameters are described by {@link GeoTiffStoreProvider#getOpenParameters()} and contains at least
     * a parameter named {@value org.apache.sis.storage.DataStoreProvider#LOCATION} with a {@link URI} value.
     * The return value may be empty if the storage input cannot be described by a URI
     * (for example a GeoTIFF file reading directly from a {@link java.nio.channels.ReadableByteChannel}).
     *
     * @return parameters used for opening this data store.
     */
    @Override
    public Optional<ParameterValueGroup> getOpenParameters() {
        return Optional.ofNullable(URIDataStore.parameters(provider, location));
    }

    /**
     * Returns an identifier constructed from the name of the TIFF file.
     * An identifier is available only if the storage input specified at construction time was something convertible to
     * {@link java.net.URI}, for example an {@link java.net.URL}, {@link java.io.File} or {@link java.nio.file.Path}.
     *
     * @return the identifier derived from the filename.
     * @throws DataStoreException if an error occurred while fetching the identifier.
     *
     * @since 1.0
     */
    @Override
    public Optional<GenericName> getIdentifier() throws DataStoreException {
        final NameSpace namespace;
        synchronized (this) {
            namespace = namespace();
        }
        return (namespace != null) ? Optional.of(namespace.name()) : Optional.empty();
    }

    /**
     * Sets the {@code metadata/identificationInfo/resourceFormat} node to "GeoTIFF" format.
     */
    final void setFormatInfo(final MetadataBuilder builder) {
        try {
            builder.setPredefinedFormat(Constants.GEOTIFF);
        } catch (MetadataStoreException e) {
            builder.addFormatName(Constants.GEOTIFF);
            listeners.warning(e);
        }
        builder.addLanguage(Locale.ENGLISH, encoding, MetadataBuilder.Scope.METADATA);
        builder.addResourceScope(ScopeCode.valueOf("COVERAGE"), null);
    }

    /**
     * Returns information about the dataset as a whole. The returned metadata object can contain information
     * such as the spatiotemporal extent of the dataset, contact information about the creator or distributor,
     * data quality, usage constraints and more.
     *
     * @return information about the dataset.
     * @throws DataStoreException if an error occurred while reading the data.
     */
    @Override
    public synchronized Metadata getMetadata() throws DataStoreException {
        if (metadata == null) {
            final Reader reader = reader();
            final MetadataBuilder builder = new MetadataBuilder();
            setFormatInfo(builder);
            int n = 0;
            try {
                GridCoverageResource dir;
                while ((dir = reader.getImage(n++)) != null) {
                    builder.addFromComponent(dir.getMetadata());
                }
            } catch (IOException e) {
                throw errorIO(e);
            } catch (ArithmeticException e) {
                listeners.warning(e);
            }
            /*
             * Add the filename as an identifier only if the input was something convertible to URI (URL, File or Path),
             * otherwise reader.input.filename may not be useful; it may be just the InputStream classname. If the TIFF
             * file did not specified any ImageDescription tag, then we will add the filename as a title instead of an
             * identifier because the title is mandatory in ISO 19115 metadata.
             */
            getIdentifier().ifPresent((id) -> builder.addTitleOrIdentifier(id.toString(), MetadataBuilder.Scope.ALL));
            builder.setISOStandards(true);
            final DefaultMetadata md = builder.build();
            metadata = customizer.customize(-1, md);
            if (metadata == null) metadata = md;
            md.transitionTo(DefaultMetadata.State.FINAL);
        }
        return metadata;
    }

    /**
     * Returns TIFF tags and GeoTIFF keys as a tree for debugging purpose.
     * The tags and keys appear in the order they are declared in the file.
     * The columns are tag numerical code as an {@link Integer},
     * tag name as a {@link String} and value as an {@link Object}.
     *
     * <p>This method should not be invoked during normal operations;
     * the {@linkplain #getMetadata() standard metadata} are preferred
     * because they allow abstraction of data format details.
     * Native metadata should be used only when an information does not appear in standard metadata,
     * or for debugging purposes.</p>
     *
     * <h4>Performance note</h4>
     * Since this method should not be invoked in normal operations, it has not been tuned for performance.
     * Invoking this method may cause a lot of {@linkplain java.nio.channels.SeekableByteChannel#position(long)
     * seek operations}.
     *
     * @return resources information structured in an implementation-specific way.
     * @throws DataStoreException if an error occurred while reading the metadata.
     *
     * @since 1.2
     */
    @Override
    public synchronized Optional<TreeTable> getNativeMetadata() throws DataStoreException {
        if (nativeMetadata == null) try {
            nativeMetadata = new NativeMetadata(getLocale()).read(reader());
        } catch (IOException e) {
            throw errorIO(e);
        }
        return Optional.of(nativeMetadata);
    }

    /**
     * Returns the exception to throw when an I/O error occurred.
     * This method wraps the exception with a {@literal "Cannot read <filename>"} message.
     */
    final DataStoreException errorIO(final IOException e) {
        return new DataStoreException(errors().getString(Errors.Keys.CanNotRead_1, reader.input.filename), e);
    }

    /**
     * Returns the reader if it is not closed, or throws an exception otherwise.
     *
     * @see #close()
     */
    private Reader reader() throws DataStoreException {
        assert Thread.holdsLock(this);
        final Reader r = reader;
        if (r == null) {
            throw new DataStoreClosedException(getLocale(), Constants.GEOTIFF, StandardOpenOption.READ);
        }
        return r;
    }

    /**
     * Returns descriptions of all images in this GeoTIFF file.
     * Images are not immediately loaded.
     *
     * <p>If an error occurs during iteration in the returned collection,
     * an unchecked {@link BackingStoreException} will be thrown with a {@link DataStoreException} as its cause.</p>
     *
     * @return descriptions of all images in this GeoTIFF file.
     * @throws DataStoreException if an error occurred while fetching the image descriptions.
     *
     * @since 1.0
     */
    @Override
    @SuppressWarnings("ReturnOfCollectionOrArrayField")
    public synchronized List<GridCoverageResource> components() throws DataStoreException {
        if (components == null) {
            components = new Components();
        }
        return components;
    }

    /**
     * The components returned by {@link #components}. Defined as a named class instead of an anonymous
     * class for more readable stack trace. This is especially useful since {@link BackingStoreException}
     * may happen in any method.
     */
    private final class Components extends ListOfUnknownSize<GridCoverageResource> {
        /** The collection size, cached when first computed. */
        private int size = -1;

        /** Returns the size or -1 if not yet known. */
        @Override protected int sizeIfKnown() {
            synchronized (GeoTiffStore.this) {
                return size;
            }
        }

        /** Returns the size, computing and caching it if needed. */
        @Override public int size() {
            synchronized (GeoTiffStore.this) {
                if (size < 0) {
                    size = super.size();
                }
                return size;
            }
        }

        /** Returns whether the given index is valid. */
        @Override protected boolean exists(final int index) {
            return (index >= 0) && getImageFileDirectory(index) != null;
        }

        /** Returns element at the given index or throw {@link IndexOutOfBoundsException}. */
        @Override public GridCoverageResource get(final int index) {
            if (index >= 0) {
                GridCoverageResource image = getImageFileDirectory(index);
                if (image != null) return image;
            }
            throw new IndexOutOfBoundsException(errors().getString(Errors.Keys.IndexOutOfBounds_1, index));
        }

        /** Returns element at the given index or returns {@code null} if the index is invalid. */
        private GridCoverageResource getImageFileDirectory(final int index) {
            try {
                synchronized (GeoTiffStore.this) {
                    return reader().getImage(index);
                }
            } catch (IOException e) {
                throw new BackingStoreException(errorIO(e));
            } catch (DataStoreException e) {
                throw new BackingStoreException(e);
            }
        }
    }

    /**
     * Returns the image at the given index. Images numbering starts at 1.
     * If the given string has a scope (e.g. "filename:1"), then the scope
     *
     * @param  sequence  string representation of the image index, starting at 1.
     * @return image at the given index.
     * @throws DataStoreException if the requested image cannot be obtained.
     */
    @Override
    public synchronized GridCoverageResource findResource(final String sequence) throws DataStoreException {
        ArgumentChecks.ensureNonNull("sequence", sequence);
        final int index = parseImageIndex(sequence);
        if (index >= 0) try {
            final GridCoverageResource image = reader().getImage(index - 1);
            if (image != null) return image;
        } catch (IOException e) {
            throw errorIO(e);
        }
        throw new IllegalNameException(StoreUtilities.resourceNotFound(this, sequence));
    }

    /**
     * Validates input resource name and extracts the image index it should contain.
     * The resource name may be of the form "1" or "filename:1". We verify that:
     *
     * <ul>
     *   <li>Input tip (last name part) is a parsable integer.</li>
     *   <li>If input provides more than a tip, all test before the tip matches this datastore namespace
     *       (should be the name of the Geotiff file without its extension).</li>
     * </ul>
     *
     * @param  sequence  a string representing the name of a resource present in this datastore.
     * @return the index of the Geotiff image matching the requested resource.
     *         There is no verification that the returned index is valid.
     * @throws IllegalNameException if the argument use an invalid namespace or if the tip is not an integer.
     */
    private int parseImageIndex(String sequence) throws IllegalNameException {
        final NameSpace namespace = namespace();
        final String separator = DefaultNameSpace.getSeparator(namespace, false);
        final int s = sequence.lastIndexOf(separator);
        if (s >= 0) {
            if (namespace != null) {
                final String expected = namespace.name().toString();
                if (!sequence.substring(0, s).equals(expected)) {
                    throw new IllegalNameException(errors().getString(Errors.Keys.UnexpectedNamespace_2, expected, sequence));
                }
            }
            sequence = sequence.substring(s + separator.length());
        }
        try {
            return Integer.parseInt(sequence);
        } catch (NumberFormatException e) {
            throw new IllegalNameException(StoreUtilities.resourceNotFound(this, sequence), e);
        }
    }

    /**
     * Registers a listener to notify when the specified kind of event occurs in this data store.
     * The current implementation of this data store can emit only {@link WarningEvent}s;
     * any listener specified for another kind of events will be ignored.
     */
    @Override
    public <T extends StoreEvent> void addListener(Class<T> eventType, StoreListener<? super T> listener) {
        // If an argument is null, we let the parent class throws (indirectly) NullPointerException.
        if (listener == null || eventType == null || eventType.isAssignableFrom(WarningEvent.class)) {
            super.addListener(eventType, listener);
        }
    }

    /**
     * Closes this GeoTIFF store and releases any underlying resources.
     * This method can be invoked asynchronously for interrupting a long reading process.
     *
     * @throws DataStoreException if an error occurred while closing the GeoTIFF file.
     */
    @Override
    public void close() throws DataStoreException {
        try {
            listeners.close();                  // Should never fail.
            final Reader r = reader;
            if (r != null) r.close();
        } catch (IOException e) {
            throw new DataStoreException(e);
        } finally {
            synchronized (this) {
                components     = null;
                namespace      = null;
                metadata       = null;
                nativeMetadata = null;
                reader         = null;
            }
        }
    }

    /**
     * Returns the error resources in the current locale.
     */
    final Errors errors() {
        return Errors.getResources(getLocale());
    }

    /**
     * Reports a warning contained in the given {@link LogRecord}.
     * Note that the given record will not necessarily be sent to the logging framework;
     * if the user has registered at least one listener, then the record will be sent to the listeners instead.
     *
     * <p>This method sets the {@linkplain LogRecord#setSourceClassName(String) source class name} and
     * {@linkplain LogRecord#setSourceMethodName(String) source method name} to hard-coded values.
     * Those values assume that the warnings occurred indirectly from a call to {@link #getMetadata()}
     * in this class. We do not report private classes or methods as the source of warnings.</p>
     *
     * @param  record  the warning to report.
     *
     * @see #listeners()
     */
    final void warning(final LogRecord record) {
        // Logger name will be set by listeners.warning(record).
        record.setSourceClassName(GeoTiffStore.class.getName());
        record.setSourceMethodName("getMetadata");
        listeners.warning(record);
    }
}
