/*
 * 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.catalina.webresources;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.cert.Certificate;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.jar.JarEntry;
import java.util.jar.Manifest;

import org.apache.catalina.util.URLEncoder;

/**
 * Abstract resource implementation for archive-based resources.
 */
public abstract class AbstractArchiveResource extends AbstractResource {

    private final AbstractArchiveResourceSet archiveResourceSet;
    private final String baseUrl;
    private final JarEntry resource;
    private final String codeBaseUrl;
    private final String name;
    private boolean readCerts = false;
    private Certificate[] certificates;

    /**
     * Deprecated even though this is the "new" constructor as code needs to call the old constructor for now.
     *
     * @param archiveResourceSet The archive resource set
     * @param webAppPath         The web application path
     * @param baseUrl            The base URL
     * @param jarEntry           The JAR entry
     */
    @Deprecated
    protected AbstractArchiveResource(AbstractArchiveResourceSet archiveResourceSet, String webAppPath, String baseUrl,
            JarEntry jarEntry) {
        this(archiveResourceSet, webAppPath, baseUrl, jarEntry, null);
    }

    /**
     * The expectation is that this will be deprecated and then removed once the SecurityManager has been fully removed
     * from the JRE and it has been confirmed that the JRE no longer depends on code base.
     *
     * See https://bz.apache.org/bugzilla/show_bug.cgi?id=69426
     *
     * @param archiveResourceSet The archive resource set
     * @param webAppPath         The web application path
     * @param baseUrl            The base URL
     * @param jarEntry           The JAR entry
     * @param codeBaseUrl        The code base URL
     */
    protected AbstractArchiveResource(AbstractArchiveResourceSet archiveResourceSet, String webAppPath, String baseUrl,
            JarEntry jarEntry, String codeBaseUrl) {
        super(archiveResourceSet.getRoot(), webAppPath);
        this.archiveResourceSet = archiveResourceSet;
        this.baseUrl = baseUrl;
        this.resource = jarEntry;
        this.codeBaseUrl = codeBaseUrl;

        String resourceName = resource.getName();
        if (resourceName.charAt(resourceName.length() - 1) == '/') {
            resourceName = resourceName.substring(0, resourceName.length() - 1);
        }
        String internalPath = archiveResourceSet.getInternalPath();
        if (!internalPath.isEmpty() && resourceName.contentEquals(internalPath.subSequence(1, internalPath.length()))) {
            name = "";
        } else {
            int index = resourceName.lastIndexOf('/');
            if (index == -1) {
                name = resourceName;
            } else {
                name = resourceName.substring(index + 1);
            }
        }
    }

    /**
     * Returns the archive resource set.
     *
     * @return the archive resource set
     */
    protected AbstractArchiveResourceSet getArchiveResourceSet() {
        return archiveResourceSet;
    }

    /**
     * Returns the base.
     *
     * @return the base
     */
    protected final String getBase() {
        return archiveResourceSet.getBase();
    }

    /**
     * Returns the base URL.
     *
     * @return the base URL
     */
    protected final String getBaseUrl() {
        return baseUrl;
    }

    /**
     * Returns the resource.
     *
     * @return the resource
     */
    protected final JarEntry getResource() {
        return resource;
    }

    @Override
    public long getLastModified() {
        return resource.getTime();
    }

    @Override
    public boolean exists() {
        return true;
    }

    @Override
    public boolean isVirtual() {
        return false;
    }

    @Override
    public boolean isDirectory() {
        return resource.isDirectory();
    }

    @Override
    public boolean isFile() {
        return !resource.isDirectory();
    }

    @Override
    public boolean delete() {
        return false;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public long getContentLength() {
        if (isDirectory()) {
            return -1;
        }
        return resource.getSize();
    }

    @Override
    public String getCanonicalPath() {
        return null;
    }

    @Override
    public boolean canRead() {
        return true;
    }

    @Override
    public long getCreation() {
        return resource.getTime();
    }

    @Override
    public URL getURL() {
        String url = baseUrl + URLEncoder.DEFAULT.encode(resource.getName(), StandardCharsets.UTF_8);
        try {
            return new URI(url).toURL();
        } catch (MalformedURLException | URISyntaxException | IllegalArgumentException e) {
            if (getLog().isDebugEnabled()) {
                getLog().debug(sm.getString("fileResource.getUrlFail", url), e);
            }
            return null;
        }
    }

    @Override
    public URL getCodeBase() {
        try {
            return new URI(codeBaseUrl).toURL();
        } catch (MalformedURLException | URISyntaxException | NullPointerException e) {
            if (getLog().isDebugEnabled()) {
                getLog().debug(sm.getString("fileResource.getUrlFail", codeBaseUrl), e);
            }
            return null;
        }
    }

    @Override
    public final byte[] getContent() {
        long len = getContentLength();

        if (len > Integer.MAX_VALUE) {
            // Can't create an array that big
            if (getLog().isDebugEnabled()) {
                getLog().debug(sm.getString("abstractResource.getContentTooLarge", getWebappPath(), Long.valueOf(len)));
            }
            return null;
        }

        if (len < 0) {
            // Content is not applicable here (e.g. is a directory)
            return null;
        }

        int size = (int) len;
        byte[] result = new byte[size];

        int pos = 0;
        try (JarInputStreamWrapper jisw = getJarInputStreamWrapper()) {
            if (jisw == null) {
                // An error occurred, don't return corrupted content
                return null;
            }
            while (pos < size) {
                int n = jisw.read(result, pos, size - pos);
                if (n < 0) {
                    break;
                }
                pos += n;
            }
            if (pos < size) {
                // Stream ended before expected size — return null to avoid partial data
                return null;
            }
            // Once the stream has been read, read the certs
            certificates = jisw.getCertificates();
            readCerts = true;
        } catch (IOException ioe) {
            if (getLog().isDebugEnabled()) {
                getLog().debug(sm.getString("abstractResource.getContentFail", getWebappPath()), ioe);
            }
            // Don't return corrupted content
            return null;
        }

        return result;
    }


    @Override
    public Certificate[] getCertificates() {
        if (!readCerts) {
            throw new IllegalStateException(sm.getString("abstractResource.getContentFirst"));
        }
        return certificates;
    }

    @Override
    public Manifest getManifest() {
        return archiveResourceSet.getManifest();
    }

    @Override
    protected final InputStream doGetInputStream() {
        if (isDirectory()) {
            return null;
        }
        return getJarInputStreamWrapper();
    }

    /**
     * Returns a wrapper for the input stream of this resource.
     *
     * @return a wrapper for the input stream of this resource
     */
    protected abstract JarInputStreamWrapper getJarInputStreamWrapper();

    /**
     * This wrapper assumes that the InputStream was created from a JarFile obtained from a call to
     * getArchiveResourceSet().openJarFile(). If this is not the case then the usage counting in
     * AbstractArchiveResourceSet will break and the JarFile may be unexpectedly closed.
     */
    protected class JarInputStreamWrapper extends InputStream {

        private final JarEntry jarEntry;
        private final InputStream is;
        private final AtomicBoolean closed = new AtomicBoolean(false);


        /**
         * Creates a new JarInputStreamWrapper.
         *
         * @param jarEntry The JAR entry
         * @param is       The input stream
         */
        public JarInputStreamWrapper(JarEntry jarEntry, InputStream is) {
            this.jarEntry = jarEntry;
            this.is = is;
        }


        @Override
        public int read() throws IOException {
            return is.read();
        }


        @Override
        public int read(byte[] b) throws IOException {
            return is.read(b);
        }


        @Override
        public int read(byte[] b, int off, int len) throws IOException {
            return is.read(b, off, len);
        }


        @Override
        public long skip(long n) throws IOException {
            return is.skip(n);
        }


        @Override
        public int available() throws IOException {
            return is.available();
        }


        @Override
        public void close() throws IOException {
            if (closed.compareAndSet(false, true)) {
                // Must only call this once else the usage counting will break
                archiveResourceSet.closeJarFile();
            }
            is.close();
        }


        @Override
        public synchronized void mark(int readlimit) {
            is.mark(readlimit);
        }


        @Override
        public synchronized void reset() throws IOException {
            is.reset();
        }


        @Override
        public boolean markSupported() {
            return is.markSupported();
        }

        /**
         * Returns the certificates.
         *
         * @return the certificates
         */
        public Certificate[] getCertificates() {
            return jarEntry.getCertificates();
        }
    }
}
