// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package http

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	"github.com/hashicorp/go-hclog"

	svc "github.com/hashicorp/consul/agent/grpc-external/services/resource"
	svctest "github.com/hashicorp/consul/agent/grpc-external/services/resource/testing"
	"github.com/hashicorp/consul/internal/resource"
	"github.com/hashicorp/consul/internal/resource/demo"
	"github.com/hashicorp/consul/proto-public/pbresource"
	pbdemov1 "github.com/hashicorp/consul/proto/private/pbdemo/v1"
	pbdemov2 "github.com/hashicorp/consul/proto/private/pbdemo/v2"
	"github.com/hashicorp/consul/sdk/testutil"
)

const testACLTokenArtistReadPolicy = "00000000-0000-0000-0000-000000000001"
const testACLTokenArtistWritePolicy = "00000000-0000-0000-0000-000000000002"
const testACLTokenArtistListPolicy = "00000000-0000-0000-0000-000000000003"
const fakeToken = "fake-token"

func parseToken(req *http.Request, token *string) {
	*token = req.Header.Get("x-consul-token")
}

func TestResourceHandler_InputValidation(t *testing.T) {
	type testCase struct {
		description          string
		request              *http.Request
		response             *httptest.ResponseRecorder
		expectedResponseCode int
		responseBodyContains string
	}

	client := svctest.NewResourceServiceBuilder().
		WithRegisterFns(demo.RegisterTypes).
		Run(t)

	resourceHandler := resourceHandler{
		resource.Registration{
			Type:  demo.TypeV2Artist,
			Proto: &pbdemov2.Artist{},
			Scope: resource.ScopeNamespace,
		},
		client,
		func(req *http.Request, token *string) {},
		hclog.NewNullLogger(),
	}

	testCases := []testCase{
		{
			description: "missing resource name",
			request: httptest.NewRequest("PUT", "/?partition=default&peer_name=local&namespace=default", strings.NewReader(`
				{
					"metadata": {
						"foo": "bar"
					},
					"data": {
						"name": "Keith Urban",
						"genre": "GENRE_COUNTRY"
					}
				}
			`)),
			response:             httptest.NewRecorder(),
			expectedResponseCode: http.StatusBadRequest,
			responseBodyContains: "resource.id.name invalid",
		},
		{
			description: "wrong schema",
			request: httptest.NewRequest("PUT", "/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(`
				{
					"metadata": {
						"foo": "bar"
					},
					"dada": {
						"name": "Keith Urban",
						"genre": "GENRE_COUNTRY"
					}
				}
			`)),
			response:             httptest.NewRecorder(),
			expectedResponseCode: http.StatusBadRequest,
			responseBodyContains: "Request body didn't follow the resource schema",
		},
		{
			description:          "invalid request body",
			request:              httptest.NewRequest("PUT", "/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader("bad-input")),
			response:             httptest.NewRecorder(),
			expectedResponseCode: http.StatusBadRequest,
			responseBodyContains: "Request body format is invalid",
		},
		{
			description:          "no id",
			request:              httptest.NewRequest("DELETE", "/?partition=default&peer_name=local&namespace=default", strings.NewReader("")),
			response:             httptest.NewRecorder(),
			expectedResponseCode: http.StatusBadRequest,
			responseBodyContains: "id.name invalid",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.description, func(t *testing.T) {
			resourceHandler.ServeHTTP(tc.response, tc.request)

			response := tc.response.Result()

			defer response.Body.Close()

			b, err := io.ReadAll(tc.response.Body)
			require.NoError(t, err)

			require.Equal(t, tc.expectedResponseCode, tc.response.Result().StatusCode)
			require.Contains(t, string(b), tc.responseBodyContains)
		})
	}
}

func TestResourceWriteHandler(t *testing.T) {
	aclResolver := &svc.MockACLResolver{}
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistReadPolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV1ReadPolicy, demo.ArtistV2ReadPolicy), nil)
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistWritePolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV1WritePolicy, demo.ArtistV2WritePolicy), nil)

	builder := svctest.NewResourceServiceBuilder().
		WithACLResolver(aclResolver).
		WithRegisterFns(demo.RegisterTypes)
	client := builder.Run(t)
	handler := NewHandler("/api", client, builder.Registry(), parseToken, hclog.NewNullLogger())

	t.Run("should be blocked if the token is not authorized", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("PUT", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(`
			{
				"metadata": {
					"foo": "bar"
				},
				"data": {
					"name": "Keith Urban",
					"genre": "GENRE_COUNTRY"
				}
			}
		`))

		req.Header.Add("x-consul-token", testACLTokenArtistReadPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusForbidden, rsp.Result().StatusCode)
	})

	var readRsp *pbresource.ReadResponse
	t.Run("should write to the resource backend", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("PUT", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(`
			{
				"metadata": {
					"foo": "bar"
				},
				"data": {
					"name": "Keith Urban",
					"genre": "GENRE_COUNTRY"
				}
			}
		`))

		req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))
		require.Equal(t, "Keith Urban", result["data"].(map[string]any)["name"])
		require.Equal(t, "keith-urban", result["id"].(map[string]any)["name"])

		var err error
		readRsp, err = client.Read(testutil.TestContext(t), &pbresource.ReadRequest{
			Id: &pbresource.ID{
				Type:    demo.TypeV2Artist,
				Tenancy: resource.DefaultNamespacedTenancy(),
				Name:    "keith-urban",
			},
		})
		require.NoError(t, err)
		require.NotNil(t, readRsp.Resource)

		var artist pbdemov2.Artist
		require.NoError(t, readRsp.Resource.Data.UnmarshalTo(&artist))
		require.Equal(t, "Keith Urban", artist.Name)
	})

	t.Run("should update the record with version parameter", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("PUT", fmt.Sprintf("/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default&version=%s", readRsp.Resource.Version), strings.NewReader(`
			{
				"metadata": {
					"foo": "bar"
				},
				"data": {
					"name": "Keith Urban Two",
					"genre": "GENRE_COUNTRY"
				}
			}
		`))

		req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)
		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))
		require.Equal(t, "Keith Urban Two", result["data"].(map[string]any)["name"])
		require.Equal(t, "keith-urban", result["id"].(map[string]any)["name"])
	})

	t.Run("should fail the update if the resource's version doesn't match the version of the existing resource", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("PUT", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default&version=1", strings.NewReader(`
			{
				"metadata": {
					"foo": "bar"
				},
				"data": {
					"name": "Keith Urban",
					"genre": "GENRE_COUNTRY"
				}
			}
		`))

		req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusConflict, rsp.Result().StatusCode)
	})

	t.Run("should write to the resource backend with owner", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("PUT", "/demo/v1/artist/keith-urban-v1?partition=default&peer_name=local&namespace=default", strings.NewReader(`
			{
				"metadata": {
					"foo": "bar"
				},
				"data": {
					"name": "Keith Urban V1",
					"genre": "GENRE_COUNTRY"
				},
				"owner": {
					"name": "keith-urban",
					"type": {
						"group": "demo",
						"group_version": "v2",
						"kind": "Artist"
					},
					"tenancy": {
						"partition": "default",
						"namespace": "default"
					}
				}
			}
		`))

		req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))
		require.Equal(t, "Keith Urban V1", result["data"].(map[string]any)["name"])
		require.Equal(t, "keith-urban-v1", result["id"].(map[string]any)["name"])

		readRsp, err := client.Read(testutil.TestContext(t), &pbresource.ReadRequest{
			Id: &pbresource.ID{
				Type:    demo.TypeV1Artist,
				Tenancy: resource.DefaultNamespacedTenancy(),
				Name:    "keith-urban-v1",
			},
		})
		require.NoError(t, err)
		require.NotNil(t, readRsp.Resource)
		require.Equal(t, "keith-urban", readRsp.Resource.Owner.Name)

		var artist pbdemov1.Artist
		require.NoError(t, readRsp.Resource.Data.UnmarshalTo(&artist))
		require.Equal(t, "Keith Urban V1", artist.Name)
	})
}

type ResourceUri struct {
	group        string
	version      string
	kind         string
	resourceName string
}

func createResource(t *testing.T, artistHandler http.Handler, resourceUri *ResourceUri) map[string]any {
	rsp := httptest.NewRecorder()

	if resourceUri == nil {
		resourceUri = &ResourceUri{group: "demo", version: "v2", kind: "artist", resourceName: "keith-urban"}
	}

	req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/%s/%s/%s?partition=default&peer_name=local&namespace=default", resourceUri.group, resourceUri.version, resourceUri.kind, resourceUri.resourceName), strings.NewReader(`
		{
			"metadata": {
				"foo": "bar"
			},
			"data": {
				"name": "test",
				"genre": "GENRE_COUNTRY"
			}
		}
	`))

	req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

	artistHandler.ServeHTTP(rsp, req)
	require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

	var result map[string]any
	require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))
	return result
}

func deleteResource(t *testing.T, artistHandler http.Handler, resourceUri *ResourceUri) {
	rsp := httptest.NewRecorder()

	if resourceUri == nil {
		resourceUri = &ResourceUri{group: "demo", version: "v2", kind: "artist", resourceName: "keith-urban"}
	}

	req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/%s/%s/%s?partition=default&peer_name=local&namespace=default", resourceUri.group, resourceUri.version, resourceUri.kind, resourceUri.resourceName), strings.NewReader(""))

	req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

	artistHandler.ServeHTTP(rsp, req)
	require.Equal(t, http.StatusNoContent, rsp.Result().StatusCode)
}

func TestResourceReadHandler(t *testing.T) {
	aclResolver := &svc.MockACLResolver{}
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistReadPolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV1ReadPolicy, demo.ArtistV2ReadPolicy), nil)
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistWritePolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV1WritePolicy, demo.ArtistV2WritePolicy), nil)
	aclResolver.On("ResolveTokenAndDefaultMeta", fakeToken, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, ""), nil)

	builder := svctest.NewResourceServiceBuilder().
		WithRegisterFns(demo.RegisterTypes).
		WithACLResolver(aclResolver)
	client := builder.Run(t)
	handler := NewHandler("/api", client, builder.Registry(), parseToken, hclog.NewNullLogger())

	createdResource := createResource(t, handler, nil)

	t.Run("Read resource", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default&consistent", nil)

		req.Header.Add("x-consul-token", testACLTokenArtistReadPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))
		require.Equal(t, result, createdResource)
	})

	t.Run("should not be found if resource not exist", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist/keith-not-exist?partition=default&peer_name=local&namespace=default&consistent", nil)

		req.Header.Add("x-consul-token", testACLTokenArtistReadPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusNotFound, rsp.Result().StatusCode)
	})

	t.Run("should be blocked if the token is not authorized", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default&consistent", nil)

		req.Header.Add("x-consul-token", fakeToken)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusForbidden, rsp.Result().StatusCode)
	})
}

func TestResourceDeleteHandler(t *testing.T) {
	aclResolver := &svc.MockACLResolver{}
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistReadPolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV2ReadPolicy), nil)
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistWritePolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV2WritePolicy), nil)

	builder := svctest.NewResourceServiceBuilder().
		WithRegisterFns(demo.RegisterTypes).
		WithACLResolver(aclResolver)
	client := builder.Run(t)
	handler := NewHandler("/api", client, builder.Registry(), parseToken, hclog.NewNullLogger())

	t.Run("should surface PermissionDenied error from resource service", func(t *testing.T) {
		createResource(t, handler, nil)

		deleteRsp := httptest.NewRecorder()
		deletReq := httptest.NewRequest("DELETE", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

		deletReq.Header.Add("x-consul-token", testACLTokenArtistReadPolicy)

		handler.ServeHTTP(deleteRsp, deletReq)

		require.Equal(t, http.StatusForbidden, deleteRsp.Result().StatusCode)
	})

	t.Run("should delete a resource without version", func(t *testing.T) {
		createResource(t, handler, nil)

		deleteRsp := httptest.NewRecorder()
		deletReq := httptest.NewRequest("DELETE", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

		deletReq.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

		handler.ServeHTTP(deleteRsp, deletReq)

		require.Equal(t, http.StatusNoContent, deleteRsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(deleteRsp.Body).Decode(&result))
		require.Empty(t, result)

		_, err := client.Read(testutil.TestContext(t), &pbresource.ReadRequest{
			Id: &pbresource.ID{
				Type:    demo.TypeV2Artist,
				Tenancy: resource.DefaultNamespacedTenancy(),
				Name:    "keith-urban",
			},
		})
		require.ErrorContains(t, err, "resource not found")
	})

	t.Run("should delete a resource with version", func(t *testing.T) {
		createResource(t, handler, nil)

		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("DELETE", "/demo/v2/artist/keith-urban?partition=default&peer_name=local&namespace=default&version=1", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)
		req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusNoContent, rsp.Result().StatusCode)

		_, err := client.Read(testutil.TestContext(t), &pbresource.ReadRequest{
			Id: &pbresource.ID{
				Type:    demo.TypeV2Artist,
				Tenancy: resource.DefaultNamespacedTenancy(),
				Name:    "keith-urban",
			},
		})
		require.ErrorContains(t, err, "resource not found")
	})
}

func TestResourceListHandler(t *testing.T) {
	aclResolver := &svc.MockACLResolver{}
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistListPolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV2ListPolicy), nil)
	aclResolver.On("ResolveTokenAndDefaultMeta", testACLTokenArtistWritePolicy, mock.Anything, mock.Anything).
		Return(svctest.AuthorizerFrom(t, demo.ArtistV2WritePolicy), nil)

	builder := svctest.NewResourceServiceBuilder().
		WithRegisterFns(demo.RegisterTypes).
		WithACLResolver(aclResolver)
	client := builder.Run(t)
	handler := NewHandler("/api", client, builder.Registry(), parseToken, hclog.NewNullLogger())

	t.Run("should return MethodNotAllowed", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("PUT", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusMethodNotAllowed, rsp.Result().StatusCode)
	})

	t.Run("should be blocked if the token is not authorized", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistWritePolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusForbidden, rsp.Result().StatusCode)
	})

	t.Run("should return list of resources", func(t *testing.T) {
		resourceUri1 := &ResourceUri{group: "demo", version: "v2", kind: "artist", resourceName: "steve"}
		resource1 := createResource(t, handler, resourceUri1)
		resourceUri2 := &ResourceUri{group: "demo", version: "v2", kind: "artist", resourceName: "elvis"}
		resource2 := createResource(t, handler, resourceUri2)

		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

		resources, _ := result["resources"].([]any)
		require.Len(t, resources, 2)

		expected := []map[string]any{resource1, resource2}
		require.Contains(t, expected, resources[0])
		require.Contains(t, expected, resources[1])

		// clean up
		deleteResource(t, handler, resourceUri1)
		deleteResource(t, handler, resourceUri2)
	})

	t.Run("should return empty list when no resources are found", func(t *testing.T) {
		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

		resources, _ := result["resources"].([]any)
		require.Len(t, resources, 0)
	})

	t.Run("should return empty list when name prefix matches don't match", func(t *testing.T) {
		createResource(t, handler, nil)

		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default&name_prefix=noname", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

		resources, _ := result["resources"].([]any)
		require.Len(t, resources, 0)

		// clean up
		deleteResource(t, handler, nil)
	})

	t.Run("should return list of resources matching name prefix", func(t *testing.T) {
		resourceUri1 := &ResourceUri{group: "demo", version: "v2", kind: "artist", resourceName: "steve"}
		resource1 := createResource(t, handler, resourceUri1)
		resourceUri2 := &ResourceUri{group: "demo", version: "v2", kind: "artist", resourceName: "elvis"}
		createResource(t, handler, resourceUri2)

		rsp := httptest.NewRecorder()
		req := httptest.NewRequest("GET", "/demo/v2/artist?partition=default&peer_name=local&namespace=default&name_prefix=steve", strings.NewReader(""))

		req.Header.Add("x-consul-token", testACLTokenArtistListPolicy)

		handler.ServeHTTP(rsp, req)

		require.Equal(t, http.StatusOK, rsp.Result().StatusCode)

		var result map[string]any
		require.NoError(t, json.NewDecoder(rsp.Body).Decode(&result))

		resources, _ := result["resources"].([]any)
		require.Len(t, resources, 1)

		require.Equal(t, resource1, resources[0])

		// clean up
		deleteResource(t, handler, resourceUri1)
		deleteResource(t, handler, resourceUri2)
	})
}
