package component

import (
	"fmt"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"sync"

	"github.com/gruntwork-io/terragrunt/pkg/config"
)

const (
	StackKind Kind = "stack"
)

// Stack represents a discovered Terragrunt stack configuration.
type Stack struct {
	cfg              *config.StackConfig
	discoveryContext *DiscoveryContext
	path             string
	reading          []string
	dependencies     Components
	dependents       Components
	Units            []*Unit
	mu               sync.Mutex
	external         bool
}

// NewStack creates a new Stack component with the given path.
func NewStack(path string) *Stack {
	return &Stack{
		path:             path,
		discoveryContext: &DiscoveryContext{},
		dependencies:     make(Components, 0),
		dependents:       make(Components, 0),
	}
}

// WithDiscoveryContext sets the discovery context for this stack.
func (s *Stack) WithDiscoveryContext(ctx *DiscoveryContext) *Stack {
	s.discoveryContext = ctx

	return s
}

// Config returns the parsed Stack configuration for this stack.
func (s *Stack) Config() *config.StackConfig {
	s.lock()
	defer s.unlock()

	return s.cfg
}

// StoreConfig stores the parsed Stack configuration for this stack.
func (s *Stack) StoreConfig(cfg *config.StackConfig) {
	s.lock()
	defer s.unlock()

	s.cfg = cfg
}

// Kind returns the kind of component (always Stack for Stack).
func (s *Stack) Kind() Kind {
	return StackKind
}

// Path returns the path to the component.
func (s *Stack) Path() string {
	return s.path
}

// SetPath sets the path to the component.
func (s *Stack) SetPath(path string) {
	s.path = path
}

// DisplayPath returns the path relative to DiscoveryContext.WorkingDir for display purposes.
// Falls back to the original path if relative path calculation fails or WorkingDir is empty.
func (s *Stack) DisplayPath() string {
	if s.discoveryContext == nil || s.discoveryContext.WorkingDir == "" {
		return s.path
	}

	if rel, err := filepath.Rel(s.discoveryContext.WorkingDir, s.path); err == nil {
		return rel
	}

	return s.path
}

// External returns whether the component is external.
func (s *Stack) External() bool {
	return s.external
}

// SetExternal marks the component as external.
func (s *Stack) SetExternal() {
	s.external = true
}

// Reading returns the list of files being read by this component.
func (s *Stack) Reading() []string {
	s.lock()
	defer s.unlock()

	return s.reading
}

// SetReading sets the list of files being read by this component.
func (s *Stack) SetReading(files ...string) {
	s.lock()
	defer s.unlock()

	s.reading = files
}

// Sources returns the list of sources for this component.
//
// Stacks don't support leveraging sources right now, so we just return an empty list.
func (s *Stack) Sources() []string {
	return []string{}
}

// ConfigFile returns the config filename for this stack.
func (s *Stack) ConfigFile() string {
	return config.DefaultStackFile
}

// DiscoveryContext returns the discovery context for this component.
func (s *Stack) DiscoveryContext() *DiscoveryContext {
	return s.discoveryContext
}

// SetDiscoveryContext sets the discovery context for this component.
func (s *Stack) SetDiscoveryContext(ctx *DiscoveryContext) {
	s.discoveryContext = ctx
}

// Origin returns the origin of the discovery context for this component.
func (s *Stack) Origin() Origin {
	if s.discoveryContext == nil {
		return OriginUnknown
	}

	return s.discoveryContext.Origin()
}

// lock locks the Stack.
func (s *Stack) lock() {
	s.mu.Lock()
}

// unlock unlocks the Stack.
func (s *Stack) unlock() {
	s.mu.Unlock()
}

// AddDependency adds a dependency to the Stack and vice versa.
//
// Using this method ensure that the dependency graph is properly maintained,
// making it easier to look up dependents and dependencies on a given component
// without the entire graph available.
func (s *Stack) AddDependency(dependency Component) {
	s.ensureDependency(dependency)

	dependency.ensureDependent(s)
}

// ensureDependency adds a dependency to a stack if it's not already present.
func (s *Stack) ensureDependency(dependency Component) {
	s.lock()
	defer s.unlock()

	if !slices.Contains(s.dependencies, dependency) {
		s.dependencies = append(s.dependencies, dependency)
	}
}

// ensureDependent adds a dependent to a stack if it's not already present.
func (s *Stack) ensureDependent(dependent Component) {
	s.lock()
	defer s.unlock()

	if !slices.Contains(s.dependents, dependent) {
		s.dependents = append(s.dependents, dependent)
	}
}

// AddDependent adds a dependent to the Stack and vice versa.
//
// Using this method ensure that the dependency graph is properly maintained,
// making it easier to look up dependents and dependencies on a given component
// without the entire graph available.
func (s *Stack) AddDependent(dependent Component) {
	s.ensureDependent(dependent)

	dependent.ensureDependency(s)
}

// Dependencies returns the dependencies of the Stack.
func (s *Stack) Dependencies() Components {
	s.lock()
	defer s.unlock()

	return s.dependencies
}

// Dependents returns the dependents of the Stack.
func (s *Stack) Dependents() Components {
	s.lock()
	defer s.unlock()

	return s.dependents
}

// String renders this stack as a human-readable string.
//
// Example output:
//
//	Stack at /path/to/stack:
//	  => Unit /path/to/unit1 (excluded: false, assume applied: false, dependencies: [/dep1])
//	  => Unit /path/to/unit2 (excluded: true, assume applied: false, dependencies: [])
func (s *Stack) String() string {
	units := make([]string, 0, len(s.Units))
	for _, unit := range s.Units {
		units = append(units, "  => "+unit.String())
	}

	sort.Strings(units)

	workingDir := s.path
	if s.discoveryContext != nil && s.discoveryContext.WorkingDir != "" {
		workingDir = s.discoveryContext.WorkingDir
	}

	return fmt.Sprintf("Stack at %s:\n%s", workingDir, strings.Join(units, "\n"))
}

// FindUnitByPath finds a unit in the stack by its path.
func (s *Stack) FindUnitByPath(path string) *Unit {
	for _, unit := range s.Units {
		if unit.Path() == path {
			return unit
		}
	}

	return nil
}
