// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
//
// SPDX-License-Identifier: LicenseRef-MutuaL-1.2

// Package configvalue resolves literal, environment, and command-backed configuration values.
package configvalue

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"regexp"
	"runtime"
	"strings"
	"sync"
	"time"
)

const maxCommandStdoutBytes = 64 * 1024

var (
	envNameRE       = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
	envNamePrefixRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`)
)

type partKind int

const (
	partLiteral partKind = iota
	partEnv
)

type templatePart struct {
	kind  partKind
	value string
}

type result struct {
	value string
	ok    bool
}

// Resolver resolves config-values.RESOLUTION.1 configuration strings.
type Resolver struct {
	CommandTimeout time.Duration

	shellCommand func(string) (string, []string)
	mu           sync.Mutex
	cache        map[string]result
}

// NewResolver returns a resolver with the default command timeout.
func NewResolver() *Resolver {
	return &Resolver{
		CommandTimeout: 10 * time.Second,
		shellCommand:   platformShellCommand,
		cache:          make(map[string]result),
	}
}

// Resolve resolves a literal, environment-interpolated, or command-backed value.
func (r *Resolver) Resolve(ctx context.Context, config string) (string, bool) {
	if strings.HasPrefix(config, "!") {
		return r.resolveCommand(ctx, config)
	}

	return resolveTemplate(parseTemplate(config))
}

// ResolveOrThrow resolves a configuration value or returns a setting-specific error.
func (r *Resolver) ResolveOrThrow(ctx context.Context, config, description string) (string, error) {
	value, ok := r.Resolve(ctx, config)
	if ok {
		return value, nil
	}

	if strings.HasPrefix(config, "!") {
		return "", fmt.Errorf("failed to resolve %s from shell command", description)
	}

	missing := MissingEnvVarNames(config)
	switch len(missing) {
	case 0:
		return "", fmt.Errorf("failed to resolve %s", description)
	case 1:
		return "", fmt.Errorf("failed to resolve %s from environment variable: %s", description, missing[0])
	default:
		return "", fmt.Errorf(
			"failed to resolve %s from environment variables: %s",
			description,
			strings.Join(missing, ", "),
		)
	}
}

// ResolveWithExplicitEnvOverride resolves an explicit environment override before a config value.
func ResolveWithExplicitEnvOverride(
	ctx context.Context,
	resolver *Resolver,
	explicitEnvName, configValue, description string,
) (string, error) {
	if value := os.Getenv(explicitEnvName); value != "" {
		return value, nil
	}

	return resolver.ResolveOrThrow(ctx, configValue, description)
}

func (r *Resolver) resolveCommand(ctx context.Context, commandConfig string) (string, bool) {
	r.mu.Lock()
	cached, cachedOK := r.cache[commandConfig]
	r.mu.Unlock()
	if cachedOK {
		return cached.value, cached.ok
	}

	value, ok := r.executeCommand(ctx, strings.TrimPrefix(commandConfig, "!"))

	r.mu.Lock()
	r.cache[commandConfig] = result{value: value, ok: ok}
	r.mu.Unlock()

	return value, ok
}

func (r *Resolver) executeCommand(ctx context.Context, command string) (string, bool) {
	timeout := r.CommandTimeout
	if timeout <= 0 {
		timeout = 10 * time.Second
	}

	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	name, args := r.shellCommand(command)
	cmd := exec.CommandContext(ctx, name, args...)
	cmd.Stdin = nil
	cmd.Stderr = io.Discard

	output, err := commandOutput(cmd)
	if err != nil {
		return "", false
	}

	value := strings.TrimSpace(string(output))
	if value == "" {
		return "", false
	}

	return value, true
}

func commandOutput(cmd *exec.Cmd) ([]byte, error) {
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, fmt.Errorf("capture command stdout: %w", err)
	}

	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("start command: %w", err)
	}

	output, err := io.ReadAll(io.LimitReader(stdout, maxCommandStdoutBytes+1))
	oversized := len(output) > maxCommandStdoutBytes
	if err != nil || oversized {
		if cmd.Process != nil {
			_ = cmd.Process.Kill()
		}
		_ = cmd.Wait()
		if oversized {
			return nil, fmt.Errorf("command stdout exceeds limit")
		}

		return nil, fmt.Errorf("read command stdout: %w", err)
	}

	if err := cmd.Wait(); err != nil {
		return nil, fmt.Errorf("wait for command: %w", err)
	}

	return output, nil
}

func platformShellCommand(command string) (string, []string) {
	if runtime.GOOS == "windows" {
		return "cmd.exe", []string{"/d", "/s", "/c", command}
	}

	return "sh", []string{"-c", command}
}

func parseTemplate(config string) []templatePart {
	var parts []templatePart

	index := 0
	for index < len(config) {
		literal, part, hasPart, next := nextTemplatePart(config, index)
		appendTemplateLiteral(&parts, literal)
		if hasPart {
			parts = append(parts, part)
		}
		index = next
	}

	return parts
}

func appendTemplateLiteral(parts *[]templatePart, value string) {
	if value == "" {
		return
	}

	last := len(*parts) - 1
	if last >= 0 && (*parts)[last].kind == partLiteral {
		(*parts)[last].value += value
		return
	}

	*parts = append(*parts, templatePart{kind: partLiteral, value: value})
}

func nextTemplatePart(config string, index int) (string, templatePart, bool, int) {
	relativeDollar := strings.IndexByte(config[index:], '$')
	if relativeDollar < 0 {
		return config[index:], templatePart{}, false, len(config)
	}

	dollar := index + relativeDollar
	literal := config[index:dollar]
	if dollar+1 >= len(config) {
		return literal + "$", templatePart{}, false, dollar + 1
	}

	next := config[dollar+1]
	if next == '$' || next == '!' {
		return literal + string(next), templatePart{}, false, dollar + 2
	}
	if next == '{' {
		return nextBracedTemplatePart(config, dollar, literal)
	}

	name := envNamePrefixRE.FindString(config[dollar+1:])
	if name == "" {
		return literal + "$", templatePart{}, false, dollar + 1
	}

	return literal, templatePart{kind: partEnv, value: name}, true, dollar + 1 + len(name)
}

func nextBracedTemplatePart(config string, dollar int, literal string) (string, templatePart, bool, int) {
	end := strings.IndexByte(config[dollar+2:], '}')
	if end < 0 {
		return literal + "$", templatePart{}, false, dollar + 1
	}

	endIndex := dollar + 2 + end
	name := config[dollar+2 : endIndex]
	if !envNameRE.MatchString(name) {
		return literal + config[dollar:endIndex+1], templatePart{}, false, endIndex + 1
	}

	return literal, templatePart{kind: partEnv, value: name}, true, endIndex + 1
}

func resolveTemplate(parts []templatePart) (string, bool) {
	var resolved strings.Builder

	for _, part := range parts {
		switch part.kind {
		case partLiteral:
			resolved.WriteString(part.value)
		case partEnv:
			value := os.Getenv(part.value)
			if value == "" {
				return "", false
			}
			resolved.WriteString(value)
		}
	}

	return resolved.String(), true
}

// EnvVarNames returns referenced environment variable names in first-seen order.
func EnvVarNames(config string) []string {
	if strings.HasPrefix(config, "!") {
		return nil
	}

	seen := make(map[string]struct{})
	var names []string

	for _, part := range parseTemplate(config) {
		if part.kind != partEnv {
			continue
		}

		if _, ok := seen[part.value]; ok {
			continue
		}

		seen[part.value] = struct{}{}
		names = append(names, part.value)
	}

	return names
}

// MissingEnvVarNames returns referenced environment variable names that do not resolve.
func MissingEnvVarNames(config string) []string {
	var missing []string

	for _, name := range EnvVarNames(config) {
		if os.Getenv(name) == "" {
			missing = append(missing, name)
		}
	}

	return missing
}
