package handler

import (
	"bytes"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
	"time"

	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data"
	apierr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer"
	apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status"
	"github.com/stretchr/testify/require"
)

func TestFetchRangeHeader(t *testing.T) {
	for _, tc := range []struct {
		header   string
		expected *layer.RangeParams
		fullSize uint64
		err      bool
	}{
		{header: "bytes=0-256", expected: &layer.RangeParams{Start: 0, End: 256}, fullSize: 257, err: false},
		{header: "bytes=0-0", expected: &layer.RangeParams{Start: 0, End: 0}, fullSize: 1, err: false},
		{header: "bytes=0-256", expected: &layer.RangeParams{Start: 0, End: 255}, fullSize: 256, err: false},
		{header: "bytes=0-", expected: &layer.RangeParams{Start: 0, End: 99}, fullSize: 100, err: false},
		{header: "bytes=-10", expected: &layer.RangeParams{Start: 90, End: 99}, fullSize: 100, err: false},
		{header: "", err: false},
		{header: "bytes=-1-256", err: true},
		{header: "bytes=256-0", err: true},
		{header: "bytes=string-0", err: true},
		{header: "bytes=0-string", err: true},
		{header: "bytes:0-256", err: true},
		{header: "bytes:-", err: true},
		{header: "bytes=0-0", fullSize: 0, err: true},
		{header: "bytes=10-20", fullSize: 5, err: true},
	} {
		h := make(http.Header)
		h.Add("Range", tc.header)
		params, err := fetchRangeHeader(h, tc.fullSize)
		if tc.err {
			require.Error(t, err)
			continue
		}

		require.NoError(t, err)
		require.Equal(t, tc.expected, params)
	}
}

func newInfo(etag string, created time.Time) *data.ObjectInfo {
	return &data.ObjectInfo{
		HashSum: etag,
		Created: created,
	}
}

func TestPreconditions(t *testing.T) {
	today := time.Now()
	yesterday := today.Add(-24 * time.Hour)
	etag := "etag"
	etag2 := "etag2"

	for _, tc := range []struct {
		name     string
		info     *data.ObjectInfo
		args     *conditionalArgs
		expected error
	}{
		{
			name:     "no conditions",
			info:     new(data.ObjectInfo),
			args:     new(conditionalArgs),
			expected: nil,
		},
		{
			name:     "IfMatch true",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfMatch: etag},
			expected: nil,
		},
		{
			name:     "IfMatch false",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfMatch: etag2},
			expected: apierr.GetAPIError(apierr.ErrPreconditionFailed)},
		{
			name:     "IfNoneMatch true",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfNoneMatch: etag2},
			expected: nil},
		{
			name:     "IfNoneMatch false",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfNoneMatch: etag},
			expected: apierr.GetAPIError(apierr.ErrNotModified)},
		{
			name:     "IfModifiedSince true",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfModifiedSince: &yesterday},
			expected: nil},
		{
			name:     "IfModifiedSince false",
			info:     newInfo(etag, yesterday),
			args:     &conditionalArgs{IfModifiedSince: &today},
			expected: apierr.GetAPIError(apierr.ErrNotModified)},
		{
			name:     "IfUnmodifiedSince true",
			info:     newInfo(etag, yesterday),
			args:     &conditionalArgs{IfUnmodifiedSince: &today},
			expected: nil},
		{
			name:     "IfUnmodifiedSince false",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfUnmodifiedSince: &yesterday},
			expected: apierr.GetAPIError(apierr.ErrPreconditionFailed)},

		{
			name:     "IfMatch true, IfUnmodifiedSince false",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfMatch: etag, IfUnmodifiedSince: &yesterday},
			expected: nil,
		},
		{
			name:     "IfMatch false, IfUnmodifiedSince true",
			info:     newInfo(etag, yesterday),
			args:     &conditionalArgs{IfMatch: etag2, IfUnmodifiedSince: &today},
			expected: apierr.GetAPIError(apierr.ErrPreconditionFailed),
		},
		{
			name:     "IfNoneMatch false, IfModifiedSince true",
			info:     newInfo(etag, today),
			args:     &conditionalArgs{IfNoneMatch: etag, IfModifiedSince: &yesterday},
			expected: apierr.GetAPIError(apierr.ErrNotModified),
		},
		{
			name:     "IfNoneMatch true, IfModifiedSince false",
			info:     newInfo(etag, yesterday),
			args:     &conditionalArgs{IfNoneMatch: etag2, IfModifiedSince: &today},
			expected: apierr.GetAPIError(apierr.ErrNotModified),
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			actual := checkPreconditions(tc.info, tc.args, false)
			if tc.expected == nil {
				require.NoError(t, actual)
			} else {
				require.True(t, errors.Is(actual, tc.expected), tc.expected, actual)
			}
		})
	}
}

func TestGetRange(t *testing.T) {
	tc := prepareHandlerContext(t)

	bktName, objName := "bucket-for-range", "object-to-range"
	createTestBucket(tc, bktName)

	content := "123456789abcdef"
	putObjectContent(tc, bktName, objName, content)

	full := getObjectRange(t, tc, bktName, objName, 0, len(content)-1)
	require.Equal(t, content, string(full))

	beginning := getObjectRange(t, tc, bktName, objName, 0, 3)
	require.Equal(t, content[:4], string(beginning))

	middle := getObjectRange(t, tc, bktName, objName, 5, 10)
	require.Equal(t, "6789ab", string(middle))

	end := getObjectRange(t, tc, bktName, objName, 10, 15)
	require.Equal(t, "bcdef", string(end))
}

func TestGetObject(t *testing.T) {
	hc := prepareHandlerContext(t)
	bktName, objName := "bucket", "obj"
	bktInfo, objInfo := createVersionedBucketAndObject(hc.t, hc, bktName, objName)

	putObject(hc, bktName, objName)

	checkFound(hc.t, hc, bktName, objName, objInfo.VersionID())
	checkFound(hc.t, hc, bktName, objName, emptyVersion)

	addr := getAddressOfLastVersion(hc, bktInfo, objName)
	hc.tp.SetObjectError(addr, &apistatus.ObjectNotFound{})
	hc.tp.SetObjectError(objInfo.Address(), &apistatus.ObjectNotFound{})

	getObjectAssertS3Error(hc, bktName, objName, objInfo.VersionID(), apierr.ErrNoSuchVersion)
	getObjectAssertS3Error(hc, bktName, objName, emptyVersion, apierr.ErrNoSuchKey)
}

func TestGetObjectEnabledMD5(t *testing.T) {
	hc := prepareHandlerContext(t)
	bktName, objName := "bucket", "obj"
	_, objInfo := createBucketAndObject(hc, bktName, objName)

	_, headers := getObject(hc, bktName, objName)
	require.Equal(t, data.Quote(objInfo.HashSum), headers.Get(api.ETag))

	hc.config.md5Enabled = true
	_, headers = getObject(hc, bktName, objName)
	require.Equal(t, data.Quote(objInfo.MD5Sum), headers.Get(api.ETag))
}

func TestGetObjectNotModifiedHeaders(t *testing.T) {
	hc := prepareHandlerContextWithMinCache(t)
	bktName, objName, metadataHeader := "bucket", "obj", api.MetadataPrefix+"header"
	createVersionedBucket(hc, bktName)
	header := putObjectWithHeaders(hc, bktName, objName, map[string]string{api.CacheControl: "value", metadataHeader: "value"})
	etag, versionID := header.Get(api.ETag), header.Get(api.AmzVersionID)
	require.NotEmpty(t, etag)
	require.NotEmpty(t, versionID)

	putObjectTagging(t, hc, bktName, objName, map[string]string{"key": "value"})

	w := getObjectWithHeaders(hc, bktName, objName, map[string]string{api.IfNoneMatch: etag})
	require.Equal(t, http.StatusNotModified, w.Code)
	require.Equal(t, "1", w.Header().Get(api.AmzTaggingCount))
	require.Equal(t, etag, w.Header().Get(api.ETag))
	require.NotEmpty(t, w.Header().Get(api.LastModified))
	require.Equal(t, versionID, w.Header().Get(api.AmzVersionID))
	require.Equal(t, "value", w.Header().Get(api.CacheControl))
	require.Equal(t, []string{"value"}, w.Header()[metadataHeader])
}

func putObjectContent(hc *handlerContext, bktName, objName, content string) http.Header {
	body := bytes.NewReader([]byte(content))
	w, r := prepareTestPayloadRequest(hc, bktName, objName, body)
	hc.Handler().PutObjectHandler(w, r)
	assertStatus(hc.t, w, http.StatusOK)
	return w.Result().Header
}

func getObjectRange(t *testing.T, tc *handlerContext, bktName, objName string, start, end int) []byte {
	w, r := prepareTestRequest(tc, bktName, objName, nil)
	r.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
	tc.Handler().GetObjectHandler(w, r)
	assertStatus(t, w, http.StatusPartialContent)
	content, err := io.ReadAll(w.Result().Body)
	require.NoError(t, err)
	return content
}

func getObjectVersion(tc *handlerContext, bktName, objName, version string) []byte {
	w := getObjectBaseResponse(tc, bktName, objName, version)
	assertStatus(tc.t, w, http.StatusOK)
	content, err := io.ReadAll(w.Result().Body)
	require.NoError(tc.t, err)
	return content
}

func getObjectAssertS3Error(hc *handlerContext, bktName, objName, version string, code apierr.ErrorCode) {
	w := getObjectBaseResponse(hc, bktName, objName, version)
	assertS3Error(hc.t, w, apierr.GetAPIError(code))
}

func getObjectBaseResponse(hc *handlerContext, bktName, objName, version string) *httptest.ResponseRecorder {
	query := make(url.Values)
	query.Add(api.QueryVersionID, version)

	w, r := prepareTestFullRequest(hc, bktName, objName, query, nil)
	hc.Handler().GetObjectHandler(w, r)
	return w
}