/*
    GNU GENERAL PUBLIC LICENSE
    Copyright (C) 2006 The Lobo Project

    This program is free software; you can redistribute it and/or
    modify it under the terms of the GNU General Public
    License as published by the Free Software Foundation; either
    verion 2 of the License, or (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    General Public License for more details.

    You should have received a copy of the GNU General Public
    License along with this library; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

    Contact info: lobochief@users.sourceforge.net
 */
/*
 * Created on Jun 1, 2005
 */
package org.lobobrowser.request;

import java.io.IOException;
import java.net.URI;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.javatuples.Pair;
import org.jooq.DSLContext;
import org.jooq.Result;
import org.lobobrowser.main.PlatformInit;
import org.lobobrowser.store.RestrictedStore;
import org.lobobrowser.store.StorageManager;

import info.gngr.db.tables.Cookies;
import info.gngr.db.tables.records.CookiesRecord;

/**
 * @author J. H. S.
 */
public class CookieStore {
  private static final String COOKIE_PATH_PREFIX = ".W$Cookies/";
  private static final String COOKIE_PATH_PATTERN = "\\.W\\$Cookies/.*";
  private static final CookieStore instance = new CookieStore();

  private static final Logger logger = Logger.getLogger(CookieStore.class.getName());

  private final Map<String, Map<Pair<String, String>, CookieValue>> transientMapByHost = new HashMap<>();

  private CookieStore() {
  }

  public static CookieStore getInstance() {
    return instance;
  }

  public void saveCookie(final URI url, final String cookieSpec) {
    final String urlHostName = url.getHost();
    if (logger.isLoggable(Level.INFO)) {
      logger.info("saveCookie(): host=" + urlHostName + ",cookieSpec=[" + cookieSpec + "]");
    }
    final Optional<CookieDetails> cookieDetailsOpt = CookieParsing.parseCookieSpec(url, cookieSpec);
    if (cookieDetailsOpt.isPresent()) {
      final CookieDetails cookieDetails = cookieDetailsOpt.get();
      if (PlatformInit.getInstance().debugOn) {
        System.out.println("Cookie details: " + cookieDetails);
      }

      if (cookieDetails.name == null) {
        logger.log(Level.SEVERE, "saveCookie(): Invalid name in cookie spec from '" + urlHostName + "'");
        return;
      }

      if (!cookieDetails.isValidDomain()) {
        logger.log(Level.SEVERE, "saveCookie(): Invalid domain in cookie spec from '" + urlHostName + "'");
        return;
      }

      this.saveCookie(cookieDetails);
    }
  }

  private void saveCookie(final CookieDetails cookieDetails) {
    final String name = cookieDetails.name;
    final String domain = cookieDetails.getEffectiveDomain();
    final String domainTL = domain.toLowerCase();
    final Optional<java.util.Date> expiresOpt = cookieDetails.getExpiresDate();
    if (logger.isLoggable(Level.INFO)) {
      logger.info("saveCookie(): " + cookieDetails);
    }
    final Optional<Long> expiresLongOpt = expiresOpt.map(e -> e.getTime());
    final CookieValue cookieValue = new CookieValue(cookieDetails.name, cookieDetails.value, cookieDetails.getEffectivePath(),
        expiresLongOpt, cookieDetails.secure, cookieDetails.httpOnly, getMonotonicTime());
    synchronized (this) {
      // Always save a transient cookie. It acts as a cache.
      Map<Pair<String, String>, CookieValue> hostMap = this.transientMapByHost.get(domainTL);
      if (hostMap == null) {
        hostMap = new HashMap<>(2);
        this.transientMapByHost.put(domainTL, hostMap);
      }
      hostMap.put(new Pair<>(cookieDetails.name, cookieDetails.getEffectivePath()), cookieValue);
    }
    if (expiresLongOpt.isPresent()) {
      final DSLContext userDB = StorageManager.getInstance().getDB();
      userDB
          .mergeInto(Cookies.COOKIES)
          .values(domainTL, name, cookieValue.getValue(), cookieValue.getPath(), true, true, cookieValue.getCreationTime(),
              cookieValue.getExpires().orElse(null))
          .execute();
    }
  }

  // This should be 1000 * 1000, but for optimization has been converted to 1024*1024
  // TODO: This seems like premature optimisation, since cookies are not that frequently stored.
  private static final long MILLION_LIKE = 1024 * 1024;

  private long previousTimeNanos = System.currentTimeMillis() * MILLION_LIKE;

  private long getMonotonicTime() {
    final long previousTimeMillis = previousTimeNanos / MILLION_LIKE;
    final long currentMillis = System.currentTimeMillis();
    if (previousTimeMillis == currentMillis) {
      previousTimeNanos += 1;
      return previousTimeNanos;
    } else {
      final long currentNanos = currentMillis * MILLION_LIKE;
      previousTimeNanos = currentNanos;
      return currentNanos;
    }
  }

  private static String getPathFromCookieName(final String cookieName) {
    return COOKIE_PATH_PREFIX + cookieName;
  }

  private static String getCookieNameFromPath(final String path) {
    if (!path.startsWith(COOKIE_PATH_PREFIX)) {
      throw new IllegalArgumentException("Invalid path: " + path);
    }
    return path.substring(COOKIE_PATH_PREFIX.length());
  }

  /* Path-match algorithm as per section 5.4.1 of RFC 6264. */
  private static boolean pathMatch(final String cookiePath, final String requestPath) {
    if (cookiePath.equals(requestPath)) {
      return true;
    } else if (requestPath.startsWith(cookiePath)) {
      return ((cookiePath.charAt(cookiePath.length() - 1) == '/') || (requestPath.charAt(cookiePath.length()) == '/'));
    } else {
      return false;
    }
  }

  /**
   * Gets cookies belonging exactly to the host name given, not to a broader
   * domain.
   */
  private List<CookieValue> getCookiesStrict(final String protocol, final String hostName, String path) {
    final String hostNameTL = hostName.toLowerCase();
    if ((path == null) || (path.length() == 0)) {
      path = "/"; // TODO: Confirm that this is correct. Issue #14 in browserTesting
    }
    final boolean secureProtocol = "https".equalsIgnoreCase(protocol);
    final boolean liflag = logger.isLoggable(Level.INFO);
    // final Set<String> transientCookieNames = new HashSet<>();
    final Set<Pair<String, String>> transientCookieNames = new HashSet<>();
    final List<CookieValue> selectedCookies = new LinkedList<>();
    synchronized (this) {
      final Map<Pair<String, String>, CookieValue> hostMap = this.transientMapByHost.get(hostNameTL);
      if (hostMap != null) {
        final Iterator<Map.Entry<Pair<String, String>, CookieValue>> i = hostMap.entrySet().iterator();
        while (i.hasNext()) {
          final Map.Entry<Pair<String, String>, CookieValue> entry = i.next();
          final CookieValue cookieValue = entry.getValue();
          if (cookieValue.isExpired()) {
            if (liflag) {
              logger.info("getCookiesStrict(): Cookie " + entry.getKey() + " from " + hostNameTL + " expired: " + cookieValue.getExpires());
            }
          } else {
            if (pathMatch(cookieValue.getPath(), path)) {
              if (cookieValue.checkSecure(secureProtocol)) {
                final Pair<String, String> cookieNameAndPath = entry.getKey();
                // transientCookieNames.add(new Pair<>(cookieName, cookieValue.getPath()));
                transientCookieNames.add(cookieNameAndPath);
                selectedCookies.add(cookieValue);
              }
            } else {
              if (liflag) {
                logger.info("getCookiesStrict(): Skipping cookie " + cookieValue + " since it does not match path " + path);
              }
            }
          }
        }
      }
    }
    try {
      final RestrictedStore store = StorageManager.getInstance().getRestrictedStore(hostNameTL, false);
      if (store != null) {
        final DSLContext userDB = StorageManager.getInstance().getDB();
        final Result<CookiesRecord> cookieResult = userDB.selectFrom(Cookies.COOKIES)
            .where(Cookies.COOKIES.HOSTNAME.eq(hostNameTL))
            .fetch();

        if (cookieResult.isNotEmpty()) {
          final CookiesRecord cookiesRecord = cookieResult.get(0);
          final String cookieName = cookiesRecord.getName();
          final CookieValue cookieValue = new CookieValue(
              cookiesRecord.getName(),
              cookiesRecord.getValue(),
              cookiesRecord.getPath(),
              Optional.ofNullable(cookiesRecord.getExpirationtime()),
              cookiesRecord.getSecure(), cookiesRecord.getHttponly(),
              cookiesRecord.getCreationtime()
              );
          if (!transientCookieNames.contains(new Pair<>(cookieName, cookieValue.getPath()))) {
            if (cookieValue.isExpired()) {
              if (logger.isLoggable(Level.INFO)) {
                logger.info("getCookiesStrict(): Cookie " + cookieName + " from " + hostName + " expired: " + cookieValue.getExpires());
              }
              cookiesRecord.delete();
            } else {
              if (pathMatch(cookieValue.getPath(), path)) {
                // Found one that is not in main memory. Cache it.
                synchronized (this) {
                  Map<Pair<String, String>, CookieValue> hostMap = this.transientMapByHost.get(hostName);
                  if (hostMap == null) {
                    hostMap = new HashMap<>();
                    this.transientMapByHost.put(hostName, hostMap);
                  }
                  hostMap.put(new Pair<>(cookieName, cookieValue.getPath()), cookieValue);
                }
                if (cookieValue.checkSecure(secureProtocol)) {
                  // Now add cookie to the collection.
                  selectedCookies.add(cookieValue);
                }
              } else {
                if (logger.isLoggable(Level.INFO)) {
                  logger.info("getCookiesStrict(): Skipping cookie " + cookieValue + " since it does not match path " + path);
                }
              }
            }
          }
        }
      }
    } catch (final IOException ioe) {
      logger.log(Level.SEVERE, "getCookiesStrict()", ioe);
    }

    return selectedCookies;
  }

  public Collection<Cookie> getCookies(final String protocol, final String hostName, final String path) {
    // Security provided by RestrictedStore.
    final Collection<String> possibleDomains = DomainValidation.getPossibleDomains(hostName);
    final List<CookieValue> allCookies = new LinkedList<>();
    for (final String domain : possibleDomains) {
      allCookies.addAll(this.getCookiesStrict(protocol, domain, path));
    }
    allCookies.sort(null);
    final List<Cookie> cookies = new LinkedList<>();
    for (final CookieValue cookieValue : allCookies) {
      cookies.add(new Cookie(cookieValue.getName(), cookieValue.getValue()));
    }
    if (logger.isLoggable(Level.INFO)) {
      logger.info("getCookies(): For host=" + hostName + ", found " + cookies.size() + " cookies: " + cookies);
    }
    return cookies;
  }
}
