package webagent

import (
	"encoding/json"
	"errors"
	"net/http"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/testkit/httputil"
	"github.com/tidbcloud/AgentTeam/internal/runtimecontrol"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/storeerr"
)

func TestProjectHandler_List(t *testing.T) {
	t.Run("returns projects envelope", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectPage = agent.ProjectPage{
			Projects: []agent.Project{
				{ID: "prj_1", WorkspaceID: "wsp_1", Name: "Project One", Visibility: agent.ProjectVisibilityPrivate, CurrentRole: agent.ProjectRoleOwner},
				{ID: "prj_2", WorkspaceID: "wsp_1", Name: "Project Two", Visibility: agent.ProjectVisibilityWorkspace},
			},
		}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Projects   []dto.Project `json:"projects"`
			HasMore    bool          `json:"hasMore"`
			NextCursor string        `json:"nextCursor"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.Equal(t, defaultProjectPageSize, fake.lastProjectListOptions.Limit)
		require.Len(t, out.Projects, 2)
		require.False(t, out.HasMore)
		require.Empty(t, out.NextCursor)
		require.Equal(t, "prj_1", out.Projects[0].ID)
		require.Equal(t, agent.ProjectRoleOwner, out.Projects[0].CurrentRole)
		require.Equal(t, agent.ProjectVisibilityWorkspace, out.Projects[1].Visibility)
	})

	t.Run("returns a filtered cursor page", func(t *testing.T) {
		fake := newFakeStore()
		updatedAt := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
		fake.projectPage = agent.ProjectPage{
			Projects: []agent.Project{{
				ID: "prj_1", WorkspaceID: "wsp_1", Name: "Project One", UpdatedAt: updatedAt,
			}},
			HasMore: true,
		}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects?limit=20&query=agentteam&machineId=mac_online", nil)
		require.Equal(t, http.StatusOK, resp.Code)
		require.Equal(t, 20, fake.lastProjectListOptions.Limit)
		require.Equal(t, "agentteam", fake.lastProjectListOptions.Query)
		require.Equal(t, []string{"mac_online"}, fake.lastProjectListOptions.MachineIDs)

		var out struct {
			Projects   []dto.Project `json:"projects"`
			HasMore    bool          `json:"hasMore"`
			NextCursor string        `json:"nextCursor"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.True(t, out.HasMore)
		require.NotEmpty(t, out.NextCursor)

		resp = httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects?limit=20&cursor="+out.NextCursor, nil)
		require.Equal(t, http.StatusOK, resp.Code)
		require.Equal(t, "prj_1", fake.lastProjectListOptions.CursorID)
		require.True(t, fake.lastProjectListOptions.CursorUpdatedAt.Equal(updatedAt))
	})

	t.Run("rejects an invalid cursor", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})
		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects?limit=20&cursor=invalid", nil)
		require.Equal(t, http.StatusBadRequest, resp.Code)
	})

	t.Run("validates list filters before hitting the store", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects?limit=0", nil)
		require.Equal(t, http.StatusBadRequest, resp.Code)

		resp = httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects?query="+strings.Repeat("x", 257), nil)
		require.Equal(t, http.StatusBadRequest, resp.Code)
	})

	t.Run("surfaces forbidden from store as 403", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectsErr = storeerr.Forbidden("no access")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_Create(t *testing.T) {
	t.Run("forwards params to store and returns project envelope", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_new", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo", Name: "New", Visibility: agent.ProjectVisibilityPrivate}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"name": "New", "machineId": "mach_1", "rootPath": "/repo",
		})
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Project dto.Project `json:"project"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.Equal(t, "prj_new", out.Project.ID)
		require.Equal(t, "wsp_1", fake.lastProjectParams.WorkspaceID)
		require.Equal(t, "mach_1", fake.lastProjectParams.MachineID)
		require.Equal(t, "/repo", fake.lastProjectParams.RootPath)
	})

	t.Run("rejects missing machineId at handler", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"name": "New", "rootPath": "/repo",
		})
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Contains(t, resp.Body.String(), "machineId is required")
	})

	t.Run("rejects missing rootPath at handler", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"machineId": "mach_1",
		})
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Contains(t, resp.Body.String(), "rootPath is required")
	})

	t.Run("surfaces bad request from store as 400", func(t *testing.T) {
		fake := newFakeStore()
		fake.createProjectErr = storeerr.BadRequest("visibility must be 'private' or 'workspace'")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"machineId": "mach_1", "rootPath": "/repo", "visibility": "bogus",
		})
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Contains(t, resp.Body.String(), "visibility")
	})

	t.Run("rejects caller who cannot manage target machine (403)", func(t *testing.T) {
		fake := newFakeStore()
		fake.machineAccessErr = storeerr.Forbidden("machine not managed by caller")
		fake.createProjectErr = errors.New("CreateProjectForUser must NOT be called when machine check fails")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"machineId": "mach_other", "rootPath": "/repo",
		})
		require.Equal(t, http.StatusForbidden, resp.Code)
		require.Equal(t, "mach_other", fake.lastMachineAccessCheck, "machine check must receive the target machineId")
		require.Empty(t, fake.lastProjectParams.MachineID, "CreateProjectForUser must not run when machine check fails")
	})

	t.Run("creates project when caller manages the machine", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_new", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo"}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"machineId": "mach_1", "rootPath": "/repo",
		})
		require.Equal(t, http.StatusOK, resp.Code)
		require.Equal(t, "mach_1", fake.lastMachineAccessCheck)
		require.Equal(t, "mach_1", fake.lastProjectParams.MachineID)
	})

	t.Run("does not send permissionMode error (not an agent create)", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_new", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo"}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		// permissionMode is irrelevant to Projects; the body must be accepted
		// rather than rejected with the agent-create-specific message.
		resp := httputil.DoJSON(t, router, http.MethodPost, "/api/v1/workspaces/wsp_1/projects", map[string]any{
			"machineId": "mach_1", "rootPath": "/repo", "permissionMode": "plan",
		})
		require.Equal(t, http.StatusOK, resp.Code)
		require.NotContains(t, resp.Body.String(), "permissionMode is not supported")
	})
}

func TestProjectHandler_Get(t *testing.T) {
	t.Run("returns project envelope", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", WorkspaceID: "wsp_1", Name: "Project One"}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Project dto.Project `json:"project"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.Equal(t, "prj_1", out.Project.ID)
		require.Equal(t, "prj_1", fake.lastProjectID)
	})

	t.Run("surfaces not found from store as 404", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.NotFound("project not found")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_missing", nil)
		require.Equal(t, http.StatusNotFound, resp.Code)
	})

	t.Run("surfaces forbidden from store as 403", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.Forbidden("no access")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_Patch(t *testing.T) {
	t.Run("forwards pointer params as-is (nil preserves, non-nil overwrites)", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", Name: "Updated", Visibility: agent.ProjectVisibilityWorkspace}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		newName := "Updated"
		newVis := agent.ProjectVisibilityWorkspace
		resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/projects/prj_1", map[string]any{
			"name": newName, "visibility": newVis,
		})
		require.Equal(t, http.StatusOK, resp.Code)

		require.Equal(t, "prj_1", fake.lastProjectID)
		require.NotNil(t, fake.lastProjectUpdate.Name)
		require.Equal(t, "Updated", *fake.lastProjectUpdate.Name)
		require.Nil(t, fake.lastProjectUpdate.Description)
		require.NotNil(t, fake.lastProjectUpdate.Visibility)
		require.Equal(t, agent.ProjectVisibilityWorkspace, *fake.lastProjectUpdate.Visibility)
	})

	t.Run("empty string is forwarded as explicit clear, not collapsed to nil", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", Name: ""}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/projects/prj_1", map[string]any{
			"name": "",
		})
		require.Equal(t, http.StatusOK, resp.Code)

		require.NotNil(t, fake.lastProjectUpdate.Name)
		require.Equal(t, "", *fake.lastProjectUpdate.Name)
	})

	t.Run("surfaces forbidden from store as 403", func(t *testing.T) {
		fake := newFakeStore()
		fake.updateProjectErr = storeerr.Forbidden("manage role required")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		newName := "x"
		resp := httputil.DoJSON(t, router, http.MethodPatch, "/api/v1/workspaces/wsp_1/projects/prj_1", map[string]any{
			"name": newName,
		})
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_Delete(t *testing.T) {
	t.Run("forwards project id and returns deleted envelope", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/projects/prj_1", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Deleted bool `json:"deleted"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.True(t, out.Deleted)
		require.Equal(t, "prj_1", fake.lastProjectID)
	})

	t.Run("surfaces forbidden from store as 403 (owner-only)", func(t *testing.T) {
		fake := newFakeStore()
		fake.deleteProjectErr = storeerr.Forbidden("owner required")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/projects/prj_1", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_ListAccess(t *testing.T) {
	t.Run("returns access envelope excluding owner", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectGrants = []agent.ProjectGrant{
			{ProjectID: "prj_1", MemberID: "mem_2", Role: agent.ProjectRoleManage},
			{ProjectID: "prj_1", MemberID: "mem_3", Role: agent.ProjectRoleView},
		}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Access []dto.ProjectGrant `json:"access"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.Len(t, out.Access, 2)
		require.Equal(t, "mem_2", out.Access[0].MemberID)
		require.Equal(t, agent.ProjectRoleManage, out.Access[0].Role)
		require.Equal(t, "prj_1", fake.lastProjectID)
	})

	t.Run("surfaces forbidden from store as 403", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectGrantsErr = storeerr.Forbidden("manage role required")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_UpsertAccess(t *testing.T) {
	t.Run("forwards project/member/role and returns access envelope", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectGrant = agent.ProjectGrant{ProjectID: "prj_1", MemberID: "mem_2", Role: agent.ProjectRoleView}
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPut, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access/mem_2", map[string]any{
			"role": agent.ProjectRoleView,
		})
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Access dto.ProjectGrant `json:"access"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.Equal(t, "mem_2", out.Access.MemberID)
		require.Equal(t, agent.ProjectRoleView, out.Access.Role)
		require.Equal(t, "prj_1", fake.lastProjectID)
		require.Equal(t, "mem_2", fake.lastGrantMemberID)
		require.Equal(t, agent.ProjectRoleView, fake.lastGrantRole)
	})

	t.Run("rejects missing role at handler", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPut, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access/mem_2", map[string]any{})
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Contains(t, resp.Body.String(), "role is required")
	})

	t.Run("surfaces owner-role rejection from store as 400", func(t *testing.T) {
		fake := newFakeStore()
		fake.upsertGrantErr = storeerr.BadRequest("project grant role must be 'manage' or 'view'")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPut, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access/mem_2", map[string]any{
			"role": agent.ProjectRoleOwner,
		})
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Contains(t, resp.Body.String(), "role")
	})

	t.Run("surfaces manage-role-required from store as 403", func(t *testing.T) {
		fake := newFakeStore()
		fake.upsertGrantErr = storeerr.Forbidden("manage role required")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodPut, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access/mem_2", map[string]any{
			"role": agent.ProjectRoleView,
		})
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_DeleteAccess(t *testing.T) {
	t.Run("forwards project/member and returns deleted envelope", func(t *testing.T) {
		fake := newFakeStore()
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access/mem_2", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Deleted bool `json:"deleted"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.True(t, out.Deleted)
		require.Equal(t, "prj_1", fake.lastProjectID)
		require.Equal(t, "mem_2", fake.lastGrantMemberID)
	})

	t.Run("surfaces owner-target rejection from store as 403", func(t *testing.T) {
		fake := newFakeStore()
		fake.deleteGrantErr = storeerr.Forbidden("cannot remove owner")
		router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

		resp := httputil.DoJSON(t, router, http.MethodDelete, "/api/v1/workspaces/wsp_1/projects/prj_1/settings/access/mem_owner", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
	})
}

func TestProjectHandler_StoreErrorMapping(t *testing.T) {
	// Cover the default-internal-error branch so the handler does not silently
	// downgrade unexpected store failures to 200.
	fake := newFakeStore()
	fake.projectErr = errors.New("unexpected")
	router := newWebAgentRouter(Dependencies{AgentStore: fake, ProjectStore: fake})

	resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1", nil)
	require.Equal(t, http.StatusInternalServerError, resp.Code)
}

func TestProjectHandler_FilesList(t *testing.T) {
	t.Run("forwards rootPath to runtime after view+ auth", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo"}
		rt := &fakeRuntime{outputFiles: []agent.WorkspaceFile{{
			Name:           "src",
			Path:           "/repo/src",
			IsDirectory:    true,
			ChildDirCount:  2,
			ChildFileCount: 3,
		}}}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files?dirPath=src", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		require.Equal(t, "mach_1", rt.lastOutputFiles.MachineID)
		require.Equal(t, "", rt.lastOutputFiles.AgentID, "agentID must be empty — targetPath is the security boundary")
		require.Equal(t, "/repo", rt.lastOutputFiles.TargetPath)
		require.Equal(t, "src", rt.lastOutputFiles.DirPath)
		require.NotContains(t, resp.Body.String(), "childDirCount")
		require.NotContains(t, resp.Body.String(), "childFileCount")
	})

	t.Run("surfaces project not-found as 404 without leaking machine/rootPath", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.NotFound("project not found")
		rt := &fakeRuntime{}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_missing/files", nil)
		require.Equal(t, http.StatusNotFound, resp.Code)
		require.Empty(t, rt.lastOutputFiles.TargetPath, "runtime must not be called when project lookup fails")
	})

	t.Run("surfaces forbidden as 403 without leaking machine/rootPath", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.Forbidden("view role required")
		rt := &fakeRuntime{}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
		require.Empty(t, rt.lastOutputFiles.TargetPath)
	})

	t.Run("returns 503 when runtime is offline", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", MachineID: "mach_1", RootPath: "/repo"}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			// Runtime intentionally nil
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files", nil)
		require.Equal(t, http.StatusServiceUnavailable, resp.Code)
	})

	t.Run("runtime offline + project not-found returns 404, not 503", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.NotFound("project not found")
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			// Runtime intentionally nil — must not short-circuit before auth.
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_missing/files", nil)
		require.Equal(t, http.StatusNotFound, resp.Code)
	})

	t.Run("runtime offline + project forbidden returns 403, not 503", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.Forbidden("view role required")
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			// Runtime intentionally nil — must not short-circuit before auth.
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
	})

	t.Run("maps daemon machine-offline to 503", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", MachineID: "mach_1", RootPath: "/repo"}
		rt := &fakeRuntime{outputFilesErr: runtimecontrol.ErrMachineOffline}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files", nil)
		require.Equal(t, http.StatusServiceUnavailable, resp.Code)
	})
}

func TestProjectHandler_FilesRead(t *testing.T) {
	t.Run("forwards path to runtime after view+ auth", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo"}
		content := "package main"
		rt := &fakeRuntime{outputContent: agent.WorkspaceFileContent{Content: &content}}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files/content?path=main.go", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		require.Equal(t, "/repo", rt.lastOutputRead.TargetPath)
		require.Equal(t, "main.go", rt.lastOutputRead.Path)

		// Content shape MUST be WorkspaceFileContent at the top level —
		// matching api_workspace_files.go and ReviewHandler patterns. The
		// "content" property of the DTO is the body, NOT a wrapping envelope.
		var body struct {
			Content *string `json:"content"`
			Binary  bool    `json:"binary"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body))
		require.NotNil(t, body.Content)
		require.Equal(t, "package main", *body.Content)
	})

	t.Run("rejects missing path at handler", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", MachineID: "mach_1", RootPath: "/repo"}
		rt := &fakeRuntime{}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files/content", nil)
		require.Equal(t, http.StatusBadRequest, resp.Code)
		require.Contains(t, resp.Body.String(), "path is required")
	})

	t.Run("surfaces project not-found as 404 without runtime call", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.NotFound("project not found")
		rt := &fakeRuntime{}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_missing/files/content?path=x", nil)
		require.Equal(t, http.StatusNotFound, resp.Code)
		require.Empty(t, rt.lastOutputRead.TargetPath)
	})

	t.Run("maps daemon machine-offline to 503", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", MachineID: "mach_1", RootPath: "/repo"}
		rt := &fakeRuntime{outputReadErr: runtimecontrol.ErrMachineOffline}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			Runtime:      rt,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/files/content?path=x", nil)
		require.Equal(t, http.StatusServiceUnavailable, resp.Code)
	})
}

func TestProjectHandler_ReviewsList(t *testing.T) {
	t.Run("returns reviews envelope after view+ auth", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", WorkspaceID: "wsp_1"}
		fake.reviews = []agent.ReviewContext{
			{ID: "rcx_1", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo"},
			{ID: "rcx_2", WorkspaceID: "wsp_1", MachineID: "mach_1", RootPath: "/repo"},
		}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			ReviewStore:  fake,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/reviews", nil)
		require.Equal(t, http.StatusOK, resp.Code)

		var out struct {
			Reviews []dto.Review `json:"reviews"`
		}
		require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &out))
		require.Len(t, out.Reviews, 2)
		require.Equal(t, "rcx_1", out.Reviews[0].ID)
		require.Equal(t, "prj_1", fake.lastProjectID)
	})

	t.Run("surfaces project not-found as 404 without listing", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.NotFound("project not found")
		// reviews should never be returned because the project lookup fails first.
		fake.reviews = []agent.ReviewContext{{ID: "rcx_should_not_leak"}}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			ReviewStore:  fake,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_missing/reviews", nil)
		require.Equal(t, http.StatusNotFound, resp.Code)
		require.NotContains(t, resp.Body.String(), "rcx_should_not_leak")
	})

	t.Run("surfaces forbidden as 403 without listing", func(t *testing.T) {
		fake := newFakeStore()
		fake.projectErr = storeerr.Forbidden("view role required")
		fake.reviews = []agent.ReviewContext{{ID: "rcx_should_not_leak"}}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			ReviewStore:  fake,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/reviews", nil)
		require.Equal(t, http.StatusForbidden, resp.Code)
		require.NotContains(t, resp.Body.String(), "rcx_should_not_leak")
	})

	t.Run("empty list returns empty envelope, not null", func(t *testing.T) {
		fake := newFakeStore()
		fake.project = agent.Project{ID: "prj_1", WorkspaceID: "wsp_1"}
		router := newWebAgentRouter(Dependencies{
			AgentStore:   fake,
			ProjectStore: fake,
			ReviewStore:  fake,
		})

		resp := httputil.DoJSON(t, router, http.MethodGet, "/api/v1/workspaces/wsp_1/projects/prj_1/reviews", nil)
		require.Equal(t, http.StatusOK, resp.Code)
		require.Contains(t, resp.Body.String(), `"reviews":[]`)
	})
}
