package configupgrade

import (
	"bytes"
	"flag"
	"io"
	"io/ioutil"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"testing"

	"github.com/davecgh/go-spew/spew"
	"github.com/zclconf/go-cty/cty"

	backendinit "github.com/hashicorp/terraform/backend/init"
	"github.com/hashicorp/terraform/configs/configschema"
	"github.com/hashicorp/terraform/helper/logging"
	"github.com/hashicorp/terraform/providers"
	"github.com/hashicorp/terraform/provisioners"
	"github.com/hashicorp/terraform/terraform"
)

func TestUpgradeValid(t *testing.T) {
	// This test uses the contents of the testdata/valid directory as
	// a table of tests. Every directory there must have both "input" and
	// "want" subdirectories, where "input" is the configuration to be
	// upgraded and "want" is the expected result.
	fixtureDir := "testdata/valid"
	testDirs, err := ioutil.ReadDir(fixtureDir)
	if err != nil {
		t.Fatal(err)
	}

	for _, entry := range testDirs {
		if !entry.IsDir() {
			continue
		}
		t.Run(entry.Name(), func(t *testing.T) {
			inputDir := filepath.Join(fixtureDir, entry.Name(), "input")
			wantDir := filepath.Join(fixtureDir, entry.Name(), "want")
			u := &Upgrader{
				Providers:    providers.ResolverFixed(testProviders),
				Provisioners: testProvisioners,
			}

			inputSrc, err := LoadModule(inputDir)
			if err != nil {
				t.Fatal(err)
			}
			wantSrc, err := LoadModule(wantDir)
			if err != nil {
				t.Fatal(err)
			}

			gotSrc, diags := u.Upgrade(inputSrc, inputDir)
			if diags.HasErrors() {
				t.Error(diags.Err())
			}

			// Upgrade uses a nil entry as a signal to delete a file, which
			// we can't test here because we aren't modifying an existing
			// dir in place, so we'll just ignore those and leave that mechanism
			// to be tested elsewhere.

			for name, got := range gotSrc {
				if gotSrc[name] == nil {
					delete(gotSrc, name)
					continue
				}
				want, wanted := wantSrc[name]
				if !wanted {
					t.Errorf("unexpected extra output file %q\n=== GOT ===\n%s", name, got)
					continue
				}

				got = bytes.TrimSpace(got)
				want = bytes.TrimSpace(want)
				if !bytes.Equal(got, want) {
					diff := diffSourceFiles(got, want)
					t.Errorf("wrong content in %q\n%s", name, diff)
				}
			}

			for name, want := range wantSrc {
				if _, present := gotSrc[name]; !present {
					t.Errorf("missing output file %q\n=== WANT ===\n%s", name, want)
				}
			}
		})
	}
}

func TestUpgradeRenameJSON(t *testing.T) {
	inputDir := filepath.Join("testdata/valid/rename-json/input")
	inputSrc, err := LoadModule(inputDir)
	if err != nil {
		t.Fatal(err)
	}

	u := &Upgrader{
		Providers: providers.ResolverFixed(testProviders),
	}
	gotSrc, diags := u.Upgrade(inputSrc, inputDir)
	if diags.HasErrors() {
		t.Error(diags.Err())
	}

	// This test fixture is also fully covered by TestUpgradeValid, so
	// we're just testing that the file was renamed here.
	src, exists := gotSrc["misnamed-json.tf"]
	if src != nil {
		t.Errorf("misnamed-json.tf still has content")
	} else if !exists {
		t.Errorf("misnamed-json.tf not marked for deletion")
	}

	src, exists = gotSrc["misnamed-json.tf.json"]
	if src == nil || !exists {
		t.Errorf("misnamed-json.tf.json was not created")
	}
}

func diffSourceFiles(got, want []byte) []byte {
	// We'll try to run "diff -u" here to get nice output, but if that fails
	// (e.g. because we're running on a machine without diff installed) then
	// we'll fall back on just printing out the before and after in full.
	gotR, gotW, err := os.Pipe()
	if err != nil {
		return diffSourceFilesFallback(got, want)
	}
	defer gotR.Close()
	defer gotW.Close()
	wantR, wantW, err := os.Pipe()
	if err != nil {
		return diffSourceFilesFallback(got, want)
	}
	defer wantR.Close()
	defer wantW.Close()

	cmd := exec.Command("diff", "-u", "--label=GOT", "--label=WANT", "/dev/fd/3", "/dev/fd/4")
	cmd.ExtraFiles = []*os.File{gotR, wantR}
	stdout, err := cmd.StdoutPipe()
	stderr, err := cmd.StderrPipe()
	if err != nil {
		return diffSourceFilesFallback(got, want)
	}

	go func() {
		wantW.Write(want)
		wantW.Close()
	}()
	go func() {
		gotW.Write(got)
		gotW.Close()
	}()

	err = cmd.Start()
	if err != nil {
		return diffSourceFilesFallback(got, want)
	}

	outR := io.MultiReader(stdout, stderr)
	out, err := ioutil.ReadAll(outR)
	if err != nil {
		return diffSourceFilesFallback(got, want)
	}

	cmd.Wait() // not checking errors here because on failure we'll have stderr captured to return

	const noNewline = "\\ No newline at end of file\n"
	if bytes.HasSuffix(out, []byte(noNewline)) {
		out = out[:len(out)-len(noNewline)]
	}
	return out
}

func diffSourceFilesFallback(got, want []byte) []byte {
	var buf bytes.Buffer
	buf.WriteString("=== GOT ===\n")
	buf.Write(got)
	buf.WriteString("\n=== WANT ===\n")
	buf.Write(want)
	buf.WriteString("\n")
	return buf.Bytes()
}

var testProviders = map[string]providers.Factory{
	"test": providers.Factory(func() (providers.Interface, error) {
		p := &terraform.MockProvider{}
		p.GetSchemaReturn = &terraform.ProviderSchema{
			ResourceTypes: map[string]*configschema.Block{
				"test_instance": {
					Attributes: map[string]*configschema.Attribute{
						"id":              {Type: cty.String, Computed: true},
						"type":            {Type: cty.String, Optional: true},
						"image":           {Type: cty.String, Optional: true},
						"tags":            {Type: cty.Map(cty.String), Optional: true},
						"security_groups": {Type: cty.List(cty.String), Optional: true},
						"subnet_ids":      {Type: cty.Set(cty.String), Optional: true},
						"list_of_obj":     {Type: cty.List(cty.EmptyObject), Optional: true},
					},
					BlockTypes: map[string]*configschema.NestedBlock{
						"network": {
							Nesting: configschema.NestingSet,
							Block: configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"cidr_block":   {Type: cty.String, Optional: true},
									"subnet_cidrs": {Type: cty.Map(cty.String), Computed: true},
								},
								BlockTypes: map[string]*configschema.NestedBlock{
									"subnet": {
										Nesting: configschema.NestingSet,
										Block: configschema.Block{
											Attributes: map[string]*configschema.Attribute{
												"number": {Type: cty.Number, Required: true},
											},
										},
									},
								},
							},
						},
						"addresses": {
							Nesting: configschema.NestingSingle,
							Block: configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"ipv4": {Type: cty.String, Computed: true},
									"ipv6": {Type: cty.String, Computed: true},
								},
							},
						},
					},
				},
			},
		}
		return p, nil
	}),
	"terraform": providers.Factory(func() (providers.Interface, error) {
		p := &terraform.MockProvider{}
		p.GetSchemaReturn = &terraform.ProviderSchema{
			DataSources: map[string]*configschema.Block{
				"terraform_remote_state": {
					// This is just enough an approximation of the remote state
					// schema to check out reference upgrade logic. It is
					// intentionally not fully-comprehensive.
					Attributes: map[string]*configschema.Attribute{
						"backend": {Type: cty.String, Optional: true},
					},
				},
			},
		}
		return p, nil
	}),
	"aws": providers.Factory(func() (providers.Interface, error) {
		// This is here only so we can test the provisioner connection info
		// migration behavior, which is resource-type specific. Do not use
		// it in any other tests.
		p := &terraform.MockProvider{}
		p.GetSchemaReturn = &terraform.ProviderSchema{
			ResourceTypes: map[string]*configschema.Block{
				"aws_instance": {},
			},
		}
		return p, nil
	}),
}

var testProvisioners = map[string]provisioners.Factory{
	"test": provisioners.Factory(func() (provisioners.Interface, error) {
		p := &terraform.MockProvisioner{}
		p.GetSchemaResponse = provisioners.GetSchemaResponse{
			Provisioner: &configschema.Block{
				Attributes: map[string]*configschema.Attribute{
					"commands":    {Type: cty.List(cty.String), Optional: true},
					"interpreter": {Type: cty.String, Optional: true},
				},
			},
		}
		return p, nil
	}),
}

func init() {
	// Initialize the backends
	backendinit.Init(nil)
}

func TestMain(m *testing.M) {
	flag.Parse()
	if testing.Verbose() {
		// if we're verbose, use the logging requested by TF_LOG
		logging.SetOutput()
	} else {
		// otherwise silence all logs
		log.SetOutput(ioutil.Discard)
	}

	// We have fmt.Stringer implementations on lots of objects that hide
	// details that we very often want to see in tests, so we just disable
	// spew's use of String methods globally on the assumption that spew
	// usage implies an intent to see the raw values and ignore any
	// abstractions.
	spew.Config.DisableMethods = true

	os.Exit(m.Run())
}
