package handler

import (
	"bytes"
	"context"
	"encoding/xml"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"

	"github.com/nspcc-dev/neofs-s3-gw/api"
	"github.com/nspcc-dev/neofs-s3-gw/api/data"
	apiErrors "github.com/nspcc-dev/neofs-s3-gw/api/errors"
	"github.com/stretchr/testify/require"
)

const defaultURL = "http://localhost/"

func TestFormObjectLock(t *testing.T) {
	for _, tc := range []struct {
		name          string
		bktInfo       *data.BucketInfo
		config        *data.ObjectLockConfiguration
		header        http.Header
		expectedError bool
		expectedLock  *data.ObjectLock
	}{
		{
			name:    "default days",
			bktInfo: &data.BucketInfo{ObjectLockEnabled: true},
			config: &data.ObjectLockConfiguration{Rule: &data.ObjectLockRule{
				DefaultRetention: &data.DefaultRetention{Mode: complianceMode, Days: 1}}},
			expectedLock: &data.ObjectLock{Retention: &data.RetentionLock{
				IsCompliance: true,
				Until:        time.Now().Add(24 * time.Hour)}},
		},
		{
			name:    "default years",
			bktInfo: &data.BucketInfo{ObjectLockEnabled: true},
			config: &data.ObjectLockConfiguration{Rule: &data.ObjectLockRule{
				DefaultRetention: &data.DefaultRetention{Mode: governanceMode, Years: 1}}},
			expectedLock: &data.ObjectLock{Retention: &data.RetentionLock{
				Until: time.Now().Add(365 * 24 * time.Hour)}},
		},
		{
			name:    "basic override",
			bktInfo: &data.BucketInfo{ObjectLockEnabled: true},
			config:  &data.ObjectLockConfiguration{Rule: &data.ObjectLockRule{DefaultRetention: &data.DefaultRetention{Mode: complianceMode, Days: 1}}},
			header: map[string][]string{
				api.AmzObjectLockRetainUntilDate: {time.Now().Format(time.RFC3339)},
				api.AmzObjectLockMode:            {governanceMode},
				api.AmzObjectLockLegalHold:       {legalHoldOn},
			},
			expectedLock: &data.ObjectLock{
				LegalHold: &data.LegalHoldLock{Enabled: true},
				Retention: &data.RetentionLock{Until: time.Now()}},
		},
		{
			name:          "lock disabled error",
			bktInfo:       &data.BucketInfo{},
			header:        map[string][]string{api.AmzObjectLockLegalHold: {legalHoldOn}},
			expectedError: true,
		},
		{
			name:    "invalid time format error",
			bktInfo: &data.BucketInfo{ObjectLockEnabled: true},
			header: map[string][]string{
				api.AmzObjectLockRetainUntilDate: {time.Now().Format(time.RFC822)},
			},
			expectedError: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			actualObjLock, err := formObjectLock(tc.bktInfo, tc.config, tc.header)
			if tc.expectedError {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
			assertObjectLocks(t, tc.expectedLock, actualObjLock)
		})
	}
}

func TestFormObjectLockFromRetention(t *testing.T) {
	for _, tc := range []struct {
		name          string
		retention     *data.Retention
		header        http.Header
		expectedError bool
		expectedLock  *data.ObjectLock
	}{
		{
			name: "basic compliance",
			retention: &data.Retention{
				Mode:            complianceMode,
				RetainUntilDate: time.Now().Format(time.RFC3339),
			},
			expectedLock: &data.ObjectLock{Retention: &data.RetentionLock{
				Until:        time.Now(),
				IsCompliance: true}},
		},
		{
			name: "basic governance",
			retention: &data.Retention{
				Mode:            governanceMode,
				RetainUntilDate: time.Now().Format(time.RFC3339),
			},
			header: map[string][]string{
				api.AmzBypassGovernanceRetention: {strconv.FormatBool(true)},
			},
			expectedLock: &data.ObjectLock{Retention: &data.RetentionLock{Until: time.Now()}},
		},
		{
			name: "error invalid mode",
			retention: &data.Retention{
				Mode:            "",
				RetainUntilDate: time.Now().Format(time.RFC3339),
			},
			expectedError: true,
		},
		{
			name: "error invalid date",
			retention: &data.Retention{
				Mode:            governanceMode,
				RetainUntilDate: "",
			},
			expectedError: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			actualObjLock, err := formObjectLockFromRetention(tc.retention, tc.header)
			if tc.expectedError {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
			assertObjectLocks(t, tc.expectedLock, actualObjLock)
		})
	}
}

func assertObjectLocks(t *testing.T, expected, actual *data.ObjectLock) {
	require.Equal(t, expected.LegalHold, actual.LegalHold)
	if expected.Retention != nil {
		require.Equal(t, expected.Retention.IsCompliance, actual.Retention.IsCompliance)
		require.InDelta(t, expected.Retention.Until.Unix(), actual.Retention.Until.Unix(), 1)
	}
}

func TestLockConfiguration(t *testing.T) {
	for _, tc := range []struct {
		name          string
		configuration *data.ObjectLockConfiguration
		expectedError bool
	}{
		{
			name:          "basic empty",
			configuration: &data.ObjectLockConfiguration{},
		},
		{
			name: "basic compliance",
			configuration: &data.ObjectLockConfiguration{
				ObjectLockEnabled: enabledValue,
				Rule: &data.ObjectLockRule{
					DefaultRetention: &data.DefaultRetention{
						Days: 1,
						Mode: complianceMode,
					},
				},
			},
		},
		{
			name: "basic governance",
			configuration: &data.ObjectLockConfiguration{
				Rule: &data.ObjectLockRule{
					DefaultRetention: &data.DefaultRetention{
						Mode:  governanceMode,
						Years: 1,
					},
				},
			},
		},
		{
			name: "error invalid enabled",
			configuration: &data.ObjectLockConfiguration{
				ObjectLockEnabled: "false",
			},
			expectedError: true,
		},
		{
			name: "error invalid mode",
			configuration: &data.ObjectLockConfiguration{
				Rule: &data.ObjectLockRule{
					DefaultRetention: &data.DefaultRetention{
						Mode: "",
					},
				},
			},
			expectedError: true,
		},
		{
			name: "error no duration",
			configuration: &data.ObjectLockConfiguration{
				Rule: &data.ObjectLockRule{
					DefaultRetention: &data.DefaultRetention{
						Mode: governanceMode,
					},
				},
			},
			expectedError: true,
		},
		{
			name: "error both durations",
			configuration: &data.ObjectLockConfiguration{
				Rule: &data.ObjectLockRule{
					DefaultRetention: &data.DefaultRetention{
						Days:  1,
						Mode:  governanceMode,
						Years: 1,
					},
				},
			},
			expectedError: true,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			err := checkLockConfiguration(tc.configuration)
			if tc.expectedError {
				require.Error(t, err)
				return
			}

			require.NoError(t, err)
		})
	}
}

func TestPutBucketLockConfigurationHandler(t *testing.T) {
	ctx := context.Background()
	hc := prepareHandlerContext(t)

	bktLockDisabled := "bucket-lock-disabled"
	createTestBucket(ctx, t, hc, bktLockDisabled)

	bktLockEnabled := "bucket-lock-enabled"
	createTestBucketWithLock(ctx, t, hc, bktLockEnabled, nil)

	bktLockEnabledWithOldConfig := "bucket-lock-enabled-old-conf"
	createTestBucketWithLock(ctx, t, hc, bktLockEnabledWithOldConfig,
		&data.ObjectLockConfiguration{
			Rule: &data.ObjectLockRule{
				DefaultRetention: &data.DefaultRetention{
					Days: 1,
					Mode: complianceMode,
				},
			},
		})

	for _, tc := range []struct {
		name          string
		bucket        string
		expectedError apiErrors.Error
		noError       bool
		configuration *data.ObjectLockConfiguration
	}{
		{
			name:          "bkt not found",
			expectedError: apiErrors.GetAPIError(apiErrors.ErrNoSuchBucket),
		},
		{
			name:          "bkt lock disabled",
			bucket:        bktLockDisabled,
			expectedError: apiErrors.GetAPIError(apiErrors.ErrObjectLockConfigurationNotAllowed),
		},
		{
			name:          "invalid configuration",
			bucket:        bktLockEnabled,
			expectedError: apiErrors.GetAPIError(apiErrors.ErrInternalError),
			configuration: &data.ObjectLockConfiguration{ObjectLockEnabled: "dummy"},
		},
		{
			name:          "basic",
			bucket:        bktLockEnabled,
			noError:       true,
			configuration: &data.ObjectLockConfiguration{},
		},
		{
			name:    "basic override",
			bucket:  bktLockEnabledWithOldConfig,
			noError: true,
			configuration: &data.ObjectLockConfiguration{
				Rule: &data.ObjectLockRule{
					DefaultRetention: &data.DefaultRetention{
						Mode:  governanceMode,
						Years: 1,
					},
				},
			},
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			body, err := xml.Marshal(tc.configuration)
			require.NoError(t, err)

			w := httptest.NewRecorder()
			r := httptest.NewRequest(http.MethodPut, defaultURL, bytes.NewReader(body))
			r = r.WithContext(api.SetReqInfo(r.Context(), api.NewReqInfo(w, r, api.ObjectRequest{Bucket: tc.bucket})))

			hc.Handler().PutBucketObjectLockConfigHandler(w, r)

			if !tc.noError {
				assertS3Error(t, w, tc.expectedError)
				return
			}

			bktInfo, err := hc.Layer().GetBucketInfo(ctx, tc.bucket)
			require.NoError(t, err)
			bktSettings, err := hc.Layer().GetBucketSettings(ctx, bktInfo)
			require.NoError(t, err)
			actualConf := bktSettings.LockConfiguration
			require.True(t, bktSettings.VersioningEnabled())
			require.Equal(t, tc.configuration.ObjectLockEnabled, actualConf.ObjectLockEnabled)
			require.Equal(t, tc.configuration.Rule, actualConf.Rule)
		})
	}
}

func TestGetBucketLockConfigurationHandler(t *testing.T) {
	ctx := context.Background()
	hc := prepareHandlerContext(t)

	bktLockDisabled := "bucket-lock-disabled"
	createTestBucket(ctx, t, hc, bktLockDisabled)

	bktLockEnabled := "bucket-lock-enabled"
	createTestBucketWithLock(ctx, t, hc, bktLockEnabled, nil)

	oldConfig := &data.ObjectLockConfiguration{
		Rule: &data.ObjectLockRule{
			DefaultRetention: &data.DefaultRetention{
				Days: 1,
				Mode: complianceMode,
			},
		},
	}
	bktLockEnabledWithOldConfig := "bucket-lock-enabled-old-conf"
	createTestBucketWithLock(ctx, t, hc, bktLockEnabledWithOldConfig, oldConfig)

	for _, tc := range []struct {
		name          string
		bucket        string
		expectedError apiErrors.Error
		noError       bool
		expectedConf  *data.ObjectLockConfiguration
	}{
		{
			name:          "bkt not found",
			expectedError: apiErrors.GetAPIError(apiErrors.ErrNoSuchBucket),
		},
		{
			name:          "bkt lock disabled",
			bucket:        bktLockDisabled,
			expectedError: apiErrors.GetAPIError(apiErrors.ErrObjectLockConfigurationNotFound),
		},
		{
			name:         "bkt lock enabled empty default",
			bucket:       bktLockEnabled,
			noError:      true,
			expectedConf: &data.ObjectLockConfiguration{ObjectLockEnabled: enabledValue},
		},
		{
			name:         "bkt lock enabled",
			bucket:       bktLockEnabledWithOldConfig,
			noError:      true,
			expectedConf: oldConfig,
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			w := httptest.NewRecorder()
			r := httptest.NewRequest(http.MethodPut, defaultURL, bytes.NewReader(nil))
			r = r.WithContext(api.SetReqInfo(r.Context(), api.NewReqInfo(w, r, api.ObjectRequest{Bucket: tc.bucket})))

			hc.Handler().GetBucketObjectLockConfigHandler(w, r)

			if !tc.noError {
				assertS3Error(t, w, tc.expectedError)
				return
			}

			actualConf := &data.ObjectLockConfiguration{}
			err := xml.NewDecoder(w.Result().Body).Decode(actualConf)
			require.NoError(t, err)

			require.Equal(t, tc.expectedConf.ObjectLockEnabled, actualConf.ObjectLockEnabled)
			require.Equal(t, tc.expectedConf.Rule, actualConf.Rule)
		})
	}
}

func assertS3Error(t *testing.T, w *httptest.ResponseRecorder, expectedError apiErrors.Error) {
	actualErrorResponse := &api.ErrorResponse{}
	err := xml.NewDecoder(w.Result().Body).Decode(actualErrorResponse)
	require.NoError(t, err)

	require.Equal(t, expectedError.HTTPStatusCode, w.Code)
	require.Equal(t, expectedError.Code, actualErrorResponse.Code)

	if expectedError.ErrCode != apiErrors.ErrInternalError {
		require.Equal(t, expectedError.Description, actualErrorResponse.Message)
	}
}

func TestObjectLegalHold(t *testing.T) {
	ctx := context.Background()
	hc := prepareHandlerContext(t)

	bktName := "bucket-lock-enabled"
	bktInfo := createTestBucketWithLock(ctx, t, hc, bktName, nil)

	objName := "obj-for-legal-hold"
	createTestObject(ctx, t, hc, bktInfo, objName)

	w, r := prepareTestRequest(t, bktName, objName, nil)
	hc.Handler().GetObjectLegalHoldHandler(w, r)
	assertLegalHold(t, w, legalHoldOff)

	w, r = prepareTestRequest(t, bktName, objName, &data.LegalHold{Status: legalHoldOn})
	hc.Handler().PutObjectLegalHoldHandler(w, r)
	require.Equal(t, http.StatusOK, w.Code)

	w, r = prepareTestRequest(t, bktName, objName, nil)
	hc.Handler().GetObjectLegalHoldHandler(w, r)
	assertLegalHold(t, w, legalHoldOn)

	// to make sure put hold is an idempotent operation
	w, r = prepareTestRequest(t, bktName, objName, &data.LegalHold{Status: legalHoldOn})
	hc.Handler().PutObjectLegalHoldHandler(w, r)
	require.Equal(t, http.StatusOK, w.Code)

	w, r = prepareTestRequest(t, bktName, objName, &data.LegalHold{Status: legalHoldOff})
	hc.Handler().PutObjectLegalHoldHandler(w, r)
	require.Equal(t, http.StatusOK, w.Code)

	w, r = prepareTestRequest(t, bktName, objName, nil)
	hc.Handler().GetObjectLegalHoldHandler(w, r)
	assertLegalHold(t, w, legalHoldOff)

	// to make sure put hold is an idempotent operation
	w, r = prepareTestRequest(t, bktName, objName, &data.LegalHold{Status: legalHoldOff})
	hc.Handler().PutObjectLegalHoldHandler(w, r)
	require.Equal(t, http.StatusOK, w.Code)
}

func assertLegalHold(t *testing.T, w *httptest.ResponseRecorder, status string) {
	actualHold := &data.LegalHold{}
	err := xml.NewDecoder(w.Result().Body).Decode(actualHold)
	require.NoError(t, err)
	require.Equal(t, status, actualHold.Status)
	require.Equal(t, http.StatusOK, w.Code)
}

func TestObjectRetention(t *testing.T) {
	ctx := context.Background()
	hc := prepareHandlerContext(t)

	bktName := "bucket-lock-enabled"
	bktInfo := createTestBucketWithLock(ctx, t, hc, bktName, nil)

	objName := "obj-for-retention"
	createTestObject(ctx, t, hc, bktInfo, objName)

	w, r := prepareTestRequest(t, bktName, objName, nil)
	hc.Handler().GetObjectRetentionHandler(w, r)
	assertS3Error(t, w, apiErrors.GetAPIError(apiErrors.ErrNoSuchKey))

	retention := &data.Retention{Mode: governanceMode, RetainUntilDate: time.Now().UTC().Format(time.RFC3339)}
	w, r = prepareTestRequest(t, bktName, objName, retention)
	hc.Handler().PutObjectRetentionHandler(w, r)
	assertStatus(t, w, http.StatusOK)

	w, r = prepareTestRequest(t, bktName, objName, nil)
	hc.Handler().GetObjectRetentionHandler(w, r)
	assertRetention(t, w, retention)

	retention = &data.Retention{Mode: governanceMode, RetainUntilDate: time.Now().UTC().Format(time.RFC3339)}
	w, r = prepareTestRequest(t, bktName, objName, retention)
	hc.Handler().PutObjectRetentionHandler(w, r)
	assertS3Error(t, w, apiErrors.GetAPIError(apiErrors.ErrInternalError))

	retention = &data.Retention{Mode: complianceMode, RetainUntilDate: time.Now().UTC().Format(time.RFC3339)}
	w, r = prepareTestRequest(t, bktName, objName, retention)
	r.Header.Set(api.AmzBypassGovernanceRetention, strconv.FormatBool(true))
	hc.Handler().PutObjectRetentionHandler(w, r)
	assertStatus(t, w, http.StatusOK)

	w, r = prepareTestRequest(t, bktName, objName, nil)
	hc.Handler().GetObjectRetentionHandler(w, r)
	assertRetention(t, w, retention)

	w, r = prepareTestRequest(t, bktName, objName, retention)
	r.Header.Set(api.AmzBypassGovernanceRetention, strconv.FormatBool(true))
	hc.Handler().PutObjectRetentionHandler(w, r)
	assertS3Error(t, w, apiErrors.GetAPIError(apiErrors.ErrInternalError))
}

func assertRetention(t *testing.T, w *httptest.ResponseRecorder, retention *data.Retention) {
	actualRetention := &data.Retention{}
	err := xml.NewDecoder(w.Result().Body).Decode(actualRetention)
	require.NoError(t, err)
	require.Equal(t, retention.Mode, actualRetention.Mode)
	require.Equal(t, retention.RetainUntilDate, actualRetention.RetainUntilDate)
	require.Equal(t, http.StatusOK, w.Code)
}

func TestPutObjectWithLock(t *testing.T) {
	ctx := context.Background()
	hc := prepareHandlerContext(t)

	bktName := "bucket-lock-enabled"
	lockConfig := &data.ObjectLockConfiguration{
		ObjectLockEnabled: enabledValue,
		Rule: &data.ObjectLockRule{
			DefaultRetention: &data.DefaultRetention{
				Days: 1,
				Mode: governanceMode,
			},
		},
	}
	createTestBucketWithLock(ctx, t, hc, bktName, lockConfig)

	objDefault := "obj-default-retention"

	w, r := prepareTestRequest(t, bktName, objDefault, nil)
	hc.Handler().PutObjectHandler(w, r)
	assertStatus(t, w, http.StatusOK)

	w, r = prepareTestRequest(t, bktName, objDefault, nil)
	hc.Handler().GetObjectRetentionHandler(w, r)
	expectedRetention := &data.Retention{
		Mode:            governanceMode,
		RetainUntilDate: time.Now().Add(24 * time.Hour).Format(time.RFC3339),
	}
	assertRetentionApproximate(t, w, expectedRetention, 1)

	w, r = prepareTestRequest(t, bktName, objDefault, nil)
	hc.Handler().GetObjectLegalHoldHandler(w, r)
	assertLegalHold(t, w, legalHoldOff)

	objOverride := "obj-override-retention"
	w, r = prepareTestRequest(t, bktName, objOverride, nil)
	r.Header.Set(api.AmzObjectLockMode, complianceMode)
	r.Header.Set(api.AmzObjectLockLegalHold, legalHoldOn)
	r.Header.Set(api.AmzBypassGovernanceRetention, "true")
	r.Header.Set(api.AmzObjectLockRetainUntilDate, time.Now().Add(2*24*time.Hour).Format(time.RFC3339))
	hc.Handler().PutObjectHandler(w, r)
	assertStatus(t, w, http.StatusOK)

	w, r = prepareTestRequest(t, bktName, objOverride, nil)
	hc.Handler().GetObjectRetentionHandler(w, r)
	expectedRetention = &data.Retention{
		Mode:            complianceMode,
		RetainUntilDate: time.Now().Add(2 * 24 * time.Hour).Format(time.RFC3339),
	}
	assertRetentionApproximate(t, w, expectedRetention, 1)

	w, r = prepareTestRequest(t, bktName, objOverride, nil)
	hc.Handler().GetObjectLegalHoldHandler(w, r)
	assertLegalHold(t, w, legalHoldOn)
}

func assertRetentionApproximate(t *testing.T, w *httptest.ResponseRecorder, retention *data.Retention, delta float64) {
	actualRetention := &data.Retention{}
	err := xml.NewDecoder(w.Result().Body).Decode(actualRetention)
	require.NoError(t, err)
	require.Equal(t, retention.Mode, actualRetention.Mode)
	require.Equal(t, http.StatusOK, w.Code)

	actualUntil, err := time.Parse(time.RFC3339, actualRetention.RetainUntilDate)
	require.NoError(t, err)

	expectedUntil, err := time.Parse(time.RFC3339, retention.RetainUntilDate)
	require.NoError(t, err)

	require.InDelta(t, expectedUntil.Unix(), actualUntil.Unix(), delta)
}

func assertStatus(t *testing.T, w *httptest.ResponseRecorder, status int) {
	if w.Code != status {
		resp, err := io.ReadAll(w.Result().Body)
		require.NoError(t, err)
		require.Failf(t, "unexpected status", "expected: %d, actual: %d, resp: '%s'", status, w.Code, string(resp))
	}
}