package exec

import (
	"encoding/json"
	"sort"
	"strings"
	"testing"

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

// Role management Exec contract tests (#t2249 V1).
//
// B4b publication boundary (locked): the typed Role DTOs/actionSpecs, the
// DefaultCatalog descriptors, and the seven literal executable routes must
// enter the 53-action public catalog and invoker set in one atomic candidate. Publishing only
// the catalog would advertise a 404 `agentteam exec role`; publishing only routes
// would create an undiscoverable route.
//
// Locked contract:
//   - direct read (permissionReadOnly):        list, get, request-get
//   - Human-approval mutation
//     (permissionExistingGate):                create, update, delete, apply
//   - public request status: pending | processing | succeeded | rejected |
//     failed | cancelled  (`approved` is internal-only, never a public status)
//   - request-get.action enum: create | update | delete | apply
//   - request-get.failure: closed {code, message}, both required
//   - delete is soft-delete (hide) only; no cascade-cleanup promise
//   - caller identity (workspaceId/userId/ownerId/approverId/memberId) is
//     resolved server-side and must NEVER appear on a request DTO
//
// Idempotency domain (declarative at this stage): workspace + applicantAgent +
// requestId; action enters the payload digest, so cross-action reuse is a 409
// conflict. Behavioural replay/conflict RED lands with the ledger/worker seam.
//
// Input and output property sets AND required sets are asserted as CLOSED sets.

// roleRequestIdUniqueDomain documents the locked idempotency key domain. It is
// referenced by TestRoleExecRequestIdIdempotencyDomain so the contract is
// executable, not merely prose.
const roleRequestIdUniqueDomain = "workspace+applicantAgent+requestId"

// describeRoleSpec projects one typed role actionSpec into its immutable
// descriptor for exact public-catalog contract assertions.
func describeRoleSpec[I any, O any](t *testing.T, spec actionSpec[I, O]) actionDescriptor {
	t.Helper()
	desc, err := describe(spec)
	require.NoError(t, err)
	return desc
}

func roleDescriptors(t *testing.T) map[string]actionDescriptor {
	t.Helper()
	return map[string]actionDescriptor{
		"list":        describeRoleSpec(t, roleListSpec),
		"get":         describeRoleSpec(t, roleGetSpec),
		"request-get": describeRoleSpec(t, roleRequestGetSpec),
		"create":      describeRoleSpec(t, roleCreateSpec),
		"update":      describeRoleSpec(t, roleUpdateSpec),
		"delete":      describeRoleSpec(t, roleDeleteSpec),
		"apply":       describeRoleSpec(t, roleApplySpec),
	}
}

func TestRoleExecSpecsDefineSevenActions(t *testing.T) {
	descs := roleDescriptors(t)

	for _, name := range []string{"list", "get", "request-get"} {
		require.Equalf(t, "role", descs[name].Domain, "role %s domain", name)
		require.Equalf(t, permissionReadOnly, descs[name].Permission,
			"role %s must be a direct read (permissionReadOnly)", name)
	}
	for _, name := range []string{"create", "update", "delete", "apply"} {
		require.Equalf(t, "role", descs[name].Domain, "role %s domain", name)
		require.Equalf(t, permissionExistingGate, descs[name].Permission,
			"role %s must use the existing approval gate", name)
	}
}

func TestRoleExecExposesExactSevenDescriptorsInDefaultCatalog(t *testing.T) {
	catalog, err := DefaultCatalog()
	require.NoError(t, err)
	expected := roleDescriptors(t)
	require.Len(t, expected, 7)
	for name, want := range expected {
		got, ok := catalog.lookup("role", name)
		require.Truef(t, ok, "role %s must be publicly discoverable", name)
		require.Equalf(t, want, got, "role %s catalog descriptor must equal its typed spec", name)
	}
}

func TestRoleExecActionInputContract(t *testing.T) {
	descs := roleDescriptors(t)

	cases := []struct {
		name       string
		properties []string
		required   []string
	}{
		{"list", []string{"view", "query", "limit", "cursor"}, nil},
		{"get", []string{"roleId"}, []string{"roleId"}},
		{"request-get", []string{"requestId"}, []string{"requestId"}},
		{"create", []string{"requestId", "name", "definition", "description"}, []string{"requestId", "name", "definition"}},
		{"update",
			[]string{"requestId", "roleId", "expectedRoleVersionId", "name", "description", "definition", "visibility"},
			[]string{"requestId", "roleId", "expectedRoleVersionId"}},
		{"delete", []string{"requestId", "roleId", "expectedRoleVersionId"}, []string{"requestId", "roleId", "expectedRoleVersionId"}},
		{"apply", []string{"requestId", "roleId", "roleVersionId", "agentId"}, []string{"requestId", "roleId", "roleVersionId", "agentId"}},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			node := roleParseSchema(t, descs[tc.name].InputSchema)
			assertClosedProperties(t, rolePropertiesOf(t, node), tc.properties, "role "+tc.name+" input")
			assertRequired(t, node, tc.required, "role "+tc.name+" input")
		})
	}
}

func TestRoleExecStaleVersionFencesAreRequired(t *testing.T) {
	descs := roleDescriptors(t)

	for _, tc := range []struct {
		name          string
		requiredField string
	}{
		{"update", "expectedRoleVersionId"},
		{"delete", "expectedRoleVersionId"},
		{"apply", "roleVersionId"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			require.Containsf(t, roleRequiredOf(roleParseSchema(t, descs[tc.name].InputSchema)),
				tc.requiredField, "role %s must require %q as a stale fence", tc.name, tc.requiredField)
		})
	}

	updateNode := roleParseSchema(t, descs["update"].InputSchema)
	for _, optional := range []string{"name", "description", "definition", "visibility"} {
		require.NotContainsf(t, roleRequiredOf(updateNode), optional,
			"role update optional mutation field %q must not be required", optional)
	}
}

func TestRoleExecUpdateEnforcesAtLeastOneMutation(t *testing.T) {
	updateDesc := describeRoleSpec(t, roleUpdateSpec)
	updateNode := roleParseSchema(t, updateDesc.InputSchema)
	require.Equalf(t, float64(4), updateNode["minProperties"],
		"role update input must enforce minProperties=4 (3 required + >=1 mutation)")

	catalog := roleInTestCatalog(t)

	wellFormed := roleJSON(t, map[string]any{
		"requestId": "req-1", "roleId": "role-1",
		"expectedRoleVersionId": "ver-1", "name": "renamed",
	})
	_, err := decodeValidatedActionInput[roleUpdateRequest](catalog, "role", "update", wellFormed)
	require.NoErrorf(t, err, "role update with a mutation field must validate via the route chain")
	emptyMutation := roleJSON(t, map[string]any{
		"requestId": "req-1", "roleId": "role-1",
		"expectedRoleVersionId": "ver-1",
	})
	_, err = decodeValidatedActionInput[roleUpdateRequest](catalog, "role", "update", emptyMutation)
	require.Errorf(t, err, "role update without any mutation field must be rejected")
}

func TestRoleExecActionOutputContract(t *testing.T) {
	descs := roleDescriptors(t)

	t.Run("list projects compact role rows", func(t *testing.T) {
		full := roleParseSchema(t, descs["list"].OutputSchema)
		data := roleDataNode(t, full)
		dataProps := rolePropertiesOf(t, data)
		assertClosedProperties(t, dataProps, []string{"roles", "nextCursor"}, "role list output data")
		assertRequired(t, data, []string{"roles"}, "role list output data")

		rolesArray, ok := dataProps["roles"].(map[string]any)
		require.True(t, ok, "roles must be an array schema")
		itemNode := roleArrayItemNode(t, full, rolesArray)
		assertClosedProperties(t, rolePropertiesOf(t, itemNode),
			[]string{"roleId", "name", "description", "visibility", "roleVersionId", "canManage"},
			"role list roles[] projection")
		assertRequired(t, itemNode,
			[]string{"roleId", "name", "visibility", "roleVersionId", "canManage"},
			"role list roles[] projection")
		listVisibility := roleResolve(t, full, rolePropertiesOf(t, itemNode)["visibility"].(map[string]any))
		assertSchemaEnum(t, listVisibility, []string{"private", "workspace"},
			"role list roles[].visibility")
	})

	t.Run("get projects the full role", func(t *testing.T) {
		full := roleParseSchema(t, descs["get"].OutputSchema)
		data := roleDataNode(t, full)
		dataProps := rolePropertiesOf(t, data)
		assertClosedProperties(t, dataProps, []string{"role"}, "role get output data")
		assertRequired(t, data, []string{"role"}, "role get output data")

		roleProp, ok := dataProps["role"].(map[string]any)
		require.True(t, ok, "role must be an object schema")
		roleNode := roleResolve(t, full, roleProp)
		assertClosedProperties(t, rolePropertiesOf(t, roleNode),
			[]string{"roleId", "name", "description", "definition", "visibility", "roleVersionId", "canManage"},
			"role get role projection")
		assertRequired(t, roleNode,
			[]string{"roleId", "name", "definition", "visibility", "roleVersionId", "canManage"},
			"role get role projection")
		getVisibility := roleResolve(t, full, rolePropertiesOf(t, roleNode)["visibility"].(map[string]any))
		assertSchemaEnum(t, getVisibility, []string{"private", "workspace"},
			"role get role.visibility")
	})

	for _, name := range []string{"create", "update", "delete", "apply"} {
		t.Run(name+" returns approval request handle", func(t *testing.T) {
			full := roleParseSchema(t, descs[name].OutputSchema)
			data := roleDataNode(t, full)
			dataProps := rolePropertiesOf(t, data)
			assertClosedProperties(t, dataProps, []string{"approvalId", "status"}, "role "+name+" output data")
			assertRequired(t, data, []string{"approvalId", "status"}, "role "+name+" output data")
			mutationStatus := roleResolve(t, full, dataProps["status"].(map[string]any))
			assertSchemaEnum(t, mutationStatus,
				[]string{"pending", "processing", "succeeded", "rejected", "failed", "cancelled"},
				"role "+name+" output status")
		})
	}

	t.Run("request-get exposes public request state", func(t *testing.T) {
		full := roleParseSchema(t, descs["request-get"].OutputSchema)
		data := roleDataNode(t, full)
		dataProps := rolePropertiesOf(t, data)
		assertClosedProperties(t, dataProps,
			[]string{"requestId", "action", "status", "approvalId", "roleId", "roleVersionId", "agentId", "failure"},
			"role request-get output data")
		assertRequired(t, data, []string{"requestId", "action", "status"}, "role request-get output data")

		actionNode := roleResolve(t, full, dataProps["action"].(map[string]any))
		assertSchemaEnum(t, actionNode, []string{"create", "update", "delete", "apply"},
			"role request-get action")

		statusNode := roleResolve(t, full, dataProps["status"].(map[string]any))
		assertSchemaEnum(t, statusNode,
			[]string{"pending", "processing", "succeeded", "rejected", "failed", "cancelled"},
			"role request-get status")

		failureNode := roleResolve(t, full, dataProps["failure"].(map[string]any))
		assertClosedProperties(t, rolePropertiesOf(t, failureNode),
			[]string{"code", "message"}, "role request-get failure")
		assertRequired(t, failureNode, []string{"code", "message"}, "role request-get failure")
	})
}

func TestRoleExecInputEnumContract(t *testing.T) {
	descs := roleDescriptors(t)

	t.Run("list.view is the finite view domain", func(t *testing.T) {
		full := roleParseSchema(t, descs["list"].InputSchema)
		viewNode := roleResolve(t, full, rolePropertiesOf(t, full)["view"].(map[string]any))
		assertSchemaEnum(t, viewNode,
			[]string{"all", "workspace", "created_by_me", "private"},
			"role list.view")
	})

	t.Run("update.visibility is the finite visibility domain", func(t *testing.T) {
		full := roleParseSchema(t, descs["update"].InputSchema)
		visibilityNode := roleResolve(t, full, rolePropertiesOf(t, full)["visibility"].(map[string]any))
		assertSchemaEnum(t, visibilityNode, []string{"private", "workspace"},
			"role update.visibility")
	})
}

func TestRoleExecRequestIdIdempotencyDomain(t *testing.T) {
	require.Equal(t, "workspace+applicantAgent+requestId", roleRequestIdUniqueDomain,
		"Role request idempotency key must be workspace+applicantAgent+requestId (action in digest)")

	desc := describeRoleSpec(t, roleRequestGetSpec)
	node := roleParseSchema(t, desc.InputSchema)
	assertClosedProperties(t, rolePropertiesOf(t, node), []string{"requestId"}, "role request-get input")
	assertRequired(t, node, []string{"requestId"}, "role request-get input")
}

func TestRoleExecUpdateOptionalStringPresence(t *testing.T) {
	// Exercise the same decodeValidatedActionInput chain the literal routes use
	// (typed decode with DisallowUnknownFields, then schema validation), via an
	// in-test catalog to isolate optional-string decode semantics.
	catalog := roleInTestCatalog(t)

	// omitted description => typed nil (keep current value)
	omit, err := decodeValidatedActionInput[roleUpdateRequest](catalog, "role", "update", roleJSON(t, map[string]any{
		"requestId": "r", "roleId": "ro", "expectedRoleVersionId": "v", "name": "renamed",
	}))
	require.NoError(t, err)
	require.Nil(t, omit.Description, "omitted description must decode to nil (keep)")

	// explicit "" description => non-nil empty (clear)
	cleared, err := decodeValidatedActionInput[roleUpdateRequest](catalog, "role", "update", roleJSON(t, map[string]any{
		"requestId": "r", "roleId": "ro", "expectedRoleVersionId": "v", "description": "",
	}))
	require.NoError(t, err)
	require.NotNil(t, cleared.Description)
	require.Equal(t, "", *cleared.Description, "explicit empty description must decode to non-nil empty (clear)")

	// explicit null description => rejected at schema validation (no tri-state null)
	_, err = decodeValidatedActionInput[roleUpdateRequest](catalog, "role", "update", roleJSON(t, map[string]any{
		"requestId": "r", "roleId": "ro", "expectedRoleVersionId": "v", "description": nil,
	}))
	require.Error(t, err, "null description must be rejected by schema validation")

	// blank/whitespace name or definition => rejected (non-blank when present)
	for _, field := range []string{"name", "definition"} {
		for _, blank := range []string{"", "  "} {
			body := map[string]any{
				"requestId": "r", "roleId": "ro", "expectedRoleVersionId": "v", field: blank,
			}
			_, err := decodeValidatedActionInput[roleUpdateRequest](catalog, "role", "update", roleJSON(t, body))
			require.Errorf(t, err, "role update blank %s=%q must be rejected", field, blank)
		}
	}
}

func TestRoleExecCreateRejectsBlankNameAndDefinition(t *testing.T) {
	catalog := roleInTestCatalog(t)
	_, err := decodeValidatedActionInput[roleCreateRequest](catalog, "role", "create", roleJSON(t, map[string]any{
		"requestId": "r", "name": "n", "definition": "d",
	}))
	require.NoError(t, err)
	for _, tc := range []struct{ field, value string }{
		{"name", ""}, {"name", "  "}, {"definition", ""}, {"definition", "  "},
	} {
		body := map[string]any{"requestId": "r", "name": "n", "definition": "d"}
		body[tc.field] = tc.value
		_, err := decodeValidatedActionInput[roleCreateRequest](catalog, "role", "create", roleJSON(t, body))
		require.Errorf(t, err, "role create blank %s=%q must be rejected", tc.field, tc.value)
	}
}

// roleInTestCatalog builds a Catalog from the seven role descriptors so tests
// can exercise the same decodeValidatedActionInput chain in isolation.
func roleInTestCatalog(t *testing.T) *Catalog {
	t.Helper()
	descs := roleDescriptors(t)
	list := make([]actionDescriptor, 0, len(descs))
	for _, descriptor := range descs {
		list = append(list, descriptor)
	}
	catalog, err := newDescriptorCatalog(list...)
	require.NoError(t, err)
	return catalog
}

// --- helpers ---

func assertClosedProperties(t *testing.T, props map[string]any, expected []string, label string) {
	t.Helper()
	require.ElementsMatchf(t, expected, roleMapKeys(props),
		"%s properties must be exactly the closed V1 set (no wide DTO): want %v", label, expected)
}

func assertRequired(t *testing.T, node map[string]any, expected []string, label string) {
	t.Helper()
	require.ElementsMatchf(t, expected, roleRequiredOf(node),
		"%s required fields must be exactly %v", label, expected)
}

func assertSchemaEnum(t *testing.T, node map[string]any, expected []string, label string) {
	t.Helper()
	raw, ok := node["enum"].([]any)
	require.Truef(t, ok, "%s must define an enum", label)
	got := make([]string, 0, len(raw))
	for _, item := range raw {
		if s, ok := item.(string); ok {
			got = append(got, s)
		}
	}
	require.ElementsMatchf(t, expected, got, "%s enum must be exactly %v", label, expected)
}

func roleMapKeys(props map[string]any) []string {
	out := make([]string, 0, len(props))
	for k := range props {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}

func roleRequiredOf(node map[string]any) []string {
	raw, ok := node["required"].([]any)
	if !ok {
		return nil
	}
	out := make([]string, 0, len(raw))
	for _, item := range raw {
		if s, ok := item.(string); ok {
			out = append(out, s)
		}
	}
	return out
}

func roleParseSchema(t *testing.T, raw json.RawMessage) map[string]any {
	t.Helper()
	var schema map[string]any
	require.NoError(t, json.Unmarshal(raw, &schema))
	return schema
}

func rolePropertiesOf(t *testing.T, node map[string]any) map[string]any {
	t.Helper()
	props, ok := node["properties"].(map[string]any)
	require.True(t, ok, "schema node must have a properties object")
	return props
}

func roleDataNode(t *testing.T, full map[string]any) map[string]any {
	t.Helper()
	props, ok := full["properties"].(map[string]any)
	require.True(t, ok, "role output schema must have properties")
	data, ok := props["data"].(map[string]any)
	require.True(t, ok, "role output schema must have a data envelope")
	return roleResolve(t, full, data)
}

func roleArrayItemNode(t *testing.T, full map[string]any, arrayNode map[string]any) map[string]any {
	t.Helper()
	arr := roleResolve(t, full, arrayNode)
	items, ok := arr["items"].(map[string]any)
	require.True(t, ok, "array schema must have items")
	return roleResolve(t, full, items)
}

// roleResolve follows "#/$defs/Name" $ref chains inside a descriptor schema so
// nested typed DTOs (which the renderer may reference rather than inline) are
// still inspectable. (The contract reflector expands references, so this is
// defensive.)
func roleResolve(t *testing.T, full map[string]any, node map[string]any) map[string]any {
	t.Helper()
	const prefix = "#/$defs/"
	for {
		ref, ok := node["$ref"].(string)
		if !ok {
			return node
		}
		require.Truef(t, strings.HasPrefix(ref, prefix), "unsupported $ref %q", ref)
		defs, _ := full["$defs"].(map[string]any)
		resolved, ok := defs[strings.TrimPrefix(ref, prefix)].(map[string]any)
		require.Truef(t, ok, "unresolved $ref %q", ref)
		node = resolved
	}
}

func roleJSON(t *testing.T, value map[string]any) json.RawMessage {
	t.Helper()
	raw, err := json.Marshal(value)
	require.NoError(t, err)
	return raw
}
