package app

import (
	"context"
	"errors"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

func testProviderTopologyConfigContract() connection.Contract {
	return connection.Contract{
		Fields: []connection.EnumField{
			{
				Name:     "topology",
				Required: true,
				Values:   []string{"aggregate", "fallback"},
			},
		},
	}
}

func TestExtensionProviderConnectionLifecycleEncryptsAndResolves(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	store := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	admin := seedAppAdminFixture(t, pool, "provider_connection_todoist", now)
	target := ExtensionProviderTarget{
		AppID:   registry.TodoistAppID,
		AppSlug: registry.TodoistAppSlug,
		Label:   "Todoist",
	}
	configContract := testProviderTopologyConfigContract()

	installation, err := store.InstallAppForWorkspaceAdmin(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		InstallAppParams{AppID: registry.TodoistAppID},
	)
	require.NoError(t, err)

	selections, err := serviceactor.New(pool).ListInstallationExtensions(
		ctx,
		admin.WorkspaceID,
		installation.ID,
	)
	require.NoError(t, err)
	require.Len(t, selections, 1)
	require.Equal(t, "todoist", selections[0].ExtensionName)
	require.Equal(t, model.TodoistBuiltinExtensionDefinitionDigest, selections[0].DefinitionDigest)

	view, err := store.UpsertExtensionProviderConnectionForUser(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		installation.ID,
		target,
		map[string]string{"apiKey": "initial-secret"},
		connection.Config{"topology": "aggregate"},
		nil,
	)
	require.NoError(t, err)
	require.True(t, view.Active)
	require.True(t, view.CredentialSet)

	var storedCredentials, storedConfig []byte
	require.NoError(t, pool.QueryRow(ctx, `
SELECT credentials, config
FROM service_connections
WHERE service_installation_id = $1
  AND external_subject_ref = 'default'
  AND status = 'active'
`, installation.ID).Scan(&storedCredentials, &storedConfig))
	require.NotContains(t, string(storedCredentials), "initial-secret")
	require.Contains(t, string(storedCredentials), CredentialStorageScheme)
	require.JSONEq(t, `{"topology":"aggregate"}`, string(storedConfig))

	_, _, err = store.ResolveExtensionProviderRuntimeConnection(
		ctx,
		admin.WorkspaceID,
		installation.ID,
		ExtensionProviderTarget{},
		connection.Contract{},
	)
	require.Error(t, err, "credential-only runtime cannot ignore stored config")
	resolvedCredentials, resolvedConfig, err :=
		store.ResolveExtensionProviderRuntimeConnection(
			ctx,
			admin.WorkspaceID,
			installation.ID,
			ExtensionProviderTarget{},
			configContract,
		)
	require.NoError(t, err)
	require.Equal(t, "initial-secret", resolvedCredentials["apiKey"])
	require.Equal(
		t,
		connection.Config{"topology": "aggregate"},
		resolvedConfig,
	)
	targetCredentials, targetConfig, err :=
		store.ResolveExtensionProviderRuntimeConnection(
			ctx,
			admin.WorkspaceID,
			installation.ID,
			target,
			configContract,
		)
	require.NoError(t, err)
	require.Equal(t, resolvedCredentials, targetCredentials)
	require.Equal(t, resolvedConfig, targetConfig)

	for _, stored := range []string{
		`{"topology":"legacy"}`,
		`{"topology":"aggregate","session_id":"opaque"}`,
	} {
		_, err = pool.Exec(ctx, `
UPDATE service_connections
SET config = $1
WHERE service_installation_id = $2
  AND external_subject_ref = 'default'
  AND status = 'active'
`, stored, installation.ID)
		require.NoError(t, err)

		_, _, err = store.ResolveExtensionProviderRuntimeConnection(
			ctx,
			admin.WorkspaceID,
			installation.ID,
			target,
			configContract,
		)
		require.ErrorContains(t, err, "violates provider contract")
		_, _, err = store.ResolveExtensionProviderRuntimeConnection(
			ctx,
			admin.WorkspaceID,
			installation.ID,
			ExtensionProviderTarget{},
			configContract,
		)
		require.ErrorContains(t, err, "violates provider contract")
	}
	_, err = pool.Exec(ctx, `
UPDATE service_connections
SET config = '{"topology":"aggregate"}'
WHERE service_installation_id = $1
  AND external_subject_ref = 'default'
  AND status = 'active'
`, installation.ID)
	require.NoError(t, err)

	peer := seedWorkspaceMemberFixture(
		t,
		pool,
		admin.WorkspaceID,
		"provider_connection_todoist_peer",
		now,
	)
	_, err = store.GetExtensionProviderConnectionForUser(
		ctx,
		peer.UserID,
		admin.WorkspaceID,
		installation.ID,
		target,
	)
	require.Error(t, err)
	require.True(t, errors.Is(err, ErrForbidden))

	require.NoError(t, store.DeleteExtensionProviderConnectionForUser(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		installation.ID,
		target,
	))
	_, _, err = store.ResolveExtensionProviderRuntimeConnection(
		ctx,
		admin.WorkspaceID,
		installation.ID,
		ExtensionProviderTarget{},
		connection.Contract{},
	)
	require.Error(t, err)
}

func TestExtensionProviderConnectionForeignTargetFailsClosed(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	store := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	admin := seedAppAdminFixture(t, pool, "provider_connection_mismatch", now)

	installation, err := store.InstallAppForWorkspaceAdmin(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		InstallAppParams{AppID: registry.TodoistAppID},
	)
	require.NoError(t, err)

	foreignTarget := ExtensionProviderTarget{
		AppID:   registry.NotionAppID,
		AppSlug: registry.NotionAppSlug,
		Label:   "Notion",
	}
	_, err = store.UpsertExtensionProviderConnectionForUser(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		installation.ID,
		foreignTarget,
		map[string]string{"apiKey": "secret"},
		nil,
		nil,
	)
	require.Error(t, err)
}

func TestUpsertExtensionProviderConnectionRequiresCurrentVersion(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	store := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	admin := seedAppAdminFixture(t, pool, "provider_connection_cas_todoist", now)
	target := ExtensionProviderTarget{
		AppID:   registry.TodoistAppID,
		AppSlug: registry.TodoistAppSlug,
		Label:   "Todoist",
	}
	installation, err := store.InstallAppForWorkspaceAdmin(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		InstallAppParams{AppID: registry.TodoistAppID},
	)
	require.NoError(t, err)

	upsert := func(expected *time.Time, apiKey string) (ExtensionProviderConnectionView, error) {
		return store.UpsertExtensionProviderConnectionForUser(
			ctx,
			admin.UserID,
			admin.WorkspaceID,
			installation.ID,
			target,
			map[string]string{"apiKey": apiKey},
			nil,
			expected,
		)
	}

	// Creating without an expectation succeeds and pins the first version.
	first, err := upsert(nil, "initial-secret")
	require.NoError(t, err)
	require.True(t, first.Active)

	// Updating while presenting the version just observed succeeds.
	second, err := upsert(first.UpdatedAt, "rotated-secret")
	require.NoError(t, err)

	// A concurrent writer that still holds the pre-rotation version is
	// rejected instead of silently restoring the rotated secret: the
	// deterministic stand-in for two overlapping "empty keeps stored"
	// merges racing on one connection.
	_, err = upsert(first.UpdatedAt, "stale-secret")
	require.ErrorIs(t, err, ErrConflict)

	// Omitting the version against an existing connection is equally
	// stale: the caller never saw the current state.
	_, err = upsert(nil, "blind-secret")
	require.ErrorIs(t, err, ErrConflict)

	// A version presented against a foreign provider target still fails
	// on the installation/target mismatch before version comparison.
	absent := ExtensionProviderTarget{
		AppID:   registry.NotionAppID,
		AppSlug: registry.NotionAppSlug,
		Label:   "Notion",
	}
	_, err = store.UpsertExtensionProviderConnectionForUser(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		installation.ID,
		absent,
		map[string]string{"apiKey": "secret"},
		nil,
		second.UpdatedAt,
	)
	require.ErrorIs(t, err, ErrNotFound)

	// The current version still writes and advances the version forward.
	third, err := upsert(second.UpdatedAt, "fresh-secret")
	require.NoError(t, err)
	require.NotNil(t, third.UpdatedAt)
	require.False(t, third.UpdatedAt.Equal(*second.UpdatedAt))

	// A same-version second writer loses even when the calls are fully
	// serialized; the truly interleaved proof runs against a real
	// non-savepoint database in TestUpsertExtensionProviderConnectionConcurrentWriters.
	_, writerAErr := upsert(third.UpdatedAt, "writer-a-secret")
	require.NoError(t, writerAErr)
	_, writerBErr := upsert(third.UpdatedAt, "writer-b-secret")
	require.ErrorIs(t, writerBErr, ErrConflict)

	resolved, _, resolveErr := store.ResolveExtensionProviderRuntimeConnection(
		ctx,
		admin.WorkspaceID,
		installation.ID,
		target,
		connection.Contract{},
	)
	require.NoError(t, resolveErr)
	require.Equal(t, "writer-a-secret", resolved["apiKey"])
}

// TestUpsertExtensionProviderConnectionConcurrentWriters drives two writers
// holding the same expected version into the upsert at the same time on a
// real database: the version comparison runs under the installation lock, so
// exactly one transaction can commit and the loser observes ErrConflict
// instead of silently restoring rotated credentials. The rollback-sandbox
// fixture serializes transactions through one savepoint session, so this
// proof needs an isolated initialized TiDB.
func TestUpsertExtensionProviderConnectionConcurrentWriters(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	dsn := testkit.StartInitializedTiDBWithSharedRuntime(t, testkit.TiDBTestRuntimeUnistore)
	pool := coredb.WrapSQLDB(testkit.OpenTiDB(t, dsn))
	store := New(pool, Options{AppCredentialStorageKey: testAppCredentialStorageKey()})
	now := time.Now().UTC()
	admin := seedAppAdminFixture(t, pool, "provider_connection_cas_race", now)
	target := ExtensionProviderTarget{
		AppID:   registry.TodoistAppID,
		AppSlug: registry.TodoistAppSlug,
		Label:   "Todoist",
	}
	installation, err := store.InstallAppForWorkspaceAdmin(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		InstallAppParams{AppID: registry.TodoistAppID},
	)
	require.NoError(t, err)

	upsert := func(expected *time.Time, apiKey string) (ExtensionProviderConnectionView, error) {
		return store.UpsertExtensionProviderConnectionForUser(
			ctx,
			admin.UserID,
			admin.WorkspaceID,
			installation.ID,
			target,
			map[string]string{"apiKey": apiKey},
			nil,
			expected,
		)
	}

	first, err := upsert(nil, "initial-secret")
	require.NoError(t, err)
	second, err := upsert(first.UpdatedAt, "rotated-secret")
	require.NoError(t, err)

	secrets := []string{"writer-a-secret", "writer-b-secret"}
	outcomes := make(chan struct {
		secret string
		err    error
	}, len(secrets))
	barrier := make(chan struct{})
	var writers sync.WaitGroup
	for _, secret := range secrets {
		writers.Add(1)
		go func(rotated string) {
			defer writers.Done()
			<-barrier
			_, upsertErr := upsert(second.UpdatedAt, rotated)
			outcomes <- struct {
				secret string
				err    error
			}{rotated, upsertErr}
		}(secret)
	}
	close(barrier)
	writers.Wait()
	close(outcomes)

	winnerSecret := ""
	conflicts := 0
	for outcome := range outcomes {
		if outcome.err == nil {
			require.Empty(t, winnerSecret, "both same-version writers committed")
			winnerSecret = outcome.secret
			continue
		}
		require.ErrorIs(t, outcome.err, ErrConflict)
		conflicts++
	}
	require.Equal(t, 1, conflicts, "exactly one same-version writer must lose")
	require.NotEmpty(t, winnerSecret)

	resolved, _, resolveErr := store.ResolveExtensionProviderRuntimeConnection(
		ctx,
		admin.WorkspaceID,
		installation.ID,
		target,
		connection.Contract{},
	)
	require.NoError(t, resolveErr)
	require.Equal(t, winnerSecret, resolved["apiKey"])
}
