package config

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"unicode/utf8"

	"iop/packages/go/singlerequesttemplate"

	"github.com/mitchellh/mapstructure"
	"github.com/spf13/viper"
)

func Load(cfgFile string) (*NodeConfig, error) {
	v := viper.New()
	v.SetConfigFile(cfgFile)
	setDefaults(v)
	if err := v.ReadInConfig(); err != nil {
		return nil, err
	}
	if err := rejectLegacyProviderConfig(v.AllSettings()); err != nil {
		return nil, err
	}
	var cfg NodeConfig
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, err
	}
	if err := validateReconnect(cfg.Reconnect); err != nil {
		return nil, err
	}
	if err := validateNodeCredentialPlane(&cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

// validateReconnect enforces the reconnect policy contract (SDD S17). setDefaults
// supplies the omitted defaults (interval_sec=10, max_attempts=10) before this
// runs, so an omitted max_attempts is already 10 here while an explicit 0 stays
// 0 (unlimited). Negative max_attempts or interval_sec are rejected, and an
// unlimited policy requires a positive interval so it cannot hot-loop.
func validateReconnect(rc ReconnectConf) error {
	if rc.MaxAttempts < 0 {
		return fmt.Errorf("reconnect.max_attempts must not be negative")
	}
	if rc.IntervalSec < 0 {
		return fmt.Errorf("reconnect.interval_sec must not be negative")
	}
	if rc.MaxAttempts == 0 && rc.IntervalSec <= 0 {
		return fmt.Errorf("reconnect.interval_sec must be positive when reconnect.max_attempts=0 (unlimited)")
	}
	return nil
}

func LoadEdge(cfgFile string) (*EdgeConfig, error) {
	v := viper.New()
	v.SetConfigFile(cfgFile)
	setEdgeDefaults(v)
	if err := v.ReadInConfig(); err != nil {
		return nil, err
	}
	if err := rejectLegacyProviderConfig(v.AllSettings()); err != nil {
		return nil, err
	}
	var cfg EdgeConfig
	if err := v.Unmarshal(&cfg); err != nil {
		return nil, err
	}
	if v.InConfig("execution_presets") {
		raw := v.Get("execution_presets")
		if err := rejectNullSingleRequestTemplatePaths(raw); err != nil {
			return nil, err
		}
		var presets []ExecutionPreset
		var metadata mapstructure.Metadata
		decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
			ErrorUnused: true,
			Result:      &presets,
			Metadata:    &metadata,
			TagName:     "mapstructure",
		})
		if err != nil {
			return nil, fmt.Errorf("execution_presets: %w", err)
		}
		if err := decoder.Decode(raw); err != nil {
			return nil, fmt.Errorf("execution_presets: %w", err)
		}
		if len(metadata.Unused) > 0 {
			return nil, fmt.Errorf("execution_presets: unknown fields %v", metadata.Unused)
		}
		cfg.ExecutionPresets = presets
	}
	if !v.InConfig("console.target") {
		if v.InConfig("console.model") {
			cfg.Console.Target = cfg.Console.Model
		}
	}
	if err := validateOpenAIRoutes(cfg.OpenAI.ModelRoutes); err != nil {
		return nil, err
	}
	if err := validateOpenAIPrincipalTokens(cfg.OpenAI.PrincipalTokens); err != nil {
		return nil, err
	}
	if err := normalizeOpenAIProviderAuth(v, &cfg.OpenAI.ProviderAuth); err != nil {
		return nil, err
	}
	if cfg.LongContextThresholdTokens <= 0 {
		return nil, fmt.Errorf("long_context_threshold_tokens must be positive")
	}
	if cfg.OpenAI.StreamEvidenceGate.MaxIngressSnapshotBytes == 0 {
		cfg.OpenAI.StreamEvidenceGate.MaxIngressSnapshotBytes = cfg.OpenAI.StreamEvidenceGate.EffectiveMaxIngressSnapshotBytes()
	}
	if err := cfg.OpenAI.StreamEvidenceGate.Validate(); err != nil {
		return nil, fmt.Errorf("openai.stream_evidence_gate: %w", err)
	}

	// Resolve the canonical Edge root provider_pool queue policy.
	// SDD S06: root provider_pool is the canonical owner of the effective
	// queue policy. Legacy per-provider max_queue/queue_timeout_ms are ignored
	// once the canonical owner is resolved.
	if err := resolveProviderPoolPolicy(v, &cfg); err != nil {
		return nil, err
	}

	// Collect all provider IDs from nodes[].providers[] for cross-referencing
	// and validate uniqueness within each node.
	providerIDs := make(map[string]struct{})
	providerByID := make(map[string]NodeProviderConf)
	for i := range cfg.Nodes {
		if err := normalizeAdapters(&cfg.Nodes[i].Adapters); err != nil {
			name := cfg.Nodes[i].ID
			if name == "" {
				name = cfg.Nodes[i].Alias
			}
			return nil, fmt.Errorf("nodes[%d] %q adapters: %w", i, name, err)
		}

		// Validate unique provider IDs within this node and across all nodes.
		seenProviderIDs := make(map[string]struct{}, len(cfg.Nodes[i].Providers))
		for j, p := range cfg.Nodes[i].Providers {
			if err := p.Validate(); err != nil {
				return nil, fmt.Errorf("nodes[%d].providers[%d]: %w", i, j, err)
			}
			if _, dup := seenProviderIDs[p.ID]; dup {
				return nil, fmt.Errorf("nodes[%d].providers: duplicate provider id %q within node", i, p.ID)
			}
			seenProviderIDs[p.ID] = struct{}{}
			if _, globalDup := providerIDs[p.ID]; globalDup {
				return nil, fmt.Errorf("nodes[%d].providers[%d]: duplicate provider id %q across nodes (global uniqueness violation)", i, j, p.ID)
			}
			providerIDs[p.ID] = struct{}{}
			providerByID[p.ID] = p
		}
	}

	for i := range cfg.Nodes {
		if err := CheckProviderLegacyConflict(i, cfg.Nodes[i].Alias, &cfg.Nodes[i]); err != nil {
			return nil, err
		}
	}

	// Resolve protocol profiles into immutable concrete snapshots on each
	// provider. This must run before model catalog validation so that
	// profile-dependent checks (e.g. token counter mode) can reference the
	// resolved snapshot.
	if err := resolveProtocolProfiles(&cfg); err != nil {
		return nil, err
	}
	// Provider structs copied before normalization do not contain the resolved
	// profile snapshots. Rebuild the index so profile-aware model validation
	// observes exactly the immutable provider values retained by cfg.
	providerByID = make(map[string]NodeProviderConf, len(providerIDs))
	for i := range cfg.Nodes {
		for _, provider := range cfg.Nodes[i].Providers {
			providerByID[provider.ID] = provider
		}
	}

	// Build the provider->servedModels index so Validate can check membership.
	serveModels := buildProviderServedModelsIndex(cfg.Nodes)

	// Validate models[].providers reference valid provider IDs.
	seenModelIDs := make(map[string]struct{}, len(cfg.Models))
	for i, m := range cfg.Models {
		id := strings.TrimSpace(m.ID)
		if id == "" {
			return nil, fmt.Errorf("models[%d]: id must not be empty", i)
		}
		if _, dup := seenModelIDs[id]; dup {
			return nil, fmt.Errorf("models: duplicate model id %q", id)
		}
		seenModelIDs[id] = struct{}{}
		if err := m.Validate(providerIDs, serveModels); err != nil {
			return nil, fmt.Errorf("models[%d]: %w", i, err)
		}
		// Provider-only budget and token-counter checks apply to provider-backed
		// entries only. Virtual (preset-only) entries delegate execution to a
		// frozen preset shape and have no provider pool to budget against.
		if strings.TrimSpace(m.ExecutionPreset) == "" {
			if err := validateModelTokenCounter(m, providerByID); err != nil {
				return nil, fmt.Errorf("models[%d]: %w", i, err)
			}
			if err := validateProviderLongContextBudget(m, providerByID); err != nil {
				return nil, fmt.Errorf("models[%d]: %w", i, err)
			}
		}
	}

	if err := resolveSingleRequestTemplates(cfg.ExecutionPresets, cfgFile); err != nil {
		return nil, fmt.Errorf("execution_presets: %w", err)
	}

	// Validate and normalize execution presets before model admission. Preset
	// validation runs early so that invalid preset shapes fail closed before
	// any runtime dispatch path can observe them.
	if err := validatePresetCatalog(cfg.ExecutionPresets, seenModelIDs); err != nil {
		return nil, fmt.Errorf("execution_presets: %w", err)
	}

	// Resolve preset ids referenced by virtual (preset-only) model entries
	// against the validated preset catalog. Dangling references fail closed.
	// Whitespace-only execution_preset values are normalized to empty so the
	// field reflects the effective (unset) state downstream, and a resolved
	// non-empty id is persisted in its canonical (trimmed) form so exact
	// downstream lookups match the value that was admitted here.
	for i := range cfg.Models {
		m := &cfg.Models[i]
		presetID := strings.TrimSpace(m.ExecutionPreset)
		if presetID == "" {
			m.ExecutionPreset = ""
			continue
		}
		found := false
		for _, p := range cfg.ExecutionPresets {
			if p.ID == presetID {
				found = true
				break
			}
		}
		if !found {
			return nil, fmt.Errorf("models[%d] id=%q: execution_preset %q does not match any execution_presets[] entry", i, m.ID, presetID)
		}
		m.ExecutionPreset = presetID
	}

	// Attribution binding validation intentionally runs after the established
	// route, principal, stream-gate, provider, model, and budget checks so a
	// missing provider identity never masks their existing diagnostics.
	if err := validateOpenAIAttributionBindings(cfg.OpenAI); err != nil {
		return nil, err
	}
	if err := validateEdgeCredentialPlane(&cfg); err != nil {
		return nil, err
	}

	// Validate and normalize operator-owned workspace catalogs. This runs
	// after all other validation so workspace errors never mask provider/
	// model diagnostics, and before presets become observable so invalid
	// workspaces fail closed before any runtime path can observe them.
	if err := validateWorkspaceCatalogs(cfg.Nodes); err != nil {
		return nil, err
	}

	return &cfg, nil
}

// rejectLegacyProviderConfig fails closed before decode so removed fields are
// never silently ignored by mapstructure.
func rejectLegacyProviderConfig(settings map[string]any) error {
	var walk func(any, string) error
	walk = func(value any, path string) error {
		switch current := value.(type) {
		case map[string]any:
			for key, child := range current {
				childPath := key
				if path != "" {
					childPath = path + "." + key
				}
				if (path == "console" && key == "agent") || key == "agent_"+"kind" || key == "workspace_"+"required" ||
					(key == "cli" && strings.Contains(path, "adapters")) ||
					(strings.Contains(path, "providers[") && isLegacyProviderProcessField(key)) {
					return fmt.Errorf("legacy provider configuration field %q is not supported", childPath)
				}
				if strings.Contains(path, "providers[") && (key == "type" || key == "category") && strings.EqualFold(fmt.Sprint(child), "cli") {
					return fmt.Errorf("legacy provider configuration value %q is not supported", childPath)
				}
				if err := walk(child, childPath); err != nil {
					return err
				}
			}
		case []any:
			for i, child := range current {
				if err := walk(child, fmt.Sprintf("%s[%d]", path, i)); err != nil {
					return err
				}
			}
		}
		return nil
	}
	return walk(settings, "")
}

func isLegacyProviderProcessField(key string) bool {
	switch key {
	case "command", "args", "env", "mode", "resume_args", "output_format":
		return true
	default:
		return false
	}
}

func setDefaults(v *viper.Viper) {
	v.SetDefault("transport.edge_addr", "localhost:9090")
	v.SetDefault("reconnect.interval_sec", 10)
	v.SetDefault("reconnect.max_attempts", 10)
	v.SetDefault("logging.level", "info")
	v.SetDefault("metrics.port", 9091)
	v.SetDefault("credential_plane.replay_cache_size", 256)
}

func setEdgeDefaults(v *viper.Viper) {
	v.SetDefault("server.listen", "0.0.0.0:9090")
	v.SetDefault("bootstrap.listen", "0.0.0.0:18080")
	v.SetDefault("bootstrap.artifact_dir", "artifacts")
	v.SetDefault("openai.enabled", false)
	v.SetDefault("openai.listen", "0.0.0.0:18081")
	v.SetDefault("openai.bearer_token", "")
	v.SetDefault("openai.adapter", "ollama")
	v.SetDefault("openai.session_id", "openai")
	v.SetDefault("openai.timeout_sec", 120)
	v.SetDefault("openai.strict_output", true)
	v.SetDefault("openai.strict_stream_buffer", false)
	v.SetDefault("openai.stream_evidence_gate.max_request_fault_recovery", 3)
	v.SetDefault("openai.stream_evidence_gate.max_ingress_snapshot_bytes", 16*1024*1024)
	v.SetDefault("a2a.enabled", false)
	v.SetDefault("a2a.listen", "0.0.0.0:8081")
	v.SetDefault("a2a.path", "/a2a")
	v.SetDefault("a2a.adapter", "openai_compat")
	v.SetDefault("a2a.session_id", "a2a")
	v.SetDefault("a2a.timeout_sec", 120)
	v.SetDefault("logging.level", "info")
	v.SetDefault("metrics.port", 19092)
	v.SetDefault("tls.enabled", false)
	v.SetDefault("console.adapter", "ollama")
	v.SetDefault("console.target", "")
	v.SetDefault("console.session_id", "default")
	v.SetDefault("console.background", false)
	v.SetDefault("console.timeout_sec", 120)
	v.SetDefault("control_plane.enabled", false)
	v.SetDefault("control_plane.wire_addr", "")
	v.SetDefault("control_plane.reconnect_interval_sec", 5)
	v.SetDefault("credential_plane.lease_ttl_seconds", 30)
	v.SetDefault("credential_plane.lease_cache_size", 256)
	v.SetDefault("refresh.enabled", false)
	v.SetDefault("refresh.listen", "127.0.0.1:19093")
	v.SetDefault("long_context_threshold_tokens", 100000)
}

// resolveProviderPoolPolicy resolves the effective queue policy from the
// canonical Edge root or, when absent, promotes the matching legacy per-
// provider pair. SDD S06.
//
// Canonical path: if either provider_pool.max_queue or provider_pool.queue_timeout_ms
// is present in the raw source config, treat the canonical owner as set.
// Missing/max=0 values fall back to defaults, while explicit timeout 0 stays
// as no-timeout.
//
// Legacy path: when no canonical key is present, collect every node provider
// that contributes a usable legacy pair (max_queue>0 or queue_timeout_ms>0),
// require all of them to agree, and promote the single shared pair. Conflicts
// trigger a deterministic error naming the offending providers.
func resolveProviderPoolPolicy(v *viper.Viper, cfg *EdgeConfig) error {
	canonicalHasKey := v.InConfig("provider_pool.max_queue") || v.InConfig("provider_pool.queue_timeout_ms")

	if canonicalHasKey {
		// Canonical present: validate boundaries before applying default semantics.
		// SDD S06: negative values are rejected; max_queue=0 normalizes to
		// default; explicit queue_timeout_ms=0 stays as no-timeout.
		if cfg.ProviderPool.MaxQueue < 0 {
			return fmt.Errorf("provider_pool.max_queue must be non-negative")
		}
		if cfg.ProviderPool.QueueTimeoutMS < 0 {
			return fmt.Errorf("provider_pool.queue_timeout_ms must be non-negative")
		}
		if cfg.ProviderPool.MaxQueue == 0 {
			cfg.ProviderPool.MaxQueue = DefaultProviderPoolMaxQueue
		}
		if v.InConfig("provider_pool.queue_timeout_ms") {
			// Key present: preserve decoded value (0 = no-timeout, N>0 = explicit).
		} else {
			cfg.ProviderPool.QueueTimeoutMS = DefaultProviderPoolQueueTimeoutMS
		}
		return nil
	}

	// Canonical absent: collect legacy effective pairs from all node providers.
	type legacyPair struct {
		id   string
		maxQ int
		tMS  int
	}
	var candidates []legacyPair
	seen := make(map[string]struct{})

	for i := range cfg.Nodes {
		for _, p := range cfg.Nodes[i].Providers {
			// Skip if this provider already seen (cross-node dedupe).
			if _, dup := seen[p.ID]; dup {
				continue
			}
			seen[p.ID] = struct{}{}

			maxQ := p.MaxQueue
			tMS := p.QueueTimeoutMS

			// A legacy pair must have at least one usable field (>0).
			// Both unset/zero alone does not participate (same as runtime default).
			if maxQ <= 0 && tMS <= 0 {
				continue
			}

			// Normalize participating legacy pair to effective value before
			// comparison/promotion. max_queue==0 in a participating pair means the
			// default queue depth, so replace it with DefaultProviderPoolMaxQueue.
			// max_queue>0 with timeout==0 is a
			// max-only candidate; its no-timeout semantics are preserved as-is.
			if maxQ == 0 {
				maxQ = DefaultProviderPoolMaxQueue
			}

			candidates = append(candidates, legacyPair{id: p.ID, maxQ: maxQ, tMS: tMS})
		}
	}

	if len(candidates) == 0 {
		cfg.ProviderPool.MaxQueue = DefaultProviderPoolMaxQueue
		cfg.ProviderPool.QueueTimeoutMS = DefaultProviderPoolQueueTimeoutMS
		return nil
	}

	// All candidates must agree on the same pair.
	ref := candidates[0]
	for _, c := range candidates[1:] {
		if c.maxQ != ref.maxQ || c.tMS != ref.tMS {
			return fmt.Errorf("conflicting provider queue policy: provider %q has max_queue=%d queue_timeout_ms=%d; provider %q has max_queue=%d queue_timeout_ms=%d; canonical provider_pool is required to disambiguate", ref.id, ref.maxQ, ref.tMS, c.id, c.maxQ, c.tMS)
		}
	}

	// Promote the single agreed legacy pair as-is (explicit 0 stays no-timeout).
	cfg.ProviderPool.MaxQueue = ref.maxQ
	cfg.ProviderPool.QueueTimeoutMS = ref.tMS
	return nil
}

// validateWorkspaceCatalogs validates all operator-owned workspace catalogs
// across every node in cfg.Nodes. It enforces: globally unique refs, the
// closed supported Unix platform set, absolute clean non-root paths,
// closed-set operations, unique command ids, command presence iff "command"
// is enabled, positive bounded byte/time limits, and unique portable
// environment variable names. An empty workspaces slice on any node is
// backward-compatible and accepted.
func validateWorkspaceCatalogs(nodes []NodeDefinition) error {
	globalRefs := make(map[string]struct{}, len(nodes))
	for i, node := range nodes {
		if len(node.Workspaces) == 0 {
			continue
		}
		if err := validateNodeWorkspaces(node.Workspaces, i); err != nil {
			return err
		}
		for _, ws := range node.Workspaces {
			ref := strings.TrimSpace(ws.Ref)
			if _, dup := globalRefs[ref]; dup {
				return fmt.Errorf("nodes[%d].workspaces: duplicate workspace ref %q across nodes", i, ref)
			}
			globalRefs[ref] = struct{}{}
		}
	}
	return nil
}

// validateNodeWorkspaces validates a single node's workspace catalog.
func validateNodeWorkspaces(workspaces []WorkspaceDefinition, nodeIdx int) error {
	seenRefs := make(map[string]struct{}, len(workspaces))
	for j := range workspaces {
		workspaces[j].Ref = strings.TrimSpace(workspaces[j].Ref)
		if workspaces[j].Ref == "" {
			return fmt.Errorf("nodes[%d].workspaces[%d]: ref must not be empty after trim", nodeIdx, j)
		}
		if _, dup := seenRefs[workspaces[j].Ref]; dup {
			return fmt.Errorf("nodes[%d].workspaces[%d]: duplicate ref %q within node", nodeIdx, j, workspaces[j].Ref)
		}
		seenRefs[workspaces[j].Ref] = struct{}{}

		if !IsSupportedWorkspacePlatform(workspaces[j].Platform) {
			return fmt.Errorf("nodes[%d].workspaces[%d]: unsupported workspace platform %q", nodeIdx, j, workspaces[j].Platform)
		}

		if !filepath.IsAbs(workspaces[j].Root) {
			return fmt.Errorf("nodes[%d].workspaces[%d]: root %q must be an absolute path", nodeIdx, j, workspaces[j].Root)
		}
		if workspaces[j].Root == "/" {
			return fmt.Errorf("nodes[%d].workspaces[%d]: root must not be \"/\"", nodeIdx, j)
		}
		if workspaces[j].Root != filepath.Clean(workspaces[j].Root) {
			return fmt.Errorf("nodes[%d].workspaces[%d]: root %q must be clean (no \".\" or \"..\" segments)", nodeIdx, j, workspaces[j].Root)
		}

		if err := validateWorkspaceOperations(workspaces[j], nodeIdx, j); err != nil {
			return err
		}

		if err := validateWorkspaceCommands(workspaces[j], nodeIdx, j); err != nil {
			return err
		}

		if err := validateWorkspaceEnvironmentAllowlist(workspaces[j], nodeIdx, j); err != nil {
			return err
		}

		if err := validateWorkspaceNumericLimits(workspaces[j], nodeIdx, j); err != nil {
			return err
		}
	}
	return nil
}

// validateWorkspaceOperations validates the operations slice for a workspace.
func validateWorkspaceOperations(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
	if len(ws.Operations) == 0 {
		return fmt.Errorf("nodes[%d].workspaces[%d]: operations must not be empty", nodeIdx, wsIdx)
	}
	seenOps := make(map[WorkspaceOperation]struct{}, len(ws.Operations))
	for k, op := range ws.Operations {
		if _, ok := knownWorkspaceOperations[op]; !ok {
			return fmt.Errorf("nodes[%d].workspaces[%d].operations[%d]: unknown operation %q", nodeIdx, wsIdx, k, string(op))
		}
		if _, dup := seenOps[op]; dup {
			return fmt.Errorf("nodes[%d].workspaces[%d].operations: duplicate operation %q", nodeIdx, wsIdx, string(op))
		}
		seenOps[op] = struct{}{}
	}
	return nil
}

// validateWorkspaceCommands validates the command templates for a workspace.
// Commands are required iff "command" is in operations; they must have unique
// ids and valid executable paths.
func validateWorkspaceCommands(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
	hasCommand := false
	for _, op := range ws.Operations {
		if op == WorkspaceOpCommand {
			hasCommand = true
			break
		}
	}

	if hasCommand && len(ws.Commands) == 0 {
		return fmt.Errorf("nodes[%d].workspaces[%d]: commands must not be empty when \"command\" is in operations", nodeIdx, wsIdx)
	}
	if !hasCommand && len(ws.Commands) > 0 {
		return fmt.Errorf("nodes[%d].workspaces[%d]: commands must be empty when \"command\" is not in operations", nodeIdx, wsIdx)
	}

	if len(ws.Commands) == 0 {
		return nil
	}

	seenCmdIDs := make(map[string]struct{}, len(ws.Commands))
	for k, cmd := range ws.Commands {
		id := strings.TrimSpace(cmd.ID)
		if id == "" {
			return fmt.Errorf("nodes[%d].workspaces[%d].commands[%d]: id must not be empty after trim", nodeIdx, wsIdx, k)
		}
		if _, dup := seenCmdIDs[id]; dup {
			return fmt.Errorf("nodes[%d].workspaces[%d].commands: duplicate command id %q", nodeIdx, wsIdx, id)
		}
		seenCmdIDs[id] = struct{}{}

		if !filepath.IsAbs(cmd.Executable) {
			return fmt.Errorf("nodes[%d].workspaces[%d].commands[%d]: executable %q must be an absolute path", nodeIdx, wsIdx, k, cmd.Executable)
		}
		if cmd.Executable != filepath.Clean(cmd.Executable) {
			return fmt.Errorf("nodes[%d].workspaces[%d].commands[%d]: executable %q must be clean", nodeIdx, wsIdx, k, cmd.Executable)
		}
	}
	return nil
}

// validateWorkspaceEnvironmentAllowlist validates the environment variable
// allowlist for a workspace. Names must be unique and portable (alphanumeric
// + underscore, must start with a letter or underscore).
func validateWorkspaceEnvironmentAllowlist(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
	if len(ws.EnvironmentAllowlist) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(ws.EnvironmentAllowlist))
	for k, name := range ws.EnvironmentAllowlist {
		if name == "" {
			return fmt.Errorf("nodes[%d].workspaces[%d].environment_allowlist[%d]: name must not be empty", nodeIdx, wsIdx, k)
		}
		if !isPortableEnvName(name) {
			return fmt.Errorf("nodes[%d].workspaces[%d].environment_allowlist[%d]: name %q is not a portable environment variable name", nodeIdx, wsIdx, k, name)
		}
		if _, dup := seen[name]; dup {
			return fmt.Errorf("nodes[%d].workspaces[%d].environment_allowlist: duplicate name %q", nodeIdx, wsIdx, name)
		}
		seen[name] = struct{}{}
	}
	return nil
}

// isPortableEnvName checks if a string is a valid portable environment
// variable name: starts with a letter or underscore, followed by letters,
// digits, or underscores.
func isPortableEnvName(name string) bool {
	if len(name) == 0 {
		return false
	}
	for i, r := range name {
		if i == 0 {
			if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_') {
				return false
			}
		} else {
			if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
				return false
			}
		}
	}
	return true
}

// validateWorkspaceNumericLimits validates the byte and time limits for a
// workspace. Every enabled operation must have its effective positive bound:
// read uses max_read_bytes, write uses max_write_bytes, list and command use
// max_output_bytes, and command also uses max_command_timeout_ms. All limits
// retain their absolute maximum of 1 GiB or one hour.
func validateWorkspaceNumericLimits(ws WorkspaceDefinition, nodeIdx, wsIdx int) error {
	const (
		maxByteLimit = 1 * 1024 * 1024 * 1024 // 1 GiB
		maxTimeoutMS = 3600000                // 1 hour
	)

	if ws.MaxReadBytes < 0 || ws.MaxReadBytes > maxByteLimit {
		return fmt.Errorf("nodes[%d].workspaces[%d]: max_read_bytes must be between 1 and %d, got %d", nodeIdx, wsIdx, maxByteLimit, ws.MaxReadBytes)
	}
	if ws.MaxWriteBytes < 0 || ws.MaxWriteBytes > maxByteLimit {
		return fmt.Errorf("nodes[%d].workspaces[%d]: max_write_bytes must be between 1 and %d, got %d", nodeIdx, wsIdx, maxByteLimit, ws.MaxWriteBytes)
	}
	if ws.MaxOutputBytes < 0 || ws.MaxOutputBytes > maxByteLimit {
		return fmt.Errorf("nodes[%d].workspaces[%d]: max_output_bytes must be between 1 and %d, got %d", nodeIdx, wsIdx, maxByteLimit, ws.MaxOutputBytes)
	}
	if ws.MaxCommandTimeoutMS < 0 || ws.MaxCommandTimeoutMS > maxTimeoutMS {
		return fmt.Errorf("nodes[%d].workspaces[%d]: max_command_timeout_ms must be between 1 and %d, got %d", nodeIdx, wsIdx, maxTimeoutMS, ws.MaxCommandTimeoutMS)
	}

	for _, operation := range ws.Operations {
		switch operation {
		case WorkspaceOpRead:
			if ws.MaxReadBytes == 0 {
				return fmt.Errorf("nodes[%d].workspaces[%d]: max_read_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
			}
		case WorkspaceOpWrite:
			if ws.MaxWriteBytes == 0 {
				return fmt.Errorf("nodes[%d].workspaces[%d]: max_write_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
			}
		case WorkspaceOpList:
			if ws.MaxOutputBytes == 0 {
				return fmt.Errorf("nodes[%d].workspaces[%d]: max_output_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
			}
		case WorkspaceOpCommand:
			if ws.MaxOutputBytes == 0 {
				return fmt.Errorf("nodes[%d].workspaces[%d]: max_output_bytes must be positive when %q is enabled", nodeIdx, wsIdx, operation)
			}
			if ws.MaxCommandTimeoutMS == 0 {
				return fmt.Errorf("nodes[%d].workspaces[%d]: max_command_timeout_ms must be positive when %q is enabled", nodeIdx, wsIdx, operation)
			}
		}
	}
	return nil
}

// rejectNullSingleRequestTemplatePaths fails closed on a present-but-null
// plan_file/review_file before the strict preset decode runs. Mapstructure
// collapses a present raw nil into the same nil pointer as an absent key, so
// `plan_file: null` and `review_file: ~` would otherwise be indistinguishable
// from omission and silently select the built-in default. Only a truly absent
// key may reach the nil-pointer fallback in resolveSingleRequestTemplates;
// non-nil values stay with the strict decoder and loadTemplateFile, and any
// other raw shape is left to the strict decoder's own type error.
func rejectNullSingleRequestTemplatePaths(raw any) error {
	presets, ok := raw.([]any)
	if !ok {
		return nil
	}
	for i, entry := range presets {
		preset, ok := entry.(map[string]any)
		if !ok {
			continue
		}
		singleRequest, ok := preset["single_request"].(map[string]any)
		if !ok {
			continue
		}
		templates, ok := singleRequest["templates"].(map[string]any)
		if !ok {
			continue
		}
		for _, field := range []string{"plan_file", "review_file"} {
			value, present := templates[field]
			if present && value == nil {
				return fmt.Errorf("execution_presets[%d] single_request.templates.%s: template path must not be empty", i, field)
			}
		}
	}
	return nil
}

// resolveSingleRequestTemplates freezes the effective Plan/Review templates for
// every single-request preset. Only an omitted plan_file/review_file selects the
// built-in default; every configured value is routed through loadTemplateFile so
// an empty or whitespace-only path fails closed instead of falling back.
func resolveSingleRequestTemplates(presets []ExecutionPreset, configFilePath string) error {
	baseDir := filepath.Dir(configFilePath)
	for i := range presets {
		p := &presets[i]
		if p.SingleRequest == nil {
			continue
		}
		sr := p.SingleRequest

		if sr.Templates.PlanFile == nil {
			sr.Templates.EffectivePlan = singlerequesttemplate.DefaultPlanTemplate
		} else {
			content, err := loadTemplateFile(baseDir, *sr.Templates.PlanFile)
			if err != nil {
				return fmt.Errorf("presets[%d] id=%q single_request.templates.plan_file: %w", i, p.ID, err)
			}
			if err := singlerequesttemplate.ValidatePlanTemplate(content); err != nil {
				return fmt.Errorf("presets[%d] id=%q single_request.templates.plan_file: %w", i, p.ID, err)
			}
			sr.Templates.EffectivePlan = content
		}

		if sr.Templates.ReviewFile == nil {
			sr.Templates.EffectiveReview = singlerequesttemplate.DefaultReviewTemplate
		} else {
			content, err := loadTemplateFile(baseDir, *sr.Templates.ReviewFile)
			if err != nil {
				return fmt.Errorf("presets[%d] id=%q single_request.templates.review_file: %w", i, p.ID, err)
			}
			if err := singlerequesttemplate.ValidateReviewTemplate(content); err != nil {
				return fmt.Errorf("presets[%d] id=%q single_request.templates.review_file: %w", i, p.ID, err)
			}
			sr.Templates.EffectiveReview = content
		}
	}
	return nil
}

// loadTemplateFile reads one operator-configured single-request template. The
// configured value must be a non-empty path relative to the directory holding
// edge.yaml: empty and absolute values are rejected before any filesystem
// access, so an absolute path never reaches Lstat or Open.
func loadTemplateFile(baseDir, relativePath string) (string, error) {
	target := strings.TrimSpace(relativePath)
	if target == "" {
		return "", fmt.Errorf("template path must not be empty")
	}
	if filepath.IsAbs(target) {
		return "", fmt.Errorf("template path must be relative to the directory containing edge.yaml")
	}
	target = filepath.Join(baseDir, target)

	st, err := os.Lstat(target)
	if err != nil {
		return "", err
	}
	if !st.Mode().IsRegular() {
		return "", fmt.Errorf("template file %q must be a regular file (mode %v)", target, st.Mode())
	}

	f, err := os.Open(target)
	if err != nil {
		return "", err
	}
	defer f.Close()

	data, err := io.ReadAll(io.LimitReader(f, 8193))
	if err != nil {
		return "", err
	}
	if len(data) > singlerequesttemplate.MaxTemplateBytes {
		return "", fmt.Errorf("template file %q size %d exceeds max %d bytes", target, len(data), singlerequesttemplate.MaxTemplateBytes)
	}
	if !utf8.Valid(data) {
		return "", fmt.Errorf("template file %q is not valid UTF-8", target)
	}
	return string(data), nil
}
