package access

import (
	"context"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

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

// concurrencyTaskFixture seeds a real DB-backed workspace / channel / board /
// task / Agent / Machine / Member on a rollback DB so barrier-controlled
// concurrency tests can hold transactions open and race the canonical
// Task->board Channel authorizer against Channel revoke / Task delete / Task
// move.
type concurrencyTaskFixture struct {
	db            coredb.DB
	workspaceID   string
	channelID     string
	boardID       string
	taskScopeID   string
	taskID        string
	agentMemberID string
	agentID       string
	machineID     string
	ownerMemberID string
	ownerUserID   string
}

func seedConcurrencyTaskFixture(t *testing.T, ctx context.Context, suffix string) concurrencyTaskFixture {
	t.Helper()
	db := startAccessTestPool(t, ctx)
	now := time.Now().UTC()

	f := concurrencyTaskFixture{
		db:            db,
		workspaceID:   "wsp_cc_" + suffix,
		channelID:     "chn_cc_" + suffix,
		boardID:       "brd_cc_" + suffix,
		taskScopeID:   "tsk_cc_" + suffix,
		taskID:        "tsk_cc_" + suffix,
		agentMemberID: "mem_cc_" + suffix,
		agentID:       "agt_cc_" + suffix,
		machineID:     "mac_cc_" + suffix,
		ownerMemberID: "mem_cc_" + suffix + "_human",
		ownerUserID:   "usr_cc_" + suffix,
	}

	mustExec(t, ctx, db, `
INSERT INTO users (id, email, name, display_name, avatar_url, is_admin, password_hash, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $2, $2, '', FALSE, 'hash', $3, $3, $3)
`, f.ownerUserID, "cc-"+suffix+"@example.com", now)
	mustExec(t, ctx, db, `
INSERT INTO workspaces (id, organization_id, slug, name, latest_message_seq, created_at, updated_at)
VALUES ($1, $2, $3, $3, 0, $4, $4)
`, f.workspaceID, testfixture.SeedWorkspaceOrganization(t, ctx, db, f.workspaceID, now), "cc-"+suffix, now)
	mustSeedChannelScope(t, ctx, db, f.workspaceID, f.channelID, "cc-"+suffix, "CC "+suffix, "tasks", "public", "", false, now)
	mustExec(t, ctx, db, `
INSERT INTO members (id, workspace_id, type, display_name, created_at, updated_at)
VALUES ($1, $2, 'agent', 'cc-agent', $3, $3),
       ($4, $2, 'human', 'cc-owner', $3, $3)
`, f.agentMemberID, f.workspaceID, now, f.ownerMemberID)
	mustExec(t, ctx, db, `
INSERT INTO machines (id, workspace_id, created_by_user_id, name, api_key, created_at, updated_at)
VALUES ($1, $2, $4, 'cc-machine', 'k', $3, $3)
`, f.machineID, f.workspaceID, now, f.ownerUserID)
	mustExec(t, ctx, db, `
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, 'cc-agent', 'codex', 'gpt', '', NULL, TRUE, 'active', 'sleeping', '', $5, $5)
`, f.agentID, f.workspaceID, f.machineID, f.agentMemberID, now)
	mustSeedScopeMembership(t, ctx, db, f.channelID, f.agentMemberID, now)
	mustSeedTaskScope(t, ctx, db, f.workspaceID, f.taskScopeID, now)
	mustSeedTaskBoard(t, ctx, db, f.workspaceID, f.channelID, f.boardID, now)
	mustSeedTaskRow(t, ctx, db, f.workspaceID, f.channelID, f.boardID, f.taskScopeID, f.taskID, f.ownerMemberID, now)
	return f
}

// TestConcurrencyChannelRevokeRacesAuthorization proves that a Channel revoke
// and an authorization read produce a linearizable result: the authorizer may
// observe the pre-revoke snapshot (allowed) while the revoke is uncommitted, but
// once the revoke commits, every subsequent authorization read must observe the
// revoked membership and fail closed. No stale membership row authorizes after
// the revoke commits.
func TestConcurrencyChannelRevokeRacesAuthorization(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	f := seedConcurrencyTaskFixture(t, ctx, "revoke")

	revokeTx, err := f.db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	defer revokeTx.Rollback(ctx)

	// Lock the Channel scope membership row first (the revoke).
	_, err = revokeTx.Exec(ctx, `
SELECT 1 FROM scope_memberships WHERE scope_id = $1 AND member_id = $2 FOR UPDATE
`, f.channelID, f.agentMemberID)
	require.NoError(t, err)

	// The authorizer runs concurrently with the uncommitted revoke. Because the
	// authorizer uses snapshot reads, it observes the pre-revoke committed state
	// (allowed) — a valid linearizable outcome before the revoke commits.
	done := make(chan error, 1)
	go func() {
		_, err := AuthorizeActiveAgentTaskBoardChannel(ctx, f.db, f.workspaceID, f.agentMemberID, f.taskScopeID)
		done <- err
	}()

	// The authorizer may complete with either outcome while the revoke is
	// uncommitted; both are linearizable. Wait for it.
	select {
	case <-done:
	case <-time.After(10 * time.Second):
		t.Fatal("authorization did not complete while Channel revoke was uncommitted")
	}

	// Commit the revoke.
	_, err = revokeTx.Exec(ctx, `
UPDATE scope_memberships SET removed_at = $3, updated_at = $3 WHERE scope_id = $1 AND member_id = $2 AND removed_at IS NULL
`, f.channelID, f.agentMemberID, time.Now().UTC())
	require.NoError(t, err)
	require.NoError(t, revokeTx.Commit(ctx))

	// After the revoke commits, no stale membership row authorizes: the next
	// authorization read must fail closed.
	_, err = AuthorizeActiveAgentTaskBoardChannel(ctx, f.db, f.workspaceID, f.agentMemberID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden, "authorization after committed Channel revoke must fail closed")
}

// TestConcurrencyTaskDeleteRacesAuthorization proves that a Task delete and an
// authorization read produce a linearizable result: the authorizer may observe
// the pre-delete snapshot while the delete is uncommitted, but once the delete
// commits, no stale Task row authorizes and the canonical resolver fails closed.
func TestConcurrencyTaskDeleteRacesAuthorization(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	f := seedConcurrencyTaskFixture(t, ctx, "del")

	deleteTx, err := f.db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	defer deleteTx.Rollback(ctx)

	// Lock the task row (the delete).
	_, err = deleteTx.Exec(ctx, `SELECT id FROM tasks WHERE id = $1 FOR UPDATE`, f.taskID)
	require.NoError(t, err)

	// The resolver runs concurrently with the uncommitted delete; it observes
	// the pre-delete snapshot (a valid linearizable outcome).
	done := make(chan error, 1)
	go func() {
		_, err := ResolveActiveTaskBoardChannel(ctx, f.db, f.workspaceID, f.taskScopeID)
		done <- err
	}()
	select {
	case <-done:
	case <-time.After(10 * time.Second):
		t.Fatal("authorization did not complete while Task delete was uncommitted")
	}

	_, err = deleteTx.Exec(ctx, `DELETE FROM tasks WHERE id = $1`, f.taskID)
	require.NoError(t, err)
	require.NoError(t, deleteTx.Commit(ctx))

	// After the delete commits, no stale Task row authorizes.
	_, err = ResolveActiveTaskBoardChannel(ctx, f.db, f.workspaceID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden, "authorization after committed Task delete must fail closed")
	_, err = AuthorizeActiveAgentTaskBoardChannel(ctx, f.db, f.workspaceID, f.agentMemberID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden)
}

// TestConcurrencyTaskMoveRacesAuthorization proves a barrier-controlled Task
// board move yields a linearizable result: after the move commits, only the new
// authoritative board Channel is valid; the old board Channel no longer
// authorizes and the resolver returns the new Channel.
func TestConcurrencyTaskMoveRacesAuthorization(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	f := seedConcurrencyTaskFixture(t, ctx, "move")

	// Seed a second board/channel (the move target).
	newChannelID := "chn_cc_move_new"
	newBoardID := "brd_cc_move_new"
	mustSeedChannelScope(t, ctx, f.db, f.workspaceID, newChannelID, "cc-move-new", "CC Move New", "tasks", "public", "", false, time.Now().UTC())
	mustSeedTaskBoard(t, ctx, f.db, f.workspaceID, newChannelID, newBoardID, time.Now().UTC())

	moveTx, err := f.db.BeginTx(ctx, coredb.TxOptions{})
	require.NoError(t, err)
	defer moveTx.Rollback(ctx)

	_, err = moveTx.Exec(ctx, `SELECT id FROM tasks WHERE id = $1 FOR UPDATE`, f.taskID)
	require.NoError(t, err)

	// The move repoints the task's board to the new board; the old board Channel
	// no longer authorizes.
	_, err = moveTx.Exec(ctx, `UPDATE tasks SET board_id = $2, channel_id = $3 WHERE id = $1`, f.taskID, newBoardID, newChannelID)
	require.NoError(t, err)

	// After the move commits, the resolver returns the NEW board Channel.
	require.NoError(t, moveTx.Commit(ctx))

	resolved, err := ResolveActiveTaskBoardChannel(ctx, f.db, f.workspaceID, f.taskScopeID)
	require.NoError(t, err)
	require.Equal(t, newChannelID, resolved.ChannelID, "after Task move only the new authoritative board Channel is valid")

	// The OLD board Channel membership no longer authorizes (the task moved away).
	_, err = AuthorizeActiveAgentTaskBoardChannel(ctx, f.db, f.workspaceID, f.agentMemberID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden, "the old board Channel must not authorize a moved Task")
}

// TestLegacyNullBoardMigrationDeniesBeforeBackfill proves a legacy task with a
// NULL board_id is denied until the matching-board backfill runs: the resolver
// requires tasks.board_id -> task_boards, so a NULL-board legacy task resolves
// no authoritative Channel and fails closed non-enumerating.
func TestLegacyNullBoardMigrationDeniesBeforeBackfill(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	f := seedConcurrencyTaskFixture(t, ctx, "legacy_null")

	// Exactly one mutation: detach the board (legacy task with NULL board_id).
	mustExec(t, ctx, f.db, `UPDATE tasks SET board_id = NULL WHERE id = $1`, f.taskID)

	// Before backfill: the resolver fails closed (no board -> no authoritative
	// Channel), even though the denormalized channel_id points at a real Channel.
	_, err := ResolveActiveTaskBoardChannel(ctx, f.db, f.workspaceID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden, "a legacy NULL-board task must be denied before backfill")
	_, err = AuthorizeActiveAgentTaskBoardChannel(ctx, f.db, f.workspaceID, f.agentMemberID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden)
}

// TestLegacyMigrationMatchingBoardBackfillEnablesOnlyThatChannel proves the
// atomic matching-board backfill (repointing board_id to the board whose
// channel matches the task's channel_id) enables only that board's Channel
// member. The resolver then resolves the board Channel and authorizes the agent
// who is a member of it; a task left pointing at a mismatched board stays
// denied.
func TestLegacyMigrationMatchingBoardBackfillEnablesOnlyThatChannel(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	f := seedConcurrencyTaskFixture(t, ctx, "legacy_backfill")

	// A second board on a DIFFERENT channel.
	otherChannelID := "chn_cc_backfill_other"
	otherBoardID := "brd_cc_backfill_other"
	mustSeedChannelScope(t, ctx, f.db, f.workspaceID, otherChannelID, "cc-backfill-other", "CC Backfill Other", "tasks", "public", "", false, time.Now().UTC())
	mustSeedTaskBoard(t, ctx, f.db, f.workspaceID, otherChannelID, otherBoardID, time.Now().UTC())

	// The task's denormalized channel_id still points at the original channel.
	// Backfilling board_id to the MISMATCHED board must remain denied.
	mustExec(t, ctx, f.db, `UPDATE tasks SET board_id = $2 WHERE id = $1`, f.taskID, otherBoardID)
	_, err := ResolveActiveTaskBoardChannel(ctx, f.db, f.workspaceID, f.taskScopeID)
	require.ErrorIs(t, err, ErrForbidden, "a backfill to a board whose channel mismatches the task channel must remain denied")

	// Atomic matching-board backfill: repoint to the board whose channel matches
	// the task's channel_id (the original board).
	mustExec(t, ctx, f.db, `UPDATE tasks SET board_id = $2 WHERE id = $1`, f.taskID, f.boardID)
	resolved, err := ResolveActiveTaskBoardChannel(ctx, f.db, f.workspaceID, f.taskScopeID)
	require.NoError(t, err)
	require.Equal(t, f.channelID, resolved.ChannelID, "after matching-board backfill only that board's Channel is valid")
	_, err = AuthorizeActiveAgentTaskBoardChannel(ctx, f.db, f.workspaceID, f.agentMemberID, f.taskScopeID)
	require.NoError(t, err, "the matching board's Channel member is authorized after backfill")
}
