package extension

import (
	"reflect"
	"testing"

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

// allowedCatalogJSONKeys is the closed set of JSON keys permitted anywhere in
// the Agent-visible Catalog DTO structure (spec §6.3). The adversarial test
// below reflects over every DTO struct field and asserts each serialized JSON
// key is a member of this set, so a future field that carries server-only state
// (endpoint, binding, auth, connection, credential, secret) cannot be added
// without explicit allowance here.
var allowedCatalogJSONKeys = map[string]struct{}{
	"extensions":    {},
	"extensionName": {},
	"displayName":   {},
	"actions":       {},
	"name":          {},
	"summary":       {},
	"description":   {},
	"safety":        {},
	"class":         {},
	"inputSchema":   {},
	"outputSchema":  {},
	"example":       {},
}

// catalogDTOTypes is the exhaustive list of Agent-visible Catalog DTO struct
// types. Adding a new DTO struct here is required for it to be covered by the
// no-secrets boundary test.
func catalogDTOTypes() []reflect.Type {
	return []reflect.Type{
		reflect.TypeOf(rootResponse{}),
		reflect.TypeOf(extensionEntry{}),
		reflect.TypeOf(actionCatalog{}),
		reflect.TypeOf(safety{}),
	}
}

// forbiddenCatalogKeyMarkers are JSON keys that MUST NEVER be serializable from
// a Catalog DTO because they encode server-only binding/auth/connection/secret
// state (spec §6.3: "Catalog never contains a base URL, provider connection
// identity, credential binding, HTTP binding, or secret").
var forbiddenCatalogKeyMarkers = []string{
	"endpoint", "baseUrl", "base_url",
	"binding", "method", "path", "staticHeaders", "static_headers",
	"parameters", "body", "mappings",
	"response", "successStatuses", "success_statuses", "contentTypes", "content_types",
	"auth", "profiles", "default", "profile",
	"requiredScopes", "required_scopes", "scopes",
	"connection", "connectionId", "connection_id",
	"credential", "secret", "token", "apiKey", "api_key",
	"sourceOperationId", "source_operation_id", "operationId",
	"kind", // Builtin|External is a server-local registration kind (spec §5.1), not Agent-visible.
	"revision", "executor", "strategy",
}

// TestCatalogDTO_AllowListIsClosed reflects over every Catalog DTO struct field
// (recursing into nested structs, stopping at maps/slices/scalars) and asserts
// each serializable JSON key is in allowedCatalogJSONKeys. This is the
// leak-proof-by-construction guard: a struct field added with a secret tag
// fails this test unless the key is explicitly allowed.
func TestCatalogDTO_AllowListIsClosed(t *testing.T) {
	for _, dtype := range catalogDTOTypes() {
		t.Run(dtype.Name(), func(t *testing.T) {
			seen := map[string]struct{}{}
			collectJSONKeys(dtype, seen)
			for key := range seen {
				_, ok := allowedCatalogJSONKeys[key]
				require.True(t, ok, "DTO %s serializes JSON key %q which is not in the allowed Catalog key set", dtype.Name(), key)
			}
		})
	}
}

// TestCatalogDTO_ForbiddenKeysAbsent asserts no DTO serializes any forbidden
// server-only key. This complements the allow-list test by naming the specific
// secret/binding keys that must never appear.
func TestCatalogDTO_ForbiddenKeysAbsent(t *testing.T) {
	for _, dtype := range catalogDTOTypes() {
		t.Run(dtype.Name(), func(t *testing.T) {
			seen := map[string]struct{}{}
			collectJSONKeys(dtype, seen)
			for _, forbidden := range forbiddenCatalogKeyMarkers {
				_, ok := seen[forbidden]
				require.False(t, ok, "DTO %s serializes forbidden JSON key %q", dtype.Name(), forbidden)
			}
		})
	}
}

// collectJSONKeys walks dtype's struct fields recursively. For each exported
// field it records the JSON key (the name in the json tag, or the field name if
// untagged). Fields tagged `json:"-"` are skipped. Recursion descends into
// nested structs and the element types of slices/arrays/pointers. It stops at
// map[string]any and interface{}: those represent Agent-visible schema/example
// content whose dynamic keys are part of the curated schema, not DTO structure.
func collectJSONKeys(dtype reflect.Type, seen map[string]struct{}) {
	if dtype == nil {
		return
	}
	if dtype.Kind() == reflect.Pointer {
		dtype = dtype.Elem()
	}
	if dtype.Kind() != reflect.Struct {
		return
	}
	for index := 0; index < dtype.NumField(); index++ {
		field := dtype.Field(index)
		if !field.IsExported() {
			continue
		}
		key := jsonKeyName(field)
		if key == "-" {
			continue
		}
		if key == "" {
			key = field.Name
		}
		seen[key] = struct{}{}
		switch field.Type.Kind() {
		case reflect.Struct:
			collectJSONKeys(field.Type, seen)
		case reflect.Pointer, reflect.Slice, reflect.Array:
			collectJSONKeys(peelPointerSlice(field.Type), seen)
		}
	}
}

func peelPointerSlice(t reflect.Type) reflect.Type {
	for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice || t.Kind() == reflect.Array {
		t = t.Elem()
	}
	return t
}

func jsonKeyName(field reflect.StructField) string {
	tag := field.Tag.Get("json")
	if tag == "" {
		return ""
	}
	// "name" or "name,omitempty" or "-".
	for i := 0; i < len(tag); i++ {
		if tag[i] == ',' {
			return tag[:i]
		}
	}
	return tag
}
