//go:build e2e

// Package e2e_test contains the AgentTeam end-to-end test suite. The
// projects_writer E2E tests pin the C4.3 writer-side Project binding contract
// against a REAL fully-migrated TiDB (post-v243): the writer MUST persist a
// non-empty project_id on every review_contexts row it creates/updates whose
// target tuple resolves to a Project, legacy NULL/empty/other project_id rows
// MUST be excluded from the Project Reviews tab, and Project delete MUST
// unlink bound reviews back to NULL.
//
// These tests bridge the schema cutover (projects_migration_e2e_test.go) and
// the unit-layer writer contracts (internal/store/review/reviews_project_binding_test.go):
//
//   - schema e2e pins what the migration ships;
//   - unit tests pin the store method contracts in isolation;
//   - THIS file pins that the store's contracts actually hold against a real
//     fully-migrated TiDB driven through the registered migration FS — the
//     missing middle where schema shape meets writer behavior.
//
// What this file deliberately does NOT assert:
//
//   - Schema shape (frozen column lists, indexes, CHECK clauses). That is the
//     C5.1 migration e2e's contract. Drift in schema surfaces there, not here.
//   - HTTP wiring or DAO translation. The store's typed errors and model
//     shapes are the boundary the HTTP layer adapts; HTTP smoke belongs to
//     C5.3.
//   - Pre-v243 backfill or NOT NULL tightening. The dual-approved v243
//     contract is nullable + no backfill; legacy NULL rows are the explicit
//     subject of TestProjectsWriter_LegacyNullReviewsExcludedFromProjectTab_E2E.
package e2e_test

import (
	"context"
	"database/sql"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/access"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/review"

	"github.com/tidbcloud/AgentTeam/e2e/testkit"
)

// writerE2EOptions returns review.Options with all cross-domain access
// callbacks stubbed permissively, EXCEPT LoadHumanWorkspaceMember which is
// left nil so the store falls back to access.LoadHumanWorkspaceMember and
// reads the real members table seeded by the e2e testkit. This matches the
// production path the C4.3 writer uses for owner resolution.
func writerE2EOptions() review.Options {
	return review.Options{
		LoadAuthorizedHumanThreadAccess: func(ctx context.Context, _ review.Queryer, _, _, _ string, _ access.HumanAction) (access.HumanThreadAccess, error) {
			return access.HumanThreadAccess{}, nil
		},
		RequireAgentReviewRead: func(ctx context.Context, _, _, _ string) (access.HumanAgentAccess, error) {
			return access.HumanAgentAccess{}, nil
		},
		RequireAgentOperation: func(ctx context.Context, _, _, _ string) (access.HumanAgentAccess, error) {
			return access.HumanAgentAccess{}, nil
		},
		AgentByID: func(ctx context.Context, _ review.Queryer, _, _ string) (agent.AgentView, error) {
			return agent.AgentView{}, nil
		},
	}
}

// startWriterE2EDB returns a *review.Store wired against a fresh, fully-
// migrated TiDB. The caller seeds workspace/machine/owner state via the
// e2etestkit helpers and exercises the store's writer path.
func startWriterE2EDB(t *testing.T, ctx context.Context) (*review.Store, *sql.DB) {
	t.Helper()

	dsn := startE2EInitializedTiDB(t)
	sqlDB := testkit.OpenE2EDB(t, dsn)
	t.Cleanup(func() {
		if err := sqlDB.Close(); err != nil {
			t.Errorf("close writer e2e db: %v", err)
		}
	})

	coreDB := coredb.WrapSQLDB(sqlDB)
	store := review.New(coreDB, writerE2EOptions())
	return store, sqlDB
}

// TestProjectsWriter_CreateBindsProjectID_E2E pins the C4.3 contract on the
// human-driven create path against a fully-migrated real TiDB: a review
// created via CreateReviewContextForUser MUST have a non-empty project_id
// persisted on the row, AND that review MUST be reachable from
// ListReviewContextsForProject for the same project_id. This is the
// end-to-end pin for planner seq=531 guardrail 2 ("review_create 主链必须
// 写 project_id").
//
// The unit-layer version of this contract lives at
// internal/store/review/reviews_project_binding_test.go::
// TestCreateReviewContextForUser_BindsToProject — this e2e adds the
// "against a real fully-migrated TiDB" axis.
func TestProjectsWriter_CreateBindsProjectID_E2E(t *testing.T) {
	t.Parallel()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	store, db := startWriterE2EDB(t, ctx)
	ws := testkit.SeedWorkspaceWithMachineAndOwner(t, ctx, db, "c52_create")

	const (
		rootPath = "/repo/c52-create"
		baseRef  = "main"
		headRef  = "feature"
	)

	rc, err := store.CreateReviewContextForUser(ctx, ws.UserID, agent.UpsertReviewContextParams{
		WorkspaceID: ws.WorkspaceID,
		MachineID:   ws.MachineID,
		RootPath:    rootPath,
		BaseRef:     baseRef,
		HeadRef:     headRef,
		DisplayName: "C5.2 create",
	})
	require.NoError(t, err)
	require.NotEmpty(t, rc.ProjectID,
		"CreateReviewContextForUser MUST bind a non-empty project_id resolved via get-or-create against the target tuple")

	// The persisted row carries the same project_id. NULL would surface here as
	// a scan into a Go string with empty value, so we explicitly check both
	// that the row exists and the column is non-NULL.
	var persistedProjectID sql.NullString
	require.NoError(t,
		db.QueryRowContext(ctx,
			`SELECT project_id FROM review_contexts WHERE id = ?`,
			rc.ID,
		).Scan(&persistedProjectID),
		"read back review_contexts.project_id after create",
	)
	require.True(t, persistedProjectID.Valid && persistedProjectID.String != "",
		"review_contexts.project_id MUST be non-NULL and non-empty on the persisted row (got %v)", persistedProjectID)
	require.Equal(t, rc.ProjectID, persistedProjectID.String,
		"returned ReviewContext.ProjectID must equal the persisted review_contexts.project_id")

	// The same review MUST appear in the Project Reviews tab.
	contexts, err := store.ListReviewContextsForProject(ctx, ws.WorkspaceID, rc.ProjectID)
	require.NoError(t, err)
	require.Len(t, contexts, 1,
		"Project Reviews tab MUST list exactly the one review bound to this project")
	require.Equal(t, rc.ID, contexts[0].ID)

	// The get-or-create path MUST have minted exactly one project for this
	// workspace — the review's project. Zero would mean the binding is bogus;
	// two would mean get-or-create raced and produced duplicates.
	var projectCount int
	require.NoError(t,
		db.QueryRowContext(ctx,
			`SELECT COUNT(*) FROM projects WHERE workspace_id = ?`,
			ws.WorkspaceID,
		).Scan(&projectCount),
	)
	require.Equal(t, 1, projectCount,
		"CreateReviewContextForUser MUST mint exactly one project for the workspace via get-or-create — got %d", projectCount)
}

// TestProjectsWriter_UpdateRebindsOnTargetChange_E2E pins planner seq=531
// guardrail 1 on a real TiDB: when the root changes, the writer MUST re-resolve
// the project via get-or-create against the new target tuple and persist the
// new project_id. The stale project_id MUST NOT survive — keeping it would
// dangle the review under the wrong project. ref-only changes (base/head
// unchanged target tuple) MUST preserve the existing project_id.
//
// The unit-layer version lives at
// internal/store/review/reviews_project_binding_test.go::
// TestUpdateReviewContextForUser_RebindsProjectOnTargetChange — this e2e
// adds the "real TiDB round-trip" axis.
func TestProjectsWriter_UpdateRebindsOnTargetChange_E2E(t *testing.T) {
	t.Parallel()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	store, db := startWriterE2EDB(t, ctx)
	ws := testkit.SeedWorkspaceWithMachineAndOwner(t, ctx, db, "c52_rebind")

	const (
		rootA = "/repo/c52-rebind-a"
		rootB = "/repo/c52-rebind-b"
	)

	rc, err := store.CreateReviewContextForUser(ctx, ws.UserID, agent.UpsertReviewContextParams{
		WorkspaceID:   ws.WorkspaceID,
		MachineID:     ws.MachineID,
		RootPath:      rootA,
		BaseRef:       "main",
		HeadRef:       "feature",
		DisplayName:   "C5.2 rebind",
		SourceAgentID: "agt_c52_rebind",
	})
	require.NoError(t, err)
	projectA := rc.ProjectID
	require.NotEmpty(t, projectA)

	// Move the review onto a different root. The stale projectA MUST NOT
	// survive — the update MUST re-resolve against root B. SourceAgentID is
	// carried forward so ensureReviewContextAccess's agent path resolves the
	// same way on update as on create (the permissive RequireAgentOperation
	// stub in writerE2EOptions grants).
	updated, err := store.UpdateReviewContextForUser(ctx, ws.UserID, ws.WorkspaceID, rc.ID, agent.UpsertReviewContextParams{
		WorkspaceID:   ws.WorkspaceID,
		MachineID:     ws.MachineID,
		RootPath:      rootB,
		BaseRef:       "main",
		HeadRef:       "feature",
		DisplayName:   "C5.2 rebind",
		SourceAgentID: "agt_c52_rebind",
	})
	require.NoError(t, err)
	require.NotEqual(t, projectA, updated.ProjectID,
		"target change MUST re-bind to a different project_id; keeping the stale id would dangle the review under the wrong project")
	require.NotEmpty(t, updated.ProjectID)

	// Project A's Reviews tab no longer lists this review.
	aContexts, err := store.ListReviewContextsForProject(ctx, ws.WorkspaceID, projectA)
	require.NoError(t, err)
	require.Empty(t, aContexts,
		"Project A's Reviews tab MUST be empty after the review moved to Project B")

	// Project B's Reviews tab lists exactly this review.
	bContexts, err := store.ListReviewContextsForProject(ctx, ws.WorkspaceID, updated.ProjectID)
	require.NoError(t, err)
	require.Len(t, bContexts, 1)
	require.Equal(t, rc.ID, bContexts[0].ID)

	// Exactly two projects for this workspace — A and B.
	var projectCount int
	require.NoError(t,
		db.QueryRowContext(ctx,
			`SELECT COUNT(*) FROM projects WHERE workspace_id = ?`,
			ws.WorkspaceID,
		).Scan(&projectCount),
	)
	require.Equal(t, 2, projectCount,
		"target change MUST have minted a second project via get-or-create — got %d", projectCount)
}

// TestProjectsWriter_LegacyNullReviewsExcludedFromProjectTab_E2E pins the
// Project Reviews tab filter contract on a real TiDB: rows whose
// project_id is NULL, empty-string, or points at a different project MUST
// be excluded from ListReviewContextsForProject's results. Only the one
// row whose project_id matches EXACTLY is returned.
//
// Why this matters: v243 ships with `project_id NULL DEFAULT NULL` and no
// backfill (dual-approved C5.1 contract), so every review_contexts row
// created before v243 enters the post-cutover database with project_id =
// NULL. The Project Reviews tab MUST NOT surface those legacy rows under
// any project — they have no project, period. The SQL contract
// "project_id = $2" implements this: NULL never equals anything (even
// NULL), and an empty string never equals a real project id. This e2e pins that
// contract against a real TiDB round-trip so a future switch to e.g.
// IS NOT DISTINCT FROM would surface as a regression.
//
// The unit-layer version lives at
// internal/store/review/reviews_project_binding_test.go::
// TestListReviewContextsForProject_ExcludesNullAndEmptyProjectID — this
// e2e adds the "real TiDB NULL semantics" axis.
func TestProjectsWriter_LegacyNullReviewsExcludedFromProjectTab_E2E(t *testing.T) {
	t.Parallel()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	store, db := startWriterE2EDB(t, ctx)
	ws := testkit.SeedWorkspaceWithMachineAndOwner(t, ctx, db, "c52_excl")

	// Project + bound review via the C4.3 writer path.
	project, err := store.CreateProjectForUser(ctx, ws.UserID, agent.CreateProjectParams{
		WorkspaceID: ws.WorkspaceID,
		MachineID:   ws.MachineID,
		RootPath:    "/repo/c52-excl-real",
		Name:        "C5.2 exclusion target",
		Visibility:  agent.ProjectVisibilityPrivate,
	})
	require.NoError(t, err)
	bound, err := store.CreateReviewContextForUser(ctx, ws.UserID, agent.UpsertReviewContextParams{
		WorkspaceID: ws.WorkspaceID,
		MachineID:   ws.MachineID,
		RootPath:    "/repo/c52-excl-real",
		BaseRef:     "main",
		HeadRef:     "feature",
	})
	require.NoError(t, err)
	require.Equal(t, project.ID, bound.ProjectID,
		"precondition: writer-bound review must point at the seeded project")

	// Direct-seed legacy / defensive rows that MUST NOT leak into the listing.
	// C4.3 paths never produce these, but legacy rows from before v243 (or a
	// future bug) could — the SQL filter must hold against real TiDB NULL
	// semantics.
	legacyRows := []struct {
		label     string
		projectID any
	}{
		{"null_project_id", nil},
		{"empty_project_id", ""},
		{"other_project_id", "prj_does_not_exist_c52"},
	}
	now := time.Now().UTC()
	for _, row := range legacyRows {
		id := "rcx_c52_excl_" + row.label
		_, err := db.ExecContext(ctx, `
INSERT INTO review_contexts (id, workspace_id, target_hash, thread_id, source_agent_id, machine_id, root_path,
  base_ref, head_ref, display_name, pr_url, project_id, created_by_member_id, updated_by_member_id, created_at, updated_at)
VALUES (?, ?, ?, NULL, NULL, ?, ?, 'main', 'feature', ?, '', ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE project_id = VALUES(project_id)
`,
			id, ws.WorkspaceID, "hash_c52_excl_"+row.label,
			ws.MachineID, "/repo/c52-excl-"+row.label,
			"Legacy "+row.label, row.projectID,
			ws.OwnerMemberID, ws.OwnerMemberID, now, now,
		)
		require.NoError(t, err, "seed legacy row: "+row.label)
	}

	contexts, err := store.ListReviewContextsForProject(ctx, ws.WorkspaceID, project.ID)
	require.NoError(t, err)
	require.Len(t, contexts, 1,
		"Project Reviews tab MUST return only the C4.3-bound review; NULL/empty/other project_id excluded by the SQL filter")
	require.Equal(t, bound.ID, contexts[0].ID)
}

// TestProjectsWriter_DeleteProjectUnlinksReviewsToNULL_E2E pins the Danger
// Zone contract on a real TiDB: when the owner deletes a Project, every
// review_contexts row whose project_id was this project is unlinked to NULL
// (NOT deleted). The review rows survive intact; only the project_id link
// is reset. This is what makes the nullable project_id contract
// load-bearing — without it, Project delete would have to either cascade
// (lose reviews) or refuse (refuse delete).
//
// The unit-layer version lives at
// internal/store/review/projects_test.go::TestProject_Delete_UnlinksReviewsToNULL
// — this e2e adds the "real TiDB transaction commit" axis, ensuring the
// unlink survives all the way to disk via the store's BeginTx/Commit path.
func TestProjectsWriter_DeleteProjectUnlinksReviewsToNULL_E2E(t *testing.T) {
	t.Parallel()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	store, db := startWriterE2EDB(t, ctx)
	ws := testkit.SeedWorkspaceWithMachineAndOwner(t, ctx, db, "c52_unlink")

	// Create a project + a writer-bound review via the C4.3 path so the
	// review's project_id is the canonical id, not a hand-seeded sentinel.
	rc, err := store.CreateReviewContextForUser(ctx, ws.UserID, agent.UpsertReviewContextParams{
		WorkspaceID: ws.WorkspaceID,
		MachineID:   ws.MachineID,
		RootPath:    "/repo/c52-unlink",
		BaseRef:     "main",
		HeadRef:     "feature",
	})
	require.NoError(t, err)
	require.NotEmpty(t, rc.ProjectID, "precondition: writer-bound review has a project_id")
	projectID := rc.ProjectID

	// Owner-initiated delete MUST succeed and unlink every bound review.
	require.NoError(t,
		store.DeleteProjectForUser(ctx, ws.UserID, ws.WorkspaceID, projectID),
		"owner delete MUST succeed; the unlink is the contract we are pinning",
	)

	// The review row MUST still exist — only the project_id link is reset.
	var linkedID sql.NullString
	require.NoError(t,
		db.QueryRowContext(ctx,
			`SELECT project_id FROM review_contexts WHERE id = ?`,
			rc.ID,
		).Scan(&linkedID),
		"read back review_contexts.project_id after project delete",
	)
	require.False(t, linkedID.Valid,
		"review_contexts.project_id MUST be unlinked to NULL (not deleted) on Project delete — got %v", linkedID)

	// The project row itself MUST be gone.
	var projectCount int
	require.NoError(t,
		db.QueryRowContext(ctx,
			`SELECT COUNT(*) FROM projects WHERE id = ? AND workspace_id = ?`,
			projectID, ws.WorkspaceID,
		).Scan(&projectCount),
	)
	require.Zero(t, projectCount,
		"Project row MUST be deleted by DeleteProjectForUser — got %d rows", projectCount)
}
