package extensionflow

import (
	"context"
	"fmt"
	"sort"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/serviceactor"
)

type appHostedCatalogFixture struct {
	DefinitionID   string
	InstallationID string
	ConnectionID   string
	Selection      serviceactor.InstallationExtension
}

func TestAppHostedCatalogProjectionAuthorizationModes(t *testing.T) {
	t.Run("default App authorization", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_default")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "default", fixture.ToolsDigest, "workspace-tools")
		service := newService(db, fixture.Registry)

		for _, agentID := range []string{"agent-1", "agent-2"} {
			requireAppHostedCatalogEntry(t, ctx, service, fixture.WorkspaceID, agentID, "workspace-tools", "tools",
				[]string{"create-item", "list-items", "purge-item"})
		}
		var authorizationRows int
		require.NoError(t, db.QueryRow(ctx, `
SELECT COUNT(*)
FROM service_principal_authorizations
WHERE workspace_id=$1
  AND service_installation_id=$2
  AND principal_kind='agent'
  AND principal_ref IN ('agent-1', 'agent-2')`,
			fixture.WorkspaceID,
			host.InstallationID,
		).Scan(&authorizationRows))
		require.Zero(t, authorizationRows, "V1 default-allow must not require per-agent authorization rows")
		require.NotEmpty(t, host.Selection.ID)
	})

	t.Run("explicit App authorization", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_explicit")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "explicit", fixture.ToolsDigest, "workspace-tools")
		authorizeAppHostForAgent(t, ctx, db, fixture, host, "agent-1", true)
		service := newService(db, fixture.Registry)

		requireAppHostedCatalogEntry(t, ctx, service, fixture.WorkspaceID, "agent-1", "workspace-tools", "tools",
			[]string{"create-item", "list-items", "purge-item"})
	})

	t.Run("explicit disabled App authorization suppresses default allow", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_disabled_auth")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "disabled-auth", fixture.ToolsDigest, "workspace-tools")
		authorizeAppHostForAgent(t, ctx, db, fixture, host, "agent-1", false)
		service := newService(db, fixture.Registry)

		entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.NoError(t, err)
		require.Empty(t, entries)
	})
}

func TestAppHostedCatalogProjectionSelectionAndConnectionState(t *testing.T) {
	t.Run("disabled selection is not callable", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_disabled_selection")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "disabled-selection", fixture.ToolsDigest, "workspace-tools")
		_, err := serviceactor.New(db).DisableInstallationExtension(
			ctx,
			fixture.WorkspaceID,
			host.Selection.ID,
			"disabled_by_manager",
			time.Now().UTC(),
		)
		require.NoError(t, err)
		service := newService(db, fixture.Registry)

		entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.NoError(t, err)
		require.Empty(t, entries)
	})

	t.Run("disconnected App is not callable", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_disconnected")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "disconnected", fixture.ToolsDigest, "workspace-tools")
		mustExecService(t, db, `
UPDATE service_connections
SET status = 'disabled', disabled_at = $2, updated_at = $2
WHERE id = $1
`, host.ConnectionID, time.Now().UTC())
		service := newService(db, fixture.Registry)

		entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.NoError(t, err)
		require.Empty(t, entries)
	})

	t.Run("Bot-only App has no Extension surface", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_bot_only")
		seedAppHostedCatalogSelection(t, ctx, db, fixture, "bot-only", "", "")
		service := newService(db, fixture.Registry)

		entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.NoError(t, err)
		require.Empty(t, entries)
	})
}

func TestAppHostedCatalogProjectionConnectionlessPolicy(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "appcat_connectionless")
	definition := builtinToolsDefinition()
	definition.Domain = "connectionless"
	registered, err := fixture.Registry.RegisterBuiltinWithOptions(
		definition,
		testExecutor{},
		"connectionless-v1",
		registry.BuiltinOptions{
			ConnectionPolicy: registry.ConnectionNone,
		},
	)
	require.NoError(t, err)
	host := seedAppHostedCatalogSelection(
		t,
		ctx,
		db,
		fixture,
		"connectionless",
		registered.Definition.Digest,
		"connectionless",
	)
	service := newService(db, fixture.Registry)

	_, err = service.AgentCatalogEntries(
		ctx,
		fixture.WorkspaceID,
		"agent-1",
	)
	require.ErrorContains(t, err, "unexpectedly has 1 active App connection")

	mustExecService(t, db, `
UPDATE service_connections
SET status = 'disabled', disabled_at = $2, updated_at = $2
WHERE id = $1
`, host.ConnectionID, time.Now().UTC())
	requireAppHostedCatalogEntry(
		t,
		ctx,
		service,
		fixture.WorkspaceID,
		"agent-1",
		"connectionless",
		"connectionless",
		[]string{"list-items"},
	)
}

func TestAppHostedCatalogProjectionIdentityAndRename(t *testing.T) {
	t.Run("selection pin must match the App capability and Registry", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_pin_mismatch")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "pin-mismatch", fixture.ToolsDigest, "workspace-tools")
		mustExecService(t, db, `
UPDATE service_installation_extensions
SET definition_digest = $2
WHERE id = $1
`, host.Selection.ID, fixture.SafeDigest)
		service := newService(db, fixture.Registry)

		_, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.ErrorContains(t, err, "does not match")
	})

	t.Run("duplicate authorization decisions fail closed", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_duplicate_auth")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "duplicate-auth", fixture.ToolsDigest, "workspace-tools")
		authorizeAppHostForAgent(t, ctx, db, fixture, host, "agent-1", true)
		now := time.Now().UTC()
		mustExecService(t, db, `
INSERT INTO service_principal_authorizations (
    id, workspace_id, service_definition_id, service_installation_id,
    service_kind, principal_kind, principal_ref, status, disabled_reason,
    scopes_json, authorized_by_member_id, policy_version, metadata,
    created_at, updated_at, disabled_at, active_authorization_key
) VALUES (
    $1, $2, $3, $4,
    'app', 'agent', 'agent-1', 'disabled', 'duplicate decision',
    '[]', $5, 1, '{}',
    $6, $6, $6, NULL
)
`, "authz_duplicate_"+host.InstallationID, fixture.WorkspaceID, host.DefinitionID, host.InstallationID, fixture.HumanMemberID, now)
		service := newService(db, fixture.Registry)

		_, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.ErrorContains(t, err, "multiple App authorization decisions")
	})

	t.Run("wrong-kind authorization on an App host fails closed", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_wrong_auth_kind")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "wrong-auth-kind", fixture.ToolsDigest, "workspace-tools")
		authorizeAppHostForAgent(t, ctx, db, fixture, host, "agent-1", true)
		mustExecService(t, db, `
UPDATE service_principal_authorizations
SET service_kind = 'service_account'
WHERE service_installation_id = $1
  AND principal_ref = 'agent-1'
`, host.InstallationID)
		service := newService(db, fixture.Registry)

		_, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
		require.ErrorContains(t, err, "identity does not match")
	})

	t.Run("selection rename changes only the flattened Extension name", func(t *testing.T) {
		ctx := context.Background()
		db := startServiceRollbackDB(t, ctx)
		fixture := seedServiceFixture(t, ctx, db, "appcat_rename")
		host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "rename", fixture.ToolsDigest, "workspace-tools")
		renamed := host.Selection
		renamed.ExtensionName = "renamed-tools"
		renamed.SelectionSource = serviceactor.InstallationExtensionSourceUser
		_, err := serviceactor.New(db).UpsertInstallationExtension(ctx, serviceactor.InstallationExtensionUpsert{
			InstallationExtension: renamed,
			Now:                   time.Now().UTC(),
		})
		require.NoError(t, err)
		service := newService(db, fixture.Registry)

		requireAppHostedCatalogEntry(t, ctx, service, fixture.WorkspaceID, "agent-1", "renamed-tools", "tools",
			[]string{"create-item", "list-items", "purge-item"})
	})
}

func TestAppHostedCatalogProjectionNoOpState(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "appcat_noop")
	host := seedAppHostedCatalogSelection(t, ctx, db, fixture, "noop", fixture.ToolsDigest, "workspace-tools")
	service := newService(db, fixture.Registry)
	now := time.Now().UTC()

	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", now)
	}))
	first := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	require.Equal(t, int64(1), first.Version)
	require.NotEqual(t, "[]", first.State)
	require.True(t, first.Enabled)

	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", now.Add(time.Second))
	}))
	noOp := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	require.Equal(t, first.Version, noOp.Version)
	require.Equal(t, first.State, noOp.State)
	require.Equal(t, first.Enabled, noOp.Enabled)

	renamed := host.Selection
	renamed.ExtensionName = "renamed-tools"
	renamed.SelectionSource = serviceactor.InstallationExtensionSourceUser
	_, err := serviceactor.New(db).UpsertInstallationExtension(ctx, serviceactor.InstallationExtensionUpsert{
		InstallationExtension: renamed,
		Now:                   now.Add(2 * time.Second),
	})
	require.NoError(t, err)
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", now.Add(2*time.Second))
	}))
	changed := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	require.Equal(t, int64(2), changed.Version)
	require.NotEqual(t, first.State, changed.State)
	require.True(t, changed.Enabled)
}

func seedAppHostedCatalogSelection(
	t *testing.T,
	ctx context.Context,
	db coredb.DB,
	fixture serviceFixture,
	suffix string,
	definitionDigest string,
	extensionName string,
) appHostedCatalogFixture {
	t.Helper()
	now := time.Now().UTC()
	definitionID := "app_catalog_" + suffix
	installationID := "inst_catalog_" + suffix
	connectionID := "conn_catalog_" + suffix
	actors := serviceactor.New(db)

	_, err := actors.UpsertDefinition(ctx, serviceactor.DefinitionUpsert{
		ID:               definitionID,
		ServiceKind:      serviceactor.KindApp,
		InstallationMode: serviceactor.InstallationModeNamedMultiInstance,
		Slug:             "catalog-" + suffix,
		Name:             "Catalog " + suffix,
		LatestVersion:    "1.0.0",
		Status:           serviceactor.StatusActive,
		Now:              now,
	})
	require.NoError(t, err)
	_, err = actors.UpsertInstallation(ctx, serviceactor.InstallationUpsert{
		ID:                  installationID,
		WorkspaceID:         fixture.WorkspaceID,
		ServiceDefinitionID: definitionID,
		ServiceKind:         serviceactor.KindApp,
		InstanceKey:         "instance-" + suffix,
		Status:              serviceactor.StatusActive,
		InstalledVersion:    "1.0.0",
		CreatedByMemberID:   fixture.HumanMemberID,
		Now:                 now,
	})
	require.NoError(t, err)
	_, err = actors.UpsertConnection(ctx, serviceactor.ConnectionUpsert{
		ID:                    connectionID,
		WorkspaceID:           fixture.WorkspaceID,
		ServiceDefinitionID:   definitionID,
		ServiceInstallationID: installationID,
		ServiceKind:           serviceactor.KindApp,
		ExternalSubjectRef:    "subject-" + suffix,
		Credentials:           []byte(`{}`),
		Status:                serviceactor.StatusActive,
		CreatedByMemberID:     fixture.HumanMemberID,
		Now:                   now,
	})
	require.NoError(t, err)

	result := appHostedCatalogFixture{
		DefinitionID:   definitionID,
		InstallationID: installationID,
		ConnectionID:   connectionID,
	}
	if definitionDigest == "" {
		return result
	}
	registered, ok := fixture.Registry.LookupByDigest(definitionDigest)
	require.True(t, ok)
	capabilityJSON, err := model.EncodeExtensionCapabilityConfig(model.ExtensionCapabilityConfig{
		Extensions: []model.ExtensionCapabilityEntry{{
			Domain:           registered.Definition.Domain,
			Version:          registered.Definition.Version,
			DefinitionDigest: registered.Definition.Digest,
			DefaultName:      extensionName,
			DefaultEnabled:   true,
		}},
	})
	require.NoError(t, err)
	_, err = actors.UpsertCapability(ctx, serviceactor.CapabilityUpsert{
		ID:                  "cap_catalog_" + suffix,
		ServiceDefinitionID: definitionID,
		ServiceKind:         serviceactor.KindApp,
		CapabilityKey:       serviceactor.CapabilityExtensions,
		Config:              capabilityJSON,
		DefaultEnabled:      true,
		Now:                 now,
	})
	require.NoError(t, err)
	selection, err := actors.UpsertInstallationExtension(ctx, serviceactor.InstallationExtensionUpsert{
		InstallationExtension: serviceactor.InstallationExtension{
			ID:                    "selection_catalog_" + suffix,
			WorkspaceID:           fixture.WorkspaceID,
			ServiceDefinitionID:   definitionID,
			ServiceInstallationID: installationID,
			ExtensionName:         extensionName,
			DefinitionDomain:      registered.Definition.Domain,
			DefinitionVersion:     registered.Definition.Version,
			DefinitionDigest:      registered.Definition.Digest,
			Status:                serviceactor.StatusActive,
			SelectionSource:       serviceactor.InstallationExtensionSourceUser,
			CreatedByMemberID:     fixture.HumanMemberID,
		},
		Now: now,
	})
	require.NoError(t, err)
	result.Selection = selection
	return result
}

func authorizeAppHostForAgent(
	t *testing.T,
	ctx context.Context,
	db coredb.DB,
	fixture serviceFixture,
	host appHostedCatalogFixture,
	agentID string,
	active bool,
) {
	t.Helper()
	now := time.Now().UTC()
	key := fmt.Sprintf("appcat/%s/%s", agentID, host.InstallationID)
	if active {
		_, err := serviceactor.New(db).UpsertPrincipalAuthorization(ctx, serviceactor.PrincipalAuthorizationUpsert{
			ID:                     "authz_" + host.InstallationID + "_" + agentID,
			WorkspaceID:            fixture.WorkspaceID,
			ServiceDefinitionID:    host.DefinitionID,
			ServiceInstallationID:  host.InstallationID,
			ServiceKind:            serviceactor.KindApp,
			PrincipalKind:          "agent",
			PrincipalRef:           agentID,
			AuthorizedByMemberID:   fixture.HumanMemberID,
			Now:                    now,
			ActiveAuthorizationKey: key,
		})
		require.NoError(t, err)
		return
	}
	_, err := serviceactor.New(db).DisablePrincipalAuthorization(ctx, serviceactor.PrincipalAuthorizationDisable{
		ID:                     "authz_" + host.InstallationID + "_" + agentID,
		WorkspaceID:            fixture.WorkspaceID,
		ServiceDefinitionID:    host.DefinitionID,
		ServiceInstallationID:  host.InstallationID,
		ServiceKind:            serviceactor.KindApp,
		PrincipalKind:          "agent",
		PrincipalRef:           agentID,
		DisabledReason:         "default_allow_opt_out",
		AuthorizedByMemberID:   fixture.HumanMemberID,
		Now:                    now,
		ActiveAuthorizationKey: key,
	})
	require.NoError(t, err)
}

func requireAppHostedCatalogEntry(
	t *testing.T,
	ctx context.Context,
	service *Service,
	workspaceID string,
	agentID string,
	extensionName string,
	domain string,
	actions []string,
) {
	t.Helper()
	entries, err := service.AgentCatalogEntries(ctx, workspaceID, agentID)
	require.NoError(t, err)
	require.Len(t, entries, 1)
	require.Equal(t, extensionName, entries[0].ExtensionName)
	require.Equal(t, domain, entries[0].Domain)
	gotActions := append([]string(nil), entries[0].Actions...)
	sort.Strings(gotActions)
	sort.Strings(actions)
	require.Equal(t, actions, gotActions)
}
