// Package execution will host the `agentteam exec role` management service:
// request idempotency, the approval subject, the operation ledger/worker, and
// MeshX authoritative CAS.
//
// This file holds the PURE-LOGIC request model for the first behavior slice:
// normalize, idempotency key, presence-aware payload digest, and replay
// classification. The functions are deterministic, allocation-light, and have
// no DB/store/approval/worker dependencies. The durable operation ledger,
// worker lease/fencing/reclaim, and MeshX CAS land in later batches.
//
// Design references (NOT imported): main's agentops reserve/CAS shape (whose
// stuck-running no-reclaim gap the Role worker must avoid) and the reviewed
// App operation reserve/attach/terminal shape. That package is NOT on main
// yet — it is a design reference only, never imported or copied. agentops IS on main. Store
// evidence for later batches comes only from main's generic approval registry,
// workflowrun lease/fence, and serviceaccount outbox.
package execution

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"strconv"
	"strings"
)

// ErrInvalidRoleRequest signals a request Normalize or Digest rejects
// (unknown/blank identity, action/branch mismatch, blank required text, illegal
// enum, non-positive schema version, invalid replay inputs, or a non-value
// proposal such as nil/typed-nil/non-nil pointer).
var ErrInvalidRoleRequest = errors.New("rolemanagement: invalid role request")

// RoleRequestAction is the locked V1 set of approval-bearing Role actions.
type RoleRequestAction string

const (
	ActionCreate RoleRequestAction = "create"
	ActionUpdate RoleRequestAction = "update"
	ActionDelete RoleRequestAction = "delete"
	ActionApply  RoleRequestAction = "apply"
)

// RoleRequestDigestSchemaVersion is the payload-digest schema version. Bumping
// it invalidates payload digests from prior versions and must itself change the
// digest. It does NOT affect the request identity/idempotency key, and must be
// positive.
const RoleRequestDigestSchemaVersion = 1

// RoleProposal is the closed typed union of action-specific Role request
// payloads. The unexported sealedRoleProposal method means only the four
// package-internal value types can implement it, so exactly one branch is
// carried per request and unrelated/cross-branch fields cannot enter the
// idempotency digest. Optional string fields are *string so the digest can
// distinguish "omitted" (nil = keep current) from "explicitly empty"
// (non-nil "" = clear) — the durable ledger must stay presence-aware so replay
// of an omitted-field request is never merged with an explicit-clear request
// (#t2249 contract).
type RoleProposal interface {
	Action() RoleRequestAction
	sealedRoleProposal()
}

// RoleCreateProposal creates a private Role. Description is a plain string
// (matching the locked Exec roleCreateRequest.Description): create has no
// "keep vs clear" patch semantics, so omitted/empty/whitespace all canonicalize
// to "" and share one digest.
type RoleCreateProposal struct {
	Name        string // required, non-blank
	Definition  string // required, non-blank
	Description string // optional, canonicalized to trimmed "" when unset/blank
}

func (RoleCreateProposal) Action() RoleRequestAction { return ActionCreate }
func (RoleCreateProposal) sealedRoleProposal()       {}

// RoleUpdateProposal updates a Role. At least one mutation field must be
// present (Name/Description/Definition/Visibility); visibility uses the closed
// {private,workspace} enum when set.
type RoleUpdateProposal struct {
	RoleID                string  // required
	ExpectedRoleVersionID string  // required (stale fence)
	Name                  *string // optional, non-blank when set
	Description           *string // optional, clearable
	Definition            *string // optional, non-blank when set
	Visibility            string  // optional, enum private|workspace, "" when unset
}

func (RoleUpdateProposal) Action() RoleRequestAction { return ActionUpdate }
func (RoleUpdateProposal) sealedRoleProposal()       {}

// RoleDeleteProposal soft-deletes (hides) a Role.
type RoleDeleteProposal struct {
	RoleID                string // required
	ExpectedRoleVersionID string // required (stale fence)
}

func (RoleDeleteProposal) Action() RoleRequestAction { return ActionDelete }
func (RoleDeleteProposal) sealedRoleProposal()       {}

// RoleApplyProposal applies a fixed, immutable Role version to a target Agent.
type RoleApplyProposal struct {
	RoleID        string // required
	RoleVersionID string // required (immutable version)
	AgentID       string // required (target agent)
}

func (RoleApplyProposal) Action() RoleRequestAction { return ActionApply }
func (RoleApplyProposal) sealedRoleProposal()       {}

// RoleRequest is the typed request envelope. Identity is the locked
// (workspace, applicantAgent, requestId) idempotency domain; action is NOT part
// of the identity (it enters the payload digest via the proposal).
type RoleRequest struct {
	WorkspaceID      string
	ApplicantAgentID string
	RequestID        string
	Proposal         RoleProposal
}

// ReplayClassification is the result of comparing an authoritative stored
// request against a fresh submission under the same idempotency key.
type ReplayClassification int

const (
	// ReplayConflict means the same (workspace, applicantAgent, requestId) key
	// was reused with a different action or payload digest (HTTP 409).
	ReplayConflict ReplayClassification = iota
	// ReplayMatch means the stored action and digest both equal the new one — a
	// safe idempotent replay of the prior request.
	ReplayMatch
)

// Domain separators for the canonical encodings. Each is length-prefixed into
// the hashed input, so the identity and digest inputs cannot be confused with
// each other or with another domain. The codec/version is embedded in the
// constant so bumping it changes every key/digest.
const (
	roleKeyDomain    = "agentteam/role-idempotency-key/v1"
	roleDigestDomain = "agentteam/role-request-digest/v1"
)

// NormalizeRoleRequest validates and canonicalizes a typed Role request: it
// trims the identity triple and every text field, validates action-specific
// branch fields, preserves *string presence (nil vs non-nil-empty), and rejects
// nil/typed-nil/non-nil-pointer proposals, trim-empty identity, blank required
// text, update-with-no-mutation, and illegal enum values.
func NormalizeRoleRequest(request RoleRequest) (RoleRequest, error) {
	workspaceID := strings.TrimSpace(request.WorkspaceID)
	applicantAgentID := strings.TrimSpace(request.ApplicantAgentID)
	requestID := strings.TrimSpace(request.RequestID)
	if workspaceID == "" || applicantAgentID == "" || requestID == "" {
		return RoleRequest{}, ErrInvalidRoleRequest
	}
	proposal, err := normalizeProposal(request.Proposal)
	if err != nil {
		return RoleRequest{}, err
	}
	return RoleRequest{
		WorkspaceID:      workspaceID,
		ApplicantAgentID: applicantAgentID,
		RequestID:        requestID,
		Proposal:         proposal,
	}, nil
}

// RoleIdempotencyKey derives the unique request key from the locked identity
// domain {workspaceID, applicantAgentID, requestID}. action is deliberately NOT
// encoded (it enters the payload digest), so request-get{requestId} is
// unambiguous and payload schemaVersion cannot change the identity. Identifiers
// are trimmed but not case-folded. The result is a deterministic lowercase-hex
// SHA-256 over a domain-separated, length-prefixed canonical envelope.
func RoleIdempotencyKey(workspaceID, applicantAgentID, requestID string) (string, error) {
	ws := strings.TrimSpace(workspaceID)
	agent := strings.TrimSpace(applicantAgentID)
	req := strings.TrimSpace(requestID)
	if ws == "" || agent == "" || req == "" {
		return "", ErrInvalidRoleRequest
	}
	return sha256HexOf(lengthPrefixed(roleKeyDomain) + lengthPrefixed(ws) + lengthPrefixed(agent) + lengthPrefixed(req)), nil
}

// DigestRoleRequest derives a deterministic lowercase-hex SHA-256 digest over a
// domain-separated canonical encoding of {schemaVersion, action, presence-aware
// typed proposal}. schemaVersion must be positive and the proposal must be a
// valid value branch (Digest reuses Normalize-style validation, so it cannot
// hash an invalid proposal). Payload field order does not affect the digest.
func DigestRoleRequest(schemaVersion int, proposal RoleProposal) (string, error) {
	if schemaVersion <= 0 {
		return "", ErrInvalidRoleRequest
	}
	normalized, err := normalizeProposal(proposal)
	if err != nil {
		return "", err
	}
	return sha256HexOf(lengthPrefixed(roleDigestDomain) + lengthPrefixed(strconv.Itoa(schemaVersion)) + encodeProposal(normalized)), nil
}

// ClassifyReplay compares an authoritative stored (action, digest) against a
// fresh submission under the same idempotency key: both equal => ReplayMatch;
// either differs => ReplayConflict. Unknown actions and malformed digests
// (not 64-char lowercase hex) are invalid comparators and return
// ErrInvalidRoleRequest rather than a replay verdict.
func ClassifyReplay(storedAction, newAction RoleRequestAction, storedDigest, newDigest string) (ReplayClassification, error) {
	if !isValidAction(storedAction) || !isValidAction(newAction) {
		return ReplayConflict, ErrInvalidRoleRequest
	}
	if !isValidDigest(storedDigest) || !isValidDigest(newDigest) {
		return ReplayConflict, ErrInvalidRoleRequest
	}
	if storedAction == newAction && storedDigest == newDigest {
		return ReplayMatch, nil
	}
	return ReplayConflict, nil
}

// normalizeProposal type-switches ONLY the four value branches; a nil interface
// or any pointer proposal (typed-nil or non-nil) falls to the default and is
// rejected, so there is never a value/pointer dual-encoding surface. Each
// branch validates its required fields and returns a trimmed, presence-aware
// canonical copy.
func normalizeProposal(proposal RoleProposal) (RoleProposal, error) {
	switch p := proposal.(type) {
	case RoleCreateProposal:
		name := strings.TrimSpace(p.Name)
		definition := strings.TrimSpace(p.Definition)
		if name == "" || definition == "" {
			return nil, ErrInvalidRoleRequest
		}
		return RoleCreateProposal{Name: name, Definition: definition, Description: strings.TrimSpace(p.Description)}, nil
	case RoleUpdateProposal:
		roleID := strings.TrimSpace(p.RoleID)
		expected := strings.TrimSpace(p.ExpectedRoleVersionID)
		if roleID == "" || expected == "" {
			return nil, ErrInvalidRoleRequest
		}
		visibility := strings.TrimSpace(p.Visibility)
		if visibility != "" && visibility != "private" && visibility != "workspace" {
			return nil, ErrInvalidRoleRequest
		}
		name, err := optionalNonBlank(p.Name)
		if err != nil {
			return nil, err
		}
		definition, err := optionalNonBlank(p.Definition)
		if err != nil {
			return nil, err
		}
		description := optionalClearable(p.Description)
		if name == nil && definition == nil && description == nil && visibility == "" {
			return nil, ErrInvalidRoleRequest
		}
		return RoleUpdateProposal{
			RoleID: roleID, ExpectedRoleVersionID: expected,
			Name: name, Description: description, Definition: definition, Visibility: visibility,
		}, nil
	case RoleDeleteProposal:
		roleID := strings.TrimSpace(p.RoleID)
		expected := strings.TrimSpace(p.ExpectedRoleVersionID)
		if roleID == "" || expected == "" {
			return nil, ErrInvalidRoleRequest
		}
		return RoleDeleteProposal{RoleID: roleID, ExpectedRoleVersionID: expected}, nil
	case RoleApplyProposal:
		roleID := strings.TrimSpace(p.RoleID)
		roleVersionID := strings.TrimSpace(p.RoleVersionID)
		agentID := strings.TrimSpace(p.AgentID)
		if roleID == "" || roleVersionID == "" || agentID == "" {
			return nil, ErrInvalidRoleRequest
		}
		return RoleApplyProposal{RoleID: roleID, RoleVersionID: roleVersionID, AgentID: agentID}, nil
	}
	return nil, ErrInvalidRoleRequest
}

// encodeProposal serializes a normalized value-branch proposal into a stable,
// presence-aware, length-prefixed canonical string (no raw delimiter
// concatenation — every component is self-delimiting). It is only called on the
// output of normalizeProposal, so only value branches reach it.
func encodeProposal(proposal RoleProposal) string {
	switch p := proposal.(type) {
	case RoleCreateProposal:
		return lengthPrefixed("create") + lengthPrefixed(p.Name) + lengthPrefixed(p.Definition) + lengthPrefixed(p.Description)
	case RoleUpdateProposal:
		return lengthPrefixed("update") + lengthPrefixed(p.RoleID) + lengthPrefixed(p.ExpectedRoleVersionID) +
			presenceString(p.Name) + presenceString(p.Description) + presenceString(p.Definition) + lengthPrefixed(p.Visibility)
	case RoleDeleteProposal:
		return lengthPrefixed("delete") + lengthPrefixed(p.RoleID) + lengthPrefixed(p.ExpectedRoleVersionID)
	case RoleApplyProposal:
		return lengthPrefixed("apply") + lengthPrefixed(p.RoleID) + lengthPrefixed(p.RoleVersionID) + lengthPrefixed(p.AgentID)
	}
	return ""
}

// lengthPrefixed prefixes a string with its decimal length + ":" so that
// distinct field values cannot collide via concatenation (e.g. "ab"+"c" vs
// "a"+"bc").
func lengthPrefixed(value string) string {
	return strconv.Itoa(len(value)) + ":" + value
}

// presenceString encodes a *string preserving all three wire states: nil => "n"
// (omit/keep), non-nil "" => "e0:" (clear), non-nil value => "e<len>:<value>".
// The leading marker ("n" vs "e") makes nil distinct from non-nil-empty, so
// the digest never merges them.
func presenceString(value *string) string {
	if value == nil {
		return "n"
	}
	return "e" + lengthPrefixed(*value)
}

// optionalNonBlank trims a *string and requires it to be non-blank when set
// (used for update Name/Definition); nil passes through as nil.
func optionalNonBlank(value *string) (*string, error) {
	if value == nil {
		return nil, nil
	}
	trimmed := strings.TrimSpace(*value)
	if trimmed == "" {
		return nil, ErrInvalidRoleRequest
	}
	return &trimmed, nil
}

// optionalClearable trims a *string when set, allowing "" (used for
// description, which is clearable); nil passes through as nil.
func optionalClearable(value *string) *string {
	if value == nil {
		return nil
	}
	trimmed := strings.TrimSpace(*value)
	return &trimmed
}

func isValidAction(action RoleRequestAction) bool {
	switch action {
	case ActionCreate, ActionUpdate, ActionDelete, ActionApply:
		return true
	}
	return false
}

// isValidDigest reports whether digest is exactly 64 lowercase hex characters.
func isValidDigest(digest string) bool {
	if len(digest) != 64 {
		return false
	}
	for _, c := range digest {
		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
			return false
		}
	}
	return true
}

func sha256HexOf(input string) string {
	sum := sha256.Sum256([]byte(input))
	return hex.EncodeToString(sum[:])
}
