/**
 * 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;

import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.io.BaseEncoding;
import javaslang.Tuple;
import javaslang.Tuple2;
import javaslang.control.Try;
import org.jooby.internal.CookieImpl;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static java.util.Objects.requireNonNull;

/**
 * Creates a cookie, a small amount of information sent by a server to
 * a Web browser, saved by the browser, and later sent back to the server.
 * A cookie's value can uniquely
 * identify a client, so cookies are commonly used for session management.
 *
 * <p>
 * A cookie has a name, a single value, and optional attributes such as a comment, path and domain
 * qualifiers, a maximum age, and a version number.
 * </p>
 *
 * <p>
 * The server sends cookies to the browser by using the {@link Response#cookie(Cookie)} method,
 * which adds fields to HTTP response headers to send cookies to the browser, one at a time. The
 * browser is expected to support 20 cookies for each Web server, 300 cookies total, and may limit
 * cookie size to 4 KB each.
 * </p>
 *
 * <p>
 * The browser returns cookies to the server by adding fields to HTTP request headers. Cookies can
 * be retrieved from a request by using the {@link Request#cookie(String)} method. Several cookies
 * might have the same name but different path attributes.
 * </p>
 *
 * <p>
 * This class supports both the Version 0 (by Netscape) and Version 1 (by RFC 2109) cookie
 * specifications. By default, cookies are created using Version 0 to ensure the best
 * interoperability.
 * </p>
 *
 * @author edgar and various
 * @since 0.1.0
 */
public interface Cookie {

  /**
   * Decode a cookie value using, like: <code>k=v</code>, multiple <code>k=v</code> pair are
   * separated by <code>&amp;</code>. Also, <code>k</code> and <code>v</code> are decoded using
   * {@link URLDecoder}.
   */
  public static final Function<String, Map<String, String>> URL_DECODER = value -> {
    if (value == null) {
      return Collections.emptyMap();
    }
    Function<String, String> decode = v -> Try
        .of(() -> URLDecoder.decode(v, StandardCharsets.UTF_8.name()))
        .get();
    return Splitter.on('&')
        .trimResults()
        .omitEmptyStrings()
        .splitToList(value)
        .stream()
        .map(v -> {
          Iterator<String> it = Splitter.on('=').trimResults().omitEmptyStrings()
              .split(v)
              .iterator();
          Tuple2<String, String> t2 = Tuple
              .of(decode.apply(it.next()), it.hasNext() ? decode.apply(it.next()) : null);
          return t2;
        })
        .filter(it -> Objects.nonNull(it._2))
        .collect(Collectors.toMap(it -> it._1, it -> it._2));
  };

  /**
   * Encode a hash into cookie value, like: <code>k1=v1&amp;...&amp;kn=vn</code>. Also,
   * <code>key</code> and <code>value</code> are encoded using {@link URLEncoder}.
   */
  public static final Function<Map<String, String>, String> URL_ENCODER = value -> {
    Function<String, String> encode = v -> Try
        .of(() -> URLEncoder.encode(v, StandardCharsets.UTF_8.name())).get();
    return value.entrySet().stream()
        .map(e -> new StringBuilder()
            .append(encode.apply(e.getKey()))
            .append('=')
            .append(encode.apply(e.getValue())))
        .collect(Collectors.joining("&"))
        .toString();
  };

  /**
   * Build a {@link Cookie}.
   *
   * @author edgar
   * @since 0.1.0
   */
  class Definition {

    /** Cookie's name. */
    private String name;

    /** Cookie's value. */
    private String value;

    /** Cookie's domain. */
    private String domain;

    /** Cookie's path. */
    private String path;

    /** Cookie's comment. */
    private String comment;

    /** HttpOnly flag. */
    private Boolean httpOnly;

    /** True, ensure that the session cookie is only transmitted via HTTPS. */
    private Boolean secure;

    /**
     * By default, <code>-1</code> is returned, which indicates that the cookie will persist until
     * browser shutdown.
     */
    private Integer maxAge;

    /**
     * Creates a new {@link Definition cookie's definition}.
     */
    protected Definition() {
    }

    /**
     * Clone a new {@link Definition cookie's definition}.
     *
     * @param def A cookie's definition.
     */
    public Definition(final Definition def) {
      this.comment = def.comment;
      this.domain = def.domain;
      this.httpOnly = def.httpOnly;
      this.maxAge = def.maxAge;
      this.name = def.name;
      this.path = def.path;
      this.secure = def.secure;
      this.value = def.value;
    }

    /**
     * Creates a new {@link Definition cookie's definition}.
     *
     * @param name Cookie's name.
     * @param value Cookie's value.
     */
    public Definition(final String name, final String value) {
      name(name);
      value(value);
    }

    /**
     * Creates a new {@link Definition cookie's definition}.
     *
     * @param name Cookie's name.
     */
    public Definition(final String name) {
      name(name);
    }

    /**
     * Produces a cookie from current definition.
     *
     * @return A new cookie.
     */
    public Cookie toCookie() {
      return new CookieImpl(this);
    }

    @Override
    public String toString() {
      return toCookie().encode();
    }

    /**
     * Set/Override the cookie's name.
     *
     * @param name A cookie's name.
     * @return This definition.
     */
    public Definition name(final String name) {
      this.name = requireNonNull(name, "A cookie name is required.");
      return this;
    }

    /**
     * @return Cookie's name.
     */
    public Optional<String> name() {
      return Optional.ofNullable(name);
    }

    /**
     * Set the cookie's value.
     *
     * @param value A value.
     * @return This definition.
     */
    public Definition value(final String value) {
      this.value = requireNonNull(value, "A cookie value is required.");
      return this;
    }

    /**
     * @return Cookie's value.
     */
    public Optional<String> value() {
      if (Strings.isNullOrEmpty(value)) {
        return Optional.empty();
      }
      return Optional.of(value);
    }

    /**
     * Set the cookie's domain.
     *
     * @param domain Cookie's domain.
     * @return This definition.
     */
    public Definition domain(final String domain) {
      this.domain = requireNonNull(domain, "A cookie domain is required.");
      return this;
    }

    /**
     * @return A cookie's domain.
     */
    public Optional<String> domain() {
      return Optional.ofNullable(domain);
    }

    /**
     * Set the cookie's path.
     *
     * @param path Cookie's path.
     * @return This definition.
     */
    public Definition path(final String path) {
      this.path = requireNonNull(path, "A cookie path is required.");
      return this;
    }

    /**
     * @return Get cookie's path.
     */
    public Optional<String> path() {
      return Optional.ofNullable(path);
    }

    /**
     * Set cookie's comment.
     *
     * @param comment A cookie's comment.
     * @return This definition.
     */
    public Definition comment(final String comment) {
      this.comment = requireNonNull(comment, "A cookie comment is required.");
      return this;
    }

    /**
     * @return Cookie's comment.
     */
    public Optional<String> comment() {
      return Optional.ofNullable(comment);
    }

    /**
     * Set HttpOnly flag.
     *
     * @param httpOnly True, for HTTP Only.
     * @return This definition.
     */
    public Definition httpOnly(final boolean httpOnly) {
      this.httpOnly = httpOnly;
      return this;
    }

    /**
     * @return HTTP only flag.
     */
    public Optional<Boolean> httpOnly() {
      return Optional.ofNullable(httpOnly);
    }

    /**
     * True, ensure that the session cookie is only transmitted via HTTPS.
     *
     * @param secure True, ensure that the session cookie is only transmitted via HTTPS.
     * @return This definition.
     */
    public Definition secure(final boolean secure) {
      this.secure = secure;
      return this;
    }

    /**
     * @return True, ensure that the session cookie is only transmitted via HTTPS.
     */
    public Optional<Boolean> secure() {
      return Optional.ofNullable(secure);
    }

    /**
     * Sets the maximum age in seconds for this Cookie.
     *
     * <p>
     * A positive value indicates that the cookie will expire after that many seconds have passed.
     * Note that the value is the <i>maximum</i> age when the cookie will expire, not the cookie's
     * current age.
     * </p>
     *
     * <p>
     * A negative value means that the cookie is not stored persistently and will be deleted when
     * the Web browser exits. A zero value causes the cookie to be deleted.
     * </p>
     *
     * @param maxAge an integer specifying the maximum age of the cookie in seconds; if negative,
     * means the cookie is not stored; if zero, deletes the cookie.
     * @return This definition.
     */
    public Definition maxAge(final int maxAge) {
      this.maxAge = maxAge;
      return this;
    }

    /**
     * Gets the maximum age in seconds for this Cookie.
     *
     * <p>
     * A positive value indicates that the cookie will expire after that many seconds have passed.
     * Note that the value is the <i>maximum</i> age when the cookie will expire, not the cookie's
     * current age.
     * </p>
     *
     * <p>
     * A negative value means that the cookie is not stored persistently and will be deleted when
     * the Web browser exits. A zero value causes the cookie to be deleted.
     * </p>
     *
     * @return Cookie's max age in seconds.
     */
    public Optional<Integer> maxAge() {
      return Optional.ofNullable(maxAge);
    }

  }

  /**
   * Sign cookies using a HMAC algorithm plus SHA-256 hash.
   * Usage:
   *
   * <pre>
   *   String signed = Signature.sign("hello", "mysecretkey");
   *   ...
   *   // is it valid?
   *   assertEquals(signed, Signature.unsign(signed, "mysecretkey");
   * </pre>
   *
   * @author edgar
   * @since 0.1.0
   */
  public class Signature {

    /** Remove trailing '='. */
    private static final Pattern EQ = Pattern.compile("=+$");

    /** Algorithm name. */
    public static final String HMAC_SHA256 = "HmacSHA256";

    /** Signature separator. */
    private static final String SEP = "|";

    /**
     * Sign a value using a secret key. A value and secret key are required. Sign is done with
     * {@link #HMAC_SHA256}.
     * Signed value looks like:
     *
     * <pre>
     *   [signed value] '|' [raw value]
     * </pre>
     *
     * @param value A value to sign.
     * @param secret A secret key.
     * @return A signed value.
     */
    public static String sign(final String value, final String secret) {
      requireNonNull(value, "A value is required.");
      requireNonNull(secret, "A secret is required.");

      try {
        Mac mac = Mac.getInstance(HMAC_SHA256);
        mac.init(new SecretKeySpec(secret.getBytes(), HMAC_SHA256));
        byte[] bytes = mac.doFinal(value.getBytes());
        return EQ.matcher(BaseEncoding.base64().encode(bytes)).replaceAll("") + SEP + value;
      } catch (Exception ex) {
        throw new IllegalArgumentException("Can't sing value", ex);
      }
    }

    /**
     * Un-sign a value, previously signed with {@link #sign(String, String)}.
     * Try {@link #valid(String, String)} to check for valid signed values.
     *
     * @param value A signed value.
     * @param secret A secret key.
     * @return A new signed value.
     */
    public static String unsign(final String value, final String secret) {
      requireNonNull(value, "A value is required.");
      requireNonNull(secret, "A secret is required.");
      int sep = value.indexOf(SEP);
      if (sep <= 0) {
        return null;
      }
      String str = value.substring(sep + 1);
      String mac = sign(str, secret);

      return mac.equals(value) ? str : null;
    }

    /**
     * True, if the given signed value is valid.
     *
     * @param value A signed value.
     * @param secret A secret key.
     * @return True, if the given signed value is valid.
     */
    public static boolean valid(final String value, final String secret) {
      return unsign(value, secret) != null;
    }

  }

  /**
   * @return Cookie's name.
   */
  String name();

  /**
   * @return Cookie's value.
   */
  Optional<String> value();

  /**
   * @return An optional comment.
   */
  Optional<String> comment();

  /**
   * @return Cookie's domain.
   */
  Optional<String> domain();

  /**
   * Gets the maximum age of this cookie (in seconds).
   *
   * <p>
   * By default, <code>-1</code> is returned, which indicates that the cookie will persist until
   * browser shutdown.
   * </p>
   *
   * @return An integer specifying the maximum age of the cookie in seconds; if negative, means
   * the cookie persists until browser shutdown
   */
  int maxAge();

  /**
   * @return Cookie's path.
   */
  Optional<String> path();

  /**
   * Returns <code>true</code> if the browser is sending cookies only over a secure protocol, or
   * <code>false</code> if the browser can send cookies using any protocol.
   *
   * @return <code>true</code> if the browser uses a secure protocol, <code>false</code> otherwise.
   */
  boolean secure();

  /**
   * @return True if HTTP Only.
   */
  boolean httpOnly();

  /**
   * @return Encode the cookie.
   */
  String encode();
}
