package app

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

// These tests guard the ChannelMemberMutationEffects invariants for the
// canonical BatchRemove path: effects must mirror exactly what the canonical
// cleanup helpers retire — parent AND child reply-thread scopes, cascade private
// agents, and cleanup-only removals — rather than a call-site guess from the
// direct target.

func startChannelMemberEffectsDB(t *testing.T, ctx context.Context) coredb.DB {
	t.Helper()
	dsn := testkit.StartInitializedTiDBWithRootDSN(t, appTiDB.RootDSN(t))
	sqlDB, err := testkit.OpenTiDBContext(ctx, dsn)
	require.NoError(t, err)
	t.Cleanup(func() { _ = sqlDB.Close() })
	return coredb.WrapSQLDB(sqlDB)
}

// seedPublicAgentEntry inserts a machine + agent member + agent row owned by
// ownerUserID and a PUBLIC channel membership for the agent. It deliberately
// creates no private entry so loadActiveChannelAgentEntry resolves the public
// branch.
func seedPublicAgentEntry(t *testing.T, pool coredb.QueryExecer, workspaceID string, ownerUserID string, channelID string, machineID string, agentID string, agentMemberID string, agentName string, now time.Time) {
	t.Helper()
	mustExec(t, pool, `
INSERT IGNORE INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at)
VALUES ($1, $2, $3, 'test-public-agent-machine', $4, $5, $5)
`, machineID, workspaceID, ownerUserID, machineID+"-key", now)
	mustExec(t, pool, `
INSERT INTO members (id, workspace_id, type, display_name, created_at, updated_at)
VALUES ($1, $2, 'agent', $3, $4, $4)
`, agentMemberID, workspaceID, agentName, now)
	mustExec(t, pool, `
INSERT INTO agents (
  id, workspace_id, machine_id, member_id, name, runtime, model, description,
  auth_token_hash, auto_wake_enabled, status, activity, activity_detail,
  created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 'codex', 'gpt-5-codex', '', NULL, TRUE, 'active', 'sleeping', '', $6, $6)
`, agentID, workspaceID, machineID, agentMemberID, agentName, now)
	require.NoError(t, message.EnsureScopeMembership(context.Background(), pool, channelID, agentMemberID, "public", "", "active", 0, 0, now))
}

// requireEffectsMembershipChange asserts the effects recorded a real change for
// (scopeID, memberID, kind).
func requireEffectsMembershipChange(t *testing.T, effects *ChannelMemberMutationEffects, scopeID string, memberID string, kind string) {
	t.Helper()
	for _, change := range effects.MembershipChanges {
		if change.ScopeID == scopeID && change.MemberID == memberID && change.Kind == kind {
			return
		}
	}
	require.Failf(t, "effects missing membership change", "scope=%s member=%s kind=%s in %#v", scopeID, memberID, kind, effects.MembershipChanges)
}

// requireEffectsNoMembershipChange asserts the effects did NOT record a change
// for (scopeID, memberID, kind).
func requireEffectsNoMembershipChange(t *testing.T, effects *ChannelMemberMutationEffects, scopeID string, memberID string, kind string) {
	t.Helper()
	for _, change := range effects.MembershipChanges {
		require.Falsef(t, change.ScopeID == scopeID && change.MemberID == memberID && change.Kind == kind,
			"effects unexpectedly recorded change scope=%s member=%s kind=%s", scopeID, memberID, kind)
	}
}

// TestBatchRemoveEffectsCapturePublicAgentParentAndChildScopes guards bug 3 for
// the public-agent path: markPublicChannelAgentEntryProjectionRemoved retires
// the agent's membership across the parent channel AND every child reply-thread
// scope, so effects must record BOTH scopes (not just the parent).
func TestBatchRemoveEffectsCapturePublicAgentParentAndChildScopes(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	db := startChannelMemberEffectsDB(t, ctx)
	now := time.Date(2026, 8, 8, 1, 0, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, db, "effects_public_parent_child", now)
	channelID := "chn_effects_public_parent_child"
	seedChannelParticipantProjectionForTests(t, db, owner.WorkspaceID, channelID, "effects-public-parent-child", "", "private", owner.MemberID, owner.MemberID, now)

	agentID := "agt_effects_public_parent_child"
	agentMemberID := "mem_effects_public_parent_child_agent"
	seedPublicAgentEntry(t, db, owner.WorkspaceID, owner.UserID, channelID, "mac_effects_public_parent_child", agentID, agentMemberID, "public-agent-effects", now)

	threadChannelID := "chn_effects_public_parent_child_reply"
	seedPrivateWakeReplyThreadForChannel(t, db, owner, channelID, threadChannelID, "effects-public-reply", now)
	require.NoError(t, message.EnsureScopeMembership(ctx, db, threadChannelID, agentMemberID, "public", "", "active", 0, 0, now))

	tx, err := db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	t.Cleanup(func() { _ = tx.Rollback(context.Background()) })

	effects := &ChannelMemberMutationEffects{}
	store := New(db, Options{})
	results, err := NewChannelManagementPort(store).BatchRemoveChannelMembersInTx(
		ctx, tx, owner.WorkspaceID, channelID, owner.UserID, owner.MemberID,
		[]ChannelMemberExecReference{{AgentID: agentID}}, now, effects,
	)
	require.NoError(t, err)
	require.Len(t, results, 1, "direct public-agent remove emits one system message")

	require.True(t, effects.HasChanges(), "effects must report changes for a real remove")
	requireEffectsMembershipChange(t, effects, channelID, agentMemberID, message.ChannelMemberEventTargetAgent)
	requireEffectsMembershipChange(t, effects, threadChannelID, agentMemberID, message.ChannelMemberEventTargetAgent)

	var parentHasAgentID, childHasAgentID bool
	for _, change := range effects.MembershipChanges {
		if change.MemberID == agentMemberID && change.AgentID == agentID {
			if change.ScopeID == channelID {
				parentHasAgentID = true
			}
			if change.ScopeID == threadChannelID {
				childHasAgentID = true
			}
		}
	}
	require.True(t, parentHasAgentID, "parent membership change must carry the resolved agentID")
	require.True(t, childHasAgentID, "child membership change must carry the resolved agentID")
}

// TestBatchRemoveEffectsCascadeOwnedPrivateAgent guards bug 3 for the human
// cascade: removing a human retires that human's owned private-agent membership
// (removePrivateEntriesForOwnerHostChannel), so effects must record the private
// agent's membership change with its resolved agentID — not just the human.
func TestBatchRemoveEffectsCascadeOwnedPrivateAgent(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	db := startChannelMemberEffectsDB(t, ctx)
	now := time.Date(2026, 8, 8, 1, 5, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, db, "effects_cascade_private", now)
	human := seedWorkspaceMemberFixture(t, db, owner.WorkspaceID, "effects_cascade_private_human", now)
	channelID := "chn_effects_cascade_private"
	seedChannelParticipantProjectionForTests(t, db, owner.WorkspaceID, channelID, "effects-cascade-private", "", "private", owner.MemberID, owner.MemberID, now)
	require.NoError(t, message.EnsureScopeMembership(ctx, db, channelID, human.MemberID, "public", "", "active", 0, 0, now))
	threadChannelID := "chn_effects_cascade_private_reply"
	seedPrivateWakeReplyThreadForChannel(t, db, owner, channelID, threadChannelID, "effects-cascade-private-reply", now)

	agentID := "agt_effects_cascade_private"
	agentMemberID := "mem_effects_cascade_private_agent"
	seedAppPrivateAgentEntry(t, db, owner.WorkspaceID, human.UserID, human.MemberID, channelID, "mac_effects_cascade_private", agentID, agentMemberID, "cascade-private-agent", now)

	tx, err := db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	t.Cleanup(func() { _ = tx.Rollback(context.Background()) })

	effects := &ChannelMemberMutationEffects{}
	store := New(db, Options{})
	results, err := NewChannelManagementPort(store).BatchRemoveChannelMembersInTx(
		ctx, tx, owner.WorkspaceID, channelID, owner.UserID, owner.MemberID,
		[]ChannelMemberExecReference{{UserID: human.UserID}}, now, effects,
	)
	require.NoError(t, err)
	require.Len(t, results, 1, "removing an active human emits one system message")

	require.True(t, effects.HasChanges())
	require.Equal(t, []string{channelID, threadChannelID}, effects.RealtimeRoomIDs,
		"active human removal must capture the parent and child room topology in the mutation tx")
	// The removed human's own parent membership is recorded...
	requireEffectsMembershipChange(t, effects, channelID, human.MemberID, message.ChannelMemberEventTargetHuman)
	// ...AND the cascade-retired private agent membership is recorded with its agentID.
	requireEffectsMembershipChange(t, effects, channelID, agentMemberID, message.ChannelMemberEventTargetAgent)
	var cascadeAgentIDResolved bool
	for _, change := range effects.MembershipChanges {
		if change.MemberID == agentMemberID && change.AgentID == agentID {
			cascadeAgentIDResolved = true
		}
	}
	require.True(t, cascadeAgentIDResolved, "cascade private-agent change must carry the resolved agentID")
}

// TestBatchRemoveEffectsCleanupOnlyStillHasChanges guards bug 4: when the direct
// human's parent membership is already inactive but a cascade private-agent
// membership is still active, the canonical cleanup retires the private agent
// while emitting no direct system message. Effects MUST still report the change
// (HasChanges=true) so post-commit wake/unread fanout fires.
func TestBatchRemoveEffectsCleanupOnlyStillHasChanges(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	db := startChannelMemberEffectsDB(t, ctx)
	now := time.Date(2026, 8, 8, 1, 10, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, db, "effects_cleanup_only", now)
	human := seedWorkspaceMemberFixture(t, db, owner.WorkspaceID, "effects_cleanup_only_human", now)
	channelID := "chn_effects_cleanup_only"
	seedChannelParticipantProjectionForTests(t, db, owner.WorkspaceID, channelID, "effects-cleanup-only", "", "private", owner.MemberID, owner.MemberID, now)
	require.NoError(t, message.EnsureScopeMembership(ctx, db, channelID, human.MemberID, "public", "", "active", 0, 0, now))

	agentID := "agt_effects_cleanup_only"
	agentMemberID := "mem_effects_cleanup_only_agent"
	seedAppPrivateAgentEntry(t, db, owner.WorkspaceID, human.UserID, human.MemberID, channelID, "mac_effects_cleanup_only", agentID, agentMemberID, "cleanup-only-agent", now)

	// Simulate the bug-4 precondition: the human's own parent membership was
	// already retired, but the owned private-agent membership lingers.
	_, err := db.Exec(ctx, `
UPDATE scope_memberships
SET removed_at = $3, updated_at = $3
WHERE scope_id = $1 AND member_id = $2 AND visibility = 'public' AND removed_at IS NULL
`, channelID, human.MemberID, now.Add(-time.Second))
	require.NoError(t, err)

	tx, err := db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	t.Cleanup(func() { _ = tx.Rollback(context.Background()) })

	effects := &ChannelMemberMutationEffects{}
	store := New(db, Options{})
	results, err := NewChannelManagementPort(store).BatchRemoveChannelMembersInTx(
		ctx, tx, owner.WorkspaceID, channelID, owner.UserID, owner.MemberID,
		[]ChannelMemberExecReference{{UserID: human.UserID}}, now, effects,
	)
	require.NoError(t, err)
	require.Nil(t, results, "cleanup-only remove emits no direct system message")
	require.Empty(t, effects.DirectMembers, "the inactive human is not a direct changed target")
	require.Empty(t, effects.HumanSubscriptions, "inactive human must not be (un)subscribed")

	// ...yet the cascade private-agent retirement is captured, so HasChanges holds
	// the documented cleanup-only contract.
	require.True(t, effects.HasChanges(), "cleanup-only remove with a real cascade change must report HasChanges")
	requireEffectsMembershipChange(t, effects, channelID, agentMemberID, message.ChannelMemberEventTargetAgent)
	requireEffectsNoMembershipChange(t, effects, channelID, human.MemberID, message.ChannelMemberEventTargetHuman)
}

// TestBatchRemoveEffectsCascadeArchivedAgentResolvesIdentity guards blocker 2:
// a human-remove cascade may retire a leftover membership of an ARCHIVED agent.
// Principal resolution must be by stable member identity (agents.member_id),
// not lifecycle status, so the change carries the agentID and downstream wake
// cleanup is not left without a principal. The mutation must not error.
func TestBatchRemoveEffectsCascadeArchivedAgentResolvesIdentity(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	db := startChannelMemberEffectsDB(t, ctx)
	now := time.Date(2026, 8, 8, 1, 15, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, db, "effects_archived_cascade", now)
	human := seedWorkspaceMemberFixture(t, db, owner.WorkspaceID, "effects_archived_cascade_human", now)
	channelID := "chn_effects_archived_cascade"
	seedChannelParticipantProjectionForTests(t, db, owner.WorkspaceID, channelID, "effects-archived-cascade", "", "private", owner.MemberID, owner.MemberID, now)
	require.NoError(t, message.EnsureScopeMembership(ctx, db, channelID, human.MemberID, "public", "", "active", 0, 0, now))

	agentID := "agt_effects_archived_cascade"
	agentMemberID := "mem_effects_archived_cascade_agent"
	seedAppPrivateAgentEntry(t, db, owner.WorkspaceID, human.UserID, human.MemberID, channelID, "mac_effects_archived_cascade", agentID, agentMemberID, "archived-cascade-agent", now)

	// Archive the agent but leave its leftover private membership in place — the
	// exact blocker-2 precondition.
	_, err := db.Exec(ctx, `UPDATE agents SET status = 'archived', updated_at = $2 WHERE id = $1`, agentID, now)
	require.NoError(t, err)

	tx, err := db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	t.Cleanup(func() { _ = tx.Rollback(context.Background()) })

	effects := &ChannelMemberMutationEffects{}
	store := New(db, Options{})
	_, err = NewChannelManagementPort(store).BatchRemoveChannelMembersInTx(
		ctx, tx, owner.WorkspaceID, channelID, owner.UserID, owner.MemberID,
		[]ChannelMemberExecReference{{UserID: human.UserID}}, now, effects,
	)
	require.NoError(t, err, "archived-agent cascade must resolve identity and not fail the mutation")

	requireEffectsMembershipChange(t, effects, channelID, agentMemberID, message.ChannelMemberEventTargetAgent)
	var resolvedAgentID string
	for _, change := range effects.MembershipChanges {
		if change.MemberID == agentMemberID {
			resolvedAgentID = change.AgentID
		}
	}
	require.Equal(t, agentID, resolvedAgentID, "archived-agent cascade change must carry the resolved agentID")
}

// TestBatchRemoveEffectsCaptureMustEqualRetiredSetRaceGuard is the deterministic
// RED for blocker 3. A child-scope membership written concurrently between the
// capture SELECT and the retirement UPDATE must NOT be retired without being
// captured — i.e. the captured set must equal the retired set. Under a broad
// retirement UPDATE this fails: the UPDATE's current read retires the
// just-committed child row while the earlier capture missed it. Narrowing the
// retirement to the captured PKs makes captured == retired hold.
func TestBatchRemoveEffectsCaptureMustEqualRetiredSetRaceGuard(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	db := startChannelMemberEffectsDB(t, ctx)
	now := time.Date(2026, 8, 8, 2, 0, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, db, "effects_race_capture", now)
	channelID := "chn_effects_race_capture"
	seedChannelParticipantProjectionForTests(t, db, owner.WorkspaceID, channelID, "effects-race-capture", "", "private", owner.MemberID, owner.MemberID, now)

	agentID := "agt_effects_race_capture"
	agentMemberID := "mem_effects_race_capture_agent"
	seedPublicAgentEntry(t, db, owner.WorkspaceID, owner.UserID, channelID, "mac_effects_race_capture", agentID, agentMemberID, "race-capture-agent", now)

	threadChannelID := "chn_effects_race_capture_reply"
	seedPrivateWakeReplyThreadForChannel(t, db, owner, channelID, threadChannelID, "effects-race-reply", now)
	// Pre-seed a RETIRED child membership for the agent. The raced write revives
	// THIS row (same PK) between capture and the retirement UPDATE. Reviving an
	// existing row is an UPDATE, so it does not take the FK/scope-row lock that a
	// fresh INSERT would deadlock against the capture's JOIN scopes FOR UPDATE.
	_, err := db.Exec(ctx, `
INSERT INTO scope_memberships (
  scope_id, member_id, visibility, owner_member_id,
  last_read_seq, last_read_global_seq, last_activity_global_seq,
  owner_context_dismissed_through_global_seq, effective_from_seq,
  created_at, updated_at, removed_at
) VALUES ($1, $2, 'public', NULL, 0, 0, 0, 0, 0, $3, $3, $3)
`, threadChannelID, agentMemberID, now)
	require.NoError(t, err)

	cleanupCaptureReached := make(chan struct{})
	releaseCleanupUpdate := make(chan struct{})
	preChannelMemberCleanupUpdateHookForTest = func(hookCtx context.Context) {
		select {
		case <-cleanupCaptureReached:
		default:
			close(cleanupCaptureReached)
		}
		select {
		case <-releaseCleanupUpdate:
		case <-hookCtx.Done():
		}
	}
	t.Cleanup(func() { preChannelMemberCleanupUpdateHookForTest = nil })

	effects := &ChannelMemberMutationEffects{}
	store := New(db, Options{})

	done := make(chan error, 1)
	go func() {
		tx, beginErr := db.BeginTx(ctx, coredb.TxOptions{})
		if beginErr != nil {
			done <- beginErr
			return
		}
		_, mutateErr := NewChannelManagementPort(store).BatchRemoveChannelMembersInTx(
			ctx, tx, owner.WorkspaceID, channelID, owner.UserID, owner.MemberID,
			[]ChannelMemberExecReference{{AgentID: agentID}}, now, effects,
		)
		if mutateErr != nil {
			_ = tx.Rollback(context.Background())
			done <- mutateErr
			return
		}
		done <- tx.Commit(ctx)
	}()

	select {
	case <-cleanupCaptureReached:
	case <-ctx.Done():
		t.Fatal("member mutation did not reach the cleanup-capture seam")
	}

	// Raced write on a separate auto-commit connection: revive the pre-seeded
	// retired child membership AFTER capture and BEFORE the retirement UPDATE.
	// This is a raw UPDATE that intentionally bypasses the shared
	// channel-reply-thread fence, so the test isolates the narrow-PK guarantee
	// (captured == retired) rather than the fence.
	_, err = db.Exec(ctx, `
UPDATE scope_memberships
SET removed_at = NULL, archived_at = NULL, updated_at = $3
WHERE scope_id = $1 AND member_id = $2 AND effective_to_seq IS NULL
`, threadChannelID, agentMemberID, now)
	require.NoError(t, err)
	close(releaseCleanupUpdate)

	select {
	case mutateErr := <-done:
		require.NoError(t, mutateErr)
	case <-ctx.Done():
		t.Fatal("member mutation did not finish after the raced child write")
	}

	var childRetired bool
	require.NoError(t, db.QueryRow(ctx, `
SELECT removed_at IS NOT NULL
FROM scope_memberships
WHERE scope_id = $1 AND member_id = $2 AND visibility = 'public'
ORDER BY effective_from_seq DESC LIMIT 1
`, threadChannelID, agentMemberID).Scan(&childRetired))
	childInEffects := false
	for _, change := range effects.MembershipChanges {
		if change.ScopeID == threadChannelID && change.MemberID == agentMemberID {
			childInEffects = true
		}
	}
	require.Equalf(t, childRetired, childInEffects,
		"captured set must equal retired set: child retired=%v but in effects=%v", childRetired, childInEffects)
}

// TestBatchRemoveEffectsChildMustNotReviveAfterParentRemoved is RED #2 for
// blocker 3. Once an agent is removed from a channel, a later child-scope write
// via the actual writer path (EnsureScopeMembership) must NOT leave the child
// membership active — otherwise the removed agent is still reachable on
// reply-thread events (permission leak). Narrowing the retirement UPDATE to
// captured PKs is not enough: the shared parent-membership fence must make the
// writer revalidate the (now removed) parent and refuse. This is sequential
// (remover commits, then the writer runs), so it is deterministic and needs no
// concurrency seam.
func TestBatchRemoveEffectsChildMustNotReviveAfterParentRemoved(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	db := startChannelMemberEffectsDB(t, ctx)
	now := time.Date(2026, 8, 8, 2, 5, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, db, "effects_revive_leak", now)
	channelID := "chn_effects_revive_leak"
	seedChannelParticipantProjectionForTests(t, db, owner.WorkspaceID, channelID, "effects-revive-leak", "", "private", owner.MemberID, owner.MemberID, now)

	agentID := "agt_effects_revive_leak"
	agentMemberID := "mem_effects_revive_leak_agent"
	seedPublicAgentEntry(t, db, owner.WorkspaceID, owner.UserID, channelID, "mac_effects_revive_leak", agentID, agentMemberID, "revive-leak-agent", now)

	threadChannelID := "chn_effects_revive_leak_reply"
	seedPrivateWakeReplyThreadForChannel(t, db, owner, channelID, threadChannelID, "effects-revive-reply", now)
	// The agent is an ACTIVE member of the child scope.
	require.NoError(t, message.EnsureScopeMembership(ctx, db, threadChannelID, agentMemberID, "public", "", "active", 0, 0, now))

	// Remove the agent from the channel (commits); parent and child retire.
	store := New(db, Options{})
	require.NoError(t, coredb.WithTx(ctx, db, func(tx coredb.Tx) error {
		_, err := NewChannelManagementPort(store).BatchRemoveChannelMembersInTx(
			ctx, tx, owner.WorkspaceID, channelID, owner.UserID, owner.MemberID,
			[]ChannelMemberExecReference{{AgentID: agentID}}, now, nil,
		)
		return err
	}))

	// Writer path: a reply-thread projection attempts to revive the agent's child
	// membership AFTER the channel removal committed. The shared channel
	// reply-thread fence must refuse because the agent is no longer active in the
	// parent channel.
	reviveErr := message.EnsureScopeMembership(ctx, db, threadChannelID, agentMemberID, "public", "", "active", 0, 0, now)
	require.ErrorIs(t, reviveErr, message.ErrScopeParentMembershipInactive, "writer must refuse to revive a reply-thread membership when the parent member was removed from the channel")

	// RED: the child membership must NOT be active once the parent agent is removed.
	var childActive bool
	require.NoError(t, db.QueryRow(ctx, `
SELECT removed_at IS NULL
FROM scope_memberships
WHERE scope_id = $1 AND member_id = $2 AND visibility = 'public'
ORDER BY effective_from_seq DESC LIMIT 1
`, threadChannelID, agentMemberID).Scan(&childActive))
	require.Falsef(t, childActive, "permission leak: child membership active after parent agent removed from channel")
}

// TestAppReplySucceedsAfterRootAuthorRemoved is the real app reply regression:
// after the channel thread's root author has been removed from the channel, a
// still-member user's reply must succeed (the derived root-author projection is
// skipped, not propagated), and the removed root author must not be revived on
// the reply thread. Without the app-copy derived-participant skip, the fence
// sentinel would roll back the replier's whole reply tx.
func TestAppReplySucceedsAfterRootAuthorRemoved(t *testing.T) {
	ctx := context.Background()
	pool := startAppRollbackDB(t, ctx)
	store := New(pool, Options{})
	now := time.Date(2026, 8, 8, 4, 0, 0, 0, time.UTC)
	owner := seedAppAdminFixture(t, pool, "app_reply_root_removed", now)
	rootAuthor := seedWorkspaceMemberFixture(t, pool, owner.WorkspaceID, "app_reply_root_removed_author", now)
	replier := seedWorkspaceMemberFixture(t, pool, owner.WorkspaceID, "app_reply_root_removed_replier", now)
	channelID := "chn_app_reply_root_removed"
	seedChannelParticipantProjectionForTests(t, pool, owner.WorkspaceID, channelID, "app-reply-root-removed", "", "private", owner.MemberID, owner.MemberID, now)
	require.NoError(t, message.EnsureScopeMembership(ctx, pool, channelID, rootAuthor.MemberID, "public", "", "active", 0, 0, now))
	require.NoError(t, message.EnsureScopeMembership(ctx, pool, channelID, replier.MemberID, "public", "", "active", 0, 0, now))

	rootUser := model.User{ID: rootAuthor.UserID, Name: "app-reply-root-author", DisplayName: "Root Author"}
	parent, _, err := store.SendAppMessageForUserWithOptions(ctx, rootUser, owner.WorkspaceID, channelID, "root message", nil, SendAppMessageOptions{})
	require.NoError(t, err)

	replyThread, err := message.OpenReplyThreadForMember(ctx, pool, replier.MemberID, owner.WorkspaceID, channelID, parent.ID)
	require.NoError(t, err)

	require.NoError(t, store.RemoveChannelHumanForUser(ctx, owner.UserID, owner.WorkspaceID, channelID, rootAuthor.UserID))

	activeChildCount := func() int {
		var n int
		require.NoError(t, pool.QueryRow(ctx, `
SELECT COUNT(*) FROM scope_memberships
WHERE scope_id = $1 AND member_id = $2 AND removed_at IS NULL AND effective_to_seq IS NULL
`, replyThread.ThreadChannelID, rootAuthor.MemberID).Scan(&n))
		return n
	}
	childNotificationCount := func() int {
		var n int
		require.NoError(t, pool.QueryRow(ctx, `
SELECT COUNT(*) FROM scope_notification_settings WHERE scope_id = $1 AND member_id = $2
`, replyThread.ThreadChannelID, rootAuthor.MemberID).Scan(&n))
		return n
	}
	membershipBefore := activeChildCount()
	notificationBefore := childNotificationCount()

	replierUser := model.User{ID: replier.UserID, Name: "app-reply-replier", DisplayName: "Replier"}
	_, _, err = store.SendMessageForUser(ctx, replierUser, owner.WorkspaceID, replyThread.ThreadChannelID, "reply after root author left", nil)
	require.NoError(t, err, "reply must succeed even if the thread's root author has left the channel")

	require.Equal(t, membershipBefore, activeChildCount(), "reply must not revive/project the removed root author on the reply thread")
	require.Equal(t, notificationBefore, childNotificationCount(), "reply must not create a notification for the removed root author")
}
