/*
 *  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.causeway.core.webapp.modules.templresources;

import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.apache.causeway.commons.internal.exceptions._Exceptions.FluentException;

public class TemplateResourceCachingFilter implements Filter {

    /**
     * Attribute set on {@link HttpServletRequest} if the filter has been
     * applied.
     *
     * <p>
     * This is intended to inform other filters.
     */
    private static final String REQUEST_ATTRIBUTE =
            TemplateResourceCachingFilter.class.getName() + ".resource";

    /**
     * To allow other filters to ask whether a request is mapped to the resource
     * caching filter.
     *
     * <p>
     * For example, the <tt>CausewayRestfulObjectsInteractionFilter</tt> uses this in order to skip
     * any session handling.
     */
    public static boolean isCachedResource(final HttpServletRequest request) {
        return request.getAttribute(REQUEST_ATTRIBUTE) != null;
    }

    /**
     * The Constant MILLISECONDS_IN_SECOND.
     */
    private static final int MILLISECONDS_IN_SECOND = 1000;

    /** The Constant POST_CHECK_VALUE. */
    private static final String POST_CHECK_VALUE = "post-check=";

    /** The Constant PRE_CHECK_VALUE. */
    private static final String PRE_CHECK_VALUE = "pre-check=";

    /** The Constant MAX_AGE_VALUE. */
    private static final String MAX_AGE_VALUE = "max-age=";

    /** The Constant ZERO_STRING_VALUE. */
    private static final String ZERO_STRING_VALUE = "0";

    /** The Constant NO_STORE_VALUE. */
    private static final String NO_STORE_VALUE = "no-store";

    /** The Constant NO_CACHE_VALUE. */
    private static final String NO_CACHE_VALUE = "no-cache";

    /** The Constant PRAGMA_HEADER. */
    private static final String PRAGMA_HEADER = "Pragma";

    /** The Constant CACHE_CONTROL_HEADER. */
    private static final String CACHE_CONTROL_HEADER = "Cache-Control";

    /** The Constant EXPIRES_HEADER. */
    private static final String EXPIRES_HEADER = "Expires";

    /** The Constant LAST_MODIFIED_HEADER. */
    private static final String LAST_MODIFIED_HEADER = "Last-Modified";

    /** The Constant CACHE_TIME_PARAM_NAME. */
    private static final String CACHE_TIME_PARAM_NAME = "CacheTime";

    /** The default for {@link #CACHE_TIME_PARAM_NAME}. */
    private static final String CACHE_TIME_PARAM_NAME_DEFAULT = "" + 86400;

    /** The reply headers. */
    private String[][] mReplyHeaders = { {} };

    /** The cache time in seconds. */
    private long cacheTime = 0L;

    private static final DateFormat httpDateFormat() {
        // not thread-safe, so each thread should have its own instance
        var dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
        dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
        return dateFormat;
    }

    /**
     * Initializes the Servlet filter with the cache time and sets up the
     * unchanging headers.
     *
     * @param pConfig
     *            the config
     *
     * @see jakarta.servlet.Filter#init(jakarta.servlet.FilterConfig)
     */
    @Override
    public void init(final FilterConfig pConfig) {
        final ArrayList<String[]> newReplyHeaders = new ArrayList<String[]>();
        final String cacheTime = pConfig.getInitParameter(CACHE_TIME_PARAM_NAME);
        this.cacheTime = Long.parseLong(cacheTime != null ? cacheTime : CACHE_TIME_PARAM_NAME_DEFAULT);
        if (this.cacheTime > 0L) {
            newReplyHeaders.add(new String[] { CACHE_CONTROL_HEADER, MAX_AGE_VALUE + this.cacheTime });
            newReplyHeaders.add(new String[] { CACHE_CONTROL_HEADER, PRE_CHECK_VALUE + this.cacheTime });
            newReplyHeaders.add(new String[] { CACHE_CONTROL_HEADER, POST_CHECK_VALUE + this.cacheTime });
        } else {
            newReplyHeaders.add(new String[] { PRAGMA_HEADER, NO_CACHE_VALUE });
            newReplyHeaders.add(new String[] { EXPIRES_HEADER, ZERO_STRING_VALUE });
            newReplyHeaders.add(new String[] { CACHE_CONTROL_HEADER, NO_CACHE_VALUE });
            newReplyHeaders.add(new String[] { CACHE_CONTROL_HEADER, NO_STORE_VALUE });
        }
        this.mReplyHeaders = new String[newReplyHeaders.size()][2];
        newReplyHeaders.toArray(this.mReplyHeaders);
    }

    @Override
    public void destroy() {
        // nothing to do
    }

    /**
     * Do filter.
     *
     * @param servletRequest
     *            the request
     * @param servletResponse
     *            the response
     * @param chain
     *            the chain
     *
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     * @throws ServletException
     *             the servlet exception
     *
     * @see jakarta.servlet.Filter#doFilter(jakarta.servlet.ServletRequest,
     *      jakarta.servlet.ServletResponse, jakarta.servlet.FilterChain)
     */
    @Override
    public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain chain) throws IOException, ServletException {
        // Apply the headers
        final HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        final HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
        for (final String[] replyHeader : this.mReplyHeaders) {
            final String name = replyHeader[0];
            final String value = replyHeader[1];
            httpResponse.addHeader(name, value);
        }
        if (this.cacheTime > 0L) {
            final long now = System.currentTimeMillis();
            var dateFormat = httpDateFormat();
            httpResponse.addHeader(LAST_MODIFIED_HEADER, dateFormat.format(new Date(now)));
            httpResponse.addHeader(EXPIRES_HEADER, dateFormat.format(new Date(now + (this.cacheTime * MILLISECONDS_IN_SECOND))));
        }
        httpRequest.setAttribute(REQUEST_ATTRIBUTE, true);

        // try to suppress java.io.IOException of kind 'client connection abort'
        // 1) the TCP protocol (by design) does not provide a means to check, whether a
        //    connection has been closed by the client
        // 2) the exception thrown and the exception message text are specific to the
        //    servlet-engine implementation, so we can only guess here
        try {
            chain.doFilter(servletRequest, servletResponse);
        } catch (IOException e) {
            FluentException.of(e)
            .suppressIf(this::isConnectionAbortException);
        }
    }

    // -- HELPER

    private boolean isConnectionAbortException(IOException e) {
        // tomcat 9
        if(e.getMessage().contains("An established connection was aborted by the software in your host machine")) {
            return true;
        }
        // payara 4
        if(e.getMessage().contains("Connection is closed")) {
            return true;
        }

        return false;
    }

}
