// Copyright IBM Corp. 2020, 2026
// SPDX-License-Identifier: BUSL-1.1

package plugin

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/hashicorp/boundary/internal/db"
	"github.com/hashicorp/boundary/internal/errors"
	"github.com/hashicorp/boundary/internal/host/plugin/store"
	"github.com/hashicorp/boundary/internal/iam"
	"github.com/hashicorp/boundary/internal/kms"
	"github.com/hashicorp/boundary/internal/plugin"
	wrapping "github.com/hashicorp/go-kms-wrapping/v2"
	"github.com/hashicorp/go-kms-wrapping/v2/aead"
	"github.com/mr-tron/base58"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/testing/protocmp"
	"google.golang.org/protobuf/types/known/structpb"
)

func TestHostCatalog_Create(t *testing.T) {
	ctx := context.Background()
	conn, _ := db.TestSetup(t, "postgres")
	wrapper := db.TestWrapper(t)
	_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
	plg := plugin.TestPlugin(t, conn, "test")

	type args struct {
		pluginId  string
		projectId string
		opts      []Option
	}

	tests := []struct {
		name          string
		args          args
		want          *HostCatalog
		wantErr       bool
		wantCreateErr bool
	}{
		{
			name: "blank-projectId",
			args: args{
				pluginId:  plg.GetPublicId(),
				projectId: "",
			},
			want: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PluginId:   plg.GetPublicId(),
					Attributes: []byte{},
				},
			},
			wantCreateErr: true,
		},
		{
			name: "blank-pluginId",
			args: args{
				pluginId:  "",
				projectId: prj.GetPublicId(),
			},
			want: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					ProjectId:  prj.GetPublicId(),
					Attributes: []byte{},
				},
			},
			wantCreateErr: true,
		},
		{
			name: "valid-no-options",
			args: args{
				pluginId:  plg.GetPublicId(),
				projectId: prj.GetPublicId(),
			},
			want: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PluginId:   plg.GetPublicId(),
					ProjectId:  prj.GetPublicId(),
					Attributes: []byte{},
				},
			},
		},
		{
			name: "valid-with-name",
			args: args{
				pluginId:  plg.GetPublicId(),
				projectId: prj.GetPublicId(),
				opts: []Option{
					WithName("test-name"),
				},
			},
			want: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PluginId:   plg.GetPublicId(),
					ProjectId:  prj.GetPublicId(),
					Name:       "test-name",
					Attributes: []byte{},
				},
			},
		},
		{
			name: "valid-with-description",
			args: args{
				pluginId:  plg.GetPublicId(),
				projectId: prj.GetPublicId(),
				opts: []Option{
					WithDescription("test-description"),
				},
			},
			want: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PluginId:    plg.GetPublicId(),
					ProjectId:   prj.GetPublicId(),
					Description: "test-description",
					Attributes:  []byte{},
				},
			},
		},
		{
			name: "valid-with-attributes",
			args: args{
				pluginId:  plg.GetPublicId(),
				projectId: prj.GetPublicId(),
				opts: []Option{
					WithAttributes(&structpb.Struct{Fields: map[string]*structpb.Value{"foo": structpb.NewStringValue("bar")}}),
				},
			},
			want: func() *HostCatalog {
				hc := &HostCatalog{
					HostCatalog: &store.HostCatalog{
						PluginId:  plg.GetPublicId(),
						ProjectId: prj.GetPublicId(),
					},
				}
				var err error
				hc.Attributes, err = proto.Marshal(&structpb.Struct{Fields: map[string]*structpb.Value{"foo": structpb.NewStringValue("bar")}})
				require.NoError(t, err)
				return hc
			}(),
		},
		{
			name: "valid-with-worker-filter",
			args: args{
				pluginId:  plg.GetPublicId(),
				projectId: prj.GetPublicId(),
				opts: []Option{
					WithWorkerFilter(`"test" in "/tags/type"`),
				},
			},
			want: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PluginId:     plg.GetPublicId(),
					ProjectId:    prj.GetPublicId(),
					Attributes:   []byte{},
					WorkerFilter: `"test" in "/tags/type"`,
				},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := NewHostCatalog(ctx, tt.args.projectId, tt.args.pluginId, tt.args.opts...)
			require.NoError(t, err)
			require.NotNil(t, got)

			assert.Emptyf(t, got.PublicId, "PublicId set")
			assert.Equal(t, tt.want, got)

			id, err := newHostCatalogId(ctx)
			assert.NoError(t, err)

			tt.want.PublicId = id
			got.PublicId = id

			w := db.New(conn)
			err = w.Create(ctx, got)
			if tt.wantCreateErr {
				assert.Error(t, err)
			} else {
				assert.NoError(t, err)
				found := &HostCatalog{
					HostCatalog: &store.HostCatalog{
						PublicId: id,
					},
				}
				require.NoError(t, w.LookupById(ctx, found))
				assert.Empty(t, cmp.Diff(got.HostCatalog, found.HostCatalog, protocmp.Transform()), "%q compared to %q", got.Attributes, found.Attributes)
			}
		})
	}
}

func TestHostCatalog_Create_DuplicateNames(t *testing.T) {
	ctx := context.Background()
	conn, _ := db.TestSetup(t, "postgres")
	w := db.New(conn)
	wrapper := db.TestWrapper(t)
	_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
	_, prj2 := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
	plg := plugin.TestPlugin(t, conn, "test1")
	plg2 := plugin.TestPlugin(t, conn, "test2")

	got, err := NewHostCatalog(ctx, prj.GetPublicId(), plg.GetPublicId(), WithName("duplicate"))
	require.NoError(t, err)
	got.PublicId, err = newHostCatalogId(ctx)
	require.NoError(t, err)
	require.NoError(t, w.Create(ctx, got))

	// Can't create another resource with the same name in the same project
	got.PublicId, err = newHostCatalogId(ctx)
	require.NoError(t, err)
	assert.Error(t, w.Create(ctx, got))

	// Can't create another resource with same name in same project even for different plugin
	got, err = NewHostCatalog(ctx, prj.GetPublicId(), plg2.GetPublicId(), WithName("duplicate"))
	require.NoError(t, err)
	got.PublicId, err = newHostCatalogId(ctx)
	require.NoError(t, err)
	assert.Error(t, w.Create(ctx, got))

	// Can create another resource with same name in different project even for same plugin
	got, err = NewHostCatalog(ctx, prj2.GetPublicId(), plg.GetPublicId(), WithName("duplicate"))
	require.NoError(t, err)
	got.PublicId, err = newHostCatalogId(ctx)
	require.NoError(t, err)
	assert.NoError(t, w.Create(ctx, got))
}

func TestHostCatalog_Delete(t *testing.T) {
	conn, _ := db.TestSetup(t, "postgres")
	wrapper := db.TestWrapper(t)
	_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
	plg := plugin.TestPlugin(t, conn, "test")
	cat := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId())
	ignoredCat := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId())
	_ = ignoredCat
	tests := []struct {
		name        string
		cat         *HostCatalog
		rowsDeleted int
	}{
		{
			name: "valid",
			cat: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PublicId: cat.GetPublicId(),
				},
			},
			rowsDeleted: 1,
		},
		{
			name: "bad-id",
			cat: &HostCatalog{
				HostCatalog: &store.HostCatalog{
					PublicId: "bad-id",
				},
			},
			rowsDeleted: 0,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctx := context.Background()
			w := db.New(conn)
			deletedRows, err := w.Delete(ctx, tt.cat)
			require.NoError(t, err)
			assert.Equal(t, tt.rowsDeleted, deletedRows)

			err = w.LookupById(ctx, tt.cat)
			require.Error(t, err)
			assert.True(t, errors.IsNotFoundError(err))
		})
	}
}

func TestHostCatalog_Delete_Cascading(t *testing.T) {
	conn, _ := db.TestSetup(t, "postgres")
	w := db.New(conn)
	wrapper := db.TestWrapper(t)
	ctx := context.Background()

	t.Run("delete-project", func(t *testing.T) {
		_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
		plg := plugin.TestPlugin(t, conn, "deletescope")
		cat := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId())

		deleted, err := w.Delete(ctx, prj)
		require.NoError(t, err)
		require.Equal(t, 1, deleted)

		err = w.LookupById(ctx, cat)
		require.Error(t, err)
		assert.True(t, errors.IsNotFoundError(err))
	})

	t.Run("delete-plugin", func(t *testing.T) {
		_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
		plg := plugin.TestPlugin(t, conn, "deleteplugin")
		cat := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId())

		deleted, err := w.Delete(ctx, plg)
		require.NoError(t, err)
		require.Equal(t, 1, deleted)

		err = w.LookupById(ctx, cat)
		require.Error(t, err)
		assert.True(t, errors.IsNotFoundError(err))
	})
}

func TestHostCatalog_SetTableName(t *testing.T) {
	defaultTableName := "host_plugin_catalog"
	tests := []struct {
		name        string
		initialName string
		setNameTo   string
		want        string
	}{
		{
			name:        "new-name",
			initialName: "",
			setNameTo:   "new-name",
			want:        "new-name",
		},
		{
			name:        "reset to default",
			initialName: "initial",
			setNameTo:   "",
			want:        defaultTableName,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			def, err := NewHostCatalog(context.Background(), "", "")
			require.NoError(t, err)
			require.Equal(t, defaultTableName, def.TableName())
			s := &HostCatalog{
				HostCatalog: &store.HostCatalog{},
				tableName:   tt.initialName,
			}
			s.SetTableName(tt.setNameTo)
			assert.Equal(t, tt.want, s.TableName())
		})
	}
}

func TestHostCatalog_SecretsHmac(t *testing.T) {
	ctx := context.Background()
	conn, _ := db.TestSetup(t, "postgres")
	rootWrapper := db.TestWrapper(t)
	kmsCache := kms.TestKms(t, conn, rootWrapper)
	_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, rootWrapper))
	databaseWrapper, err := kmsCache.GetWrapper(ctx, prj.PublicId, kms.KeyPurposeDatabase)
	require.NoError(t, err)

	plg := plugin.TestPlugin(t, conn, "testplugin")
	cat := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId())

	// stableValue checks that HMACing the same value returns the same result.
	// The first time it's called to match it will be empty and thus assign the
	// expected value; the second time it will assert that value.
	var stableValue string
	tests := []struct {
		name             string
		hcFn             func() *HostCatalog
		hmacWrapper      wrapping.Wrapper
		emptyHmac        bool
		matchStableValue bool
		wantHmacErrMatch *errors.Template
	}{
		{
			name: "valid-empty-secrets",
			hcFn: func() *HostCatalog {
				cat.Secrets = nil
				cat.SecretsHmac = []byte("foobar")
				return cat
			},
			emptyHmac:   true,
			hmacWrapper: databaseWrapper,
		},
		{
			name: "valid",
			hcFn: func() *HostCatalog {
				cat.Secrets = mustStruct(map[string]any{"foo": "bar"})
				cat.SecretsHmac = nil
				return cat
			},
			hmacWrapper:      databaseWrapper,
			matchStableValue: true,
		},
		{
			name: "valid-different-val",
			hcFn: func() *HostCatalog {
				cat.Secrets = mustStruct(map[string]any{"zip": "zap"})
				cat.SecretsHmac = nil
				return cat
			},
			hmacWrapper: databaseWrapper,
		},
		{
			name: "valid-original-val",
			hcFn: func() *HostCatalog {
				cat.Secrets = mustStruct(map[string]any{"foo": "bar"})
				cat.SecretsHmac = nil
				return cat
			},
			hmacWrapper:      databaseWrapper,
			matchStableValue: true,
		},
		{
			name: "hmac-missing-wrapper",
			hcFn: func() *HostCatalog {
				cat.Secrets = mustStruct(map[string]any{"foo": "bar"})
				cat.SecretsHmac = []byte("foobar")
				return cat
			},
			wantHmacErrMatch: errors.T(errors.InvalidParameter),
		},
		{
			name: "hmac-bad-wrapper",
			hcFn: func() *HostCatalog {
				cat.Secrets = mustStruct(map[string]any{"foo": "bar"})
				cat.SecretsHmac = nil
				return cat
			},
			hmacWrapper:      &aead.Wrapper{},
			wantHmacErrMatch: errors.T(errors.InvalidParameter),
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			assert, require := assert.New(t), require.New(t)

			hmacCat := tt.hcFn().clone()
			err = hmacCat.hmacSecrets(ctx, tt.hmacWrapper)
			if tt.wantHmacErrMatch != nil {
				require.Error(err)
				return
			}
			require.NoError(err)
			if tt.emptyHmac {
				assert.Empty(hmacCat.SecretsHmac)
				stableValue = ""
				return
			}
			assert.NotEmpty(hmacCat.SecretsHmac)
			if tt.matchStableValue {
				if stableValue == "" {
					stableValue = base58.Encode(hmacCat.SecretsHmac)
				} else {
					assert.Equal(stableValue, base58.Encode(hmacCat.SecretsHmac))
				}
			}
		})
	}
}

func TestCatalogAgg(t *testing.T) {
	ctx := context.Background()
	conn, _ := db.TestSetup(t, "postgres")
	rw := db.New(conn)
	_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, db.TestWrapper(t)))
	plg := plugin.TestPlugin(t, conn, "test")
	hc := TestCatalog(t, conn, prj.GetPublicId(), plg.GetPublicId())

	ca := catalogAgg{}
	ca.PublicId = hc.GetPublicId()
	require.NoError(t, rw.LookupByPublicId(ctx, &ca))

	outHc, _ := ca.toCatalogAndPersisted()
	require.NotNil(t, outHc)
	require.Empty(t, cmp.Diff(hc, outHc, protocmp.Transform()))

	require.NotNil(t, ca.plugin())
	require.Empty(t, cmp.Diff(plg, ca.plugin(), protocmp.Transform()))
}
