// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1

package command

import (
	"context"
	"os"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/hashicorp/cli"
	"github.com/zclconf/go-cty/cty"

	"github.com/hashicorp/terraform/internal/addrs"
	"github.com/hashicorp/terraform/internal/backend"
	backendInit "github.com/hashicorp/terraform/internal/backend/init"
	"github.com/hashicorp/terraform/internal/configs/configload"
	"github.com/hashicorp/terraform/internal/configs/configschema"
	"github.com/hashicorp/terraform/internal/initwd"
	"github.com/hashicorp/terraform/internal/plans"
	"github.com/hashicorp/terraform/internal/providers"
	"github.com/hashicorp/terraform/internal/registry"
	"github.com/hashicorp/terraform/internal/states"
	"github.com/hashicorp/terraform/internal/terminal"
)

func TestGraph_planPhase(t *testing.T) {
	td := t.TempDir()
	testCopyDir(t, testFixturePath("graph"), td)
	t.Chdir(td)

	ui := new(cli.MockUi)
	streams, closeStreams := terminal.StreamsForTesting(t)
	c := &GraphCommand{
		Meta: Meta{
			testingOverrides: metaOverridesForProvider(applyFixtureProvider()),
			Ui:               ui,
			Streams:          streams,
		},
	}

	args := []string{"-type=plan"}
	if code := c.Run(args); code != 0 {
		t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
	}

	output := closeStreams(t)
	if !strings.Contains(output.Stdout(), `provider[\"registry.terraform.io/hashicorp/test\"]`) {
		t.Fatalf("doesn't look like digraph:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr())
	}
}

func TestGraph_cyclic(t *testing.T) {
	td := t.TempDir()
	testCopyDir(t, testFixturePath("graph-cyclic"), td)
	t.Chdir(td)

	tests := []struct {
		name     string
		args     []string
		expected string

		// The cyclic errors do not maintain a consistent order, so we can't
		// predict the exact output. We'll just check that the error messages
		// are present for the things we know are cyclic.
		errors []string
	}{
		{
			name: "plan",
			args: []string{"-type=plan"},
			errors: []string{`Error: Cycle: test_instance.`,
				`Error: Cycle: local.`},
		},
		{
			name: "plan with -draw-cycles option",
			args: []string{"-draw-cycles", "-type=plan"},
			expected: `digraph {
	compound = "true"
	newrank = "true"
	subgraph "root" {
		"[root] provider[\"registry.terraform.io/hashicorp/test\"]" [label = "provider[\"registry.terraform.io/hashicorp/test\"]", shape = "diamond"]
		"[root] test_instance.bar (expand)" [label = "test_instance.bar", shape = "box"]
		"[root] test_instance.foo (expand)" [label = "test_instance.foo", shape = "box"]
		"[root] local.test1 (expand)" -> "[root] local.test2 (expand)"
		"[root] local.test2 (expand)" -> "[root] local.test1 (expand)"
		"[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]"
		"[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.bar (expand)"
		"[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.foo (expand)"
		"[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)"
		"[root] test_instance.bar (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]"
		"[root] test_instance.bar (expand)" -> "[root] test_instance.foo (expand)" [color = "red", penwidth = "2.0"]
		"[root] test_instance.foo (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]"
		"[root] test_instance.foo (expand)" -> "[root] test_instance.bar (expand)" [color = "red", penwidth = "2.0"]
	}
}`,
		},
		{
			name: "apply",
			args: []string{"-type=apply"},
			// The cyclic errors do not maintain a consistent order, so we can't
			// predict the exact output. We'll just check that the error messages
			// are present for the things we know are cyclic.
			errors: []string{`Error: Cycle: test_instance.`,
				`Error: Cycle: local.`},
		},
		{
			name: "apply with -draw-cycles option",
			args: []string{"-draw-cycles", "-type=apply"},
			expected: `digraph {
	compound = "true"
	newrank = "true"
	subgraph "root" {
		"[root] provider[\"registry.terraform.io/hashicorp/test\"]" [label = "provider[\"registry.terraform.io/hashicorp/test\"]", shape = "diamond"]
		"[root] test_instance.bar (expand)" [label = "test_instance.bar", shape = "box"]
		"[root] test_instance.foo (expand)" [label = "test_instance.foo", shape = "box"]
		"[root] local.test1 (expand)" -> "[root] local.test2 (expand)"
		"[root] local.test2 (expand)" -> "[root] local.test1 (expand)"
		"[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]"
		"[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.bar (expand)"
		"[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)" -> "[root] test_instance.foo (expand)"
		"[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"] (close)"
		"[root] test_instance.bar (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]"
		"[root] test_instance.bar (expand)" -> "[root] test_instance.foo (expand)" [color = "red", penwidth = "2.0"]
		"[root] test_instance.foo (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/test\"]"
		"[root] test_instance.foo (expand)" -> "[root] test_instance.bar (expand)" [color = "red", penwidth = "2.0"]
	}
}`,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ui := new(cli.MockUi)
			streams, closeStreams := terminal.StreamsForTesting(t)
			c := &GraphCommand{
				Meta: Meta{
					testingOverrides: metaOverridesForProvider(applyFixtureProvider()),
					Ui:               ui,
					Streams:          streams,
				},
			}

			code := c.Run(tt.args)
			// If we expect errors, make sure they are present
			if len(tt.errors) > 0 {
				if code == 0 {
					t.Fatalf("expected error, got none")
				}
				got := strings.TrimSpace(ui.ErrorWriter.String())
				for _, err := range tt.errors {
					if !strings.Contains(got, err) {
						t.Fatalf("expected error:\n%s\n\nactual error:\n%s", err, got)
					}
				}
				return
			}

			// If we don't expect errors, make sure the command ran successfully
			if code != 0 {
				t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
			}
			output := closeStreams(t)
			if strings.TrimSpace(output.Stdout()) != strings.TrimSpace(tt.expected) {
				t.Fatalf("expected dot graph to match:\n%s", cmp.Diff(output.Stdout(), tt.expected))
			}

		})
	}
}

func TestGraph_multipleArgs(t *testing.T) {
	ui := new(cli.MockUi)
	c := &GraphCommand{
		Meta: Meta{
			testingOverrides: metaOverridesForProvider(applyFixtureProvider()),
			Ui:               ui,
		},
	}

	args := []string{
		"bad",
		"bad",
	}
	if code := c.Run(args); code != 1 {
		t.Fatalf("bad: \n%s", ui.OutputWriter.String())
	}
}

func TestGraph_noConfig(t *testing.T) {
	td := t.TempDir()
	os.MkdirAll(td, 0755)
	t.Chdir(td)

	streams, closeStreams := terminal.StreamsForTesting(t)
	defer closeStreams(t)
	ui := cli.NewMockUi()
	c := &GraphCommand{
		Meta: Meta{
			testingOverrides: metaOverridesForProvider(applyFixtureProvider()),
			Ui:               ui,
			Streams:          streams,
		},
	}

	// Running the graph command without a config should not panic,
	// but this may be an error at some point in the future.
	args := []string{"-type", "apply"}
	if code := c.Run(args); code != 0 {
		t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
	}
}

func TestGraph_resourcesOnly(t *testing.T) {
	wd := tempWorkingDirFixture(t, "graph-interesting")
	t.Chdir(wd.RootModuleDir())

	// The graph-interesting fixture has a child module, so we'll need to
	// run the module installer just to get the working directory set up
	// properly, as if the user has run "terraform init". This is really
	// just building the working directory's index of module directories.
	loader, cleanupLoader := configload.NewLoaderForTests(t)
	t.Cleanup(cleanupLoader)
	err := os.MkdirAll(".terraform/modules", 0700)
	if err != nil {
		t.Fatal(err)
	}
	inst := initwd.NewModuleInstaller(".terraform/modules", loader, registry.NewClient(nil, nil), nil)
	_, instDiags := inst.InstallModules(context.Background(), ".", "tests", true, false, initwd.ModuleInstallHooksImpl{})
	if instDiags.HasErrors() {
		t.Fatal(instDiags.Err())
	}

	p := testProvider()
	p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{
		ResourceTypes: map[string]providers.Schema{
			"foo": {
				Body: &configschema.Block{
					Attributes: map[string]*configschema.Attribute{
						"arg": {
							Type:     cty.String,
							Optional: true,
						},
					},
				},
			},
		},
	}

	ui := cli.NewMockUi()
	streams, closeStreams := terminal.StreamsForTesting(t)
	c := &GraphCommand{
		Meta: Meta{
			testingOverrides: &testingOverrides{
				Providers: map[addrs.Provider]providers.Factory{
					addrs.NewDefaultProvider("foo"): providers.FactoryFixed(p),
				},
			},
			Ui:      ui,
			Streams: streams,
		},
	}

	// A "resources only" graph is the default behavior, with no extra arguments.
	args := []string{}
	if code := c.Run(args); code != 0 {
		output := closeStreams(t)
		t.Fatalf("unexpected error: \n%s", output.Stderr())
	}

	output := closeStreams(t)
	gotGraph := strings.TrimSpace(output.Stdout())
	wantGraph := strings.TrimSpace(`
digraph G {
  rankdir = "RL";
  node [shape = rect, fontname = "sans-serif"];
  "foo.bar" [label="foo.bar"];
  "foo.baz" [label="foo.baz"];
  "foo.boop" [label="foo.boop"];
  subgraph "cluster_module.child" {
    label = "module.child"
    fontname = "sans-serif"
    "module.child.foo.bleep" [label="foo.bleep"];
  }
  "foo.baz" -> "foo.bar";
  "foo.boop" -> "module.child.foo.bleep";
  "module.child.foo.bleep" -> "foo.bar";
}
`)
	if diff := cmp.Diff(wantGraph, gotGraph); diff != "" {
		t.Fatalf("wrong result\n%s", diff)
	}
}

func TestGraph_applyPhaseSavedPlan(t *testing.T) {
	tmp := t.TempDir()
	t.Chdir(tmp)

	emptyObj, err := plans.NewDynamicValue(cty.EmptyObjectVal, cty.EmptyObject)
	if err != nil {
		t.Fatal(err)
	}

	nullEmptyObj, err := plans.NewDynamicValue(cty.NullVal((cty.EmptyObject)), cty.EmptyObject)
	if err != nil {
		t.Fatal(err)
	}

	plan := &plans.Plan{
		Changes: plans.NewChangesSrc(),
	}
	plan.Changes.Resources = append(plan.Changes.Resources, &plans.ResourceInstanceChangeSrc{
		Addr: addrs.Resource{
			Mode: addrs.ManagedResourceMode,
			Type: "test_instance",
			Name: "bar",
		}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
		ChangeSrc: plans.ChangeSrc{
			Action: plans.Delete,
			Before: emptyObj,
			After:  nullEmptyObj,
		},
		ProviderAddr: addrs.AbsProviderConfig{
			Provider: addrs.NewDefaultProvider("test"),
			Module:   addrs.RootModule,
		},
	})

	plan.Backend = &plans.Backend{
		// Doesn't actually matter since we aren't going to activate the backend
		// for this command anyway, but we need something here for the plan
		// file writer to succeed.
		Type:      "placeholder",
		Config:    emptyObj,
		Workspace: "default",
	}
	_, configSnap := testModuleWithSnapshot(t, "graph")

	planPath := testPlanFile(t, configSnap, states.NewState(), plan)

	streams, closeStreams := terminal.StreamsForTesting(t)
	ui := cli.NewMockUi()
	c := &GraphCommand{
		Meta: Meta{
			testingOverrides: metaOverridesForProvider(applyFixtureProvider()),
			Ui:               ui,
			Streams:          streams,
		},
	}

	args := []string{
		"-plan", planPath,
	}
	if code := c.Run(args); code != 0 {
		t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
	}

	output := closeStreams(t)
	if !strings.Contains(output.Stdout(), `provider[\"registry.terraform.io/hashicorp/test\"]`) {
		t.Fatalf("doesn't look like digraph:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr())
	}
}

func TestGraph_constVariable(t *testing.T) {
	t.Run("missing value", func(t *testing.T) {
		wd := tempWorkingDirFixture(t, "dynamic-module-sources/command-with-const-var")
		t.Chdir(wd.RootModuleDir())

		ui := cli.NewMockUi()
		streams, closeStreams := terminal.StreamsForTesting(t)
		c := &GraphCommand{
			Meta: Meta{
				testingOverrides: metaOverridesForProvider(testProvider()),
				Ui:               ui,
				Streams:          streams,
				WorkingDir:       wd,
			},
		}

		args := []string{}
		if code := c.Run(args); code != 1 {
			output := closeStreams(t)
			t.Fatalf("expected exit status 1\nstdout:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr())
		}

		if !strings.Contains(ui.ErrorWriter.String(), "No value for required variable") {
			t.Fatalf("expected missing variable error, got:\n%s", ui.ErrorWriter.String())
		}
		closeStreams(t)
	})

	t.Run("value via cli", func(t *testing.T) {
		wd := tempWorkingDirFixture(t, "dynamic-module-sources/command-with-const-var")
		t.Chdir(wd.RootModuleDir())

		ui := cli.NewMockUi()
		streams, closeStreams := terminal.StreamsForTesting(t)
		c := &GraphCommand{
			Meta: Meta{
				testingOverrides: metaOverridesForProvider(testProvider()),
				Ui:               ui,
				Streams:          streams,
				WorkingDir:       wd,
			},
		}

		args := []string{"-var", "module_name=child"}
		if code := c.Run(args); code != 0 {
			output := closeStreams(t)
			t.Fatalf("bad:\nstdout:\n%s\n\nstderr:\n%s", output.Stdout(), output.Stderr())
		}

		output := closeStreams(t)
		wantOutput := []string{
			`"module.child.test_instance.test" [label="test_instance.test"]`,
		}
		for _, want := range wantOutput {
			if !strings.Contains(output.Stdout(), want) {
				t.Fatalf("output missing %s:\n%s", want, output.Stdout())
			}
		}
	})

	t.Run("value via backend", func(t *testing.T) {
		mockBackend := TestNewVariableBackend(map[string]string{
			"module_name": "child",
		})
		backendInit.Set("local-vars", func() backend.Backend { return mockBackend })
		defer backendInit.Set("local-vars", nil)

		wd := tempWorkingDirFixture(t, "dynamic-module-sources/command-with-const-var-backend")
		t.Chdir(wd.RootModuleDir())

		ui := cli.NewMockUi()
		streams, closeStreams := terminal.StreamsForTesting(t)
		c := &GraphCommand{
			Meta: Meta{
				testingOverrides: metaOverridesForProvider(testProvider()),
				Ui:               ui,
				Streams:          streams,
				WorkingDir:       wd,
			},
		}

		args := []string{}
		if code := c.Run(args); code != 0 {
			output := closeStreams(t)
			stderr := ui.ErrorWriter.String()
			t.Fatalf("bad:\nstdout:\n%s\n\nstderr:\n%s", output.Stdout(), stderr)
		}

		output := closeStreams(t)
		wantOutput := []string{
			`"module.child.test_instance.test" [label="test_instance.test"]`,
		}
		for _, want := range wantOutput {
			if !strings.Contains(output.Stdout(), want) {
				t.Fatalf("output missing %s:\n%s", want, output.Stdout())
			}
		}
	})
}
