package terraform

import (
	"encoding/json"
	"fmt"
	"sync"

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

	"github.com/hashicorp/terraform-plugin-sdk/internal/configs/hcl2shim"
	"github.com/hashicorp/terraform-plugin-sdk/internal/providers"
	"github.com/hashicorp/terraform-plugin-sdk/tfdiags"
)

var _ providers.Interface = (*MockProvider)(nil)

// MockProvider implements providers.Interface but mocks out all the
// calls for testing purposes.
type MockProvider struct {
	sync.Mutex

	// Anything you want, in case you need to store extra data with the mock.
	Meta interface{}

	GetSchemaCalled bool
	GetSchemaReturn *ProviderSchema // This is using ProviderSchema directly rather than providers.GetSchemaResponse for compatibility with old tests

	PrepareProviderConfigCalled   bool
	PrepareProviderConfigResponse providers.PrepareProviderConfigResponse
	PrepareProviderConfigRequest  providers.PrepareProviderConfigRequest
	PrepareProviderConfigFn       func(providers.PrepareProviderConfigRequest) providers.PrepareProviderConfigResponse

	ValidateResourceTypeConfigCalled   bool
	ValidateResourceTypeConfigTypeName string
	ValidateResourceTypeConfigResponse providers.ValidateResourceTypeConfigResponse
	ValidateResourceTypeConfigRequest  providers.ValidateResourceTypeConfigRequest
	ValidateResourceTypeConfigFn       func(providers.ValidateResourceTypeConfigRequest) providers.ValidateResourceTypeConfigResponse

	ValidateDataSourceConfigCalled   bool
	ValidateDataSourceConfigTypeName string
	ValidateDataSourceConfigResponse providers.ValidateDataSourceConfigResponse
	ValidateDataSourceConfigRequest  providers.ValidateDataSourceConfigRequest
	ValidateDataSourceConfigFn       func(providers.ValidateDataSourceConfigRequest) providers.ValidateDataSourceConfigResponse

	UpgradeResourceStateCalled   bool
	UpgradeResourceStateTypeName string
	UpgradeResourceStateResponse providers.UpgradeResourceStateResponse
	UpgradeResourceStateRequest  providers.UpgradeResourceStateRequest
	UpgradeResourceStateFn       func(providers.UpgradeResourceStateRequest) providers.UpgradeResourceStateResponse

	ConfigureCalled   bool
	ConfigureResponse providers.ConfigureResponse
	ConfigureRequest  providers.ConfigureRequest
	ConfigureNewFn    func(providers.ConfigureRequest) providers.ConfigureResponse // Named ConfigureNewFn so we can still have the legacy ConfigureFn declared below

	StopCalled   bool
	StopFn       func() error
	StopResponse error

	ReadResourceCalled   bool
	ReadResourceResponse providers.ReadResourceResponse
	ReadResourceRequest  providers.ReadResourceRequest
	ReadResourceFn       func(providers.ReadResourceRequest) providers.ReadResourceResponse

	PlanResourceChangeCalled   bool
	PlanResourceChangeResponse providers.PlanResourceChangeResponse
	PlanResourceChangeRequest  providers.PlanResourceChangeRequest
	PlanResourceChangeFn       func(providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse

	ApplyResourceChangeCalled   bool
	ApplyResourceChangeResponse providers.ApplyResourceChangeResponse
	ApplyResourceChangeRequest  providers.ApplyResourceChangeRequest
	ApplyResourceChangeFn       func(providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse

	ImportResourceStateCalled   bool
	ImportResourceStateResponse providers.ImportResourceStateResponse
	ImportResourceStateRequest  providers.ImportResourceStateRequest
	ImportResourceStateFn       func(providers.ImportResourceStateRequest) providers.ImportResourceStateResponse
	// Legacy return type for existing tests, which will be shimmed into an
	// ImportResourceStateResponse if set
	ImportStateReturn []*InstanceState

	ReadDataSourceCalled   bool
	ReadDataSourceResponse providers.ReadDataSourceResponse
	ReadDataSourceRequest  providers.ReadDataSourceRequest
	ReadDataSourceFn       func(providers.ReadDataSourceRequest) providers.ReadDataSourceResponse

	CloseCalled bool
	CloseError  error

	// Legacy callbacks: if these are set, we will shim incoming calls for
	// new-style methods to these old-fashioned terraform.ResourceProvider
	// mock callbacks, for the benefit of older tests that were written against
	// the old mock API.
	ValidateFn  func(c *ResourceConfig) (ws []string, es []error)
	ConfigureFn func(c *ResourceConfig) error
	DiffFn      func(info *InstanceInfo, s *InstanceState, c *ResourceConfig) (*InstanceDiff, error)
	ApplyFn     func(info *InstanceInfo, s *InstanceState, d *InstanceDiff) (*InstanceState, error)
}

func (p *MockProvider) GetSchema() providers.GetSchemaResponse {
	p.Lock()
	defer p.Unlock()
	p.GetSchemaCalled = true
	return p.getSchema()
}

func (p *MockProvider) getSchema() providers.GetSchemaResponse {
	// This version of getSchema doesn't do any locking, so it's suitable to
	// call from other methods of this mock as long as they are already
	// holding the lock.

	ret := providers.GetSchemaResponse{
		Provider:      providers.Schema{},
		DataSources:   map[string]providers.Schema{},
		ResourceTypes: map[string]providers.Schema{},
	}
	if p.GetSchemaReturn != nil {
		ret.Provider.Block = p.GetSchemaReturn.Provider
		for n, s := range p.GetSchemaReturn.DataSources {
			ret.DataSources[n] = providers.Schema{
				Block: s,
			}
		}
		for n, s := range p.GetSchemaReturn.ResourceTypes {
			ret.ResourceTypes[n] = providers.Schema{
				Version: int64(p.GetSchemaReturn.ResourceTypeSchemaVersions[n]),
				Block:   s,
			}
		}
	}

	return ret
}

func (p *MockProvider) PrepareProviderConfig(r providers.PrepareProviderConfigRequest) providers.PrepareProviderConfigResponse {
	p.Lock()
	defer p.Unlock()

	p.PrepareProviderConfigCalled = true
	p.PrepareProviderConfigRequest = r
	if p.PrepareProviderConfigFn != nil {
		return p.PrepareProviderConfigFn(r)
	}
	return p.PrepareProviderConfigResponse
}

func (p *MockProvider) ValidateResourceTypeConfig(r providers.ValidateResourceTypeConfigRequest) providers.ValidateResourceTypeConfigResponse {
	p.Lock()
	defer p.Unlock()

	p.ValidateResourceTypeConfigCalled = true
	p.ValidateResourceTypeConfigRequest = r

	if p.ValidateFn != nil {
		resp := p.getSchema()
		schema := resp.Provider.Block
		rc := NewResourceConfigShimmed(r.Config, schema)
		warns, errs := p.ValidateFn(rc)
		ret := providers.ValidateResourceTypeConfigResponse{}
		for _, warn := range warns {
			ret.Diagnostics = ret.Diagnostics.Append(tfdiags.SimpleWarning(warn))
		}
		for _, err := range errs {
			ret.Diagnostics = ret.Diagnostics.Append(err)
		}
	}
	if p.ValidateResourceTypeConfigFn != nil {
		return p.ValidateResourceTypeConfigFn(r)
	}

	return p.ValidateResourceTypeConfigResponse
}

func (p *MockProvider) ValidateDataSourceConfig(r providers.ValidateDataSourceConfigRequest) providers.ValidateDataSourceConfigResponse {
	p.Lock()
	defer p.Unlock()

	p.ValidateDataSourceConfigCalled = true
	p.ValidateDataSourceConfigRequest = r

	if p.ValidateDataSourceConfigFn != nil {
		return p.ValidateDataSourceConfigFn(r)
	}

	return p.ValidateDataSourceConfigResponse
}

func (p *MockProvider) UpgradeResourceState(r providers.UpgradeResourceStateRequest) providers.UpgradeResourceStateResponse {
	p.Lock()
	defer p.Unlock()

	schemas := p.getSchema()
	schema := schemas.ResourceTypes[r.TypeName]
	schemaType := schema.Block.ImpliedType()

	p.UpgradeResourceStateCalled = true
	p.UpgradeResourceStateRequest = r

	if p.UpgradeResourceStateFn != nil {
		return p.UpgradeResourceStateFn(r)
	}

	resp := p.UpgradeResourceStateResponse

	if resp.UpgradedState == cty.NilVal {
		switch {
		case r.RawStateFlatmap != nil:
			v, err := hcl2shim.HCL2ValueFromFlatmap(r.RawStateFlatmap, schemaType)
			if err != nil {
				resp.Diagnostics = resp.Diagnostics.Append(err)
				return resp
			}
			resp.UpgradedState = v
		case len(r.RawStateJSON) > 0:
			v, err := ctyjson.Unmarshal(r.RawStateJSON, schemaType)

			if err != nil {
				resp.Diagnostics = resp.Diagnostics.Append(err)
				return resp
			}
			resp.UpgradedState = v
		}
	}
	return resp
}

func (p *MockProvider) Configure(r providers.ConfigureRequest) providers.ConfigureResponse {
	p.Lock()
	defer p.Unlock()

	p.ConfigureCalled = true
	p.ConfigureRequest = r

	if p.ConfigureFn != nil {
		resp := p.getSchema()
		schema := resp.Provider.Block
		rc := NewResourceConfigShimmed(r.Config, schema)
		ret := providers.ConfigureResponse{}

		err := p.ConfigureFn(rc)
		if err != nil {
			ret.Diagnostics = ret.Diagnostics.Append(err)
		}
		return ret
	}
	if p.ConfigureNewFn != nil {
		return p.ConfigureNewFn(r)
	}

	return p.ConfigureResponse
}

func (p *MockProvider) Stop() error {
	// We intentionally don't lock in this one because the whole point of this
	// method is to be called concurrently with another operation that can
	// be cancelled.  The provider itself is responsible for handling
	// any concurrency concerns in this case.

	p.StopCalled = true
	if p.StopFn != nil {
		return p.StopFn()
	}

	return p.StopResponse
}

func (p *MockProvider) ReadResource(r providers.ReadResourceRequest) providers.ReadResourceResponse {
	p.Lock()
	defer p.Unlock()

	p.ReadResourceCalled = true
	p.ReadResourceRequest = r

	if p.ReadResourceFn != nil {
		return p.ReadResourceFn(r)
	}

	// make sure the NewState fits the schema
	newState, err := p.GetSchemaReturn.ResourceTypes[r.TypeName].CoerceValue(p.ReadResourceResponse.NewState)
	if err != nil {
		panic(err)
	}
	resp := p.ReadResourceResponse
	resp.NewState = newState

	return resp
}

func (p *MockProvider) PlanResourceChange(r providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
	p.Lock()
	defer p.Unlock()

	p.PlanResourceChangeCalled = true
	p.PlanResourceChangeRequest = r

	if p.DiffFn != nil {
		ps := p.getSchema()
		if ps.ResourceTypes == nil || ps.ResourceTypes[r.TypeName].Block == nil {
			return providers.PlanResourceChangeResponse{
				Diagnostics: tfdiags.Diagnostics(nil).Append(fmt.Printf("mock provider has no schema for resource type %s", r.TypeName)),
			}
		}
		schema := ps.ResourceTypes[r.TypeName].Block
		info := &InstanceInfo{
			Type: r.TypeName,
		}
		priorState := NewInstanceStateShimmedFromValue(r.PriorState, 0)
		cfg := NewResourceConfigShimmed(r.Config, schema)

		legacyDiff, err := p.DiffFn(info, priorState, cfg)

		var res providers.PlanResourceChangeResponse
		res.PlannedState = r.ProposedNewState
		if err != nil {
			res.Diagnostics = res.Diagnostics.Append(err)
		}
		if legacyDiff != nil {
			newVal, err := legacyDiff.ApplyToValue(r.PriorState, schema)
			if err != nil {
				res.Diagnostics = res.Diagnostics.Append(err)
			}

			res.PlannedState = newVal

			var requiresNew []string
			for attr, d := range legacyDiff.Attributes {
				if d.RequiresNew {
					requiresNew = append(requiresNew, attr)
				}
			}
			requiresReplace, err := hcl2shim.RequiresReplace(requiresNew, schema.ImpliedType())
			if err != nil {
				res.Diagnostics = res.Diagnostics.Append(err)
			}
			res.RequiresReplace = requiresReplace
		}
		return res
	}
	if p.PlanResourceChangeFn != nil {
		return p.PlanResourceChangeFn(r)
	}

	return p.PlanResourceChangeResponse
}

func (p *MockProvider) ApplyResourceChange(r providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
	p.Lock()
	p.ApplyResourceChangeCalled = true
	p.ApplyResourceChangeRequest = r
	p.Unlock()

	if p.ApplyFn != nil {
		// ApplyFn is a special callback fashioned after our old provider
		// interface, which expected to be given an actual diff rather than
		// separate old/new values to apply. Therefore we need to approximate
		// a diff here well enough that _most_ of our legacy ApplyFns in old
		// tests still see the behavior they are expecting. New tests should
		// not use this, and should instead use ApplyResourceChangeFn directly.
		providerSchema := p.getSchema()
		schema, ok := providerSchema.ResourceTypes[r.TypeName]
		if !ok {
			return providers.ApplyResourceChangeResponse{
				Diagnostics: tfdiags.Diagnostics(nil).Append(fmt.Errorf("no mocked schema available for resource type %s", r.TypeName)),
			}
		}

		info := &InstanceInfo{
			Type: r.TypeName,
		}

		priorVal := r.PriorState
		plannedVal := r.PlannedState
		priorMap := hcl2shim.FlatmapValueFromHCL2(priorVal)
		plannedMap := hcl2shim.FlatmapValueFromHCL2(plannedVal)
		s := NewInstanceStateShimmedFromValue(priorVal, 0)
		d := &InstanceDiff{
			Attributes: make(map[string]*ResourceAttrDiff),
		}
		if plannedMap == nil { // destroying, then
			d.Destroy = true
			// Destroy diffs don't have any attribute diffs
		} else {
			if priorMap == nil { // creating, then
				// We'll just make an empty prior map to make things easier below.
				priorMap = make(map[string]string)
			}

			for k, new := range plannedMap {
				old := priorMap[k]
				newComputed := false
				if new == hcl2shim.UnknownVariableValue {
					new = ""
					newComputed = true
				}
				d.Attributes[k] = &ResourceAttrDiff{
					Old:         old,
					New:         new,
					NewComputed: newComputed,
					Type:        DiffAttrInput, // not generally used in tests, so just hard-coded
				}
			}
			// Also need any attributes that were removed in "planned"
			for k, old := range priorMap {
				if _, ok := plannedMap[k]; ok {
					continue
				}
				d.Attributes[k] = &ResourceAttrDiff{
					Old:        old,
					NewRemoved: true,
					Type:       DiffAttrInput,
				}
			}
		}
		newState, err := p.ApplyFn(info, s, d)
		resp := providers.ApplyResourceChangeResponse{}
		if err != nil {
			resp.Diagnostics = resp.Diagnostics.Append(err)
		}
		if newState != nil {
			var newVal cty.Value
			if newState != nil {
				var err error
				newVal, err = newState.AttrsAsObjectValue(schema.Block.ImpliedType())
				if err != nil {
					resp.Diagnostics = resp.Diagnostics.Append(err)
				}
			} else {
				// If apply returned a nil new state then that's the old way to
				// indicate that the object was destroyed. Our new interface calls
				// for that to be signalled as a null value.
				newVal = cty.NullVal(schema.Block.ImpliedType())
			}
			resp.NewState = newVal
		}

		return resp
	}
	if p.ApplyResourceChangeFn != nil {
		return p.ApplyResourceChangeFn(r)
	}

	return p.ApplyResourceChangeResponse
}

func (p *MockProvider) ImportResourceState(r providers.ImportResourceStateRequest) providers.ImportResourceStateResponse {
	p.Lock()
	defer p.Unlock()

	if p.ImportStateReturn != nil {
		for _, is := range p.ImportStateReturn {
			if is.Attributes == nil {
				is.Attributes = make(map[string]string)
			}
			is.Attributes["id"] = is.ID

			typeName := is.Ephemeral.Type
			// Use the requested type if the resource has no type of it's own.
			// We still return the empty type, which will error, but this prevents a panic.
			if typeName == "" {
				typeName = r.TypeName
			}

			schema := p.GetSchemaReturn.ResourceTypes[typeName]
			if schema == nil {
				panic("no schema found for " + typeName)
			}

			private, err := json.Marshal(is.Meta)
			if err != nil {
				panic(err)
			}

			state, err := hcl2shim.HCL2ValueFromFlatmap(is.Attributes, schema.ImpliedType())
			if err != nil {
				panic(err)
			}

			state, err = schema.CoerceValue(state)
			if err != nil {
				panic(err)
			}

			p.ImportResourceStateResponse.ImportedResources = append(
				p.ImportResourceStateResponse.ImportedResources,
				providers.ImportedResource{
					TypeName: is.Ephemeral.Type,
					State:    state,
					Private:  private,
				})
		}
	}

	p.ImportResourceStateCalled = true
	p.ImportResourceStateRequest = r
	if p.ImportResourceStateFn != nil {
		return p.ImportResourceStateFn(r)
	}

	return p.ImportResourceStateResponse
}

func (p *MockProvider) ReadDataSource(r providers.ReadDataSourceRequest) providers.ReadDataSourceResponse {
	p.Lock()
	defer p.Unlock()

	p.ReadDataSourceCalled = true
	p.ReadDataSourceRequest = r

	if p.ReadDataSourceFn != nil {
		return p.ReadDataSourceFn(r)
	}

	return p.ReadDataSourceResponse
}

func (p *MockProvider) Close() error {
	p.CloseCalled = true
	return p.CloseError
}
