/*
 * 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.maven.index.reader;

import java.io.Closeable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.maven.index.reader.ResourceHandler.Resource;

import static java.util.Objects.requireNonNull;
import static org.apache.maven.index.reader.Utils.loadProperties;
import static org.apache.maven.index.reader.Utils.storeProperties;

/**
 * Maven Index reader that handles incremental updates, if possible, and provides one or more {@link ChunkReader}s, to
 * read all the required records. Instances of this class MUST BE handled as resources (have them closed once done with
 * them), it is user responsibility to close them, ideally in try-with-resource block.
 * <p>
 * Every involved instance, this {@link IndexReader}, provided {@link ChunkReader}, and used  {@link ResourceHandler}s
 * are {@link Closeable}, and all have to be explicitly closed, best in try-with-resource.
 *
 * @since 5.1.2
 */
public class IndexReader implements Iterable<ChunkReader>, Closeable {
    private final AtomicBoolean closed;

    private final WritableResourceHandler local;

    private final ResourceHandler remote;

    private final Properties localIndexProperties;

    private final Properties remoteIndexProperties;

    private final String indexId;

    private final Date publishedTimestamp;

    private final boolean incremental;

    private final List<String> chunkNames;

    public IndexReader(final WritableResourceHandler local, final ResourceHandler remote) throws IOException {
        requireNonNull(remote, "remote resource handler null");
        this.closed = new AtomicBoolean(false);
        this.local = local;
        this.remote = remote;
        remoteIndexProperties = loadProperties(remote.locate(Utils.INDEX_FILE_PREFIX + ".properties"));
        if (remoteIndexProperties == null) {
            throw new IllegalArgumentException("Non-existent remote index");
        }
        try {
            if (local != null) {
                Properties localProperties = loadProperties(local.locate(Utils.INDEX_FILE_PREFIX + ".properties"));
                if (localProperties != null) {
                    this.localIndexProperties = localProperties;
                    String remoteIndexId = remoteIndexProperties.getProperty("nexus.index.id");
                    String localIndexId = localIndexProperties.getProperty("nexus.index.id");
                    if (remoteIndexId == null || !remoteIndexId.equals(localIndexId)) {
                        throw new IllegalArgumentException("local and remote index IDs does not match or is null: "
                                + localIndexId + ", " + remoteIndexId);
                    }
                    this.indexId = localIndexId;
                    this.incremental = canRetrieveAllChunks();
                } else {
                    localIndexProperties = null;
                    this.indexId = remoteIndexProperties.getProperty("nexus.index.id");
                    this.incremental = false;
                }
            } else {
                localIndexProperties = null;
                this.indexId = remoteIndexProperties.getProperty("nexus.index.id");
                this.incremental = false;
            }
            this.publishedTimestamp =
                    Utils.INDEX_DATE_FORMAT.parse(remoteIndexProperties.getProperty("nexus.index.timestamp"));
            this.chunkNames = calculateChunkNames();
        } catch (ParseException e) {
            throw new IOException("Index properties corrupted", e);
        }
    }

    /**
     * Returns the index context ID that published index has set. Usually it is equal to "repository ID" used in {@link
     * Record.Type#DESCRIPTOR} but does not have to be.
     */
    public String getIndexId() {
        return indexId;
    }

    /**
     * Returns the {@link Date} when remote index was last published.
     */
    public Date getPublishedTimestamp() {
        return publishedTimestamp;
    }

    /**
     * Returns {@code true} if incremental update is about to happen. If incremental update, the {@link #iterator()}
     * will return only the diff from the last update.
     */
    public boolean isIncremental() {
        return incremental;
    }

    /**
     * Returns unmodifiable list of actual chunks that needs to be pulled from remote {@link ResourceHandler}. Those are
     * incremental chunks or the big main file, depending on result of {@link #isIncremental()}. Empty list means local
     * index is up to date, and {@link #iterator()} will return empty iterator.
     */
    public List<String> getChunkNames() {
        return chunkNames;
    }

    /**
     * Closes the underlying {@link ResourceHandler}s. In case of incremental update use, it also assumes that user
     * consumed all the iterator and integrated it, hence, it will update the {@link WritableResourceHandler} contents
     * to prepare it for future incremental update. If this is not desired (ie. due to aborted update), then this
     * method should NOT be invoked, but rather the {@link ResourceHandler}s that caller provided in constructor of
     * this class should be closed manually.
     */
    @Override
    public void close() throws IOException {
        if (closed.compareAndSet(false, true)) {
            remote.close();
            if (local != null) {
                try {
                    syncLocalWithRemote();
                } finally {
                    local.close();
                }
            }
        }
    }

    /**
     * Returns an {@link Iterator} of {@link ChunkReader}s, that if read in sequence, provide all the (incremental)
     * updates from the index. It is caller responsibility to either consume fully this iterator, or to close current
     * {@link ChunkReader} if aborting.
     */
    @Override
    public Iterator<ChunkReader> iterator() {
        return new ChunkReaderIterator(remote, chunkNames.iterator());
    }

    /**
     * Stores the remote index properties into local index properties, preparing local {@link WritableResourceHandler}
     * for future incremental updates.
     */
    private void syncLocalWithRemote() throws IOException {
        storeProperties(local.locate(Utils.INDEX_FILE_PREFIX + ".properties"), remoteIndexProperties);
    }

    /**
     * Calculates the chunk names that needs to be fetched.
     */
    private List<String> calculateChunkNames() {
        if (incremental) {
            ArrayList<String> chunkNames = new ArrayList<>();
            int maxCounter = Integer.parseInt(remoteIndexProperties.getProperty("nexus.index.last-incremental"));
            int currentCounter = Integer.parseInt(localIndexProperties.getProperty("nexus.index.last-incremental"));
            currentCounter++;
            while (currentCounter <= maxCounter) {
                chunkNames.add(Utils.INDEX_FILE_PREFIX + "." + currentCounter++ + ".gz");
            }
            return Collections.unmodifiableList(chunkNames);
        } else {
            return Collections.singletonList(Utils.INDEX_FILE_PREFIX + ".gz");
        }
    }

    /**
     * Verifies incremental update is possible, as all the diff chunks we need are still enlisted in remote properties.
     */
    private boolean canRetrieveAllChunks() {
        String localChainId = localIndexProperties.getProperty("nexus.index.chain-id");
        String remoteChainId = remoteIndexProperties.getProperty("nexus.index.chain-id");

        // If no chain id, or not the same, do full update
        if (localChainId == null || !localChainId.equals(remoteChainId)) {
            return false;
        }

        try {
            int localLastIncremental =
                    Integer.parseInt(localIndexProperties.getProperty("nexus.index.last-incremental"));
            String currentLocalCounter = String.valueOf(localLastIncremental);
            String nextLocalCounter = String.valueOf(localLastIncremental + 1);
            // check remote props for existence of current or next chunk after local
            for (Object key : remoteIndexProperties.keySet()) {
                String sKey = (String) key;
                if (sKey.startsWith("nexus.index.incremental-")) {
                    String value = remoteIndexProperties.getProperty(sKey);
                    if (currentLocalCounter.equals(value) || nextLocalCounter.equals(value)) {
                        return true;
                    }
                }
            }
        } catch (NumberFormatException e) {
            // fall through
        }
        return false;
    }

    /**
     * Internal iterator implementation that lazily opens and closes the returned {@link ChunkReader}s as this iterator
     * is being consumed.
     */
    private static class ChunkReaderIterator implements Iterator<ChunkReader> {
        private final ResourceHandler resourceHandler;

        private final Iterator<String> chunkNamesIterator;

        private ChunkReaderIterator(final ResourceHandler resourceHandler, final Iterator<String> chunkNamesIterator) {
            this.resourceHandler = resourceHandler;
            this.chunkNamesIterator = chunkNamesIterator;
        }

        @Override
        public boolean hasNext() {
            return chunkNamesIterator.hasNext();
        }

        @Override
        public ChunkReader next() {
            String chunkName = chunkNamesIterator.next();
            try {
                Resource currentResource = resourceHandler.locate(chunkName);
                return new ChunkReader(chunkName, currentResource.read());
            } catch (IOException e) {
                throw new UncheckedIOException("IO problem while opening chunk readers", e);
            }
        }

        @Override
        public void remove() {
            throw new UnsupportedOperationException("remove");
        }
    }
}
