package api

import (
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
	"time"

	apiErrors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors"
	s3middleware "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware"
	"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/metrics"
	engineiam "git.frostfs.info/TrueCloudLab/policy-engine/iam"
	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory"
	"git.frostfs.info/TrueCloudLab/policy-engine/schema/s3"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/stretchr/testify/require"
	"go.uber.org/zap/zaptest"
)

type routerMock struct {
	router             *chi.Mux
	cfg                Config
	middlewareSettings *middlewareSettingsMock
	policyChecker      engine.LocalOverrideEngine
}

func (m *routerMock) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	m.router.ServeHTTP(w, r)
}

func prepareRouter(t *testing.T) *routerMock {
	middlewareSettings := &middlewareSettingsMock{}
	policyChecker := inmemory.NewInMemoryLocalOverrides()

	cfg := Config{
		Throttle: middleware.ThrottleOpts{
			Limit:          10,
			BacklogTimeout: 30 * time.Second,
		},
		Handler:            &handlerMock{t: t},
		Center:             &centerMock{},
		Log:                zaptest.NewLogger(t),
		Metrics:            &metrics.AppMetrics{},
		MiddlewareSettings: middlewareSettings,
		PolicyChecker:      policyChecker,
		Domains:            []string{"domain1", "domain2"},
	}
	return &routerMock{
		router:             NewRouter(cfg),
		cfg:                cfg,
		middlewareSettings: middlewareSettings,
		policyChecker:      policyChecker,
	}
}

func TestRouterUploadPart(t *testing.T) {
	chiRouter := prepareRouter(t)

	w := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPut, "/dkirillov/fix-object", nil)
	query := make(url.Values)
	query.Set("uploadId", "some-id")
	query.Set("partNumber", "1")
	r.URL.RawQuery = query.Encode()

	chiRouter.ServeHTTP(w, r)
	resp := readResponse(t, w)
	require.Equal(t, "UploadPart", resp.Method)
}

func TestRouterListMultipartUploads(t *testing.T) {
	chiRouter := prepareRouter(t)

	w := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodGet, "/test-bucket", nil)
	query := make(url.Values)
	query.Set("uploads", "")
	r.URL.RawQuery = query.Encode()

	chiRouter.ServeHTTP(w, r)
	resp := readResponse(t, w)
	require.Equal(t, "ListMultipartUploads", resp.Method)
}

func TestRouterObjectWithSlashes(t *testing.T) {
	chiRouter := prepareRouter(t)

	bktName, objName := "dkirillov", "/fix/object"
	target := fmt.Sprintf("/%s/%s", bktName, objName)

	w := httptest.NewRecorder()
	r := httptest.NewRequest(http.MethodPut, target, nil)

	chiRouter.ServeHTTP(w, r)
	resp := readResponse(t, w)
	require.Equal(t, "PutObject", resp.Method)
	require.Equal(t, objName, resp.ReqInfo.ObjectName)
}

func TestRouterObjectEscaping(t *testing.T) {
	chiRouter := prepareRouter(t)

	bktName := "dkirillov"

	for _, tc := range []struct {
		name            string
		expectedObjName string
		objName         string
	}{
		{
			name:            "simple",
			expectedObjName: "object",
			objName:         "object",
		},
		{
			name:            "with slashes",
			expectedObjName: "fix/object",
			objName:         "fix/object",
		},
		{
			name:            "with slash escaped",
			expectedObjName: "/foo/bar",
			objName:         "/foo%2fbar",
		},
		{
			name:            "with percentage escaped",
			expectedObjName: "fix/object%ac",
			objName:         "fix/object%25ac",
		},
		{
			name:            "with awful mint name",
			expectedObjName: "äöüex ®©µÄÆÐÕæŒƕƩDž 01000000 0x40 \u0040 amȡȹɆple&0a!-_.*'()&$@=;:+,?<>.pdf",
			objName:         "%C3%A4%C3%B6%C3%BCex%20%C2%AE%C2%A9%C2%B5%C3%84%C3%86%C3%90%C3%95%C3%A6%C5%92%C6%95%C6%A9%C7%85%2001000000%200x40%20%40%20am%C8%A1%C8%B9%C9%86ple%260a%21-_.%2A%27%28%29%26%24%40%3D%3B%3A%2B%2C%3F%3C%3E.pdf",
		},
	} {
		t.Run(tc.name, func(t *testing.T) {
			target := fmt.Sprintf("/%s/%s", bktName, tc.objName)

			w := httptest.NewRecorder()
			r := httptest.NewRequest(http.MethodPut, target, nil)

			chiRouter.ServeHTTP(w, r)
			resp := readResponse(t, w)
			require.Equal(t, "PutObject", resp.Method)
			require.Equal(t, tc.expectedObjName, resp.ReqInfo.ObjectName)
		})
	}
}

func TestPolicyChecker(t *testing.T) {
	chiRouter := prepareRouter(t)
	namespace := "custom-ns"
	bktName, objName := "bucket", "object"
	target := fmt.Sprintf("/%s/%s", bktName, objName)

	ruleChain := &chain.Chain{
		ID: "id",
		Rules: []chain.Rule{{
			Status:    chain.AccessDenied,
			Actions:   chain.Actions{Names: []string{"*"}},
			Resources: chain.Resources{Names: []string{fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName)}},
		}},
	}

	_, _, err := chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(namespace), ruleChain)
	require.NoError(t, err)

	// check we can access 'bucket' in default namespace
	w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil)
	chiRouter.ServeHTTP(w, r)
	resp := readResponse(t, w)
	require.Equal(t, s3middleware.PutObjectOperation, resp.Method)

	// check we can access 'other-bucket' in custom namespace
	w, r = httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, "/other-bucket/object", nil)
	r.Header.Set(FrostfsNamespaceHeader, namespace)
	chiRouter.ServeHTTP(w, r)
	resp = readResponse(t, w)
	require.Equal(t, s3middleware.PutObjectOperation, resp.Method)

	// check we cannot access 'bucket' in custom namespace
	w, r = httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil)
	r.Header.Set(FrostfsNamespaceHeader, namespace)
	chiRouter.ServeHTTP(w, r)
	assertAPIError(t, w, apiErrors.ErrAccessDenied)
}

func TestPolicyCheckerReqTypeDetermination(t *testing.T) {
	chiRouter := prepareRouter(t)
	bktName, objName := "bucket", "object"

	policy := engineiam.Policy{
		Statement: []engineiam.Statement{{
			Principal: map[engineiam.PrincipalType][]string{engineiam.Wildcard: {}},
			Effect:    engineiam.AllowEffect,
			Action:    engineiam.Action{"s3:*"},
			Resource:  engineiam.Resource{fmt.Sprintf(s3.ResourceFormatS3All)},
		}},
	}

	ruleChain, err := engineiam.ConvertToS3Chain(policy, nil)
	require.NoError(t, err)

	_, _, err = chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(""), ruleChain)
	require.NoError(t, err)

	chiRouter.middlewareSettings.denyByDefault = true
	t.Run("can list buckets", func(t *testing.T) {
		w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)
		chiRouter.ServeHTTP(w, r)
		resp := readResponse(t, w)
		require.Equal(t, s3middleware.ListBucketsOperation, resp.Method)
	})

	t.Run("can head 'bucket'", func(t *testing.T) {
		w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodHead, "/"+bktName, nil)
		chiRouter.ServeHTTP(w, r)
		resp := readResponse(t, w)
		require.Equal(t, s3middleware.HeadBucketOperation, resp.Method)
	})

	t.Run("can put object into 'bucket'", func(t *testing.T) {
		w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, fmt.Sprintf("/%s/%s", bktName, objName), nil)
		chiRouter.ServeHTTP(w, r)
		resp := readResponse(t, w)
		require.Equal(t, s3middleware.PutObjectOperation, resp.Method)
	})
}

func TestDefaultBehaviorPolicyChecker(t *testing.T) {
	chiRouter := prepareRouter(t)
	bktName, objName := "bucket", "object"
	target := fmt.Sprintf("/%s/%s", bktName, objName)

	// check we can access bucket if rules not found
	w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil)
	chiRouter.ServeHTTP(w, r)
	resp := readResponse(t, w)
	require.Equal(t, s3middleware.PutObjectOperation, resp.Method)

	// check we cannot access if rules not found when settings is enabled
	chiRouter.middlewareSettings.denyByDefault = true
	w, r = httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, target, nil)
	chiRouter.ServeHTTP(w, r)
	assertAPIError(t, w, apiErrors.ErrAccessDenied)
}

func readResponse(t *testing.T, w *httptest.ResponseRecorder) handlerResult {
	var res handlerResult

	resData, err := io.ReadAll(w.Result().Body)
	require.NoError(t, err)

	err = json.Unmarshal(resData, &res)
	require.NoErrorf(t, err, "actual body: '%s'", string(resData))
	return res
}

func assertAPIError(t *testing.T, w *httptest.ResponseRecorder, expectedErrorCode apiErrors.ErrorCode) {
	actualErrorResponse := &s3middleware.ErrorResponse{}
	err := xml.NewDecoder(w.Result().Body).Decode(actualErrorResponse)
	require.NoError(t, err)

	expectedError := apiErrors.GetAPIError(expectedErrorCode)

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

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