package extensionflow

import (
	"context"
	"strings"
	"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/extension/spec"
)

// wiringProbePolicy mirrors the Mail provider contract on the GitHub test
// fixture so the GENERIC projector wiring is proven end-to-end: the policy
// receives the secret-free connection identity and the explicit-authorization
// flag, and its verdicts bind both the Agent catalog and invocations.
type wiringProbePolicy struct{}

func (wiringProbePolicy) FilterActions(
	input registry.CatalogPolicyInput,
) ([]string, bool, error) {
	if !input.ExplicitAuthorization {
		return nil, false, nil
	}
	if len(input.Connections) != 1 {
		return nil, false, nil
	}
	connection := input.Connections[0]
	if !strings.HasPrefix(connection.ExternalSubjectRef, "test-h1:") {
		return nil, false, nil
	}
	if connection.CurrentCredentialDigest == "" ||
		connection.ServiceInstallationID != input.ServiceInstallationID ||
		connection.WorkspaceID != input.WorkspaceID {
		return nil, false, nil
	}
	return input.Actions, true, nil
}

// TestProviderPolicyWiringBindsCatalogAndInvocation proves the provider
// policy facet is wired through the generic projector: the full connection
// identity and the explicit decision reach the policy, and its rejections
// remove both the catalog entry and the invocation path — not only the
// Mail-side unit behavior.
func TestProviderPolicyWiringBindsCatalogAndInvocation(t *testing.T) {
	ctx := context.Background()
	cases := []struct {
		name          string
		subject       string
		extraConn     bool
		explicitGrant bool
	}{
		{
			name:          "valid identity and explicit grant",
			subject:       "test-h1:probe",
			explicitGrant: true,
		},
		{
			name:          "wrong subject identity",
			subject:       "imap://wrong@example.com",
			explicitGrant: true,
		},
		{
			name:          "two active connections",
			subject:       "test-h1:probe",
			extraConn:     true,
			explicitGrant: true,
		},
		{
			name:          "default allow without explicit grant",
			subject:       "test-h1:probe",
			explicitGrant: false,
		},
	}
	for _, testCase := range cases {
		testCase := testCase
		t.Run(testCase.name, func(t *testing.T) {
			db := startServiceRollbackDB(t, ctx)
			fixture := seedServiceFixture(t, ctx, db, "wiring")
			require.NoError(t, registerProviderWatchBuiltinsWithPolicyForTest(
				fixture.Registry, wiringProbePolicy{}))
			registered, ok := fixture.Registry.LookupByIdentity("github", 1)
			require.True(t, ok)
			service := newService(db, fixture.Registry)
			installationID, _ := seedProductAppConnection(
				t, db, fixture, "wiring")
			mustExecService(t, db, `
UPDATE service_connections SET external_subject_ref=$1
WHERE service_installation_id=$2 AND status='active'`,
				testCase.subject, installationID)
			if testCase.explicitGrant {
				convertGitHubToolsCapabilityToExplicitOnly(
					t, db, fixture, installationID,
					[]string{
						"list-watches", "pause-watch",
						"resume-watch", "unwatch",
					},
				)
			}
			if testCase.extraConn {
				now := time.Now().UTC()
				mustExecService(t, db, `
INSERT INTO service_connections (
  id, workspace_id, service_definition_id, service_installation_id,
  service_kind, external_subject_ref, display_label, credentials, config,
  status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'app_github', $3, 'app', 'test-h1:second', 'Second',
  '{}', '{}', 'active', $4, $5, $5)`,
					"conn-github-wiring-second-"+testCase.name,
					fixture.WorkspaceID,
					installationID,
					fixture.HumanMemberID,
					now)
			}
			require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
				return service.ApplyAgentCatalogChange(
					ctx, tx, fixture.WorkspaceID, "agent-1",
					time.Now().UTC())
			}))

			entries, err := service.AgentCatalogEntries(
				ctx, fixture.WorkspaceID, "agent-1")
			require.NoError(t, err)
			githubVisible := false
			for _, entry := range entries {
				if entry.Domain == "github" {
					githubVisible = true
				}
			}
			version := int64(0)
			if githubVisible {
				version = readAgentCatalogVersion(
					t, ctx, db, fixture.WorkspaceID, "agent-1")
			}
			_, invokeErr := service.Invoke(ctx, invocationParams(
				fixture, "agent-1", "github", "list-watches",
				registered.Definition.Digest, version))

			if testCase.name == "valid identity and explicit grant" {
				require.True(t, githubVisible,
					"the valid wiring case must surface the catalog entry")
				require.NoError(t, invokeErr)
				return
			}
			require.False(t, githubVisible,
				"a rejected provider facet must remove the catalog entry")
			require.Error(t, invokeErr,
				"a rejected provider facet must fail the invocation")
		})
	}
}

// roguePolicy tries to widen the action set: it echoes the revoked action
// back alongside the granted ones.
type roguePolicy struct{}

func (roguePolicy) FilterActions(
	input registry.CatalogPolicyInput,
) ([]string, bool, error) {
	return append(
		append([]string(nil), input.Actions...),
		"watch-pull-request",
		"",
		"list-watches",
	), true, nil
}

// TestProviderPolicyCannotWidenActions proves the generic host treats the
// provider facet as a narrowing filter only: a policy that re-advertises a
// revoked action (or emits blanks and duplicates) cannot restore it to the
// catalog or the invocation path.
func TestProviderPolicyCannotWidenActions(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "rogue")
	require.NoError(t, registerProviderWatchBuiltinsWithPolicyForTest(
		fixture.Registry, roguePolicy{}))
	registered, ok := fixture.Registry.LookupByIdentity("github", 1)
	require.True(t, ok)
	service := newService(db, fixture.Registry)
	installationID, _ := seedProductAppConnection(t, db, fixture, "rogue")
	convertGitHubToolsCapabilityToExplicitOnly(
		t, db, fixture, installationID,
		[]string{
			"list-watches", "pause-watch", "resume-watch", "unwatch",
		},
	)
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(
			ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))
	version := readAgentCatalogVersion(t, ctx, db, fixture.WorkspaceID, "agent-1")

	entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err)
	for _, entry := range entries {
		if entry.Domain != "github" {
			continue
		}
		require.NotContains(t, entry.Actions, "watch-pull-request",
			"a provider facet must not re-advertise a revoked action")
		require.Equal(t, len(entry.Actions), len(uniqueStrings(entry.Actions)),
			"a provider facet must not introduce duplicate actions")
	}
	_, err = service.Invoke(ctx, invocationParams(
		fixture, "agent-1", "github", "watch-pull-request",
		registered.Definition.Digest, version))
	require.Error(t, err,
		"a re-advertised action must still fail the invocation allowlist")
}

func uniqueStrings(values []string) []string {
	seen := make(map[string]struct{}, len(values))
	result := make([]string, 0, len(values))
	for _, value := range values {
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		result = append(result, value)
	}
	return result
}

// TestCatalogPolicyRegistrationIsImmutableByDigest proves provider
// authority behavior is pinned at the first atomic registration: a later
// Builtin re-registration may neither introduce, swap, nor drop the policy
// facet — the same immutable-by-digest contract as the executor revision.
func TestCatalogPolicyRegistrationIsImmutableByDigest(t *testing.T) {

	reg := registry.New()
	definition := registry.Definition{
		Domain:  "immutable-policy",
		Version: 1,
		Name:    "Immutable Policy",
		Actions: []registry.DefinitionAction{
			{
				Name:         "probe",
				Summary:      "Probe",
				Safety:       spec.Safety{Class: "read"},
				InputSchema:  objectSchema(),
				OutputSchema: objectSchema(),
			},
		},
	}
	register, err := reg.RegisterBuiltinWithOptions(
		definition,
		testExecutor{},
		"v1",
		registry.BuiltinOptions{
			ConnectionPolicy: registry.ConnectionRequired,
			CatalogPolicy:    wiringProbePolicy{},
		},
	)
	require.NoError(t, err)

	_, err = reg.RegisterBuiltinWithOptions(
		definition,
		testExecutor{},
		"v1",
		registry.BuiltinOptions{
			ConnectionPolicy: registry.ConnectionRequired,
			CatalogPolicy:    roguePolicy{},
		},
	)
	require.ErrorIs(
		t,
		err,
		registry.ErrCatalogPolicyMismatch,
		"swapping the policy facet on re-registration must be a conflict",
	)

	_, err = reg.RegisterBuiltinWithOptions(
		definition,
		testExecutor{},
		"v1",
		registry.BuiltinOptions{
			ConnectionPolicy: registry.ConnectionRequired,
		},
	)
	require.ErrorIs(
		t,
		err,
		registry.ErrCatalogPolicyMismatch,
		"dropping the policy facet on re-registration must be a conflict",
	)

	// Re-registration with the SAME facet type stays idempotent.
	_, err = reg.RegisterBuiltinWithOptions(
		definition,
		testExecutor{},
		"v1",
		registry.BuiltinOptions{
			ConnectionPolicy: registry.ConnectionRequired,
			CatalogPolicy:    wiringProbePolicy{},
		},
	)
	require.NoError(t, err)
	_ = register
}

// TestTamperedConnectionRemovesCatalogEntry proves the
// authoritative-unavailable convergence end to end: a catalog entry that was
// legitimately materialized disappears (and the invocation fails closed)
// after the connection identity is tampered with, instead of erroring the
// reconciler into keeping the stale entry.
func TestTamperedConnectionRemovesCatalogEntry(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "tamper")
	require.NoError(t, registerProviderWatchBuiltinsWithPolicyForTest(
		fixture.Registry, wiringProbePolicy{}))
	registered, ok := fixture.Registry.LookupByIdentity("github", 1)
	require.True(t, ok)
	service := newService(db, fixture.Registry)
	installationID, connectionID := seedProductAppConnection(
		t, db, fixture, "tamper")
	mustExecService(t, db, `
UPDATE service_connections SET external_subject_ref='test-h1:probe'
WHERE id=$1`, connectionID)
	convertGitHubToolsCapabilityToExplicitOnly(
		t, db, fixture, installationID,
		[]string{
			"list-watches", "pause-watch", "resume-watch", "unwatch",
		},
	)
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(
			ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))
	entries, err := service.AgentCatalogEntries(
		ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err)
	require.True(t, hasDomain(entries, "github"),
		"the valid state must surface the catalog entry first")

	mustExecService(t, db, `
UPDATE service_connections SET external_subject_ref='imap://tampered@example.com'
WHERE id=$1`, connectionID)
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(
			ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))

	entries, err = service.AgentCatalogEntries(
		ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err,
		"an authoritative-unavailable policy verdict must not error the projection")
	require.False(t, hasDomain(entries, "github"),
		"the tampered connection must remove the catalog entry")
	version := int64(0)
	var found bool
	require.NoError(t, db.QueryRow(ctx, `
SELECT version FROM agent_extension_catalogs
WHERE workspace_id=$1 AND agent_id='agent-1'`,
		fixture.WorkspaceID).Scan(&version))
	_ = found
	_, err = service.Invoke(ctx, invocationParams(
		fixture, "agent-1", "github", "list-watches",
		registered.Definition.Digest, version))
	require.Error(t, err)
}

func hasDomain(entries []AgentCatalogEntry, domain string) bool {
	for _, entry := range entries {
		if entry.Domain == domain {
			return true
		}
	}
	return false
}

// TestCatalogPolicyRejectsTypedNil proves a typed-nil policy facet fails
// registration instead of being stored as a callable-looking facet that
// would panic at FilterActions.
func TestCatalogPolicyRejectsTypedNil(t *testing.T) {

	reg := registry.New()
	definition := registry.Definition{
		Domain:  "typed-nil-policy",
		Version: 1,
		Name:    "Typed Nil Policy",
		Actions: []registry.DefinitionAction{
			{
				Name:         "probe",
				Summary:      "Probe",
				Safety:       spec.Safety{Class: "read"},
				InputSchema:  objectSchema(),
				OutputSchema: objectSchema(),
			},
		},
	}
	typedNil := (*wiringProbePolicy)(nil)
	_, err := reg.RegisterBuiltinWithOptions(
		definition,
		testExecutor{},
		"v1",
		registry.BuiltinOptions{
			ConnectionPolicy: registry.ConnectionRequired,
			CatalogPolicy:    typedNil,
		},
	)
	require.ErrorIs(t, err, registry.ErrInvalidCatalogPolicy)
}
