package review

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

	"github.com/stretchr/testify/require"

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

// projectTestSeed is the workspace + member graph used by the project auth
// matrix tests. Each test re-seeds inside its own rollback tx so cross-test
// state cannot leak.
type projectTestSeed struct {
	WorkspaceID string
	Now         time.Time

	OwnerUserID    string
	OwnerMemberID  string
	ManageUserID   string
	ManageMemberID string
	ViewUserID     string
	ViewMemberID   string
	OutsiderUserID string
}

func seedProjectTestWorkspace(t *testing.T, ctx context.Context, db coredb.QueryExecer, suffix string) projectTestSeed {
	t.Helper()
	now := time.Now().UTC()
	workspaceID := "wsp_proj_" + suffix
	ownerUserID := "usr_owner_" + suffix
	manageUserID := "usr_manage_" + suffix
	viewUserID := "usr_view_" + suffix
	outsiderUserID := "usr_outsider_" + suffix
	organizationID := "org_proj_" + suffix
	ownerMemberID := "mem_owner_" + suffix
	manageMemberID := "mem_manage_" + suffix
	viewMemberID := "mem_view_" + suffix

	mustExecProj(t, ctx, db, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $3, 'hash', FALSE, $4, $4, $4)
`, ownerUserID, ownerUserID+"@example.com", ownerUserID, now)
	mustExecProj(t, ctx, db, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $3, 'hash', FALSE, $4, $4, $4)
`, manageUserID, manageUserID+"@example.com", manageUserID, now)
	mustExecProj(t, ctx, db, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $3, 'hash', FALSE, $4, $4, $4)
`, viewUserID, viewUserID+"@example.com", viewUserID, now)
	mustExecProj(t, ctx, db, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $3, 'hash', FALSE, $4, $4, $4)
`, outsiderUserID, outsiderUserID+"@example.com", outsiderUserID, now)

	mustExecProj(t, ctx, db, `
INSERT INTO organizations (id, slug, name, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $2, $3, $4, $4)
`, organizationID, workspaceID, ownerUserID, now)
	mustExecProj(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)
`, workspaceID, organizationID, suffix, now)

	mustExecProj(t, ctx, db, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'admin', 'active', $5, $5)
`, ownerMemberID, workspaceID, ownerUserID, ownerUserID, now)
	mustExecProj(t, ctx, db, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
`, manageMemberID, workspaceID, manageUserID, manageUserID, now)
	mustExecProj(t, ctx, db, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
`, viewMemberID, workspaceID, viewUserID, viewUserID, now)

	return projectTestSeed{
		WorkspaceID:    workspaceID,
		Now:            now,
		OwnerUserID:    ownerUserID,
		OwnerMemberID:  ownerMemberID,
		ManageUserID:   manageUserID,
		ManageMemberID: manageMemberID,
		ViewUserID:     viewUserID,
		ViewMemberID:   viewMemberID,
		OutsiderUserID: outsiderUserID,
	}
}

func mustExecProj(t *testing.T, ctx context.Context, db coredb.QueryExecer, query string, args ...any) {
	t.Helper()
	_, err := db.Exec(ctx, query, args...)
	require.NoError(t, err, "seed exec: "+query)
}

func newProjectTestStore(db coredb.DB) *Store {
	return newReviewStoreWithRealMemberResolver(db)
}

// makeCreateParams returns a validated CreateProjectParams with the given
// visibility. RootPath is intentionally distinct per test so two tests running
// in the same workspace cannot collide on the (machine, root) tuple.
func makeCreateParams(workspaceID string, visibility string) agent.CreateProjectParams {
	return agent.CreateProjectParams{
		WorkspaceID: workspaceID,
		MachineID:   "mac_proj_test",
		RootPath:    "/repo/project-test-" + workspaceID,
		Name:        "Project " + workspaceID,
		Description: "test description",
		Visibility:  visibility,
	}
}

// TestProject_Create_PrivateDefault pins two things: when the caller omits
// Visibility, the store defaults to 'private'; and the freshly-created project
// is readable by its owner.
func TestProject_Create_PrivateDefault(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "create_default")
	store := newProjectTestStore(db)

	params := makeCreateParams(seed.WorkspaceID, "")
	params.RootPath = "/repo/create-default"
	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, params)
	require.NoError(t, err)
	require.Equal(t, agent.ProjectVisibilityPrivate, project.Visibility,
		"omitted visibility MUST default to private")
	require.Equal(t, seed.OwnerUserID, project.OwnerUserID,
		"owner must be resolved from the authenticated user")
	require.NotEmpty(t, project.ID)
	require.Equal(t, "mac_proj_test", project.MachineID)

	loaded, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID)
	require.NoError(t, err)
	require.Equal(t, project.ID, loaded.ID)
	require.Equal(t, agent.ProjectRoleOwner, loaded.CurrentRole)
}

// TestProject_Get_PrivateVisibility_Matrix pins the private-visibility
// authorization matrix: owner OK, granted manage OK, granted view OK,
// un-granted workspace member forbidden, outsider forbidden.
func TestProject_Get_PrivateVisibility_Matrix(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "get_private")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)

	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleManage); err != nil {
		t.Fatalf("seed manage grant: %v", err)
	}
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ViewMemberID, agent.ProjectRoleView); err != nil {
		t.Fatalf("seed view grant: %v", err)
	}

	ownerProject, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID)
	if err != nil {
		t.Errorf("owner must read own project: %v", err)
	}
	require.Equal(t, agent.ProjectRoleOwner, ownerProject.CurrentRole)
	manageProject, err := store.GetProjectForUser(ctx, seed.ManageUserID, seed.WorkspaceID, project.ID)
	if err != nil {
		t.Errorf("manage-granted member must read project: %v", err)
	}
	require.Equal(t, agent.ProjectRoleManage, manageProject.CurrentRole)
	viewProject, err := store.GetProjectForUser(ctx, seed.ViewUserID, seed.WorkspaceID, project.ID)
	if err != nil {
		t.Errorf("view-granted member must read project: %v", err)
	}
	require.Equal(t, agent.ProjectRoleView, viewProject.CurrentRole)
	// Outsider is not a workspace member at all — LoadHumanWorkspaceMember
	// returns ErrForbidden, which resolveProjectRole maps to no-access.
	_, err = store.GetProjectForUser(ctx, seed.OutsiderUserID, seed.WorkspaceID, project.ID)
	if !errors.Is(err, storeerr.ErrForbidden) && !errors.Is(err, storeerr.ErrNotFound) {
		t.Errorf("outsider must NOT read private project, got err=%v", err)
	}
}

// TestProject_Get_WorkspaceVisibility_AllowActiveMember pins the
// visibility=workspace relaxation: any active human workspace member can view
// even without an explicit grant.
func TestProject_Get_WorkspaceVisibility_AllowActiveMember(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "get_workspace")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityWorkspace))
	require.NoError(t, err)

	viewProject, err := store.GetProjectForUser(ctx, seed.ViewUserID, seed.WorkspaceID, project.ID)
	if err != nil {
		t.Errorf("workspace-visibility project must be readable by any active workspace member: %v", err)
	}
	require.Equal(t, agent.ProjectRoleView, viewProject.CurrentRole)
}

// TestProject_Update_OnlyMutableFields pins that PATCH cannot mutate machine,
// root, or owner. The update payload only carries Name/Description/Visibility;
// the store MUST preserve the immutable fields.
func TestProject_Update_OnlyMutableFields(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "update_immutable")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)
	originalMachine := project.MachineID
	originalRoot := project.RootPath
	originalOwner := project.OwnerUserID

	newName := "renamed"
	newDesc := "renamed description"
	newVis := agent.ProjectVisibilityWorkspace
	updated, err := store.UpdateProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, agent.UpdateProjectParams{
		Name:        &newName,
		Description: &newDesc,
		Visibility:  &newVis,
	})
	require.NoError(t, err)
	require.Equal(t, newName, updated.Name)
	require.Equal(t, newDesc, updated.Description)
	require.Equal(t, newVis, updated.Visibility)
	require.Equal(t, originalMachine, updated.MachineID, "machine MUST be immutable")
	require.Equal(t, originalRoot, updated.RootPath, "root_path MUST be immutable")
	require.Equal(t, originalOwner, updated.OwnerUserID, "owner_user_id MUST be immutable")
}

// TestProject_Update_RequiresManage pins the role matrix on update:
// owner OK, manage-granted OK, view-granted forbidden.
func TestProject_Update_RequiresManage(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "update_role")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleManage); err != nil {
		t.Fatalf("seed manage grant: %v", err)
	}
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ViewMemberID, agent.ProjectRoleView); err != nil {
		t.Fatalf("seed view grant: %v", err)
	}

	newName := "from-owner"
	if _, err := store.UpdateProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, agent.UpdateProjectParams{Name: &newName}); err != nil {
		t.Errorf("owner must update project: %v", err)
	}
	newName = "from-manage"
	if _, err := store.UpdateProjectForUser(ctx, seed.ManageUserID, seed.WorkspaceID, project.ID, agent.UpdateProjectParams{Name: &newName}); err != nil {
		t.Errorf("manage-granted member must update project: %v", err)
	}
	newName = "from-view"
	_, err = store.UpdateProjectForUser(ctx, seed.ViewUserID, seed.WorkspaceID, project.ID, agent.UpdateProjectParams{Name: &newName})
	if !errors.Is(err, storeerr.ErrForbidden) {
		t.Errorf("view-granted member must NOT update project, got err=%v", err)
	}
}

// TestProject_Delete_OwnerOnly pins that delete is owner-only: manage-granted
// is forbidden, view-granted is forbidden, owner succeeds.
func TestProject_Delete_OwnerOnly(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "delete_role")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleManage); err != nil {
		t.Fatalf("seed manage grant: %v", err)
	}
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ViewMemberID, agent.ProjectRoleView); err != nil {
		t.Fatalf("seed view grant: %v", err)
	}

	if err := store.DeleteProjectForUser(ctx, seed.ManageUserID, seed.WorkspaceID, project.ID); !errors.Is(err, storeerr.ErrForbidden) {
		t.Fatalf("manage-granted must NOT delete, got err=%v", err)
	}
	if err := store.DeleteProjectForUser(ctx, seed.ViewUserID, seed.WorkspaceID, project.ID); !errors.Is(err, storeerr.ErrForbidden) {
		t.Fatalf("view-granted must NOT delete, got err=%v", err)
	}
	if err := store.DeleteProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID); err != nil {
		t.Fatalf("owner must delete: %v", err)
	}
	if _, err := store.GetProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID); !errors.Is(err, storeerr.ErrNotFound) {
		t.Fatalf("after delete, get must be NotFound, got err=%v", err)
	}
}

// TestProject_Delete_UnlinksReviewsToNULL pins the Danger Zone contract: on
// delete, every review_contexts row whose project_id was this project is
// unlinked to NULL (NOT deleted). Machine files are untouched by definition
// (the store never touches machine data).
func TestProject_Delete_UnlinksReviewsToNULL(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "delete_unlink")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)

	// Seed a review_contexts row that points at this project.
	reviewID := "rcx_unlink_target"
	mustExecProj(t, ctx, db, `
INSERT INTO review_contexts (id, workspace_id, target_hash, machine_id, root_path, base_ref, head_ref, display_name, pr_url, project_id, created_at, updated_at)
VALUES ($1, $2, 'hash_unlink', 'mac_proj_test', '/repo/unlink', '', '', 'review', '', $3, $4, $4)
`, reviewID, seed.WorkspaceID, project.ID, seed.Now)

	if err := store.DeleteProjectForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID); err != nil {
		t.Fatalf("owner delete: %v", err)
	}

	var linkedID *string
	err = db.QueryRow(ctx, `SELECT project_id FROM review_contexts WHERE id = $1`, reviewID).Scan(&linkedID)
	require.NoError(t, err)
	require.Nil(t, linkedID, "review_contexts.project_id MUST be unlinked to NULL on Project delete")
}

// TestProject_Grant_OwnerCannotBeTarget pins that the owner can never appear
// in project_grants: upserting a grant whose target is the owner is rejected
// with BadRequest. The owner's role is implicit via projects.owner_user_id.
func TestProject_Grant_OwnerCannotBeTarget(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "owner_target")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)

	_, err = store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.OwnerMemberID, agent.ProjectRoleManage)
	require.Error(t, err, "UpsertProjectGrantForUser must reject owner as target")
}

// TestProject_Grant_RejectOwnerRole pins that role='owner' is rejected at
// validation time. The persisted grant role enum is manage|view only.
func TestProject_Grant_RejectOwnerRole(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "owner_role")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)

	_, err = store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleOwner)
	if err == nil {
		t.Fatalf("UpsertProjectGrantForUser must reject role=owner")
	}
}

// TestProject_Grant_RequiresActiveHumanTarget pins that the target member
// must be an active human member of the workspace. A random non-member ID is
// rejected with BadRequest.
func TestProject_Grant_RequiresActiveHumanTarget(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "active_target")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)

	_, err = store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, "mem_does_not_exist", agent.ProjectRoleView)
	if err == nil {
		t.Fatalf("UpsertProjectGrantForUser must reject non-member target")
	}
}

// TestProject_Grant_ManageRoleRequired pins that only owner/manage can
// mutate grants; view-granted members cannot.
func TestProject_Grant_ManageRoleRequired(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "grant_role")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ViewMemberID, agent.ProjectRoleView); err != nil {
		t.Fatalf("seed view grant: %v", err)
	}

	_, err = store.UpsertProjectGrantForUser(ctx, seed.ViewUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleManage)
	if !errors.Is(err, storeerr.ErrForbidden) {
		t.Errorf("view-granted must NOT upsert grants, got err=%v", err)
	}
}

// TestProject_Grant_DeleteOwnerForbidden pins the defensive guard: a stale
// owner row in project_grants (shouldn't exist, but) cannot be deleted via
// DeleteProjectGrantForUser.
func TestProject_Grant_DeleteOwnerForbidden(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "del_owner")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)

	if err := store.DeleteProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.OwnerMemberID); err == nil {
		t.Fatalf("DeleteProjectGrantForUser must reject owner as target")
	}
}

// TestProject_Grant_ListExcludesOwner pins that ListProjectAccessForUser
// returns only persisted grants — owner is never in the list because owner
// access is implicit.
func TestProject_Grant_ListExcludesOwner(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "list_excl")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleManage); err != nil {
		t.Fatalf("seed manage grant: %v", err)
	}

	grants, err := store.ListProjectAccessForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID)
	require.NoError(t, err)
	require.Len(t, grants, 1, "exactly one non-owner grant expected")
	require.Equal(t, seed.ManageMemberID, grants[0].MemberID)
	require.Equal(t, agent.ProjectRoleManage, grants[0].Role)
}

// TestProject_Grant_ListRequiresManage pins that Settings/Access is a
// management-plane surface: view-only callers MUST be rejected. Reviewer2
// seq=537 point 5.
func TestProject_Grant_ListRequiresManage(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "list_manage")
	store := newProjectTestStore(db)

	project, err := store.CreateProjectForUser(ctx, seed.OwnerUserID, makeCreateParams(seed.WorkspaceID, agent.ProjectVisibilityPrivate))
	require.NoError(t, err)
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ViewMemberID, agent.ProjectRoleView); err != nil {
		t.Fatalf("seed view grant: %v", err)
	}
	if _, err := store.UpsertProjectGrantForUser(ctx, seed.OwnerUserID, seed.WorkspaceID, project.ID, seed.ManageMemberID, agent.ProjectRoleManage); err != nil {
		t.Fatalf("seed manage grant: %v", err)
	}

	if _, err := store.ListProjectAccessForUser(ctx, seed.ViewUserID, seed.WorkspaceID, project.ID); err == nil {
		t.Fatalf("ListProjectAccessForUser must reject view-only callers")
	} else if !errors.Is(err, storeerr.ErrForbidden) {
		t.Fatalf("expected ErrForbidden for view-only caller, got %v", err)
	}

	// Manage+ callers (owner or manage grant) still see the list.
	if _, err := store.ListProjectAccessForUser(ctx, seed.ManageUserID, seed.WorkspaceID, project.ID); err != nil {
		t.Fatalf("manage grant must allow list: %v", err)
	}
}

// TestProject_GetOrCreate_ForUser_CreatesThenReuses pins the C4 get-or-create
// contract on the human-driven path: the first call for a (workspace, machine,
// root) tuple mints a new private project owned by the caller; the second call
// returns the SAME row without minting a duplicate.
func TestProject_GetOrCreate_ForUser_CreatesThenReuses(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "goc_user")
	store := newProjectTestStore(db)

	target := agent.GetOrCreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_goc_user",
		RootPath:    "/repo/goc-user-target",
		Name:        "GOC User Project",
	}
	first, err := store.GetOrCreateProjectForUser(ctx, seed.OwnerUserID, target)
	require.NoError(t, err)
	require.NotEmpty(t, first.ID, "first call must mint a project id")
	require.Equal(t, seed.OwnerUserID, first.OwnerUserID, "owner is the authenticating human")
	require.Equal(t, agent.ProjectVisibilityPrivate, first.Visibility, "agent/user mint defaults to private")

	second, err := store.GetOrCreateProjectForUser(ctx, seed.OwnerUserID, target)
	require.NoError(t, err)
	require.Equal(t, first.ID, second.ID, "second call must return the same project")

	// A second caller resolving the same target also lands on the existing row;
	// the resolver never rewrites ownership on the way in.
	otherMember, err := store.GetOrCreateProjectForUser(ctx, seed.ManageUserID, target)
	require.NoError(t, err)
	require.Equal(t, first.ID, otherMember.ID, "second user must reuse the same project")
	require.Equal(t, seed.OwnerUserID, otherMember.OwnerUserID, "owner stays as the original creator")
}

// TestProject_GetOrCreate_ForUser_DistinctTargetsMintDistinctProjects pins
// that the (workspace, machine, root) tuple is the dedup key: same workspace
// and machine but a different root yields a different project row.
func TestProject_GetOrCreate_ForUser_DistinctTargetsMintDistinctProjects(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "goc_distinct")
	store := newProjectTestStore(db)

	first, err := store.GetOrCreateProjectForUser(ctx, seed.OwnerUserID, agent.GetOrCreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_goc_distinct",
		RootPath:    "/repo/distinct-A",
	})
	require.NoError(t, err)

	second, err := store.GetOrCreateProjectForUser(ctx, seed.OwnerUserID, agent.GetOrCreateProjectParams{
		WorkspaceID: seed.WorkspaceID,
		MachineID:   "mac_goc_distinct",
		RootPath:    "/repo/distinct-B",
	})
	require.NoError(t, err)

	require.NotEqual(t, first.ID, second.ID, "different root_path must yield different projects")
}

// TestProject_GetOrCreate_ForAgent_OwnerIsAgentMember pins that the
// agent-runtime path resolves ownership to the user that owns the trusted
// machine, independent of the individual Agent member.
func TestProject_GetOrCreate_ForAgent_OwnerIsMachineUser(t *testing.T) {
	ctx := context.Background()
	db := startReviewRollbackDB(t, ctx)
	seed := seedProjectTestWorkspace(t, ctx, db, "goc_agent")
	store := newProjectTestStore(db)

	agentID := "agt_goc_" + seed.WorkspaceID
	memberID := seed.OwnerMemberID
	machineID := "mac_goc_agent"
	seedProjectAgentMachine(t, ctx, db, seed, machineID, seed.OwnerUserID)
	authenticated := agent.AuthenticatedAgent{
		AgentID:     agentID,
		WorkspaceID: seed.WorkspaceID,
		MemberID:    memberID,
		MachineID:   machineID,
	}

	first, err := store.GetOrCreateProjectForAgent(ctx, authenticated, agent.GetOrCreateProjectParams{
		RootPath: "/repo/goc-agent-target",
	})
	require.NoError(t, err)
	require.Equal(t, seed.OwnerUserID, first.OwnerUserID, "owner is the user managing the agent's machine")
	require.Equal(t, machineID, first.MachineID, "machine defaults to the authenticated agent's machine")

	// Re-resolving on the same target returns the same row.
	second, err := store.GetOrCreateProjectForAgent(ctx, authenticated, agent.GetOrCreateProjectParams{
		MachineID: machineID, // explicit, matches default
		RootPath:  "/repo/goc-agent-target",
	})
	require.NoError(t, err)
	require.Equal(t, first.ID, second.ID)
}

func TestProject_GetOrCreate_ForAgent_RejectsEmptyMachine(t *testing.T) {
	ctx := context.Background()
	store := newProjectTestStore(nil)

	authenticated := agent.AuthenticatedAgent{
		AgentID:     "agt_goc_no_member",
		WorkspaceID: "wsp_goc_no_member",
		MemberID:    "mem_agent_irrelevant",
		MachineID:   "",
	}
	_, err := store.GetOrCreateProjectForAgent(ctx, authenticated, agent.GetOrCreateProjectParams{
		RootPath: "/repo/goc-no-member",
	})
	require.ErrorIs(t, err, storeerr.ErrBadRequest)
}
