package act.handler.builtin;

/*-
 * #%L
 * ACT Framework
 * %%
 * Copyright (C) 2014 - 2017 ActFramework
 * %%
 * Licensed 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.
 * #L%
 */

import static org.osgl.http.H.Format.*;
import static org.osgl.http.H.Header.Names.CACHE_CONTROL;

import act.Act;
import act.ActResponse;
import act.app.ActionContext;
import act.app.App;
import act.app.event.SysEventId;
import act.conf.AppConfig;
import act.controller.ParamNames;
import act.handler.RequestHandler;
import act.handler.builtin.controller.FastRequestHandler;
import act.util.$$;
import org.osgl.$;
import org.osgl.http.H;
import org.osgl.mvc.result.NotFound;
import org.osgl.util.*;

import java.io.*;
import java.net.URL;
import java.nio.ByteBuffer;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/**
 * Unlike a {@link FileGetter}, the
 * `StaticResourceGetter` read resource from jar packages
 */
public class ResourceGetter extends FastRequestHandler {

    private static final char SEP = '/';

    private FastRequestHandler delegate;

    private App app;
    private String base;
    private URL baseUrl;
    private int preloadSizeLimit;
    private boolean isFolder;
    private ByteBuffer buffer;
    private H.Format preloadedContentType;
    private boolean preloadFailure;
    private boolean preloaded;
    private String etag;
    private boolean filterResource;
    private volatile RequestHandler indexHandler;
    private ConcurrentMap<String, RequestHandler> subFolderIndexHandlers = new ConcurrentHashMap<>();

    private Set<URL> folders = new HashSet<>();
    private Map<String, String> etags = new HashMap<>();
    private Map<String, ByteBuffer> cachedBuffers = new HashMap<>();
    private Map<String, H.Format> cachedContentType = new HashMap<>();
    private Map<String, Boolean> cachedFailures = new HashMap<>();
    private Set<String> largeResource = new HashSet<>();

    public ResourceGetter(String base) {
        E.illegalArgumentIf(S.blank(base), "empty resource string encountered");
        String path = base.charAt(0) == SEP ? base.substring(1) : base;
        this.base = path;
        this.app = Act.app();
        this.filterResource = app.config().resourceFiltering();
        this.baseUrl = app.getResource(path);
        this.delegate = verifyBase(this.baseUrl, base);
        if (null == delegate) {
            this.isFolder = isFolder(this.baseUrl, path);
            if (!this.isFolder && "file".equals(baseUrl.getProtocol())) {
                Act.jobManager().on(SysEventId.START, "ResourceGetter[" + base + "]:preloadCache", new Runnable() {
                    @Override
                    public void run() {
                        preloadCache();
                    }
                });
            }
            this.preloadSizeLimit = Act.appConfig().resourcePreloadSizeLimit();
        }
    }

    @Override
    protected void releaseResources() {
    }

    @Override
    public boolean express(ActionContext context) {
        if (preloaded || null != delegate) {
            return true;
        }
        String path = context.__pathParamVal();
        return Act.isProd() &&
                (cachedBuffers.containsKey(path)
                        || cachedFailures.containsKey(path)
                        || (null != context.req().etag() && context.req().etagMatches(etags.get(path))));
    }

    /**
     * No result commit events triggering for resource getter.
     * @param context the action context
     * @return `true`
     */
    @Override
    public boolean skipEvents(ActionContext context) {
        return true;
    }

    @Override
    public void handle(ActionContext context) {
        if (null != delegate) {
            delegate.handle(context);
            return;
        }
        context.handler(this);
        String path = path(context);
        handle(path, context);
    }

    protected String path(ActionContext context) {
        return context.__pathParamVal();
    }

    protected void handle(String path, ActionContext context) {
        H.Request req = context.req();
        final boolean isProd = Act.isProd();
        if (isProd) {
            ActResponse resp = context.prepareRespForResultEvaluation();
            if (preloaded) {
                // this is a reloaded file resource
                if (preloadFailure) {
                    AlwaysNotFound.INSTANCE.handle(context);
                } else {
                    resp.contentType(preloadedContentType);
                    if (req.etagMatches(etag)) {
                        // still need to generates the headers
                        // see https://stackoverflow.com/questions/1587667/should-http-304-not-modified-responses-contain-cache-control-headers
                        resp.header(CACHE_CONTROL, "max-age=86400").etag(this.etag);
                        AlwaysNotModified.INSTANCE.handle(context);
                    } else {
                        H.Format contentType = cachedContentType.get(path);
                        if (null == contentType) {
                            contentType = req.contentType();
                        }
                        resp
                                .contentType(contentType)
                                .header(CACHE_CONTROL, "max-age=86400")
                                .etag(this.etag)
                                .writeContent(buffer.duplicate());
                    }
                }
                return;
            }

            if (cachedFailures.containsKey(path)) {
                AlwaysNotFound.INSTANCE.handle(context);
                return;
            }

            final String reqEtag = req.etag();
            if (null != reqEtag && req.etagMatches(etags.get(path))) {
                H.Format contentType = cachedContentType.get(path);
                if (null == contentType) {
                    contentType = req.contentType();
                }
                // still need to generates the headers
                // see https://stackoverflow.com/questions/1587667/should-http-304-not-modified-responses-contain-cache-control-headers
                resp.contentType(contentType).header(CACHE_CONTROL, "max-age=86400").etag(reqEtag);
                AlwaysNotModified.INSTANCE.handle(context);
                return;
            }
        }
        ByteBuffer buffer = cachedBuffers.get(path);
        if (null != buffer) {
            context.resp()
                    .contentType(cachedContentType.get(path))
                    .commitContentType()
                    .header(CACHE_CONTROL, "public, max-age=7200")
                    .etag(etags.get(path))
                    .writeContent(buffer.duplicate());
            return;
        }
        try {
            URL target;
            String loadPath;
            if (S.blank(path)) {
                target = baseUrl;
                loadPath = base;
                if (isFolder) {
                    if (null == indexHandler) {
                        synchronized (this) {
                            if (null == indexHandler) {
                                loadPath = S.pathConcat(base, SEP, "index.html");
                                target = app.getResource(loadPath);
                            }
                            indexHandler = null == target ? AlwaysForbidden.INSTANCE : new FixedResourceGetter(loadPath);
                        }
                    }
                    indexHandler.handle(context);
                    return;
                }
            } else {
                loadPath = S.pathConcat(base, SEP, path);
                target = app.getResource(loadPath);
                if (null == target) {
                    AlwaysNotFound.INSTANCE.handle(context);
                    return;
                }
            }
            if (preventFolderAccess(target, loadPath, context)) {
                AlwaysForbidden.INSTANCE.handle(context);
                return;
            }
            ActResponse resp = context.prepareRespForResultEvaluation();
            H.Format contentType;
            if (isProd) {
                contentType = cachedContentType.get(path);
                if (null == contentType) {
                    contentType = FileGetter.contentType(target.getPath());
                    cachedContentType.put(path, contentType);
                }
            } else {
                contentType = FileGetter.contentType(target.getPath());
            }
            resp.contentType(contentType).commitContentType();
            if (isProd) {
                resp.header(CACHE_CONTROL, "max-age=86400");
                String etag = etags.get(path);
                if (null != etag) {
                    resp.etag(etag);
                }
            }
            context.applyCorsSpec().applyContentSecurityPolicy().applyContentType();
            final AppConfig config = context.config();
            boolean isFile = "file".equals(target.getProtocol());
            if (isFile) {
                File file = new File(target.getPath());
                long len = file.length();
                if (Act.isProd()) {
                    etags.put(path, String.valueOf(len));
                    boolean smallResource = len < config.resourcePreloadSizeLimit();
                    if (smallResource) {
                        $.Var<String> etagBag = $.var();
                        buffer = doPreload(target, etagBag);
                        if (null == buffer) {
                            cachedFailures.put(path, true);
                            return;
                        }
                        cachedBuffers.put(path, buffer);
                        resp.writeContent(buffer.duplicate());
                        return;
                    }
                }
                if (!filterResource || FileGetter.isBinary(contentType)) {
                    resp.send(file);
                } else {
                    String content = IO.readContentAsString(file);
                    content = $$.processStringSubstitution(content);
                    resp.writeContent(content);
                }
            } else if (largeResource.contains(path)) {
                resp.send(target);
            } else {
                try {
                    int n = IO.copy(target.openStream(), resp.outputStream());
                    resp.afterWritingContent();
                    boolean smallResource = n < config.resourcePreloadSizeLimit();
                    if (!smallResource) {
                        largeResource.add(path);
                    }
                    if (Act.isProd()) {
                        etags.put(path, String.valueOf(n));
                        if (smallResource) {
                            $.Var<String> etagBag = $.var();
                            buffer = doPreload(target, etagBag);
                            if (null == buffer) {
                                cachedFailures.put(path, true);
                            } else {
                                cachedBuffers.put(path, buffer);
                                cachedContentType.put(path, contentType);
                            }
                        }
                    }
                } catch (NullPointerException e) {
                    // this is caused by accessing folder inside jar URL
                    folders.add(target);
                    AlwaysForbidden.INSTANCE.handle(context);
                }
            }
        } catch (IOException e) {
            logger.warn(e, "Error servicing static resource request");
            throw NotFound.get();
        }
    }

    private boolean preventFolderAccess(URL target, String path, ActionContext context) {
        RequestHandler folderHandler = subFolderIndexHandlers.get(path);
        if (null != folderHandler) {
            folderHandler.handle(context);
            return true;
        }
        if (isFolder(target, path)) {
            String indexPath = S.pathConcat(path, SEP, "index.html");
            URL indexTarget = app.getResource(indexPath);
            if (null != indexTarget) {
                folderHandler = exists(indexTarget, indexPath) ? new FixedResourceGetter(indexPath) : AlwaysForbidden.INSTANCE;
                subFolderIndexHandlers.putIfAbsent(path, folderHandler);
                folderHandler.handle(context);
            }
            return true;
        }
        return false;
    }

    private boolean isFolder(URL target, String path) {
        if ("file".equals(target.getProtocol())) {
            File file = new File(target.getFile());
            return file.isDirectory();
        }
        if ("jar".equals(target.getProtocol())) {
            if (path.endsWith("/")) {
                return true;
            }
            URL url = app.getResource(S.ensureEndsWith(path, "/"));
            return null != url;
        }
        return false;
    }

    private boolean exists(URL target, String path) {
        if ("file".equals(target.getProtocol())) {
            File file = new File(target.getFile());
            return file.exists();
        }
        if ("jar".equals(target.getProtocol())) {
            URL url = app.getResource(path);
            return null != url;
        }
        return false;
    }

    private void preloadCache() {
        if (Act.isDev()) {
            return;
        }
        H.Format contentType = FileGetter.contentType(baseUrl.getPath());
        if (HTML == contentType || CSS == contentType || JAVASCRIPT == contentType
                || TXT == contentType || CSV == contentType
                || JSON == contentType || XML == contentType
                || resourceSizeIsOkay()) {
            $.Var<String> etagBag = $.var();
            buffer = doPreload(baseUrl, etagBag);
            if (null == buffer) {
                preloadFailure = true;
            } else {
                this.etag = etagBag.get();
            }
            preloadedContentType = contentType;
            preloaded = true;
        }
    }

    private ByteBuffer doPreload(URL target, $.Var<String> etagBag) {
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            IO.copy(target.openStream(), baos);
            byte[] ba = baos.toByteArray();
            buffer = ByteBuffer.wrap(ba);
            etagBag.set(String.valueOf(Arrays.hashCode(ba)));
            return buffer;
        } catch (IOException e) {
            Act.LOGGER.warn(e, "Error loading resource: %s", baseUrl.getPath());
        }
        return null;
    }

    private boolean resourceSizeIsOkay() {
        if (preloadSizeLimit <= 0) {
            return false;
        }
        if ("file".equals(baseUrl.getProtocol())) {
            File file = new File(baseUrl.getFile());
            return file.length() < preloadSizeLimit;
        }
        return false;
    }

    @Override
    public boolean supportPartialPath() {
        return isFolder;
    }

    @Override
    public String toString() {
        S.Buffer buf = S.buffer().append("resource: ").append(base);
        if (null == baseUrl) {
            buf.append("(not found)");
        }
        return buf.toString();
    }

    /*
     * If base is valid then return null
     * otherwise return delegate request handler
     */
    private FastRequestHandler verifyBase(URL baseUrl, String baseSupplied) {
        if (null == baseUrl) {
            if ("META-INF/resources/webjars".equalsIgnoreCase(baseSupplied)) {
                // webjars not provided, just ignore it.
            } else {
                logger.warn("URL base not exists: " + baseSupplied);
            }
            return AlwaysNotFound.INSTANCE;
        }
        return null;
    }

}
