/*
 * 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.karaf.cave.repository.service.maven;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.Principal;
import java.util.Enumeration;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.AccountException;
import javax.security.auth.login.FailedLoginException;
import javax.security.auth.login.LoginContext;
import javax.security.auth.login.LoginException;
import javax.servlet.AsyncContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.bind.DatatypeConverter;

import org.apache.karaf.util.StreamUtils;
import org.ops4j.pax.url.mvn.MavenResolver;
import org.osgi.framework.Bundle;
import org.osgi.framework.FrameworkUtil;
import org.osgi.service.http.HttpContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MavenServlet extends HttpServlet {

    public static Logger LOGGER = LoggerFactory.getLogger(MavenServlet.class);

    public static final Pattern REPOSITORY_ID_REGEX = Pattern.compile("[^ ]*(@id=([^@ ]+))+[^ ]*");

    private static final String SNAPSHOT_TIMESTAMP_REGEX = "^([0-9]{8}.[0-9]{6}-[0-9]+).*";
    private static final Pattern SNAPSHOT_TIMESTAMP_PATTERN = Pattern.compile(SNAPSHOT_TIMESTAMP_REGEX);

    //The pattern below matches a path to the following:
    //1: groupId
    //2: artifactId
    //3: version
    //4: artifact filename
    public static final Pattern ARTIFACT_REQUEST_URL_REGEX = Pattern.compile("([^ ]+)/([^/ ]+)/([^/ ]+)/([^/ ]+)");

    //The pattern bellow matches the path to the following:
    //1: groupId
    //2: artifactId
    //3: version
    //4: maven-metadata xml filename
    //7: repository id.
    //9: type
    public static final Pattern ARTIFACT_METADATA_URL_REGEX = Pattern.compile("([^ ]+)/([^/ ]+)/([^/ ]+)/(maven-metadata([-]([^ .]+))?.xml)([.]([^ ]+))?");

    private static final String HEADER_WWW_AUTHENTICATE = "WWW-Authenticate";
    private static final String HEADER_AUTHORIZATION = "Authorization";
    private static final String AUTHENTICATION_SCHEME_BASIC = "Basic";

    protected static final String LOCATION_HEADER = "X-Location";

    private final ConcurrentMap<String, ArtifactDownloadFuture> requestMap = new ConcurrentHashMap<>();
    private final int threadMaximumPoolSize;
    private final String realm;
    private final String downloadRole;
    private final String uploadRole;
    private ThreadPoolExecutor executorService;

    protected File tmpFolder = new File(System.getProperty("karaf.data") + File.separator + "maven" + File.separator + "proxy" + File.separator + "tmp");

    final MavenResolver resolver;

    final String name;
    final String location;

    public MavenServlet(MavenResolver resolver, String name, String location, int threadMaximumPoolSize, String realm, String downloadRole, String uploadRole) {
        this.resolver = resolver;
        this.threadMaximumPoolSize = threadMaximumPoolSize;
        this.realm = realm;
        this.downloadRole = downloadRole;
        this.uploadRole = uploadRole;
        this.name = name;
        this.location = location;
    }

    //
    //  Lifecycle
    //

    @Override
    public void init() throws ServletException {
        if (!tmpFolder.exists() && !tmpFolder.mkdirs()) {
            throw new ServletException("Failed to create temporary artifact folder");
        }
        // Create a thread pool with the given maxmimum number of threads
        // All threads will time out after 60 seconds
        int nbThreads = threadMaximumPoolSize > 0 ? threadMaximumPoolSize : 8;
        executorService = new ThreadPoolExecutor(0, nbThreads, 60, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(), new ThreadFactory("MavenDownloadProxyServlet"));
    }

    @Override
    public void destroy() {
        if (executorService != null) {
            executorService.shutdown();
            try {
                executorService.awaitTermination(5, TimeUnit.MINUTES);
            } catch (InterruptedException e) {
                executorService.shutdownNow();
            }
        }
    }

    //
    // Security
    //

    protected boolean authorize(HttpServletRequest request, HttpServletResponse response, String role) throws IOException {
        if (role == null) {
            return true;
        }
        // Return immediately if the header is missing
        String authHeader = request.getHeader(HEADER_AUTHORIZATION);
        if (authHeader != null && authHeader.length() > 0) {

            // Get the authType (Basic, Digest) and authInfo (user/password)
            // from the header
            authHeader = authHeader.trim();
            int blank = authHeader.indexOf(' ');
            if (blank > 0) {
                String authType = authHeader.substring(0, blank);
                String authInfo = authHeader.substring(blank).trim();

                // Check whether authorization type matches
                if (authType.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) {
                    try {
                        String srcString = base64Decode(authInfo);
                        int i = srcString.indexOf(':');
                        String username = srcString.substring(0, i);
                        String password = srcString.substring(i + 1);

                        // authenticate
                        Subject subject = doAuthenticate(username, password, role);
                        if (subject != null) {
                            // as per the spec, set attributes
                            request.setAttribute(HttpContext.AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH);
                            request.setAttribute(HttpContext.REMOTE_USER, username);
                            // succeed
                            return true;
                        }
                    } catch (Exception e) {
                        // Ignore
                    }
                }
            }
        }

        // request authentication
        try {
            response.setHeader(HEADER_WWW_AUTHENTICATE, AUTHENTICATION_SCHEME_BASIC + " realm=\"" + this.realm + "\"");
            // must response with status and flush as Jetty may report org.eclipse.jetty.server.Response Committed before 401 null
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentLength(0);
            response.flushBuffer();
        } catch (IOException ioe) {
            // failed sending the response ... cannot do anything about it
        }

        // inform HttpService that authentication failed
        return false;
    }

    private static String base64Decode(String srcString) {
        byte[] transformed = DatatypeConverter.parseBase64Binary(srcString);
        return new String(transformed, StandardCharsets.ISO_8859_1);
    }

    public Subject doAuthenticate(final String username, final String password, final String role) {
        try {
            Subject subject = new Subject();
            LoginContext loginContext = new LoginContext(realm, subject, new CallbackHandler() {
                public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
                    for (Callback callback : callbacks) {
                        if (callback instanceof NameCallback) {
                            ((NameCallback) callback).setName(username);
                        } else if (callback instanceof PasswordCallback) {
                            ((PasswordCallback) callback).setPassword(password.toCharArray());
                        } else {
                            throw new UnsupportedCallbackException(callback);
                        }
                    }
                }
            });
            loginContext.login();
            if (role != null && role.length() > 0) {
                String clazz = "org.apache.karaf.jaas.boot.principal.RolePrincipal";
                String name = role;
                int idx = role.indexOf(':');
                if (idx > 0) {
                    clazz = role.substring(0, idx);
                    name = role.substring(idx + 1);
                }
                boolean found = false;
                for (Principal p : subject.getPrincipals()) {
                    if (p.getClass().getName().equals(clazz)
                            && p.getName().equals(name)) {
                        found = true;
                        break;
                    }
                }
                if (!found) {
                    throw new FailedLoginException("User does not have the required role " + role);
                }
            }
            return subject;
        } catch (AccountException e) {
            LOGGER.warn("Account failure", e);
            return null;
        } catch (LoginException e) {
            LOGGER.debug("Login failed", e);
            return null;
        }
    }

    //
    // Download
    //

    @Override
    protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException {
        if (!authorize(req, resp, downloadRole)) {
            return;
        }
        String tpath = req.getPathInfo();
        if (tpath == null) {
            resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            return;
        }
        if (tpath.startsWith("/")) {
            tpath = tpath.substring(1);
        }
        final String path = tpath;

        final AsyncContext asyncContext = req.startAsync();
        asyncContext.setTimeout(TimeUnit.MINUTES.toMillis(5));
        final ArtifactDownloadFuture future = new ArtifactDownloadFuture(path);
        ArtifactDownloadFuture masterFuture = requestMap.putIfAbsent(path, future);
        if (masterFuture == null) {
            masterFuture = future;
            masterFuture.lock();
            executorService.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        File file = download(path);
                        future.setValue(file);
                    } catch (Throwable t) {
                        future.setValue(t);
                    }
                }
            });
        } else {
            masterFuture.lock();
        }
        masterFuture.addListener(new FutureListener<ArtifactDownloadFuture>() {
            @Override
            public void operationComplete(ArtifactDownloadFuture future) {
                Object value = future.getValue();
                if (value instanceof Throwable) {
                    LOGGER.warn("Error while downloading artifact: {}", ((Throwable) value).getMessage(), value);
                    resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                } else if (value instanceof File) {
                    File artifactFile = (File) value;
                    try (InputStream is = new FileInputStream(artifactFile)) {
                        LOGGER.info("Writing response for file : {}", path);
                        resp.setStatus(HttpServletResponse.SC_OK);
                        resp.setContentType("application/octet-stream");
                        resp.setDateHeader("Date", System.currentTimeMillis());
                        resp.setHeader("Connection", "close");
                        resp.setContentLength(is.available());
                        Bundle bundle = FrameworkUtil.getBundle(getClass());
                        if (bundle != null) {
                            resp.setHeader("Server", bundle.getSymbolicName() + "/" + bundle.getVersion());
                        } else {
                            resp.setHeader("Server", "Karaf Maven Proxy");
                        }
                        StreamUtils.copy(is, resp.getOutputStream());
                    } catch (Exception e) {
                        LOGGER.warn("Error while sending artifact: {}", e.getMessage(), e);
                        resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                    }
                } else {
                    // browsing
                    try {
                        File requested = new File(location, path);
                        if (requested.exists()) {
                            if (requested.isDirectory()) {
                                Writer writer = new OutputStreamWriter(resp.getOutputStream());
                                writer.write("<html>");
                                writer.write("<head><title>Cave Repository " + name + ": " + path + "</title></head>");
                                writer.write("<body>");
                                writer.write("<header><h1>" + path + "</h1></header>");
                                writer.write("<hr/>");
                                writer.write("<main><pre id=\"contents\">");
                                if (!path.isEmpty()) {
                                    writer.write("<a href=\"" + req.getRequestURI() + "../\">..</a><br/>");
                                }
                                for (File child : requested.listFiles()) {
                                    writer.write("<a href=\"" + req.getRequestURI() + child.getName() + "/\" title=\"" + child.getName() + "\">" + child.getName() + "</a><br/>");
                                }
                                writer.write("</pre><hr/></main>");
                                writer.write("</body></html>");
                                writer.flush();
                            } else {
                                StreamUtils.copy(new FileInputStream(requested), resp.getOutputStream());
                            }
                        } else {
                            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
                        }
                    } catch (Exception e) {
                        LOGGER.warn("", e);
                        resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                    }
                }
                future.release();
                try {
                    asyncContext.complete();
                } catch (IllegalStateException e) {
                    // Ignore, the response must have already been sent with an error
                }
            }
        });
    }

    public File download(String path) throws InvalidMavenArtifactRequest {
        if (path == null) {
            throw new InvalidMavenArtifactRequest();
        }

        Matcher artifactMatcher = ARTIFACT_REQUEST_URL_REGEX.matcher(path);
        Matcher metadataMatcher = ARTIFACT_METADATA_URL_REGEX.matcher(path);

        if (metadataMatcher.matches()) {
            LOGGER.info("Received request for maven metadata : {}", path);
            try {
                MavenCoord coord = convertMetadataPathToCoord(path);
                return resolver.resolveMetadata(coord.groupId, coord.artifactId, coord.type, coord.version);
            } catch (Exception e) {
                LOGGER.warn(String.format("Could not find metadata : %s due to %s", path, e.getMessage()), e);
                return null;
            }
        } else if (artifactMatcher.matches()) {
            LOGGER.info("Received request for maven artifact : {}", path);
            try {
                MavenCoord artifact = convertArtifactPathToCoord(path);
                Path download = resolver.resolve(artifact.groupId, artifact.artifactId, artifact.classifier, artifact.type, artifact.version).toPath();
                Path tmpFile = Files.createTempFile("mvn-", ".tmp");
                Files.copy(download, tmpFile, StandardCopyOption.REPLACE_EXISTING);
                return tmpFile.toFile();
            } catch (Exception e) {
                LOGGER.warn(String.format("Could not find artifact : %s due to %s", path, e.getMessage()), e);
                return null;
            }
        }
        return null;
    }

    private class ArtifactDownloadFuture extends DefaultFuture<ArtifactDownloadFuture> {

        private final AtomicInteger participants = new AtomicInteger();
        private final String path;

        private ArtifactDownloadFuture(String path) {
            this.path = path;
        }

        public void lock() {
            participants.incrementAndGet();
        }

        public void release() {
            if (participants.decrementAndGet() == 0) {
                requestMap.remove(path);
                Object v = getValue();
                if (v instanceof File) {
                    ((File) v).delete();
                }
            }
        }

    }

    //
    // Upload
    //

    @Override
    protected void doPut(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        if (!authorize(request, response, uploadRole)) {
            return;
        }
        try {
            String path = request.getPathInfo();
            //Make sure path is valid
            if (path != null) {
                if (path.startsWith("/")) {
                    path = path.substring(1);
                }
            }
            if (path == null || path.isEmpty()) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                return;
            }

            boolean result;
            // handle move
            String location = request.getHeader(LOCATION_HEADER);
            if (location != null) {
                result = upload(new File(location), path, response);
            } else {
                Path dir = tmpFolder.toPath().resolve(UUID.randomUUID().toString());
                Path temp = dir.resolve(Paths.get(path).getFileName());
                Files.createDirectories(dir);
                try (OutputStream os = Files.newOutputStream(temp)) {
                    StreamUtils.copy(request.getInputStream(), os);
                }
                result = upload(temp.toFile(), path, response);
            }

            response.setStatus(result ? HttpServletResponse.SC_ACCEPTED : HttpServletResponse.SC_NOT_ACCEPTABLE);

        } catch (InvalidMavenArtifactRequest ex) {
            // must response with status and flush as Jetty may report org.eclipse.jetty.server.Response Committed before 401 null
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.setContentLength(0);
            response.flushBuffer();
        } catch (Exception ex) {
            // must response with status and flush as Jetty may report org.eclipse.jetty.server.Response Committed before 401 null
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            response.setContentLength(0);
            response.flushBuffer();
        }

    }

    protected boolean upload(File input, String path, HttpServletResponse response) throws InvalidMavenArtifactRequest, NoSuchFileException {
        if (!input.isFile()) {
            throw new NoSuchFileException(input.toString());
        }
        if (path == null) {
            throw new InvalidMavenArtifactRequest();
        }
        // root path, try reading mvn coords
        if (path.indexOf('/') < 0) {
            try {
                String mvnCoordsPath = readMvnCoordsPath(input);
                if (mvnCoordsPath != null) {
                    return install(input, mvnCoordsPath);
                } else {
                    response.addHeader(LOCATION_HEADER, input.toString()); // we need manual mvn coords input
                    return true;
                }
            } catch (Exception e) {
                LOGGER.warn(String.format("Failed to deploy artifact : %s due to %s", path, e.getMessage()), e);
                return false;
            }
        }

        return install(input, path);

    }

    private boolean install(File file, String path) {
        Matcher artifactMatcher = ARTIFACT_REQUEST_URL_REGEX.matcher(path);
        Matcher metadataMatcher = ARTIFACT_METADATA_URL_REGEX.matcher(path);

        if (metadataMatcher.matches()) {
            LOGGER.info("Received upload request for maven metadata : {}", path);
            try {
                MavenCoord coord = convertMetadataPathToCoord(path);
                resolver.uploadMetadata(coord.groupId, coord.artifactId, coord.type, coord.version, file);
                LOGGER.info("Maven metadata installed: {}", coord.toString());
                return true;
            } catch (Exception e) {
                LOGGER.warn(String.format("Failed to upload metadata: %s due to %s", path, e.getMessage()), e);
                return false;
            }
            //If no matching metadata found return nothing
        } else if (artifactMatcher.matches()) {
            LOGGER.info("Received upload request for maven artifact : {}", path);
            try {
                MavenCoord coord = convertArtifactPathToCoord(path);
                resolver.upload(coord.groupId, coord.artifactId, coord.classifier, coord.type, coord.version, file);
                LOGGER.info("Artifact installed: {}", coord.toString());
                return true;
            } catch (Exception e) {
                LOGGER.warn(String.format("Failed to upload artifact : %s due to %s", path, e.getMessage()), e);
                return false;
            }
        }
        return false;
    }

    protected static String readMvnCoordsPath(File file) throws Exception {
        try (JarFile jarFile = new JarFile(file)) {
            String previous = null;
            String match = null;

            Enumeration<JarEntry> entries = jarFile.entries();
            while (entries.hasMoreElements()) {
                JarEntry entry = entries.nextElement();
                String name = entry.getName();
                if (name.startsWith("META-INF/maven/") && name.endsWith("pom.properties")) {
                    if (previous != null) {
                        throw new IllegalStateException(String.format("Duplicate pom.properties found: %s != %s", name, previous));
                    }

                    previous = name; // check for dups

                    Properties props = new Properties();
                    try (InputStream stream = jarFile.getInputStream(entry)) {
                        props.load(stream);
                    }
                    String groupId = props.getProperty("groupId");
                    String artifactId = props.getProperty("artifactId");
                    String version = props.getProperty("version");
                    String type = getFileExtension(file);
                    match = String.format("%s/%s/%s/%s-%s.%s", groupId, artifactId, version, artifactId, version, type != null ? type : "jar");
                }
            }

            return match;
        }
    }

    private static String getFileExtension(File file) {
        String fileName = file.getName();
        int idx = fileName.lastIndexOf('.');
        if (idx > 1) {
            String answer = fileName.substring(idx + 1);
            if (answer.length() > 0) {
                return answer;
            }
        }
        return null;
    }

    /**
     * Converts the path of the request to maven coords.
     *
     * @param path The request path, following the format: {@code <groupId>/<artifactId>/<version>/<artifactId>-<version>-[<classifier>].extension}
     * @return A {@link MavenCoord}
     * @throws InvalidMavenArtifactRequest
     */
    protected MavenCoord convertArtifactPathToCoord(String path) throws InvalidMavenArtifactRequest {
        if (path == null) {
            throw new InvalidMavenArtifactRequest("Cannot match request path to maven url, request path is empty.");
        }
        Matcher pathMatcher = ARTIFACT_REQUEST_URL_REGEX.matcher(path);
        if (pathMatcher.matches()) {
            String groupId = pathMatcher.group(1).replaceAll("/", ".");
            String artifactId = pathMatcher.group(2);
            String version = pathMatcher.group(3);
            String filename = pathMatcher.group(4);
            String extension;
            String classifier = "";
            String filePerfix = artifactId + "-" + version;
            String stripedFileName;

            if (version.endsWith("SNAPSHOT")) {
                String baseVersion = version.replaceAll("-SNAPSHOT", "");
                String timestampedFileName = filename.substring(artifactId.length() + baseVersion.length() + 2);
                //Check if snapshot is timestamped and override the version. @{link Artifact} will still treat it as a SNAPSHOT.
                //and also in case of artifact installation the proper filename will be used.
                Matcher ts = SNAPSHOT_TIMESTAMP_PATTERN.matcher(timestampedFileName);
                if (ts.matches()) {
                    version = baseVersion + "-" + ts.group(1);
                    filePerfix = artifactId + "-" + version;
                }
                stripedFileName = filename.replaceAll(SNAPSHOT_TIMESTAMP_REGEX, "SNAPSHOT");
                stripedFileName = stripedFileName.substring(filePerfix.length());
            } else {
                stripedFileName = filename.substring(filePerfix.length());
            }

            if (stripedFileName.startsWith("-") && stripedFileName.contains(".")) {
                classifier = stripedFileName.substring(1, stripedFileName.indexOf('.'));
            }
            extension = stripedFileName.substring(stripedFileName.indexOf('.') + 1);

            MavenCoord coord = new MavenCoord();
            coord.groupId = groupId;
            coord.artifactId = artifactId;
            coord.type = extension;
            coord.classifier = classifier;
            coord.version = version;
            return coord;
        }
        return null;
    }

    /**
     * Converts the path of the request to {@link MavenCoord}.
     *
     * @param path The request path, following the format: {@code <groupId>/<artifactId>/<version>/<artifactId>-<version>-[<classifier>].extension}
     * @return A {@link MavenCoord}
     * @throws InvalidMavenArtifactRequest
     */
    protected MavenCoord convertMetadataPathToCoord(String path) throws InvalidMavenArtifactRequest {
        if (path == null) {
            throw new InvalidMavenArtifactRequest("Cannot match request path to maven url, request path is empty.");
        }
        Matcher pathMatcher = ARTIFACT_METADATA_URL_REGEX.matcher(path);
        if (pathMatcher.matches()) {
            MavenCoord coord = new MavenCoord();
            coord.groupId = pathMatcher.group(1).replaceAll("/", ".");
            coord.artifactId = pathMatcher.group(2);
            coord.version = pathMatcher.group(3);
            String type = pathMatcher.group(8);
            coord.type = type == null ? "maven-metadata.xml" : "maven-metadata.xml." + type;
            return coord;
        }
        return null;
    }

    static class MavenCoord {
        String groupId;
        String artifactId;
        String type;
        String classifier;
        String version;

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append(groupId).append(":").append(artifactId).append(":");
            sb.append(type).append(":");
            if (classifier != null && !classifier.isEmpty()) {
                sb.append(classifier).append(":");
            }
            sb.append(version);
            return sb.toString();
        }
    }

}
