// 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"
)

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

	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,
		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 := shellCommand(command)
	cmd := exec.CommandContext(ctx, name, args...)
	cmd.Stdin = nil
	cmd.Stderr = io.Discard

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

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

	return value, true
}

func shellCommand(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

	appendLiteral := func(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})
	}

	index := 0
	for index < len(config) {
		relativeDollar := strings.IndexByte(config[index:], '$')
		if relativeDollar < 0 {
			appendLiteral(config[index:])
			break
		}

		dollar := index + relativeDollar
		appendLiteral(config[index:dollar])

		if dollar+1 >= len(config) {
			appendLiteral("$")
			index = dollar + 1
			continue
		}

		next := config[dollar+1]
		if next == '$' || next == '!' {
			appendLiteral(string(next))
			index = dollar + 2
			continue
		}

		if next == '{' {
			end := strings.IndexByte(config[dollar+2:], '}')
			if end < 0 {
				appendLiteral("$")
				index = dollar + 1
				continue
			}

			endIndex := dollar + 2 + end
			name := config[dollar+2 : endIndex]
			if envNameRE.MatchString(name) {
				parts = append(parts, templatePart{kind: partEnv, value: name})
			} else {
				appendLiteral(config[dollar : endIndex+1])
			}

			index = endIndex + 1
			continue
		}

		name := envNamePrefixRE.FindString(config[dollar+1:])
		if name != "" {
			parts = append(parts, templatePart{kind: partEnv, value: name})
			index = dollar + 1 + len(name)
			continue
		}

		appendLiteral("$")
		index = dollar + 1
	}

	return parts
}

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
}
