package execution

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/require"
)

func ptrString(value string) *string { return &value }

// sha256Hex is the locked hash shape for both the idempotency key and the
// payload digest: 64 lowercase hex characters.
const sha256Hex = "^[0-9a-f]{64}$"

// validDigest is a 64-char lowercase-hex digest used as a comparator fixture.
var validDigest = strings.Repeat("0123456789abcdef", 4)

func TestNormalizeRoleRequestRejectsInvalidInput(t *testing.T) {
	for _, tc := range []struct {
		name    string
		request RoleRequest
	}{
		{"nil proposal", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req"}},
		{"blank workspace", RoleRequest{WorkspaceID: "   ", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleCreateProposal{Name: "n", Definition: "d"}}},
		{"blank applicant", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: " ", RequestID: "req",
			Proposal: RoleCreateProposal{Name: "n", Definition: "d"}}},
		{"blank requestId", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "",
			Proposal: RoleCreateProposal{Name: "n", Definition: "d"}}},
		{"create blank name", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleCreateProposal{Name: "  ", Definition: "d"}}},
		{"create blank definition", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleCreateProposal{Name: "n", Definition: "   "}}},
		{"update missing roleId", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleUpdateProposal{ExpectedRoleVersionID: "v1", Name: ptrString("n")}}},
		{"update missing expectedVersion", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleUpdateProposal{RoleID: "role-1", Name: ptrString("n")}}},
		{"update blank name", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleUpdateProposal{RoleID: "role-1", ExpectedRoleVersionID: "v1", Name: ptrString("   ")}}},
		{"update blank definition", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleUpdateProposal{RoleID: "role-1", ExpectedRoleVersionID: "v1", Definition: ptrString(" ")}}},
		{"update with no mutation", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleUpdateProposal{RoleID: "role-1", ExpectedRoleVersionID: "v1"}}},
		{"update illegal visibility", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleUpdateProposal{RoleID: "role-1", ExpectedRoleVersionID: "v1", Visibility: "public"}}},
		{"delete missing roleId", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleDeleteProposal{ExpectedRoleVersionID: "v1"}}},
		{"delete missing expectedVersion", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleDeleteProposal{RoleID: "role-1"}}},
		{"apply missing roleId", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleApplyProposal{RoleVersionID: "v1", AgentID: "agt-1"}}},
		{"apply missing roleVersionId", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleApplyProposal{RoleID: "role-1", AgentID: "agt-1"}}},
		{"apply missing agentId", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: RoleApplyProposal{RoleID: "role-1", RoleVersionID: "v1"}}},
		{"typed-nil create proposal", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: (*RoleCreateProposal)(nil)}},
		{"typed-nil update proposal", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: (*RoleUpdateProposal)(nil)}},
		{"typed-nil delete proposal", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: (*RoleDeleteProposal)(nil)}},
		{"typed-nil apply proposal", RoleRequest{WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req",
			Proposal: (*RoleApplyProposal)(nil)}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := NormalizeRoleRequest(tc.request)
			require.ErrorIs(t, err, ErrInvalidRoleRequest, "%s must be rejected", tc.name)
		})
	}
}

func TestNormalizeRoleRequestNormalizesValidBranches(t *testing.T) {
	envelope := func(proposal RoleProposal) RoleRequest {
		return RoleRequest{WorkspaceID: " ws ", ApplicantAgentID: " agent ", RequestID: " req ", Proposal: proposal}
	}

	t.Run("create trims identity, text, and description; omitted/empty/whitespace description expectedize to empty", func(t *testing.T) {
		got, err := NormalizeRoleRequest(envelope(RoleCreateProposal{
			Name: " n ", Definition: " d ", Description: " desc ",
		}))
		require.NoError(t, err)
		require.Equal(t, "ws", got.WorkspaceID)
		require.Equal(t, "agent", got.ApplicantAgentID)
		require.Equal(t, "req", got.RequestID)
		create, ok := got.Proposal.(RoleCreateProposal)
		require.True(t, ok)
		require.Equal(t, "n", create.Name)
		require.Equal(t, "d", create.Definition)
		require.Equal(t, "desc", create.Description, "non-empty description is trimmed but kept")

		// create description is a plain string (matches Exec roleCreateRequest);
		// omitted/empty/whitespace all expectedize to "".
		omitted, err := NormalizeRoleRequest(envelope(RoleCreateProposal{Name: "n", Definition: "d"}))
		require.NoError(t, err)
		require.Equal(t, "", omitted.Proposal.(RoleCreateProposal).Description)
		blank, err := NormalizeRoleRequest(envelope(RoleCreateProposal{Name: "n", Definition: "d", Description: "   "}))
		require.NoError(t, err)
		require.Equal(t, "", blank.Proposal.(RoleCreateProposal).Description)
	})

	t.Run("update trims roleId, version, visibility, name, definition, description", func(t *testing.T) {
		got, err := NormalizeRoleRequest(envelope(RoleUpdateProposal{
			RoleID: " role-1 ", ExpectedRoleVersionID: " v1 ", Visibility: " workspace ",
			Name: ptrString(" n "), Definition: ptrString(" d "), Description: ptrString(" desc "),
		}))
		require.NoError(t, err)
		update, ok := got.Proposal.(RoleUpdateProposal)
		require.True(t, ok)
		require.Equal(t, "role-1", update.RoleID)
		require.Equal(t, "v1", update.ExpectedRoleVersionID)
		require.Equal(t, "workspace", update.Visibility)
		require.NotNil(t, update.Name)
		require.Equal(t, "n", *update.Name)
		require.NotNil(t, update.Definition)
		require.Equal(t, "d", *update.Definition)
		require.NotNil(t, update.Description)
		require.Equal(t, "desc", *update.Description)

		cleared, err := NormalizeRoleRequest(envelope(RoleUpdateProposal{
			RoleID: "role-1", ExpectedRoleVersionID: "v1", Description: ptrString(""),
		}))
		require.NoError(t, err)
		updateClear, ok := cleared.Proposal.(RoleUpdateProposal)
		require.True(t, ok)
		require.NotNil(t, updateClear.Description)
		require.Equal(t, "", *updateClear.Description)
	})

	t.Run("delete trims roleId and expectedVersion", func(t *testing.T) {
		got, err := NormalizeRoleRequest(envelope(RoleDeleteProposal{RoleID: " role-1 ", ExpectedRoleVersionID: " v1 "}))
		require.NoError(t, err)
		del, ok := got.Proposal.(RoleDeleteProposal)
		require.True(t, ok)
		require.Equal(t, "role-1", del.RoleID)
		require.Equal(t, "v1", del.ExpectedRoleVersionID)
	})

	t.Run("apply trims roleId, roleVersionId, agentId", func(t *testing.T) {
		got, err := NormalizeRoleRequest(envelope(RoleApplyProposal{
			RoleID: " role-1 ", RoleVersionID: " v1 ", AgentID: " agt-1 ",
		}))
		require.NoError(t, err)
		apply, ok := got.Proposal.(RoleApplyProposal)
		require.True(t, ok)
		require.Equal(t, "role-1", apply.RoleID)
		require.Equal(t, "v1", apply.RoleVersionID)
		require.Equal(t, "agt-1", apply.AgentID)
	})
}

func TestNormalizeAndDigestRejectNonNilPointerProposal(t *testing.T) {
	// Only value branches are accepted; non-nil pointer proposals are invalid
	// (avoids value/pointer dual encoding semantics).
	for _, tc := range []struct {
		name     string
		proposal RoleProposal
	}{
		{"non-nil create pointer", &RoleCreateProposal{Name: "n", Definition: "d"}},
		{"non-nil update pointer", &RoleUpdateProposal{RoleID: "r", ExpectedRoleVersionID: "v", Name: ptrString("n")}},
		{"non-nil delete pointer", &RoleDeleteProposal{RoleID: "r", ExpectedRoleVersionID: "v"}},
		{"non-nil apply pointer", &RoleApplyProposal{RoleID: "r", RoleVersionID: "v", AgentID: "a"}},
	} {
		t.Run("normalize/"+tc.name, func(t *testing.T) {
			_, err := NormalizeRoleRequest(RoleRequest{
				WorkspaceID: "ws", ApplicantAgentID: "agent", RequestID: "req", Proposal: tc.proposal,
			})
			require.ErrorIs(t, err, ErrInvalidRoleRequest)
		})
		t.Run("digest/"+tc.name, func(t *testing.T) {
			_, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, tc.proposal)
			require.ErrorIs(t, err, ErrInvalidRoleRequest)
		})
	}
}

func TestRoleIdempotencyKeyShapesIdentityAndHash(t *testing.T) {
	// action is NOT part of the identity: the key API takes only the identity
	// triple (no action parameter), so any action under one requestId shares it.
	key, err := RoleIdempotencyKey("ws", "agent", "req")
	require.NoError(t, err)
	require.Regexp(t, sha256Hex, key)

	// each identity member participates: varying any one yields a different key.
	a, err := RoleIdempotencyKey("ws", "agent-a", "req")
	require.NoError(t, err)
	b, err := RoleIdempotencyKey("ws", "agent-b", "req")
	require.NoError(t, err)
	require.NotEqual(t, a, b)
	ws1, err := RoleIdempotencyKey("ws-1", "agent", "req")
	require.NoError(t, err)
	ws2, err := RoleIdempotencyKey("ws-2", "agent", "req")
	require.NoError(t, err)
	require.NotEqual(t, ws1, ws2)
	r1, err := RoleIdempotencyKey("ws", "agent", "req-1")
	require.NoError(t, err)
	r2, err := RoleIdempotencyKey("ws", "agent", "req-2")
	require.NoError(t, err)
	require.NotEqual(t, r1, r2)

	// length-prefixed encoding: "ab"+"c" must not collide with "a"+"bc".
	left, err := RoleIdempotencyKey("ab", "c", "r")
	require.NoError(t, err)
	right, err := RoleIdempotencyKey("a", "bc", "r")
	require.NoError(t, err)
	require.NotEqual(t, left, right)

	// surrounding whitespace is trimmed for every identity member.
	for _, padded := range []struct{ ws, agent, req string }{
		{" ws ", "agent", "req"},
		{"ws", " agent ", "req"},
		{"ws", "agent", " req "},
	} {
		got, err := RoleIdempotencyKey(padded.ws, padded.agent, padded.req)
		require.NoError(t, err)
		require.Equal(t, key, got, "trim-equivalence for %q/%q/%q", padded.ws, padded.agent, padded.req)
	}
	// identifiers are NOT case-folded.
	upper, err := RoleIdempotencyKey("WS", "agent", "req")
	require.NoError(t, err)
	require.NotEqual(t, key, upper)

	for _, tc := range []struct {
		name                        string
		workspaceID                 string
		applicantAgentID, requestID string
	}{
		{"blank workspace", "  ", "agent", "req"},
		{"blank applicant", "ws", " ", "req"},
		{"blank requestId", "ws", "agent", ""},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := RoleIdempotencyKey(tc.workspaceID, tc.applicantAgentID, tc.requestID)
			require.ErrorIs(t, err, ErrInvalidRoleRequest)
		})
	}
}

func TestDigestRoleRequestPresenceShapeVersioningAndBranchValidation(t *testing.T) {
	base := RoleUpdateProposal{RoleID: "role-1", ExpectedRoleVersionID: "v1", Name: ptrString("renamed")}
	omitDescription := base
	clearDescription := base
	clearDescription.Description = ptrString("")
	setDescription := base
	setDescription.Description = ptrString("desc")

	omit, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, omitDescription)
	require.NoError(t, err)
	require.Regexp(t, sha256Hex, omit)
	clear, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, clearDescription)
	require.NoError(t, err)
	set, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, setDescription)
	require.NoError(t, err)
	// presence-aware: omitted (nil) != explicit empty ("") != explicit value.
	require.NotEqual(t, omit, clear)
	require.NotEqual(t, clear, set)
	require.NotEqual(t, omit, set)

	// deterministic + trim-equivalent typed payloads hash equal (incl. trimmed
	// non-empty create description).
	first, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: "n", Definition: "d", Description: "desc"})
	require.NoError(t, err)
	second, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: " n ", Definition: " d ", Description: " desc "})
	require.NoError(t, err)
	require.Equal(t, first, second)

	// create description is a plain string: omitted/empty/whitespace expectedize
	// to "" and share one digest (no presence distinction for create).
	omittedDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: "n", Definition: "d"})
	require.NoError(t, err)
	emptyDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: "n", Definition: "d", Description: ""})
	require.NoError(t, err)
	whitespaceDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: "n", Definition: "d", Description: "   "})
	require.NoError(t, err)
	require.Equal(t, omittedDigest, emptyDigest)
	require.Equal(t, omittedDigest, whitespaceDigest)

	// case differs => different digest (no case-folding). Same proposal, only
	// one character's case changes.
	caseBase := RoleCreateProposal{Name: "n", Definition: "d", Description: "desc"}
	lowerDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, caseBase)
	require.NoError(t, err)
	caseUpper := caseBase
	caseUpper.Name = "N"
	upperDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, caseUpper)
	require.NoError(t, err)
	require.NotEqual(t, lowerDigest, upperDigest)

	// action enters the digest (two legal, action-specific typed branches).
	createDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: "n", Definition: "d"})
	require.NoError(t, err)
	updateDigest, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, base)
	require.NoError(t, err)
	require.NotEqual(t, createDigest, updateDigest)

	// schema version enters the digest.
	v1, err := DigestRoleRequest(1, base)
	require.NoError(t, err)
	v2, err := DigestRoleRequest(2, base)
	require.NoError(t, err)
	require.NotEqual(t, v1, v2)

	// non-positive schema version is rejected.
	_, err = DigestRoleRequest(0, base)
	require.ErrorIs(t, err, ErrInvalidRoleRequest)
	_, err = DigestRoleRequest(-1, base)
	require.ErrorIs(t, err, ErrInvalidRoleRequest)

	// Digest must not bypass Normalize: invalid typed branches are rejected.
	_, err = DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleCreateProposal{Name: "  ", Definition: "d"})
	require.ErrorIs(t, err, ErrInvalidRoleRequest, "blank create name must be rejected")
	_, err = DigestRoleRequest(RoleRequestDigestSchemaVersion, RoleUpdateProposal{RoleID: "role-1", ExpectedRoleVersionID: "v1"})
	require.ErrorIs(t, err, ErrInvalidRoleRequest, "update with no mutation must be rejected")
}

func TestDigestRoleRequestRejectsNilAndTypedNilProposal(t *testing.T) {
	for _, tc := range []struct {
		name     string
		proposal RoleProposal
	}{
		{"nil interface", nil},
		{"typed-nil create", (*RoleCreateProposal)(nil)},
		{"typed-nil update", (*RoleUpdateProposal)(nil)},
		{"typed-nil delete", (*RoleDeleteProposal)(nil)},
		{"typed-nil apply", (*RoleApplyProposal)(nil)},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := DigestRoleRequest(RoleRequestDigestSchemaVersion, tc.proposal)
			require.ErrorIs(t, err, ErrInvalidRoleRequest)
		})
	}
}

func TestClassifyReplayMatchConflictAndSafeComparator(t *testing.T) {
	classification, err := ClassifyReplay(ActionCreate, ActionCreate, validDigest, validDigest)
	require.NoError(t, err)
	require.Equal(t, ReplayMatch, classification)

	classification, err = ClassifyReplay(ActionCreate, ActionUpdate, validDigest, validDigest)
	require.NoError(t, err)
	require.Equal(t, ReplayConflict, classification)

	classification, err = ClassifyReplay(ActionCreate, ActionCreate, validDigest, strings.Repeat("fedcba9876543210", 4))
	require.NoError(t, err)
	require.Equal(t, ReplayConflict, classification)

	// either-side unknown action is an invalid comparator (never a replay).
	for _, actions := range [][2]RoleRequestAction{
		{RoleRequestAction("bogus"), ActionCreate},
		{ActionCreate, RoleRequestAction("bogus")},
	} {
		_, err := ClassifyReplay(actions[0], actions[1], validDigest, validDigest)
		require.ErrorIs(t, err, ErrInvalidRoleRequest, "unknown action %q/%q must be rejected", actions[0], actions[1])
	}

	// either-side malformed digest is an invalid comparator.
	malformed := []string{
		validDigest[:16],             // too short
		validDigest + "0",            // too long (65)
		strings.Repeat("g", 64),      // non-hex
		strings.ToUpper(validDigest), // uppercase
	}
	for _, bad := range malformed {
		_, err := ClassifyReplay(ActionCreate, ActionCreate, bad, validDigest)
		require.ErrorIs(t, err, ErrInvalidRoleRequest, "stored digest %q must be rejected", bad)
		_, err = ClassifyReplay(ActionCreate, ActionCreate, validDigest, bad)
		require.ErrorIs(t, err, ErrInvalidRoleRequest, "new digest %q must be rejected", bad)
	}
}
