package activity_file

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sync"
	"testing"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gin-gonic/gin"

	"github.com/pingcap-inc/clinic/pkg/activityhub_client"
	apierr "github.com/pingcap-inc/clinic/pkg/api/error"
	clinicsession "github.com/pingcap-inc/clinic/pkg/auth/session"
)

const testActivityID = "activity-1"

var activityFileRoutes = []string{
	"/clinic/api/v1/activityhub/file/view",
	"/clinic/api/v1/activityhub/file/download",
}

func TestActivityFileHandlersReadMatchedActivityFile(t *testing.T) {
	for _, route := range activityFileRoutes {
		t.Run(route, func(t *testing.T) {
			activity := newTestActivity(testActivityID, "diagnosis/allowed.json")
			router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, internalTestUser(), activity)

			response := serveActivityFileRequest(router, route, validActivityFileQuery())

			if response.Code != http.StatusOK {
				t.Errorf("status = %d, want %d; body = %s", response.Code, http.StatusOK, response.Body.String())
			}
			assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), []string{"/activity/activity-1"})
			assertRequestPaths(t, "S3", s3Requests.snapshot(), []string{"/configured-bucket/diagnosis/allowed.json"})
		})
	}
}

func TestActivityFileHandlersRejectLegacyKeyOnlyRequest(t *testing.T) {
	for _, route := range activityFileRoutes {
		t.Run(route, func(t *testing.T) {
			activity := newTestActivity(testActivityID, "diagnosis/allowed.json")
			router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, internalTestUser(), activity)

			response := serveActivityFileRequest(router, route, url.Values{"key": {"diagnosis/allowed.json"}})

			if response.Code != http.StatusBadRequest {
				t.Errorf("status = %d, want %d", response.Code, http.StatusBadRequest)
			}
			assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), nil)
			assertRequestPaths(t, "S3", s3Requests.snapshot(), nil)
		})
	}
}

func TestActivityFileHandlersRequireInternalUser(t *testing.T) {
	users := []struct {
		name string
		user *clinicsession.CtxUser
	}{
		{name: "missing user"},
		{name: "external user", user: &clinicsession.CtxUser{Internal: false}},
	}

	for _, route := range activityFileRoutes {
		for _, test := range users {
			t.Run(route+"/"+test.name, func(t *testing.T) {
				activity := newTestActivity(testActivityID, "diagnosis/allowed.json")
				router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, test.user, activity)

				response := serveActivityFileRequest(router, route, validActivityFileQuery())

				if response.Code != http.StatusForbidden {
					t.Errorf("status = %d, want %d", response.Code, http.StatusForbidden)
				}
				assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), nil)
				assertRequestPaths(t, "S3", s3Requests.snapshot(), nil)
			})
		}
	}
}

func TestActivityFileHandlersRejectDifferentActivity(t *testing.T) {
	for _, route := range activityFileRoutes {
		t.Run(route, func(t *testing.T) {
			activity := newTestActivity("activity-2", "diagnosis/other.json")
			router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, internalTestUser(), activity)

			response := serveActivityFileRequest(router, route, validActivityFileQuery())

			if response.Code != http.StatusNotFound {
				t.Errorf("status = %d, want %d", response.Code, http.StatusNotFound)
			}
			assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), []string{"/activity/activity-1"})
			assertRequestPaths(t, "S3", s3Requests.snapshot(), nil)
		})
	}
}

func TestActivityFileHandlersReturnNotFoundForMissingActivity(t *testing.T) {
	for _, route := range activityFileRoutes {
		t.Run(route, func(t *testing.T) {
			router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, internalTestUser(), nil)

			response := serveActivityFileRequest(router, route, validActivityFileQuery())

			if response.Code != http.StatusNotFound {
				t.Errorf("status = %d, want %d", response.Code, http.StatusNotFound)
			}
			assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), []string{"/activity/activity-1"})
			assertRequestPaths(t, "S3", s3Requests.snapshot(), nil)
		})
	}
}

func TestActivityFileHandlersEscapeActivityIDForUpstreamLookup(t *testing.T) {
	query := validActivityFileQuery()
	query.Set("activity_id", "activity/../2")
	activity := newTestActivity("activity/../2", "diagnosis/allowed.json")
	router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, internalTestUser(), activity)

	response := serveActivityFileRequest(router, activityFileRoutes[0], query)

	if response.Code != http.StatusOK {
		t.Errorf("status = %d, want %d; body = %s", response.Code, http.StatusOK, response.Body.String())
	}
	assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), []string{"/activity/activity%2F..%2F2"})
	assertRequestPaths(t, "S3", s3Requests.snapshot(), []string{"/configured-bucket/diagnosis/allowed.json"})
}

func TestActivityFileHandlersRejectInvalidServerDerivedKey(t *testing.T) {
	tests := []struct {
		name    string
		payload any
	}{
		{name: "missing details", payload: map[string]any{}},
		{name: "missing key", payload: map[string]any{"details": map[string]any{}}},
		{name: "empty key", payload: map[string]any{"details": map[string]any{"key": ""}}},
		{name: "bucket parent", payload: map[string]any{"details": map[string]any{"key": ".."}}},
		{name: "other bucket", payload: map[string]any{"details": map[string]any{"key": "../other-bucket/object.json"}}},
		{name: "absolute key", payload: map[string]any{"details": map[string]any{"key": "/other-bucket/object.json"}}},
		{name: "repeated separator", payload: map[string]any{"details": map[string]any{"key": "diagnosis//object.json"}}},
	}

	for _, route := range activityFileRoutes {
		for _, test := range tests {
			t.Run(route+"/"+test.name, func(t *testing.T) {
				activity := newTestActivity(testActivityID, "diagnosis/allowed.json")
				activity.Payload = test.payload
				router, activityHubRequests, s3Requests := newActivityFileTestRouter(t, internalTestUser(), activity)

				response := serveActivityFileRequest(router, route, validActivityFileQuery())

				if response.Code != http.StatusBadRequest {
					t.Errorf("status = %d, want %d", response.Code, http.StatusBadRequest)
				}
				assertRequestPaths(t, "ActivityHub", activityHubRequests.snapshot(), []string{"/activity/activity-1"})
				assertRequestPaths(t, "S3", s3Requests.snapshot(), nil)
			})
		}
	}
}

func validActivityFileQuery() url.Values {
	return url.Values{
		"activity_id": {testActivityID},
	}
}

func serveActivityFileRequest(router http.Handler, route string, query url.Values) *httptest.ResponseRecorder {
	response := httptest.NewRecorder()
	router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, route+"?"+query.Encode(), nil))
	return response
}

func newTestActivity(activityID, key string) *activityhub_client.Activity {
	return &activityhub_client.Activity{
		ActivityID: activityID,
		Payload: map[string]any{
			"details": map[string]any{
				"key": key,
			},
		},
	}
}

func internalTestUser() *clinicsession.CtxUser {
	return &clinicsession.CtxUser{Internal: true}
}

func assertRequestPaths(t *testing.T, upstream string, got, want []string) {
	t.Helper()
	if len(got) != len(want) {
		t.Fatalf("%s request paths = %q, want %q", upstream, got, want)
	}
	for index := range want {
		if got[index] != want[index] {
			t.Errorf("%s request paths = %q, want %q", upstream, got, want)
		}
	}
}

type requestRecorder struct {
	mu    sync.Mutex
	paths []string
}

func (r *requestRecorder) record(path string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.paths = append(r.paths, path)
}

func (r *requestRecorder) snapshot() []string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return append([]string(nil), r.paths...)
}

func newActivityFileTestRouter(
	t *testing.T,
	user *clinicsession.CtxUser,
	activity *activityhub_client.Activity,
) (*gin.Engine, *requestRecorder, *requestRecorder) {
	t.Helper()

	activityHubRequests := &requestRecorder{}
	activityHubServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		activityHubRequests.record(r.URL.EscapedPath())
		w.Header().Set("Content-Type", "application/json")
		response := map[string]any{
			"status": "success",
			"result": activity,
		}
		if activity == nil {
			response["status"] = "error"
			response["msg"] = "Activity not found."
		}
		_ = json.NewEncoder(w).Encode(response)
	}))
	t.Cleanup(activityHubServer.Close)

	s3Requests := &requestRecorder{}
	s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		s3Requests.record(r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"source":"mock-s3"}`))
	}))
	t.Cleanup(s3Server.Close)

	sess := session.Must(session.NewSession(&aws.Config{
		Credentials:      credentials.AnonymousCredentials,
		Endpoint:         aws.String(s3Server.URL),
		Region:           aws.String("us-west-2"),
		S3ForcePathStyle: aws.Bool(true),
	}))
	api := &DiagnosisActivityAPI{
		s3API:       s3.New(sess),
		bucket:      "configured-bucket",
		activityAPI: activityhub_client.NewClient(activityHubServer.URL, "test-source", "test-key"),
	}

	gin.SetMode(gin.TestMode)
	router := gin.New()
	router.Use(apierr.ErrorHandlerFn)
	if user != nil {
		router.Use(func(c *gin.Context) {
			c.Set(clinicsession.CtxUserKey, user)
			c.Next()
		})
	}
	RegisterRouter(router.Group("/clinic/api/v1"), api)
	return router, activityHubRequests, s3Requests
}
