package extensionflow

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

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/extension"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
)

// mockCatalogHub satisfies CatalogDeliveryHub for worker tests.
type mockCatalogHub struct {
	syncCalls   int
	lastMachine string
	syncErr     error
}

func (m *mockCatalogHub) SyncMachineCatalog(_ context.Context, machineID string) error {
	m.syncCalls++
	m.lastMachine = machineID
	return m.syncErr
}

// readCatalogHint reads the catalog row for assertions (no lock).
func readCatalogHint(t *testing.T, ctx context.Context, db coredb.DB, ws, agent string) extension.CatalogHint {
	t.Helper()
	var hint extension.CatalogHint
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		h, err := extension.New(db).InTx(tx).ReadAgentCatalogHint(ctx, ws, agent)
		hint = h
		return err
	}))
	return hint
}

type catalogStateRow struct {
	Version int64
	State   string
	Enabled bool
}

func readCatalogStateRow(
	t *testing.T,
	ctx context.Context,
	db coredb.DB,
	workspaceID string,
	agentID string,
) catalogStateRow {
	t.Helper()
	var row catalogStateRow
	require.NoError(t, db.QueryRow(ctx, `
SELECT version, catalog_state, enabled
FROM agent_extension_catalogs
WHERE workspace_id=$1 AND agent_id=$2`,
		workspaceID,
		agentID,
	).Scan(&row.Version, &row.State, &row.Enabled))
	return row
}

func assertUpdateCount(t *testing.T, ctx context.Context, db coredb.DB, ws, agent string, expected int) {
	t.Helper()
	var n int
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return tx.QueryRow(ctx, `SELECT COUNT(*) FROM agent_extension_catalog_updates WHERE workspace_id=$1 AND agent_id=$2`, ws, agent).Scan(&n)
	}))
	assert.Equal(t, expected, n)
}

func catalogRowCount(t *testing.T, ctx context.Context, db coredb.DB, ws, agent string) int {
	t.Helper()
	var n int
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return tx.QueryRow(ctx, `SELECT COUNT(*) FROM agent_extension_catalogs WHERE workspace_id=$1 AND agent_id=$2`, ws, agent).Scan(&n)
	}))
	return n
}

func seedProductAppConnection(t *testing.T, db coredb.QueryExecer, fixture serviceFixture, suffix string) (string, string) {
	t.Helper()
	now := time.Now().UTC()
	installationID := "inst-github-" + suffix
	connectionID := "conn-github-" + suffix

	// Use the real product Definition seeded by the App registry migrations:
	// id=app_github, service_kind=app, slug=github, latest_version=1.0.0.
	var kind, slug, status string
	require.NoError(t, db.QueryRow(context.Background(), `
SELECT service_kind, slug, status
FROM service_definitions
WHERE id = 'app_github'`).Scan(&kind, &slug, &status))
	require.Equal(t, "app", kind)
	require.Equal(t, "github", slug)
	require.Equal(t, "active", status)

	mustExecService(t, db, `
INSERT INTO service_installations (
    id, workspace_id, service_definition_id, service_kind, instance_key,
    status, installed_version, config, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'app_github', 'app', 'default', 'active', '1.0.0', '{}', $3, $4, $4)`,
		installationID, fixture.WorkspaceID, fixture.HumanMemberID, now)
	mustExecService(t, db, `
INSERT INTO service_connections (
    id, workspace_id, service_definition_id, service_installation_id,
    service_kind, external_subject_ref, display_label, credentials, config,
    status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'app_github', $3, 'app', $4, 'GitHub', '{}', '{}',
          'active', $5, $6, $6)`,
		connectionID, fixture.WorkspaceID, installationID, "github-"+suffix, fixture.HumanMemberID, now)
	registered, ok := fixture.Registry.LookupByIdentity("github", 1)
	require.True(t, ok, "GitHub Builtin must be registered before seeding its App-hosted selection")
	capabilityConfig, err := model.EncodeExtensionCapabilityConfig(
		model.ExtensionCapabilityConfig{
			Extensions: []model.ExtensionCapabilityEntry{{
				Domain:           "github",
				Version:          1,
				DefinitionDigest: registered.Definition.Digest,
				DefaultName:      "github",
				DefaultEnabled:   true,
			}},
		},
	)
	require.NoError(t, err)
	mustExecService(t, db, `
UPDATE service_capabilities
SET config=$1, updated_at=$2
WHERE service_definition_id='app_github'
  AND capability_key='extensions'`,
		capabilityConfig,
		now,
	)
	mustExecService(t, db, `
INSERT INTO service_installation_extensions (
    id, workspace_id, service_definition_id, service_installation_id,
    extension_name, definition_domain, definition_version, definition_digest,
    status, disabled_reason, config, selection_source, created_by_member_id,
    created_at, updated_at, disabled_at
) VALUES (
    $1, $2, 'app_github', $3,
    'github', 'github', 1, $4,
    'active', '', NULL, 'app_default', $5,
    $6, $6, NULL
)`,
		"selection-github-"+suffix,
		fixture.WorkspaceID,
		installationID,
		registered.Definition.Digest,
		fixture.HumanMemberID,
		now,
	)
	return installationID, connectionID
}

func assertGitHubCatalogEntry(t *testing.T, entries []AgentCatalogEntry) {
	t.Helper()
	require.Len(t, entries, 1)
	assert.Equal(t, "github", entries[0].ExtensionName)
	assert.Equal(t, "github", entries[0].Domain)
	actions := append([]string(nil), entries[0].Actions...)
	sort.Strings(actions)
	assert.Equal(t, []string{
		"list-watches",
		"pause-watch",
		"resume-watch",
		"unwatch",
		"watch-pull-request",
	}, actions)
}

// TestCatalogDelivery_WorkerE2E proves the delivery agentteam drains a pending
// update, resolves the machine, calls SyncMachineCatalog, and marks delivered.
func TestCatalogDelivery_WorkerE2E(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catworker")
	service := newService(db, fixture.Registry)
	seedAppHostedCatalogSelection(t, ctx, db, fixture, "worker", fixture.ToolsDigest, "tools")
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))

	// Run one drain cycle with a mock hub.
	hub := &mockCatalogHub{}
	service.drainPendingCatalogUpdates(ctx, hub)

	assert.GreaterOrEqual(t, hub.syncCalls, 1, "SyncMachineCatalog should be called")
	assert.NotEmpty(t, hub.lastMachine, "machine ID resolved")

	// The update should be delivered.
	assertUpdateStatus(t, ctx, db, fixture.WorkspaceID, "agent-1", 1, "delivered")
}

func assertUpdateStatus(t *testing.T, ctx context.Context, db coredb.DB, ws, agent string, version int64, expected string) {
	t.Helper()
	var status string
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return tx.QueryRow(ctx, `SELECT status FROM agent_extension_catalog_updates WHERE workspace_id=$1 AND agent_id=$2 AND version=$3`, ws, agent, version).Scan(&status)
	}))
	assert.Equal(t, expected, status)
}

// TestApplyAgentCatalogChange_NoOpWhenTargetsUnchanged proves the effective-target
// no-op gate (stage-6 final-review blocker 2): after materialization, calling
// ApplyAgentCatalogChange (the app Enable/Disable hook entry point) does NOT
// advance the version because the App-hosted target is unchanged.
func TestApplyAgentCatalogChange_NoOpWhenTargetsUnchanged(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catappnoop")
	service := newService(db, fixture.Registry)
	seedAppHostedCatalogSelection(t, ctx, db, fixture, "app-noop", fixture.ToolsDigest, "tools")
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))
	before := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	require.Equal(t, int64(1), before.Version)
	require.NotEqual(t, "[]", before.State)
	require.True(t, before.Enabled)

	// ApplyAgentCatalogChange with the SAME target → no-op (version does NOT advance).
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))
	after := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	assert.Equal(t, before.Version, after.Version, "equivalent re-enable must NOT advance the version")
	assert.Equal(t, before.State, after.State,
		"byte-identical catalog_state must be treated as a no-op")
	assert.Equal(t, before.Enabled, after.Enabled)
}

// TestBackfillDefaultAllowCatalogs_NoCandidates proves the reconciliation scan
// is a no-op when there is neither an existing catalog row nor an active
// product-app installation+connection.
func TestBackfillDefaultAllowCatalogs_NoCandidates(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catbackfill")
	service := newService(db, fixture.Registry)

	n, err := service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	assert.Equal(t, 0, n, "no app installations → no backfill candidates")
}

// TestBackfillDefaultAllowCatalogs_MaterializesRealProductIdentity proves the
// production App identity (app_github/app/github, not the synthetic github/v1
// slug) materializes the default-allow five-action Catalog exactly once.
func TestBackfillDefaultAllowCatalogs_MaterializesRealProductIdentity(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catbackfill_real")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	seedProductAppConnection(t, db, fixture, "real")

	n, err := service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	assert.Equal(t, 6, n, "all six active fixture agents receive the default-allow catalog")

	catalog := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	assert.Equal(t, int64(1), catalog.Version)
	assert.NotEqual(t, "[]", catalog.State)
	assert.True(t, catalog.Enabled)
	assertUpdateCount(t, ctx, db, fixture.WorkspaceID, "agent-1", 1)
	entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err)
	assertGitHubCatalogEntry(t, entries)

	n, err = service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	assert.Equal(t, 0, n, "an equivalent reconcile is a no-op")
	assert.Equal(t, int64(1), readCatalogHint(t, ctx, db, fixture.WorkspaceID, "agent-1").Version)
	assertUpdateCount(t, ctx, db, fixture.WorkspaceID, "agent-1", 1)
}

// TestBackfillDefaultAllowCatalogs_ReconcilesConnectionLifecycle proves the
// periodic reconciliation target is bidirectional: disconnect removes the
// default-allow commands and reconnect restores them, each with one version.
func TestBackfillDefaultAllowCatalogs_ReconcilesConnectionLifecycle(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catbackfill_lifecycle")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	_, connectionID := seedProductAppConnection(t, db, fixture, "lifecycle")

	_, err := service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	require.Equal(t, int64(1), readCatalogHint(t, ctx, db, fixture.WorkspaceID, "agent-1").Version)

	mustExecService(t, db, `UPDATE service_connections SET status='disabled', disabled_at=$2, updated_at=$2 WHERE id=$1`,
		connectionID, time.Now().UTC())
	n, err := service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	assert.Equal(t, 6, n)
	disabled := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	assert.Equal(t, int64(2), disabled.Version)
	assert.Equal(t, "[]", disabled.State)
	assert.False(t, disabled.Enabled)

	mustExecService(t, db, `UPDATE service_connections SET status='active', disabled_at=NULL, updated_at=$2 WHERE id=$1`,
		connectionID, time.Now().UTC())
	n, err = service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	assert.Equal(t, 6, n)
	reEnabled := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	assert.Equal(t, int64(3), reEnabled.Version)
	assert.NotEqual(t, "[]", reEnabled.State)
	assert.True(t, reEnabled.Enabled)
	assertUpdateCount(t, ctx, db, fixture.WorkspaceID, "agent-1", 3)
}

// TestBackfillDefaultAllowCatalogs_BadCandidateDoesNotStarveLaterAgents proves a
// corrupt catalog fails closed for that Agent without preventing independent
// candidates from reconciling.
func TestBackfillDefaultAllowCatalogs_BadCandidateDoesNotStarveLaterAgents(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catbackfill_bad")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	seedProductAppConnection(t, db, fixture, "bad")
	now := time.Now().UTC()
	mustExecService(t, db, `
INSERT INTO agent_extension_catalogs (
    workspace_id, agent_id, version, catalog_state, enabled, created_at, updated_at
) VALUES ($1, 'agent-1', 1, 'corrupt', 1, $2, $2)`, fixture.WorkspaceID, now)

	n, err := service.BackfillDefaultAllowCatalogs(ctx)
	require.Error(t, err)
	assert.Equal(t, 5, n, "the five healthy agents still materialize")
	assert.Equal(t, int64(1), readCatalogHint(t, ctx, db, fixture.WorkspaceID, "agent-2").Version)
	assertUpdateCount(t, ctx, db, fixture.WorkspaceID, "agent-2", 1)
}

// TestBackfillDefaultAllowCatalogs_DisabledAuthorizationOptsOut proves any
// explicit disabled App authorization suppresses legacy default-allow for that
// Agent while independent Agents in the same workspace still materialize.
func TestBackfillDefaultAllowCatalogs_DisabledAuthorizationOptsOut(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catbackfill_optout")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	installationID, _ := seedProductAppConnection(t, db, fixture, "optout")
	now := time.Now().UTC()
	mustExecService(t, db, `
INSERT INTO service_principal_authorizations (
    id, workspace_id, service_definition_id, service_installation_id,
    service_kind, principal_kind, principal_ref, status, disabled_reason,
    scopes_json, authorized_by_member_id, policy_version, metadata,
    created_at, updated_at, disabled_at, active_authorization_key
) VALUES (
    'authz-app-optout', $1, 'app_github', $2,
    'app', 'agent', 'agent-1', 'disabled', 'opt out',
    '[]', $3, 1, '{"source":"default_allow_opt_out"}',
    $4, $4, $4, NULL
)`, fixture.WorkspaceID, installationID, fixture.HumanMemberID, now)

	n, err := service.BackfillDefaultAllowCatalogs(ctx)
	require.NoError(t, err)
	assert.Equal(t, 5, n)
	optedOut := readCatalogStateRow(t, ctx, db, fixture.WorkspaceID, "agent-1")
	assert.Equal(t, int64(0), optedOut.Version)
	assert.Equal(t, "", optedOut.State)
	assert.False(t, optedOut.Enabled)
	assert.Equal(t, 1, catalogRowCount(t, ctx, db, fixture.WorkspaceID, "agent-1"), "explicit opt-out keeps a v0 tombstone/fence row")
	assertUpdateCount(t, ctx, db, fixture.WorkspaceID, "agent-1", 0)
	assert.Equal(t, int64(1), readCatalogHint(t, ctx, db, fixture.WorkspaceID, "agent-2").Version)
}

func TestWorkspaceCatalogReconcileBatch_PagesAndWritesSparseCatalogs(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catjob_page")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	seedProductAppConnection(t, db, fixture, "jobpage")

	first, err := service.reconcileWorkspaceCatalogJobBatch(ctx, fixture.WorkspaceID, "", 2, time.Now().UTC())
	require.NoError(t, err)
	assert.Equal(t, 2, first.Scanned)
	assert.Equal(t, 2, first.Changed)
	assert.True(t, first.HasMore)
	assert.NotEmpty(t, first.NextCursorAgentID)

	second, err := service.reconcileWorkspaceCatalogJobBatch(ctx, fixture.WorkspaceID, first.NextCursorAgentID, 2, time.Now().UTC())
	require.NoError(t, err)
	assert.Equal(t, 2, second.Scanned)
	assert.Equal(t, 2, second.Changed)
	assert.True(t, second.HasMore)

	repeat, err := service.reconcileWorkspaceCatalogJobBatch(ctx, fixture.WorkspaceID, "", 2, time.Now().UTC())
	require.NoError(t, err)
	assert.Equal(t, 2, repeat.Scanned)
	assert.Equal(t, 0, repeat.Changed, "unchanged catalog_state rows are skipped")
}

func TestWorkspaceCatalogReconcileJob_UnregisteredDigestBlocksOnce(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catjob_blocked")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	seedProductAppConnection(t, db, fixture, "jobblocked")

	missingDigest := "sha256:" + strings.Repeat("a", 64)
	capabilityConfig, err := model.EncodeExtensionCapabilityConfig(
		model.ExtensionCapabilityConfig{
			Extensions: []model.ExtensionCapabilityEntry{{
				Domain:           "github",
				Version:          1,
				DefinitionDigest: missingDigest,
				DefaultName:      "github",
				DefaultEnabled:   true,
			}},
		},
	)
	require.NoError(t, err)
	mustExecService(t, db, `
UPDATE service_capabilities
SET config=$1, updated_at=$2
WHERE service_definition_id='app_github'
  AND capability_key='extensions'`,
		capabilityConfig,
		time.Now().UTC(),
	)
	mustExecService(t, db, `
UPDATE service_installation_extensions
SET definition_digest=$1, updated_at=$2
WHERE workspace_id=$3
  AND definition_domain='github'`,
		missingDigest,
		time.Now().UTC(),
		fixture.WorkspaceID,
	)
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.EnqueueWorkspaceCatalogReconcile(ctx, tx, fixture.WorkspaceID, time.Now().UTC())
	}))

	found, err := service.processOneWorkspaceCatalogReconcileJob(ctx)
	require.True(t, found)
	require.Error(t, err)
	require.ErrorContains(t, err, CatalogReconcileErrorUnregisteredDigest)
	assertUpdateCount(t, ctx, db, fixture.WorkspaceID, "agent-1", 0)

	var status, lastErrorCode string
	var attempts int
	require.NoError(t, db.QueryRow(ctx, `
SELECT status, last_error_code, attempts
FROM extension_catalog_reconcile_jobs
WHERE workspace_id = $1
`, fixture.WorkspaceID).Scan(&status, &lastErrorCode, &attempts))
	assert.Equal(t, extension.CatalogReconcileJobStatusBlocked, status)
	assert.Equal(t, CatalogReconcileErrorUnregisteredDigest, lastErrorCode)
	assert.Equal(t, 1, attempts)

	found, err = service.processOneWorkspaceCatalogReconcileJob(ctx)
	require.NoError(t, err)
	assert.False(t, found, "blocked deterministic digest errors are not retried every tick")
}

// TestComputeAgentCatalogEntries_AppMetadataNotMasked proves the ServiceKind
// branching fix (stage-6 final-review blocker 1b): an app-kind authorization with
// valid-JSON non-Extension metadata ({"source":"agent_settings"}) goes through
// the app projection path (which skips gracefully if the definition isn't found),
// NOT the extension path (which would decode the metadata to zero-value Extension
// and fail the exact flattened-name validation.
func TestComputeAgentCatalogEntries_AppMetadataNotMasked(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "appmeta")
	// Register the GitHub Builtin so the app projection path can resolve it.
	reg := fixture.Registry
	if err := registerProviderWatchBuiltinsForTest(reg); err != nil {
		t.Fatalf("register github/gitea builtins: %v", err)
	}
	service := newService(db, reg)
	now := time.Now().UTC()

	installationID, _ := seedProductAppConnection(t, db, fixture, "explicit")
	mustExecService(t, db, `
INSERT INTO service_principal_authorizations (
    id, workspace_id, service_definition_id, service_installation_id,
    service_kind, principal_kind, principal_ref, status, disabled_reason,
    scopes_json, authorized_by_member_id, policy_version, metadata,
    created_at, updated_at, active_authorization_key
) VALUES (
    'authz-app-1', $1, 'app_github', $2,
    'app', 'agent', $3, 'active', '',
    '[]', $4, 1, '{"source":"agent_settings"}',
    $5, $5, 'key-app-1'
)`, fixture.WorkspaceID, installationID, "agent-1", fixture.HumanMemberID, now)

	entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err, "real app-kind authz with non-Extension metadata must project")
	assertGitHubCatalogEntry(t, entries)
}

// TestComputeAgentCatalogEntries_AppInstallationIdentityMismatchFailsClosed
// proves an app authorization cannot point at a connected installation whose
// durable Definition identity was swapped independently.
func TestComputeAgentCatalogEntries_AppInstallationIdentityMismatchFailsClosed(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "appchain")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	now := time.Now().UTC()
	installationID, _ := seedProductAppConnection(t, db, fixture, "chain")
	mustExecService(t, db, `
INSERT INTO service_principal_authorizations (
    id, workspace_id, service_definition_id, service_installation_id,
    service_kind, principal_kind, principal_ref, status, disabled_reason,
    scopes_json, authorized_by_member_id, policy_version, metadata,
    created_at, updated_at, active_authorization_key
) VALUES (
    'authz-app-chain', $1, 'app_github', $2,
    'app', 'agent', 'agent-1', 'active', '',
    '[]', $3, 1, '{"source":"agent_settings"}',
    $4, $4, 'key-app-chain'
)`, fixture.WorkspaceID, installationID, fixture.HumanMemberID, now)

	// Independent FKs permit this swapped Definition reference; the Catalog
	// projector must compare the complete raw chain and reject it.
	mustExecService(t, db, `
UPDATE service_installations
SET service_definition_id='app_gitea', updated_at=$2
WHERE id=$1`, installationID, now.Add(time.Second))

	_, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
	require.ErrorContains(t, err, "does not match its active App host")
}

// TestComputeAgentCatalogEntries_LegacyMCPToolsDoNotControlExtensions proves
// the flattened Extension target is selected by capability_key='extensions',
// not by the legacy mcp.tools action list.
func TestComputeAgentCatalogEntries_LegacyMCPToolsDoNotControlExtensions(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "appcapempty")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	seedProductAppConnection(t, db, fixture, "capempty")
	mustExecService(t, db, `
UPDATE service_capabilities
SET config='{"tools":[]}', updated_at=$1
WHERE service_definition_id='app_github' AND capability_key='mcp.tools'`,
		time.Now().UTC())

	entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err)
	assertGitHubCatalogEntry(t, entries)
}

// TestCatalogDelivery_OfflineRetry proves an offline machine (SyncMachineCatalog
// error) requeues the update instead of marking it delivered, and a later drain
// (after the retry is due) delivers it (stage-6 final-review blocker 5 E2E).
func TestCatalogDelivery_OfflineRetry(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catoffline")
	service := newService(db, fixture.Registry)
	seedAppHostedCatalogSelection(t, ctx, db, fixture, "offline", fixture.ToolsDigest, "tools")
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))

	// First drain: SyncMachineCatalog fails (offline) → requeue, NOT delivered.
	hub := &mockCatalogHub{syncErr: errors.New("machine offline")}
	service.drainPendingCatalogUpdates(ctx, hub)
	assert.GreaterOrEqual(t, hub.syncCalls, 1, "SyncMachineCatalog was attempted")
	assertUpdateStatus(t, ctx, db, fixture.WorkspaceID, "agent-1", 1, "pending")

	// Simulate the retry becoming due, then drain again → delivered.
	mustExecService(t, db, `UPDATE agent_extension_catalog_updates SET next_retry_at = NULL, status='pending' WHERE workspace_id=$1 AND agent_id=$2`,
		fixture.WorkspaceID, "agent-1")
	hub2 := &mockCatalogHub{}
	service.drainPendingCatalogUpdates(ctx, hub2)
	assert.GreaterOrEqual(t, hub2.syncCalls, 1, "retry drain delivers")
	assertUpdateStatus(t, ctx, db, fixture.WorkspaceID, "agent-1", 1, "delivered")
}

// TestCatalogDelivery_HintFailureNotDelivered proves a corrupt catalog hint is
// never pushed: when the durable hint read fails closed, SyncMachineCatalog is
// NOT called and the update is requeued (stage-6 final-review blocker 5 E2E).
func TestCatalogDelivery_HintFailureNotDelivered(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "catbadhint")
	service := newService(db, fixture.Registry)
	seedAppHostedCatalogSelection(t, ctx, db, fixture, "bad-hint", fixture.ToolsDigest, "tools")
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		return service.ApplyAgentCatalogChange(ctx, tx, fixture.WorkspaceID, "agent-1", time.Now().UTC())
	}))

	// Corrupt the durable catalog state so the hint read fails closed.
	mustExecService(t, db, `UPDATE agent_extension_catalogs SET catalog_state = 'corrupt-not-json' WHERE workspace_id=$1 AND agent_id=$2`,
		fixture.WorkspaceID, "agent-1")

	hub := &mockCatalogHub{}
	service.drainPendingCatalogUpdates(ctx, hub)
	assert.Equal(t, 0, hub.syncCalls, "SyncMachineCatalog must NOT be called when the hint is corrupt")
	assertUpdateStatus(t, ctx, db, fixture.WorkspaceID, "agent-1", 1, "pending")
}

// TestComputeAgentCatalogEntries_ExplicitOnlyToolsAllowlistIsAuthoritative
// proves the durable mcp.tools list under the explicit-only contract removes
// actions without touching scopes: the catalog must drop the removed tool
// while the others stay.
func TestComputeAgentCatalogEntries_ExplicitOnlyToolsAllowlistIsAuthoritative(t *testing.T) {
	ctx := context.Background()
	db := startServiceRollbackDB(t, ctx)
	fixture := seedServiceFixture(t, ctx, db, "apptools")
	require.NoError(t, registerProviderWatchBuiltinsForTest(fixture.Registry))
	service := newService(db, fixture.Registry)
	installationID, _ := seedProductAppConnection(t, db, fixture, "tools")
	now := time.Now().UTC()

	// Convert the host App to the modern explicit-only execution contract
	// with a kebab-case durable tools allowlist missing watch-pull-request.
	mustExecService(t, db, `
UPDATE service_capabilities
SET config=JSON_OBJECT(
      'tools', JSON_ARRAY('list-watches', 'pause-watch', 'resume-watch', 'unwatch'),
      'scopes', JSON_ARRAY('provider.watches.read', 'provider.watches.write'),
      'authorizationMode', 'explicit_only',
      'installMode', 'feature_owned',
      'requiresSubscriptions', FALSE
    ), updated_at=$1
WHERE service_definition_id='app_github' AND capability_key='mcp.tools'`,
		now)
	mustExecService(t, db, `
INSERT INTO service_principal_authorizations (
    id, workspace_id, service_definition_id, service_installation_id,
    service_kind, principal_kind, principal_ref, status, disabled_reason,
    scopes_json, authorized_by_member_id, policy_version, metadata,
    created_at, updated_at, active_authorization_key
) VALUES (
    'authz-app-tools', $1, 'app_github', $2,
    'app', 'agent', $3, 'active', '',
    '["provider.watches.read","provider.watches.write"]', $4, 1, '{}',
    $5, $5, 'key-app-tools'
)`, fixture.WorkspaceID, installationID, "agent-1", fixture.HumanMemberID, now)

	entries, err := service.AgentCatalogEntries(ctx, fixture.WorkspaceID, "agent-1")
	require.NoError(t, err)
	var githubEntry *AgentCatalogEntry
	for index := range entries {
		if entries[index].Domain == "github" {
			githubEntry = &entries[index]
		}
	}
	require.NotNil(t, githubEntry, "github entry must project under the explicit grant")
	require.NotContains(t, githubEntry.Actions, "watch-pull-request",
		"a tool removed from the durable mcp.tools allowlist must disappear")
	for _, kept := range []string{"list-watches", "pause-watch", "resume-watch", "unwatch"} {
		require.Contains(t, githubEntry.Actions, kept)
	}
}
