package handlers

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	clinic_internal_api "github.com/pingcap-inc/clinic/clinic_data_service/pkg/clinic-internal-api"
	"github.com/pingcap-inc/clinic/clinic_data_service/pkg/service"
)

type fakeBYOCTopSQLQuerier struct {
	result    *service.BYOCTopSQLResult
	err       error
	calls     int
	orgID     uint64
	clusterID string
	operation service.BYOCTopSQLOperation
	query     url.Values
}

func (f *fakeBYOCTopSQLQuerier) Query(
	_ context.Context,
	orgID uint64,
	clusterID string,
	operation service.BYOCTopSQLOperation,
	query url.Values,
) (*service.BYOCTopSQLResult, error) {
	f.calls++
	f.orgID = orgID
	f.clusterID = clusterID
	f.operation = operation
	f.query = cloneURLValues(query)
	return f.result, f.err
}

func TestRegisterBYOCTopSQLRoutes(t *testing.T) {
	gin.SetMode(gin.TestMode)
	router := gin.New()
	NewBYOCTopSQLHandler(&fakeBYOCTopSQLQuerier{}).RegisterRoutes(router)

	want := map[string]bool{
		http.MethodGet + " /data-proxy/api/v1/orgs/:orgId/clusters/:clusterId/top-sql/instances": false,
		http.MethodGet + " /data-proxy/api/v1/orgs/:orgId/clusters/:clusterId/top-sql/summary":   false,
	}
	for _, route := range router.Routes() {
		key := route.Method + " " + route.Path
		if _, ok := want[key]; ok {
			want[key] = true
		}
	}
	for route, found := range want {
		assert.Truef(t, found, "route %s was not registered", route)
	}
}

func TestBYOCTopSQLInstancesCanonicalizesDecimalTimesAndPreservesEnvelope(t *testing.T) {
	wantBody := `{"status":"ok","data":[{"instance":"db-tidb-0:10080","instance_type":"tidb"}],"future":true}`
	fake := successfulBYOCTopSQLQuerier(wantBody)
	response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
		topSQLPath("/instances?start=0001.5000&end=2.2500"), "", nil)

	require.Equal(t, http.StatusOK, response.Code, response.Body.String())
	require.Equal(t, wantBody, response.Body.String())
	require.Equal(t, 1, fake.calls)
	require.Equal(t, uint64(123), fake.orgID)
	require.Equal(t, "logical", fake.clusterID)
	require.Equal(t, service.BYOCTopSQLOperationInstances, fake.operation)
	require.Equal(t, url.Values{"start": {"1.5"}, "end": {"2.25"}}, fake.query)
}

func TestBYOCTopSQLSummarySupportsPhysicalClusterAndBoundaryValues(t *testing.T) {
	wantBody := `{"status":"ok","data":[{"sql_digest":"digest-a","cpu_time_ms":42}],"future":true}`
	fake := successfulBYOCTopSQLQuerier(wantBody)
	query := url.Values{
		"start":         {"1000.25"},
		"end":           {"1297000.25"}, // exactly 15 days
		"window":        {"317s"},       // keeps the result below 4096 buckets
		"top":           {"0100"},
		"instance":      {"db-tikv_1.example:[20160]"},
		"instance_type": {"pd"},
	}
	response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
		"/data-proxy/api/v1/orgs/123/clusters/physical/top-sql/summary?"+query.Encode(), "", nil)

	require.Equal(t, http.StatusOK, response.Code, response.Body.String())
	require.Equal(t, wantBody, response.Body.String())
	require.Equal(t, 1, fake.calls)
	require.Equal(t, "physical", fake.clusterID)
	require.Equal(t, service.BYOCTopSQLOperationSummary, fake.operation)
	require.Equal(t, "100", fake.query.Get("top"))
	require.Equal(t, "317s", fake.query.Get("window"))
	require.Equal(t, "db-tikv_1.example:[20160]", fake.query.Get("instance"))
	require.Equal(t, "pd", fake.query.Get("instance_type"))
}

func TestBYOCTopSQLSummaryAllowsExactly4096Buckets(t *testing.T) {
	fake := successfulBYOCTopSQLQuerier(`{"status":"ok","data":[]}`)
	query := url.Values{
		"start":         {"0.25"},
		"end":           {"4096.25"},
		"window":        {"1s"},
		"top":           {"1"},
		"instance":      {strings.Repeat("a", maxBYOCTopSQLInstanceBytes)},
		"instance_type": {"tidb"},
	}
	response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
		topSQLPath("/summary?"+query.Encode()), "", nil)

	require.Equal(t, http.StatusOK, response.Code, response.Body.String())
	require.Equal(t, 1, fake.calls)
	require.Len(t, fake.query.Get("instance"), maxBYOCTopSQLInstanceBytes)
}

func TestBYOCTopSQLSummaryAllowsExactMaximumValues(t *testing.T) {
	fake := successfulBYOCTopSQLQuerier(`{"status":"ok","data":[]}`)
	query := url.Values{
		"start":         {"253401004799"},
		"end":           {"253402300799"},
		"window":        {"360h"},
		"top":           {"100"},
		"instance":      {strings.Repeat("a", maxBYOCTopSQLInstanceBytes)},
		"instance_type": {"tikv"},
	}
	response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
		topSQLPath("/summary?"+query.Encode()), "", nil)

	require.Equal(t, http.StatusOK, response.Code, response.Body.String())
	require.Equal(t, 1, fake.calls)
	require.Equal(t, "360h", fake.query.Get("window"))
}

func TestBYOCTopSQLRejectsInvalidRequestsBeforeService(t *testing.T) {
	validSummary := topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=db-tidb-0:10080&instance_type=tidb")
	tooLargeAndMalformed := topSQLPath("/instances?start=%ZZ&padding=" + strings.Repeat("a", maxBYOCTopSQLQueryBytes))
	longInstance := strings.Repeat("a", maxBYOCTopSQLInstanceBytes+1)

	tests := []struct {
		name       string
		path       string
		body       string
		headers    http.Header
		wantStatus int
	}{
		{name: "instances missing end", path: topSQLPath("/instances?start=1"), wantStatus: http.StatusBadRequest},
		{name: "instances duplicate start", path: topSQLPath("/instances?start=1&start=2&end=3"), wantStatus: http.StatusBadRequest},
		{name: "unknown parameter", path: topSQLPath("/instances?start=1&end=2&step=1"), wantStatus: http.StatusBadRequest},
		{name: "malformed escaping", path: topSQLPath("/instances?start=1&end=%ZZ"), wantStatus: http.StatusBadRequest},
		{name: "query cap runs before parse", path: tooLargeAndMalformed, wantStatus: http.StatusRequestEntityTooLarge},
		{name: "request body", path: validSummary, body: "x", wantStatus: http.StatusBadRequest},
		{name: "caller placement query", path: topSQLPath("/instances?start=1&end=2&provider=aws"), wantStatus: http.StatusBadRequest},
		{name: "caller placement header", path: topSQLPath("/instances?start=1&end=2"), headers: http.Header{"X-Region": {"us-east-1"}}, wantStatus: http.StatusBadRequest},
		{name: "negative start", path: topSQLPath("/instances?start=-1&end=2"), wantStatus: http.StatusBadRequest},
		{name: "nan start", path: topSQLPath("/instances?start=NaN&end=2"), wantStatus: http.StatusBadRequest},
		{name: "infinite start", path: topSQLPath("/instances?start=Inf&end=2"), wantStatus: http.StatusBadRequest},
		{name: "exponent start", path: topSQLPath("/instances?start=1e3&end=1001"), wantStatus: http.StatusBadRequest},
		{name: "timestamp above maximum", path: topSQLPath("/instances?start=0&end=253402300800"), wantStatus: http.StatusBadRequest},
		{name: "reverse range", path: topSQLPath("/instances?start=2&end=1"), wantStatus: http.StatusBadRequest},
		{name: "range above 15 days", path: topSQLPath("/instances?start=1.25&end=1296001.2500001"), wantStatus: http.StatusBadRequest},
		{name: "summary missing window", path: topSQLPath("/summary?start=1&end=2&top=1&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "summary missing top", path: topSQLPath("/summary?start=1&end=2&window=1s&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "summary missing instance", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "summary missing instance type", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=i"), wantStatus: http.StatusBadRequest},
		{name: "duplicate summary scalar", path: validSummary + "&top=2", wantStatus: http.StatusBadRequest},
		{name: "zero window", path: topSQLPath("/summary?start=1&end=2&window=0s&top=1&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "fractional window", path: topSQLPath("/summary?start=1&end=2&window=1500ms&top=1&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "window above 15 days", path: topSQLPath("/summary?start=1&end=2&window=360h1s&top=1&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "more than 4096 buckets", path: topSQLPath("/summary?start=0.25&end=4096.250001&window=1s&top=1&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "zero top", path: topSQLPath("/summary?start=1&end=2&window=1s&top=0&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "top above maximum", path: topSQLPath("/summary?start=1&end=2&window=1s&top=101&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "fractional top", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1.5&instance=i&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "empty instance", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "long instance", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=" + longInstance + "&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "quote in instance", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=db%22x&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "backslash in instance", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=db%5Cx&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "control in instance", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=db%0Ax&instance_type=tidb"), wantStatus: http.StatusBadRequest},
		{name: "unsupported instance type", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=i&instance_type=tiflash"), wantStatus: http.StatusBadRequest},
		{name: "empty instance type", path: topSQLPath("/summary?start=1&end=2&window=1s&top=1&instance=i&instance_type="), wantStatus: http.StatusBadRequest},
		{name: "invalid org", path: "/data-proxy/api/v1/orgs/nope/clusters/logical/top-sql/instances?start=1&end=2", wantStatus: http.StatusBadRequest},
		{name: "invalid cluster", path: "/data-proxy/api/v1/orgs/123/clusters/%0A/top-sql/instances?start=1&end=2", wantStatus: http.StatusBadRequest},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fake := successfulBYOCTopSQLQuerier(`{"status":"ok","data":[]}`)
			response := performBYOCTopSQLRequest(t, fake, http.MethodGet, tt.path, tt.body, tt.headers)
			require.Equal(t, tt.wantStatus, response.Code, response.Body.String())
			require.Equal(t, 0, fake.calls)
		})
	}
}

func TestBYOCTopSQLRejectsServerOwnedRoutingHeadersBeforeService(t *testing.T) {
	for _, header := range []string{
		"X-Env",
		"X-Biz-Type",
		"X-Region-Id",
		"X-Application-Id",
		"X-Deploy-Type",
		"X-Deploy-Type-V2",
		"X-O11y-Cluster-Id",
	} {
		t.Run(header, func(t *testing.T) {
			fake := successfulBYOCTopSQLQuerier(`{"status":"ok","data":[]}`)
			response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
				topSQLPath("/instances?start=1&end=2"), "", http.Header{header: {"forged"}})
			require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
			require.Equal(t, 0, fake.calls)
		})
	}
}

func TestBYOCTopSQLMapsErrorsWithoutLeakingDetails(t *testing.T) {
	tests := []struct {
		name       string
		err        error
		wantStatus int
		wantCode   string
	}{
		{name: "canceled", err: context.Canceled, wantStatus: clientClosedStatus, wantCode: "Canceled"},
		{name: "deadline", err: context.DeadlineExceeded, wantStatus: http.StatusGatewayTimeout, wantCode: "DeadlineExceeded"},
		{name: "service unavailable", err: service.ErrBYOCTopSQLUnavailable, wantStatus: http.StatusServiceUnavailable, wantCode: "Unavailable"},
		{name: "invalid target", err: service.ErrInvalidBYOCTopSQLTarget, wantStatus: http.StatusBadGateway, wantCode: "BadGateway"},
		{name: "invalid response", err: service.ErrInvalidBYOCTopSQLResponse, wantStatus: http.StatusBadGateway, wantCode: "BadGateway"},
		{name: "unsupported tier", err: &clinic_internal_api.UpstreamError{StatusCode: http.StatusBadRequest}, wantStatus: http.StatusBadRequest, wantCode: "InvalidArgument"},
		{name: "wrong org", err: &clinic_internal_api.UpstreamError{StatusCode: http.StatusNotFound}, wantStatus: http.StatusNotFound, wantCode: "NotFound"},
		{name: "target rate limited", err: &clinic_internal_api.UpstreamError{StatusCode: http.StatusTooManyRequests}, wantStatus: http.StatusTooManyRequests, wantCode: "ResourceExhausted"},
		{name: "target unavailable", err: &clinic_internal_api.UpstreamError{StatusCode: http.StatusServiceUnavailable}, wantStatus: http.StatusServiceUnavailable, wantCode: "Unavailable"},
		{name: "bad upstream request", err: &service.BYOCTopSQLUpstreamError{StatusCode: http.StatusBadRequest}, wantStatus: http.StatusBadRequest, wantCode: "InvalidArgument"},
		{name: "upstream not found", err: &service.BYOCTopSQLUpstreamError{StatusCode: http.StatusNotFound}, wantStatus: http.StatusNotFound, wantCode: "NotFound"},
		{name: "upstream rate limited", err: &service.BYOCTopSQLUpstreamError{StatusCode: http.StatusTooManyRequests}, wantStatus: http.StatusTooManyRequests, wantCode: "ResourceExhausted"},
		{name: "upstream timeout", err: &service.BYOCTopSQLUpstreamError{StatusCode: http.StatusGatewayTimeout}, wantStatus: http.StatusGatewayTimeout, wantCode: "DeadlineExceeded"},
		{name: "upstream unavailable", err: &service.BYOCTopSQLUpstreamError{StatusCode: http.StatusServiceUnavailable}, wantStatus: http.StatusServiceUnavailable, wantCode: "Unavailable"},
		{name: "upstream internal", err: &service.BYOCTopSQLUpstreamError{StatusCode: http.StatusInternalServerError}, wantStatus: http.StatusBadGateway, wantCode: "BadGateway"},
		{name: "unknown", err: errors.New("secret customer SQL and target URL"), wantStatus: http.StatusBadGateway, wantCode: "BadGateway"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fake := &fakeBYOCTopSQLQuerier{err: tt.err}
			response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
				topSQLPath("/instances?start=1&end=2"), "", nil)
			require.Equal(t, tt.wantStatus, response.Code, response.Body.String())
			require.Contains(t, response.Body.String(), fmt.Sprintf(`"code":"%s"`, tt.wantCode))
			require.NotContains(t, response.Body.String(), "secret customer SQL")
			require.Equal(t, 1, fake.calls)
		})
	}
}

func TestBYOCTopSQLRejectsNilServiceAndNilResult(t *testing.T) {
	t.Run("nil service", func(t *testing.T) {
		response := performBYOCTopSQLRequest(t, nil, http.MethodGet,
			topSQLPath("/instances?start=1&end=2"), "", nil)
		require.Equal(t, http.StatusServiceUnavailable, response.Code, response.Body.String())
	})

	t.Run("nil result", func(t *testing.T) {
		fake := &fakeBYOCTopSQLQuerier{}
		response := performBYOCTopSQLRequest(t, fake, http.MethodGet,
			topSQLPath("/instances?start=1&end=2"), "", nil)
		require.Equal(t, http.StatusBadGateway, response.Code, response.Body.String())
		require.Equal(t, 1, fake.calls)
	})
}

func successfulBYOCTopSQLQuerier(body string) *fakeBYOCTopSQLQuerier {
	return &fakeBYOCTopSQLQuerier{result: &service.BYOCTopSQLResult{
		Body:         []byte(body),
		Provider:     "aws",
		Region:       "us-east-1",
		DeployTypeV2: "byoc",
	}}
}

func topSQLPath(suffix string) string {
	return "/data-proxy/api/v1/orgs/123/clusters/logical/top-sql" + suffix
}

func performBYOCTopSQLRequest(
	t *testing.T,
	fake byocTopSQLQuerier,
	method string,
	path string,
	body string,
	headers http.Header,
) *httptest.ResponseRecorder {
	t.Helper()
	gin.SetMode(gin.TestMode)
	router := gin.New()
	NewBYOCTopSQLHandler(fake).RegisterRoutes(router)
	request := httptest.NewRequest(method, path, strings.NewReader(body))
	for key, values := range headers {
		for _, value := range values {
			request.Header.Add(key, value)
		}
	}
	response := httptest.NewRecorder()
	router.ServeHTTP(response, request)
	return response
}
