package spec

import (
	"testing"

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

// TestEncodeJSONPointer_RFC6901 proves the encoder produces correct RFC 6901
// pointers and round-trips with ParseJSONPointer for tokens containing "~" and
// "/".
func TestEncodeJSONPointer_RFC6901(t *testing.T) {
	t.Parallel()
	cases := []struct {
		name   string
		tokens []string
		want   string
	}{
		{name: "root", tokens: nil, want: ""},
		{name: "simple", tokens: []string{"a"}, want: "/a"},
		{name: "nested", tokens: []string{"a", "b", "c"}, want: "/a/b/c"},
		{name: "slash in token", tokens: []string{"filter/name"}, want: "/filter~1name"},
		{name: "tilde in token", tokens: []string{"a~b"}, want: "/a~0b"},
		{name: "tilde and slash", tokens: []string{"~/"}, want: "/~0~1"},
		{name: "empty key", tokens: []string{""}, want: "/"},
	}
	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := EncodeJSONPointer(tc.tokens)
			assert.Equal(t, tc.want, got)
			// Round-trip: parse the encoded pointer and verify tokens match.
			decoded, err := ParseJSONPointer(got)
			require.NoError(t, err)
			assert.Equal(t, tc.tokens, decoded)
		})
	}
}

// TestBinding_ExplodeEntersDigest proves that two bindings differing only in
// the Explode field produce different digests — form?explode=true vs false must
// not collapse to the same canonical projection.
func TestBinding_ExplodeEntersDigest(t *testing.T) {
	t.Parallel()
	makePkg := func(explode bool) Package {
		p := validStructPackage()
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "query", Name: "q", Style: "form", Explode: explode},
		}
		p.Actions[0].Binding.Body = nil
		return p
	}
	dTrue, err := DefinitionDigest(makePkg(true))
	require.NoError(t, err)
	dFalse, err := DefinitionDigest(makePkg(false))
	require.NoError(t, err)
	assert.NotEqual(t, dTrue, dFalse, "explode diff must change digest")
}

// TestParseJSONPointer_RFC6901 proves the shared pointer parser implements RFC
// 6901 exactly: root "", bare "/", ~0/~1 decoding, and rejection of illegal
// escapes — the invariants the validator and future runtime binder rely on.
func TestParseJSONPointer_RFC6901(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name    string
		ptr     string
		want    []string
		wantErr string
	}{
		{name: "root empty", ptr: "", want: nil},
		{name: "simple", ptr: "/a", want: []string{"a"}},
		{name: "nested", ptr: "/a/b/c", want: []string{"a", "b", "c"}},
		{name: "empty key", ptr: "/", want: []string{""}},
		{name: "tilde one decodes slash", ptr: "/a~1b", want: []string{"a/b"}},
		{name: "tilde zero decodes tilde", ptr: "/a~0b", want: []string{"a~b"}},
		{name: "slash then tilde", ptr: "/~1", want: []string{"/"}},
		{name: "missing leading slash", ptr: "a/b", wantErr: "must be empty"},
		{name: "illegal escape ~2", ptr: "/bad~2escape", wantErr: "invalid escape"},
		{name: "trailing tilde", ptr: "/bad~", wantErr: "trailing"},
	}
	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got, err := ParseJSONPointer(tc.ptr)
			if tc.wantErr != "" {
				require.Error(t, err)
				assert.Contains(t, err.Error(), tc.wantErr)
				return
			}
			require.NoError(t, err)
			assert.Equal(t, tc.want, got)
		})
	}
}

// mutatePkg clones validStructPackage, applies fn, and returns a LoadDefinition
// error (or nil) over the encoded package.
func mutatePkg(t *testing.T, fn func(*Package)) error {
	t.Helper()
	p := validStructPackage()
	fn(&p)
	_, err := LoadDefinition(pkgToFS(t, p), "pkg")
	return err
}

// TestBinding_HeaderConflictCaseInsensitive proves the §4.4 invariant that
// header targets collide case-insensitively (X-Foo vs x-foo is one header).
func TestBinding_HeaderConflictCaseInsensitive(t *testing.T) {
	t.Parallel()

	// Two header parameters differing only by case collide.
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "header", Name: "X-Foo"},
			{From: "/id", In: "header", Name: "x-foo"},
		}
		p.Actions[0].Binding.Body = nil
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "conflict")

	// A static header and a header parameter differing only by case collide.
	err = mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "header", Name: "X-Foo"},
		}
		p.Actions[0].Binding.StaticHeaders = map[string]string{"x-foo": "v"}
		p.Actions[0].Binding.Body = nil
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "conflict")
}

// TestBinding_HeaderParamTokenValidated proves a header parameter name that is
// not a valid header token is rejected.
func TestBinding_HeaderParamTokenValidated(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "header", Name: "Bad Header"}, // space is illegal
		}
		p.Actions[0].Binding.Body = nil
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "header token")
}

// TestBinding_SourceMustExistInSchema proves a `from` pointer addressing a
// property not declared in input_schema is rejected (Finding 4b).
func TestBinding_SourceMustExistInSchema(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things/{id}"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/does-not-exist", In: "path", Name: "id"},
		}
		// Make id required so the only failure is pointer existence.
		p.Actions[0].Catalog.InputSchema["required"] = []any{"id", "does-not-exist"}
		props := p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)
		props["does-not-exist"] = nil // ensure it isn't accidentally present
		delete(props, "does-not-exist")
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "does not exist")
}

// TestBinding_PathSourceMustBeRequired proves a path parameter whose source
// exists in the schema but is NOT a required input property is rejected, while
// a required path source is accepted.
func TestBinding_PathSourceMustBeRequired(t *testing.T) {
	t.Parallel()

	// Path source that exists in schema but is NOT required is rejected.
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things/{opt}"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/opt", In: "path", Name: "opt"},
		}
		props := p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)
		props["opt"] = map[string]any{"type": "string"}
		// "opt" is deliberately absent from required (which stays [id]).
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "required")

	// A required path source is accepted.
	err = mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things/{id}"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "path", Name: "id"},
		}
		p.Actions[0].Binding.Body = nil
	})
	require.NoError(t, err)
}

// TestBinding_BodyMappingFromMustExist proves a body mapping whose source does
// not exist in the input schema is rejected.
func TestBinding_BodyMappingFromMustExist(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Body.Mappings = []BodyMapping{
			{From: "/nope", To: "/nope"},
		}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "does not exist")
}

// TestBinding_RootPointerAccepted proves the root pointer "" is a valid source
// (whole document) for a body mapping and a parameter.
func TestBinding_RootPointerAccepted(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Body.Mappings = []BodyMapping{{From: "", To: ""}}
	})
	require.NoError(t, err)
}

// TestValidate_DuplicateSuccessStatusRejected proves a response with duplicate
// success statuses fails validation (so a "set with dup" cannot form a
// different canonical digest).
func TestValidate_DuplicateSuccessStatusRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Response.SuccessStatuses = []int{200, 200}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "duplicate")
}

// TestValidate_DuplicateContentTypeRejected proves a response with duplicate
// content types fails validation.
func TestValidate_DuplicateContentTypeRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Response.ContentTypes = []string{"application/json", "application/json"}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "duplicate")
}

// TestValidate_DuplicateScopeRejected proves an Action auth requirement with
// duplicate required_scopes fails validation.
func TestValidate_DuplicateScopeRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Auth = &ActionAuthRequirement{
			Profile:        "default",
			RequiredScopes: []string{"repo", "repo"},
		}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "duplicate")
}

// TestValidate_UnknownAuthTypeRejected proves a hand-curated profile with an
// unknown type (e.g. "totally_unknown") fails the Definition-layer closed-set
// validator, not only authoring-time review.
func TestValidate_UnknownAuthTypeRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Metadata.Auth.Profiles[0].Type = "totally_unknown"
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "not supported")
}

// TestValidate_ApiKeyInCookieRejected proves a hand-curated api_key profile with
// in: cookie fails the Definition-layer validator.
func TestValidate_ApiKeyInCookieRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Metadata.Auth.Profiles[0] = AuthProfile{
			ID:   "default",
			Type: "api_key",
			In:   "cookie",
			Name: "session",
		}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "cookie")
}

func TestValidate_ApiKeyReservedHeaderRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Metadata.Auth.Profiles[0] = AuthProfile{
			ID: "default", Type: "api_key", In: "header", Name: "Cookie",
		}
	})
	require.ErrorContains(t, err, "reserved header")
}

// TestValidate_ImagePngResponseRejected proves a hand-curated response with an
// image/png content type fails the Definition-layer response-media validator.
func TestValidate_ImagePngResponseRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Response.ContentTypes = []string{"image/png"}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "image/png")
}

// TestValidate_StreamingResponseRejected proves a text/event-stream content
// type fails the Definition-layer validator even though it matches text/*.
func TestValidate_StreamingResponseRejected(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Response.ContentTypes = []string{"text/event-stream"}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "text/event-stream")
}

// TestValidate_BoundedTextResponseAccepted proves a bounded text response
// (text/plain) passes the Definition-layer validator.
func TestValidate_BoundedTextResponseAccepted(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Response.ContentTypes = []string{"text/plain"}
	})
	require.NoError(t, err)
}

// TestBinding_NestedOptionalChildFails proves a path parameter whose source is a
// nested property (/parent/child) fails when the child is NOT required within
// parent — the full required chain along the pointer must be satisfied.
func TestBinding_NestedOptionalChildFails(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things/{child}"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/parent/child", In: "path", Name: "child"},
		}
		p.Actions[0].Binding.Body = nil
		p.Actions[0].Catalog.InputSchema = map[string]any{
			"$schema":  "https://json-schema.org/draft/2020-12/schema",
			"type":     "object",
			"required": []any{"parent"},
			"properties": map[string]any{
				"parent": map[string]any{
					"type": "object",
					"properties": map[string]any{
						"child": map[string]any{"type": "string"},
					},
					// child deliberately NOT required
				},
			},
		}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "required")
}

// TestBinding_NestedRequiredChainAccepted proves a nested path parameter whose
// full chain is required (parent AND child required) passes.
func TestBinding_NestedRequiredChainAccepted(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things/{child}"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/parent/child", In: "path", Name: "child"},
		}
		p.Actions[0].Binding.Body = nil
		p.Actions[0].Catalog.InputSchema = map[string]any{
			"$schema":  "https://json-schema.org/draft/2020-12/schema",
			"type":     "object",
			"required": []any{"parent"},
			"properties": map[string]any{
				"parent": map[string]any{
					"type":     "object",
					"required": []any{"child"},
					"properties": map[string]any{
						"child": map[string]any{"type": "string"},
					},
				},
			},
		}
	})
	require.NoError(t, err)
}

// TestBinding_NullAllowingNonBodyFails proves a non-body parameter whose source
// schema allows null (type includes "null") is rejected per §4.4.
func TestBinding_NullAllowingNonBodyFails(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "query", Name: "q"},
		}
		p.Actions[0].Binding.Body = nil
		props := p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)
		props["id"] = map[string]any{"type": []any{"string", "null"}}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "null")
}

// TestBinding_NullAdmittingCompositionNonBodyFails proves a non-body parameter
// whose source schema admits null through a composition (anyOf with a null
// branch) is rejected by the COMPILED-schema null check — not a hand-listed
// keyword check that would miss anyOf.
func TestBinding_NullAdmittingCompositionNonBodyFails(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "query", Name: "q"},
		}
		p.Actions[0].Binding.Body = nil
		props := p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)
		props["id"] = map[string]any{"anyOf": []any{
			map[string]any{"type": "string"},
			map[string]any{"type": "null"},
		}}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "null")
}

// TestBinding_ConstNullHeaderFails proves a non-body header parameter whose
// source schema is const:null is rejected — the compiled-schema check catches
// const:null which a hand-listed type/enum check would miss.
func TestBinding_ConstNullHeaderFails(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "header", Name: "X-Id"},
		}
		p.Actions[0].Binding.Body = nil
		props := p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)
		props["id"] = map[string]any{"const": nil}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "null")
}

// TestBinding_UnconstrainedNonBodyFails proves a non-body parameter whose
// source schema is unconstrained (no type) admits null and is therefore
// rejected — an empty Draft 2020-12 schema validates every value, including
// null, so the parameter cannot serialize null.
func TestBinding_UnconstrainedNonBodyFails(t *testing.T) {
	t.Parallel()
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "query", Name: "q"},
		}
		p.Actions[0].Binding.Body = nil
		props := p.Actions[0].Catalog.InputSchema["properties"].(map[string]any)
		props["id"] = map[string]any{}
	})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "null")
}

// TestBinding_CleanNonNullNonBodyOK proves a non-body parameter with a plain
// non-null source schema compiles cleanly — the compiled-schema null check
// accepts type:string (null does not validate).
func TestBinding_CleanNonNullNonBodyOK(t *testing.T) {
	t.Parallel()
	// The base package already declares id as a required type:string property,
	// so binding it to a path placeholder exercises a clean non-null non-body
	// parameter without mutating the schema.
	err := mutatePkg(t, func(p *Package) {
		p.Actions[0].Binding.Path = "/things/{id}"
		p.Actions[0].Binding.Parameters = []Parameter{
			{From: "/id", In: "path", Name: "id"},
		}
		p.Actions[0].Binding.Body = nil
	})
	require.NoError(t, err)
}

// TestBinding_V1MethodClosedSetRejectsHeadOptionsTrace proves the shared
// Definition validator enforces the V1 Package Spec method closed-set (§4.4):
// a Package carrying binding.method HEAD, OPTIONS, or TRACE fails
// LoadDefinition rather than loading successfully.
// Parameterized so the closed-set decision is documented in the test surface.
func TestBinding_V1MethodClosedSetRejectsHeadOptionsTrace(t *testing.T) {
	t.Parallel()
	for _, method := range []string{"HEAD", "OPTIONS", "TRACE"} {
		err := mutatePkg(t, func(p *Package) {
			p.Actions[0].Binding.Method = method
		})
		require.Error(t, err, "%s must be rejected", method)
		assert.Contains(t, err.Error(), method)
		assert.Contains(t, err.Error(), "V1 supports")
	}
}
