package review

import (
	"context"
	"sync"
	"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/agent"
)

// startFreshIsolatedDB opens a fresh, fully-migrated TiDB database that is
// NOT wrapped in the rollback fixture. The rollback fixture funnels all
// queries through a single outer transaction on a single connection, which
// makes real concurrency impossible — it would serialize two goroutines on
// the same connection. For the C4 concurrency regression we need multiple
// real connections issuing real BEGIN/COMMIT transactions, so the TiDB
// unique-key path is actually exercised.
//
// Each call lands in its own freshly-cloned database; t.Cleanup drops it.
func startFreshIsolatedDB(t *testing.T, ctx context.Context) coredb.DB {
	t.Helper()
	rootDSN := reviewTiDB.RootDSN(t)
	dsn := testkit.StartInitializedTiDBWithRootDSN(t, rootDSN)
	sqlDB, err := testkit.OpenTiDBContext(ctx, dsn)
	require.NoError(t, err)
	// Multiple connections so two goroutines can run real concurrent
	// transactions against the unique-key race.
	sqlDB.SetMaxOpenConns(8)
	sqlDB.SetMaxIdleConns(8)
	t.Cleanup(func() {
		if err := sqlDB.Close(); err != nil {
			t.Errorf("close fresh isolated db: %v", err)
		}
	})
	return coredb.WrapSQLDB(sqlDB)
}

// TestGetOrCreateProjectForUser_ConcurrentSameTargetProducesOneRow is the
// load-bearing concurrency regression Codex2 + planner seq=628 required.
//
// What it pins:
//   - Two concurrent GetOrCreateProjectForUser calls on the same
//     (workspace, machine, root) tuple MUST produce exactly ONE project row.
//   - Both callers MUST observe the SAME project_id.
//
// Why it has to be a real-TiDB two-tx test:
//   - The C4.2 implementation originally used SELECT ... FOR UPDATE in a tx,
//     then conditional INSERT. TiDB does not gap-lock missing rows, so two
//     transactions can both see "no row" and both INSERT — splitting the
//     project identity across two ids. That bug only surfaces under real
//     concurrency; a single connection (the rollback fixture) cannot
//     reproduce it because everything serializes on one tx.
//
// What it does NOT pin:
//   - The exact ordering of which caller wins. Either caller may mint the
//     canonical row; the loser MUST reload by target_hash and return the
//     winner's id.
func TestGetOrCreateProjectForUser_ConcurrentSameTargetProducesOneRow(t *testing.T) {
	// Real concurrency + multiple connections: not parallel-test safe under
	// the package fixture's shared runtime, but the isolated database per call
	// keeps the test self-contained.
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	db := startFreshIsolatedDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "goc_concurrent")
	store := newProjectTestStore(db)

	const concurrency = 8
	target := agent.GetOrCreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_concurrent",
		RootPath:    "/repo/concurrent-target",
		Name:        "Concurrent Project",
	}

	// Barrier: open all goroutines, then release them simultaneously to
	// maximize race chance. Each goroutine uses a different user so the
	// owner_user_id of the minted row varies (but only the winner's owner
	// persists — losers reload the canonical row).
	barrier := make(chan struct{})
	var wg sync.WaitGroup
	results := make([]agent.Project, concurrency)
	errs := make([]error, concurrency)
	users := []string{seed.OwnerUserID, seed.ManageUserID, seed.ViewUserID}

	for i := 0; i < concurrency; i++ {
		wg.Add(1)
		// Assign users round-robin. Multiple goroutines per user are fine —
		// the get-or-create contract holds regardless of caller identity.
		user := users[i%len(users)]
		idx := i
		go func() {
			defer wg.Done()
			<-barrier
			project, err := store.GetOrCreateProjectForUser(ctx, user, target)
			results[idx] = project
			errs[idx] = err
		}()
	}
	close(barrier)
	wg.Wait()

	// Every goroutine must return without error and with a non-empty id.
	for i, err := range errs {
		require.NoError(t, err, "goroutine %d GetOrCreateProjectForUser failed", i)
		require.NotEmpty(t, results[i].ID, "goroutine %d returned empty project id", i)
	}

	// All goroutines observed the same project_id — the load-bearing assertion.
	firstID := results[0].ID
	for i, r := range results {
		require.Equal(t, firstID, r.ID,
			"goroutine %d observed a different project_id (%q vs %q) — concurrent get-or-create on the same target MUST converge on one row",
			i, r.ID, firstID)
	}

	// Exactly one row in the projects table for this workspace. If two rows
	// existed, the unique constraint would not be holding — but the resolver
	// would also have returned different ids (caught above). This assertion
	// is the direct schema-side check.
	var rowCount int
	require.NoError(t,
		db.QueryRow(ctx,
			`SELECT COUNT(*) FROM projects WHERE workspace_id = $1 AND machine_id = $2 AND root_path = $3`,
			seed.WorkspaceID, target.MachineID, target.RootPath,
		).Scan(&rowCount),
	)
	require.Equal(t, 1, rowCount,
		"concurrent get-or-create MUST produce exactly one row; got %d (target_hash unique constraint may be missing)", rowCount)

	// Also verify the target_hash column is populated and matches what the
	// resolver would compute.
	var targetHash string
	require.NoError(t,
		db.QueryRow(ctx,
			`SELECT target_hash FROM projects WHERE workspace_id = $1 AND id = $2`,
			seed.WorkspaceID, firstID,
		).Scan(&targetHash),
	)
	require.Equal(t,
		projectTargetHash(seed.WorkspaceID, target.MachineID, target.RootPath),
		targetHash,
		"persisted target_hash must match the resolver's deterministic computation",
	)
}

// TestGetOrCreateProjectForUser_SequentialReuseDoesNotMintDuplicate is the
// single-connection counterpart: outside the race window, the second call
// MUST reuse the existing row. Runs on the rollback fixture (no real
// concurrency needed); pins the steady-state contract the C4.3 review writer
// depends on.
func TestGetOrCreateProjectForUser_SequentialReuseDoesNotMintDuplicate(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "goc_seq")
	store := newProjectTestStore(db)

	target := agent.GetOrCreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_goc_seq",
		RootPath:    "/repo/goc-seq",
	}
	first, err := store.GetOrCreateProjectForUser(ctx, seed.OwnerUserID, target)
	require.NoError(t, err)
	second, err := store.GetOrCreateProjectForUser(ctx, seed.ManageUserID, target)
	require.NoError(t, err)
	require.Equal(t, first.ID, second.ID, "second call MUST reuse existing row, not mint a new one")
}

// TestProjectTargetHash_StabilityAcrossTrims pins the trim semantics: the
// hash MUST ignore trailing whitespace on workspaceID/machineID (control IDs)
// but MUST distinguish trailing whitespace on rootPath (canonical paths).
// A regression here would either collapse distinct roots into one project
// (data loss) or split the same target into two (identity divergence).
func TestProjectTargetHash_StabilityAcrossTrims(t *testing.T) {
	w := "wsp_x"
	m := "mac_x"
	r := "/repo/x"

	base := projectTargetHash(w, m, r)
	require.Equal(t, base, projectTargetHash(" "+w, m, r), "leading whitespace on workspaceID should not change hash")
	require.Equal(t, base, projectTargetHash(w, " "+m, r), "leading whitespace on machineID should not change hash")
	require.Equal(t, base, projectTargetHash(w, m+" ", r), "trailing whitespace on machineID should not change hash")

	// rootPath is byte-exact: trailing whitespace yields a different hash.
	require.NotEqual(t, base, projectTargetHash(w, m, r+" "),
		"trailing whitespace on rootPath MUST yield a distinct hash (byte-exact path identity)")

	// Different workspace / machine / root each yield a distinct hash.
	require.NotEqual(t, base, projectTargetHash("other", m, r))
	require.NotEqual(t, base, projectTargetHash(w, "other", r))
	require.NotEqual(t, base, projectTargetHash(w, m, "/repo/other"))
}
