package service

import (
	"context"
	"errors"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/google/uuid"

	"github.com/codex-k8s/kodex/libs/go/accesscatalog"
	"github.com/codex-k8s/kodex/services/internal/access-manager/internal/domain/errs"
	"github.com/codex-k8s/kodex/services/internal/access-manager/internal/domain/types/entity"
	"github.com/codex-k8s/kodex/services/internal/access-manager/internal/domain/types/enum"
	"github.com/codex-k8s/kodex/services/internal/access-manager/internal/domain/types/query"
	"github.com/codex-k8s/kodex/services/internal/access-manager/internal/domain/types/value"
)

func TestBootstrapUserFromIdentityUsesAllowlistDomain(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "kodex", DisplayName: "Платформа KODEX",
		Meta: commandMeta("create-owner-org"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	_, err = svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchDomain, Value: "Example.com", OrganizationID: &org.ID, DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("put allowlist: %v", err)
	}

	result, err := svc.BootstrapUserFromIdentity(ctx, BootstrapUserFromIdentityInput{
		Provider: enum.IdentityProviderGitHub, Subject: "42", Email: "Owner@Example.com", DisplayName: "Owner",
	})
	if err != nil {
		t.Fatalf("bootstrap user: %v", err)
	}
	if result.Decision != enum.AccessDecisionAllow {
		t.Fatalf("decision = %s, want %s", result.Decision, enum.AccessDecisionAllow)
	}
	if result.User.PrimaryEmail != "owner@example.com" {
		t.Fatalf("email = %s", result.User.PrimaryEmail)
	}
	if result.Organization == nil || result.Organization.ID != org.ID {
		t.Fatalf("organization was not resolved")
	}
}

func TestCreateOrganizationTreatsEmptyStatusAsActiveForOwnerGuard(t *testing.T) {
	ctx := context.Background()
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())

	_, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "kodex", DisplayName: "Платформа KODEX",
		Meta: commandMeta("create-owner-org"),
	})
	if err != nil {
		t.Fatalf("create first owner: %v", err)
	}
	_, err = svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "kodex-2", DisplayName: "Платформа KODEX 2",
		Meta: commandMeta("create-second-owner-org"),
	})
	if !errors.Is(err, errs.ErrAlreadyExists) {
		t.Fatalf("err = %v, want %v", err, errs.ErrAlreadyExists)
	}
}

func TestCreateOrganizationRejectsNonActiveOwner(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.CreateOrganization(context.Background(), CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "kodex", DisplayName: "Платформа KODEX",
		Status: enum.OrganizationStatusPending, Meta: commandMeta("create-pending-owner"),
	})
	if !errors.Is(err, errs.ErrPreconditionFailed) {
		t.Fatalf("err = %v, want %v", err, errs.ErrPreconditionFailed)
	}
}

func TestCreateOrganizationRequiresCommandIdentity(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.CreateOrganization(context.Background(), CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "kodex", DisplayName: "Платформа KODEX",
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestCreateOrganizationReplaysCommandResult(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	created, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "kodex", DisplayName: "Платформа KODEX",
		Meta: commandMeta("create-owner-idempotent"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	replayed, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindOwner, Slug: "different", DisplayName: "Другой владелец",
		Meta: commandMeta("create-owner-idempotent"),
	})
	if err != nil {
		t.Fatalf("replay organization command: %v", err)
	}
	if replayed.ID != created.ID || replayed.Version != created.Version {
		t.Fatalf("replay changed result: id %s/%s version %d/%d", replayed.ID, created.ID, replayed.Version, created.Version)
	}
	if len(store.organizations) != 1 || len(store.events) != 1 {
		t.Fatalf("stored organizations/events = %d/%d, want 1/1", len(store.organizations), len(store.events))
	}
}

func TestBootstrapUserFromIdentityResolvesOrganizationBeforeCreateUser(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	missingOrganizationID := uuid.New()

	_, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchDomain, Value: "example.com", OrganizationID: &missingOrganizationID,
		DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("put allowlist: %v", err)
	}
	_, err = svc.BootstrapUserFromIdentity(ctx, BootstrapUserFromIdentityInput{
		Provider: enum.IdentityProviderGitHub, Subject: "42", Email: "owner@example.com",
	})
	if !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("err = %v, want %v", err, errs.ErrNotFound)
	}
	if len(store.users) != 0 {
		t.Fatalf("users were created after failed organization lookup")
	}
}

func TestBootstrapUserFromIdentityRejectsDisabledAllowlist(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	_, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "owner@example.com", DefaultStatus: enum.UserStatusActive,
		Status: enum.AllowlistStatusDisabled,
	})
	if err != nil {
		t.Fatalf("put allowlist: %v", err)
	}
	_, err = svc.BootstrapUserFromIdentity(ctx, BootstrapUserFromIdentityInput{
		Provider: enum.IdentityProviderGitHub, Subject: "42", Email: "owner@example.com",
	})
	if !errors.Is(err, errs.ErrUnauthorizedSubject) {
		t.Fatalf("err = %v, want %v", err, errs.ErrUnauthorizedSubject)
	}
	if len(store.users) != 0 {
		t.Fatalf("users were created from disabled allowlist")
	}
}

func TestBootstrapUserFromIdentityLinksExistingUserByEmail(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	_, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchDomain, Value: "example.com", DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("put allowlist: %v", err)
	}
	created, err := svc.BootstrapUserFromIdentity(ctx, BootstrapUserFromIdentityInput{
		Provider: enum.IdentityProviderGitHub, Subject: "42", Email: "owner@example.com",
	})
	if err != nil {
		t.Fatalf("bootstrap first identity: %v", err)
	}
	linked, err := svc.BootstrapUserFromIdentity(ctx, BootstrapUserFromIdentityInput{
		Provider: enum.IdentityProviderKeycloak, Subject: "kc-42", Email: "OWNER@example.com",
	})
	if err != nil {
		t.Fatalf("bootstrap second identity: %v", err)
	}
	if linked.User.ID != created.User.ID {
		t.Fatalf("linked user = %s, want %s", linked.User.ID, created.User.ID)
	}
	if linked.ReasonCode != reasonIdentityLinked {
		t.Fatalf("reason = %s, want %s", linked.ReasonCode, reasonIdentityLinked)
	}
	if len(store.users) != 1 {
		t.Fatalf("user count = %d, want 1", len(store.users))
	}
}

func TestPutAllowlistEntryRejectsBlockedDefaultStatus(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.PutAllowlistEntry(context.Background(), PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "owner@example.com", DefaultStatus: enum.UserStatusBlocked,
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestPutAllowlistEntryKeepsIdentityOnRepeat(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	created, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "Owner@Example.com", DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("create allowlist: %v", err)
	}
	repeated, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "owner@example.com", DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("repeat allowlist: %v", err)
	}
	if repeated.ID != created.ID || repeated.Version != created.Version {
		t.Fatalf("repeat changed identity/version: id %s/%s version %d/%d", repeated.ID, created.ID, repeated.Version, created.Version)
	}
	updated, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "owner@example.com", DefaultStatus: enum.UserStatusPending,
		Meta: value.CommandMeta{ExpectedVersion: ptrInt64(created.Version)},
	})
	if err != nil {
		t.Fatalf("update allowlist: %v", err)
	}
	if updated.ID != created.ID || updated.Version != created.Version+1 {
		t.Fatalf("update identity/version = %s/%d, want %s/%d", updated.ID, updated.Version, created.ID, created.Version+1)
	}
}

func TestSetUserStatusUpdatesStatusAndRecordsEvent(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusPending)
	meta := store.seedOperatorMeta("set-user-status", accessActionSetUserStatus, accessResourceUser)
	meta.ExpectedVersion = ptrInt64(user.Version)
	meta.Reason = "owner_approved"

	updated, err := svc.SetUserStatus(ctx, SetUserStatusInput{
		UserID: user.ID,
		Status: enum.UserStatusActive,
		Meta:   meta,
	})
	if err != nil {
		t.Fatalf("set user status: %v", err)
	}
	if updated.Status != enum.UserStatusActive || updated.Version != user.Version+1 {
		t.Fatalf("updated user = %+v, want active version %d", updated, user.Version+1)
	}
	if got := store.users[user.ID].Status; got != enum.UserStatusActive {
		t.Fatalf("stored status = %s, want active", got)
	}
	if len(store.events) != 1 || store.events[0].EventType != accessEventUserStatusChanged {
		t.Fatalf("events = %+v, want one user status event", store.events)
	}
}

func TestSetUserStatusRejectsStaleExpectedVersion(t *testing.T) {
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusPending)
	meta := store.seedOperatorMeta("set-user-status-stale", accessActionSetUserStatus, accessResourceUser)
	meta.ExpectedVersion = ptrInt64(user.Version + 1)

	_, err := svc.SetUserStatus(context.Background(), SetUserStatusInput{
		UserID: user.ID,
		Status: enum.UserStatusActive,
		Meta:   meta,
	})
	if !errors.Is(err, errs.ErrConflict) {
		t.Fatalf("err = %v, want %v", err, errs.ErrConflict)
	}
}

func TestSetUserStatusRequiresAuthorizedActor(t *testing.T) {
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	target := store.seedUser(enum.UserStatusPending)
	actor := store.seedUser(enum.UserStatusActive)
	meta := commandMeta("set-user-status-forbidden")
	meta.Actor = value.Actor{Type: string(enum.AccessSubjectUser), ID: actor.ID.String()}
	meta.ExpectedVersion = ptrInt64(target.Version)

	_, err := svc.SetUserStatus(context.Background(), SetUserStatusInput{
		UserID: target.ID,
		Status: enum.UserStatusActive,
		Meta:   meta,
	})
	if !errors.Is(err, errs.ErrForbidden) {
		t.Fatalf("err = %v, want %v", err, errs.ErrForbidden)
	}
}

func TestSetUserStatusUsesOrganizationScope(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	now := fixedClock{}.Now()
	target := store.seedUser(enum.UserStatusPending)
	organizationID := uuid.New()
	membershipID := uuid.New()
	store.memberships[membershipID] = entity.Membership{
		Base:        entity.Base{ID: membershipID, Version: 1, CreatedAt: now, UpdatedAt: now},
		SubjectType: enum.MembershipSubjectUser,
		SubjectID:   target.ID,
		TargetType:  enum.MembershipTargetOrganization,
		TargetID:    organizationID,
		Status:      enum.MembershipStatusActive,
		Source:      enum.MembershipSourceManual,
	}
	scope := value.ScopeRef{Type: accessRuleScopeOrganization, ID: organizationID.String()}
	meta := store.seedOperatorMetaForScope("set-user-status-org", accessActionSetUserStatus, accessResourceUser, scope)
	meta.ExpectedVersion = ptrInt64(target.Version)

	updated, err := svc.SetUserStatus(ctx, SetUserStatusInput{
		UserID: target.ID,
		Status: enum.UserStatusActive,
		Meta:   meta,
	})
	if err != nil {
		t.Fatalf("set user status in organization scope: %v", err)
	}
	if updated.Status != enum.UserStatusActive {
		t.Fatalf("status = %s, want active", updated.Status)
	}
	gotScope, ok := lastAuditScope(store)
	if !ok || gotScope != scope {
		t.Fatalf("last audit scope = %+v, want %+v", gotScope, scope)
	}
}

func TestSetUserStatusSameTargetWithStaleVersionConflicts(t *testing.T) {
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)
	meta := store.seedOperatorMeta("set-user-status-noop-stale", accessActionSetUserStatus, accessResourceUser)
	meta.ExpectedVersion = ptrInt64(user.Version - 1)

	_, err := svc.SetUserStatus(context.Background(), SetUserStatusInput{
		UserID: user.ID,
		Status: enum.UserStatusActive,
		Meta:   meta,
	})
	if !errors.Is(err, errs.ErrConflict) {
		t.Fatalf("err = %v, want %v", err, errs.ErrConflict)
	}
	if store.users[user.ID].Version != user.Version || len(store.events) != 0 {
		t.Fatalf("stale noop changed state/events: user=%+v events=%+v", store.users[user.ID], store.events)
	}
}

func TestSetUserStatusReplaysCommandResult(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusPending)
	meta := store.seedOperatorMeta("set-user-status-replay", accessActionSetUserStatus, accessResourceUser)
	meta.ExpectedVersion = ptrInt64(user.Version)

	updated, err := svc.SetUserStatus(ctx, SetUserStatusInput{UserID: user.ID, Status: enum.UserStatusActive, Meta: meta})
	if err != nil {
		t.Fatalf("set user status: %v", err)
	}
	replayed, err := svc.SetUserStatus(ctx, SetUserStatusInput{UserID: user.ID, Status: enum.UserStatusBlocked, Meta: meta})
	if err != nil {
		t.Fatalf("replay user status command: %v", err)
	}
	if replayed.ID != updated.ID || replayed.Status != updated.Status || len(store.events) != 1 {
		t.Fatalf("replay = %+v events=%+v, want original updated user and one mutation event", replayed, store.events)
	}
}

func TestDisableAllowlistEntryUpdatesStatusAndKeepsIdentity(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	entry, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "owner@example.com", DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("put allowlist entry: %v", err)
	}
	meta := store.seedOperatorMeta("disable-allowlist", accessActionDisableAllowlistEntry, accessResourceAllowlistEntry)
	meta.ExpectedVersion = ptrInt64(entry.Version)

	disabled, err := svc.DisableAllowlistEntry(ctx, DisableAllowlistEntryInput{
		AllowlistEntryID: entry.ID,
		Meta:             meta,
	})
	if err != nil {
		t.Fatalf("disable allowlist entry: %v", err)
	}
	if disabled.ID != entry.ID || disabled.Status != enum.AllowlistStatusDisabled || disabled.Version != entry.Version+1 {
		t.Fatalf("disabled entry = %+v, want same id disabled version %d", disabled, entry.Version+1)
	}
	if got := store.allowlist[allowlistKey(entry.MatchType, entry.Value)].Status; got != enum.AllowlistStatusDisabled {
		t.Fatalf("stored status = %s, want disabled", got)
	}
	if len(store.events) != 2 || store.events[1].EventType != accessEventAllowlistEntryDisabled {
		t.Fatalf("events = %+v, want disabled event after create", store.events)
	}
}

func TestDisableAllowlistEntryUsesOrganizationScope(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	organizationID := uuid.New()
	entry, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType:      enum.AllowlistMatchDomain,
		Value:          "example.com",
		OrganizationID: &organizationID,
		DefaultStatus:  enum.UserStatusPending,
	})
	if err != nil {
		t.Fatalf("put allowlist entry: %v", err)
	}
	scope := value.ScopeRef{Type: accessRuleScopeOrganization, ID: organizationID.String()}
	meta := store.seedOperatorMetaForScope("disable-allowlist-org", accessActionDisableAllowlistEntry, accessResourceAllowlistEntry, scope)
	meta.ExpectedVersion = ptrInt64(entry.Version)

	disabled, err := svc.DisableAllowlistEntry(ctx, DisableAllowlistEntryInput{
		AllowlistEntryID: entry.ID,
		Meta:             meta,
	})
	if err != nil {
		t.Fatalf("disable allowlist entry in organization scope: %v", err)
	}
	if disabled.Status != enum.AllowlistStatusDisabled {
		t.Fatalf("status = %s, want disabled", disabled.Status)
	}
	gotScope, ok := lastAuditScope(store)
	if !ok || gotScope != scope {
		t.Fatalf("last audit scope = %+v, want %+v", gotScope, scope)
	}
}

func TestDisableAllowlistEntrySameTargetWithStaleVersionConflicts(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	entry, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType: enum.AllowlistMatchEmail, Value: "owner@example.com", DefaultStatus: enum.UserStatusActive,
	})
	if err != nil {
		t.Fatalf("put allowlist entry: %v", err)
	}
	meta := store.seedOperatorMeta("disable-allowlist", accessActionDisableAllowlistEntry, accessResourceAllowlistEntry)
	meta.ExpectedVersion = ptrInt64(entry.Version)
	if _, err := svc.DisableAllowlistEntry(ctx, DisableAllowlistEntryInput{AllowlistEntryID: entry.ID, Meta: meta}); err != nil {
		t.Fatalf("disable allowlist entry: %v", err)
	}
	staleMeta := store.seedOperatorMeta("disable-allowlist-stale", accessActionDisableAllowlistEntry, accessResourceAllowlistEntry)
	staleMeta.ExpectedVersion = ptrInt64(entry.Version)

	_, err = svc.DisableAllowlistEntry(ctx, DisableAllowlistEntryInput{AllowlistEntryID: entry.ID, Meta: staleMeta})
	if !errors.Is(err, errs.ErrConflict) {
		t.Fatalf("err = %v, want %v", err, errs.ErrConflict)
	}
}

func TestListPendingAccessReturnsOperatorItems(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusPending)
	blocked := store.seedUser(enum.UserStatusBlocked)
	active := store.seedUser(enum.UserStatusActive)
	meta := store.seedOperatorMeta("list-pending-access", accessActionListPendingAccess, accessResourcePendingAccess)

	result, err := svc.ListPendingAccess(ctx, ListPendingAccessInput{Limit: 10, Meta: meta})
	if err != nil {
		t.Fatalf("list pending access: %v", err)
	}
	seen := make(map[string]string, len(result.Items))
	for _, item := range result.Items {
		seen[item.Subject.ID] = item.Status
	}
	if seen[user.ID.String()] != string(enum.UserStatusPending) || seen[blocked.ID.String()] != string(enum.UserStatusBlocked) {
		t.Fatalf("items = %+v, want pending and blocked users", result.Items)
	}
	if _, ok := seen[active.ID.String()]; ok {
		t.Fatalf("active user appeared in pending access items: %+v", result.Items)
	}
}

func TestListPendingAccessIncludesScopedAllowlistUser(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	organizationID := uuid.New()
	_, err := svc.PutAllowlistEntry(ctx, PutAllowlistEntryInput{
		MatchType:      enum.AllowlistMatchDomain,
		Value:          "example.com",
		OrganizationID: &organizationID,
		DefaultStatus:  enum.UserStatusPending,
	})
	if err != nil {
		t.Fatalf("put allowlist: %v", err)
	}
	pending := store.seedUser(enum.UserStatusPending)
	pending.PrimaryEmail = "newcomer@example.com"
	store.users[pending.ID] = pending
	scope := value.ScopeRef{Type: accessRuleScopeOrganization, ID: organizationID.String()}
	meta := store.seedOperatorMetaForScope("list-pending-org", accessActionListPendingAccess, accessResourcePendingAccess, scope)

	result, err := svc.ListPendingAccess(ctx, ListPendingAccessInput{Scope: scope, Limit: 10, Meta: meta})
	if err != nil {
		t.Fatalf("list pending access in organization scope: %v", err)
	}
	if len(result.Items) != 1 || result.Items[0].ItemID != pending.ID.String() {
		t.Fatalf("items = %+v, want pending allowlist user %s", result.Items, pending.ID)
	}
}

func TestListPendingAccessMapsExternalAccountReasonsToContractStatus(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	now := fixedClock{}.Now()
	account := entity.ExternalAccount{
		Base:           entity.Base{ID: store.ids.New(), Version: 1, CreatedAt: now, UpdatedAt: now},
		AccountType:    enum.ExternalAccountBot,
		DisplayName:    "github-bot",
		OwnerScopeType: enum.ExternalAccountScopeGlobal,
		Status:         enum.ExternalAccountStatusNeedsReauth,
	}
	store.accounts[account.ID] = account
	meta := store.seedOperatorMeta("list-pending-external", accessActionListPendingAccess, accessResourcePendingAccess)

	result, err := svc.ListPendingAccess(ctx, ListPendingAccessInput{Limit: 10, Meta: meta})
	if err != nil {
		t.Fatalf("list pending access: %v", err)
	}
	seen := make(map[string]entity.PendingAccessItem, len(result.Items))
	for _, item := range result.Items {
		seen[item.ItemID] = item
	}
	item, ok := seen[account.ID.String()]
	if !ok || item.Status != string(enum.UserStatusPending) || item.ReasonCode != "external_account_needs_reauth" {
		t.Fatalf("external pending item = %+v, want pending with needs_reauth reason", item)
	}
}

func TestCheckAccessExplicitDenyWins(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "project", ScopeID: "project-1",
		Priority: 10,
	})
	if err != nil {
		t.Fatalf("put allow rule: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectDeny, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "project", ScopeID: "project-1",
		Priority: 1,
	})
	if err != nil {
		t.Fatalf("put deny rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionDeny {
		t.Fatalf("decision = %s, want %s", result.Decision, enum.AccessDecisionDeny)
	}
	if result.ReasonCode != reasonExplicitDeny {
		t.Fatalf("reason = %s, want %s", result.ReasonCode, reasonExplicitDeny)
	}
	if len(store.audits) != 1 {
		t.Fatalf("audit count = %d, want 1", len(store.audits))
	}
}

func TestPutAccessRuleAllowsServiceSubjectAndCheckAccess(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.repository.attach", DisplayName: "Привязать репозиторий", ResourceType: "repository",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	rule, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectService,
		SubjectID:    "onboarding-runner",
		ActionKey:    action.Key,
		ResourceType: "repository",
		ScopeType:    "global",
	})
	if err != nil {
		t.Fatalf("put service rule: %v", err)
	}
	updated, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectType(" service "),
		SubjectID:    "onboarding-runner",
		ActionKey:    action.Key,
		ResourceType: "repository",
		ScopeType:    "global",
		Priority:     20,
		Meta:         value.CommandMeta{ExpectedVersion: ptrInt64(rule.Version)},
	})
	if err != nil {
		t.Fatalf("update service rule: %v", err)
	}
	if updated.ID != rule.ID || updated.Version != rule.Version+1 {
		t.Fatalf("updated rule = %s/%d, want %s/%d", updated.ID, updated.Version, rule.ID, rule.Version+1)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectService), ID: "onboarding-runner"},
		ActionKey: action.Key,
		Resource:  value.ResourceRef{Type: "repository", ID: "codex-k8s/kodex"},
		Scope:     value.ScopeRef{Type: "repository", ID: "codex-k8s/kodex"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionAllow || result.ReasonCode != reasonExplicitAllow {
		t.Fatalf("decision = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionAllow, reasonExplicitAllow)
	}
	if len(result.Explanation.MatchedRules) != 1 {
		t.Fatalf("matched rules = %d, want 1", len(result.Explanation.MatchedRules))
	}
	matched := result.Explanation.MatchedRules[0]
	if matched.Subject.Type != string(enum.AccessSubjectService) || matched.Subject.ID != "onboarding-runner" {
		t.Fatalf("matched subject = %+v, want service onboarding-runner", matched.Subject)
	}
}

func TestCheckAccessServiceSubjectExplicitDeny(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.deploy", DisplayName: "Запустить deploy", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectService,
		SubjectID:    "agent-manager",
		ActionKey:    action.Key,
		ResourceType: "project",
		ScopeType:    "global",
		Priority:     1,
	})
	if err != nil {
		t.Fatalf("put allow rule: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectDeny,
		SubjectType:  enum.AccessSubjectService,
		SubjectID:    "agent-manager",
		ActionKey:    action.Key,
		ResourceType: "project",
		ScopeType:    "global",
		Priority:     5,
	})
	if err != nil {
		t.Fatalf("put deny rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectService), ID: "agent-manager"},
		ActionKey: action.Key,
		Resource:  value.ResourceRef{Type: "project", ID: "self-deploy"},
		Scope:     value.ScopeRef{Type: "project", ID: "self-deploy"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionDeny || result.ReasonCode != reasonExplicitDeny {
		t.Fatalf("decision = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionDeny, reasonExplicitDeny)
	}
}

func TestPutAccessRuleRejectsUnknownSubjectType(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectType("robot"),
		SubjectID:    "robot-1",
		ActionKey:    action.Key,
		ResourceType: "project",
		ScopeType:    "global",
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestPutExternalProviderCreateOnlyRejectsMutation(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	_, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub", CreateOnly: true,
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	_, err = svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub Enterprise", CreateOnly: true,
	})
	if !errors.Is(err, errs.ErrAlreadyExists) {
		t.Fatalf("err = %v, want %v", err, errs.ErrAlreadyExists)
	}
}

func TestCheckAccessRejectsBlankRequiredInput(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.CheckAccess(context.Background(), CheckAccessInput{
		Subject:  value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: uuid.New().String()},
		Resource: value.ResourceRef{Type: "project", ID: "project-1"},
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}

	_, err = svc.CheckAccess(context.Background(), CheckAccessInput{
		ActionKey: "test.project.read",
		Resource:  value.ResourceRef{Type: "project", ID: "project-1"},
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("subject err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestSystemAccessActionCatalogProvidesCodeOwnedProjectActions(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	_, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: "project.read", ResourceType: "project", ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("put rule for system action: %v", err)
	}
	if _, ok := store.actions["project.read"]; ok {
		t.Fatal("system action was persisted in custom action catalog")
	}
}

func TestSystemAccessActionCatalogProvidesCodeOwnedPackageActions(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	_, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectUser,
		SubjectID:    user.ID.String(),
		ActionKey:    accesscatalog.ActionPackageInstall,
		ResourceType: accesscatalog.ResourcePackageInstallation,
		ScopeType:    "global",
	})
	if err != nil {
		t.Fatalf("put rule for package system action: %v", err)
	}
	if _, ok := store.actions[accesscatalog.ActionPackageInstall]; ok {
		t.Fatal("package system action was persisted in custom action catalog")
	}
}

func TestSystemAccessActionCatalogProvidesRuntimeActions(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	_, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectUser,
		SubjectID:    user.ID.String(),
		ActionKey:    accesscatalog.ActionRuntimeWorkspaceMaterializationStart,
		ResourceType: accesscatalog.ResourceRuntimeWorkspace,
		ScopeType:    "global",
	})
	if err != nil {
		t.Fatalf("put rule for runtime system action: %v", err)
	}
	if _, ok := store.actions[accesscatalog.ActionRuntimeWorkspaceMaterializationStart]; ok {
		t.Fatal("runtime system action was persisted in custom action catalog")
	}
}

func TestPutAccessActionRejectsSystemActionMutation(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())

	_, err := svc.PutAccessAction(context.Background(), PutAccessActionInput{
		Key: "project.read", DisplayName: "Переопределить", ResourceType: "project",
	})
	if !errors.Is(err, errs.ErrPreconditionFailed) {
		t.Fatalf("err = %v, want %v", err, errs.ErrPreconditionFailed)
	}

	_, err = svc.PutAccessAction(context.Background(), PutAccessActionInput{
		Key:          accesscatalog.ActionPackageInstall,
		DisplayName:  "Переопределить",
		ResourceType: accesscatalog.ResourcePackageInstallation,
	})
	if !errors.Is(err, errs.ErrPreconditionFailed) {
		t.Fatalf("package err = %v, want %v", err, errs.ErrPreconditionFailed)
	}

	_, err = svc.PutAccessAction(context.Background(), PutAccessActionInput{
		Key:          accesscatalog.ActionRuntimeSlotReserve,
		DisplayName:  "Переопределить",
		ResourceType: accesscatalog.ResourceRuntimeSlot,
	})
	if !errors.Is(err, errs.ErrPreconditionFailed) {
		t.Fatalf("runtime err = %v, want %v", err, errs.ErrPreconditionFailed)
	}
}

func TestCheckAccessResolvesTransitiveMembershipGraph(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-client-org"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	group, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "dev", DisplayName: "Разработчики",
		Meta: commandMeta("create-dev-group"),
	})
	if err != nil {
		t.Fatalf("create group: %v", err)
	}
	_, err = svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
	})
	if err != nil {
		t.Fatalf("set user group membership: %v", err)
	}
	_, err = svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectGroup, SubjectID: group.ID,
		TargetType: enum.MembershipTargetOrganization, TargetID: org.ID,
	})
	if err != nil {
		t.Fatalf("set group organization membership: %v", err)
	}
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectOrganization, SubjectID: org.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "project", ScopeID: "project-1",
	})
	if err != nil {
		t.Fatalf("put rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionAllow {
		t.Fatalf("decision = %s, want %s", result.Decision, enum.AccessDecisionAllow)
	}
}

func TestListMembershipGraphReturnsActiveAndOptionalInactiveEdges(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-client-org"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	group, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "dev", DisplayName: "Разработчики",
		Meta: commandMeta("create-dev-group"),
	})
	if err != nil {
		t.Fatalf("create group: %v", err)
	}
	activeUserGroup, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
		Source: enum.MembershipSourceManual,
	})
	if err != nil {
		t.Fatalf("set active user group membership: %v", err)
	}
	activeGroupOrg, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectGroup, SubjectID: group.ID,
		TargetType: enum.MembershipTargetOrganization, TargetID: org.ID,
		Source: enum.MembershipSourceManual,
	})
	if err != nil {
		t.Fatalf("set active group organization membership: %v", err)
	}
	pendingUserOrg, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetOrganization, TargetID: org.ID,
		Status: enum.MembershipStatusPending,
		Source: enum.MembershipSourceSync,
	})
	if err != nil {
		t.Fatalf("set pending user organization membership: %v", err)
	}
	meta := store.seedOperatorMetaForScope("list-membership-graph", accessActionListMembershipGraph, accessResourceMembershipGraph, value.ScopeRef{
		Type: accessRuleScopeOrganization,
		ID:   org.ID.String(),
	})

	activeOnly, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject: value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		Meta:    meta,
	})
	if err != nil {
		t.Fatalf("list active graph: %v", err)
	}
	assertMembershipIDs(t, activeOnly.Edges, activeUserGroup.ID, activeGroupOrg.ID)

	withInactive, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject:         value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		IncludeInactive: true,
		Meta:            meta,
	})
	if err != nil {
		t.Fatalf("list graph with inactive: %v", err)
	}
	assertMembershipIDs(t, withInactive.Edges, activeUserGroup.ID, pendingUserOrg.ID, activeGroupOrg.ID)
	if withInactive.Root.ID != user.ID.String() || withInactive.Root.Type != string(enum.AccessSubjectUser) {
		t.Fatalf("root = %+v, want user %s", withInactive.Root, user.ID)
	}
}

func TestListMembershipGraphAuthorizesInactiveMembershipScopesThroughGroups(t *testing.T) {
	t.Run("pending user group", func(t *testing.T) {
		ctx := context.Background()
		store := newMemoryRepository()
		svc := New(store, fixedClock{}, newSequenceIDs())

		user := store.seedUser(enum.UserStatusActive)
		org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
			Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
			Meta: commandMeta("create-pending-group-scope-org"),
		})
		if err != nil {
			t.Fatalf("create organization: %v", err)
		}
		group, err := svc.CreateGroup(ctx, CreateGroupInput{
			ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "dev", DisplayName: "Разработчики",
			Meta: commandMeta("create-pending-group-scope"),
		})
		if err != nil {
			t.Fatalf("create group: %v", err)
		}
		pendingUserGroup, err := svc.SetMembership(ctx, SetMembershipInput{
			SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
			TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
			Status: enum.MembershipStatusPending, Source: enum.MembershipSourceSync,
		})
		if err != nil {
			t.Fatalf("set pending user group membership: %v", err)
		}
		meta := store.seedOperatorMetaForScope("list-pending-group-scope", accessActionListMembershipGraph, accessResourceMembershipGraph, value.ScopeRef{
			Type: accessRuleScopeOrganization,
			ID:   org.ID.String(),
		})

		graph, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
			Subject:         value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
			IncludeInactive: true,
			Meta:            meta,
		})
		if err != nil {
			t.Fatalf("list graph with pending group scope: %v", err)
		}
		assertMembershipIDs(t, graph.Edges, pendingUserGroup.ID)
	})

	t.Run("blocked group organization", func(t *testing.T) {
		ctx := context.Background()
		store := newMemoryRepository()
		svc := New(store, fixedClock{}, newSequenceIDs())

		user := store.seedUser(enum.UserStatusActive)
		org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
			Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
			Meta: commandMeta("create-blocked-group-org"),
		})
		if err != nil {
			t.Fatalf("create organization: %v", err)
		}
		group, err := svc.CreateGroup(ctx, CreateGroupInput{
			ScopeType: enum.GroupScopeGlobal, Slug: "release-operators", DisplayName: "Релизные операторы",
			Meta: commandMeta("create-global-blocked-group"),
		})
		if err != nil {
			t.Fatalf("create group: %v", err)
		}
		activeUserGroup, err := svc.SetMembership(ctx, SetMembershipInput{
			SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
			TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
			Source: enum.MembershipSourceManual,
		})
		if err != nil {
			t.Fatalf("set active user group membership: %v", err)
		}
		blockedGroupOrg, err := svc.SetMembership(ctx, SetMembershipInput{
			SubjectType: enum.MembershipSubjectGroup, SubjectID: group.ID,
			TargetType: enum.MembershipTargetOrganization, TargetID: org.ID,
			Status: enum.MembershipStatusBlocked, Source: enum.MembershipSourceSync,
		})
		if err != nil {
			t.Fatalf("set blocked group organization membership: %v", err)
		}
		meta := store.seedOperatorMetaForScope("list-blocked-group-org", accessActionListMembershipGraph, accessResourceMembershipGraph, value.ScopeRef{
			Type: accessRuleScopeOrganization,
			ID:   org.ID.String(),
		})

		graph, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
			Subject:         value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
			IncludeInactive: true,
			Meta:            meta,
		})
		if err != nil {
			t.Fatalf("list graph with blocked group organization: %v", err)
		}
		assertMembershipIDs(t, graph.Edges, activeUserGroup.ID, blockedGroupOrg.ID)
	})
}

func TestListMembershipGraphReadsOrganizationTargetSide(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-target-org"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	group, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "dev", DisplayName: "Разработчики",
		Meta: commandMeta("create-target-group"),
	})
	if err != nil {
		t.Fatalf("create group: %v", err)
	}
	userGroup, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
		Source: enum.MembershipSourceManual,
	})
	if err != nil {
		t.Fatalf("set user group membership: %v", err)
	}
	groupOrg, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectGroup, SubjectID: group.ID,
		TargetType: enum.MembershipTargetOrganization, TargetID: org.ID,
		Source: enum.MembershipSourceManual,
	})
	if err != nil {
		t.Fatalf("set group organization membership: %v", err)
	}
	meta := store.seedOperatorMetaForScope("list-organization-membership-graph", accessActionListMembershipGraph, accessResourceMembershipGraph, value.ScopeRef{
		Type: accessRuleScopeOrganization,
		ID:   org.ID.String(),
	})

	graph, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject: value.SubjectRef{Type: string(enum.AccessSubjectOrganization), ID: org.ID.String()},
		Meta:    meta,
	})
	if err != nil {
		t.Fatalf("list organization graph: %v", err)
	}
	assertMembershipIDs(t, graph.Edges, userGroup.ID, groupOrg.ID)
}

func TestListMembershipGraphIncludesParentGroupMembershipEdges(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-parent-org"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	parent, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "owners", DisplayName: "Владельцы",
		Meta: commandMeta("create-parent-list-group"),
	})
	if err != nil {
		t.Fatalf("create parent group: %v", err)
	}
	child, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "reviewers", DisplayName: "Ревьюеры",
		ParentGroupID: &parent.ID, Meta: commandMeta("create-child-list-group"),
	})
	if err != nil {
		t.Fatalf("create child group: %v", err)
	}
	userChild, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: child.ID,
		Source: enum.MembershipSourceManual,
	})
	if err != nil {
		t.Fatalf("set user child membership: %v", err)
	}
	parentOrg, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectGroup, SubjectID: parent.ID,
		TargetType: enum.MembershipTargetOrganization, TargetID: org.ID,
		Source: enum.MembershipSourceManual,
	})
	if err != nil {
		t.Fatalf("set parent organization membership: %v", err)
	}
	meta := store.seedOperatorMetaForScope("list-parent-membership-graph", accessActionListMembershipGraph, accessResourceMembershipGraph, value.ScopeRef{
		Type: accessRuleScopeOrganization,
		ID:   org.ID.String(),
	})

	graph, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject: value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		Meta:    meta,
	})
	if err != nil {
		t.Fatalf("list parent graph: %v", err)
	}
	assertMembershipIDs(t, graph.Edges, userChild.ID, parentOrg.ID)
}

func TestListMembershipGraphUsesRawExternalAccountOwnerScope(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	owner := store.seedUser(enum.UserStatusActive)
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID,
		AccountType:        enum.ExternalAccountBot,
		DisplayName:        "bot",
		OwnerScopeType:     enum.ExternalAccountScopeUser,
		OwnerScopeID:       owner.ID.String(),
		Status:             enum.ExternalAccountStatusActive,
		Meta:               commandMeta("register-user-owned-account"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	meta := store.seedOperatorMeta("list-user-owned-account-graph", accessActionListMembershipGraph, accessResourceMembershipGraph)

	_, err = svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject: value.SubjectRef{Type: string(enum.AccessSubjectExternalAccount), ID: account.ID.String()},
		Meta:    meta,
	})
	if err != nil {
		t.Fatalf("list external account graph: %v", err)
	}
	scope, ok := lastAuditScope(store)
	if !ok || scope != (value.ScopeRef{Type: string(enum.ExternalAccountScopeUser), ID: owner.ID.String()}) {
		t.Fatalf("audit scope = %+v, want user owner scope", scope)
	}
}

func TestListMembershipGraphRequiresAuthorizedActor(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	_, err := svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject: value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}

	operator := store.seedUser(enum.UserStatusActive)
	_, err = svc.ListMembershipGraph(ctx, ListMembershipGraphInput{
		Subject: value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		Meta:    value.CommandMeta{Actor: value.Actor{Type: string(enum.AccessSubjectUser), ID: operator.ID.String()}},
	})
	if !errors.Is(err, errs.ErrForbidden) {
		t.Fatalf("err = %v, want %v", err, errs.ErrForbidden)
	}
}

func TestCheckAccessDeniesNonActiveExternalAccountSubject(t *testing.T) {
	statuses := []enum.ExternalAccountStatus{
		enum.ExternalAccountStatusPending,
		enum.ExternalAccountStatusNeedsReauth,
		enum.ExternalAccountStatusLimited,
		enum.ExternalAccountStatusBlocked,
		enum.ExternalAccountStatusDisabled,
	}
	for _, status := range statuses {
		t.Run(string(status), func(t *testing.T) {
			ctx := context.Background()
			store := newMemoryRepository()
			svc := New(store, fixedClock{}, newSequenceIDs())

			provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
				Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
			})
			if err != nil {
				t.Fatalf("put provider: %v", err)
			}
			account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
				ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "bot",
				Status: status, Meta: commandMeta("register-account-" + string(status)),
			})
			if err != nil {
				t.Fatalf("register account: %v", err)
			}
			action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
				Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
			})
			if err != nil {
				t.Fatalf("put action: %v", err)
			}
			_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
				Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectExternalAccount, SubjectID: account.ID.String(),
				ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "global",
			})
			if err != nil {
				t.Fatalf("put rule: %v", err)
			}

			result, err := svc.CheckAccess(ctx, CheckAccessInput{
				Subject:   value.SubjectRef{Type: string(enum.AccessSubjectExternalAccount), ID: account.ID.String()},
				ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
				Scope: value.ScopeRef{Type: "project", ID: "project-1"},
			})
			if err != nil {
				t.Fatalf("check access: %v", err)
			}
			if result.Decision != enum.AccessDecisionDeny || result.ReasonCode != reasonSubjectBlocked {
				t.Fatalf("decision/reason = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionDeny, reasonSubjectBlocked)
			}
		})
	}
}

func TestCheckAccessSkipsInactiveMembershipTarget(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	user := store.seedUser(enum.UserStatusActive)
	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-client-org-for-inactive-target"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	group, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "ops", DisplayName: "Операторы",
		Meta: commandMeta("create-ops-group"),
	})
	if err != nil {
		t.Fatalf("create group: %v", err)
	}
	_, err = svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
	})
	if err != nil {
		t.Fatalf("set membership: %v", err)
	}
	group.Status = enum.GroupStatusDisabled
	store.groups[group.ID] = group

	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectGroup, SubjectID: group.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("put rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionDeny || result.ReasonCode != reasonNoMatchingRule {
		t.Fatalf("decision/reason = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionDeny, reasonNoMatchingRule)
	}
}

func TestCheckAccessResolvesRootGroupParents(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-client-org-for-root-groups"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	parent, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "owners", DisplayName: "Владельцы",
		Meta: commandMeta("create-owners-group"),
	})
	if err != nil {
		t.Fatalf("create parent group: %v", err)
	}
	child, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "reviewers", DisplayName: "Ревьюеры",
		ParentGroupID: &parent.ID, Meta: commandMeta("create-reviewers-group"),
	})
	if err != nil {
		t.Fatalf("create child group: %v", err)
	}
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectGroup, SubjectID: parent.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("put rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectGroup), ID: child.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionAllow {
		t.Fatalf("decision = %s, want %s", result.Decision, enum.AccessDecisionAllow)
	}
}

func TestSetMembershipUpdatesExistingIdentityAndVersion(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)
	org, err := svc.CreateOrganization(ctx, CreateOrganizationInput{
		Kind: enum.OrganizationKindClient, Slug: "client", DisplayName: "Клиент",
		Meta: commandMeta("create-client-org-for-membership"),
	})
	if err != nil {
		t.Fatalf("create organization: %v", err)
	}
	group, err := svc.CreateGroup(ctx, CreateGroupInput{
		ScopeType: enum.GroupScopeOrganization, ScopeID: &org.ID, Slug: "qa", DisplayName: "QA",
		Meta: commandMeta("create-qa-group"),
	})
	if err != nil {
		t.Fatalf("create group: %v", err)
	}

	created, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
	})
	if err != nil {
		t.Fatalf("create membership: %v", err)
	}
	updated, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
		RoleHint: "owner",
	})
	if err != nil {
		t.Fatalf("update membership: %v", err)
	}
	if updated.ID != created.ID {
		t.Fatalf("membership ID changed: %s != %s", updated.ID, created.ID)
	}
	if updated.Version != created.Version+1 {
		t.Fatalf("version = %d, want %d", updated.Version, created.Version+1)
	}
	_, err = svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: group.ID,
		Meta: value.CommandMeta{
			ExpectedVersion: ptrInt64(created.Version),
		},
	})
	if !errors.Is(err, errs.ErrConflict) {
		t.Fatalf("err = %v, want %v", err, errs.ErrConflict)
	}
}

func TestSetMembershipRequiresExistingEndpoints(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	_, err := svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: user.ID,
		TargetType: enum.MembershipTargetGroup, TargetID: uuid.New(),
	})
	if !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("target err = %v, want %v", err, errs.ErrNotFound)
	}
	_, err = svc.SetMembership(ctx, SetMembershipInput{
		SubjectType: enum.MembershipSubjectUser, SubjectID: uuid.New(),
		TargetType: enum.MembershipTargetGroup, TargetID: uuid.New(),
	})
	if !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("subject err = %v, want %v", err, errs.ErrNotFound)
	}
}

func TestResolveExternalAccountUsageRequiresAllowedActionAndSecret(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	secret := store.seedSecret(enum.SecretStoreVault, "kv/kodex/github/bot")
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		OwnerScopeType: enum.ExternalAccountScopeGlobal, Status: enum.ExternalAccountStatusActive, SecretBindingRefID: &secret.ID,
		Meta: commandMeta("register-github-bot-with-secret"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	_, err = svc.BindExternalAccount(ctx, BindExternalAccountInput{
		ExternalAccountID: account.ID, UsageScopeType: enum.ExternalAccountScopeProject, UsageScopeID: "project-1",
		AllowedActionKeys: []string{accesscatalog.ActionProviderIssueWrite}, Status: enum.ExternalAccountBindingStatusActive,
	})
	if err != nil {
		t.Fatalf("bind account: %v", err)
	}

	result, err := svc.ResolveExternalAccountUsage(ctx, ResolveExternalAccountUsageInput{
		ExternalAccountID: account.ID, ActionKey: accesscatalog.ActionProviderIssueWrite,
		UsageScope: value.ScopeRef{Type: string(enum.ExternalAccountScopeProject), ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("resolve usage: %v", err)
	}
	if result.SecretRef.StoreRef != secret.StoreRef {
		t.Fatalf("secret ref = %s, want %s", result.SecretRef.StoreRef, secret.StoreRef)
	}
	if result.ExternalProvider.Slug != "github" {
		t.Fatalf("provider slug = %s, want github", result.ExternalProvider.Slug)
	}
}

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

	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.ResolveExternalAccountUsage(context.Background(), ResolveExternalAccountUsageInput{
		ExternalAccountID: uuid.Nil,
		ActionKey:         accesscatalog.ActionProviderIssueWrite,
		UsageScope:        value.ScopeRef{Type: string(enum.ExternalAccountScopeProject), ID: "project-1"},
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("resolve err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestListPackageInstallationSecretRefsReturnsValueFreeRefs(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	installationID := uuid.New()
	scope := value.ScopeRef{Type: accessRuleScopeProject, ID: "project-1"}
	secret := store.seedSecret(enum.SecretStoreVault, "secret/package/telegram#token")
	store.seedPackageInstallationSecretRef(installationID, scope, "telegram_token", secret)
	meta := store.seedOperatorMetaForScope(
		"package-secret-refs-read",
		accesscatalog.ActionPackageInstallationSecretRefRead,
		accesscatalog.ResourcePackageInstallationSecretRef,
		scope,
	)

	result, err := svc.ListPackageInstallationSecretRefs(ctx, ListPackageInstallationSecretRefsInput{
		PackageInstallationID: installationID,
		InstallationScope:     scope,
		LogicalKeys:           []string{"telegram_token"},
		Meta:                  meta,
	})
	if err != nil {
		t.Fatalf("list package secret refs: %v", err)
	}
	if len(result.SecretRefs) != 1 {
		t.Fatalf("refs = %d, want 1", len(result.SecretRefs))
	}
	ref := result.SecretRefs[0]
	if ref.LogicalKey != "telegram_token" || ref.Status != enum.PackageInstallationSecretRefStatusConfigured {
		t.Fatalf("ref = %+v, want configured telegram_token", ref)
	}
	if ref.SecretRef.StoreType != enum.SecretStoreVault || ref.SecretRef.StoreRef != secret.StoreRef {
		t.Fatalf("secret ref = %+v, want safe vault ref %s", ref.SecretRef, secret.StoreRef)
	}
	if strings.Contains(ref.SecretRef.StoreRef, "raw-secret-value") {
		t.Fatalf("secret ref leaks value: %s", ref.SecretRef.StoreRef)
	}
}

func TestListPackageInstallationSecretRefsRejectsForeignScope(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	installationID := uuid.New()
	allowedScope := value.ScopeRef{Type: accessRuleScopeProject, ID: "project-1"}
	requestedScope := value.ScopeRef{Type: accessRuleScopeProject, ID: "project-2"}
	meta := store.seedOperatorMetaForScope(
		"package-secret-refs-foreign-scope",
		accesscatalog.ActionPackageInstallationSecretRefRead,
		accesscatalog.ResourcePackageInstallationSecretRef,
		allowedScope,
	)

	_, err := svc.ListPackageInstallationSecretRefs(ctx, ListPackageInstallationSecretRefsInput{
		PackageInstallationID: installationID,
		InstallationScope:     requestedScope,
		LogicalKeys:           []string{"telegram_token"},
		Meta:                  meta,
	})
	if !errors.Is(err, errs.ErrForbidden) {
		t.Fatalf("err = %v, want %v", err, errs.ErrForbidden)
	}
}

func TestListPackageInstallationSecretRefsReportsMissingKeys(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	installationID := uuid.New()
	scope := value.ScopeRef{Type: accessRuleScopeOrganization, ID: uuid.NewString()}
	secret := store.seedSecret(enum.SecretStoreEnv, "TELEGRAM_TOKEN")
	store.seedPackageInstallationSecretRef(installationID, scope, "telegram_token", secret)
	meta := store.seedOperatorMetaForScope(
		"package-secret-refs-incomplete",
		accesscatalog.ActionPackageInstallationSecretRefRead,
		accesscatalog.ResourcePackageInstallationSecretRef,
		scope,
	)

	result, err := svc.ListPackageInstallationSecretRefs(ctx, ListPackageInstallationSecretRefsInput{
		PackageInstallationID: installationID,
		InstallationScope:     scope,
		LogicalKeys:           []string{"telegram_token", "webhook_secret"},
		Meta:                  meta,
	})
	if err != nil {
		t.Fatalf("list package secret refs: %v", err)
	}
	if len(result.SecretRefs) != 2 {
		t.Fatalf("refs = %d, want 2", len(result.SecretRefs))
	}
	statuses := map[string]enum.PackageInstallationSecretRefStatus{}
	for _, ref := range result.SecretRefs {
		statuses[ref.LogicalKey] = ref.Status
	}
	if statuses["telegram_token"] != enum.PackageInstallationSecretRefStatusConfigured {
		t.Fatalf("telegram_token status = %s, want configured", statuses["telegram_token"])
	}
	if statuses["webhook_secret"] != enum.PackageInstallationSecretRefStatusMissing {
		t.Fatalf("webhook_secret status = %s, want missing", statuses["webhook_secret"])
	}
}

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

	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.ListPackageInstallationSecretRefs(context.Background(), ListPackageInstallationSecretRefsInput{
		PackageInstallationID: uuid.New(),
		InstallationScope:     value.ScopeRef{Type: accessRuleScopeProject, ID: "project-1"},
		LogicalKeys:           []string{"   "},
		Meta:                  commandMeta("blank-logical-key"),
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestBindExternalAccountRejectsBlankAllowedActions(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.BindExternalAccount(context.Background(), BindExternalAccountInput{
		ExternalAccountID: uuid.New(),
		UsageScopeType:    enum.ExternalAccountScopeProject,
		UsageScopeID:      "project-1",
		AllowedActionKeys: []string{
			"   ",
		},
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestBindExternalAccountRequiresCatalogAction(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		Meta: commandMeta("register-github-bot-for-binding-action"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	_, err = svc.BindExternalAccount(ctx, BindExternalAccountInput{
		ExternalAccountID: account.ID, UsageScopeType: enum.ExternalAccountScopeProject, UsageScopeID: "project-1",
		AllowedActionKeys: []string{"provider.issue.wirte"},
	})
	if !errors.Is(err, errs.ErrNotFound) {
		t.Fatalf("err = %v, want %v", err, errs.ErrNotFound)
	}
}

func TestBindExternalAccountKeepsIdentityOnUpdate(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		Meta: commandMeta("register-github-bot-for-binding-update"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	created, err := svc.BindExternalAccount(ctx, BindExternalAccountInput{
		ExternalAccountID: account.ID, UsageScopeType: enum.ExternalAccountScopeProject, UsageScopeID: "project-1",
		AllowedActionKeys: []string{accesscatalog.ActionProviderIssueWrite},
	})
	if err != nil {
		t.Fatalf("bind account: %v", err)
	}
	updated, err := svc.BindExternalAccount(ctx, BindExternalAccountInput{
		ExternalAccountID: account.ID, UsageScopeType: enum.ExternalAccountScopeProject, UsageScopeID: "project-1",
		AllowedActionKeys: []string{accesscatalog.ActionProviderIssueWrite, accesscatalog.ActionProviderPullRequestWrite},
		Meta:              value.CommandMeta{ExpectedVersion: ptrInt64(created.Version)},
	})
	if err != nil {
		t.Fatalf("update binding: %v", err)
	}
	if updated.ID != created.ID || updated.Version != created.Version+1 {
		t.Fatalf("update identity/version = %s/%d, want %s/%d", updated.ID, updated.Version, created.ID, created.Version+1)
	}
}

func TestBindExternalAccountRejectsDisabledStatus(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		Meta: commandMeta("register-github-bot-for-disabled-binding"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	_, err = svc.BindExternalAccount(ctx, BindExternalAccountInput{
		ExternalAccountID: account.ID, UsageScopeType: enum.ExternalAccountScopeProject, UsageScopeID: "project-1",
		AllowedActionKeys: []string{accesscatalog.ActionProviderIssueWrite}, Status: enum.ExternalAccountBindingStatusDisabled,
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestUpdateExternalProviderDisablesProviderAndRecordsEvent(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	meta := store.seedOperatorMeta("disable-provider", accessActionManageExternalProvider, accessResourceExternalProvider)
	meta.ExpectedVersion = ptrInt64(provider.Version)
	meta.Reason = "provider_offboarding"

	disabled, err := svc.UpdateExternalProvider(ctx, UpdateExternalProviderInput{
		ExternalProviderID: provider.ID,
		Status:             enum.ExternalProviderStatusDisabled,
		Meta:               meta,
	})
	if err != nil {
		t.Fatalf("update provider: %v", err)
	}
	if disabled.Status != enum.ExternalProviderStatusDisabled || disabled.Version != provider.Version+1 {
		t.Fatalf("provider = %+v, want disabled version %d", disabled, provider.Version+1)
	}
	if last := store.events[len(store.events)-1]; last.EventType != accessEventExternalProviderDisabled {
		t.Fatalf("last event = %s, want disabled provider event", last.EventType)
	}
}

func TestUpdateExternalProviderClearsOptionalIcon(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub", IconAssetRef: "icons/github.svg",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	meta := store.seedOperatorMeta("clear-provider-icon", accessActionManageExternalProvider, accessResourceExternalProvider)
	meta.ExpectedVersion = ptrInt64(provider.Version)

	updated, err := svc.UpdateExternalProvider(ctx, UpdateExternalProviderInput{
		ExternalProviderID: provider.ID,
		IconAssetRef:       ptrString(""),
		Meta:               meta,
	})
	if err != nil {
		t.Fatalf("update provider: %v", err)
	}
	if updated.IconAssetRef != "" || updated.Version != provider.Version+1 {
		t.Fatalf("provider = %+v, want cleared icon and incremented version", updated)
	}
}

func TestUpdateExternalAccountStatusUpdatesStatusAndReplaysCommand(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		OwnerScopeType: enum.ExternalAccountScopeProject, OwnerScopeID: "project-1",
		Meta: commandMeta("register-account-for-status"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	scope := value.ScopeRef{Type: string(enum.ExternalAccountScopeProject), ID: "project-1"}
	meta := store.seedOperatorMetaForScope("activate-external-account", accessActionManageExternalAccount, accessResourceExternalAccount, scope)
	meta.ExpectedVersion = ptrInt64(account.Version)

	updated, err := svc.UpdateExternalAccountStatus(ctx, UpdateExternalAccountStatusInput{
		ExternalAccountID: account.ID,
		Status:            enum.ExternalAccountStatusActive,
		Meta:              meta,
	})
	if err != nil {
		t.Fatalf("update account status: %v", err)
	}
	replayed, err := svc.UpdateExternalAccountStatus(ctx, UpdateExternalAccountStatusInput{
		ExternalAccountID: account.ID,
		Status:            enum.ExternalAccountStatusBlocked,
		Meta:              meta,
	})
	if err != nil {
		t.Fatalf("replay account status: %v", err)
	}
	if replayed.ID != updated.ID || replayed.Status != enum.ExternalAccountStatusActive {
		t.Fatalf("replay = %+v, want original active account", replayed)
	}
	if last := store.audits[len(store.audits)-1]; last.Scope != scope {
		t.Fatalf("audit scope = %+v, want %+v", last.Scope, scope)
	}
	if last := store.events[len(store.events)-1]; last.EventType != accessEventExternalAccountStatusChanged {
		t.Fatalf("last event = %s, want status changed event", last.EventType)
	}
}

func TestUpdateExternalAccountStatusRequiresAuthorizedActor(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		Meta: commandMeta("register-account-for-forbidden-status"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	actor := store.seedUser(enum.UserStatusActive)
	meta := commandMeta("activate-external-account-forbidden")
	meta.Actor = value.Actor{Type: string(enum.AccessSubjectUser), ID: actor.ID.String()}
	meta.ExpectedVersion = ptrInt64(account.Version)

	_, err = svc.UpdateExternalAccountStatus(ctx, UpdateExternalAccountStatusInput{
		ExternalAccountID: account.ID,
		Status:            enum.ExternalAccountStatusActive,
		Meta:              meta,
	})
	if !errors.Is(err, errs.ErrForbidden) {
		t.Fatalf("err = %v, want %v", err, errs.ErrForbidden)
	}
}

func TestDisableExternalAccountBindingUpdatesStatusAndKeepsIdentity(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	account, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		Meta: commandMeta("register-account-for-binding-disable"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	binding, err := svc.BindExternalAccount(ctx, BindExternalAccountInput{
		ExternalAccountID: account.ID, UsageScopeType: enum.ExternalAccountScopeProject, UsageScopeID: "project-1",
		AllowedActionKeys: []string{accesscatalog.ActionProviderIssueWrite},
	})
	if err != nil {
		t.Fatalf("bind account: %v", err)
	}
	scope := value.ScopeRef{Type: string(enum.ExternalAccountScopeProject), ID: "project-1"}
	meta := store.seedOperatorMetaForScope("disable-account-binding", accessActionManageExternalAccountBinding, accessResourceExternalAccountBinding, scope)
	meta.ExpectedVersion = ptrInt64(binding.Version)

	disabled, err := svc.DisableExternalAccountBinding(ctx, DisableExternalAccountBindingInput{
		ExternalAccountBindingID: binding.ID,
		Meta:                     meta,
	})
	if err != nil {
		t.Fatalf("disable binding: %v", err)
	}
	if disabled.ID != binding.ID || disabled.Status != enum.ExternalAccountBindingStatusDisabled || disabled.Version != binding.Version+1 {
		t.Fatalf("disabled binding = %+v, want same id disabled version %d", disabled, binding.Version+1)
	}
	if last := store.audits[len(store.audits)-1]; last.Scope != scope {
		t.Fatalf("audit scope = %+v, want %+v", last.Scope, scope)
	}
	if last := store.events[len(store.events)-1]; last.EventType != accessEventExternalAccountBindingDisabled {
		t.Fatalf("last event = %s, want disabled binding event", last.EventType)
	}
}

func TestRegisterExternalAccountReplaysCommandResult(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	created, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "kodex-agent",
		Meta: commandMeta("register-bot-idempotent"),
	})
	if err != nil {
		t.Fatalf("register account: %v", err)
	}
	replayed, err := svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountUser, DisplayName: "another-account",
		Meta: commandMeta("register-bot-idempotent"),
	})
	if err != nil {
		t.Fatalf("replay register account command: %v", err)
	}
	if replayed.ID != created.ID || replayed.Version != created.Version {
		t.Fatalf("replay changed account: id %s/%s version %d/%d", replayed.ID, created.ID, replayed.Version, created.Version)
	}
	if len(store.accounts) != 1 {
		t.Fatalf("accounts = %d, want 1", len(store.accounts))
	}
}

func TestPutAccessRuleRequiresActiveCatalogAction(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project", Status: enum.AccessActionStatusDisabled,
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ScopeType: "global",
	})
	if !errors.Is(err, errs.ErrPreconditionFailed) {
		t.Fatalf("err = %v, want %v", err, errs.ErrPreconditionFailed)
	}
}

func TestPutAccessRuleRequiresCatalogResourceType(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	_, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: "test.project.read", ResourceType: "repository", ScopeType: "global",
	})
	if !errors.Is(err, errs.ErrPreconditionFailed) {
		t.Fatalf("err = %v, want %v", err, errs.ErrPreconditionFailed)
	}
}

func TestRegisterExternalAccountRejectsInvalidOwnerScope(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	provider, err := svc.PutExternalProvider(ctx, PutExternalProviderInput{
		Slug: "github", ProviderKind: enum.ExternalProviderRepository, DisplayName: "GitHub",
	})
	if err != nil {
		t.Fatalf("put provider: %v", err)
	}
	_, err = svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "bad",
		OwnerScopeType: enum.ExternalAccountScopeStage, OwnerScopeID: "stage-1",
		Meta: commandMeta("register-invalid-stage-owner"),
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
	_, err = svc.RegisterExternalAccount(ctx, RegisterExternalAccountInput{
		ExternalProviderID: provider.ID, AccountType: enum.ExternalAccountBot, DisplayName: "bad",
		OwnerScopeType: enum.ExternalAccountScopeProject,
		Meta:           commandMeta("register-invalid-project-owner"),
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("missing scope id err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestPutAccessRuleKeepsIdentityOnUpdate(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	created, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("put rule: %v", err)
	}
	repeated, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("repeat rule: %v", err)
	}
	if repeated.ID != created.ID || repeated.Version != created.Version {
		t.Fatalf("repeat changed identity/version: id %s/%s version %d/%d", repeated.ID, created.ID, repeated.Version, created.Version)
	}
	updated, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ScopeType: "global", Priority: 50,
		Meta: value.CommandMeta{ExpectedVersion: ptrInt64(created.Version)},
	})
	if err != nil {
		t.Fatalf("update rule: %v", err)
	}
	if updated.ID != created.ID || updated.Version != created.Version+1 {
		t.Fatalf("update identity/version = %s/%d, want %s/%d", updated.ID, updated.Version, created.ID, created.Version+1)
	}
}

func TestPutAccessRuleRejectsDisabledStatus(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ScopeType: "global", Status: enum.AccessRuleStatusDisabled,
	})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestCheckAccessUsesGlobalScopeRule(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("put global rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionAllow {
		t.Fatalf("decision = %s, want %s", result.Decision, enum.AccessDecisionAllow)
	}
}

func TestCheckAccessAuditsAllowAndExplainAccessReadsDecision(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}
	rule, err := svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: action.Key, ResourceType: "project", ResourceID: "project-1", ScopeType: "project", ScopeID: "project-1",
		Priority: 5,
	})
	if err != nil {
		t.Fatalf("put rule: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
		Meta:  value.CommandMeta{RequestContext: value.RequestContext{Source: "staff-gateway", TraceID: "trace-1"}},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionAllow || result.ReasonCode != reasonExplicitAllow {
		t.Fatalf("decision/reason = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionAllow, reasonExplicitAllow)
	}
	if result.Explanation.PolicyVersion != rule.Version || len(result.Explanation.MatchedRules) != 1 {
		t.Fatalf("explanation = %+v, want policy version %d and one rule", result.Explanation, rule.Version)
	}
	if len(store.audits) != 1 {
		t.Fatalf("audit count = %d, want 1", len(store.audits))
	}
	auditID := store.audits[0].ID
	if store.audits[0].Scope != (value.ScopeRef{Type: "project", ID: "project-1"}) {
		t.Fatalf("audit scope = %+v, want project/project-1", store.audits[0].Scope)
	}
	if store.audits[0].RequestContext.TraceID != "trace-1" {
		t.Fatalf("audit request trace = %q, want trace-1", store.audits[0].RequestContext.TraceID)
	}
	_, err = svc.PutAccessRule(ctx, PutAccessRuleInput{
		Effect: enum.AccessEffectAllow, SubjectType: enum.AccessSubjectUser, SubjectID: user.ID.String(),
		ActionKey: accessActionExplainAccess, ResourceType: accessResourceAccessDecisionAudit, ScopeType: "global",
	})
	if err != nil {
		t.Fatalf("put explain rule: %v", err)
	}
	explained, err := svc.ExplainAccess(ctx, ExplainAccessInput{
		AuditID: auditID,
		Scope:   value.ScopeRef{Type: "global"},
		Meta:    value.CommandMeta{Actor: value.Actor{Type: string(enum.AccessSubjectUser), ID: user.ID.String()}},
	})
	if err != nil {
		t.Fatalf("explain access: %v", err)
	}
	if explained.Audit.Decision != result.Decision || explained.Audit.ReasonCode != result.ReasonCode {
		t.Fatalf("explained audit = %s/%s, want %s/%s", explained.Audit.Decision, explained.Audit.ReasonCode, result.Decision, result.ReasonCode)
	}
	if len(explained.Audit.Explanation.MatchedRules) != 1 || explained.Audit.Explanation.MatchedRules[0].RuleID != rule.ID {
		t.Fatalf("explained matched rules = %+v, want rule %s", explained.Audit.Explanation.MatchedRules, rule.ID)
	}
}

func TestExplainAccessRejectsEmptyAuditID(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.ExplainAccess(context.Background(), ExplainAccessInput{})
	if !errors.Is(err, errs.ErrInvalidArgument) {
		t.Fatalf("err = %v, want %v", err, errs.ErrInvalidArgument)
	}
}

func TestExplainAccessRequiresAuthorizedActor(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusActive)

	_, err := svc.ExplainAccess(ctx, ExplainAccessInput{
		AuditID: uuid.New(),
		Scope:   value.ScopeRef{Type: "global"},
		Meta:    value.CommandMeta{Actor: value.Actor{Type: string(enum.AccessSubjectUser), ID: user.ID.String()}},
	})
	if !errors.Is(err, errs.ErrForbidden) {
		t.Fatalf("err = %v, want %v", err, errs.ErrForbidden)
	}
	if len(store.audits) != 1 || store.audits[0].ActionKey != accessActionExplainAccess {
		t.Fatalf("authorization audit = %+v, want explain access decision", store.audits)
	}
}

func TestCheckAccessDeniesInvalidActionState(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())

	_, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "project.disabled", DisplayName: "Отключённое действие", ResourceType: "project",
		Status: enum.AccessActionStatusDisabled,
	})
	if err != nil {
		t.Fatalf("put disabled action: %v", err)
	}
	_, err = svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}

	cases := []struct {
		name         string
		actionKey    string
		resourceType string
		wantReason   string
	}{
		{name: "missing action", actionKey: "project.missing", resourceType: "project", wantReason: reasonActionNotFound},
		{name: "disabled action", actionKey: "project.disabled", resourceType: "project", wantReason: reasonActionDisabled},
		{name: "resource mismatch", actionKey: "test.project.read", resourceType: "repository", wantReason: reasonResourceTypeMismatch},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			result, err := svc.CheckAccess(ctx, CheckAccessInput{
				Subject:   value.SubjectRef{Type: string(enum.AccessSubjectAgent), ID: "agent-1"},
				ActionKey: tc.actionKey,
				Resource:  value.ResourceRef{Type: tc.resourceType, ID: "resource-1"},
				Scope:     value.ScopeRef{Type: "project", ID: "project-1"},
			})
			if err != nil {
				t.Fatalf("check access: %v", err)
			}
			if result.Decision != enum.AccessDecisionDeny || result.ReasonCode != tc.wantReason {
				t.Fatalf("decision/reason = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionDeny, tc.wantReason)
			}
		})
	}
}

func TestCheckAccessReturnsPendingForPendingUser(t *testing.T) {
	ctx := context.Background()
	store := newMemoryRepository()
	svc := New(store, fixedClock{}, newSequenceIDs())
	user := store.seedUser(enum.UserStatusPending)
	action, err := svc.PutAccessAction(ctx, PutAccessActionInput{
		Key: "test.project.read", DisplayName: "Чтение проекта", ResourceType: "project",
	})
	if err != nil {
		t.Fatalf("put action: %v", err)
	}

	result, err := svc.CheckAccess(ctx, CheckAccessInput{
		Subject:   value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
		ActionKey: action.Key, Resource: value.ResourceRef{Type: "project", ID: "project-1"},
		Scope: value.ScopeRef{Type: "project", ID: "project-1"},
	})
	if err != nil {
		t.Fatalf("check access: %v", err)
	}
	if result.Decision != enum.AccessDecisionPending || result.ReasonCode != reasonSubjectPending {
		t.Fatalf("decision/reason = %s/%s, want %s/%s", result.Decision, result.ReasonCode, enum.AccessDecisionPending, reasonSubjectPending)
	}
}

type fixedClock struct{}

func (fixedClock) Now() time.Time {
	return time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
}

type sequenceIDs struct {
	next int
}

func newSequenceIDs() *sequenceIDs {
	return &sequenceIDs{}
}

func (g *sequenceIDs) New() uuid.UUID {
	g.next++
	return uuid.NewSHA1(uuid.NameSpaceOID, []byte(time.Unix(int64(g.next), 0).String()))
}

type memoryRepository struct {
	organizations     map[uuid.UUID]entity.Organization
	users             map[uuid.UUID]entity.User
	identities        map[string]entity.UserIdentity
	allowlist         map[string]entity.AllowlistEntry
	groups            map[uuid.UUID]entity.Group
	memberships       map[uuid.UUID]entity.Membership
	providers         map[uuid.UUID]entity.ExternalProvider
	accounts          map[uuid.UUID]entity.ExternalAccount
	bindings          map[uuid.UUID]entity.ExternalAccountBinding
	secrets           map[uuid.UUID]entity.SecretBindingRef
	packageSecretRefs map[uuid.UUID]entity.PackageInstallationSecretRef
	actions           map[string]entity.AccessAction
	rules             map[uuid.UUID]entity.AccessRule
	commands          map[string]entity.CommandResult
	audits            []entity.AccessDecisionAudit
	events            []entity.OutboxEvent
	ids               *sequenceIDs
}

func newMemoryRepository() *memoryRepository {
	return &memoryRepository{
		organizations:     make(map[uuid.UUID]entity.Organization),
		users:             make(map[uuid.UUID]entity.User),
		identities:        make(map[string]entity.UserIdentity),
		allowlist:         make(map[string]entity.AllowlistEntry),
		groups:            make(map[uuid.UUID]entity.Group),
		memberships:       make(map[uuid.UUID]entity.Membership),
		providers:         make(map[uuid.UUID]entity.ExternalProvider),
		accounts:          make(map[uuid.UUID]entity.ExternalAccount),
		bindings:          make(map[uuid.UUID]entity.ExternalAccountBinding),
		secrets:           make(map[uuid.UUID]entity.SecretBindingRef),
		packageSecretRefs: make(map[uuid.UUID]entity.PackageInstallationSecretRef),
		actions:           make(map[string]entity.AccessAction),
		rules:             make(map[uuid.UUID]entity.AccessRule),
		commands:          make(map[string]entity.CommandResult),
		ids:               newSequenceIDs(),
	}
}

func (r *memoryRepository) GetCommandResult(_ context.Context, identity query.CommandIdentity) (entity.CommandResult, error) {
	for _, result := range r.commands {
		if identity.CommandID != uuid.Nil && result.CommandID == identity.CommandID {
			return result, nil
		}
	}
	if identity.CommandID != uuid.Nil {
		return entity.CommandResult{}, errs.ErrNotFound
	}
	for _, result := range r.commands {
		if identity.IdempotencyKey != "" && result.IdempotencyKey == identity.IdempotencyKey {
			return result, nil
		}
	}
	return entity.CommandResult{}, errs.ErrNotFound
}

func (r *memoryRepository) CreateOrganization(_ context.Context, organization entity.Organization, event entity.OutboxEvent, result entity.CommandResult) error {
	r.organizations[organization.ID] = organization
	r.commands[result.Key] = result
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetOrganization(_ context.Context, id uuid.UUID) (entity.Organization, error) {
	organization, ok := r.organizations[id]
	if !ok {
		return entity.Organization{}, errs.ErrNotFound
	}
	return organization, nil
}

func (r *memoryRepository) CountActiveOwnerOrganizations(_ context.Context) (int, error) {
	var count int
	for _, organization := range r.organizations {
		if organization.Kind == enum.OrganizationKindOwner && organization.Status == enum.OrganizationStatusActive {
			count++
		}
	}
	return count, nil
}

func (r *memoryRepository) CreateUser(_ context.Context, user entity.User, identity entity.UserIdentity, event entity.OutboxEvent) error {
	r.users[user.ID] = user
	r.identities[identityKey(identity.Provider, identity.Subject)] = identity
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetUser(_ context.Context, id uuid.UUID) (entity.User, error) {
	user, ok := r.users[id]
	if !ok {
		return entity.User{}, errs.ErrNotFound
	}
	return user, nil
}

func (r *memoryRepository) UpdateUser(_ context.Context, user entity.User, previousVersion int64, event entity.OutboxEvent, result *entity.CommandResult) error {
	existing, ok := r.users[user.ID]
	if !ok {
		return errs.ErrNotFound
	}
	if existing.Version != previousVersion {
		return errs.ErrConflict
	}
	r.users[user.ID] = user
	if result != nil {
		r.commands[result.Key] = *result
	}
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetUserByEmail(_ context.Context, email string) (entity.User, error) {
	for _, user := range r.users {
		if user.PrimaryEmail == email {
			return user, nil
		}
	}
	return entity.User{}, errs.ErrNotFound
}

func (r *memoryRepository) GetUserByIdentity(_ context.Context, provider enum.IdentityProvider, subject string) (entity.User, error) {
	identity, ok := r.identities[identityKey(provider, subject)]
	if !ok {
		return entity.User{}, errs.ErrNotFound
	}
	return r.GetUser(context.Background(), identity.UserID)
}

func (r *memoryRepository) ListUserAccessScopes(_ context.Context, userID uuid.UUID) ([]value.ScopeRef, error) {
	user, ok := r.users[userID]
	if !ok {
		return nil, errs.ErrNotFound
	}
	scopes := make(map[value.ScopeRef]struct{})
	for _, membership := range r.memberships {
		if membership.SubjectType == enum.MembershipSubjectUser &&
			membership.SubjectID == userID &&
			membership.TargetType == enum.MembershipTargetOrganization &&
			(membership.Status == enum.MembershipStatusActive ||
				membership.Status == enum.MembershipStatusPending ||
				membership.Status == enum.MembershipStatusBlocked) {
			scopes[value.ScopeRef{Type: accessRuleScopeOrganization, ID: membership.TargetID.String()}] = struct{}{}
		}
	}
	for _, entry := range r.allowlist {
		if entry.Status == enum.AllowlistStatusActive && entry.OrganizationID != nil && allowlistMatchesEmail(entry, user.PrimaryEmail) {
			scopes[value.ScopeRef{Type: accessRuleScopeOrganization, ID: entry.OrganizationID.String()}] = struct{}{}
		}
	}
	result := make([]value.ScopeRef, 0, len(scopes))
	for scope := range scopes {
		result = append(result, scope)
	}
	sort.Slice(result, func(i, j int) bool {
		if result[i].Type == result[j].Type {
			return result[i].ID < result[j].ID
		}
		return result[i].Type < result[j].Type
	})
	return result, nil
}

func (r *memoryRepository) LinkUserIdentity(_ context.Context, identity entity.UserIdentity, event entity.OutboxEvent) error {
	r.identities[identityKey(identity.Provider, identity.Subject)] = identity
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) PutAllowlistEntry(_ context.Context, entry entity.AllowlistEntry, event entity.OutboxEvent) error {
	r.allowlist[allowlistKey(entry.MatchType, entry.Value)] = entry
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) UpdateAllowlistEntry(_ context.Context, entry entity.AllowlistEntry, previousVersion int64, event entity.OutboxEvent, result *entity.CommandResult) error {
	existing, ok := r.allowlist[allowlistKey(entry.MatchType, entry.Value)]
	if !ok {
		return errs.ErrNotFound
	}
	if existing.ID != entry.ID || existing.Version != previousVersion {
		return errs.ErrConflict
	}
	r.allowlist[allowlistKey(entry.MatchType, entry.Value)] = entry
	if result != nil {
		r.commands[result.Key] = *result
	}
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) FindAllowlistEntry(_ context.Context, matchType enum.AllowlistMatchType, v string) (entity.AllowlistEntry, error) {
	entry, ok := r.allowlist[allowlistKey(matchType, v)]
	if !ok {
		return entity.AllowlistEntry{}, errs.ErrNotFound
	}
	return entry, nil
}

func (r *memoryRepository) GetAllowlistEntry(_ context.Context, id uuid.UUID) (entity.AllowlistEntry, error) {
	for _, entry := range r.allowlist {
		if entry.ID == id {
			return entry, nil
		}
	}
	return entity.AllowlistEntry{}, errs.ErrNotFound
}

func (r *memoryRepository) CreateGroup(_ context.Context, group entity.Group, event entity.OutboxEvent, result entity.CommandResult) error {
	r.groups[group.ID] = group
	r.commands[result.Key] = result
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetGroup(_ context.Context, id uuid.UUID) (entity.Group, error) {
	group, ok := r.groups[id]
	if !ok {
		return entity.Group{}, errs.ErrNotFound
	}
	return group, nil
}

func (r *memoryRepository) SetMembership(_ context.Context, membership entity.Membership, event entity.OutboxEvent) error {
	for id, existing := range r.memberships {
		if sameMembershipIdentity(existing, membership) && id != membership.ID {
			delete(r.memberships, id)
			break
		}
	}
	r.memberships[membership.ID] = membership
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) FindMembership(_ context.Context, identity query.MembershipIdentity) (entity.Membership, error) {
	for _, membership := range r.memberships {
		if membership.SubjectType == identity.SubjectType && membership.SubjectID == identity.SubjectID &&
			membership.TargetType == identity.TargetType && membership.TargetID == identity.TargetID {
			return membership, nil
		}
	}
	return entity.Membership{}, errs.ErrNotFound
}

func (r *memoryRepository) ListMemberships(_ context.Context, filter query.MembershipGraphFilter) ([]entity.Membership, error) {
	statuses := membershipStatusesSet(filter.Statuses)
	var result []entity.Membership
	for _, membership := range r.memberships {
		if string(membership.SubjectType) == filter.Subject.Type && membership.SubjectID.String() == filter.Subject.ID && statuses[membership.Status] {
			result = append(result, membership)
		}
	}
	return result, nil
}

func (r *memoryRepository) ListMembershipsByTarget(_ context.Context, filter query.MembershipTargetFilter) ([]entity.Membership, error) {
	statuses := membershipStatusesSet(filter.Statuses)
	var result []entity.Membership
	for _, membership := range r.memberships {
		if string(membership.TargetType) == filter.Target.Type && membership.TargetID.String() == filter.Target.ID && statuses[membership.Status] {
			result = append(result, membership)
		}
	}
	return result, nil
}

func membershipStatusesSet(statuses []enum.MembershipStatus) map[enum.MembershipStatus]bool {
	result := make(map[enum.MembershipStatus]bool, len(statuses))
	for _, status := range statuses {
		result[status] = true
	}
	return result
}

func (r *memoryRepository) PutExternalProvider(_ context.Context, provider entity.ExternalProvider, event entity.OutboxEvent) error {
	r.providers[provider.ID] = provider
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) UpdateExternalProvider(_ context.Context, provider entity.ExternalProvider, previousVersion int64, event entity.OutboxEvent, result *entity.CommandResult) error {
	existing, ok := r.providers[provider.ID]
	if !ok {
		return errs.ErrNotFound
	}
	if existing.Version != previousVersion {
		return errs.ErrConflict
	}
	r.providers[provider.ID] = provider
	if result != nil {
		r.commands[result.Key] = *result
	}
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetExternalProvider(_ context.Context, id uuid.UUID) (entity.ExternalProvider, error) {
	provider, ok := r.providers[id]
	if !ok {
		return entity.ExternalProvider{}, errs.ErrNotFound
	}
	return provider, nil
}

func (r *memoryRepository) GetExternalProviderBySlug(_ context.Context, slug string) (entity.ExternalProvider, error) {
	for _, provider := range r.providers {
		if provider.Slug == slug {
			return provider, nil
		}
	}
	return entity.ExternalProvider{}, errs.ErrNotFound
}

func (r *memoryRepository) RegisterExternalAccount(_ context.Context, account entity.ExternalAccount, event entity.OutboxEvent, result entity.CommandResult) error {
	r.accounts[account.ID] = account
	r.commands[result.Key] = result
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) UpdateExternalAccount(_ context.Context, account entity.ExternalAccount, previousVersion int64, event entity.OutboxEvent, result *entity.CommandResult) error {
	existing, ok := r.accounts[account.ID]
	if !ok {
		return errs.ErrNotFound
	}
	if existing.Version != previousVersion {
		return errs.ErrConflict
	}
	r.accounts[account.ID] = account
	if result != nil {
		r.commands[result.Key] = *result
	}
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetExternalAccount(_ context.Context, id uuid.UUID) (entity.ExternalAccount, error) {
	account, ok := r.accounts[id]
	if !ok {
		return entity.ExternalAccount{}, errs.ErrNotFound
	}
	return account, nil
}

func (r *memoryRepository) BindExternalAccount(_ context.Context, binding entity.ExternalAccountBinding, event entity.OutboxEvent) error {
	for id, existing := range r.bindings {
		if sameExternalAccountBindingIdentity(existing, binding) && id != binding.ID {
			delete(r.bindings, id)
			break
		}
	}
	r.bindings[binding.ID] = binding
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetExternalAccountBinding(_ context.Context, id uuid.UUID) (entity.ExternalAccountBinding, error) {
	binding, ok := r.bindings[id]
	if !ok {
		return entity.ExternalAccountBinding{}, errs.ErrNotFound
	}
	return binding, nil
}

func (r *memoryRepository) UpdateExternalAccountBinding(_ context.Context, binding entity.ExternalAccountBinding, previousVersion int64, event entity.OutboxEvent, result *entity.CommandResult) error {
	existing, ok := r.bindings[binding.ID]
	if !ok {
		return errs.ErrNotFound
	}
	if existing.Version != previousVersion {
		return errs.ErrConflict
	}
	r.bindings[binding.ID] = binding
	if result != nil {
		r.commands[result.Key] = *result
	}
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) FindExternalAccountBinding(_ context.Context, filter query.ExternalAccountUsageFilter) (entity.ExternalAccountBinding, error) {
	for _, binding := range r.bindings {
		if binding.ExternalAccountID == filter.ExternalAccountID && string(binding.UsageScopeType) == filter.UsageScope.Type && binding.UsageScopeID == filter.UsageScope.ID {
			return binding, nil
		}
	}
	return entity.ExternalAccountBinding{}, errs.ErrNotFound
}

func (r *memoryRepository) FindExternalAccountBindingByIdentity(_ context.Context, identity query.ExternalAccountBindingIdentity) (entity.ExternalAccountBinding, error) {
	for _, binding := range r.bindings {
		if binding.ExternalAccountID == identity.ExternalAccountID &&
			string(binding.UsageScopeType) == identity.UsageScope.Type &&
			binding.UsageScopeID == identity.UsageScope.ID {
			return binding, nil
		}
	}
	return entity.ExternalAccountBinding{}, errs.ErrNotFound
}

func (r *memoryRepository) PutSecretBindingRef(_ context.Context, secret entity.SecretBindingRef, event entity.OutboxEvent) error {
	r.secrets[secret.ID] = secret
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetSecretBindingRef(_ context.Context, id uuid.UUID) (entity.SecretBindingRef, error) {
	secret, ok := r.secrets[id]
	if !ok {
		return entity.SecretBindingRef{}, errs.ErrNotFound
	}
	return secret, nil
}

func (r *memoryRepository) ListPackageInstallationSecretRefs(_ context.Context, filter query.PackageInstallationSecretRefsFilter) ([]entity.PackageInstallationSecretRef, error) {
	keySet := make(map[string]struct{}, len(filter.LogicalKeys))
	for _, key := range filter.LogicalKeys {
		keySet[key] = struct{}{}
	}
	result := make([]entity.PackageInstallationSecretRef, 0)
	for _, ref := range r.packageSecretRefs {
		if ref.PackageInstallationID != filter.PackageInstallationID {
			continue
		}
		if ref.InstallationScope != filter.InstallationScope {
			continue
		}
		if len(keySet) > 0 {
			if _, ok := keySet[ref.LogicalKey]; !ok {
				continue
			}
		}
		result = append(result, ref)
	}
	return result, nil
}

func (r *memoryRepository) PutAccessAction(_ context.Context, action entity.AccessAction, event entity.OutboxEvent) error {
	r.actions[action.Key] = action
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) GetAccessActionByKey(_ context.Context, key string) (entity.AccessAction, error) {
	action, ok := r.actions[key]
	if !ok {
		return entity.AccessAction{}, errs.ErrNotFound
	}
	return action, nil
}

func (r *memoryRepository) PutAccessRule(_ context.Context, rule entity.AccessRule, event entity.OutboxEvent) error {
	for id, existing := range r.rules {
		if sameAccessRuleIdentity(existing, rule) && id != rule.ID {
			delete(r.rules, id)
			break
		}
	}
	r.rules[rule.ID] = rule
	r.events = append(r.events, event)
	return nil
}

func (r *memoryRepository) FindAccessRule(_ context.Context, identity query.AccessRuleIdentity) (entity.AccessRule, error) {
	for _, rule := range r.rules {
		if rule.Effect == identity.Effect &&
			rule.SubjectType == identity.SubjectType &&
			rule.SubjectID == identity.SubjectID &&
			rule.ActionKey == identity.ActionKey &&
			rule.ResourceType == identity.ResourceType &&
			rule.ResourceID == identity.ResourceID &&
			rule.ScopeType == identity.ScopeType &&
			rule.ScopeID == identity.ScopeID {
			return rule, nil
		}
	}
	return entity.AccessRule{}, errs.ErrNotFound
}

func (r *memoryRepository) ListAccessRules(_ context.Context, filter query.AccessRuleFilter) ([]entity.AccessRule, error) {
	var result []entity.AccessRule
	for _, rule := range r.rules {
		if rule.ActionKey != filter.ActionKey || rule.ResourceType != filter.ResourceType {
			continue
		}
		if rule.ResourceID != "" && rule.ResourceID != filter.ResourceID {
			continue
		}
		if rule.ScopeType == "global" && rule.ScopeID == "" {
			// Global policy applies to all scopes.
		} else if rule.ScopeType != filter.Scope.Type || rule.ScopeID != filter.Scope.ID {
			continue
		}
		for _, subject := range filter.Subjects {
			if string(rule.SubjectType) == subject.Type && rule.SubjectID == subject.ID {
				result = append(result, rule)
				break
			}
		}
	}
	return result, nil
}

func (r *memoryRepository) RecordAccessDecision(_ context.Context, audit entity.AccessDecisionAudit, event *entity.OutboxEvent) error {
	r.audits = append(r.audits, audit)
	if event != nil {
		r.events = append(r.events, *event)
	}
	return nil
}

func (r *memoryRepository) GetAccessDecisionAudit(_ context.Context, id uuid.UUID) (entity.AccessDecisionAudit, error) {
	for _, audit := range r.audits {
		if audit.ID == id {
			return audit, nil
		}
	}
	return entity.AccessDecisionAudit{}, errs.ErrNotFound
}

func (r *memoryRepository) ListPendingAccess(_ context.Context, filter query.PendingAccessFilter) ([]entity.PendingAccessItem, error) {
	var items []entity.PendingAccessItem
	for _, user := range r.users {
		if user.Status != enum.UserStatusPending && user.Status != enum.UserStatusBlocked {
			continue
		}
		if !r.userVisibleInPendingScope(user, filter.Scope) {
			continue
		}
		items = append(items, entity.PendingAccessItem{
			ItemID: user.ID.String(), ItemType: "user",
			Subject: value.SubjectRef{Type: string(enum.AccessSubjectUser), ID: user.ID.String()},
			Status:  string(user.Status), ReasonCode: "user_" + string(user.Status), CreatedAt: user.CreatedAt,
		})
	}
	for _, membership := range r.memberships {
		if membership.Status != enum.MembershipStatusPending && membership.Status != enum.MembershipStatusBlocked {
			continue
		}
		if filter.Scope.Type != "" && filter.Scope.Type != accessRuleScopeGlobal &&
			(string(membership.TargetType) != filter.Scope.Type || membership.TargetID.String() != filter.Scope.ID) {
			continue
		}
		items = append(items, entity.PendingAccessItem{
			ItemID: membership.ID.String(), ItemType: "membership",
			Subject: value.SubjectRef{Type: string(membership.SubjectType), ID: membership.SubjectID.String()},
			Status:  string(membership.Status), ReasonCode: "membership_" + string(membership.Status), CreatedAt: membership.CreatedAt,
		})
	}
	for _, account := range r.accounts {
		if account.Status != enum.ExternalAccountStatusPending &&
			account.Status != enum.ExternalAccountStatusNeedsReauth &&
			account.Status != enum.ExternalAccountStatusLimited &&
			account.Status != enum.ExternalAccountStatusBlocked {
			continue
		}
		if filter.Scope.Type != "" && filter.Scope.Type != accessRuleScopeGlobal &&
			(string(account.OwnerScopeType) != filter.Scope.Type || account.OwnerScopeID != filter.Scope.ID) {
			continue
		}
		status := string(enum.UserStatusPending)
		if account.Status == enum.ExternalAccountStatusBlocked {
			status = string(enum.UserStatusBlocked)
		}
		items = append(items, entity.PendingAccessItem{
			ItemID: account.ID.String(), ItemType: "external_account",
			Subject: value.SubjectRef{Type: string(enum.AccessSubjectExternalAccount), ID: account.ID.String()},
			Status:  status, ReasonCode: "external_account_" + string(account.Status), CreatedAt: account.CreatedAt,
		})
	}
	sort.SliceStable(items, func(i, j int) bool {
		if items[i].CreatedAt.Equal(items[j].CreatedAt) {
			if items[i].ItemType == items[j].ItemType {
				return items[i].ItemID < items[j].ItemID
			}
			return items[i].ItemType < items[j].ItemType
		}
		return items[i].CreatedAt.After(items[j].CreatedAt)
	})
	if filter.Offset >= len(items) {
		return nil, nil
	}
	end := filter.Offset + filter.Limit
	if end > len(items) {
		end = len(items)
	}
	return items[filter.Offset:end], nil
}

func (r *memoryRepository) userVisibleInPendingScope(user entity.User, scope value.ScopeRef) bool {
	if scope.Type == "" || scope.Type == accessRuleScopeGlobal {
		return true
	}
	if scope.Type != accessRuleScopeOrganization {
		return false
	}
	for _, membership := range r.memberships {
		if membership.SubjectType == enum.MembershipSubjectUser &&
			membership.SubjectID == user.ID &&
			membership.TargetType == enum.MembershipTargetOrganization &&
			membership.TargetID.String() == scope.ID &&
			(membership.Status == enum.MembershipStatusActive ||
				membership.Status == enum.MembershipStatusPending ||
				membership.Status == enum.MembershipStatusBlocked) {
			return true
		}
	}
	for _, entry := range r.allowlist {
		if entry.Status == enum.AllowlistStatusActive &&
			entry.OrganizationID != nil &&
			entry.OrganizationID.String() == scope.ID &&
			allowlistMatchesEmail(entry, user.PrimaryEmail) {
			return true
		}
	}
	return false
}

func (r *memoryRepository) seedUser(status enum.UserStatus) entity.User {
	now := fixedClock{}.Now()
	user := entity.User{
		Base:         entity.Base{ID: r.ids.New(), Version: 1, CreatedAt: now, UpdatedAt: now},
		PrimaryEmail: "user@example.com", Status: status,
	}
	r.users[user.ID] = user
	return user
}

func (r *memoryRepository) seedSecret(storeType enum.SecretStoreType, storeRef string) entity.SecretBindingRef {
	now := fixedClock{}.Now()
	secret := entity.SecretBindingRef{
		Base:      entity.Base{ID: r.ids.New(), Version: 1, CreatedAt: now, UpdatedAt: now},
		StoreType: storeType,
		StoreRef:  storeRef,
	}
	r.secrets[secret.ID] = secret
	return secret
}

func (r *memoryRepository) seedPackageInstallationSecretRef(installationID uuid.UUID, scope value.ScopeRef, logicalKey string, secret entity.SecretBindingRef) entity.PackageInstallationSecretRef {
	now := fixedClock{}.Now()
	ref := entity.PackageInstallationSecretRef{
		Base:                  entity.Base{ID: r.ids.New(), Version: 1, CreatedAt: now, UpdatedAt: now},
		PackageInstallationID: installationID,
		InstallationScope:     scope,
		LogicalKey:            logicalKey,
		SecretRef:             secret,
		Status:                enum.PackageInstallationSecretRefStatusConfigured,
		Metadata:              map[string]string{"source": "test"},
	}
	r.packageSecretRefs[ref.ID] = ref
	return ref
}

func (r *memoryRepository) seedOperatorMeta(key string, actionKey string, resourceType string) value.CommandMeta {
	return r.seedOperatorMetaForScope(key, actionKey, resourceType, value.ScopeRef{Type: accessRuleScopeGlobal})
}

func (r *memoryRepository) seedOperatorMetaForScope(key string, actionKey string, resourceType string, scope value.ScopeRef) value.CommandMeta {
	now := fixedClock{}.Now()
	actor := r.seedUser(enum.UserStatusActive)
	action := entity.AccessAction{
		Base:         entity.Base{ID: r.ids.New(), Version: 1, CreatedAt: now, UpdatedAt: now},
		Key:          actionKey,
		DisplayName:  actionKey,
		ResourceType: resourceType,
		Status:       enum.AccessActionStatusActive,
	}
	rule := entity.AccessRule{
		Base:         entity.Base{ID: r.ids.New(), Version: 1, CreatedAt: now, UpdatedAt: now},
		Effect:       enum.AccessEffectAllow,
		SubjectType:  enum.AccessSubjectUser,
		SubjectID:    actor.ID.String(),
		ActionKey:    actionKey,
		ResourceType: resourceType,
		ScopeType:    scope.Type,
		ScopeID:      scope.ID,
		Status:       enum.AccessRuleStatusActive,
	}
	r.actions[action.Key] = action
	r.rules[rule.ID] = rule
	meta := commandMeta(key)
	meta.Actor = value.Actor{Type: string(enum.AccessSubjectUser), ID: actor.ID.String()}
	return meta
}

func identityKey(provider enum.IdentityProvider, subject string) string {
	return string(provider) + ":" + subject
}

func allowlistKey(matchType enum.AllowlistMatchType, value string) string {
	return string(matchType) + ":" + value
}

func lastAuditScope(store *memoryRepository) (value.ScopeRef, bool) {
	if len(store.audits) == 0 {
		return value.ScopeRef{}, false
	}
	return store.audits[len(store.audits)-1].Scope, true
}

func allowlistMatchesEmail(entry entity.AllowlistEntry, email string) bool {
	switch entry.MatchType {
	case enum.AllowlistMatchEmail:
		return entry.Value == email
	case enum.AllowlistMatchDomain:
		return strings.HasSuffix(email, "@"+entry.Value)
	default:
		return false
	}
}

func sameMembershipIdentity(a entity.Membership, b entity.Membership) bool {
	return a.SubjectType == b.SubjectType && a.SubjectID == b.SubjectID && a.TargetType == b.TargetType && a.TargetID == b.TargetID
}

func sameExternalAccountBindingIdentity(a entity.ExternalAccountBinding, b entity.ExternalAccountBinding) bool {
	return a.ExternalAccountID == b.ExternalAccountID && a.UsageScopeType == b.UsageScopeType && a.UsageScopeID == b.UsageScopeID
}

func sameAccessRuleIdentity(a entity.AccessRule, b entity.AccessRule) bool {
	return a.Effect == b.Effect &&
		a.SubjectType == b.SubjectType &&
		a.SubjectID == b.SubjectID &&
		a.ActionKey == b.ActionKey &&
		a.ResourceType == b.ResourceType &&
		a.ResourceID == b.ResourceID &&
		a.ScopeType == b.ScopeType &&
		a.ScopeID == b.ScopeID
}

func assertMembershipIDs(t *testing.T, edges []entity.Membership, want ...uuid.UUID) {
	t.Helper()
	if len(edges) != len(want) {
		t.Fatalf("edges = %d, want %d: %+v", len(edges), len(want), edges)
	}
	got := make(map[uuid.UUID]struct{}, len(edges))
	for _, edge := range edges {
		got[edge.ID] = struct{}{}
	}
	for _, id := range want {
		if _, ok := got[id]; !ok {
			t.Fatalf("edges missing membership %s: %+v", id, edges)
		}
	}
}

func ptrInt64(value int64) *int64 {
	return &value
}

func ptrString(value string) *string {
	return &value
}

func commandMeta(key string) value.CommandMeta {
	return value.CommandMeta{IdempotencyKey: key}
}

func TestBootstrapDeniedWithoutAllowlist(t *testing.T) {
	svc := New(newMemoryRepository(), fixedClock{}, newSequenceIDs())
	_, err := svc.BootstrapUserFromIdentity(context.Background(), BootstrapUserFromIdentityInput{
		Provider: enum.IdentityProviderGitHub, Subject: "42", Email: "owner@example.com",
	})
	if !errors.Is(err, errs.ErrUnauthorizedSubject) {
		t.Fatalf("err = %v, want %v", err, errs.ErrUnauthorizedSubject)
	}
}
