package handler

import (
	"bytes"
	"encoding/xml"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"

	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data"
	apiErrors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer/encryption"
	apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status"
	oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/stretchr/testify/require"
)

const (
	emptyVersion = ""
)

func TestDeleteBucketOnAlreadyRemovedError(t *testing.T) {
	hc := prepareHandlerContext(t)

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo := createTestBucket(hc, bktName)

	putObject(hc, bktName, objName)

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

	deleteObjects(t, hc, bktName, [][2]string{{objName, emptyVersion}})

	deleteBucket(t, hc, bktName, http.StatusNoContent)
}

func getAddressOfLastVersion(hc *handlerContext, bktInfo *data.BucketInfo, objName string) oid.Address {
	nodeVersion, err := hc.tree.GetLatestVersion(hc.context, bktInfo, objName)
	require.NoError(hc.t, err)
	var addr oid.Address
	addr.SetContainer(bktInfo.CID)
	addr.SetObject(nodeVersion.OID)
	return addr
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	_, objInfo := createVersionedBucketAndObject(t, tc, bktName, objName)

	deleteMarkerVersion, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
	require.True(t, isDeleteMarker)

	deleteBucket(t, tc, bktName, http.StatusConflict)
	deleteObject(t, tc, bktName, objName, objInfo.VersionID())
	deleteBucket(t, tc, bktName, http.StatusConflict)
	deleteObject(t, tc, bktName, objName, deleteMarkerVersion)
	deleteBucket(t, tc, bktName, http.StatusNoContent)
}

func TestDeleteBucketOnNotFoundError(t *testing.T) {
	hc := prepareHandlerContext(t)

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo := createTestBucket(hc, bktName)

	putObject(hc, bktName, objName)

	nodeVersion, err := hc.tree.GetUnversioned(hc.context, bktInfo, objName)
	require.NoError(t, err)
	var addr oid.Address
	addr.SetContainer(bktInfo.CID)
	addr.SetObject(nodeVersion.OID)
	hc.tp.SetObjectError(addr, &apistatus.ObjectNotFound{})

	deleteObjects(t, hc, bktName, [][2]string{{objName, emptyVersion}})

	deleteBucket(t, hc, bktName, http.StatusNoContent)
}

func TestDeleteMultipleObjectCheckUniqueness(t *testing.T) {
	hc := prepareHandlerContext(t)

	bktName, objName := "bucket", "object"
	createTestBucket(hc, bktName)

	putObject(hc, bktName, objName)

	resp := deleteObjects(t, hc, bktName, [][2]string{{objName, emptyVersion}, {objName, emptyVersion}})
	require.Empty(t, resp.Errors)
	require.Len(t, resp.DeletedObjects, 1)
}

func TestDeleteObjectsError(t *testing.T) {
	hc := prepareHandlerContext(t)

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo := createTestBucket(hc, bktName)
	putBucketVersioning(t, hc, bktName, true)

	putObject(hc, bktName, objName)

	nodeVersion, err := hc.tree.GetLatestVersion(hc.context, bktInfo, objName)
	require.NoError(t, err)
	var addr oid.Address
	addr.SetContainer(bktInfo.CID)
	addr.SetObject(nodeVersion.OID)

	expectedError := apiErrors.GetAPIError(apiErrors.ErrAccessDenied)
	hc.tp.SetObjectError(addr, expectedError)

	w := deleteObjectsBase(hc, bktName, [][2]string{{objName, nodeVersion.OID.EncodeToString()}})

	res := &s3.DeleteObjectsOutput{}
	err = xmlutil.UnmarshalXML(res, xml.NewDecoder(w.Result().Body), "")
	require.NoError(t, err)

	require.ElementsMatch(t, []*s3.Error{{
		Code:      aws.String(expectedError.Code),
		Key:       aws.String(objName),
		Message:   aws.String(expectedError.Error()),
		VersionId: aws.String(nodeVersion.OID.EncodeToString()),
	}}, res.Errors)
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, objInfo := createBucketAndObject(tc, bktName, objName)

	checkFound(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	checkNotFound(t, tc, bktName, objName, emptyVersion)

	require.False(t, existInMockedFrostFS(tc, bktInfo, objInfo))
}

func TestDeleteObjectFromSuspended(t *testing.T) {
	tc := prepareHandlerContext(t)
	bktName, objName := "bucket-versioned-for-removal", "object-to-delete"

	createSuspendedBucket(t, tc, bktName)
	putObject(tc, bktName, objName)

	versionID, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
	require.True(t, isDeleteMarker)
	require.Equal(t, data.UnversionedObjectVersionID, versionID)
}

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

	t.Run("unversioned bucket", func(t *testing.T) {
		bktName, objName := "bucket-unversioned-removal", "object-to-delete"
		createBucketAndObject(tc, bktName, objName)

		versionID, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
		require.Empty(t, versionID)
		require.False(t, isDeleteMarker)
		versionID, isDeleteMarker = deleteObject(t, tc, bktName, objName, emptyVersion)
		require.Empty(t, versionID)
		require.False(t, isDeleteMarker)
	})

	t.Run("versioned bucket", func(t *testing.T) {
		bktName, objName := "bucket-versioned-for-removal", "object-to-delete"
		createVersionedBucketAndObject(t, tc, bktName, objName)

		_, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
		_, isDeleteMarker = deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
	})

	t.Run("versioned bucket not found obj", func(t *testing.T) {
		bktName, objName := "bucket-versioned-for-removal-not-found", "object-to-delete"
		_, objInfo := createVersionedBucketAndObject(t, tc, bktName, objName)

		versionID, isDeleteMarker := deleteObject(t, tc, bktName, objName, objInfo.VersionID())
		require.False(t, isDeleteMarker)
		require.Equal(t, objInfo.VersionID(), versionID)

		versionID2, isDeleteMarker := deleteObject(t, tc, bktName, objName, versionID)
		require.False(t, isDeleteMarker)
		require.Equal(t, objInfo.VersionID(), versionID2)
	})
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, objInfo := createVersionedBucketAndObject(t, tc, bktName, objName)

	checkFound(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	checkNotFound(t, tc, bktName, objName, emptyVersion)

	checkFound(t, tc, bktName, objName, objInfo.VersionID())
	deleteObject(t, tc, bktName, objName, objInfo.VersionID())
	checkNotFound(t, tc, bktName, objName, objInfo.VersionID())

	require.False(t, existInMockedFrostFS(tc, bktInfo, objInfo), "object exists but shouldn't")
}

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

	bktName, objName := "bucket-for-removal-unversioned", "object-to-delete-unversioned"
	bktInfo, objInfo := createBucketAndObject(tc, bktName, objName)

	checkFound(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	checkNotFound(t, tc, bktName, objName, emptyVersion)

	versions := listVersions(t, tc, bktName)
	require.Len(t, versions.DeleteMarker, 0, "delete markers must be empty")
	require.Len(t, versions.Version, 0, "versions must be empty")

	require.False(t, existInMockedFrostFS(tc, bktInfo, objInfo), "object exists but shouldn't")
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, objInfo := createVersionedBucketAndObject(t, tc, bktName, objName)

	checkFound(t, tc, bktName, objName, emptyVersion)
	deleteMarkerVersion, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
	require.True(t, isDeleteMarker)
	checkNotFound(t, tc, bktName, objName, emptyVersion)

	checkFound(t, tc, bktName, objName, objInfo.VersionID())
	deleteObject(t, tc, bktName, objName, deleteMarkerVersion)
	checkFound(t, tc, bktName, objName, emptyVersion)

	require.True(t, existInMockedFrostFS(tc, bktInfo, objInfo), "object doesn't exist but should")
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	createVersionedBucketAndObject(t, tc, bktName, objName)

	t.Run("not create new delete marker if last version is delete marker", func(t *testing.T) {
		deleteMarkerVersion, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
		versions := listVersions(t, tc, bktName)
		require.Len(t, versions.DeleteMarker, 1)
		require.Equal(t, deleteMarkerVersion, versions.DeleteMarker[0].VersionID)

		_, isDeleteMarker = deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
		versions = listVersions(t, tc, bktName)
		require.Len(t, versions.DeleteMarker, 1)
		require.Equal(t, deleteMarkerVersion, versions.DeleteMarker[0].VersionID)
	})

	t.Run("do not create delete marker if object does not exist", func(t *testing.T) {
		versionsBefore := listVersions(t, tc, bktName)
		_, isDeleteMarker := deleteObject(t, tc, bktName, "dummy", emptyVersion)
		require.False(t, isDeleteMarker)
		versionsAfter := listVersions(t, tc, bktName)
		require.Equal(t, versionsBefore, versionsAfter)
	})
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, _ := createVersionedBucketAndObject(t, tc, bktName, objName)
	putBucketVersioning(t, tc, bktName, false)

	t.Run("not create new delete marker if last version is delete marker", func(t *testing.T) {
		deleteMarkerVersion, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
		require.Equal(t, data.UnversionedObjectVersionID, deleteMarkerVersion)

		deleteMarkerVersion, isDeleteMarker = deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
		require.Equal(t, data.UnversionedObjectVersionID, deleteMarkerVersion)

		versions := listVersions(t, tc, bktName)
		require.Len(t, versions.DeleteMarker, 1)
		require.Equal(t, deleteMarkerVersion, versions.DeleteMarker[0].VersionID)
	})

	t.Run("do not create delete marker if object does not exist", func(t *testing.T) {
		versionsBefore := listVersions(t, tc, bktName)
		_, isDeleteMarker := deleteObject(t, tc, bktName, "dummy", emptyVersion)
		require.False(t, isDeleteMarker)
		versionsAfter := listVersions(t, tc, bktName)
		require.Equal(t, versionsBefore, versionsAfter)
	})

	t.Run("remove last unversioned non delete marker", func(t *testing.T) {
		objName := "obj3"
		putObject(tc, bktName, objName)

		nodeVersion, err := tc.tree.GetUnversioned(tc.Context(), bktInfo, objName)
		require.NoError(t, err)

		deleteMarkerVersion, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
		require.True(t, isDeleteMarker)
		require.Equal(t, data.UnversionedObjectVersionID, deleteMarkerVersion)

		objVersions := getVersion(listVersions(t, tc, bktName), objName)
		require.Len(t, objVersions, 0)

		require.False(t, tc.MockedPool().ObjectExists(nodeVersion.OID))
	})
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, objInfo := createBucketAndObject(tc, bktName, objName)

	putBucketVersioning(t, tc, bktName, true)

	checkFound(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	checkNotFound(t, tc, bktName, objName, emptyVersion)

	checkFound(t, tc, bktName, objName, objInfo.VersionID())

	require.True(t, existInMockedFrostFS(tc, bktInfo, objInfo), "object doesn't exist but should")
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, objInfo := createBucketAndObject(tc, bktName, objName)

	putBucketVersioning(t, tc, bktName, true)

	checkFound(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	checkNotFound(t, tc, bktName, objName, emptyVersion)

	putBucketVersioning(t, tc, bktName, false)

	deleteObject(t, tc, bktName, objName, emptyVersion)
	checkNotFound(t, tc, bktName, objName, objInfo.VersionID())

	require.False(t, existInMockedFrostFS(tc, bktInfo, objInfo), "object exists but shouldn't")
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	createTestBucket(tc, bktName)
	putBucketVersioning(t, tc, bktName, true)

	checkNotFound(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)
	deleteObject(t, tc, bktName, objName, emptyVersion)

	versions := listVersions(t, tc, bktName)
	require.Len(t, versions.DeleteMarker, 0, "invalid delete markers length")
	require.Len(t, versions.Version, 0, "versions must be empty")

	require.Len(t, listOIDsFromMockedFrostFS(t, tc, bktName), 0, "shouldn't be any object in frostfs")
}

func TestGetHeadDeleteMarker(t *testing.T) {
	hc := prepareHandlerContext(t)

	bktName, objName := "bucket-for-removal", "object-to-delete"
	createTestBucket(hc, bktName)
	putBucketVersioning(t, hc, bktName, true)

	putObject(hc, bktName, objName)

	deleteMarkerVersionID, _ := deleteObject(t, hc, bktName, objName, emptyVersion)

	w := headObjectBase(hc, bktName, objName, deleteMarkerVersionID)
	require.Equal(t, w.Code, http.StatusMethodNotAllowed)
	require.Equal(t, w.Result().Header.Get(api.AmzDeleteMarker), "true")

	w, r := prepareTestRequest(hc, bktName, objName, nil)
	hc.Handler().GetObjectHandler(w, r)
	assertStatus(hc.t, w, http.StatusNotFound)
	require.Equal(t, w.Result().Header.Get(api.AmzDeleteMarker), "true")
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	bktInfo, objInfo := createVersionedBucketAndObject(t, tc, bktName, objName)

	versions := listObjectsV1(tc, bktName, "", "", "", -1)
	require.Len(t, versions.Contents, 1)

	checkFound(t, tc, bktName, objName, objInfo.VersionID())
	deleteObject(t, tc, bktName, objName, objInfo.VersionID())
	checkNotFound(t, tc, bktName, objName, objInfo.VersionID())

	// check cache is clean after object removal
	versions = listObjectsV1(tc, bktName, "", "", "", -1)
	require.Len(t, versions.Contents, 0)

	require.False(t, existInMockedFrostFS(tc, bktInfo, objInfo))
}

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

	bktName, objName := "bucket-for-removal", "object-to-delete"
	createVersionedBucketAndObject(t, tc, bktName, objName)

	deleteMarkerVersion, isDeleteMarker := deleteObject(t, tc, bktName, objName, emptyVersion)
	require.True(t, isDeleteMarker)

	versions := listVersions(t, tc, bktName)
	require.Len(t, versions.DeleteMarker, 1)
	require.Equal(t, deleteMarkerVersion, versions.DeleteMarker[0].VersionID)

	deleteMarkerVersion2, isDeleteMarker2 := deleteObject(t, tc, bktName, objName, deleteMarkerVersion)
	require.True(t, isDeleteMarker2)
	versions = listVersions(t, tc, bktName)
	require.Len(t, versions.DeleteMarker, 0)
	require.Equal(t, deleteMarkerVersion, deleteMarkerVersion2)
}

func createBucketAndObject(tc *handlerContext, bktName, objName string) (*data.BucketInfo, *data.ObjectInfo) {
	bktInfo := createTestBucket(tc, bktName)

	objInfo := createTestObject(tc, bktInfo, objName, encryption.Params{})

	return bktInfo, objInfo
}

func createVersionedBucketAndObject(_ *testing.T, tc *handlerContext, bktName, objName string) (*data.BucketInfo, *data.ObjectInfo) {
	bktInfo := createVersionedBucket(tc, bktName)
	objInfo := createTestObject(tc, bktInfo, objName, encryption.Params{})

	return bktInfo, objInfo
}

func createVersionedBucket(hc *handlerContext, bktName string) *data.BucketInfo {
	bktInfo := createTestBucket(hc, bktName)
	putBucketVersioning(hc.t, hc, bktName, true)

	return bktInfo
}

func putBucketVersioning(t *testing.T, tc *handlerContext, bktName string, enabled bool) {
	cfg := &VersioningConfiguration{Status: "Suspended"}
	if enabled {
		cfg.Status = "Enabled"
	}
	w, r := prepareTestRequest(tc, bktName, "", cfg)
	tc.Handler().PutBucketVersioningHandler(w, r)
	assertStatus(t, w, http.StatusOK)
}

func getBucketVersioning(hc *handlerContext, bktName string) *VersioningConfiguration {
	w, r := prepareTestRequest(hc, bktName, "", nil)
	hc.Handler().GetBucketVersioningHandler(w, r)
	assertStatus(hc.t, w, http.StatusOK)

	res := &VersioningConfiguration{}
	parseTestResponse(hc.t, w, res)
	return res
}

func deleteObject(t *testing.T, tc *handlerContext, bktName, objName, version string) (string, bool) {
	query := make(url.Values)
	query.Add(api.QueryVersionID, version)

	w, r := prepareTestFullRequest(tc, bktName, objName, query, nil)
	tc.Handler().DeleteObjectHandler(w, r)
	assertStatus(t, w, http.StatusNoContent)

	return w.Header().Get(api.AmzVersionID), w.Header().Get(api.AmzDeleteMarker) != ""
}

func deleteObjects(t *testing.T, tc *handlerContext, bktName string, objVersions [][2]string) *DeleteObjectsResponse {
	w := deleteObjectsBase(tc, bktName, objVersions)

	res := &DeleteObjectsResponse{}
	parseTestResponse(t, w, res)
	return res
}

func deleteObjectsBase(hc *handlerContext, bktName string, objVersions [][2]string) *httptest.ResponseRecorder {
	req := &DeleteObjectsRequest{}
	for _, version := range objVersions {
		req.Objects = append(req.Objects, ObjectIdentifier{
			ObjectName: version[0],
			VersionID:  version[1],
		})
	}

	w, r := prepareTestRequest(hc, bktName, "", req)
	r.Header.Set(api.ContentMD5, "")
	hc.Handler().DeleteMultipleObjectsHandler(w, r)
	assertStatus(hc.t, w, http.StatusOK)

	return w
}

func deleteBucket(t *testing.T, tc *handlerContext, bktName string, code int) {
	w, r := prepareTestRequest(tc, bktName, "", nil)
	tc.Handler().DeleteBucketHandler(w, r)
	assertStatus(t, w, code)
}

func checkNotFound(t *testing.T, hc *handlerContext, bktName, objName, version string) {
	w := headObjectBase(hc, bktName, objName, version)
	assertStatus(t, w, http.StatusNotFound)
}

func headObjectAssertS3Error(hc *handlerContext, bktName, objName, version string, code apiErrors.ErrorCode) {
	w := headObjectBase(hc, bktName, objName, version)
	assertS3Error(hc.t, w, apiErrors.GetAPIError(code))
}

func checkFound(t *testing.T, hc *handlerContext, bktName, objName, version string) {
	w := headObjectBase(hc, bktName, objName, version)
	assertStatus(t, w, http.StatusOK)
}

func headObjectBase(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().HeadObjectHandler(w, r)
	return w
}

func listVersions(_ *testing.T, tc *handlerContext, bktName string) *ListObjectsVersionsResponse {
	return listObjectsVersions(tc, bktName, "", "", "", "", -1)
}

func getVersion(resp *ListObjectsVersionsResponse, objName string) []*ObjectVersionResponse {
	var res []*ObjectVersionResponse
	for i, version := range resp.Version {
		if version.Key == objName {
			res = append(res, &resp.Version[i])
		}
	}
	return res
}

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

func createSuspendedBucket(t *testing.T, tc *handlerContext, bktName string) *data.BucketInfo {
	createTestBucket(tc, bktName)
	bktInfo, err := tc.Layer().GetBucketInfo(tc.Context(), bktName)
	require.NoError(t, err)
	putBucketVersioning(t, tc, bktName, false)
	return bktInfo
}