package cel_test

import (
	"context"
	"testing"

	. "github.com/onsi/gomega"

	"zotregistry.dev/zot/v2/pkg/api/config"
	"zotregistry.dev/zot/v2/pkg/cel"
)

func TestNewClaimProcessor(t *testing.T) {
	t.Parallel()

	for _, testCase := range []struct {
		name      string
		audiences []string
		conf      *config.CELClaimValidationAndMapping
		err       string
	}{
		{
			name:      "nil config uses defaults",
			audiences: []string{"my-audience"},
			conf:      nil,
		},
		{
			name:      "empty config uses defaults",
			audiences: []string{"my-audience"},
			conf:      &config.CELClaimValidationAndMapping{},
		},
		{
			name:      "custom username expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Username: "claims.email",
			},
		},
		{
			name:      "custom groups expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Groups: "claims.groups",
			},
		},
		{
			name:      "multiple audiences",
			audiences: []string{"aud1", "aud2", "aud3"},
			conf:      nil,
		},
		{
			name:      "with variables",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "org", Expression: "claims.org"},
					{Name: "team", Expression: "claims.team"},
				},
			},
		},
		{
			name:      "with validations",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "claims.email_verified == true", Message: "email must be verified"},
				},
			},
		},
		{
			name:      "empty audiences",
			audiences: []string{},
			conf:      nil,
			err:       "at least one audience must be specified",
		},
		{
			name:      "nil audiences",
			audiences: nil,
			conf:      nil,
			err:       "at least one audience must be specified",
		},
		{
			name:      "empty audience in list",
			audiences: []string{"valid", ""},
			conf:      nil,
			err:       "audience[1]:",
		},
		{
			name:      "variable with empty name",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "", Expression: "claims.org"},
				},
			},
			err: "variable[0]:",
		},
		{
			name:      "variable with invalid expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "org", Expression: "claims."},
				},
			},
			err: "failed to parse CEL expression for variable[0] (name: org)",
		},
		{
			name:      "validation with empty message",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "true", Message: ""},
				},
			},
			err: "validation[0]:",
		},
		{
			name:      "validation with invalid expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "claims.", Message: "some error"},
				},
			},
			err: "failed to parse CEL expression for validation[0]",
		},
		{
			name:      "invalid username expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Username: "claims.",
			},
			err: "failed to parse CEL expression for username",
		},
		{
			name:      "invalid groups expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Groups: "claims.",
			},
			err: "failed to parse CEL expression for groups",
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			gomega := NewWithT(t)

			processor, err := cel.NewClaimProcessor(testCase.audiences, testCase.conf)

			if testCase.err != "" {
				gomega.Expect(err).To(HaveOccurred())
				gomega.Expect(err.Error()).To(ContainSubstring(testCase.err))
				gomega.Expect(processor).To(BeNil())
			} else {
				gomega.Expect(err).NotTo(HaveOccurred())
				gomega.Expect(processor).NotTo(BeNil())
			}
		})
	}
}

func TestClaimProcessor_Process(t *testing.T) {
	t.Parallel()

	for _, testCase := range []struct {
		name      string
		audiences []string
		conf      *config.CELClaimValidationAndMapping
		claims    map[string]any
		username  string
		groups    []string
		err       string
	}{
		{
			name:      "default config extracts iss/sub as username",
			audiences: []string{"my-audience"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
			groups:   nil,
		},
		{
			name:      "custom username from email claim",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Username: "claims.email",
			},
			claims: map[string]any{
				"sub":   "user123",
				"email": "user@example.com",
				"aud":   []any{"my-audience"},
			},
			username: "user@example.com",
			groups:   nil,
		},
		{
			name:      "extract groups from claims",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Groups: "claims.groups",
			},
			claims: map[string]any{
				"iss":    "https://issuer.example.com",
				"sub":    "user123",
				"groups": []string{"admin", "developers"},
				"aud":    []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
			groups:   []string{"admin", "developers"},
		},
		{
			name:      "extract groups from any slice",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Groups: "claims.groups",
			},
			claims: map[string]any{
				"iss":    "https://issuer.example.com",
				"sub":    "user123",
				"groups": []any{"admin", "developers"},
				"aud":    []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
			groups:   []string{"admin", "developers"},
		},
		{
			name:      "audience validation - single audience match",
			audiences: []string{"my-audience"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "audience validation - multiple audiences, one matches",
			audiences: []string{"aud1", "aud2"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": []any{"aud2", "other"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "audience validation - token has multiple, config has one",
			audiences: []string{"aud2"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": []any{"aud1", "aud2", "aud3"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "audience validation - single string audience matches",
			audiences: []string{"my-audience"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": "my-audience",
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "audience validation - []string type audience matches",
			audiences: []string{"my-audience"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": []string{"my-audience", "other-audience"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "audience validation fails - single string audience no match",
			audiences: []string{"expected-aud"},
			conf:      nil,
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"aud": "other-aud",
			},
			err: "does not match any of the expected audiences",
		},
		{
			name:      "audience validation fails - no match",
			audiences: []string{"expected-aud"},
			conf:      nil,
			claims: map[string]any{
				"sub": "user123",
				"aud": []any{"other-aud"},
			},
			err: "token audience does not match any of the expected audiences",
		},
		{
			name:      "audience validation fails - empty token audience",
			audiences: []string{"expected-aud"},
			conf:      nil,
			claims: map[string]any{
				"sub": "user123",
				"aud": []any{},
			},
			err: "does not match any of the expected audiences",
		},
		{
			name:      "variables can be used in username expression",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "prefix", Expression: "'user-'"},
				},
				Username: "vars.prefix + claims.sub",
			},
			claims: map[string]any{
				"sub": "123",
				"aud": []any{"my-audience"},
			},
			username: "user-123",
		},
		{
			name:      "variables can reference claims",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "domain", Expression: "claims.email.split('@')[1]"},
				},
				Username: "vars.domain + '/' + claims.sub",
			},
			claims: map[string]any{
				"sub":   "user123",
				"email": "user@example.com",
				"aud":   []any{"my-audience"},
			},
			username: "example.com/user123",
		},
		{
			name:      "variables can reference other variables",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "org", Expression: "claims.org"},
					{Name: "fullOrg", Expression: "'org-' + vars.org"},
				},
				Username: "vars.fullOrg + '/' + claims.sub",
			},
			claims: map[string]any{
				"sub": "user123",
				"org": "myorg",
				"aud": []any{"my-audience"},
			},
			username: "org-myorg/user123",
		},
		{
			name:      "validation passes",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "claims.email_verified == true", Message: "email must be verified"},
				},
			},
			claims: map[string]any{
				"iss":            "https://issuer.example.com",
				"sub":            "user123",
				"email_verified": true,
				"aud":            []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "validation fails",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "claims.email_verified == true", Message: "email must be verified"},
				},
			},
			claims: map[string]any{
				"sub":            "user123",
				"email_verified": false,
				"aud":            []any{"my-audience"},
			},
			err: "OIDC claim validation failed: email must be verified",
		},
		{
			name:      "multiple validations all pass",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "claims.email_verified == true", Message: "email must be verified"},
					{Expression: "claims.org == 'myorg'", Message: "must be in myorg"},
				},
			},
			claims: map[string]any{
				"iss":            "https://issuer.example.com",
				"sub":            "user123",
				"email_verified": true,
				"org":            "myorg",
				"aud":            []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "multiple validations - second fails",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Validations: []config.CELValidation{
					{Expression: "claims.email_verified == true", Message: "email must be verified"},
					{Expression: "claims.org == 'myorg'", Message: "must be in myorg"},
				},
			},
			claims: map[string]any{
				"sub":            "user123",
				"email_verified": true,
				"org":            "otherorg",
				"aud":            []any{"my-audience"},
			},
			err: "OIDC claim validation failed: must be in myorg",
		},
		{
			name:      "validation can use variables",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "allowedOrgs", Expression: "['org1', 'org2', 'org3']"},
				},
				Validations: []config.CELValidation{
					{Expression: "claims.org in vars.allowedOrgs", Message: "organization not allowed"},
				},
			},
			claims: map[string]any{
				"iss": "https://issuer.example.com",
				"sub": "user123",
				"org": "org2",
				"aud": []any{"my-audience"},
			},
			username: "https://issuer.example.com/user123",
		},
		{
			name:      "validation using variables fails",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "allowedOrgs", Expression: "['org1', 'org2', 'org3']"},
				},
				Validations: []config.CELValidation{
					{Expression: "claims.org in vars.allowedOrgs", Message: "organization not allowed"},
				},
			},
			claims: map[string]any{
				"sub": "user123",
				"org": "org4",
				"aud": []any{"my-audience"},
			},
			err: "OIDC claim validation failed: organization not allowed",
		},
		{
			name:      "username expression evaluation error",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Username: "claims.nonexistent",
			},
			claims: map[string]any{
				"sub": "user123",
				"aud": []any{"my-audience"},
			},
			err: "failed to evaluate username expression",
		},
		{
			name:      "groups expression evaluation error",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Username: "claims.sub",
				Groups:   "claims.nonexistent",
			},
			claims: map[string]any{
				"sub": "user123",
				"aud": []any{"my-audience"},
			},
			err: "failed to evaluate groups expression",
		},
		{
			name:      "variable expression evaluation error",
			audiences: []string{"my-audience"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "bad", Expression: "claims.nonexistent"},
				},
			},
			claims: map[string]any{
				"sub": "user123",
				"aud": []any{"my-audience"},
			},
			err: "failed to evaluate variable 'bad'",
		},
		{
			name:      "complex real-world scenario - GitHub Actions OIDC",
			audiences: []string{"zot-registry"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "repo", Expression: "claims.repository"},
					{Name: "owner", Expression: "claims.repository_owner"},
				},
				Validations: []config.CELValidation{
					{Expression: "vars.owner == 'myorg'", Message: "only myorg repositories allowed"},
					{Expression: "claims.ref.startsWith('refs/heads/')", Message: "must be a branch ref"},
				},
				Username: "vars.repo",
				Groups:   "['github-actions', 'ci']",
			},
			claims: map[string]any{
				"sub":              "repo:myorg/myrepo:ref:refs/heads/main",
				"repository":       "myorg/myrepo",
				"repository_owner": "myorg",
				"ref":              "refs/heads/main",
				"aud":              []any{"zot-registry"},
			},
			username: "myorg/myrepo",
			groups:   []string{"github-actions", "ci"},
		},
		{
			name:      "complex real-world scenario - Kubernetes service account",
			audiences: []string{"zot"},
			conf: &config.CELClaimValidationAndMapping{
				Variables: []config.CELVariable{
					{Name: "ns", Expression: "claims['kubernetes.io/serviceaccount/namespace']"},
					{Name: "sa", Expression: "claims['kubernetes.io/serviceaccount/service-account.name']"},
				},
				Validations: []config.CELValidation{
					{Expression: "vars.ns in ['production', 'staging']", Message: "namespace not allowed"},
				},
				Username: "vars.ns + ':' + vars.sa",
				Groups:   "['k8s-workloads']",
			},
			claims: map[string]any{
				"sub":                                    "system:serviceaccount:production:my-app",
				"kubernetes.io/serviceaccount/namespace": "production",
				"kubernetes.io/serviceaccount/service-account.name": "my-app",
				"aud": []any{"zot"},
			},
			username: "production:my-app",
			groups:   []string{"k8s-workloads"},
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			gomega := NewWithT(t)

			processor, err := cel.NewClaimProcessor(testCase.audiences, testCase.conf)
			gomega.Expect(err).NotTo(HaveOccurred())

			result, err := processor.Process(context.Background(), testCase.claims)

			if testCase.err != "" {
				gomega.Expect(err).To(HaveOccurred())
				gomega.Expect(err.Error()).To(ContainSubstring(testCase.err))
				gomega.Expect(result).To(BeNil())
			} else {
				gomega.Expect(err).NotTo(HaveOccurred())
				gomega.Expect(result).NotTo(BeNil())
				gomega.Expect(result.Username).To(Equal(testCase.username))
				gomega.Expect(result.Groups).To(Equal(testCase.groups))
			}
		})
	}
}

func TestClaimProcessor_Process_AudienceEdgeCases(t *testing.T) {
	t.Parallel()

	for _, testCase := range []struct {
		name      string
		audiences []string
		claims    map[string]any
		err       string
	}{
		{
			name:      "missing aud claim",
			audiences: []string{"my-audience"},
			claims: map[string]any{
				"sub": "user123",
			},
			err: "missing 'aud' claim",
		},
		{
			name:      "aud claim with wrong type (integer)",
			audiences: []string{"my-audience"},
			claims: map[string]any{
				"sub": "user123",
				"iss": "test-issuer",
				"aud": 12345,
			},
			err: "does not match any of the expected audiences",
		},
		{
			name:      "aud claim with wrong type (map)",
			audiences: []string{"my-audience"},
			claims: map[string]any{
				"sub": "user123",
				"iss": "test-issuer",
				"aud": map[string]any{"key": "value"},
			},
			err: "does not match any of the expected audiences",
		},
		{
			name:      "aud array contains non-string value",
			audiences: []string{"my-audience"},
			claims: map[string]any{
				"sub": "user123",
				"iss": "test-issuer",
				"aud": []any{"valid-aud", 123},
			},
			err: "'aud' claim contains non-string value",
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			t.Parallel()

			gomega := NewWithT(t)

			processor, err := cel.NewClaimProcessor(testCase.audiences, nil)
			gomega.Expect(err).NotTo(HaveOccurred())

			result, err := processor.Process(context.Background(), testCase.claims)

			gomega.Expect(err).To(HaveOccurred())
			gomega.Expect(err.Error()).To(ContainSubstring(testCase.err))
			gomega.Expect(result).To(BeNil())
		})
	}
}
