package webagent

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/dto/converter"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
)

const defaultProjectPageSize = 20

type projectPageCursor struct {
	UpdatedAt time.Time `json:"updatedAt"`
	ID        string    `json:"id"`
}

func decodeProjectPageCursor(raw string) (projectPageCursor, error) {
	decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
	if err != nil {
		return projectPageCursor{}, err
	}
	var cursor projectPageCursor
	if err := json.Unmarshal(decoded, &cursor); err != nil {
		return projectPageCursor{}, err
	}
	if cursor.UpdatedAt.IsZero() || strings.TrimSpace(cursor.ID) == "" {
		return projectPageCursor{}, api.NewHTTPError(http.StatusBadRequest, "invalid project cursor")
	}
	return cursor, nil
}

func encodeProjectPageCursor(project agent.Project) (string, error) {
	encoded, err := json.Marshal(projectPageCursor{UpdatedAt: project.UpdatedAt, ID: project.ID})
	if err != nil {
		return "", fmt.Errorf("encode project cursor: %w", err)
	}
	return base64.RawURLEncoding.EncodeToString(encoded), nil
}

func projectListOptionsFromRequest(r api.WorkspaceScopedRequest) (agent.ProjectListOptions, error) {
	query := strings.TrimSpace(r.Gin.Query("query"))
	rawLimit := strings.TrimSpace(r.Gin.Query("limit"))
	rawCursor := strings.TrimSpace(r.Gin.Query("cursor"))
	machineIDs := r.Gin.QueryArray("machineId")
	excludeMachineIDs := r.Gin.QueryArray("excludeMachineId")
	if len(machineIDs)+len(excludeMachineIDs) > 200 {
		return agent.ProjectListOptions{}, api.NewHTTPError(http.StatusBadRequest, "too many machine filters")
	}
	if len([]rune(query)) > 256 {
		return agent.ProjectListOptions{}, api.NewHTTPError(http.StatusBadRequest, "query is too long")
	}

	limit := defaultProjectPageSize
	if rawLimit != "" {
		parsed, err := strconv.Atoi(rawLimit)
		if err != nil || parsed < 1 || parsed > 100 {
			return agent.ProjectListOptions{}, api.NewHTTPError(http.StatusBadRequest, "limit must be between 1 and 100")
		}
		limit = parsed
	}
	options := agent.ProjectListOptions{
		Limit:             limit,
		Query:             query,
		MachineIDs:        machineIDs,
		ExcludeMachineIDs: excludeMachineIDs,
	}
	if rawCursor == "" {
		return options, nil
	}
	cursor, err := decodeProjectPageCursor(rawCursor)
	if err != nil {
		return agent.ProjectListOptions{}, api.NewHTTPError(http.StatusBadRequest, "invalid project cursor")
	}
	options.CursorUpdatedAt = cursor.UpdatedAt
	options.CursorID = cursor.ID
	return options, nil
}

// ProjectHandler exposes the Project CRUD + Settings/Access surface.
//
// Authorization is delegated to the ProjectStore — every *ForUser method
// resolves the caller's workspace member and enforces the owner/manage/view
// role matrix described in agent.ProjectRole* constants. Handler code MUST
// NOT duplicate those checks (planner seq=513 contract point 5).
//
// Files and Reviews endpoints additionally hold reviewStore + runtime so
// they can reuse the daemon machine file-reading path (planner seq=513
// point 3) and list review_contexts attached to a Project. The store layer
// authorizes via GetProjectForUser before any runtime RPC — Project
// not-found / no-access surfaces as 404/403 without leaking machine or
// rootPath back to the caller (planner seq=531 guardrail 1).
type ProjectHandler struct {
	projectStore  ProjectStore
	reviewStore   ReviewStore
	machineAccess MachineAccessChecker
	runtime       Runtime
}

// List handles GET /api/v1/workspaces/:workspaceId/projects.
// Returns projects the caller can see — owner ∪ granted ∪ workspace-visibility.
func (handler *ProjectHandler) List(r api.WorkspaceScopedRequest) (api.Response, error) {
	options, err := projectListOptionsFromRequest(r)
	if err != nil {
		return api.Response{}, err
	}
	page, err := handler.projectStore.ListProjectPageForUser(r.Context(), r.UserID(), r.WorkspaceID(), options)
	if err != nil {
		return api.Response{}, err
	}
	nextCursor := ""
	if page.HasMore && len(page.Projects) > 0 {
		nextCursor, err = encodeProjectPageCursor(page.Projects[len(page.Projects)-1])
		if err != nil {
			return api.Response{}, err
		}
	}
	return api.OK(gin.H{
		"projects":   converter.ToProjects(page.Projects),
		"hasMore":    page.HasMore,
		"nextCursor": nextCursor,
	}), nil
}

// Create handles POST /api/v1/workspaces/:workspaceId/projects.
// Owner is resolved by the store from the authenticating user — callers
// cannot forge ownership. The caller must additionally be able to manage the
// target machine, mirroring the legacy Directory create path (planner
// seq=538): an arbitrary workspace member must not be able to create a
// Project pointing at a machine/root they do not control and then read its
// files via owner-only access.
func (handler *ProjectHandler) Create(r api.WorkspaceScopedJSON[dto.CreateProjectRequest]) (api.Response, error) {
	body := r.Body
	machineID := strings.TrimSpace(body.MachineID)
	if machineID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "machineId is required")
	}
	if body.RootPath == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "rootPath is required")
	}
	if handler.machineAccess != nil {
		if err := handler.machineAccess.RequireMachineManagementForUser(r.Context(), r.UserID(), r.WorkspaceID(), machineID); err != nil {
			return api.Response{}, err
		}
	}
	params := agent.CreateProjectParams{
		WorkspaceID: r.WorkspaceID(),
		MachineID:   machineID,
		RootPath:    body.RootPath,
		Name:        body.Name,
		Description: body.Description,
		Visibility:  body.Visibility,
	}
	project, err := handler.projectStore.CreateProjectForUser(r.Context(), r.UserID(), params)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"project": converter.ToProject(project)}), nil
}

// Get handles GET /api/v1/workspaces/:workspaceId/projects/:projectId.
func (handler *ProjectHandler) Get(r api.WorkspaceScopedRequest) (api.Response, error) {
	projectID, err := requireProjectID(r)
	if err != nil {
		return api.Response{}, err
	}
	project, err := handler.projectStore.GetProjectForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"project": converter.ToProject(project)}), nil
}

// Patch handles PATCH /api/v1/workspaces/:workspaceId/projects/:projectId.
//
// Each UpdateProjectRequest pointer field is forwarded as-is: nil preserves
// the existing value, non-nil overwrites it (including empty string, which
// is an explicit clear). Handler does not TrimSpace pointer bodies into nil
// — see Reviewer2 seq=523 for the nil-vs-empty contract.
func (handler *ProjectHandler) Patch(r api.WorkspaceScopedJSON[dto.UpdateProjectRequest]) (api.Response, error) {
	projectID, err := requireProjectID(r.WorkspaceScopedRequest)
	if err != nil {
		return api.Response{}, err
	}
	params := agent.UpdateProjectParams{
		Name:        r.Body.Name,
		Description: r.Body.Description,
		Visibility:  r.Body.Visibility,
	}
	project, err := handler.projectStore.UpdateProjectForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID, params)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"project": converter.ToProject(project)}), nil
}

// Delete handles DELETE /api/v1/workspaces/:workspaceId/projects/:projectId.
// Owner-only at the store layer. Delete semantics: drops Project metadata
// only — same transaction unlinks grants and sets review_contexts.project_id
// to NULL. Machine files and Review rows are preserved.
func (handler *ProjectHandler) Delete(r api.WorkspaceScopedRequest) (api.Response, error) {
	projectID, err := requireProjectID(r)
	if err != nil {
		return api.Response{}, err
	}
	if err := handler.projectStore.DeleteProjectForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID); err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"deleted": true}), nil
}

// ListAccess handles GET /api/v1/workspaces/:workspaceId/projects/:projectId/settings/access.
// Returns persisted manage/view grants only — owner is omitted because owner
// access is derived from projects.owner_user_id and never written as a grant.
func (handler *ProjectHandler) ListAccess(r api.WorkspaceScopedRequest) (api.Response, error) {
	projectID, err := requireProjectID(r)
	if err != nil {
		return api.Response{}, err
	}
	grants, err := handler.projectStore.ListProjectAccessForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"access": converter.ToProjectGrants(grants)}), nil
}

// UpsertAccess handles PUT /api/v1/workspaces/:workspaceId/projects/:projectId/settings/access/:memberId.
// Role must be 'manage' or 'view'; 'owner' is rejected. Target must be an
// active human workspace member — store-side validation surfaces as
// storeerr.BadRequest / ErrForbidden.
func (handler *ProjectHandler) UpsertAccess(r api.WorkspaceScopedJSON[dto.UpsertProjectGrantRequest]) (api.Response, error) {
	projectID, err := requireProjectID(r.WorkspaceScopedRequest)
	if err != nil {
		return api.Response{}, err
	}
	memberID := strings.TrimSpace(r.Gin.Param("memberId"))
	if memberID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "memberId is required")
	}
	role := strings.TrimSpace(r.Body.Role)
	if role == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "role is required")
	}
	grant, err := handler.projectStore.UpsertProjectGrantForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID, memberID, role)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"access": converter.ToProjectGrant(grant)}), nil
}

// DeleteAccess handles DELETE /api/v1/workspaces/:workspaceId/projects/:projectId/settings/access/:memberId.
// Owner grant cannot be removed because it is never persisted; store rejects
// attempts to delete the owner's implicit access.
func (handler *ProjectHandler) DeleteAccess(r api.WorkspaceScopedRequest) (api.Response, error) {
	projectID, err := requireProjectID(r)
	if err != nil {
		return api.Response{}, err
	}
	memberID := strings.TrimSpace(r.Gin.Param("memberId"))
	if memberID == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "memberId is required")
	}
	if err := handler.projectStore.DeleteProjectGrantForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID, memberID); err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"deleted": true}), nil
}

func requireProjectID(r api.WorkspaceScopedRequest) (string, error) {
	projectID := strings.TrimSpace(r.Gin.Param("projectId"))
	if projectID == "" {
		return "", api.NewHTTPError(http.StatusBadRequest, "projectId is required")
	}
	return projectID, nil
}

// FilesList handles GET /api/v1/workspaces/:workspaceId/projects/:projectId/files?dirPath=...
//
// Reuses the daemon machine file-reading path via ListAgentOutputTargetFilesPage
// with Project.RootPath as targetPath. agentID is empty because Projects are
// not bound to an agent — targetPath is the security boundary, matching the
// ReviewHandler empty-agentID pattern.
func (handler *ProjectHandler) FilesList(r api.WorkspaceScopedRequest) (api.Response, error) {
	project, err := handler.loadProjectForFileRequest(r)
	if err != nil {
		return api.Response{}, err
	}
	dirPath := strings.TrimSpace(r.Gin.Query("dirPath"))
	requestCtx := remoteRequestContext(r)
	page, err := handler.runtime.ListAgentOutputTargetFilesPage(requestCtx, project.MachineID, "", project.RootPath, dirPath, workspaceListOptions(r.Gin))
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(projectFilesPageResponse(page)), nil
}

func projectFilesPageResponse(page agent.WorkspaceFilePage) gin.H {
	files := make([]dto.ProjectFile, 0, len(page.Files))
	for _, file := range page.Files {
		files = append(files, dto.ProjectFile{
			Name:        file.Name,
			Path:        file.Path,
			IsDirectory: file.IsDirectory,
			Size:        file.Size,
			ModifiedAt:  file.ModifiedAt,
		})
	}
	return gin.H{
		"files":      files,
		"rootPath":   strings.TrimSpace(page.RootPath),
		"limit":      page.Limit,
		"cursor":     page.Cursor,
		"hasMore":    page.HasMore,
		"nextCursor": page.NextCursor,
	}
}

// FilesRead handles GET /api/v1/workspaces/:workspaceId/projects/:projectId/files/content?path=...
//
// path is a raw filesystem path relative to Project.RootPath: do NOT TrimSpace
// because legitimate paths may contain whitespace. Only check emptiness,
// mirroring ReviewHandler.Diff.
func (handler *ProjectHandler) FilesRead(r api.WorkspaceScopedRequest) (api.Response, error) {
	project, err := handler.loadProjectForFileRequest(r)
	if err != nil {
		return api.Response{}, err
	}
	path := r.Gin.Query("path")
	if path == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "path is required")
	}
	requestCtx := remoteRequestContext(r)
	content, err := handler.runtime.ReadAgentOutputTargetFile(requestCtx, project.MachineID, "", project.RootPath, path)
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(converter.ToWorkspaceFileContent(content)), nil
}

// ReviewsList handles GET /api/v1/workspaces/:workspaceId/projects/:projectId/reviews.
//
// Returns review_contexts whose project_id matches exactly. Legacy NULL
// project_id rows are excluded — they remain readable via the global review
// list and only get re-attached to Projects by the C4 writer. Planner
// seq=531 guardrail 2.
func (handler *ProjectHandler) ReviewsList(r api.WorkspaceScopedRequest) (api.Response, error) {
	projectID, err := requireProjectID(r)
	if err != nil {
		return api.Response{}, err
	}
	// Load Project first so view+ authorization runs in the store. The
	// returned Project value is unused — the access check is the side effect.
	if _, err := handler.projectStore.GetProjectForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID); err != nil {
		return api.Response{}, err
	}
	contexts, err := handler.reviewStore.ListReviewContextsForProject(r.Context(), r.WorkspaceID(), projectID)
	if err != nil {
		return api.Response{}, err
	}
	items := make([]dto.Review, 0, len(contexts))
	for i := range contexts {
		if item := converter.ToReview(&contexts[i]); item != nil {
			items = append(items, *item)
		}
	}
	return api.OK(gin.H{"reviews": items}), nil
}

// loadProjectForFileRequest resolves the Project via the store so the view+
// role matrix runs before any runtime RPC. Authorization (404/403) MUST be
// surfaced before the runtime-nil 503 — otherwise an unauthorized caller
// could distinguish existing Projects from missing ones via the offline
// response (planner seq=531 guardrail 1). machine/rootPath never leak
// because GetProjectForUser returns ErrNotFound/ErrForbidden on miss/no
// access without materializing the row.
func (handler *ProjectHandler) loadProjectForFileRequest(r api.WorkspaceScopedRequest) (agent.Project, error) {
	projectID, err := requireProjectID(r)
	if err != nil {
		return agent.Project{}, err
	}
	project, err := handler.projectStore.GetProjectForUser(r.Context(), r.UserID(), r.WorkspaceID(), projectID)
	if err != nil {
		return agent.Project{}, err
	}
	if handler.runtime == nil {
		return agent.Project{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}
	return project, nil
}

// remoteRequestContext and reviewRuntimeError are package-level helpers
// defined in api_review.go. They are reused here so file errors map to the
// same HTTP surface as ReviewHandler (503 offline, 422 unsupported path).
