package agent

import (
	"path/filepath"
	"strings"
	"unicode"

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

// ValidateReviewParams normalizes and validates a review context upsert. Reviews
// are always git_repo targets; directory targets are dropped.
//
// RootPath is the canonical filesystem path to the worktree and MUST be
// preserved byte-exact: a trailing-whitespace canonical root ("/repo " vs
// "/repo") is a distinct worktree, and trimming would silently remap one to
// the other on create/update/identity-hash. Only the empty string means
// "missing". The absoluteness check likewise runs against the raw path so a
// leading-space path is rejected as non-absolute rather than rewritten.
//
// WorkspaceID and MachineID are the core of the target identity
// (target_hash = sha256(workspace ⊕ machine ⊕ root ⊕ base ⊕ head)). An empty
// value for either after trim makes the identity hash ambiguous — two
// different missing-machine calls would hash to the SAME key and silently
// dedup to one row. They are therefore REQUIRED and surface as typed
// storeerr.BadRequest (HTTP 400) when missing. The caller is expected to
// fill in defaults BEFORE calling validate (e.g. CreateReviewContextForAgent
// fills MachineID from authenticatedAgent.MachineID; UpdateReviewContextForUser
// carries WorkspaceID forward from the existing row).
//
// Update semantics: UpdateReviewContextForUser is a REPLACE-TARGET operation.
// The new machine/root/base/head tuple REPLACES the existing one — omitting
// MachineID is NOT "preserve existing", it is a 400. Callers that want to
// keep the existing machine must re-supply the same value. DisplayName is the
// only field with omit=preserve semantics; see UpdateReviewContextForUser.
func ValidateReviewParams(params UpsertReviewContextParams) (UpsertReviewContextParams, error) {
	normalized := UpsertReviewContextParams{
		WorkspaceID:     strings.TrimSpace(params.WorkspaceID),
		ThreadID:        strings.TrimSpace(params.ThreadID),
		TaskScopeID:     strings.TrimSpace(params.TaskScopeID),
		SourceAgentID:   strings.TrimSpace(params.SourceAgentID),
		MachineID:       strings.TrimSpace(params.MachineID),
		RootPath:        params.RootPath,
		BaseRef:         strings.TrimSpace(params.BaseRef),
		HeadRef:         strings.TrimSpace(params.HeadRef),
		DisplayName:     strings.TrimSpace(params.DisplayName),
		PRURL:           strings.TrimSpace(params.PRURL),
		ProjectID:       strings.TrimSpace(params.ProjectID),
		CreatedByMember: params.CreatedByMember,
		UpdatedByMember: params.UpdatedByMember,
	}
	if !isAbsoluteTargetPath(normalized.RootPath) {
		return UpsertReviewContextParams{}, storeerr.BadRequest("rootPath must be an absolute path")
	}
	if normalized.WorkspaceID == "" {
		return UpsertReviewContextParams{}, storeerr.BadRequest("workspaceId is required")
	}
	if normalized.MachineID == "" {
		return UpsertReviewContextParams{}, storeerr.BadRequest("machineId is required")
	}
	if normalized.TaskScopeID != "" {
		if !strings.HasPrefix(normalized.TaskScopeID, "tsk_") {
			return UpsertReviewContextParams{}, storeerr.BadRequest("taskScopeId must be a canonical task scope")
		}
		if normalized.SourceAgentID == "" {
			return UpsertReviewContextParams{}, storeerr.BadRequest("task review context requires sourceAgentId")
		}
	}
	if normalized.DisplayName == "" {
		normalized.DisplayName = defaultReviewDisplayName(normalized.RootPath)
	}
	return normalized, nil
}

// defaultReviewDisplayName returns the basename of rootPath for use as the
// display name when the caller omitted one. The basename is returned RAW —
// trailing whitespace is preserved — because two worktrees whose canonical
// roots differ only by trailing whitespace ("/repo" vs "/repo ") are distinct
// targets (see ValidateReviewParams RootPath contract) and their auto-
// generated display names MUST also differ; trimming the basename would
// produce identical display names for distinct targets and hide the
// distinction in the UI. The TrimSpace check is used ONLY to detect an
// all-whitespace basename (degenerate case where we fall back to the raw
// rootPath), not to mutate the returned value.
//
// Compat note: an earlier revision of this function returned the FULL
// rootPath as the default display name. The basename default is intentional —
// the UI shows the display name as a short label, where the full path is
// noise. Clients that need the canonical path should read RootPath, not
// DisplayName; the two fields are independent and DisplayName MUST NOT be
// used as a fallback path.
func defaultReviewDisplayName(rootPath string) string {
	base := filepath.Base(rootPath)
	if strings.TrimSpace(base) == "" || base == "." || base == string(filepath.Separator) {
		return rootPath
	}
	return base
}

func isAbsoluteTargetPath(path string) bool {
	if path == "" {
		return false
	}
	if filepath.IsAbs(path) {
		return true
	}
	if len(path) >= 3 && unicode.IsLetter(rune(path[0])) && path[1] == ':' {
		switch path[2] {
		case '\\', '/':
			return true
		}
	}
	return strings.HasPrefix(path, `\\`)
}
