package imageresizer

import (
	"encoding/base64"
	"encoding/json"
	"image"
	"image/png"
	"io/ioutil"
	"net/http"
	"net/http/httptest"
	"os"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
	"gitlab.com/gitlab-org/labkit/log"

	"gitlab.com/gitlab-org/gitlab-workhorse/internal/config"
	"gitlab.com/gitlab-org/gitlab-workhorse/internal/testhelper"

	_ "image/jpeg" // need this for image.Decode with JPEG
)

const imagePath = "../../testdata/image.png"

func TestMain(m *testing.M) {
	if err := testhelper.BuildExecutables(); err != nil {
		log.WithError(err).Fatal()
	}

	os.Exit(m.Run())
}

func requestScaledImage(t *testing.T, httpHeaders http.Header, params resizeParams, cfg config.ImageResizerConfig) *http.Response {
	httpRequest := httptest.NewRequest("GET", "/image", nil)
	if httpHeaders != nil {
		httpRequest.Header = httpHeaders
	}
	responseWriter := httptest.NewRecorder()
	paramsJSON := encodeParams(t, &params)

	NewResizer(config.Config{ImageResizerConfig: cfg}).Inject(responseWriter, httpRequest, paramsJSON)

	return responseWriter.Result()
}

func TestRequestScaledImageFromPath(t *testing.T) {
	cfg := config.DefaultImageResizerConfig

	testCases := []struct {
		desc        string
		imagePath   string
		contentType string
	}{
		{
			desc:        "PNG",
			imagePath:   imagePath,
			contentType: "image/png",
		},
		{
			desc:        "JPEG",
			imagePath:   "../../testdata/image.jpg",
			contentType: "image/jpeg",
		},
		{
			desc:        "JPEG < 1kb",
			imagePath:   "../../testdata/image_single_pixel.jpg",
			contentType: "image/jpeg",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.desc, func(t *testing.T) {
			params := resizeParams{Location: tc.imagePath, ContentType: tc.contentType, Width: 64}

			resp := requestScaledImage(t, nil, params, cfg)
			require.Equal(t, http.StatusOK, resp.StatusCode)

			bounds := imageFromResponse(t, resp).Bounds()
			require.Equal(t, int(params.Width), bounds.Size().X, "wrong width after resizing")
		})
	}
}

func TestRequestScaledImageWithConditionalGetAndImageNotChanged(t *testing.T) {
	cfg := config.DefaultImageResizerConfig
	params := resizeParams{Location: imagePath, ContentType: "image/png", Width: 64}

	clientTime := testImageLastModified(t)
	header := http.Header{}
	header.Set("If-Modified-Since", httpTimeStr(clientTime))

	resp := requestScaledImage(t, header, params, cfg)
	require.Equal(t, http.StatusNotModified, resp.StatusCode)
	require.Equal(t, httpTimeStr(testImageLastModified(t)), resp.Header.Get("Last-Modified"))
	require.Empty(t, resp.Header.Get("Content-Type"))
	require.Empty(t, resp.Header.Get("Content-Length"))
}

func TestRequestScaledImageWithConditionalGetAndImageChanged(t *testing.T) {
	cfg := config.DefaultImageResizerConfig
	params := resizeParams{Location: imagePath, ContentType: "image/png", Width: 64}

	clientTime := testImageLastModified(t).Add(-1 * time.Second)
	header := http.Header{}
	header.Set("If-Modified-Since", httpTimeStr(clientTime))

	resp := requestScaledImage(t, header, params, cfg)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	require.Equal(t, httpTimeStr(testImageLastModified(t)), resp.Header.Get("Last-Modified"))
}

func TestRequestScaledImageWithConditionalGetAndInvalidClientTime(t *testing.T) {
	cfg := config.DefaultImageResizerConfig
	params := resizeParams{Location: imagePath, ContentType: "image/png", Width: 64}

	header := http.Header{}
	header.Set("If-Modified-Since", "0")

	resp := requestScaledImage(t, header, params, cfg)
	require.Equal(t, http.StatusOK, resp.StatusCode)
	require.Equal(t, httpTimeStr(testImageLastModified(t)), resp.Header.Get("Last-Modified"))
}

func TestServeOriginalImageWhenSourceImageTooLarge(t *testing.T) {
	originalImage := testImage(t)
	cfg := config.ImageResizerConfig{MaxScalerProcs: 1, MaxFilesize: 1}
	params := resizeParams{Location: imagePath, ContentType: "image/png", Width: 64}

	resp := requestScaledImage(t, nil, params, cfg)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	img := imageFromResponse(t, resp)
	require.Equal(t, originalImage.Bounds(), img.Bounds(), "expected original image size")
}

func TestFailFastOnOpenStreamFailure(t *testing.T) {
	cfg := config.DefaultImageResizerConfig
	params := resizeParams{Location: "does_not_exist.png", ContentType: "image/png", Width: 64}
	resp := requestScaledImage(t, nil, params, cfg)

	require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
}

func TestIgnoreContentTypeMismatchIfImageFormatIsAllowed(t *testing.T) {
	cfg := config.DefaultImageResizerConfig
	params := resizeParams{Location: imagePath, ContentType: "image/jpeg", Width: 64}
	resp := requestScaledImage(t, nil, params, cfg)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	bounds := imageFromResponse(t, resp).Bounds()
	require.Equal(t, int(params.Width), bounds.Size().X, "wrong width after resizing")
}

func TestUnpackParametersReturnsParamsInstanceForValidInput(t *testing.T) {
	r := Resizer{}
	inParams := resizeParams{Location: imagePath, Width: 64, ContentType: "image/png"}

	outParams, err := r.unpackParameters(encodeParams(t, &inParams))

	require.NoError(t, err, "unexpected error when unpacking params")
	require.Equal(t, inParams, *outParams)
}

func TestUnpackParametersReturnsErrorWhenLocationBlank(t *testing.T) {
	r := Resizer{}
	inParams := resizeParams{Location: "", Width: 64, ContentType: "image/jpg"}

	_, err := r.unpackParameters(encodeParams(t, &inParams))

	require.Error(t, err, "expected error when Location is blank")
}

func TestUnpackParametersReturnsErrorWhenContentTypeBlank(t *testing.T) {
	r := Resizer{}
	inParams := resizeParams{Location: imagePath, Width: 64, ContentType: ""}

	_, err := r.unpackParameters(encodeParams(t, &inParams))

	require.Error(t, err, "expected error when ContentType is blank")
}

func TestServeOriginalImageWhenSourceImageFormatIsNotAllowed(t *testing.T) {
	cfg := config.DefaultImageResizerConfig
	// SVG images are not allowed to be resized
	svgImagePath := "../../testdata/image.svg"
	svgImage, err := ioutil.ReadFile(svgImagePath)
	require.NoError(t, err)
	// ContentType is no longer used to perform the format validation.
	// To make the test more strict, we'll use allowed, but incorrect ContentType.
	params := resizeParams{Location: svgImagePath, ContentType: "image/png", Width: 64}

	resp := requestScaledImage(t, nil, params, cfg)
	require.Equal(t, http.StatusOK, resp.StatusCode)

	responseData, err := ioutil.ReadAll(resp.Body)
	require.NoError(t, err)
	require.Equal(t, svgImage, responseData, "expected original image")
}

// The Rails applications sends a Base64 encoded JSON string carrying
// these parameters in an HTTP response header
func encodeParams(t *testing.T, p *resizeParams) string {
	json, err := json.Marshal(*p)
	if err != nil {
		require.NoError(t, err, "JSON encoder encountered unexpected error")
	}
	return base64.StdEncoding.EncodeToString(json)
}

func testImage(t *testing.T) image.Image {
	f, err := os.Open(imagePath)
	require.NoError(t, err)

	image, err := png.Decode(f)
	require.NoError(t, err, "decode original image")

	return image
}

func testImageLastModified(t *testing.T) time.Time {
	fi, err := os.Stat(imagePath)
	require.NoError(t, err)

	return fi.ModTime()
}

func imageFromResponse(t *testing.T, resp *http.Response) image.Image {
	img, _, err := image.Decode(resp.Body)
	require.NoError(t, err, "decode resized image")
	return img
}

func httpTimeStr(time time.Time) string {
	return time.UTC().Format(http.TimeFormat)
}
