/*
 * ====================================================================
 * 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */
package org.apache.hc.client5.http.impl.cache;

import java.net.URI;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.cache.HttpCacheCASOperation;
import org.apache.hc.client5.http.cache.HttpCacheEntry;
import org.apache.hc.client5.http.cache.HttpCacheEntryFactory;
import org.apache.hc.client5.http.cache.HttpCacheStorage;
import org.apache.hc.client5.http.cache.HttpCacheUpdateException;
import org.apache.hc.client5.http.cache.Resource;
import org.apache.hc.client5.http.cache.ResourceFactory;
import org.apache.hc.client5.http.cache.ResourceIOException;
import org.apache.hc.client5.http.validator.ETag;
import org.apache.hc.client5.http.validator.ValidatorType;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.Method;
import org.apache.hc.core5.util.ByteArrayBuffer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class BasicHttpCache implements HttpCache {

    private static final Logger LOG = LoggerFactory.getLogger(BasicHttpCache.class);

    private final ResourceFactory resourceFactory;
    private final HttpCacheEntryFactory cacheEntryFactory;
    private final CacheKeyGenerator cacheKeyGenerator;
    private final HttpCacheStorage storage;

    public BasicHttpCache(
            final ResourceFactory resourceFactory,
            final HttpCacheEntryFactory cacheEntryFactory,
            final HttpCacheStorage storage,
            final CacheKeyGenerator cacheKeyGenerator) {
        this.resourceFactory = resourceFactory;
        this.cacheEntryFactory = cacheEntryFactory;
        this.cacheKeyGenerator = cacheKeyGenerator;
        this.storage = storage;
    }

    public BasicHttpCache(
            final ResourceFactory resourceFactory,
            final HttpCacheStorage storage,
            final CacheKeyGenerator cacheKeyGenerator) {
        this(resourceFactory, HttpCacheEntryFactory.INSTANCE, storage, cacheKeyGenerator);
    }

    public BasicHttpCache(final ResourceFactory resourceFactory, final HttpCacheStorage storage) {
        this( resourceFactory, storage, new CacheKeyGenerator());
    }

    public BasicHttpCache(final CacheConfig config) {
        this(new HeapResourceFactory(), new BasicHttpCacheStorage(config));
    }

    public BasicHttpCache() {
        this(CacheConfig.DEFAULT);
    }

    void storeInternal(final String cacheKey, final HttpCacheEntry entry) {
        try {
            storage.putEntry(cacheKey, entry);
        } catch (final ResourceIOException ex) {
            if (LOG.isWarnEnabled()) {
                LOG.warn("I/O error storing cache entry with key {}", cacheKey);
            }
        }
    }

    void updateInternal(final String cacheKey, final HttpCacheCASOperation casOperation) {
        try {
            storage.updateEntry(cacheKey, casOperation);
        } catch (final HttpCacheUpdateException ex) {
            if (LOG.isWarnEnabled()) {
                LOG.warn("Cannot update cache entry with key {}", cacheKey);
            }
        } catch (final ResourceIOException ex) {
            if (LOG.isWarnEnabled()) {
                LOG.warn("I/O error updating cache entry with key {}", cacheKey);
            }
        }
    }

    HttpCacheEntry getInternal(final String cacheKey) {
        try {
            return storage.getEntry(cacheKey);
        } catch (final ResourceIOException ex) {
            if (LOG.isWarnEnabled()) {
                LOG.warn("I/O error retrieving cache entry with key {}", cacheKey);
            }
            return null;
        }
    }

    private void removeInternal(final String cacheKey) {
        try {
            storage.removeEntry(cacheKey);
        } catch (final ResourceIOException ex) {
            if (LOG.isWarnEnabled()) {
                LOG.warn("I/O error removing cache entry with key {}", cacheKey);
            }
        }
    }

    @Override
    public CacheMatch match(final HttpHost host, final SimpleHttpRequest request) {
        final String rootKey = cacheKeyGenerator.generateKey(host, request, SimpleHttpRequest::getBodyBytes);
        if (LOG.isDebugEnabled()) {
            LOG.debug("Get cache root entry: {}", rootKey);
        }
        final HttpCacheEntry root = getInternal(rootKey);
        if (root == null) {
            return null;
        }
        if (root.hasVariants()) {
            final List<String> variantNames = CacheKeyGenerator.variantNames(root);
            final String variantKey = cacheKeyGenerator.generateVariantKey(request, variantNames);
            if (root.getVariants().contains(variantKey)) {
                final String cacheKey = variantKey + rootKey;
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Get cache variant entry: {}", cacheKey);
                }
                final HttpCacheEntry entry = getInternal(cacheKey);
                if (entry != null) {
                    return new CacheMatch(new CacheHit(rootKey, cacheKey, entry), new CacheHit(rootKey, root));
                }
            }
            return new CacheMatch(null, new CacheHit(rootKey, root));
        }
        return new CacheMatch(new CacheHit(rootKey, root), null);
    }

    @Override
    public List<CacheHit> getVariants(final CacheHit hit) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Get variant cache entries: {}", hit.rootKey);
        }
        final HttpCacheEntry root = hit.entry;
        final String rootKey = hit.rootKey;
        if (root != null && root.hasVariants()) {
            final List<CacheHit> variants = new ArrayList<>();
            for (final String variantKey : root.getVariants()) {
                final String variantCacheKey = variantKey + rootKey;
                final HttpCacheEntry variant = getInternal(variantCacheKey);
                if (variant != null) {
                    variants.add(new CacheHit(rootKey, variantCacheKey, variant));
                }
            }
            return variants;
        }
        return Collections.emptyList();
    }

    CacheHit store(final String rootKey, final String variantKey, final HttpCacheEntry entry) {
        if (variantKey == null) {
            if (LOG.isDebugEnabled()) {
                LOG.debug("Store entry in cache: {}", rootKey);
            }
            storeInternal(rootKey, entry);
            return new CacheHit(rootKey, entry);
        }
        final String variantCacheKey = variantKey + rootKey;

        if (LOG.isDebugEnabled()) {
            LOG.debug("Store variant entry in cache: {}", variantCacheKey);
        }

        storeInternal(variantCacheKey, entry);

        if (LOG.isDebugEnabled()) {
            LOG.debug("Update root entry: {}", rootKey);
        }

        updateInternal(rootKey, existing -> {
            final Set<String> variants = existing != null ? new HashSet<>(existing.getVariants()) : new HashSet<>();
            variants.add(variantKey);
            return cacheEntryFactory.createRoot(entry, variants);
        });
        return new CacheHit(rootKey, variantCacheKey, entry);
    }

    @Override
    public CacheHit store(
            final HttpHost host,
            final SimpleHttpRequest request,
            final HttpResponse originResponse,
            final ByteArrayBuffer content,
            final Instant requestSent,
            final Instant responseReceived) {
        final byte[] requestContent = request.getBodyBytes();
        final String rootKey = cacheKeyGenerator.generateKey(host, request, r -> requestContent);
        if (LOG.isDebugEnabled()) {
            LOG.debug("Create cache entry: {}", rootKey);
        }
        final Resource resource;
        try {
            final ETag eTag = ETag.get(originResponse);
            resource = content != null ? resourceFactory.generate(
                    rootKey,
                    eTag != null && eTag.getType() == ValidatorType.STRONG ? eTag.getValue() : null,
                    content.array(), 0, content.length()) : null;
        } catch (final ResourceIOException ex) {
            if (LOG.isWarnEnabled()) {
                LOG.warn("I/O error creating cache entry with key {}", rootKey);
            }
            final HttpCacheEntry backup = cacheEntryFactory.create(
                    requestSent,
                    responseReceived,
                    host,
                    request,
                    requestContent,
                    originResponse,
                    content != null ? HeapResourceFactory.INSTANCE.generate(null, content.array(), 0, content.length()) : null);
            return new CacheHit(rootKey, backup);
        }
        final HttpCacheEntry entry = cacheEntryFactory.create(requestSent, responseReceived, host, request, requestContent, originResponse, resource);
        final String variantKey = cacheKeyGenerator.generateVariantKey(request, entry);
        return store(rootKey,variantKey, entry);
    }

    @Override
    public CacheHit update(
            final CacheHit stale,
            final HttpHost host,
            final SimpleHttpRequest request,
            final HttpResponse originResponse,
            final Instant requestSent,
            final Instant responseReceived) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Update cache entry: {}", stale.getEntryKey());
        }
        final HttpCacheEntry updatedEntry = cacheEntryFactory.createUpdated(
                requestSent,
                responseReceived,
                host,
                request,
                originResponse,
                stale.entry);
        final String variantKey = cacheKeyGenerator.generateVariantKey(request, updatedEntry);
        return store(stale.rootKey, variantKey, updatedEntry);
    }

    @Override
    public CacheHit storeFromNegotiated(
            final CacheHit negotiated,
            final HttpHost host,
            final SimpleHttpRequest request,
            final HttpResponse originResponse,
            final Instant requestSent,
            final Instant responseReceived) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Update negotiated cache entry: {}", negotiated.getEntryKey());
        }
        final HttpCacheEntry updatedEntry = cacheEntryFactory.createUpdated(
                requestSent,
                responseReceived,
                host,
                request,
                originResponse,
               negotiated.entry);
        storeInternal(negotiated.getEntryKey(), updatedEntry);

        final String rootKey = cacheKeyGenerator.generateKey(host, request, SimpleHttpRequest::getBodyBytes);
        final HttpCacheEntry copy = cacheEntryFactory.copy(updatedEntry);
        final String variantKey = cacheKeyGenerator.generateVariantKey(request, copy);
        return store(rootKey, variantKey, copy);
    }

    private void evictAll(final HttpCacheEntry root, final String rootKey) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Evicting root cache entry {}", rootKey);
        }
        removeInternal(rootKey);
        if (root.hasVariants()) {
            for (final String variantKey : root.getVariants()) {
                final String variantEntryKey = variantKey + rootKey;
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Evicting variant cache entry {}", variantEntryKey);
                }
                removeInternal(variantEntryKey);
            }
        }
    }

    private void evict(final String rootKey) {
        final HttpCacheEntry root = getInternal(rootKey);
        if (root == null) {
            return;
        }
        evictAll(root, rootKey);
    }

    private void evict(final String rootKey, final HttpResponse response) {
        final HttpCacheEntry root = getInternal(rootKey);
        if (root == null) {
            return;
        }
        final ETag existingETag = root.getETag();
        final ETag newETag = ETag.get(response);
        if (existingETag != null && newETag != null &&
                !ETag.strongCompare(existingETag, newETag) &&
                !HttpCacheEntry.isNewer(root, response)) {
            evictAll(root, rootKey);
        }
    }

    @Override
    public void evictInvalidatedEntries(final HttpHost host, final SimpleHttpRequest request, final HttpResponse response) {
        if (LOG.isDebugEnabled()) {
            LOG.debug("Evict cache entries invalidated by exchange: {}; {} {} -> {}",
                    host, request.getMethod(), request.getRequestUri(), response.getCode());
        }
        final int status = response.getCode();
        if (status >= HttpStatus.SC_SUCCESS && status < HttpStatus.SC_CLIENT_ERROR &&
                !Method.isSafe(request.getMethod())) {
            final String rootKey = cacheKeyGenerator.generateKey(host, request, SimpleHttpRequest::getBodyBytes);
            evict(rootKey);
            final URI requestUri = CacheSupport.requestUriNormalizedOrNull(host, request);
            if (requestUri != null) {
                final URI contentLocation = CacheSupport.getLocationURI(requestUri, response, HttpHeaders.CONTENT_LOCATION);
                if (contentLocation != null && CacheSupport.isSameOrigin(requestUri, contentLocation)) {
                    final String cacheKey = cacheKeyGenerator.generateKey(contentLocation);
                    evict(cacheKey, response);
                }
                final URI location = CacheSupport.getLocationURI(requestUri, response, HttpHeaders.LOCATION);
                if (location != null && CacheSupport.isSameOrigin(requestUri, location)) {
                    final String cacheKey = cacheKeyGenerator.generateKey(location);
                    evict(cacheKey, response);
                }
            }
        }
    }

}
