package policy_test

import (
	"fmt"
	"go/parser"
	"go/token"
	"path/filepath"
	"testing"

	plugin "github.com/golang/protobuf/protoc-gen-go/plugin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/chef/automate/lib/grpc/grpctest"
)

// /!\ Warning /!\
//
// Due to the way protoc-gen-star works, any errors occurring from its internal
// use of proto-gen-go (it's embedding that, running it, and then discarding the
// output) HIDE LOG MESSAGES emitted via `t.Logf`, or `t.Errorf`, in any of the
// helpers.
//
// So, when you're trying to get behind a test failure that ends with a line
// that starts with `protoc-gen-go:`, you'll have to resort to aborting the test
// using `t.Fatalf` to see your debug output.`

const a2Root = "github.com/chef/automate"

func TestCreatesValidCode(t *testing.T) {
	testdata := func(baseFileName string) string {
		return fmt.Sprintf("automate-grpc/protoc-gen-policy/testdata/%s.proto", baseFileName)
	}
	fullPath := func(baseFileName, pkgName string) string {
		return filepath.Join(a2Root,
			fmt.Sprintf("components/mock-service/%s/%s.pb.policy.go", pkgName, baseFileName))
	}

	tests := map[string]struct {
		filename string
		checks   []checkFunc
	}{
		// NB: Each test uses an actual proto file from the testdata directory.
		"no services defined": {
			testdata("no_services"),
			check(noError, outputFiles(0))},

		"service without methods": {
			testdata("service_without_methods"),
			check(noError, outputFiles(0))},

		"service with one method plus http annotation": {
			testdata("one_method_plus_http"),
			check(noError,
				outputFiles(1),
				syntacticallyCorrectFile(fullPath("one_method_plus_http", "mock")),
				fileWithContent(fullPath("one_method_plus_http", "mock"), "// Code generated by protoc-gen-policy.",
					"// source: "+testdata("one_method_plus_http"),
					"package mock",
					`policy.MapMethodTo("/chef.automate.domain.one_method.Mock/MockMethod", "mock:foo:{bar}", "action:item:verb", `+
						`"GET", "/api/v0/auth/foo/baz", func(unexpandedResource string, input interface{}) string {`),
			)},

		"service with one method": {
			testdata("one_method"),
			check(noError,
				outputFiles(1),
				syntacticallyCorrectFile(fullPath("one_method", "mock")),
				fileWithContent(fullPath("one_method", "mock"), "// Code generated by protoc-gen-policy.",
					"// source: "+testdata("one_method"),
					"package mock", // matches go_package
					`policy.MapMethodTo("/chef.automate.domain.one_method.Mock/MockMethod", `+
						`"mock:foo:{bar}", "action:item:verb", "", "", func(unexpandedResource string, input interface{}) string {`,
					"input.(*Foo)", // check that there's now circular import
				),
			)},

		"service with resource pattern annotation that doesn't match message fields": {
			testdata("resource_field_name_mismatch"),
			check(someError)},

		"service with invalid annotations: too few terms in action": {
			testdata("invalid_action_too_few_terms"),
			check(someError)},

		"service with invalid annotations: too many terms in action": {
			testdata("invalid_action_too_many_terms"),
			check(someError)},

		"service whose go_package name contains underscore": {
			testdata("pkg_name_with_underscore"),
			check(noError,
				outputFiles(1),
				syntacticallyCorrectFile(fullPath("pkg_name_with_underscore", "pkg_name_with_underscore")),
				fileWithContent(fullPath("pkg_name_with_underscore", "pkg_name_with_underscore"), "// Code generated by protoc-gen-policy.",
					"// source: "+testdata("pkg_name_with_underscore"),
					"package pkg_name_with_underscore"),
			)},

		"service with imported method arguments": {
			testdata("imported_method_arguments"),
			check(noError,
				outputFiles(1),
				syntacticallyCorrectFile(fullPath("imported_method_arguments", "mock")),
				fileWithContent(fullPath("imported_method_arguments", "mock"), "// Code generated by protoc-gen-policy.",
					"// source: "+testdata("imported_method_arguments"),
					"package mock",
					`messages "github.com/chef/automate/components/mock-service/mock/messages"`,
					"input.(*messages.Foo)"),
			)},
	}

	// Each call to GenerateCode (via runWithInputFile) will return a cleanup
	// function referencing _the same cached executable_. So, it's fine to use
	// any of them. But if all the tests fail, we need a not null pointer
	// function to run, so make an empty function to start:
	cleanup := func() {}

	for desc, tc := range tests {
		t.Run(desc, func(t *testing.T) {
			var resp *plugin.CodeGeneratorResponse
			var err error
			resp, cleanup, err = runWithInputFile(t, tc.filename)
			for _, cf := range tc.checks {
				cf(t, resp, err)
			}
		})
	}
	cleanup()
}

// test helpers, definitions

func noError(t *testing.T, resp *plugin.CodeGeneratorResponse, err error) {
	require.NoError(t, err)
	require.Zero(t, resp.GetError(), "expecting no error output")
}

func someError(t *testing.T, _ *plugin.CodeGeneratorResponse, err error) {
	require.Error(t, err)
}

func outputFiles(n int) checkFunc {
	return func(t *testing.T, resp *plugin.CodeGeneratorResponse, _ error) {
		files := []string{}
		for _, f := range resp.GetFile() {
			files = append(files, *f.Name)
		}
		assert.Equalf(t, n, len(resp.GetFile()), "expecting %d generated file(s), got %v", n, files)
	}
}

// This is a helper "check" to allow manually inspecting code generated in a test
func dumpFiles(t *testing.T, resp *plugin.CodeGeneratorResponse, _ error) {
	for _, f := range resp.GetFile() {
		t.Logf("%s: %s", *f.Name, f.GetContent())
	}
}

// This helper checks if the file _contains_ a substring -- it's not an exact
// match of all the contents
func fileWithContent(name string, contents ...string) checkFunc {
	return func(t *testing.T, resp *plugin.CodeGeneratorResponse, _ error) {
		for _, file := range resp.GetFile() {
			if file.GetName() == name {
				for _, content := range contents {
					require.Contains(t, file.GetContent(), content)
				}
				return
			}
		}
		t.Errorf("file %q not in response", name)
	}
}

// Helper for checking that a substring should NOT be in the file
func fileWithoutContent(name, content string) checkFunc {
	return func(t *testing.T, resp *plugin.CodeGeneratorResponse, _ error) {
		for _, file := range resp.GetFile() {
			if file.GetName() == name {
				assert.NotContains(t, file.GetContent(), content)
				return
			}
		}
		t.Errorf("file %q not in response", name)
	}
}

func syntacticallyCorrectFile(name string) checkFunc {
	return func(t *testing.T, resp *plugin.CodeGeneratorResponse, _ error) {
		for _, file := range resp.GetFile() {
			if file.GetName() == name {
				assert.NoErrorf(t, parseFileContent(file.GetContent()), "parsing file %q", name)
				return
			}
		}
		t.Errorf("file name %q not in response", name)
	}
}

func parseFileContent(src string) error {
	fset := token.NewFileSet()
	_, err := parser.ParseFile(fset, "", src, parser.AllErrors)
	return err
}

type checkFunc func(*testing.T, *plugin.CodeGeneratorResponse, error)

func check(fs ...checkFunc) []checkFunc {
	return fs
}

func runWithInputFile(t *testing.T, filename string) (*plugin.CodeGeneratorResponse, func(), error) {
	files := []string{filename}
	protoFiles, err := grpctest.ParseProtoFiles(files)
	if err != nil {
		t.Fatalf("parse proto file %q: %s", filename, err)
	}
	return grpctest.GenerateCode(t, "policy", protoFiles)
}
