package exec

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

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

func describeSkillSpec[I any, O any](t *testing.T, spec actionSpec[I, O]) actionDescriptor {
	t.Helper()
	descriptor, err := describe(spec)
	require.NoError(t, err)
	return descriptor
}

func skillDescriptors(t *testing.T) map[string]actionDescriptor {
	t.Helper()
	return map[string]actionDescriptor{
		"list":   describeSkillSpec(t, skillListSpec),
		"get":    describeSkillSpec(t, skillGetSpec),
		"create": describeSkillSpec(t, skillCreateSpec),
		"update": describeSkillSpec(t, skillUpdateSpec),
		"import": describeSkillSpec(t, skillImportSpec),
		"delete": describeSkillSpec(t, skillDeleteSpec),
	}
}

func TestSkillExecDefinesExactSixActionPermissionContract(t *testing.T) {
	descriptors := skillDescriptors(t)
	require.Len(t, descriptors, 6)
	for _, name := range []string{"list", "get"} {
		require.Equal(t, "skill", descriptors[name].Domain)
		require.Equal(t, permissionReadOnly, descriptors[name].Permission)
	}
	for _, name := range []string{"create", "update", "import"} {
		require.Equal(t, "skill", descriptors[name].Domain)
		require.Equal(t, permissionAgentScopedMutation, descriptors[name].Permission)
	}
	require.Equal(t, "skill", descriptors["delete"].Domain)
	require.Equal(t, permissionExistingGate, descriptors["delete"].Permission)
}

func TestSkillExecInputContractMirrorsExistingSkillAPI(t *testing.T) {
	descriptors := skillDescriptors(t)
	cases := []struct {
		name       string
		properties []string
		required   []string
	}{
		{"list", []string{"query", "page", "perPage", "templateLocale", "view"}, nil},
		{"get", []string{"skillId"}, []string{"skillId"}},
		{"create", []string{"name", "repoNameOverride", "description", "storageScope", "visibilityScope", "templateLocale", "isActive"}, []string{"name"}},
		{"update", []string{"skillId", "name", "description"}, []string{"skillId"}},
		{"import", []string{"skillId", "file", "url"}, []string{"skillId"}},
		{"delete", []string{"requestId", "skillId"}, []string{"requestId", "skillId"}},
	}
	for _, test := range cases {
		t.Run(test.name, func(t *testing.T) {
			node := roleParseSchema(t, descriptors[test.name].InputSchema)
			assertClosedProperties(t, rolePropertiesOf(t, node), test.properties, "skill "+test.name+" input")
			assertRequired(t, node, test.required, "skill "+test.name+" input")
		})
	}
}

func TestSkillExecSyncMutationsHaveNoRequestReceiptOrFenceFields(t *testing.T) {
	descriptors := skillDescriptors(t)
	for _, action := range []string{"create", "update", "import"} {
		properties := rolePropertiesOf(t, roleParseSchema(t, descriptors[action].InputSchema))
		for _, forbidden := range []string{
			"requestId", "workspaceId", "subject", "ownerId", "repoOwner",
			"expectedRevision", "expectedCommitSha", "cursor",
		} {
			require.NotContainsf(t, properties, forbidden, "skill %s must not expose %s", action, forbidden)
		}
	}
}

func TestSkillExecUpdateAllowsOnlyDisplayFieldsAndRequiresAMutation(t *testing.T) {
	descriptor := describeSkillSpec(t, skillUpdateSpec)
	node := roleParseSchema(t, descriptor.InputSchema)
	require.Equal(t, float64(2), node["minProperties"])
	properties := rolePropertiesOf(t, node)
	assertClosedProperties(t, properties, []string{"skillId", "name", "description"}, "skill update input")
	for _, forbidden := range []string{
		"slug", "repository", "repoName", "visibilityScope", "isActive",
		"grants", "bindings",
	} {
		require.NotContains(t, properties, forbidden)
	}
}

func TestSkillExecImportRequiresExactlyOneFileOrURL(t *testing.T) {
	descriptor := describeSkillSpec(t, skillImportSpec)
	node := roleParseSchema(t, descriptor.InputSchema)
	oneOf, ok := node["oneOf"].([]any)
	require.True(t, ok)
	require.Len(t, oneOf, 2)

	catalog := skillInTestCatalog(t)
	for _, valid := range []map[string]any{
		{"skillId": "sk_1", "file": "skills.zip"},
		{"skillId": "sk_1", "url": "https://example.test/skills.zip"},
	} {
		_, err := decodeValidatedActionInput[skillImportRequest](catalog, "skill", "import", skillJSON(t, valid))
		require.NoError(t, err)
	}
	for _, invalid := range []map[string]any{
		{"skillId": "sk_1"},
		{"skillId": "sk_1", "file": "skills.zip", "url": "https://example.test/skills.zip"},
	} {
		_, err := decodeValidatedActionInput[skillImportRequest](catalog, "skill", "import", skillJSON(t, invalid))
		require.Error(t, err)
	}
}

func TestSkillExecOutputContractAddsNoRevisionOrRecoveryState(t *testing.T) {
	descriptors := skillDescriptors(t)
	skillFields := []string{
		"id", "workspaceId", "slug", "name", "description", "storageScope",
		"storageOwnerLogin", "visibilityScope", "visibilityOwnerId", "skillCount",
		"isActive", "isOfficial", "repositories", "role", "canManage", "createdBy",
		"updatedBy", "createdAt", "updatedAt",
	}
	listSchema := roleParseSchema(t, descriptors["list"].OutputSchema)
	listData := roleDataNode(t, listSchema)
	assertClosedProperties(t, rolePropertiesOf(t, listData),
		[]string{"items", "page", "perPage", "total", "hasMore"},
		"skill list output data")
	listItems := rolePropertiesOf(t, listData)["items"].(map[string]any)
	listItem := roleArrayItemNode(t, listSchema, listItems)
	assertClosedProperties(t, rolePropertiesOf(t, listItem), skillFields, "skill list items[]")

	for _, action := range []string{"get", "create", "update"} {
		node := roleDataNode(t, roleParseSchema(t, descriptors[action].OutputSchema))
		assertClosedProperties(t, rolePropertiesOf(t, node), skillFields, "skill "+action+" output data")
		for _, forbidden := range []string{"revision", "projectedCommitSha", "requestId", "operationKey"} {
			require.NotContains(t, rolePropertiesOf(t, node), forbidden)
		}
	}

	importSchema := roleParseSchema(t, descriptors["import"].OutputSchema)
	importData := roleDataNode(t, importSchema)
	assertClosedProperties(t, rolePropertiesOf(t, importData),
		[]string{"skill", "importedSkills", "commitSha", "skillCount", "warnings"},
		"skill import output data")
	importedSkill := roleResolve(t, importSchema, rolePropertiesOf(t, importData)["skill"].(map[string]any))
	assertClosedProperties(t, rolePropertiesOf(t, importedSkill), skillFields, "skill import output skill")
	importedItems := rolePropertiesOf(t, importData)["importedSkills"].(map[string]any)
	importedItem := roleArrayItemNode(t, importSchema, importedItems)
	assertClosedProperties(t, rolePropertiesOf(t, importedItem),
		[]string{"name", "description", "path", "layout", "sizeBytes", "status", "issueCode", "frontmatter"},
		"skill import output importedSkills[]")

	deleteNode := roleDataNode(t, roleParseSchema(t, descriptors["delete"].OutputSchema))
	assertClosedProperties(t, rolePropertiesOf(t, deleteNode), []string{"approvalId", "status"}, "skill delete output data")
	status := roleResolve(t, roleParseSchema(t, descriptors["delete"].OutputSchema), rolePropertiesOf(t, deleteNode)["status"].(map[string]any))
	assertSchemaEnum(t, status, []string{"pending", "rejected", "deleted"}, "skill delete status")
}

func TestSkillExecDescriptorsArePublishedAndRoutesAreTypedAtomically(t *testing.T) {
	catalog, err := DefaultCatalog()
	require.NoError(t, err)
	for name, expected := range skillDescriptors(t) {
		actual, ok := catalog.lookup("skill", name)
		require.Truef(t, ok, "skill %s must be publicly discoverable", name)
		require.Equal(t, expected, actual)
	}

}

func skillInTestCatalog(t *testing.T) *Catalog {
	t.Helper()
	descriptors := skillDescriptors(t)
	names := make([]string, 0, len(descriptors))
	for name := range descriptors {
		names = append(names, name)
	}
	sort.Strings(names)
	list := make([]actionDescriptor, 0, len(names))
	for _, name := range names {
		list = append(list, descriptors[name])
	}
	catalog, err := newDescriptorCatalog(list...)
	require.NoError(t, err)
	return catalog
}

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