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


#include "net/instaweb/rewriter/public/local_storage_cache_filter.h"

#include <set>

#include "base/logging.h"
#include "net/instaweb/rewriter/public/rewrite_driver.h"
#include "net/instaweb/rewriter/public/server_context.h"
#include "net/instaweb/rewriter/public/static_asset_manager.h"
#include "pagespeed/kernel/base/escaping.h"
#include "pagespeed/kernel/base/hasher.h"
#include "pagespeed/kernel/base/statistics.h"
#include "pagespeed/kernel/base/time_util.h"
#include "pagespeed/kernel/html/html_element.h"
#include "pagespeed/kernel/html/html_name.h"
#include "pagespeed/kernel/html/html_node.h"
#include "pagespeed/kernel/http/google_url.h"
#include "pagespeed/kernel/http/http_names.h"
#include "pagespeed/kernel/http/request_headers.h"

namespace net_instaweb {

const char LocalStorageCacheFilter::kLscCookieName[] = "_GPSLSC";
const char LocalStorageCacheFilter::kLscInitializer[] =
    "pagespeed.localStorageCacheInit();";

const char LocalStorageCacheFilter::kCandidatesFound[] =
    "num_local_storage_cache_candidates_found";
const char LocalStorageCacheFilter::kStoredTotal[] =
    "num_local_storage_cache_stored_total";
const char LocalStorageCacheFilter::kStoredImages[] =
    "num_local_storage_cache_stored_images";
const char LocalStorageCacheFilter::kStoredCss[] =
    "num_local_storage_cache_stored_css";
const char LocalStorageCacheFilter::kCandidatesAdded[] =
    "num_local_storage_cache_candidates_added";
const char LocalStorageCacheFilter::kCandidatesRemoved[] =
    "num_local_storage_cache_candidates_removed";

LocalStorageCacheFilter::LocalStorageCacheFilter(RewriteDriver* rewrite_driver)
    : RewriteFilter(rewrite_driver),
      script_inserted_(false),
      script_needs_inserting_(false) {
  Statistics* stats = server_context()->statistics();
  num_local_storage_cache_candidates_found_ =
      stats->GetVariable(kCandidatesFound);
  num_local_storage_cache_stored_total_ =
      stats->GetVariable(kStoredTotal);
  num_local_storage_cache_stored_images_ =
      stats->GetVariable(kStoredImages);
  num_local_storage_cache_stored_css_ =
      stats->GetVariable(kStoredCss);
  num_local_storage_cache_candidates_added_ =
      stats->GetVariable(kCandidatesAdded);
  num_local_storage_cache_candidates_removed_ =
      stats->GetVariable(kCandidatesRemoved);
}

LocalStorageCacheFilter::~LocalStorageCacheFilter() {
  cookie_hashes_.clear();
}

void LocalStorageCacheFilter::InitStats(Statistics* statistics) {
  statistics->AddVariable(LocalStorageCacheFilter::kCandidatesFound);
  statistics->AddVariable(LocalStorageCacheFilter::kStoredTotal);
  statistics->AddVariable(LocalStorageCacheFilter::kStoredImages);
  statistics->AddVariable(LocalStorageCacheFilter::kStoredCss);
  statistics->AddVariable(LocalStorageCacheFilter::kCandidatesAdded);
  statistics->AddVariable(LocalStorageCacheFilter::kCandidatesRemoved);
}

void LocalStorageCacheFilter::StartDocumentImpl() {
  script_inserted_ = false;
  script_needs_inserting_ = false;
}

void LocalStorageCacheFilter::EndDocument() {
  cookie_hashes_.clear();
}

void LocalStorageCacheFilter::StartElementImpl(HtmlElement* element) {
  // The css_inline_filter and image_rewrite_filter can add the LSC URL to
  // the inlined resource, indicating that we have to insert our JS for them.
  if (element->keyword() == HtmlName::kImg ||
      element->keyword() == HtmlName::kLink) {
    if (element->AttributeValue(HtmlName::kDataPagespeedLscUrl) != NULL) {
      // Note that we might end up not needing the inserted script because
      // the img/link might not be able to be inlined. So be it.
      script_needs_inserting_ = true;
    }
  }

  // We need to insert our javascript before the first element that uses it.
  if (script_needs_inserting_  && !script_inserted_) {
    InsertOurScriptElement(element);
  }
}

void LocalStorageCacheFilter::EndElementImpl(HtmlElement* element) {
  // An <img> or <link> that has a data-pagespeed-lsc-url attribute, and whose
  // URL's hash is in the LSC cookie, needs to be replaced by a JS snippet.
  bool is_img = false;
  bool is_link = false;
  if (element->keyword() == HtmlName::kImg) {
    is_img = true;
  } else if (element->keyword() == HtmlName::kLink) {
    is_link = true;
  }
  if (is_img || is_link) {
    const char* url = element->AttributeValue(HtmlName::kDataPagespeedLscUrl);
    if (url != NULL) {
      num_local_storage_cache_candidates_found_->Add(1);
      GoogleString hash = GenerateHashFromUrlAndElement(driver(), url, element);
      if (IsHashInCookie(driver(), kLscCookieName, hash, &cookie_hashes_)) {
        num_local_storage_cache_stored_total_->Add(1);
        StringPiece given_url(url);
        GoogleUrl abs_url(base_url(), given_url);
        StringPiece lsc_url(abs_url.IsWebValid() ? abs_url.Spec() : given_url);
        GoogleString snippet("pagespeed.localStorageCache.");
        if (is_img) {
          num_local_storage_cache_stored_images_->Add(1);
          StrAppend(&snippet, "inlineImg(\"", lsc_url, "\", \"", hash, "\"",
                    ExtractOtherImgAttributes(element), ");");
        } else /* is_link */ {
          num_local_storage_cache_stored_css_->Add(1);
          StrAppend(&snippet, "inlineCss(\"", lsc_url, "\");");
        }
        HtmlElement* script_element =
            driver()->NewElement(element->parent(), HtmlName::kScript);
        driver()->AddAttribute(script_element, HtmlName::kDataPagespeedNoDefer,
                               StringPiece());
        if (driver()->ReplaceNode(element, script_element)) {
          driver()->AppendChild(script_element,
                                driver()->NewCharactersNode(element, snippet));
        }
      }
    }
  }
}

void LocalStorageCacheFilter::InsertOurScriptElement(HtmlElement* before) {
  StaticAssetManager* static_asset_manager =
      driver()->server_context()->static_asset_manager();
  StringPiece local_storage_cache_js =
      static_asset_manager->GetAsset(
          StaticAssetEnum::LOCAL_STORAGE_CACHE_JS, driver()->options());
  const GoogleString& initialized_js = StrCat(local_storage_cache_js,
                                              kLscInitializer);
  HtmlElement* script_element = driver()->NewElement(before->parent(),
                                                     HtmlName::kScript);
  driver()->InsertNodeBeforeNode(before, script_element);
  AddJsToElement(initialized_js, script_element);
  driver()->AddAttribute(script_element, HtmlName::kDataPagespeedNoDefer,
                         StringPiece());
  script_inserted_ = true;
}

bool LocalStorageCacheFilter::AddStorableResource(const StringPiece& url,
                                                  RewriteDriver* driver,
                                                  bool skip_cookie_check,
                                                  HtmlElement* element,
                                                  InlineState* state) {
  // Only determine the state once.
  if (!state->initialized_) {
    // If LSC isn't enabled, we're done.
    state->enabled_ =
        driver->options()->Enabled(RewriteOptions::kLocalStorageCache);

    // Get the absolute LSC url from the link url if it's valid otherwise as-is.
    if (state->enabled_) {
      GoogleUrl gurl(driver->base_url(), url);
      StringPiece best_url(gurl.IsWebValid() ? gurl.Spec() : url);
      best_url.CopyToString(&state->url_);
    }

    state->initialized_ = true;
  }

  if (!state->enabled_) {
    return false;
  }

  // If we've been told to skip the cookie then mark the element regardless,
  // otherwise we need to check if the hash of the url is in the LSC cookie.
  bool add_the_attr = skip_cookie_check;
  if (!skip_cookie_check) {
    RewriteFilter* filter =
        driver->FindFilter(RewriteOptions::kLocalStorageCacheId);
    if (filter != NULL) {
      LocalStorageCacheFilter* lsc =
          static_cast<LocalStorageCacheFilter*>(filter);
      GoogleString hash = GenerateHashFromUrlAndElement(driver, state->url_,
                                                        element);
      add_the_attr = IsHashInCookie(driver, kLscCookieName, hash,
                                    lsc->mutable_cookie_hashes());
    }
  }

  // If necessary, set the data-pagespeed-lsc-url attribute in the element,
  // which later triggers the LSC filter to replace element with JS.
  if (add_the_attr) {
    driver->AddAttribute(element, HtmlName::kDataPagespeedLscUrl, state->url_);
  }

  return add_the_attr;
}

bool LocalStorageCacheFilter::AddLscAttributes(const StringPiece url,
                                               const CachedResult& cached,
                                               RewriteDriver* driver,
                                               HtmlElement* element) {
  if (!driver->options()->Enabled(RewriteOptions::kLocalStorageCache)) {
    return false;
  }

  // Don't add the other attributes if we don't have a data-pagespeed-lsc-url.
  if (element->AttributeValue(HtmlName::kDataPagespeedLscUrl) == NULL) {
    return false;
  }

  // TODO(matterbury): Determine how expensive this is and drop it if too high.
  RewriteFilter* filter =
      driver->FindFilter(RewriteOptions::kLocalStorageCacheId);
  if (filter != NULL) {
    LocalStorageCacheFilter* lsc =
        static_cast<LocalStorageCacheFilter*>(filter);
    lsc->num_local_storage_cache_candidates_added_->Add(1);
  }

  GoogleUrl gurl(driver->base_url(), url);
  StringPiece lsc_url(gurl.IsWebValid() ? gurl.Spec() : url);
  GoogleString hash = GenerateHashFromUrlAndElement(driver, lsc_url, element);
  driver->AddAttribute(element, HtmlName::kDataPagespeedLscHash, hash);
  if (cached.input_size() > 0) {
    const InputInfo& input_info = cached.input(0);
    if (input_info.has_expiration_time_ms()) {
      GoogleString expiry;
      if (ConvertTimeToString(input_info.expiration_time_ms(), &expiry)) {
        driver->AddAttribute(
            element, HtmlName::kDataPagespeedLscExpiry, expiry);
      }
    }
  }

  return true;
}

void LocalStorageCacheFilter::RemoveLscAttributes(HtmlElement* element,
                                                  RewriteDriver* driver) {
  if (!driver->options()->Enabled(RewriteOptions::kLocalStorageCache)) {
    return;
  }
  element->DeleteAttribute(HtmlName::kDataPagespeedLscUrl);
  element->DeleteAttribute(HtmlName::kDataPagespeedLscHash);
  element->DeleteAttribute(HtmlName::kDataPagespeedLscExpiry);

  RewriteFilter* filter =
      driver->FindFilter(RewriteOptions::kLocalStorageCacheId);
  if (filter != NULL) {
    LocalStorageCacheFilter* lsc =
        static_cast<LocalStorageCacheFilter*>(filter);
    lsc->num_local_storage_cache_candidates_removed_->Add(1);
  }
}

bool LocalStorageCacheFilter::IsHashInCookie(const RewriteDriver* driver,
                                             const StringPiece cookie_name,
                                             const StringPiece hash,
                                             std::set<StringPiece>* hash_set) {
  if (driver->request_headers() == NULL) {
    LOG(WARNING) << "LocalStorageCacheFilter::IsHashInCookie: NO HEADERS!";
    return false;
  }

  // If we have a cookie header and we haven't yet parsed it.
  if (hash_set->empty()) {
    ConstStringStarVector v;
    if (driver->request_headers()->Lookup(HttpAttributes::kCookie, &v)) {
      GoogleString prefix;
      StrAppend(&prefix, cookie_name, "=");
      for (int i = 0, nv = v.size(); i < nv; ++i) {
        StringPieceVector cookie_vector;
        SplitStringPieceToVector(*(v[i]), ";", &cookie_vector, true);
        for (int j = 0, nc = cookie_vector.size(); j < nc; ++j) {
          StringPiece cookie(cookie_vector[j]);
          TrimQuote(&cookie);
          if (StringCaseStartsWith(cookie, prefix)) {
            cookie.remove_prefix(prefix.length());
            StringPieceVector hashes;
            SplitStringPieceToVector(cookie, "!", &hashes, true /*omit empty*/);
            for (int k = 0, nh = hashes.size(); k < nh; ++k) {
              hash_set->insert(hashes[k]);
            }
            break;
          }
        }
      }
    }
    // If the named cookie isn't set, store a sentinel to prevent us from
    // checking again pointlessly next time we're called.
    if (hash_set->empty()) {
      hash_set->insert(StringPiece());
    }
  }
  return (hash_set->find(hash) != hash_set->end());
}

GoogleString LocalStorageCacheFilter::ExtractOtherImgAttributes(
    const HtmlElement* element) {
  // Copy over all the 'other' attributes from an img element except for
  // data-pagespeed-lsc-url, data-pagespeed-lsc-hash, data-pagespeed-lsc-expiry,
  // data-pagespeed-no-defer, pagespeed_no_defer, and src.
  GoogleString result;
  const HtmlElement::AttributeList& attrs = element->attributes();
  for (HtmlElement::AttributeConstIterator i(attrs.begin());
       i != attrs.end(); ++i) {
    const HtmlElement::Attribute& attr = *i;
    HtmlName::Keyword keyword = attr.keyword();
    if (keyword != HtmlName::kDataPagespeedLscUrl &&
        keyword != HtmlName::kDataPagespeedLscHash &&
        keyword != HtmlName::kDataPagespeedLscExpiry &&
        keyword != HtmlName::kDataPagespeedNoDefer &&
        keyword != HtmlName::kPagespeedNoDefer &&
        keyword != HtmlName::kSrc) {
      GoogleString escaped_js;
      // Escape problematic characters but don't quote it as we do that.
      if (attr.DecodedValueOrNull() != NULL) {
        EscapeToJsStringLiteral(attr.DecodedValueOrNull(), false, &escaped_js);
      }
      StrAppend(&result, ", \"", attr.name_str(), "=", escaped_js, "\"");
    }
  }
  return result;
}

GoogleString LocalStorageCacheFilter::GenerateHashFromUrlAndElement(
    const RewriteDriver* driver,
    const StringPiece& url,
    const HtmlElement* element) {
  GoogleString backing_string;
  StringPiece url_to_hash;
  // If the element has a width and/or height attribute, append them to the
  // given URL. Precede both with "!" to keep the logic simple; the resulting
  // URL is never used for anything other than hashing.
  // NOTE: We add the width and height because within the same page if the same
  // image appears multiple times with different resolutions, we do not want to
  // use the same cached image for all occurences. Currently, resolution is the
  // only thing we need to handle but if anything else comes up in the future
  // we might have to add it here as well (e.g, say a new attribute 'units' was
  // added that the cached image depended on; we'd need to add that here).
  // TODO(matterbury): Keep an eye on the attributes that make up the cache key
  // for images in RewriteContext.
  const char* width  = element->AttributeValue(HtmlName::kWidth);
  const char* height = element->AttributeValue(HtmlName::kHeight);
  if (width == NULL && height == NULL) {
    url_to_hash.set(url.data(), url.size());
  } else {
    url.CopyToString(&backing_string);
    if (width != NULL) {
      StrAppend(&backing_string, "!w=", width);
    }
    if (height != NULL) {
      StrAppend(&backing_string, "!h=", height);
    }
    url_to_hash.set(backing_string.data(), backing_string.size());
  }
  return driver->server_context()->hasher()->Hash(url_to_hash);
}

}  // namespace net_instaweb
