package framework

import (
	"bytes"
	"encoding/json"
	"io/ioutil"
	"path/filepath"
	"reflect"
	"regexp"
	"sort"
	"strings"
	"testing"

	"github.com/go-test/deep"
	"github.com/hashicorp/vault/sdk/helper/jsonutil"
	"github.com/hashicorp/vault/sdk/helper/wrapping"
	"github.com/hashicorp/vault/sdk/logical"
	"github.com/hashicorp/vault/sdk/version"
)

func TestOpenAPI_Regex(t *testing.T) {
	t.Run("Required", func(t *testing.T) {
		tests := []struct {
			input    string
			captures []string
		}{
			{`/foo/bar/(?P<val>.*)`, []string{"val"}},
			{`/foo/bar/` + GenericNameRegex("val"), []string{"val"}},
			{`/foo/bar/` + GenericNameRegex("first") + "/b/" + GenericNameRegex("second"), []string{"first", "second"}},
			{`/foo/bar`, []string{}},
		}

		for _, test := range tests {
			result := reqdRe.FindAllStringSubmatch(test.input, -1)
			if len(result) != len(test.captures) {
				t.Fatalf("Capture error (%s): expected %d matches, actual: %d", test.input, len(test.captures), len(result))
			}

			for i := 0; i < len(result); i++ {
				if result[i][1] != test.captures[i] {
					t.Fatalf("Capture error (%s): expected %s, actual: %s", test.input, test.captures[i], result[i][1])
				}
			}
		}
	})
	t.Run("Optional", func(t *testing.T) {
		input := "foo/(maybe/)?bar"
		expStart := len("foo/")
		expEnd := len(input) - len("bar")

		match := optRe.FindStringIndex(input)
		if diff := deep.Equal(match, []int{expStart, expEnd}); diff != nil {
			t.Fatal(diff)
		}

		input = "/foo/maybe/bar"
		match = optRe.FindStringIndex(input)
		if match != nil {
			t.Fatalf("Expected nil match (%s), got %+v", input, match)
		}
	})
	t.Run("Alternation", func(t *testing.T) {
		input := `(raw/?$|raw/(?P<path>.+))`

		matches := altRe.FindAllStringSubmatch(input, -1)
		exp1 := "raw/?$"
		exp2 := "raw/(?P<path>.+)"
		if matches[0][1] != exp1 || matches[0][2] != exp2 {
			t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches[0][1:])
		}

		input = `/foo/bar/` + GenericNameRegex("val")

		matches = altRe.FindAllStringSubmatch(input, -1)
		if matches != nil {
			t.Fatalf("Expected nil match (%s), got %+v", input, matches)
		}
	})
	t.Run("Alternation Fields", func(t *testing.T) {
		input := `/foo/bar/(?P<type>auth|database|secret)/(?P<blah>a|b)`

		act := altFieldsGroupRe.ReplaceAllStringFunc(input, func(s string) string {
			return altFieldsRe.ReplaceAllString(s, ".+")
		})

		exp := "/foo/bar/(?P<type>.+)/(?P<blah>.+)"
		if act != exp {
			t.Fatalf("Replace error. Expected %s, got %v", exp, act)
		}
	})
	t.Run("Path fields", func(t *testing.T) {
		input := `/foo/bar/{inner}/baz/{outer}`

		matches := pathFieldsRe.FindAllStringSubmatch(input, -1)

		exp1 := "inner"
		exp2 := "outer"
		if matches[0][1] != exp1 || matches[1][1] != exp2 {
			t.Fatalf("Capture error. Expected %s and %s, got %v", exp1, exp2, matches)
		}

		input = `/foo/bar/inner/baz/outer`
		matches = pathFieldsRe.FindAllStringSubmatch(input, -1)

		if matches != nil {
			t.Fatalf("Expected nil match (%s), got %+v", input, matches)
		}
	})
	t.Run("Filtering", func(t *testing.T) {
		tests := []struct {
			input  string
			regex  *regexp.Regexp
			output string
		}{
			{
				input:  `ab?cde^fg(hi?j$k`,
				regex:  cleanCharsRe,
				output: "abcdefghijk",
			},
			{
				input:  `abcde/?`,
				regex:  cleanSuffixRe,
				output: "abcde",
			},
			{
				input:  `abcde/?$`,
				regex:  cleanSuffixRe,
				output: "abcde",
			},
			{
				input:  `abcde`,
				regex:  wsRe,
				output: "abcde",
			},
			{
				input:  `  a         b    cd   e   `,
				regex:  wsRe,
				output: "abcde",
			},
		}

		for _, test := range tests {
			result := test.regex.ReplaceAllString(test.input, "")
			if result != test.output {
				t.Fatalf("Clean Regex error (%s). Expected %s, got %s", test.input, test.output, result)
			}
		}
	})
}

func TestOpenAPI_ExpandPattern(t *testing.T) {
	tests := []struct {
		inPattern   string
		outPathlets []string
	}{
		{"rekey/backup", []string{"rekey/backup"}},
		{"rekey/backup$", []string{"rekey/backup"}},
		{"auth/(?P<path>.+?)/tune$", []string{"auth/{path}/tune"}},
		{"auth/(?P<path>.+?)/tune/(?P<more>.*?)$", []string{"auth/{path}/tune/{more}"}},
		{"tools/hash(/(?P<urlalgorithm>.+))?", []string{
			"tools/hash",
			"tools/hash/{urlalgorithm}",
		}},
		{"(leases/)?renew(/(?P<url_lease_id>.+))?", []string{
			"leases/renew",
			"leases/renew/{url_lease_id}",
			"renew",
			"renew/{url_lease_id}",
		}},
		{`config/ui/headers/` + GenericNameRegex("header"), []string{"config/ui/headers/{header}"}},
		{`leases/lookup/(?P<prefix>.+?)?`, []string{
			"leases/lookup/",
			"leases/lookup/{prefix}",
		}},
		{`(raw/?$|raw/(?P<path>.+))`, []string{
			"raw",
			"raw/{path}",
		}},
		{"lookup" + OptionalParamRegex("urltoken"), []string{
			"lookup",
			"lookup/{urltoken}",
		}},
		{"roles/?$", []string{
			"roles",
		}},
		{"roles/?", []string{
			"roles",
		}},
		{"accessors/$", []string{
			"accessors/",
		}},
		{"verify/" + GenericNameRegex("name") + OptionalParamRegex("urlalgorithm"), []string{
			"verify/{name}",
			"verify/{name}/{urlalgorithm}",
		}},
		{"^plugins/catalog/(?P<type>auth|database|secret)/(?P<name>.+)$", []string{
			"plugins/catalog/{type}/{name}",
		}},
		{"^plugins/catalog/(?P<type>auth|database|secret)/?$", []string{
			"plugins/catalog/{type}",
		}},
		{"(pathOne|pathTwo)/", []string{"pathOne/", "pathTwo/"}},
		{"(pathOne|pathTwo)/" + GenericNameRegex("name"), []string{"pathOne/{name}", "pathTwo/{name}"}},
		{
			"(pathOne|path-2|Path_3)/" + GenericNameRegex("name"),
			[]string{"Path_3/{name}", "path-2/{name}", "pathOne/{name}"},
		},
	}

	for i, test := range tests {
		out := expandPattern(test.inPattern)
		sort.Strings(out)
		if !reflect.DeepEqual(out, test.outPathlets) {
			t.Fatalf("Test %d: Expected %v got %v", i, test.outPathlets, out)
		}
	}
}

func TestOpenAPI_SplitFields(t *testing.T) {
	fields := map[string]*FieldSchema{
		"a": {Description: "path"},
		"b": {Description: "body"},
		"c": {Description: "body"},
		"d": {Description: "body"},
		"e": {Description: "path"},
	}

	pathFields, bodyFields := splitFields(fields, "some/{a}/path/{e}")

	lp := len(pathFields)
	lb := len(bodyFields)
	l := len(fields)
	if lp+lb != l {
		t.Fatalf("split length error: %d + %d != %d", lp, lb, l)
	}

	for name, field := range pathFields {
		if field.Description != "path" {
			t.Fatalf("expected field %s to be in 'path', found in %s", name, field.Description)
		}
	}
	for name, field := range bodyFields {
		if field.Description != "body" {
			t.Fatalf("expected field %s to be in 'body', found in %s", name, field.Description)
		}
	}
}

func TestOpenAPI_SpecialPaths(t *testing.T) {
	tests := []struct {
		pattern     string
		rootPaths   []string
		root        bool
		unauthPaths []string
		unauth      bool
	}{
		{"foo", []string{}, false, []string{"foo"}, true},
		{"foo", []string{"foo"}, true, []string{"bar"}, false},
		{"foo/bar", []string{"foo"}, false, []string{"foo/*"}, true},
		{"foo/bar", []string{"foo/*"}, true, []string{"foo"}, false},
		{"foo/", []string{"foo/*"}, true, []string{"a", "b", "foo/"}, true},
		{"foo", []string{"foo*"}, true, []string{"a", "fo*"}, true},
		{"foo/bar", []string{"a", "b", "foo/*"}, true, []string{"foo/baz/*"}, false},
	}
	for i, test := range tests {
		doc := NewOASDocument()
		path := Path{
			Pattern: test.pattern,
		}
		sp := &logical.Paths{
			Root:            test.rootPaths,
			Unauthenticated: test.unauthPaths,
		}
		err := documentPath(&path, sp, "kv", logical.TypeLogical, doc)
		if err != nil {
			t.Fatal(err)
		}
		result := test.root
		if doc.Paths["/"+test.pattern].Sudo != result {
			t.Fatalf("Test (root) %d: Expected %v got %v", i, test.root, result)
		}
		result = test.unauth
		if doc.Paths["/"+test.pattern].Unauthenticated != result {
			t.Fatalf("Test (unauth) %d: Expected %v got %v", i, test.unauth, result)
		}
	}
}

func TestOpenAPI_Paths(t *testing.T) {
	origDepth := deep.MaxDepth
	defer func() { deep.MaxDepth = origDepth }()
	deep.MaxDepth = 20

	t.Run("Legacy callbacks", func(t *testing.T) {
		p := &Path{
			Pattern: "lookup/" + GenericNameRegex("id"),

			Fields: map[string]*FieldSchema{
				"id": {
					Type:        TypeString,
					Description: "My id parameter",
				},
				"token": {
					Type:        TypeString,
					Description: "My token",
				},
			},

			Callbacks: map[logical.Operation]OperationFunc{
				logical.ReadOperation:   nil,
				logical.UpdateOperation: nil,
			},

			HelpSynopsis:    "Synopsis",
			HelpDescription: "Description",
		}

		sp := &logical.Paths{
			Root:            []string{},
			Unauthenticated: []string{},
		}
		testPath(t, p, sp, expected("legacy"))
	})

	t.Run("Operations - All Operations", func(t *testing.T) {
		p := &Path{
			Pattern: "foo/" + GenericNameRegex("id"),
			Fields: map[string]*FieldSchema{
				"id": {
					Type:        TypeString,
					Description: "id path parameter",
				},
				"flavors": {
					Type:        TypeCommaStringSlice,
					Description: "the flavors",
				},
				"name": {
					Type:        TypeNameString,
					Default:     "Larry",
					Description: "the name",
				},
				"age": {
					Type:          TypeInt,
					Description:   "the age",
					AllowedValues: []interface{}{1, 2, 3},
					Required:      true,
					DisplayAttrs: &DisplayAttributes{
						Name:      "Age",
						Sensitive: true,
						Group:     "Some Group",
						Value:     7,
					},
				},
				"x-abc-token": {
					Type:          TypeHeader,
					Description:   "a header value",
					AllowedValues: []interface{}{"a", "b", "c"},
				},
				"maximum": {
					Type:        TypeInt64,
					Description: "a maximum value",
				},
				"format": {
					Type:        TypeString,
					Description: "a query param",
					Query:       true,
				},
			},
			HelpSynopsis:    "Synopsis",
			HelpDescription: "Description",
			Operations: map[logical.Operation]OperationHandler{
				logical.ReadOperation: &PathOperation{
					Summary:     "My Summary",
					Description: "My Description",
				},
				logical.UpdateOperation: &PathOperation{
					Summary:     "Update Summary",
					Description: "Update Description",
				},
				logical.CreateOperation: &PathOperation{
					Summary:     "Create Summary",
					Description: "Create Description",
				},
				logical.ListOperation: &PathOperation{
					Summary:     "List Summary",
					Description: "List Description",
				},
				logical.DeleteOperation: &PathOperation{
					Summary:     "This shouldn't show up",
					Unpublished: true,
				},
			},
			DisplayAttrs: &DisplayAttributes{
				Navigation: true,
			},
		}

		sp := &logical.Paths{
			Root: []string{"foo*"},
		}
		testPath(t, p, sp, expected("operations"))
	})

	t.Run("Operations - List Only", func(t *testing.T) {
		p := &Path{
			Pattern: "foo/" + GenericNameRegex("id"),
			Fields: map[string]*FieldSchema{
				"id": {
					Type:        TypeString,
					Description: "id path parameter",
				},
				"flavors": {
					Type:        TypeCommaStringSlice,
					Description: "the flavors",
				},
				"name": {
					Type:        TypeNameString,
					Default:     "Larry",
					Description: "the name",
				},
				"age": {
					Type:          TypeInt,
					Description:   "the age",
					AllowedValues: []interface{}{1, 2, 3},
					Required:      true,
					DisplayAttrs: &DisplayAttributes{
						Name:      "Age",
						Sensitive: true,
						Group:     "Some Group",
						Value:     7,
					},
				},
				"x-abc-token": {
					Type:          TypeHeader,
					Description:   "a header value",
					AllowedValues: []interface{}{"a", "b", "c"},
				},
				"format": {
					Type:        TypeString,
					Description: "a query param",
					Query:       true,
				},
			},
			HelpSynopsis:    "Synopsis",
			HelpDescription: "Description",
			Operations: map[logical.Operation]OperationHandler{
				logical.ListOperation: &PathOperation{
					Summary:     "List Summary",
					Description: "List Description",
				},
			},
			DisplayAttrs: &DisplayAttributes{
				Navigation: true,
			},
		}

		sp := &logical.Paths{
			Root: []string{"foo*"},
		}
		testPath(t, p, sp, expected("operations_list"))
	})

	t.Run("Responses", func(t *testing.T) {
		p := &Path{
			Pattern:         "foo",
			HelpSynopsis:    "Synopsis",
			HelpDescription: "Description",
			Operations: map[logical.Operation]OperationHandler{
				logical.ReadOperation: &PathOperation{
					Summary:     "My Summary",
					Description: "My Description",
					Responses: map[int][]Response{
						202: {{
							Description: "Amazing",
							Example: &logical.Response{
								Data: map[string]interface{}{
									"amount": 42,
								},
							},
						}},
					},
				},
				logical.DeleteOperation: &PathOperation{
					Summary: "Delete stuff",
				},
			},
		}

		sp := &logical.Paths{
			Unauthenticated: []string{"x", "y", "foo"},
		}

		testPath(t, p, sp, expected("responses"))
	})
}

func TestOpenAPI_OperationID(t *testing.T) {
	path1 := &Path{
		Pattern: "foo/" + GenericNameRegex("id"),
		Fields: map[string]*FieldSchema{
			"id": {Type: TypeString},
		},
		Operations: map[logical.Operation]OperationHandler{
			logical.ReadOperation:   &PathOperation{},
			logical.UpdateOperation: &PathOperation{},
			logical.DeleteOperation: &PathOperation{},
		},
	}

	path2 := &Path{
		Pattern: "Foo/" + GenericNameRegex("id"),
		Fields: map[string]*FieldSchema{
			"id": {Type: TypeString},
		},
		Operations: map[logical.Operation]OperationHandler{
			logical.ReadOperation: &PathOperation{},
		},
	}

	for _, context := range []string{"", "bar"} {
		doc := NewOASDocument()
		err := documentPath(path1, nil, "kv", logical.TypeLogical, doc)
		if err != nil {
			t.Fatal(err)
		}
		err = documentPath(path2, nil, "kv", logical.TypeLogical, doc)
		if err != nil {
			t.Fatal(err)
		}
		doc.CreateOperationIDs(context)

		tests := []struct {
			path string
			op   string
			opID string
		}{
			{"/Foo/{id}", "get", "getFooId"},
			{"/foo/{id}", "get", "getFooId_2"},
			{"/foo/{id}", "post", "postFooId"},
			{"/foo/{id}", "delete", "deleteFooId"},
		}

		for _, test := range tests {
			actual := getPathOp(doc.Paths[test.path], test.op).OperationID
			expected := test.opID
			if context != "" {
				expected += "_" + context
			}

			if actual != expected {
				t.Fatalf("expected %v, got %v", expected, actual)
			}
		}
	}
}

func TestOpenAPI_CustomDecoder(t *testing.T) {
	p := &Path{
		Pattern:      "foo",
		HelpSynopsis: "Synopsis",
		Operations: map[logical.Operation]OperationHandler{
			logical.ReadOperation: &PathOperation{
				Summary: "My Summary",
				Responses: map[int][]Response{
					100: {{
						Description: "OK",
						Example: &logical.Response{
							Data: map[string]interface{}{
								"foo": 42,
							},
						},
					}},
					200: {{
						Description: "Good",
						Example:     (*logical.Response)(nil),
					}},
					599: {{
						Description: "Bad",
					}},
				},
			},
		},
	}

	docOrig := NewOASDocument()
	err := documentPath(p, nil, "kv", logical.TypeLogical, docOrig)
	if err != nil {
		t.Fatal(err)
	}

	docJSON := mustJSONMarshal(t, docOrig)

	var intermediate map[string]interface{}
	if err := jsonutil.DecodeJSON(docJSON, &intermediate); err != nil {
		t.Fatal(err)
	}

	docNew, err := NewOASDocumentFromMap(intermediate)
	if err != nil {
		t.Fatal(err)
	}

	docNewJSON := mustJSONMarshal(t, docNew)

	if diff := deep.Equal(docJSON, docNewJSON); diff != nil {
		t.Fatal(diff)
	}
}

func TestOpenAPI_CleanResponse(t *testing.T) {
	// Verify that an all-null input results in empty JSON
	orig := &logical.Response{}

	cr := cleanResponse(orig)

	newJSON := mustJSONMarshal(t, cr)

	if !bytes.Equal(newJSON, []byte("{}")) {
		t.Fatalf("expected {}, got: %q", newJSON)
	}

	// Verify that all non-null inputs results in JSON that matches the marshalling of
	// logical.Response. This will fail if logical.Response changes without a corresponding
	// change to cleanResponse()
	orig = &logical.Response{
		Secret:   new(logical.Secret),
		Auth:     new(logical.Auth),
		Data:     map[string]interface{}{"foo": 42},
		Redirect: "foo",
		Warnings: []string{"foo"},
		WrapInfo: &wrapping.ResponseWrapInfo{Token: "foo"},
		Headers:  map[string][]string{"foo": {"bar"}},
	}
	origJSON := mustJSONMarshal(t, orig)

	cr = cleanResponse(orig)

	cleanJSON := mustJSONMarshal(t, cr)

	if diff := deep.Equal(origJSON, cleanJSON); diff != nil {
		t.Fatal(diff)
	}
}

func testPath(t *testing.T, path *Path, sp *logical.Paths, expectedJSON string) {
	t.Helper()

	doc := NewOASDocument()
	if err := documentPath(path, sp, "kv", logical.TypeLogical, doc); err != nil {
		t.Fatal(err)
	}
	doc.CreateOperationIDs("")

	docJSON, err := json.MarshalIndent(doc, "", "  ")
	if err != nil {
		t.Fatal(err)
	}

	// Compare json by first decoding, then comparing with a deep equality check.
	var expected, actual interface{}
	if err := jsonutil.DecodeJSON(docJSON, &actual); err != nil {
		t.Fatal(err)
	}

	if err := jsonutil.DecodeJSON([]byte(expectedJSON), &expected); err != nil {
		t.Fatal(err)
	}

	if diff := deep.Equal(actual, expected); diff != nil {
		// fmt.Println(string(docJSON)) // uncomment to debug generated JSON (very helpful when fixing tests)
		t.Fatal(diff)
	}
}

func getPathOp(pi *OASPathItem, op string) *OASOperation {
	switch op {
	case "get":
		return pi.Get
	case "post":
		return pi.Post
	case "delete":
		return pi.Delete
	default:
		panic("unexpected operation: " + op)
	}
}

func expected(name string) string {
	data, err := ioutil.ReadFile(filepath.Join("testdata", name+".json"))
	if err != nil {
		panic(err)
	}

	content := strings.Replace(string(data), "<vault_version>", version.GetVersion().Version, 1)

	return content
}

func mustJSONMarshal(t *testing.T, data interface{}) []byte {
	j, err := json.MarshalIndent(data, "", "  ")
	if err != nil {
		t.Fatal(err)
	}
	return j
}
