package review

import (
	"context"
	"errors"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/core/identifiers"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

func TestProjectGrantAuthorizesReviewExplorerListAndDetail(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "review_project_grant")
	store := newProjectTestStore(db)

	rc, err := store.CreateReviewContextForUser(ctx, seed.OwnerUserID, agent.UpsertReviewContextParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_review_project_grant",
		RootPath:    "/repo/review-project-grant",
		BaseRef:     "main",
		HeadRef:     "feature",
	})
	require.NoError(t, err)
	require.NotEmpty(t, rc.ProjectID)

	_, err = store.UpsertProjectGrantForUser(
		ctx,
		seed.OwnerUserID,
		seed.WorkspaceID,
		rc.ProjectID,
		seed.ViewMemberID,
		agent.ProjectRoleView,
	)
	require.NoError(t, err)

	visible, err := store.ListVisibleReviewContextsForUser(
		ctx,
		seed.ViewUserID,
		seed.WorkspaceID,
	)
	require.NoError(t, err)
	require.Len(t, visible, 1)
	require.Equal(t, rc.ID, visible[0].ID)

	for _, testCase := range []struct {
		name   string
		userID string
	}{
		{name: "owner", userID: seed.OwnerUserID},
		{name: "project viewer", userID: seed.ViewUserID},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			loaded, err := store.GetReviewContextForUser(
				ctx,
				testCase.userID,
				seed.WorkspaceID,
				rc.ID,
			)
			require.NoError(t, err)
			require.Equal(t, rc.ID, loaded.ID)
		})
	}

	_, err = store.GetReviewContextForUser(
		ctx,
		seed.OutsiderUserID,
		seed.WorkspaceID,
		rc.ID,
	)
	require.True(t, errors.Is(err, storeerr.ErrNotFound))
}

// TestCreateReviewContextForUser_BindsToProject pins the C4.3 wiring contract
// on the human-driven create path: CreateReviewContextForUser MUST resolve a
// Project for the (workspace, machine, root) target via get-or-create and
// persist that project_id on the new review_context row. Reviewer2 seq=545
// guardrail: "review_create 主链必须写 project_id".
func TestCreateReviewContextForUser_BindsToProject(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "rc_bind_user")
	store := newProjectTestStore(db)

	machineID := "mac_rc_bind_user"
	root := "/repo/rc-bind-user"

	// Create the project ahead of time so we know its id; the review create
	// path must resolve to the SAME row via get-or-create.
	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, agent.CreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   machineID,
		RootPath:    root,
		Name:        "Pre-existing Project",
		Visibility:  agent.ProjectVisibilityPrivate,
	})
	require.NoError(t, err)

	rc, err := store.CreateReviewContextForUser(ctx, seed.OwnerUserID, agent.UpsertReviewContextParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   machineID,
		RootPath:    root,
		BaseRef:     "main",
		HeadRef:     "feature",
	})
	require.NoError(t, err)
	require.Equal(t, project.ID, rc.ProjectID,
		"CreateReviewContextForUser MUST bind the review to the project resolved from (workspace, machine, root)")

	// The review must be visible from the Project's Reviews tab.
	contexts, err := store.ListReviewContextsForProject(ctx, seed.WorkspaceID, project.ID)
	require.NoError(t, err)
	require.Len(t, contexts, 1)
	require.Equal(t, rc.ID, contexts[0].ID)
}

// TestCreateReviewContextForAgent_BindsToProject mirrors the user-path binding
// test for the agent-runtime review_add main chain (planner seq=531
// guardrail 2). The owner of the minted project is the user that owns the
// Agent's trusted machine.
func TestCreateReviewContextForAgent_BindsToProject(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "rc_bind_agent")
	seedProjectAgentMachine(t, ctx, db, seed, "mac_rc_bind_agent", seed.OwnerUserID)
	store := newProjectTestStore(db)

	authenticated := agent.AuthenticatedAgent{
		AgentID:     "agt_rc_bind_agent",
		WorkspaceID: seed.WorkspaceID,
		MemberID:    seed.OwnerMemberID,
		MachineID:   "mac_rc_bind_agent",
	}

	rc, err := store.CreateReviewContextForAgent(ctx, authenticated, agent.UpsertReviewContextParams{
		RootPath: "/repo/rc-bind-agent",
		BaseRef:  "main",
		HeadRef:  "feature",
	})
	require.NoError(t, err)
	require.NotEmpty(t, rc.ProjectID, "agent-runtime create MUST bind a project_id")

	// The minted project is owned by the machine owner's user.
	project, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, rc.ProjectID)
	require.NoError(t, err)
	require.Equal(t, seed.OwnerUserID, project.OwnerUserID,
		"project minted via agent-runtime create is owned by the machine owner's user")
}

func TestCreateReviewContextForAgent_PreservesExistingProjectName(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "rc_project_name")
	seedProjectAgentMachine(t, ctx, db, seed, "mac_rc_project_name", seed.OwnerUserID)
	store := newProjectTestStore(db)

	authenticated := agent.AuthenticatedAgent{
		AgentID:     "agt_rc_project_name",
		WorkspaceID: seed.WorkspaceID,
		MemberID:    seed.OwnerMemberID,
		MachineID:   "mac_rc_project_name",
	}

	first, err := store.CreateReviewContextForAgent(ctx, authenticated, agent.UpsertReviewContextParams{
		RootPath:    "/repo/rc-project-name",
		BaseRef:     "main",
		HeadRef:     "feature-a",
		DisplayName: "Initial Project Name",
	})
	require.NoError(t, err)

	manualName := "Manually Renamed Project"
	renamed, err := store.UpdateProjectForUser(
		ctx,
		seed.OwnerUserID,
		seed.WorkspaceID,
		first.ProjectID,
		agent.UpdateProjectParams{Name: &manualName},
	)
	require.NoError(t, err)
	require.Equal(t, manualName, renamed.Name)

	second, err := store.CreateReviewContextForAgent(ctx, authenticated, agent.UpsertReviewContextParams{
		RootPath:    "/repo/rc-project-name",
		BaseRef:     "main",
		HeadRef:     "feature-b",
		DisplayName: "Later Review Display Name",
	})
	require.NoError(t, err)
	require.Equal(t, first.ProjectID, second.ProjectID,
		"reviews for the same workspace, machine, and root must reuse the Project")

	project, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, first.ProjectID)
	require.NoError(t, err)
	require.Equal(t, manualName, project.Name,
		"review_add metadata must not overwrite the existing Project name")
}

// TestUpdateReviewContextForUser_RebindsProjectOnTargetChange pins planner
// seq=531 guardrail 1: when machine/root changes, the review MUST NOT keep the
// stale project_id — it is re-resolved via get-or-create against the new
// target. baseRef/headRef changes alone do NOT trigger re-binding because
// project membership is keyed on (workspace, machine, root) only.
func TestUpdateReviewContextForUser_RebindsProjectOnTargetChange(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "rc_rebind")
	store := newProjectTestStore(db)

	const (
		machineID = "mac_rc_rebind"
		rootA     = "/repo/rc-rebind-a"
		rootB     = "/repo/rc-rebind-b"
	)

	rc, err := store.CreateReviewContextForUser(ctx, seed.OwnerUserID, agent.UpsertReviewContextParams{
		WorkspaceID:   seed.WorkspaceID,
		MachineID:     machineID,
		RootPath:      rootA,
		BaseRef:       "main",
		HeadRef:       "feature",
		SourceAgentID: "agt_rc_rebind",
	})
	require.NoError(t, err)
	projectA, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, rc.ProjectID)
	require.NoError(t, err)
	require.Equal(t, rootA, projectA.RootPath, "initial binding points at root A's project")

	// Move the review onto a different root. The stale project_id (projectA)
	// must NOT survive — the update must re-resolve against root B.
	updated, err := store.UpdateReviewContextForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, rc.ID, agent.UpsertReviewContextParams{
		WorkspaceID:   seed.WorkspaceID,
		MachineID:     machineID,
		RootPath:      rootB,
		BaseRef:       "main",
		HeadRef:       "feature",
		SourceAgentID: "agt_rc_rebind",
	})
	require.NoError(t, err)
	require.NotEqual(t, projectA.ID, updated.ProjectID,
		"target change MUST re-bind to a different project; keeping the stale id would dangle the review under the wrong project")

	projectB, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, updated.ProjectID)
	require.NoError(t, err)
	require.Equal(t, rootB, projectB.RootPath, "re-bound project tracks the new root")

	// Project A no longer lists this review; Project B does.
	aContexts, err := store.ListReviewContextsForProject(ctx, seed.WorkspaceID, projectA.ID)
	require.NoError(t, err)
	require.Empty(t, aContexts, "review moved off project A; its Reviews tab must be empty")
	bContexts, err := store.ListReviewContextsForProject(ctx, seed.WorkspaceID, projectB.ID)
	require.NoError(t, err)
	require.Len(t, bContexts, 1)
	require.Equal(t, rc.ID, bContexts[0].ID)
}

// TestUpdateReviewContextForUser_PreservesProjectOnRefOnlyChange pins the
// counterpart: when only baseRef/headRef change (machine+root unchanged), the
// project binding is still valid and MUST be preserved. This protects the
// common "refresh refs" flow from accidentally orphaning a review from its
// project.
func TestUpdateReviewContextForUser_PreservesProjectOnRefOnlyChange(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "rc_preserve")
	store := newProjectTestStore(db)

	rc, err := store.CreateReviewContextForUser(ctx, seed.OwnerUserID, agent.UpsertReviewContextParams{
		WorkspaceID:   seed.WorkspaceID,
		MachineID:     "mac_rc_preserve",
		RootPath:      "/repo/rc-preserve",
		BaseRef:       "main",
		HeadRef:       "feature",
		SourceAgentID: "agt_rc_preserve",
	})
	require.NoError(t, err)
	originalProject := rc.ProjectID
	require.NotEmpty(t, originalProject)

	updated, err := store.UpdateReviewContextForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, rc.ID, agent.UpsertReviewContextParams{
		WorkspaceID:   seed.WorkspaceID,
		MachineID:     "mac_rc_preserve",
		RootPath:      "/repo/rc-preserve",
		BaseRef:       "main",
		HeadRef:       "release",
		SourceAgentID: "agt_rc_preserve",
	})
	require.NoError(t, err)
	require.Equal(t, originalProject, updated.ProjectID,
		"ref-only change MUST preserve project_id; project membership is keyed on (workspace, machine, root)")

	// Still listed under the same project.
	contexts, err := store.ListReviewContextsForProject(ctx, seed.WorkspaceID, originalProject)
	require.NoError(t, err)
	require.Len(t, contexts, 1)
	require.Equal(t, rc.ID, contexts[0].ID)
}

// TestListReviewContextsForProject_ExcludesNullAndEmptyProjectID pins planner
// seq=531 guardrail 2: the Project Reviews tab only returns rows whose
// project_id matches EXACTLY. Legacy rows (NULL project_id) and any
// empty-string project_id (defensive — should never occur via C4.3 paths but
// could exist from older writes) MUST be excluded. The SQL contract
// "project_id = $2" implements this: NULL never equals anything and ” never
// equals a real project id. This test 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.
func TestListReviewContextsForProject_ExcludesNullAndEmptyProjectID(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "rc_list_boundary")
	store := newProjectTestStore(db)

	// Project + bound review via the C4.3 path.
	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, agent.CreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_rc_boundary",
		RootPath:    "/repo/rc-boundary-real",
		Name:        "Boundary Test Project",
		Visibility:  agent.ProjectVisibilityPrivate,
	})
	require.NoError(t, err)
	bound, err := store.CreateReviewContextForUser(ctx, seed.OwnerUserID, agent.UpsertReviewContextParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_rc_boundary",
		RootPath:    "/repo/rc-boundary-real",
		BaseRef:     "main",
		HeadRef:     "feature",
	})
	require.NoError(t, err)
	require.Equal(t, project.ID, bound.ProjectID)

	// Direct-seed legacy / defensive rows that must NOT leak into the listing.
	// C4.3 paths never produce these, but legacy rows from before C4.3 (or a
	// future bug) could — the SQL filter must hold.
	legacyRows := []struct {
		label     string
		projectID any
	}{
		{"null_project_id", nil},
		{"empty_project_id", ""},
		{"other_project_id", "prj_does_not_exist"},
	}
	now := time.Now().UTC()
	for _, row := range legacyRows {
		id, err := identifiers.NewEntityID("rcx")
		require.NoError(t, err)
		targetHash := reviewContextTargetHash("mac_rc_boundary", "/repo/rc-boundary-"+row.label, "main", "feature")
		_, err = db.Exec(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 ($1, $2, $3, NULL, NULL, $4, $5, 'main', 'feature', $6, '', $7, NULL, NULL, $8, $8)
`,
			id, seed.WorkspaceID, targetHash, "mac_rc_boundary", "/repo/rc-boundary-"+row.label,
			"Legacy "+row.label, row.projectID, now,
		)
		require.NoError(t, err, "seed legacy row: "+row.label)
	}

	contexts, err := store.ListReviewContextsForProject(ctx, seed.WorkspaceID, project.ID)
	require.NoError(t, err)
	require.Len(t, contexts, 1, "only the C4.3-bound review must be returned; NULL/empty/other project_id excluded")
	require.Equal(t, bound.ID, contexts[0].ID)
}
