// Copyright (C) 2015 The Android Open Source Project
//
// 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.

package com.google.gerrit.httpd.raw;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_OK;

import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.Lists;
import com.google.common.io.ByteStreams;
import com.google.common.jimfs.Configuration;
import com.google.common.jimfs.Jimfs;
import com.google.gerrit.httpd.raw.ResourceServlet.Resource;
import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.GZIPInputStream;
import org.junit.Before;
import org.junit.Test;

public class ResourceServletTest {
  private static Cache<Path, Resource> newCache(int size) {
    return CacheBuilder.newBuilder().maximumSize(size).recordStats().build();
  }

  private static class Servlet extends ResourceServlet {
    private static final long serialVersionUID = 1L;

    private final FileSystem fs;

    private Servlet(FileSystem fs, Cache<Path, Resource> cache, boolean refresh) {
      super(cache, refresh);
      this.fs = fs;
    }

    private Servlet(
        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, boolean cacheOnClient) {
      super(cache, refresh, cacheOnClient);
      this.fs = fs;
    }

    private Servlet(
        FileSystem fs, Cache<Path, Resource> cache, boolean refresh, int cacheFileSizeLimitBytes) {
      super(cache, refresh, true, cacheFileSizeLimitBytes);
      this.fs = fs;
    }

    private Servlet(
        FileSystem fs,
        Cache<Path, Resource> cache,
        boolean refresh,
        boolean cacheOnClient,
        int cacheFileSizeLimitBytes) {
      super(cache, refresh, cacheOnClient, cacheFileSizeLimitBytes);
      this.fs = fs;
    }

    @Override
    protected Path getResourcePath(String pathInfo) {
      return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
    }
  }

  private FileSystem fs;
  private AtomicLong ts;

  @Before
  public void setUp() {
    fs = Jimfs.newFileSystem(Configuration.unix());
    ts =
        new AtomicLong(
            LocalDateTime.of(2010, Month.JANUARY, 30, 12, 0, 0)
                .atOffset(ZoneOffset.ofHours(-8))
                .toInstant()
                .toEpochMilli());
  }

  @Test
  public void notFoundWithoutRefresh() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, false);

    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(request("/notfound"), res);
    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
    assertNotCacheable(res);
    assertCacheHits(cache, 0, 1);

    res = new FakeHttpServletResponse();
    servlet.doGet(request("/notfound"), res);
    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
    assertNotCacheable(res);
    assertCacheHits(cache, 1, 1);
  }

  @Test
  public void notFoundWithRefresh() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, true);

    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(request("/notfound"), res);
    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
    assertNotCacheable(res);
    assertCacheHits(cache, 0, 1);

    res = new FakeHttpServletResponse();
    servlet.doGet(request("/notfound"), res);
    assertThat(res.getStatus()).isEqualTo(SC_NOT_FOUND);
    assertNotCacheable(res);
    assertCacheHits(cache, 1, 1);
  }

  @Test
  public void smallFileWithRefresh() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, true);

    writeFile("/foo", "foo1");
    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertCacheable(res, true);
    assertHasETag(res);
    // Miss on getIfPresent, miss on get.
    assertCacheHits(cache, 0, 2);

    res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertCacheable(res, true);
    assertHasETag(res);
    assertCacheHits(cache, 1, 2);

    writeFile("/foo", "foo2");
    res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo2");
    assertCacheable(res, true);
    assertHasETag(res);
    // Hit, invalidate, miss.
    assertCacheHits(cache, 2, 3);
  }

  @Test
  public void smallFileWithoutClientCache() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, false, false);

    writeFile("/foo", "foo1");
    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertNotCacheable(res);

    // Miss on getIfPresent, miss on get.
    assertCacheHits(cache, 0, 2);

    res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertNotCacheable(res);
    assertCacheHits(cache, 1, 2);

    writeFile("/foo", "foo2");
    res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertNotCacheable(res);
    assertCacheHits(cache, 2, 2);
  }

  @Test
  public void smallFileWithoutRefresh() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, false);

    writeFile("/foo", "foo1");
    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertCacheable(res, false);
    assertHasETag(res);
    // Miss on getIfPresent, miss on get.
    assertCacheHits(cache, 0, 2);

    res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertCacheable(res, false);
    assertHasETag(res);
    assertCacheHits(cache, 1, 2);

    writeFile("/foo", "foo2");
    res = new FakeHttpServletResponse();
    servlet.doGet(request("/foo"), res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertCacheable(res, false);
    assertHasETag(res);
    assertCacheHits(cache, 2, 2);
  }

  @Test
  public void verySmallFileDoesntBotherWithGzip() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, true);
    writeFile("/foo", "foo1");

    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(req, res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getHeader("Content-Encoding")).isNull();
    assertThat(res.getActualBodyString()).isEqualTo("foo1");
    assertHasETag(res);
    assertCacheable(res, true);
  }

  @Test
  public void smallFileWithGzip() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, true);
    String content = Strings.repeat("a", 100);
    writeFile("/foo", content);

    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(req, res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
    assertHasETag(res);
    assertCacheable(res, true);
  }

  @Test
  public void largeFileBypassesCacheRegardlessOfRefreshParamter() throws Exception {
    for (boolean refresh : Lists.newArrayList(true, false)) {
      Cache<Path, Resource> cache = newCache(1);
      Servlet servlet = new Servlet(fs, cache, refresh, 3);

      writeFile("/foo", "foo1");
      FakeHttpServletResponse res = new FakeHttpServletResponse();
      servlet.doGet(request("/foo"), res);
      assertThat(res.getStatus()).isEqualTo(SC_OK);
      assertThat(res.getActualBodyString()).isEqualTo("foo1");
      assertThat(res.getHeader("Last-Modified")).isNotNull();
      assertCacheable(res, refresh);
      assertHasLastModified(res);
      assertCacheHits(cache, 0, 1);

      writeFile("/foo", "foo1");
      res = new FakeHttpServletResponse();
      servlet.doGet(request("/foo"), res);
      assertThat(res.getStatus()).isEqualTo(SC_OK);
      assertThat(res.getActualBodyString()).isEqualTo("foo1");
      assertThat(res.getHeader("Last-Modified")).isNotNull();
      assertCacheable(res, refresh);
      assertHasLastModified(res);
      assertCacheHits(cache, 0, 2);

      writeFile("/foo", "foo2");
      res = new FakeHttpServletResponse();
      servlet.doGet(request("/foo"), res);
      assertThat(res.getStatus()).isEqualTo(SC_OK);
      assertThat(res.getActualBodyString()).isEqualTo("foo2");
      assertThat(res.getHeader("Last-Modified")).isNotNull();
      assertCacheable(res, refresh);
      assertHasLastModified(res);
      assertCacheHits(cache, 0, 3);
    }
  }

  @Test
  public void largeFileWithGzip() throws Exception {
    Cache<Path, Resource> cache = newCache(1);
    Servlet servlet = new Servlet(fs, cache, true, 3);
    String content = Strings.repeat("a", 100);
    writeFile("/foo", content);

    FakeHttpServletRequest req = request("/foo").addHeader("Accept-Encoding", "gzip");
    FakeHttpServletResponse res = new FakeHttpServletResponse();
    servlet.doGet(req, res);
    assertThat(res.getStatus()).isEqualTo(SC_OK);
    assertThat(res.getHeader("Content-Encoding")).isEqualTo("gzip");
    assertThat(gunzip(res.getActualBody())).isEqualTo(content);
    assertHasLastModified(res);
    assertCacheable(res, true);
  }

  // TODO(dborowitz): Check MIME type.
  // TODO(dborowitz): Test that JS is not gzipped.
  // TODO(dborowitz): Test ?e parameter.
  // TODO(dborowitz): Test If-None-Match behavior.
  // TODO(dborowitz): Test If-Modified-Since behavior.

  private void writeFile(String path, String content) throws Exception {
    Files.write(fs.getPath(path), content.getBytes(UTF_8));
    Files.setLastModifiedTime(fs.getPath(path), FileTime.fromMillis(ts.getAndIncrement()));
  }

  private static void assertCacheHits(Cache<?, ?> cache, int hits, int misses) {
    assertWithMessage("hits").that(cache.stats().hitCount()).isEqualTo(hits);
    assertWithMessage("misses").that(cache.stats().missCount()).isEqualTo(misses);
  }

  private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
    String header = res.getHeader("Cache-Control").toLowerCase();
    assertThat(header).contains("public");
    if (revalidate) {
      assertThat(header).contains("must-revalidate");
    } else {
      assertThat(header).doesNotContain("must-revalidate");
    }
  }

  private static void assertHasLastModified(FakeHttpServletResponse res) {
    assertThat(res.getHeader("Last-Modified")).isNotNull();
    assertThat(res.getHeader("ETag")).isNull();
  }

  private static void assertHasETag(FakeHttpServletResponse res) {
    assertThat(res.getHeader("ETag")).isNotNull();
    assertThat(res.getHeader("Last-Modified")).isNull();
  }

  private static void assertNotCacheable(FakeHttpServletResponse res) {
    assertThat(res.getHeader("Cache-Control")).contains("no-cache");
    assertThat(res.getHeader("ETag")).isNull();
    assertThat(res.getHeader("Last-Modified")).isNull();
  }

  private static FakeHttpServletRequest request(String path) {
    return new FakeHttpServletRequest().setPathInfo(path);
  }

  private static String gunzip(byte[] data) throws Exception {
    try (InputStream in = new GZIPInputStream(new ByteArrayInputStream(data))) {
      return new String(ByteStreams.toByteArray(in), UTF_8);
    }
  }
}
