//go:build !cgo

// Omnipus - Ultra-lightweight personal AI agent
// License: MIT
// Copyright (c) 2026 Omnipus contributors

package gateway

import (
	"log/slog"
	"net/http"

	gen "github.com/dapicom-ai/omnipus/pkg/api/generated"
	"github.com/dapicom-ai/omnipus/pkg/audit"
	"github.com/dapicom-ai/omnipus/pkg/config"
	"github.com/dapicom-ai/omnipus/pkg/gateway/ctxkey"
)

// SandboxConfigUpdate request body is defined in
// contracts/components/schemas/SandboxConfigUpdate.yaml and generated into
// pkg/api/generated/. Use gen.SandboxConfigUpdate directly in putSandboxConfig.

// validSandboxProfiles is the canonical set accepted for default_profile.
// Mirrors config.SandboxProfile* constants.
var validSandboxProfiles = map[string]bool{
	"":              true, // empty = inherit, not-set
	"none":          true, // explicit "use global default"
	"workspace":     true,
	"workspace+net": true,
	"host":          true,
	"off":           true,
}

// SandboxConfigUpdate.Ssrf (nested) is inlined in the generated type;
// see contracts/components/schemas/SandboxConfigUpdate.yaml.

// validSandboxModes is the canonical set accepted by putSandboxConfig.
var validSandboxModes = map[string]bool{
	"off":        true,
	"permissive": true,
	"enforce":    true,
}

// HandleSandboxConfig handles GET/PUT /api/v1/security/sandbox-config.
//
// GET returns the full sandbox config. See pkg/gateway/rest_sandbox_config_test.go
// for the exact response and request shapes.
//
// PUT accepts a partial body — any subset of
// {mode, allow_network_outbound, allowed_paths, ssrf_enabled,
// ssrf_allow_internal, ssrf.allow_internal}. On validation success each
// changed field is persisted atomically via safeUpdateConfigJSON.
// mode and allowed_paths are restart-gated (requires_restart=true).
// ssrf.allow_internal is hot-reload (requires_restart=false).
//
// Admin-only: non-admin PUT returns 403.
func (a *restAPI) HandleSandboxConfig(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		a.getSandboxConfig(w, r)
	case http.MethodPut:
		a.putSandboxConfig(w, r)
	default:
		jsonErr(w, http.StatusMethodNotAllowed, "method not allowed")
	}
}

func (a *restAPI) getSandboxConfig(w http.ResponseWriter, r *http.Request) {
	if a.agentLoop == nil {
		jsonErr(w, http.StatusServiceUnavailable, "sandbox: agent loop not initialized")
		return
	}
	cfg := a.agentLoop.GetConfig()

	// Coerce nil slices to empty slices — never null (Ava-chat bug class).
	allowedPaths := append([]string(nil), cfg.Sandbox.AllowedPaths...)
	if allowedPaths == nil {
		allowedPaths = []string{}
	}
	allowInternal := append([]string(nil), cfg.Sandbox.SSRF.AllowInternal...)
	if allowInternal == nil {
		allowInternal = []string{}
	}
	shellDenyPatterns := append([]string(nil), cfg.Sandbox.ShellDenyPatterns...)
	if shellDenyPatterns == nil {
		shellDenyPatterns = []string{}
	}

	// applied_mode reflects what the gateway is ACTUALLY running with. It
	// differs from mode when the operator saved a change but hasn't restarted.
	applied := ""
	if a.sandboxResult != nil {
		applied = string(a.sandboxResult.ApplyState.Mode)
	}

	// Collect scalar values into pointers for the generated type.
	resolvedMode := gen.SandboxConfigMode(cfg.Sandbox.ResolvedMode())
	allowNetOut := cfg.Sandbox.AllowNetworkOutbound
	ssrfEnabled := cfg.Sandbox.SSRF.Enabled
	defaultProfile := gen.SandboxConfigDefaultProfile(cfg.Sandbox.DefaultProfile)

	// Return both the flat-field shape and the nested ssrf object.
	// The flat fields are the canonical wire format; the nested ssrf block is
	// included for backward-compatible clients. Both are safe to include — JSON
	// consumers pick what they need.
	jsonOK(w, gen.SandboxConfig{
		Mode:                 &resolvedMode,
		AllowNetworkOutbound: &allowNetOut,
		AllowedPaths:         &allowedPaths,
		SsrfEnabled:          &ssrfEnabled,
		SsrfAllowInternal:    &allowInternal,
		AppliedMode:          &applied,
		DefaultProfile:       &defaultProfile,
		ShellDenyPatterns:    &shellDenyPatterns,
		// Nested ssrf object for backward-compatible clients.
		Ssrf: &struct {
			AllowInternal *[]string `json:"allow_internal,omitempty"`
			Enabled       *bool     `json:"enabled,omitempty"`
		}{
			Enabled:       &ssrfEnabled,
			AllowInternal: &allowInternal,
		},
	})
}

func (a *restAPI) putSandboxConfig(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

	var body gen.SandboxConfigUpdate
	validateEnabled := a.agentLoop.GetConfig().Gateway.ValidateInbound
	if !decodeAndValidate(w, r, "SandboxConfigUpdate", &body, validateEnabled) {
		return
	}

	// Resolve which fields are being updated. Flat ssrf_allow_internal takes
	// precedence over nested ssrf.allow_internal when both are present.
	changedMode := body.Mode != nil
	changedAllowNetworkOutbound := body.AllowNetworkOutbound != nil
	changedAllowedPaths := body.AllowedPaths != nil
	changedSSRFEnabled := body.SsrfEnabled != nil

	// Resolve allow_internal source: flat field takes precedence over nested.
	var resolvedAllowInternal *[]string
	if body.SsrfAllowInternal != nil {
		resolvedAllowInternal = body.SsrfAllowInternal
	} else if body.Ssrf != nil && body.Ssrf.AllowInternal != nil {
		resolvedAllowInternal = body.Ssrf.AllowInternal
	}
	changedAllowInternal := resolvedAllowInternal != nil
	changedDefaultProfile := body.DefaultProfile != nil
	changedShellDenyPatterns := body.ShellDenyPatterns != nil

	if !changedMode && !changedAllowNetworkOutbound && !changedAllowedPaths &&
		!changedSSRFEnabled && !changedAllowInternal &&
		!changedDefaultProfile && !changedShellDenyPatterns {
		jsonErr(
			w,
			http.StatusBadRequest,
			"at least one field required — expected mode, allowed_paths, ssrf.allow_internal, default_profile, or shell_deny_patterns",
		)
		return
	}

	// Validate mode value before any disk writes.
	if changedMode {
		if !validSandboxModes[string(*body.Mode)] {
			jsonErr(w, http.StatusBadRequest, `invalid sandbox mode — must be one of "off", "permissive", "enforce"`)
			return
		}
	}

	// Validate default profile value before any disk writes.
	if changedDefaultProfile {
		if !validSandboxProfiles[string(*body.DefaultProfile)] {
			jsonErr(
				w,
				http.StatusBadRequest,
				`invalid default_profile — must be one of "", "none", "workspace", "workspace+net", "host", "off"`,
			)
			return
		}
	}

	// Strict validation — one bad entry fails the whole PUT, nothing persists.
	if changedAllowedPaths {
		if err := validateAllowedPaths(*body.AllowedPaths); err != nil {
			jsonErr(w, http.StatusBadRequest, err.Error())
			return
		}
	}
	var ssrfWarnings []string
	if changedAllowInternal {
		warnings, err := validateSSRFAllowInternal(*resolvedAllowInternal)
		if err != nil {
			jsonErr(w, http.StatusBadRequest, err.Error())
			return
		}
		ssrfWarnings = warnings
	}

	// Capture old values for auditing inside the safeUpdateConfigJSON callback
	// so the snapshot is taken atomically with the write. Reading before the
	// lock can yield a stale value when two writers race.
	var (
		oldMode              string
		oldAllowedPaths      []string
		oldAllowInternal     []string
		oldDefaultProfile    string
		oldShellDenyPatterns []string
	)

	if err := a.safeUpdateConfigJSON(func(m map[string]any) error {
		sandbox := ensureMap(m, "sandbox")

		// Snapshot old values from the just-read map so the audit diff is
		// consistent with the actual atomic state, not a pre-lock race copy.
		if changedMode {
			if s, ok := sandbox["mode"].(string); ok {
				oldMode = s
			}
			sandbox["mode"] = string(*body.Mode)
			// Strip any stale legacy "enabled" bool from older configs so
			// the on-disk shape matches the current schema.
			delete(sandbox, "enabled")
		}
		if changedAllowNetworkOutbound {
			sandbox["allow_network_outbound"] = *body.AllowNetworkOutbound
		}
		if changedAllowedPaths {
			if raw, ok := sandbox["allowed_paths"].([]any); ok {
				for _, v := range raw {
					if s, ok := v.(string); ok {
						oldAllowedPaths = append(oldAllowedPaths, s)
					}
				}
			}
			sandbox["allowed_paths"] = toAnySlice(*body.AllowedPaths)
		}
		if changedSSRFEnabled || changedAllowInternal {
			ssrf := ensureMap(m, "sandbox", "ssrf")
			if changedSSRFEnabled {
				ssrf["enabled"] = *body.SsrfEnabled
			}
			if changedAllowInternal {
				if raw, ok := ssrf["allow_internal"].([]any); ok {
					for _, v := range raw {
						if s, ok := v.(string); ok {
							oldAllowInternal = append(oldAllowInternal, s)
						}
					}
				}
				ssrf["allow_internal"] = toAnySlice(*resolvedAllowInternal)
			}
		}
		if changedDefaultProfile {
			if s, ok := sandbox["default_profile"].(string); ok {
				oldDefaultProfile = s
			}
			if string(*body.DefaultProfile) == "" {
				delete(sandbox, "default_profile")
			} else {
				sandbox["default_profile"] = string(*body.DefaultProfile)
			}
		}
		if changedShellDenyPatterns {
			if raw, ok := sandbox["shell_deny_patterns"].([]any); ok {
				for _, v := range raw {
					if s, ok := v.(string); ok {
						oldShellDenyPatterns = append(oldShellDenyPatterns, s)
					}
				}
			}
			sandbox["shell_deny_patterns"] = toAnySlice(*body.ShellDenyPatterns)
		}
		return nil
	}); err != nil {
		slog.Error("rest: update sandbox config", "error", err)
		jsonErr(w, http.StatusInternalServerError, "could not save config")
		return
	}

	// FR-053 invalidation fires inside safeUpdateConfigJSON above.
	// Audit each changed field. Errors are logged, never surface to the
	// caller — the mutation has already been persisted atomically.
	if a.agentLoop != nil {
		if auditLogger := a.agentLoop.AuditLogger(); auditLogger != nil {
			if changedMode {
				if err := audit.EmitSecuritySettingChange(
					r.Context(), auditLogger,
					"sandbox.mode",
					oldMode, string(*body.Mode),
				); err != nil {
					slog.Error("rest: audit emit sandbox.mode change", "error", err)
				}
			}
			if changedAllowedPaths {
				if err := audit.EmitSecuritySettingChange(
					r.Context(), auditLogger,
					"sandbox.allowed_paths",
					oldAllowedPaths, *body.AllowedPaths,
				); err != nil {
					slog.Error("rest: audit emit allowed_paths change", "error", err)
				}
			}
			if changedAllowInternal {
				if err := audit.EmitSecuritySettingChange(
					r.Context(), auditLogger,
					"sandbox.ssrf.allow_internal",
					oldAllowInternal, *resolvedAllowInternal,
				); err != nil {
					slog.Error("rest: audit emit ssrf.allow_internal change", "error", err)
				}
			}
			if changedDefaultProfile {
				if err := audit.EmitSecuritySettingChange(
					r.Context(), auditLogger,
					"sandbox.default_profile",
					oldDefaultProfile, string(*body.DefaultProfile),
				); err != nil {
					slog.Error("rest: audit emit default_profile change", "error", err)
				}
			}
			if changedShellDenyPatterns {
				if err := audit.EmitSecuritySettingChange(
					r.Context(), auditLogger,
					"sandbox.shell_deny_patterns",
					oldShellDenyPatterns, *body.ShellDenyPatterns,
				); err != nil {
					slog.Error("rest: audit emit shell_deny_patterns change", "error", err)
				}
			}
		}
	}

	// Wildcard SSRF entries (0.0.0.0/0, ::/0) validate successfully but
	// effectively disable internal-block protection. Log each one with
	// the actor username so security review catches the divergence.
	if len(ssrfWarnings) > 0 {
		actor := actorUsername(r)
		for _, warn := range ssrfWarnings {
			slog.Warn("ssrf: wildcard allow_internal accepted",
				"event", "ssrf_wildcard_accepted",
				"entry", warn,
				"actor", actor)
		}
	}

	// mode, allowed_paths, and default_profile are restart-gated (each is
	// consumed at boot or agent-wiring time). ssrf.allow_internal and
	// shell_deny_patterns are hot-reload via the config-poll loop.
	partialRestartRequired := changedMode || changedAllowedPaths || changedDefaultProfile

	// Return the updated config so the UI can cache-update without a follow-up GET.
	// Include both flat fields and nested ssrf object for backward-compatible clients.
	saved := true
	if a.agentLoop != nil {
		updatedCfg := a.agentLoop.GetConfig()
		updatedAllowedPaths := append([]string(nil), updatedCfg.Sandbox.AllowedPaths...)
		if updatedAllowedPaths == nil {
			updatedAllowedPaths = []string{}
		}
		updatedAllowInternal := append([]string(nil), updatedCfg.Sandbox.SSRF.AllowInternal...)
		if updatedAllowInternal == nil {
			updatedAllowInternal = []string{}
		}
		updatedApplied := ""
		if a.sandboxResult != nil {
			updatedApplied = string(a.sandboxResult.ApplyState.Mode)
		}
		updatedShellDenyPatterns := append([]string(nil), updatedCfg.Sandbox.ShellDenyPatterns...)
		if updatedShellDenyPatterns == nil {
			updatedShellDenyPatterns = []string{}
		}
		updatedMode := gen.SandboxConfigMode(updatedCfg.Sandbox.ResolvedMode())
		updatedAllowNetOut := updatedCfg.Sandbox.AllowNetworkOutbound
		updatedSsrfEnabled := updatedCfg.Sandbox.SSRF.Enabled
		updatedDefaultProfile := gen.SandboxConfigDefaultProfile(updatedCfg.Sandbox.DefaultProfile)
		jsonOK(w, gen.SandboxConfig{
			Saved:                &saved,
			Mode:                 &updatedMode,
			AllowNetworkOutbound: &updatedAllowNetOut,
			AllowedPaths:         &updatedAllowedPaths,
			SsrfEnabled:          &updatedSsrfEnabled,
			SsrfAllowInternal:    &updatedAllowInternal,
			AppliedMode:          &updatedApplied,
			DefaultProfile:       &updatedDefaultProfile,
			ShellDenyPatterns:    &updatedShellDenyPatterns,
			RequiresRestart:      &partialRestartRequired,
			Ssrf: &struct {
				AllowInternal *[]string `json:"allow_internal,omitempty"`
				Enabled       *bool     `json:"enabled,omitempty"`
			}{
				Enabled:       &updatedSsrfEnabled,
				AllowInternal: &updatedAllowInternal,
			},
		})
		return
	}

	// Fallback when agentLoop is nil (test harness or startup race).
	jsonOK(w, gen.SandboxConfig{
		Saved:           &saved,
		RequiresRestart: &partialRestartRequired,
	})
}

// toAnySlice converts []string to []any so the JSON map round-trip
// through safeUpdateConfigJSON produces a stable shape (map[string]any
// parse would turn a []string marshal into []any anyway — doing it
// here keeps the mutation function honest).
func toAnySlice(in []string) []any {
	out := make([]any, len(in))
	for i, s := range in {
		out[i] = s
	}
	return out
}

// actorUsername pulls the authenticated username from the request's
// context, falling back to "" when no user is attached (system-initiated
// or test request). Mirrors the extractor in pkg/audit but lives here so
// the gateway does not need to import audit internals.
func actorUsername(r *http.Request) string {
	if r == nil {
		return ""
	}
	v := r.Context().Value(ctxkey.UserContextKey{})
	if v == nil {
		return ""
	}
	if u, ok := v.(*config.UserConfig); ok && u != nil {
		return u.Username
	}
	if s, ok := v.(string); ok {
		return s
	}
	return ""
}
