// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package tofu

import (
	"context"
	"testing"

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

	"github.com/opentofu/opentofu/internal/addrs"
	"github.com/opentofu/opentofu/internal/checks"
	"github.com/opentofu/opentofu/internal/configs/configschema"
	"github.com/opentofu/opentofu/internal/plans"
	"github.com/opentofu/opentofu/internal/providers"
	"github.com/opentofu/opentofu/internal/states"
	"github.com/opentofu/opentofu/internal/tfdiags"
)

// This file contains 'integration' tests for the OpenTofu check blocks.
//
// These tests could live in context_apply_test or context_apply2_test but given
// the size of those files, it makes sense to keep these check related tests
// grouped together.

type checksTestingStatus struct {
	status   checks.Status
	messages []string
}

func TestContextChecks(t *testing.T) {
	tests := map[string]struct {
		configs      map[string]string
		plan         map[string]checksTestingStatus
		planError    string
		planWarning  string
		apply        map[string]checksTestingStatus
		applyError   string
		applyWarning string
		state        *states.State
		provider     *MockProvider
		providerHook func(*MockProvider)
	}{
		"passing": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

check "passing" {
  data "checks_object" "positive" {}

  assert {
    condition     = data.checks_object.positive.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			plan: map[string]checksTestingStatus{
				"passing": {
					status: checks.StatusPass,
				},
			},
			apply: map[string]checksTestingStatus{
				"passing": {
					status: checks.StatusPass,
				},
			},
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						State: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(0),
						}),
					}
				},
			},
		},
		"failing": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

check "failing" {
  data "checks_object" "positive" {}

  assert {
    condition     = data.checks_object.positive.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			plan: map[string]checksTestingStatus{
				"failing": {
					status:   checks.StatusFail,
					messages: []string{"negative number"},
				},
			},
			planWarning: "Check block assertion failed: negative number",
			apply: map[string]checksTestingStatus{
				"failing": {
					status:   checks.StatusFail,
					messages: []string{"negative number"},
				},
			},
			applyWarning: "Check block assertion failed: negative number",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						State: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(-1),
						}),
					}
				},
			},
		},
		"mixed": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

check "failing" {
  data "checks_object" "neutral" {}

  assert {
    condition     = data.checks_object.neutral.number >= 0
    error_message = "negative number"
  }

  assert {
    condition = data.checks_object.neutral.number < 0
    error_message = "positive number"
  }
}
`,
			},
			plan: map[string]checksTestingStatus{
				"failing": {
					status:   checks.StatusFail,
					messages: []string{"positive number"},
				},
			},
			planWarning: "Check block assertion failed: positive number",
			apply: map[string]checksTestingStatus{
				"failing": {
					status:   checks.StatusFail,
					messages: []string{"positive number"},
				},
			},
			applyWarning: "Check block assertion failed: positive number",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						State: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(0),
						}),
					}
				},
			},
		},
		"nested data blocks reload during apply": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

data "checks_object" "data_block" {}

check "data_block" {
  assert {
    condition     = data.checks_object.data_block.number >= 0
    error_message = "negative number"
  }
}

check "nested_data_block" {
  data "checks_object" "nested_data_block" {}

  assert {
    condition     = data.checks_object.nested_data_block.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			plan: map[string]checksTestingStatus{
				"nested_data_block": {
					status:   checks.StatusFail,
					messages: []string{"negative number"},
				},
				"data_block": {
					status:   checks.StatusFail,
					messages: []string{"negative number"},
				},
			},
			planWarning: "2 warnings:\n\n- Check block assertion failed: negative number\n- Check block assertion failed: negative number",
			apply: map[string]checksTestingStatus{
				"nested_data_block": {
					status: checks.StatusPass,
				},
				"data_block": {
					status:   checks.StatusFail,
					messages: []string{"negative number"},
				},
			},
			applyWarning: "Check block assertion failed: negative number",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						State: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(-1),
						}),
					}
				},
			},
			providerHook: func(provider *MockProvider) {
				provider.ReadDataSourceFn = func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					// The data returned by the data sources are changing
					// between the plan and apply stage. The nested data block
					// will update to reflect this while the normal data block
					// will not detect the change.
					return providers.ReadDataSourceResponse{
						State: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(0),
						}),
					}
				}
			},
		},
		"returns unknown for unknown config": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

resource "checks_object" "resource_block" {}

check "resource_block" {
  data "checks_object" "data_block" {
    id = checks_object.resource_block.id
  }

  assert {
    condition = data.checks_object.data_block.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			plan: map[string]checksTestingStatus{
				"resource_block": {
					status: checks.StatusUnknown,
				},
			},
			planWarning: "Check block assertion known after apply: The condition could not be evaluated at this time, a result will be known when this plan is applied.",
			apply: map[string]checksTestingStatus{
				"resource_block": {
					status: checks.StatusPass,
				},
			},
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					ResourceTypes: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"id": {
										Type:     cty.String,
										Computed: true,
									},
								},
							},
						},
					},
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"id": {
										Type:     cty.String,
										Required: true,
									},
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				PlanResourceChangeFn: func(request providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
					return providers.PlanResourceChangeResponse{
						PlannedState: cty.ObjectVal(map[string]cty.Value{
							"id": cty.UnknownVal(cty.String),
						}),
					}
				},
				ApplyResourceChangeFn: func(request providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
					return providers.ApplyResourceChangeResponse{
						NewState: cty.ObjectVal(map[string]cty.Value{
							"id": cty.StringVal("7A9F887D-44C7-4281-80E5-578E41F99DFC"),
						}),
					}
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					values := request.Config.AsValueMap()
					if id, ok := values["id"]; ok {
						if id.IsKnown() && id.AsString() == "7A9F887D-44C7-4281-80E5-578E41F99DFC" {
							return providers.ReadDataSourceResponse{
								State: cty.ObjectVal(map[string]cty.Value{
									"id":     cty.StringVal("7A9F887D-44C7-4281-80E5-578E41F99DFC"),
									"number": cty.NumberIntVal(0),
								}),
							}
						}
					}

					return providers.ReadDataSourceResponse{
						Diagnostics: tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "shouldn't make it here", "really shouldn't make it here")},
					}
				},
			},
		},
		"failing nested data source doesn't block the plan": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

check "error" {
  data "checks_object" "data_block" {}

  assert {
    condition = data.checks_object.data_block.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			plan: map[string]checksTestingStatus{
				"error": {
					status: checks.StatusFail,
					messages: []string{
						"data source read failed: something bad happened and the provider couldn't read the data source",
					},
				},
			},
			planWarning: "data source read failed: something bad happened and the provider couldn't read the data source",
			apply: map[string]checksTestingStatus{
				"error": {
					status: checks.StatusFail,
					messages: []string{
						"data source read failed: something bad happened and the provider couldn't read the data source",
					},
				},
			},
			applyWarning: "data source read failed: something bad happened and the provider couldn't read the data source",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						Diagnostics: tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "data source read failed", "something bad happened and the provider couldn't read the data source")},
					}
				},
			},
		}, "failing nested data source should prevent checks from executing": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

resource "checks_object" "resource_block" {
  number = -1
}

check "error" {
  data "checks_object" "data_block" {}

  assert {
    condition = checks_object.resource_block.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			state: states.BuildState(func(state *states.SyncState) {
				state.SetResourceInstanceCurrent(
					addrs.Resource{
						Mode: addrs.ManagedResourceMode,
						Type: "checks_object",
						Name: "resource_block",
					}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
					&states.ResourceInstanceObjectSrc{
						Status:    states.ObjectReady,
						AttrsJSON: []byte(`{"number": -1}`),
					},
					addrs.AbsProviderConfig{
						Provider: addrs.NewDefaultProvider("test"),
						Module:   addrs.RootModule,
					}, addrs.NoKey)
			}),
			plan: map[string]checksTestingStatus{
				"error": {
					status: checks.StatusFail,
					messages: []string{
						"data source read failed: something bad happened and the provider couldn't read the data source",
					},
				},
			},
			planWarning: "data source read failed: something bad happened and the provider couldn't read the data source",
			apply: map[string]checksTestingStatus{
				"error": {
					status: checks.StatusFail,
					messages: []string{
						"data source read failed: something bad happened and the provider couldn't read the data source",
					},
				},
			},
			applyWarning: "data source read failed: something bad happened and the provider couldn't read the data source",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					ResourceTypes: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Required: true,
									},
								},
							},
						},
					},
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				PlanResourceChangeFn: func(request providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
					return providers.PlanResourceChangeResponse{
						PlannedState: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(-1),
						}),
					}
				},
				ApplyResourceChangeFn: func(request providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
					return providers.ApplyResourceChangeResponse{
						NewState: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(-1),
						}),
					}
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						Diagnostics: tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "data source read failed", "something bad happened and the provider couldn't read the data source")},
					}
				},
			},
		},
		"check failing in state and passing after plan and apply": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

resource "checks_object" "resource" {
  number = 0
}

check "passing" {
  assert {
    condition     = checks_object.resource.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			state: states.BuildState(func(state *states.SyncState) {
				state.SetResourceInstanceCurrent(
					addrs.Resource{
						Mode: addrs.ManagedResourceMode,
						Type: "checks_object",
						Name: "resource",
					}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
					&states.ResourceInstanceObjectSrc{
						Status:    states.ObjectReady,
						AttrsJSON: []byte(`{"number": -1}`),
					},
					addrs.AbsProviderConfig{
						Provider: addrs.NewDefaultProvider("test"),
						Module:   addrs.RootModule,
					}, addrs.NoKey)
			}),
			plan: map[string]checksTestingStatus{
				"passing": {
					status: checks.StatusPass,
				},
			},
			apply: map[string]checksTestingStatus{
				"passing": {
					status: checks.StatusPass,
				},
			},
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					ResourceTypes: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Required: true,
									},
								},
							},
						},
					},
				},
				PlanResourceChangeFn: func(request providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
					return providers.PlanResourceChangeResponse{
						PlannedState: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(0),
						}),
					}
				},
				ApplyResourceChangeFn: func(request providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
					return providers.ApplyResourceChangeResponse{
						NewState: cty.ObjectVal(map[string]cty.Value{
							"number": cty.NumberIntVal(0),
						}),
					}
				},
			},
		},
		"failing data source does block the plan": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

data "checks_object" "data_block" {}

check "error" {
  assert {
    condition = data.checks_object.data_block.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			planError: "data source read failed: something bad happened and the provider couldn't read the data source",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"number": {
										Type:     cty.Number,
										Computed: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					return providers.ReadDataSourceResponse{
						Diagnostics: tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "data source read failed", "something bad happened and the provider couldn't read the data source")},
					}
				},
			},
		},
		"invalid reference into check block": {
			configs: map[string]string{
				"main.tf": `
provider "checks" {}

data "checks_object" "data_block" {
  id = data.checks_object.nested_data_block.id
}

check "error" {
  data "checks_object" "nested_data_block" {}

  assert {
    condition = data.checks_object.data_block.number >= 0
    error_message = "negative number"
  }
}
`,
			},
			planError: "Reference to scoped resource: The referenced data resource \"checks_object\" \"nested_data_block\" is not available from this context.",
			provider: &MockProvider{
				Meta: "checks",
				GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{
					DataSources: map[string]providers.Schema{
						"checks_object": {
							Block: &configschema.Block{
								Attributes: map[string]*configschema.Attribute{
									"id": {
										Type:     cty.String,
										Computed: true,
										Optional: true,
									},
								},
							},
						},
					},
				},
				ReadDataSourceFn: func(request providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
					input := request.Config.AsValueMap()
					if _, ok := input["id"]; ok {
						return providers.ReadDataSourceResponse{
							State: request.Config,
						}
					}

					return providers.ReadDataSourceResponse{
						State: cty.ObjectVal(map[string]cty.Value{
							"id": cty.UnknownVal(cty.String),
						}),
					}
				},
			},
		},
	}
	for name, test := range tests {
		t.Run(name, func(t *testing.T) {
			configs := testModuleInline(t, test.configs)
			ctx := testContext2(t, &ContextOpts{
				Providers: map[addrs.Provider]providers.Factory{
					addrs.NewDefaultProvider(test.provider.Meta.(string)): testProviderFuncFixed(test.provider),
				},
			})

			initialState := states.NewState()
			if test.state != nil {
				initialState = test.state
			}

			plan, diags := ctx.Plan(context.Background(), configs, initialState, &PlanOpts{
				Mode: plans.NormalMode,
			})
			if validateCheckDiagnostics(t, "planning", test.planWarning, test.planError, diags) {
				return
			}
			validateCheckResults(t, "planning", test.plan, plan.Checks)

			if test.providerHook != nil {
				// This gives an opportunity to change the behaviour of the
				// provider between the plan and apply stages.
				test.providerHook(test.provider)
			}

			state, diags := ctx.Apply(context.Background(), plan, configs)
			if validateCheckDiagnostics(t, "apply", test.applyWarning, test.applyError, diags) {
				return
			}
			validateCheckResults(t, "apply", test.apply, state.CheckResults)
		})
	}
}

func validateCheckDiagnostics(t *testing.T, stage string, expectedWarning, expectedError string, actual tfdiags.Diagnostics) bool {
	if expectedError != "" {
		if !actual.HasErrors() {
			t.Errorf("expected %s to error with \"%s\", but no errors were returned", stage, expectedError)
		} else if expectedError != actual.Err().Error() {
			t.Errorf("expected %s to error with \"%s\" but found \"%s\"", stage, expectedError, actual.Err())
		}

		// If we expected an error then we won't finish the rest of the test.
		return true
	}

	if expectedWarning != "" {
		warnings := actual.ErrWithWarnings()
		if actual.ErrWithWarnings() == nil {
			t.Errorf("expected %s to warn with \"%s\", but no errors were returned", stage, expectedWarning)
		} else if expectedWarning != warnings.Error() {
			t.Errorf("expected %s to warn with \"%s\" but found \"%s\"", stage, expectedWarning, warnings)
		}
	} else {
		if actual.ErrWithWarnings() != nil {
			t.Errorf("expected %s to produce no diagnostics but found \"%s\"", stage, actual.ErrWithWarnings())
		}
	}

	assertNoErrors(t, actual)
	return false
}

func validateCheckResults(t *testing.T, stage string, expected map[string]checksTestingStatus, actual *states.CheckResults) {

	// Just a quick sanity check that the plan or apply process didn't create
	// some non-existent checks.
	if len(expected) != len(actual.ConfigResults.Keys()) {
		t.Errorf("expected %d check results but found %d after %s", len(expected), len(actual.ConfigResults.Keys()), stage)
	}

	// Now, lets make sure the checks all match what we expect.
	for check, want := range expected {
		results := actual.GetObjectResult(addrs.Check{
			Name: check,
		}.Absolute(addrs.RootModuleInstance))

		if results.Status != want.status {
			t.Errorf("%s: wanted %s but got %s after %s", check, want.status, results.Status, stage)
		}

		if len(want.messages) != len(results.FailureMessages) {
			t.Errorf("%s: expected %d failure messages but had %d after %s", check, len(want.messages), len(results.FailureMessages), stage)
		}

		max := len(want.messages)
		if len(results.FailureMessages) > max {
			max = len(results.FailureMessages)
		}

		for ix := 0; ix < max; ix++ {
			var expected, actual string
			if ix < len(want.messages) {
				expected = want.messages[ix]
			}
			if ix < len(results.FailureMessages) {
				actual = results.FailureMessages[ix]
			}

			// Order matters!
			if actual != expected {
				t.Errorf("%s: expected failure message at %d to be \"%s\" but was \"%s\" after %s", check, ix, expected, actual, stage)
			}
		}

	}
}
