package webagent

import (
	"context"
	"errors"
	"net/http"
	"strings"
	"sync"

	"github.com/gin-gonic/gin"

	"github.com/tidbcloud/AgentTeam/internal/api"
	"github.com/tidbcloud/AgentTeam/internal/api/daemon"
	"github.com/tidbcloud/AgentTeam/internal/api/dto"
	"github.com/tidbcloud/AgentTeam/internal/api/dto/converter"
	"github.com/tidbcloud/AgentTeam/internal/observability/logging"
	"github.com/tidbcloud/AgentTeam/internal/runtimecontrol"
	"github.com/tidbcloud/AgentTeam/internal/store/agent"
)

// List handles GET /reviews — the workspace-level review list (Explorer).
// Returns only review contexts the user can see (permission-filtered).
func (handler *ReviewHandler) List(r api.WorkspaceScopedRequest) (api.Response, error) {
	contexts, err := handler.reviewStore.ListVisibleReviewContextsForUser(r.Context(), r.UserID(), r.WorkspaceID())
	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
}

// Get handles GET /reviews/:reviewId.
func (handler *ReviewHandler) Get(r api.WorkspaceScopedRequest) (api.Response, error) {
	rc, err := handler.loadReviewContextForRequest(r)
	if err != nil {
		return api.Response{}, err
	}
	return api.OK(gin.H{"review": converter.ToReview(rc)}), nil
}

// Summary handles GET /reviews/:reviewId/summary[?agentId=...&baseRef=...&headRef=...].
func (handler *ReviewHandler) Summary(r api.WorkspaceScopedRequest) (api.Response, error) {
	rc, agentID, err := handler.loadReviewContextForRPC(r)
	if err != nil {
		return api.Response{}, err
	}
	baseRef, headRef, comparisonMode := reviewRefs(r)
	if strings.TrimSpace(baseRef) == "" {
		baseRef = rc.BaseRef
	}
	if strings.TrimSpace(headRef) == "" {
		headRef = rc.HeadRef
	}
	summary, err := handler.reviewSummary(r, rc, agentID, baseRef, headRef, comparisonMode)
	if err != nil {
		return api.Response{}, err
	}
	converted := converter.ToAgentReviewSummary(summary)
	return api.OK(gin.H{"summary": converted}), nil
}

// Snapshot handles GET /reviews/:reviewId/snapshot.
// Summary and the initial branch file list are computed by the daemon in one
// repository scan; individual file diffs remain lazy requests.
func (handler *ReviewHandler) Snapshot(r api.WorkspaceScopedRequest) (api.Response, error) {
	rc, agentID, err := handler.loadReviewContextForRPC(r)
	if err != nil {
		return api.Response{}, err
	}
	baseRef, headRef, comparisonMode := reviewRefs(r)
	if strings.TrimSpace(baseRef) == "" {
		baseRef = rc.BaseRef
	}
	if strings.TrimSpace(headRef) == "" {
		headRef = rc.HeadRef
	}
	requestCtx := remoteRequestContext(r)
	snapshot, err := handler.runtime.GetAgentReviewSnapshot(requestCtx, rc.MachineID, agentID, rc.RootPath, baseRef, headRef, comparisonMode)
	if errors.Is(err, runtimecontrol.ErrDaemonReviewSnapshotUnavailable) {
		snapshot, err = handler.legacyReviewSnapshot(requestCtx, rc, agentID, baseRef, headRef, comparisonMode)
	}
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(gin.H{"snapshot": converter.ToAgentReviewSnapshot(snapshot)}), nil
}

// Fingerprint handles GET /reviews/:reviewId/fingerprint. It is the cheap
// polling shortcut the frontend hits on a low cadence; only when one of the
// tokens changes does it re-fetch the full snapshot. Capability miss is
// surfaced as HTTP 501 with the upgrade message — the frontend falls back
// to polling the snapshot endpoint at the slower cadence.
func (handler *ReviewHandler) Fingerprint(r api.WorkspaceScopedRequest) (api.Response, error) {
	rc, agentID, err := handler.loadReviewContextForRPC(r)
	if err != nil {
		return api.Response{}, err
	}
	baseRef, headRef, comparisonMode := reviewRefs(r)
	if strings.TrimSpace(baseRef) == "" {
		baseRef = rc.BaseRef
	}
	if strings.TrimSpace(headRef) == "" {
		headRef = rc.HeadRef
	}
	requestCtx := remoteRequestContext(r)
	fingerprint, err := handler.runtime.GetAgentReviewFingerprint(requestCtx, rc.MachineID, agentID, rc.RootPath, baseRef, headRef, comparisonMode)
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(gin.H{"fingerprint": converter.ToAgentReviewFingerprint(fingerprint)}), nil
}

func (handler *ReviewHandler) legacyReviewSnapshot(ctx context.Context, rc agent.ReviewContext, agentID, baseRef, headRef, comparisonMode string) (agent.AgentReviewSnapshot, error) {
	var (
		snapshot                          agent.AgentReviewSnapshot
		summaryErr, branchErr, workingErr error
		wg                                sync.WaitGroup
	)
	wg.Add(3)
	logging.Go(nil, func() {
		defer wg.Done()
		snapshot.Summary, summaryErr = handler.runtime.GetAgentReviewSummary(ctx, rc.MachineID, agentID, rc.RootPath, baseRef, headRef, comparisonMode)
	})
	logging.Go(nil, func() {
		defer wg.Done()
		snapshot.Files, branchErr = handler.runtime.ListAgentReviewFiles(ctx, rc.MachineID, agentID, rc.RootPath, "branch", baseRef, headRef, comparisonMode)
	})
	logging.Go(nil, func() {
		defer wg.Done()
		snapshot.WorkingFiles, workingErr = handler.runtime.ListAgentReviewFiles(ctx, rc.MachineID, agentID, rc.RootPath, "working", baseRef, headRef, comparisonMode)
	})
	wg.Wait()
	return snapshot, errors.Join(summaryErr, branchErr, workingErr)
}

// Files handles GET /reviews/:reviewId/files[?agentId=...&scope=...&baseRef=...&headRef=...].
func (handler *ReviewHandler) Files(r api.WorkspaceScopedRequest) (api.Response, error) {
	rc, agentID, err := handler.loadReviewContextForRPC(r)
	if err != nil {
		return api.Response{}, err
	}
	scope, err := reviewScope(r)
	if err != nil {
		return api.Response{}, err
	}
	baseRef, headRef, comparisonMode := reviewRefs(r)
	if strings.TrimSpace(baseRef) == "" {
		baseRef = rc.BaseRef
	}
	if strings.TrimSpace(headRef) == "" {
		headRef = rc.HeadRef
	}
	if handler.runtime == nil {
		return api.Response{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}
	requestCtx := remoteRequestContext(r)
	files, err := handler.runtime.ListAgentReviewFiles(requestCtx, rc.MachineID, agentID, rc.RootPath, scope, baseRef, headRef, comparisonMode)
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(gin.H{"files": converter.ToAgentReviewFiles(files)}), nil
}

// Diff handles GET /reviews/:reviewId/diff[?agentId=...&scope=...&path=...&baseRef=...&headRef=...].
func (handler *ReviewHandler) Diff(r api.WorkspaceScopedRequest) (api.Response, error) {
	rc, agentID, err := handler.loadReviewContextForRPC(r)
	if err != nil {
		return api.Response{}, err
	}
	scope, err := reviewScope(r)
	if err != nil {
		return api.Response{}, err
	}
	// Path is a raw Git path: do not TrimSpace. The URL query decoder
	// already gives us the exact bytes the client sent, and Git paths may
	// legitimately contain leading/trailing whitespace or newlines. Trimming
	// here would request the diff for a different file. Only check emptiness.
	path := r.Gin.Query("path")
	if path == "" {
		return api.Response{}, api.NewHTTPError(http.StatusBadRequest, "path is required")
	}
	baseRef, headRef, comparisonMode := reviewRefs(r)
	if strings.TrimSpace(baseRef) == "" {
		baseRef = rc.BaseRef
	}
	if strings.TrimSpace(headRef) == "" {
		headRef = rc.HeadRef
	}
	if handler.runtime == nil {
		return api.Response{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}
	requestCtx := remoteRequestContext(r)
	diff, err := handler.runtime.GetAgentReviewDiff(requestCtx, rc.MachineID, agentID, rc.RootPath, scope, path, baseRef, headRef, comparisonMode)
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(gin.H{"diff": converter.ToAgentReviewDiff(diff)}), nil
}

// DiffBatch handles POST /reviews/:reviewId/diff/batch. It exists so the
// frontend can resolve every visible diff panel in one server round-trip
// instead of fanning out N parallel GETs — each of which re-opens the repo
// and re-walks the merge base on the desktop side. The desktop-side cost is
// only paid once per batch (loadReviewRepoState runs a single time inside
// workspace.ReviewDiffBatch), and the wire cost collapses N HTTP requests
// into one.
//
// Per-path errors (unsupported content, file not found) are returned in the
// response body so a single bad path does not poison the batch. Top-level
// errors (repo unreachable, capability miss, machine offline) surface as
// normal HTTP errors via reviewRuntimeError.
func (handler *ReviewHandler) DiffBatch(r api.WorkspaceScopedJSON[dto.ReviewDiffBatchRequest]) (api.Response, error) {
	rc, agentID, err := handler.loadReviewContextForRPC(r.WorkspaceScopedRequest)
	if err != nil {
		return api.Response{}, err
	}
	scope, err := reviewBatchScope(r.Body.Scope)
	if err != nil {
		return api.Response{}, err
	}
	paths, err := reviewBatchPaths(r.Body.Paths)
	if err != nil {
		return api.Response{}, err
	}
	baseRef, headRef, comparisonMode := reviewBatchRefs(r.Body, rc)
	if handler.runtime == nil {
		return api.Response{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}
	requestCtx := remoteRequestContext(r.WorkspaceScopedRequest)
	results, err := handler.runtime.GetAgentReviewDiffBatch(requestCtx, rc.MachineID, agentID, rc.RootPath, scope, paths, baseRef, headRef, comparisonMode)
	if err != nil {
		return api.Response{}, reviewRuntimeError(err)
	}
	return api.OK(buildReviewDiffBatchResponse(paths, results)), nil
}

func (handler *ReviewHandler) reviewSummary(r api.WorkspaceScopedRequest, rc agent.ReviewContext, agentID, baseRef, headRef, comparisonMode string) (agent.AgentReviewSummary, error) {
	if handler.runtime == nil {
		return agent.AgentReviewSummary{}, api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}
	requestCtx := remoteRequestContext(r)
	summary, err := handler.runtime.GetAgentReviewSummary(requestCtx, rc.MachineID, agentID, rc.RootPath, baseRef, headRef, comparisonMode)
	if err != nil {
		return agent.AgentReviewSummary{}, reviewRuntimeError(err)
	}
	return summary, nil
}

func (handler *ReviewHandler) loadReviewContextForRequest(r api.WorkspaceScopedRequest) (*agent.ReviewContext, error) {
	reviewID := strings.TrimSpace(r.Gin.Param("reviewId"))
	if reviewID == "" {
		return nil, api.NewHTTPError(http.StatusBadRequest, "reviewId is required")
	}
	return handler.reviewStore.GetReviewContextForUser(r.Context(), r.UserID(), r.WorkspaceID(), reviewID)
}

// loadReviewContextForRPC loads a review context by :reviewId for a summary/
// files/diff RPC. Access is gated by GetReviewContextForUser.
func (handler *ReviewHandler) loadReviewContextForRPC(r api.WorkspaceScopedRequest) (agent.ReviewContext, string, error) {
	rc, err := handler.loadReviewContextForRequest(r)
	if err != nil {
		return agent.ReviewContext{}, "", err
	}
	if rc == nil {
		return agent.ReviewContext{}, "", api.ErrNotFound
	}
	agentID := strings.TrimSpace(r.Gin.Query("agentId"))
	if agentID == "" {
		agentID = strings.TrimSpace(r.Gin.Param("agentId"))
	}
	return *rc, agentID, nil
}

func reviewRefs(r api.WorkspaceScopedRequest) (string, string, string) {
	baseRef := strings.TrimSpace(r.Gin.Query("baseRef"))
	if baseRef == "" {
		baseRef = strings.TrimSpace(r.Gin.Query("compareRef"))
	}
	return baseRef, strings.TrimSpace(r.Gin.Query("headRef")), strings.TrimSpace(r.Gin.Query("comparisonMode"))
}

func reviewScope(r api.WorkspaceScopedRequest) (string, error) {
	scope := strings.TrimSpace(r.Gin.Query("scope"))
	if scope == "" {
		scope = "branch"
	}
	if scope != "branch" && scope != "working" {
		return "", api.NewHTTPError(http.StatusBadRequest, "scope must be branch or working")
	}
	return scope, nil
}

func remoteRequestContext(r api.WorkspaceScopedRequest) context.Context {
	return runtimecontrol.WithRemoteRequestContext(r.Context(), runtimecontrol.RemoteRequestContext{
		Origin: runtimecontrol.RemoteRequestOriginUserInteractive,
		Cause:  runtimecontrol.RemoteRequestCauseWebAPI,
	})
}

func reviewRuntimeError(err error) error {
	if errors.Is(err, runtimecontrol.ErrMachineOffline) {
		return api.NewHTTPError(http.StatusServiceUnavailable, "Machine is offline")
	}
	if errors.Is(err, runtimecontrol.ErrDaemonReviewFingerprintUnavailable) {
		return api.NewHTTPError(http.StatusNotImplemented, runtimecontrol.ReviewFingerprintDaemonUpgradeMessage)
	}
	if errors.Is(err, runtimecontrol.ErrDaemonWorkspaceTargetPathUpgradeRequired) {
		return api.NewHTTPError(http.StatusConflict, runtimecontrol.WorkspaceTargetPathDaemonUpgradeMessage)
	}
	if errors.Is(err, runtimecontrol.ErrDaemonPermissionModeUpgradeRequired) {
		return api.NewHTTPError(http.StatusConflict, runtimecontrol.AgentPermissionModeDaemonUpgradeMessage)
	}
	// Product limitation: repo tree contains a path the diff engine cannot
	// handle. Surface a stable "unsupported" response so the frontend can
	// distinguish it from transient 500s and show the user a fixed message
	// instead of retry-storming. The sentinel's message is path-free.
	if errors.Is(err, runtimecontrol.ErrReviewTargetUnsupported) {
		return api.NewHTTPError(http.StatusUnprocessableEntity, runtimecontrol.ReviewTargetUnsupportedMessage)
	}
	// Stable business errors from the remote-endpoint routing layer: the
	// daemon cannot accept this request via any transport right now. Map
	// busy/unavailable to 503 so clients can retry; the stable code is
	// preserved in the response body for telemetry and frontend routing.
	if stable, ok := remoteRequestStableErrorClassification(err); ok {
		return api.NewHTTPError(http.StatusServiceUnavailable, stable)
	}
	return api.NewHTTPError(http.StatusInternalServerError, "Internal server error")
}

// remoteRequestStableErrorClassification maps the opaque stable-error strings
// emitted by the remote-endpoint routing layer (remoteRequestStableError in
// the daemon package) back to a stable, user-facing classification. Returns
// ok=false for errors that are not stable-business errors so the caller can
// fall through to its generic 500 mapping.
func remoteRequestStableErrorClassification(err error) (string, bool) {
	if err == nil {
		return "", false
	}
	switch err.Error() {
	case "desktop_busy":
		return "Desktop is busy", true
	case "desktop_unavailable":
		return "Desktop endpoint is unavailable", true
	default:
		return "", false
	}
}

// reviewBatchScope validates the scope field on a batch request body. Empty
// defaults to "branch" — same as the GET /diff path. The handler does not
// TrimSpace the scope because scope is a closed enum (branch|working), so
// stray whitespace is a client bug we want to surface as 400, not silently
// rewrite.
func reviewBatchScope(scope string) (string, error) {
	if scope == "" {
		return "branch", nil
	}
	if scope != "branch" && scope != "working" {
		return "", api.NewHTTPError(http.StatusBadRequest, "scope must be branch or working")
	}
	return scope, nil
}

// reviewBatchPaths validates the paths field on a batch request body. As with
// the single-path Diff endpoint, entries are raw Git paths and MUST NOT be
// TrimSpace'd — leading/trailing whitespace is legitimate path content. We
// only reject the empty list and empty-string entries, both of which are
// client bugs.
func reviewBatchPaths(paths []string) ([]string, error) {
	if len(paths) == 0 {
		return nil, api.NewHTTPError(http.StatusBadRequest, "paths is required")
	}
	for _, path := range paths {
		if path == "" {
			return nil, api.NewHTTPError(http.StatusBadRequest, "paths must not contain empty strings")
		}
	}
	// Cap the batch size to keep the wire payload and the desktop-side work
	// bounded. The frontend's refresh transaction already slices required
	// paths to a per-run maximum (see reviewLiveRefresh.MAX_PATHS); the
	// server cap is the backstop for direct API callers.
	const maxBatchPaths = 64
	if len(paths) > maxBatchPaths {
		return nil, api.NewHTTPError(http.StatusBadRequest, "too many paths in a single batch")
	}
	return paths, nil
}

// reviewBatchRefs resolves refs for a batch request. Empty fields fall back
// to the review context's persisted refs — same behavior as the GET /diff
// path. We TrimSpace only the ref fields (which are git ref names, not file
// paths), never the path entries.
func reviewBatchRefs(body dto.ReviewDiffBatchRequest, rc agent.ReviewContext) (string, string, string) {
	baseRef := strings.TrimSpace(body.BaseRef)
	if baseRef == "" {
		baseRef = rc.BaseRef
	}
	headRef := strings.TrimSpace(body.HeadRef)
	if headRef == "" {
		headRef = rc.HeadRef
	}
	comparisonMode := strings.TrimSpace(body.ComparisonMode)
	return baseRef, headRef, comparisonMode
}

// buildReviewDiffBatchResponse converts the runtime's per-path result map
// into the wire DTO. The paths slice is the request's paths in order;
// results is the runtime's answer (one entry per path, by contract).
//
// Per-path errors are encoded inside the matching AgentReviewDiff with a
// generic "error" status. Internal error text is intentionally NOT forwarded
// — those messages are typically go-git / os-level details that the frontend
// can't act on and that may leak filesystem specifics. The unsupported_target
// sentinel is a product-limitation signal that the desktop already encoded
// into ReviewDiff.Status before reaching us, so it round-trips naturally as a
// Diff entry, not an Error.
func buildReviewDiffBatchResponse(paths []string, results map[string]daemon.AgentReviewDiffBatchResult) dto.ReviewDiffBatchResponse {
	response := dto.ReviewDiffBatchResponse{
		Diffs:  make(map[string]dto.AgentReviewDiff, len(paths)),
		Errors: make(map[string]string, len(paths)),
	}
	for _, path := range paths {
		result, ok := results[path]
		if !ok {
			response.Errors[path] = "Diff was not returned for this path"
			continue
		}
		if result.Error != nil {
			response.Errors[path] = "Failed to load diff"
			continue
		}
		response.Diffs[path] = converter.ToAgentReviewDiff(result.Diff)
	}
	if len(response.Errors) == 0 {
		response.Errors = nil
	}
	return response
}
