// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package checks

import (
	"fmt"
	"sort"
	"sync"

	"github.com/hashicorp/terraform/internal/addrs"
	"github.com/hashicorp/terraform/internal/configs"
)

// State is a container for state tracking of all of the checks declared in
// a particular Terraform configuration and their current statuses.
//
// A State object is mutable during plan and apply operations but should
// otherwise be treated as a read-only snapshot of the status of checks
// at a particular moment.
//
// The checks State tracks a few different concepts:
//   - configuration objects: items in the configuration which statically
//     declare some checks associated with zero or more checkable objects.
//   - checkable objects: dynamically-determined objects that are each
//     associated with one configuration object.
//   - checks: a single check that is declared as part of a configuration
//     object and then resolved once for each of its associated checkable
//     objects.
//   - check statuses: the current state of a particular check associated
//     with a particular checkable object.
//
// This container type is concurrency-safe for both reads and writes through
// its various methods.
type State struct {
	mu sync.Mutex

	statuses    addrs.Map[addrs.ConfigCheckable, *configCheckableState]
	failureMsgs addrs.Map[addrs.CheckRule, string]
}

// configCheckableState is an internal part of type State that represents
// the evaluation status for a particular addrs.ConfigCheckable address.
//
// Its initial state, at the beginning of a run, is that it doesn't even know
// how many checkable objects will be dynamically-declared yet. Terraform Core
// will notify the State object of the associated Checkables once
// it has decided the appropriate expansion of that configuration object,
// and then will gradually report the results of each check once the graph
// walk reaches it.
//
// This must be accessed only while holding the mutex inside the associated
// State object.
type configCheckableState struct {
	// checkTypes captures the expected number of checks of each type
	// associated with object declared by this configuration construct. Since
	// checks are statically declared (even though the checkable objects
	// aren't) we can compute this only from the configuration.
	checkTypes map[addrs.CheckRuleType]int

	// objects represents the set of dynamic checkable objects associated
	// with this configuration construct. This is initially nil to represent
	// that we don't know the objects yet, and is replaced by a non-nil map
	// once Terraform Core reports the expansion of this configuration
	// construct.
	//
	// The leaf Status values will initially be StatusUnknown
	// and then gradually updated by Terraform Core as it visits the
	// individual checkable objects and reports their status.
	objects addrs.Map[addrs.Checkable, map[addrs.CheckRuleType][]Status]
}

// NOTE: For the "Report"-prefixed methods that we use to gradually update
// the structure with results during a plan or apply operation, see the
// state_report.go file also in this package.

// NewState returns a new State object representing the check statuses of
// objects declared in the given configuration.
//
// The configuration determines which configuration objects and associated
// checks we'll be expecting to see, so that we can seed their statuses as
// all unknown until we see affirmative reports sent by the Report-prefixed
// methods on Checks.
func NewState(config *configs.Config) *State {
	return &State{
		statuses: initialStatuses(config),
	}
}

// ConfigHasChecks returns true if and only if the given address refers to
// a configuration object that this State object is expecting to recieve
// statuses for.
//
// Other methods of Checks will typically panic if given a config address
// that would not have returned true from ConfigHasChecked.
func (c *State) ConfigHasChecks(addr addrs.ConfigCheckable) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.statuses.Has(addr)
}

// AllConfigAddrs returns all of the addresses of all configuration objects
// that could potentially produce checkable objects at runtime.
//
// This is a good starting point for reporting on the outcome of all of the
// configured checks at the configuration level of granularity, e.g. for
// automated testing reports where we want to report the status of all
// configured checks even if the graph walk aborted before we reached any
// of their objects.
func (c *State) AllConfigAddrs() addrs.Set[addrs.ConfigCheckable] {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.statuses.Keys()
}

// ObjectAddrs returns the addresses of individual checkable objects belonging
// to the configuration object with the given address.
//
// This will panic if the given address isn't a known configuration object
// that has checks.
func (c *State) ObjectAddrs(configAddr addrs.ConfigCheckable) addrs.Set[addrs.Checkable] {
	c.mu.Lock()
	defer c.mu.Unlock()

	st, ok := c.statuses.GetOk(configAddr)
	if !ok {
		panic(fmt.Sprintf("unknown configuration object %s", configAddr))
	}

	ret := addrs.MakeSet[addrs.Checkable]()
	for _, elem := range st.objects.Elems {
		ret.Add(elem.Key)
	}
	return ret

}

// AggregateCheckStatus returns a summarization of all of the check results
// for a particular configuration object into a single status.
//
// The given address must refer to an object within the configuration that
// this Checks was instantiated from, or this method will panic.
func (c *State) AggregateCheckStatus(addr addrs.ConfigCheckable) Status {
	c.mu.Lock()
	defer c.mu.Unlock()

	st, ok := c.statuses.GetOk(addr)
	if !ok {
		panic(fmt.Sprintf("request for status of unknown configuration object %s", addr))
	}

	if st.objects.Elems == nil {
		// If we don't even know how many objects we have for this
		// configuration construct then that summarizes as unknown.
		// (Note: this is different than Elems being a non-nil empty map,
		// which means that we know there are zero objects and therefore
		// the aggregate result will be pass to pass below.)
		return StatusUnknown
	}

	// Otherwise, our result depends on how many of our known objects are
	// in each status.
	errorCount := 0
	failCount := 0
	unknownCount := 0

	for _, objects := range st.objects.Elems {
		for _, checks := range objects.Value {
			for _, status := range checks {
				switch status {
				case StatusPass:
					// ok
				case StatusFail:
					failCount++
				case StatusError:
					errorCount++
				default:
					unknownCount++
				}
			}
		}
	}

	return summarizeCheckStatuses(errorCount, failCount, unknownCount)
}

// ObjectCheckStatus returns a summarization of all of the check results
// for a particular checkable object into a single status.
//
// The given address must refer to a checkable object that Terraform Core
// previously reported while doing a graph walk, or this method will panic.
func (c *State) ObjectCheckStatus(addr addrs.Checkable) Status {
	c.mu.Lock()
	defer c.mu.Unlock()

	configAddr := addr.ConfigCheckable()

	st, ok := c.statuses.GetOk(configAddr)
	if !ok {
		panic(fmt.Sprintf("request for status of unknown object %s", addr))
	}
	if st.objects.Elems == nil {
		panic(fmt.Sprintf("request for status of %s before establishing the checkable objects for %s", addr, configAddr))
	}
	checks, ok := st.objects.GetOk(addr)
	if !ok {
		panic(fmt.Sprintf("request for status of unknown object %s", addr))
	}

	errorCount := 0
	failCount := 0
	unknownCount := 0
	for _, statuses := range checks {
		for _, status := range statuses {
			switch status {
			case StatusPass:
				// ok
			case StatusFail:
				failCount++
			case StatusError:
				errorCount++
			default:
				unknownCount++
			}
		}
	}
	return summarizeCheckStatuses(errorCount, failCount, unknownCount)
}

// ObjectFailureMessages returns the zero or more failure messages reported
// for the object with the given address.
//
// Failure messages are recorded only for checks whose status is StatusFail,
// but since this aggregates together the results of all of the checks
// on the given object it's possible for there to be a mixture of failures
// and errors at the same time, which would aggregate as StatusError in
// ObjectCheckStatus's result because errors are defined as "stronger"
// than failures.
func (c *State) ObjectFailureMessages(addr addrs.Checkable) []string {
	var ret []string

	configAddr := addr.ConfigCheckable()

	st, ok := c.statuses.GetOk(configAddr)
	if !ok {
		panic(fmt.Sprintf("request for status of unknown object %s", addr))
	}
	if st.objects.Elems == nil {
		panic(fmt.Sprintf("request for status of %s before establishing the checkable objects for %s", addr, configAddr))
	}
	checksByType, ok := st.objects.GetOk(addr)
	if !ok {
		panic(fmt.Sprintf("request for status of unknown object %s", addr))
	}

	for checkType, checks := range checksByType {
		for i, status := range checks {
			if status == StatusFail {
				checkAddr := addrs.NewCheckRule(addr, checkType, i)
				msg := c.failureMsgs.Get(checkAddr)
				if msg != "" {
					ret = append(ret, msg)
				}
			}
		}
	}

	// We always return the messages in a lexical sort order just so that
	// it'll be consistent between runs if we still have the same problems.
	sort.Strings(ret)

	return ret
}

func summarizeCheckStatuses(errorCount, failCount, unknownCount int) Status {
	switch {
	case errorCount > 0:
		// If we saw any errors then we'll treat the whole thing as errored.
		return StatusError
	case failCount > 0:
		// If anything failed then this whole configuration construct failed.
		return StatusFail
	case unknownCount > 0:
		// If nothing failed but we still have unknowns then our outcome isn't
		// known yet.
		return StatusUnknown
	default:
		// If we have no failures and no unknowns then either we have all
		// passes or no checkable objects at all, both of which summarize as
		// a pass.
		return StatusPass
	}
}
