/**
 * 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.jooby.handlers;

import static java.util.Objects.requireNonNull;

import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.MessageFormat;
import java.time.Duration;
import java.util.Date;
import java.util.Map;

import org.jooby.Asset;
import org.jooby.Jooby;
import org.jooby.MediaType;
import org.jooby.Request;
import org.jooby.Response;
import org.jooby.Route;
import org.jooby.Status;
import org.jooby.internal.URLAsset;

import com.google.common.base.Strings;
import com.typesafe.config.ConfigFactory;
import com.typesafe.config.ConfigValueFactory;

import javaslang.Function1;
import javaslang.Function2;
import javaslang.control.Try;

/**
 * Serve static resources, via {@link Jooby#assets(String)} or variants.
 *
 * <h1>e-tag support</h1>
 * <p>
 * It generates <code>ETag</code> headers using {@link Asset#etag()}. It handles
 * <code>If-None-Match</code> header automatically.
 * </p>
 * <p>
 * <code>ETag</code> handling is enabled by default. If you want to disabled etag support
 * {@link #etag(boolean)}.
 * </p>
 *
 * <h1>modified since support</h1>
 * <p>
 * It generates <code>Last-Modified</code> header using {@link Asset#lastModified()}. It handles
 * <code>If-Modified-Since</code> header automatically.
 * </p>
 *
 * <h1>CDN support</h1>
 * <p>
 * Asset can be serve from a content delivery network (a.k.a cdn). All you have to do is to set the
 * <code>assets.cdn</code> property.
 * </p>
 *
 * <pre>
 * assets.cdn = "http://d7471vfo50fqt.cloudfront.net"
 * </pre>
 *
 * <p>
 * Resolved assets are redirected to the cdn.
 * </p>
 *
 * @author edgar
 * @since 0.1.0
 */
public class AssetHandler implements Route.Handler {

  private interface Loader {
    URL getResource(String name);
  }

  private static final Function1<String, String> prefix = prefix().memoized();

  private Function2<Request, String, String> fn;

  private Loader loader;

  private String cdn;

  private boolean etag = true;

  private long maxAge = -1;

  private boolean lastModified = true;

  /**
   * <p>
   * Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for
   * locating the static resource.
   * </p>
   *
   * Given <code>assets("/assets/**", "/")</code> with:
   *
   * <pre>
   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
   * </pre>
   *
   * Given <code>assets("/js/**", "/assets")</code> with:
   *
   * <pre>
   *   GET /js/index.js it translate the path to: /assets/js/index.js
   * </pre>
   *
   * Given <code>assets("/webjars/**", "/META-INF/resources/webjars/{0}")</code> with:
   *
   * <pre>
   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
   * </pre>
   *
   * @param pattern Pattern to locate static resources.
   * @param loader The one who load the static resources.
   */
  public AssetHandler(final String pattern, final ClassLoader loader) {
    init(Route.normalize(pattern), Paths.get("public"), loader);
  }

  /**
   * <p>
   * Creates a new {@link AssetHandler}. The handler accepts a location pattern, that serve for
   * locating the static resource.
   * </p>
   *
   * Given <code>assets("/assets/**", "/")</code> with:
   *
   * <pre>
   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
   * </pre>
   *
   * Given <code>assets("/js/**", "/assets")</code> with:
   *
   * <pre>
   *   GET /js/index.js it translate the path to: /assets/js/index.js
   * </pre>
   *
   * Given <code>assets("/webjars/**", "/META-INF/resources/webjars/{0}")</code> with:
   *
   * <pre>
   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
   * </pre>
   *
   * @param basedir Base directory.
   */
  public AssetHandler(final Path basedir) {
    init("/{0}", basedir, getClass().getClassLoader());
  }

  /**
   * <p>
   * Creates a new {@link AssetHandler}. The location pattern can be one of.
   * </p>
   *
   * Given <code>/</code> like in <code>assets("/assets/**", "/")</code> with:
   *
   * <pre>
   *   GET /assets/js/index.js it translates the path to: /assets/js/index.js
   * </pre>
   *
   * Given <code>/assets</code> like in <code>assets("/js/**", "/assets")</code> with:
   *
   * <pre>
   *   GET /js/index.js it translate the path to: /assets/js/index.js
   * </pre>
   *
   * Given <code>/META-INF/resources/webjars/{0}</code> like in
   * <code>assets("/webjars/**", "/META-INF/resources/webjars/{0}")</code> with:
   *
   * <pre>
   *   GET /webjars/jquery/2.1.3/jquery.js it translate the path to: /META-INF/resources/webjars/jquery/2.1.3/jquery.js
   * </pre>
   *
   * @param pattern Pattern to locate static resources.
   */
  public AssetHandler(final String pattern) {
    init(Route.normalize(pattern), Paths.get("public"), getClass().getClassLoader());
  }

  /**
   * @param etag Turn on/off etag support.
   * @return This handler.
   */
  public AssetHandler etag(final boolean etag) {
    this.etag = etag;
    return this;
  }

  /**
   * @param enabled Turn on/off last modified support.
   * @return This handler.
   */
  public AssetHandler lastModified(final boolean enabled) {
    this.lastModified = enabled;
    return this;
  }

  /**
   * @param cdn If set, every resolved asset will be serve from it.
   * @return This handler.
   */
  public AssetHandler cdn(final String cdn) {
    this.cdn = Strings.emptyToNull(cdn);
    return this;
  }

  /**
   * @param maxAge Set the cache header max-age value.
   * @return This handler.
   */
  public AssetHandler maxAge(final Duration maxAge) {
    return maxAge(maxAge.getSeconds());
  }

  /**
   * @param maxAge Set the cache header max-age value in seconds.
   * @return This handler.
   */
  public AssetHandler maxAge(final long maxAge) {
    this.maxAge = maxAge;
    return this;
  }

  /**
   * Parse value as {@link Duration}. If the value is already a number then it uses as seconds.
   * Otherwise, it parse expressions like: 8m, 1h, 365d, etc...
   *
   * @param maxAge Set the cache header max-age value in seconds.
   * @return This handler.
   */
  public AssetHandler maxAge(final String maxAge) {
    Try.of(() -> Long.parseLong(maxAge))
        .recover(x -> ConfigFactory.empty()
            .withValue("v", ConfigValueFactory.fromAnyRef(maxAge))
            .getDuration("v")
            .getSeconds())
        .onSuccess(this::maxAge);
    return this;
  }

  @Override
  public void handle(final Request req, final Response rsp) throws Throwable {
    String path = req.path();
    URL resource = resolve(req, path);

    if (resource != null) {
      String localpath = resource.getPath();
      int jarEntry = localpath.indexOf("!/");
      if (jarEntry > 0) {
        localpath = localpath.substring(jarEntry + 2);
      }

      URLAsset asset = new URLAsset(resource, path,
          MediaType.byPath(localpath).orElse(MediaType.octetstream));

      if (asset.exists()) {
        // cdn?
        if (cdn != null) {
          String absUrl = cdn + path;
          rsp.redirect(absUrl);
          rsp.end();
        } else {
          doHandle(req, rsp, asset);
        }
      }
    }
  }

  private void doHandle(final Request req, final Response rsp, final Asset asset) throws Throwable {

    // handle etag
    if (this.etag) {
      String etag = asset.etag();
      boolean ifnm = req.header("If-None-Match").toOptional()
          .map(etag::equals)
          .orElse(false);
      if (ifnm) {
        rsp.header("ETag", etag).status(Status.NOT_MODIFIED).end();
        return;
      }

      rsp.header("ETag", etag);
    }

    // Handle if modified since
    if (this.lastModified) {
      long lastModified = asset.lastModified();
      if (lastModified > 0) {
        boolean ifm = req.header("If-Modified-Since").toOptional(Long.class)
            .map(ifModified -> lastModified / 1000 <= ifModified / 1000)
            .orElse(false);
        if (ifm) {
          rsp.status(Status.NOT_MODIFIED).end();
          return;
        }
        rsp.header("Last-Modified", new Date(lastModified));
      }
    }

    // cache max-age
    if (maxAge > 0) {
      rsp.header("Cache-Control", "max-age=" + maxAge);
    }

    send(req, rsp, asset);
  }

  /**
   * Send an asset to the client.
   *
   * @param req Request.
   * @param rsp Response.
   * @param asset Resolve asset.
   * @throws Exception If send fails.
   */
  protected void send(final Request req, final Response rsp, final Asset asset) throws Throwable {
    rsp.send(asset);
  }

  private URL resolve(final Request req, final String path) throws Exception {
    String target = fn.apply(req, path);
    return resolve(target);
  }

  /**
   * Resolve a path as a {@link URL}.
   *
   * @param path Path of resource to resolve.
   * @return A URL or <code>null</code> for unresolved resource.
   * @throws Exception If something goes wrong.
   */
  protected URL resolve(final String path) throws Exception {
    return loader.getResource(path);
  }

  private void init(final String pattern, final Path basedir, final ClassLoader loader) {
    requireNonNull(loader, "Resource loader is required.");
    this.fn = pattern.equals("/")
        ? (req, p) -> prefix.apply(p)
        : (req, p) -> MessageFormat.format(prefix.apply(pattern), vars(req));
    this.loader = loader(basedir, loader);
  }

  private static Object[] vars(final Request req) {
    Map<Object, String> vars = req.route().vars();
    return vars.values().toArray(new Object[vars.size()]);
  }

  private static Loader loader(final Path basedir, final ClassLoader classloader) {
    if (Files.exists(basedir)) {
      return name -> {
        Path path = basedir.resolve(name).normalize();
        if (Files.exists(path) && path.startsWith(basedir)) {
          try {
            return path.toUri().toURL();
          } catch (MalformedURLException x) {
            // shh
          }
        }
        return classloader.getResource(name);
      };
    }
    return classloader::getResource;
  }

  private static Function1<String, String> prefix() {
    return p -> p.substring(1);
  }

}
