package app

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/objectstorage"
	"github.com/tidbcloud/AgentTeam/internal/store/attachment"
)

func TestWorkspaceAppManagerLifecycleControlsVisibilityAndAuthority(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, "bot_manager_lifecycle_admin", now)
	manager := seedWorkspaceMemberFixture(t, pool, admin.WorkspaceID, "bot_manager_lifecycle_manager", now)
	peer := seedWorkspaceMemberFixture(t, pool, admin.WorkspaceID, "bot_manager_lifecycle_peer", now)
	otherWorkspace := seedAppAdminFixture(t, pool, "bot_manager_lifecycle_other", now)

	created, err := store.InstallCustomAppForWorkspaceAdmin(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		directAppOnboardingParams("app-manager-lifecycle"),
	)
	require.NoError(t, err)
	installation := created.Installation

	initial, err := store.ListAppManagersForUser(ctx, admin.UserID, admin.WorkspaceID, installation.ID)
	require.NoError(t, err)
	require.Len(t, initial, 1)
	require.Equal(t, admin.MemberID, initial[0].MemberID)
	require.Equal(t, AppManagerStatusActive, initial[0].Status)
	require.EqualValues(t, 1, initial[0].Version)
	require.Nil(t, initial[0].EndedAt)

	_, err = store.RemoveAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, initial[0].ID, initial[0].Version, now.Add(time.Second),
	)
	require.ErrorIs(t, err, ErrConflict)
	require.ErrorContains(t, err, "at least one manager")

	assertWorkspaceAppManagementVisibility(t, ctx, store, manager.UserID, admin.WorkspaceID, installation.ID, installation.AppID, false)
	_, err = store.ListAppManagersForUser(ctx, manager.UserID, admin.WorkspaceID, installation.ID)
	require.ErrorIs(t, err, ErrNotFound)

	_, err = store.AddAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, otherWorkspace.MemberID, now.Add(2*time.Second),
	)
	require.ErrorIs(t, err, ErrConflict, "a member from another workspace cannot administer the App")
	_, err = store.AddAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, installation.MemberID, now.Add(2*time.Second),
	)
	require.ErrorIs(t, err, ErrConflict, "a service actor cannot administer the App")

	granted, err := store.AddAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, manager.MemberID, now.Add(3*time.Second),
	)
	require.NoError(t, err)
	require.NotEmpty(t, granted.ID)
	require.Equal(t, manager.MemberID, granted.MemberID)
	require.EqualValues(t, 1, granted.Version)

	replayed, err := store.AddAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, manager.MemberID, now.Add(4*time.Second),
	)
	require.NoError(t, err)
	require.Equal(t, granted.ID, replayed.ID)
	require.Equal(t, granted.Version, replayed.Version, "repeated active grants are idempotent")
	assertWorkspaceAppManagementVisibility(t, ctx, store, manager.UserID, admin.WorkspaceID, installation.ID, installation.AppID, true)

	configured, err := store.ConfigureAppInstallationForWorkspaceAdmin(
		ctx,
		manager.UserID,
		admin.WorkspaceID,
		installation.ID,
		ConfigureAppInstallationParams{
			ExpectedBaseConfig: map[string]any{"tone": "brief"},
			Config:             map[string]any{"tone": "detailed"},
		},
	)
	require.NoError(t, err, "an assigned App administrator receives the complete management capability")
	require.Equal(t, "detailed", configured.Config["tone"])

	peerGrant, err := store.AddAppManagerForUser(
		ctx, manager.UserID, admin.WorkspaceID, installation.ID, peer.MemberID, now.Add(5*time.Second),
	)
	require.NoError(t, err, "an assigned App administrator can add another administrator")
	require.Equal(t, peer.MemberID, peerGrant.MemberID)

	ended, err := store.RemoveAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, granted.ID, granted.Version, now.Add(6*time.Second),
	)
	require.NoError(t, err)
	require.Equal(t, AppManagerStatusEnded, ended.Status)
	require.EqualValues(t, 2, ended.Version)
	require.NotNil(t, ended.EndedAt)
	assertWorkspaceAppManagementVisibility(t, ctx, store, manager.UserID, admin.WorkspaceID, installation.ID, installation.AppID, false)

	replayedRemoval, err := store.RemoveAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, granted.ID, granted.Version, now.Add(7*time.Second),
	)
	require.NoError(t, err, "replaying the exact successful removal is idempotent")
	require.Equal(t, ended.ID, replayedRemoval.ID)
	require.Equal(t, ended.Version, replayedRemoval.Version)
	require.Equal(t, AppManagerStatusEnded, replayedRemoval.Status)

	reactivated, err := store.AddAppManagerForUser(
		ctx, peer.UserID, admin.WorkspaceID, installation.ID, manager.MemberID, now.Add(8*time.Second),
	)
	require.NoError(t, err)
	require.Equal(t, granted.ID, reactivated.ID, "reactivation preserves assignment identity")
	require.EqualValues(t, 3, reactivated.Version)
	require.Equal(t, AppManagerStatusActive, reactivated.Status)
	require.Nil(t, reactivated.EndedAt)
	assertWorkspaceAppManagementVisibility(t, ctx, store, manager.UserID, admin.WorkspaceID, installation.ID, installation.AppID, true)

	_, err = store.RemoveAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, reactivated.ID, granted.Version, now.Add(9*time.Second),
	)
	require.ErrorIs(t, err, ErrConflict, "stale assignment versions cannot revoke current authority")
}

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

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	mediaStorage := &recordingAppAvatarMediaStorage{}
	store := New(pool, Options{
		AppCredentialStorageKey: testAppCredentialStorageKey(),
		MediaObjectStorage:      mediaStorage,
	})
	now := time.Now().UTC()
	admin := seedAppAdminFixture(t, pool, "bot_avatar_projection_admin", now)
	manager := seedWorkspaceMemberFixture(t, pool, admin.WorkspaceID, "bot_avatar_projection_manager", now)
	otherWorkspace := seedAppAdminFixture(t, pool, "bot_avatar_projection_other", now)

	created, err := store.InstallCustomAppForWorkspaceAdmin(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		directAppOnboardingParams("app-avatar-projection"),
	)
	require.NoError(t, err)
	installation := created.Installation
	_, err = store.AddAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installation.ID, manager.MemberID, now.Add(time.Second),
	)
	require.NoError(t, err)

	insertAppAvatarAssetForTest(t, pool, "ava_bot_valid", admin.WorkspaceID, installation.AppID, now.Add(2*time.Second))
	insertAppAvatarAssetForTest(t, pool, "ava_bot_wrong_owner", admin.WorkspaceID, "app_someone_else", now.Add(2*time.Second))
	insertAppAvatarAssetForTest(t, pool, "ava_bot_wrong_workspace", otherWorkspace.WorkspaceID, installation.AppID, now.Add(2*time.Second))

	for name, avatarAssetID := range map[string]string{
		"wrong owner":     "ava_bot_wrong_owner",
		"wrong workspace": "ava_bot_wrong_workspace",
	} {
		t.Run(name, func(t *testing.T) {
			_, err := store.UpdateAppAvatarForUser(
				ctx, manager.UserID, admin.WorkspaceID, installation.ID, avatarAssetID, now.Add(3*time.Second),
			)
			require.ErrorIs(t, err, ErrBadRequest)
			require.ErrorContains(t, err, "does not belong to App")
		})
	}

	updated, err := store.UpdateAppAvatarForUser(
		ctx, manager.UserID, admin.WorkspaceID, installation.ID, "ava_bot_valid", now.Add(4*time.Second),
	)
	require.NoError(t, err)
	wantURL := attachment.AvatarAssetURL("ava_bot_valid")
	require.Equal(t, wantURL, updated.AvatarURL)
	assertWorkspaceAppAvatarProjection(t, ctx, pool, installation, "ava_bot_valid", wantURL)
	replayedAvatar, err := store.UpdateAppAvatarForUser(
		ctx, manager.UserID, admin.WorkspaceID, installation.ID, "ava_bot_valid", now.Add(4*time.Second),
	)
	require.NoError(t, err, "reapplying an unchanged avatar in the same timestamp is idempotent")
	require.Equal(t, wantURL, replayedAvatar.AvatarURL)

	definitions, err := store.ListAppsForUser(ctx, manager.UserID, admin.WorkspaceID)
	require.NoError(t, err)
	foundDefinition := false
	for _, definition := range definitions {
		if definition.ID != installation.AppID {
			continue
		}
		foundDefinition = true
		require.Equal(t, wantURL, definition.AvatarURL)
	}
	require.True(t, foundDefinition)
	installations, err := store.ListAppInstallationsForUser(ctx, manager.UserID, admin.WorkspaceID)
	require.NoError(t, err)
	require.Len(t, installations, 1)
	require.Equal(t, wantURL, installations[0].AvatarURL)

	cleared, err := store.UpdateAppAvatarForUser(
		ctx, manager.UserID, admin.WorkspaceID, installation.ID, "", now.Add(5*time.Second),
	)
	require.NoError(t, err)
	require.Empty(t, cleared.AvatarURL)
	assertWorkspaceAppAvatarProjection(t, ctx, pool, installation, "", "")

	insertAppAvatarAssetForTest(t, pool, "ava_bot_delete_1", admin.WorkspaceID, installation.AppID, now.Add(6*time.Second))
	deleteObjectKey := "avatars/" + admin.WorkspaceID + "/ava_bot_delete_2"
	insertAppAvatarMediaAssetForTest(
		t, pool, "ava_bot_delete_2", admin.WorkspaceID, installation.AppID,
		objectstorage.DriverOSS, deleteObjectKey, now.Add(6*time.Second),
	)
	_, err = store.UpdateAppAvatarForUser(
		ctx, manager.UserID, admin.WorkspaceID, installation.ID, "ava_bot_delete_1", now.Add(7*time.Second),
	)
	require.NoError(t, err)

	uninstalled, err := store.UninstallAppInstallationForUser(ctx, manager.UserID, admin.WorkspaceID, installation.ID)
	require.NoError(t, err)
	require.False(t, uninstalled.Enabled)
	for label, query := range map[string]string{
		"App definition": `SELECT COUNT(*) FROM service_definitions WHERE id = $1`,
		"App managers":   `SELECT COUNT(*) FROM extension_definition_managers WHERE service_definition_id = $1`,
	} {
		var count int
		require.NoError(t, pool.QueryRow(ctx, query, installation.AppID).Scan(&count), label)
		require.Zero(t, count, "%s must be deleted with the App", label)
	}
	var deletedWorkspaceAvatarCount int
	require.NoError(t, pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM avatars
WHERE workspace_id = $1
  AND owner_kind = 'app'
  AND owner_id = $2
`, admin.WorkspaceID, installation.AppID).Scan(&deletedWorkspaceAvatarCount))
	require.Zero(t, deletedWorkspaceAvatarCount, "all avatar assets owned by the workspace App must be deleted")
	require.Equal(t, []string{deleteObjectKey}, mediaStorage.deletedKeys, "App deletion must remove every owned external avatar object after commit")
	var otherWorkspaceAvatarCount int
	require.NoError(t, pool.QueryRow(ctx, `SELECT COUNT(*) FROM avatars WHERE id = $1`, "ava_bot_wrong_workspace").Scan(&otherWorkspaceAvatarCount))
	require.Equal(t, 1, otherWorkspaceAvatarCount, "App deletion must not cross the workspace ownership boundary")
}

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

	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	mediaStorage := &recordingAppAvatarMediaStorage{}
	store := New(pool, Options{
		AppCredentialStorageKey: testAppCredentialStorageKey(),
		MediaObjectStorage:      mediaStorage,
	})
	now := time.Now().UTC()
	admin := seedAppAdminFixture(t, pool, "bot_avatar_rejection_admin", now)
	requester := seedWorkspaceMemberFixture(t, pool, admin.WorkspaceID, "bot_avatar_rejection_requester", now)

	requested, err := store.RequestCustomAppInstallationForUser(
		ctx,
		requester.UserID,
		admin.WorkspaceID,
		directAppOnboardingParams("app-avatar-rejection"),
	)
	require.NoError(t, err)
	objectKey := "avatars/" + admin.WorkspaceID + "/ava_bot_rejection"
	insertAppAvatarMediaAssetForTest(
		t, pool, "ava_bot_rejection", admin.WorkspaceID, requested.Request.AppID,
		objectstorage.DriverOSS, objectKey, now.Add(time.Second),
	)

	rejected, err := store.RejectAppInstallationRequestForUser(
		ctx,
		admin.UserID,
		admin.WorkspaceID,
		requested.Request.ID,
		DecideAppInstallationRequestParams{Reason: "not approved"},
	)
	require.NoError(t, err)
	require.Equal(t, AppInstallationRequestStatusRejected, rejected.Status)

	var avatarCount int
	require.NoError(t, pool.QueryRow(ctx, `SELECT COUNT(*) FROM avatars WHERE id = $1`, "ava_bot_rejection").Scan(&avatarCount))
	require.Zero(t, avatarCount, "terminal request cleanup must delete the App-owned avatar row")
	require.Equal(t, []string{objectKey}, mediaStorage.deletedKeys, "terminal request cleanup must remove external avatar media only after commit")
}

type recordingAppAvatarMediaStorage struct {
	deletedKeys []string
}

func (storage *recordingAppAvatarMediaStorage) Driver() string {
	return objectstorage.DriverOSS
}

func (storage *recordingAppAvatarMediaStorage) Put(context.Context, string, string, []byte) error {
	return nil
}

func (storage *recordingAppAvatarMediaStorage) Get(context.Context, string) ([]byte, error) {
	return nil, objectstorage.ErrObjectNotFound
}

func (storage *recordingAppAvatarMediaStorage) Delete(_ context.Context, key string) error {
	storage.deletedKeys = append(storage.deletedKeys, key)
	return nil
}

func assertWorkspaceAppManagementVisibility(
	t *testing.T,
	ctx context.Context,
	store *Store,
	userID string,
	workspaceID string,
	installationID string,
	appID string,
	wantVisible bool,
) {
	t.Helper()
	definitions, err := store.ListAppsForUser(ctx, userID, workspaceID)
	require.NoError(t, err)
	definitionVisible := false
	for _, definition := range definitions {
		definitionVisible = definitionVisible || definition.ID == appID
	}
	require.Equal(t, wantVisible, definitionVisible, "App catalog visibility must follow active manager authority")

	installations, err := store.ListAppInstallationsForUser(ctx, userID, workspaceID)
	require.NoError(t, err)
	installationVisible := false
	for _, installation := range installations {
		if installation.ID != installationID {
			continue
		}
		installationVisible = true
		require.Equal(t, wantVisible, installation.CanManageApp)
	}
	require.Equal(t, wantVisible, installationVisible, "resolved App detail visibility must follow active manager authority")
}

func insertAppAvatarAssetForTest(
	t *testing.T,
	pool coredb.QueryExecer,
	avatarID string,
	workspaceID string,
	appID string,
	now time.Time,
) {
	t.Helper()
	// Use the shared test helper instead of depending on the blob storage
	// adapter: ownership is the contract exercised by App avatar application.
	mustExec(t, pool, `
INSERT INTO avatars (
    id, workspace_id, owner_kind, owner_id, filename, content_type,
    size_bytes, storage_driver, object_key, created_at
) VALUES ($1, $2, 'app', $3, 'app.png', 'image/png', 0, 'db', NULL, $4)
`, avatarID, workspaceID, appID, now)
}

func insertAppAvatarMediaAssetForTest(
	t *testing.T,
	pool coredb.QueryExecer,
	avatarID string,
	workspaceID string,
	appID string,
	storageDriver string,
	objectKey string,
	now time.Time,
) {
	t.Helper()
	mustExec(t, pool, `
INSERT INTO avatars (
    id, workspace_id, owner_kind, owner_id, filename, content_type,
    size_bytes, storage_driver, object_key, created_at
) VALUES ($1, $2, 'app', $3, 'app.png', 'image/png', 12, $4, $5, $6)
`, avatarID, workspaceID, appID, storageDriver, objectKey, now)
}

func assertWorkspaceAppAvatarProjection(
	t *testing.T,
	ctx context.Context,
	pool coredb.QueryRower,
	installation AppInstallationView,
	wantAssetID string,
	wantURL string,
) {
	t.Helper()
	var appAssetID, appURL, definitionURL, memberURL string
	require.NoError(t, pool.QueryRow(ctx, `SELECT COALESCE(JSON_UNQUOTE(JSON_EXTRACT(COALESCE(config, JSON_OBJECT()), '$.avatarAssetId')), ''), avatar_url FROM service_definitions WHERE id = $1`, installation.AppID).Scan(&appAssetID, &appURL))
	require.NoError(t, pool.QueryRow(ctx, `SELECT avatar_url FROM service_definitions WHERE id = $1`, installation.AppID).Scan(&definitionURL))
	require.NoError(t, pool.QueryRow(ctx, `SELECT avatar_url FROM service_members WHERE member_id = $1`, installation.MemberID).Scan(&memberURL))
	require.Equal(t, wantAssetID, appAssetID)
	require.Equal(t, wantURL, appURL)
	require.Equal(t, wantURL, definitionURL)
	require.Equal(t, wantURL, memberURL)
}
