package app

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
)

// setupAppPermissionsFixture seeds a workspace admin, a non-admin peer, and an
// installed mention App whose pinned capability scopes are exactly
// {app.mentions.read, messages.write}. The installing admin is the App's first
// manager; the peer is an active workspace member who can neither read nor
// mutate the App permission projection (GET shares PATCH's management
// authority and returns an existence-hiding 404 for non-managers).
func setupAppPermissionsFixture(
	t *testing.T,
	ctx context.Context,
	store *Store,
	pool coredb.QueryExecer,
	suffix string,
	now time.Time,
) (admin, peer appAdminFixture, installationID string, initial AppPermissionsView) {
	t.Helper()

	admin = seedAppAdminFixture(t, pool, suffix+"_admin", now)
	peer = seedWorkspaceMemberFixture(t, pool, admin.WorkspaceID, suffix+"_peer", now)

	created, err := store.InstallCustomAppForWorkspaceAdmin(
		ctx, admin.UserID, admin.WorkspaceID, directAppOnboardingParams(suffix+"-app"),
	)
	require.NoError(t, err)
	installationID = created.Installation.ID

	var grantErr error
	initial, grantErr = store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, grantErr, "the installing admin must read the App permission view")
	require.ElementsMatch(t,
		[]string{AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		initial.AllowedScopes,
		"AllowedScopes mirrors the pinned manifest capability ceiling",
	)
	require.ElementsMatch(t,
		[]string{AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		initial.ActiveScopes,
		"a freshly installed App is granted the full pinned capability set",
	)
	return admin, peer, installationID, initial
}

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

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

	admin, peer, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-list", now)

	// GET shares PATCH's manager/admin authority. The installing admin (an
	// active App manager) reads the full pinned manifest scope ceiling and the
	// currently granted active subset.
	managerView, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.Equal(t, initial.AllowedScopes, managerView.AllowedScopes)
	require.Equal(t, initial.ActiveScopes, managerView.ActiveScopes)
	require.Equal(t, initial.PolicyDigest, managerView.PolicyDigest)

	// A non-manager peer is denied with an existence-hiding 404. Exposing the
	// full policy to any member holding the installation ID would leak an App
	// turned private, so GET — like PATCH — is gated on management authority.
	_, err = store.ListAppPermissionsForUser(ctx, peer.UserID, admin.WorkspaceID, installationID)
	require.ErrorIs(t, err, ErrNotFound)

	// The PolicyDigest is the optimistic-concurrency token computed from the
	// current active scopes, so a GET-then-PATCH cycle starts consistent.
	require.Equal(t, computeScopePolicyDigest(initial.ActiveScopes), initial.PolicyDigest)

	// The active grant tables backing the projection are themselves consistent.
	require.Equal(t, []string{AppMentionScopeRead, AppRuntimeScopeMessageWrite}, activeServiceGrantScopes(t, ctx, pool, installationID))
	require.Equal(t, []string{AppMentionScopeRead, AppRuntimeScopeMessageWrite}, activeAppGrantScopes(t, ctx, pool, installationID))
}

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

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

	admin, _, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-narrow", now)

	result, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppRuntimeScopeMessageWrite},
		now.Add(time.Second),
	)
	require.NoError(t, err)
	require.ElementsMatch(t, []string{AppRuntimeScopeMessageWrite}, result.ActiveScopes)
	require.NotEqual(t, initial.PolicyDigest, result.PolicyDigest, "narrowing must rotate the policy digest")
	require.Equal(t, computeScopePolicyDigest([]string{AppRuntimeScopeMessageWrite}), result.PolicyDigest)

	// The canonical read model reflects the narrowed subset.
	after, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.ElementsMatch(t, []string{AppRuntimeScopeMessageWrite}, after.ActiveScopes)
	require.Equal(t, result.PolicyDigest, after.PolicyDigest)
	// The immutable capability ceiling is unaffected by narrowing.
	require.ElementsMatch(t, []string{AppMentionScopeRead, AppRuntimeScopeMessageWrite}, after.AllowedScopes)

	// Both grant tables reconcile to the exact requested subset atomically.
	require.Equal(t, []string{AppRuntimeScopeMessageWrite}, activeServiceGrantScopes(t, ctx, pool, installationID))
	require.Equal(t, []string{AppRuntimeScopeMessageWrite}, activeAppGrantScopes(t, ctx, pool, installationID))
}

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

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

	admin, _, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-escalation", now)

	// app.notifications.write is not part of this mention App's pinned
	// capability, so anti-escalation must reject it even though the co-constraint
	// validator would otherwise be silent.
	_, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppNotificationScopeWrite, AppRuntimeScopeMessageWrite},
		now.Add(time.Second),
	)
	require.ErrorIs(t, err, ErrBadRequest)
	require.ErrorContains(t, err, "not allowed by the pinned capability")

	// A rejected mutation leaves the active scopes untouched.
	unchanged, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.ElementsMatch(t, initial.ActiveScopes, unchanged.ActiveScopes)
	require.Equal(t, initial.PolicyDigest, unchanged.PolicyDigest)
}

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

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

	admin, _, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-coconstraint", now)

	// app.mentions.read is a retained pinned scope but its messages.write
	// dependency was dropped, violating the scope-driven co-constraint.
	_, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppMentionScopeRead},
		now.Add(time.Second),
	)
	require.ErrorIs(t, err, ErrBadRequest)
	require.ErrorContains(t, err, "app.mentions.read requires messages.write")

	unchanged, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.ElementsMatch(t, initial.ActiveScopes, unchanged.ActiveScopes)
	require.Equal(t, initial.PolicyDigest, unchanged.PolicyDigest)
}

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

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

	admin, _, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-cas", now)

	// Mutate the active grant set out-of-band (a second actor revokes the mention
	// grant), rotating the current policy digest underneath the stale client view.
	outOfBandAt := now.Add(time.Second)
	mustExec(t, pool, `
UPDATE service_grants
SET revoked_at = $2, updated_at = $2
WHERE service_installation_id = $1
  AND scope = $3
  AND revoked_at IS NULL
`, installationID, outOfBandAt, AppMentionScopeRead)
	mustExec(t, pool, `
UPDATE service_grants
SET revoked_at = $2, updated_at = $2
WHERE service_installation_id = $1
  AND scope = $3
  AND revoked_at IS NULL
`, installationID, outOfBandAt, AppMentionScopeRead)

	// The client PATCHes with the digest captured before the concurrent
	// mutation. Optimistic concurrency must reject the stale expectation.
	_, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppRuntimeScopeMessageWrite},
		now.Add(2*time.Second),
	)
	require.ErrorIs(t, err, ErrConflict)
	require.ErrorContains(t, err, "App permission scopes changed")

	// The rejected PATCH did not double-apply: the active set remains the
	// out-of-band {messages.write} subset.
	after, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.ElementsMatch(t, []string{AppRuntimeScopeMessageWrite}, after.ActiveScopes)
}

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

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

	admin, peer, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-authority", now)

	// A non-admin workspace member who is not an active App manager cannot
	// mutate permission scopes. The authority check classifies this as
	// not-found so the surface leaks no App existence to unauthorized members.
	_, err := store.UpdateAppPermissionsForUser(
		ctx, peer.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppRuntimeScopeMessageWrite},
		now.Add(time.Second),
	)
	require.ErrorIs(t, err, ErrNotFound)

	// The same peer may NOT read the projection either: GET shares PATCH's
	// manager/admin authority and returns an existence-hiding 404.
	_, err = store.ListAppPermissionsForUser(ctx, peer.UserID, admin.WorkspaceID, installationID)
	require.ErrorIs(t, err, ErrNotFound)

	// Granting the peer App manager authority enables the previously rejected
	// narrowing, proving the failure was authority rather than the request shape.
	_, err = store.AddAppManagerForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID, peer.MemberID, now.Add(2*time.Second),
	)
	require.NoError(t, err)
	result, err := store.UpdateAppPermissionsForUser(
		ctx, peer.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppRuntimeScopeMessageWrite},
		now.Add(3*time.Second),
	)
	require.NoError(t, err, "an active App manager may narrow permission scopes")
	require.ElementsMatch(t, []string{AppRuntimeScopeMessageWrite}, result.ActiveScopes)
}

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

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

	admin, _, installationID, initial := setupAppPermissionsFixture(t, ctx, store, pool, "app-perms-default", now)

	// Submitting the full pinned capability set is a no-op narrowing: the active
	// scopes and policy digest remain unchanged so existing behavior is preserved.
	result, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		[]string{AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		now.Add(time.Second),
	)
	require.NoError(t, err)
	require.ElementsMatch(t, initial.ActiveScopes, result.ActiveScopes)
	require.Equal(t, initial.PolicyDigest, result.PolicyDigest, "re-asserting the full set must not rotate the digest")

	require.Equal(t,
		[]string{AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		activeServiceGrantScopes(t, ctx, pool, installationID),
	)
	require.Equal(t,
		[]string{AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		activeAppGrantScopes(t, ctx, pool, installationID),
	)
}

// appOnboardingParamsWithGenericScope mirrors directAppOnboardingParams but
// pins an additional top-level generic scope alongside the App capability
// scopes. The full pinned manifest scope set is then the union of the generic
// scope and capabilities.runtime.scopes, which is the anti-escalation ceiling the
// App-permission surface must preserve across a full-set PATCH. It reuses
// directAppOnboardingParams so the rest of the manifest stays canonical.
func appOnboardingParamsWithGenericScope(slug string, genericScope string) RequestCustomAppInstallationParams {
	params := directAppOnboardingParams(slug)
	params.Manifest["scopes"] = []string{genericScope, AppMentionScopeRead, AppRuntimeScopeMessageWrite}
	return params
}

// TestAppPermissionsGenericScopeIsPreservedByFullSetPatch regression-tests the
// anti-escalation ceiling: it must be the FULL pinned manifest scope set (top-
// level generic scopes + capabilities.runtime.scopes), not just the App capability
// scopes. An App whose pinned manifest carries a top-level generic scope such as
// profile.read must keep that scope across a full-set PATCH, while narrowing to
// drop it remains allowed. Previously the ceiling was only capabilities.runtime.Scopes,
// so a full-set PATCH silently revoked the generic scope.
func TestAppPermissionsGenericScopeIsPreservedByFullSetPatch(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, "app-perms-generic_admin", now)

	// Pin a top-level generic scope (profile.read) alongside the App capability
	// scopes. The anti-escalation ceiling is the union granted at install.
	params := appOnboardingParamsWithGenericScope("app-perms-generic-app", "profile.read")

	created, err := store.InstallCustomAppForWorkspaceAdmin(ctx, admin.UserID, admin.WorkspaceID, params)
	require.NoError(t, err)
	installationID := created.Installation.ID

	// GET AllowedScopes must surface the top-level generic scope, not just the
	// App capability scopes.
	initial, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.Contains(t, initial.AllowedScopes, "profile.read", "AllowedScopes must surface the top-level generic scope")
	require.ElementsMatch(t,
		[]string{"profile.read", AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		initial.AllowedScopes,
		"AllowedScopes is the full pinned manifest scope set",
	)
	// A freshly installed App is granted the full pinned set, including the
	// generic scope, in both grant tables.
	require.Contains(t, initial.ActiveScopes, "profile.read")
	require.ElementsMatch(t,
		[]string{"profile.read", AppMentionScopeRead, AppRuntimeScopeMessageWrite},
		initial.ActiveScopes,
	)
	require.Contains(t, activeServiceGrantScopes(t, ctx, pool, installationID), "profile.read")
	require.Contains(t, activeAppGrantScopes(t, ctx, pool, installationID), "profile.read")

	// A full-set PATCH (every allowed scope incl. profile.read) must NOT revoke
	// the generic scope. This is the regression: with the old ceiling
	// (capabilities.runtime.Scopes only) the generic scope was dropped here.
	fullSet := []string{"profile.read", AppMentionScopeRead, AppRuntimeScopeMessageWrite}
	fullResult, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		initial.PolicyDigest,
		fullSet,
		now.Add(time.Second),
	)
	require.NoError(t, err)
	require.Contains(t, fullResult.ActiveScopes, "profile.read", "a full-set PATCH must preserve the generic scope")
	require.ElementsMatch(t, fullSet, fullResult.ActiveScopes)
	require.Equal(t, initial.PolicyDigest, fullResult.PolicyDigest, "re-asserting the full set must not rotate the digest")
	require.Contains(t, activeServiceGrantScopes(t, ctx, pool, installationID), "profile.read")
	require.Contains(t, activeAppGrantScopes(t, ctx, pool, installationID), "profile.read")

	// Narrowing to drop profile.read (submit only the App scopes) IS allowed and
	// does revoke the generic scope from both grant tables.
	appOnly := []string{AppMentionScopeRead, AppRuntimeScopeMessageWrite}
	narrowed, err := store.UpdateAppPermissionsForUser(
		ctx, admin.UserID, admin.WorkspaceID, installationID,
		fullResult.PolicyDigest,
		appOnly,
		now.Add(2*time.Second),
	)
	require.NoError(t, err)
	require.NotContains(t, narrowed.ActiveScopes, "profile.read", "narrowing may drop the generic scope")
	require.ElementsMatch(t, appOnly, narrowed.ActiveScopes)
	require.NotContains(t, activeServiceGrantScopes(t, ctx, pool, installationID), "profile.read")
	require.NotContains(t, activeAppGrantScopes(t, ctx, pool, installationID), "profile.read")

	// The immutable ceiling still contains the generic scope even after it has
	// been narrowed out of the active set.
	afterNarrow, err := store.ListAppPermissionsForUser(ctx, admin.UserID, admin.WorkspaceID, installationID)
	require.NoError(t, err)
	require.Contains(t, afterNarrow.AllowedScopes, "profile.read")
	require.ElementsMatch(t, appOnly, afterNarrow.ActiveScopes)
}
