package stack

import (
	"fmt"
	"strings"

	"gopkg.in/yaml.v3"
)

// ComposeSpec is a minimal, lenient representation of a compose file.
// We only decode fields we need for validation + label-based proxy routing;
// everything else is preserved as-is and passed to `docker compose`.
type ComposeSpec struct {
	Version  string                 `yaml:"version,omitempty"`
	Services map[string]ServiceSpec `yaml:"services"`
}

// ServiceSpec captures the subset of compose service fields we inspect.
//
// All host-escape-adjacent fields are decoded here even though Tinyforge
// itself never reads them at runtime — surfacing them to Validate() is the
// only way to *reject* them. Add new fields here when blocking a new
// escape vector.
type ServiceSpec struct {
	Image       string            `yaml:"image,omitempty"`
	Build       any               `yaml:"build,omitempty"` // banned — see Validate
	Ports       []any             `yaml:"ports,omitempty"`
	Labels      map[string]string `yaml:"labels,omitempty"`
	Privileged  bool              `yaml:"privileged,omitempty"`
	Volumes     []any             `yaml:"volumes,omitempty"`
	NetworkMode string            `yaml:"network_mode,omitempty"`
	Pid         string            `yaml:"pid,omitempty"`
	Ipc         string            `yaml:"ipc,omitempty"`
	UsernsMode  string            `yaml:"userns_mode,omitempty"`
	CapAdd      []string          `yaml:"cap_add,omitempty"`
	Devices     []any             `yaml:"devices,omitempty"`
	SecurityOpt []string          `yaml:"security_opt,omitempty"`
}

// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
func Parse(yamlText string) (ComposeSpec, error) {
	var spec ComposeSpec
	if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil {
		return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err)
	}
	if len(spec.Services) == 0 {
		return ComposeSpec{}, fmt.Errorf("compose file has no services")
	}
	return spec, nil
}

// Validate enforces Tinyforge-level constraints beyond compose schema validity.
// All blocked fields below are documented host-escape vectors: any one of
// them on its own gives the container root on the host. Tinyforge already
// owns the docker socket, so the threat model is "any admin == host root,"
// and these blocks raise the bar for any *future* viewer-to-admin
// escalation as well as honest-mistake guardrails.
//
// Current rules:
//   - No service may set `privileged: true`.
//   - Every service must declare an image (build contexts disallowed).
//   - No host-IPC / host-PID / host-userns / host networking.
//   - No `cap_add`, `security_opt`, `devices`.
//   - `volumes` may not bind-mount the docker socket, /, /etc, /var, /proc,
//     /sys, /root, or /home — list is conservative; operators with real
//     bind-mount needs should ship a Source plugin or a dedicated wizard.
func Validate(spec ComposeSpec) error {
	for name, svc := range spec.Services {
		if svc.Privileged {
			return fmt.Errorf("service %q: privileged mode is not allowed", name)
		}
		if svc.Image == "" {
			return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
		}
		if svc.Build != nil {
			return fmt.Errorf("service %q: build: is not supported (use image:)", name)
		}
		if isBlockedNamespaceMode(svc.NetworkMode) {
			return fmt.Errorf("service %q: network_mode %q is not allowed", name, svc.NetworkMode)
		}
		if isBlockedNamespaceMode(svc.Pid) {
			return fmt.Errorf("service %q: pid: %q is not allowed", name, svc.Pid)
		}
		if isBlockedNamespaceMode(svc.Ipc) {
			return fmt.Errorf("service %q: ipc: %q is not allowed", name, svc.Ipc)
		}
		if isHostMode(svc.UsernsMode) {
			return fmt.Errorf("service %q: userns_mode %q is not allowed", name, svc.UsernsMode)
		}
		if len(svc.CapAdd) > 0 {
			return fmt.Errorf("service %q: cap_add is not allowed", name)
		}
		if len(svc.SecurityOpt) > 0 {
			return fmt.Errorf("service %q: security_opt is not allowed", name)
		}
		if len(svc.Devices) > 0 {
			return fmt.Errorf("service %q: devices is not allowed", name)
		}
		for _, v := range svc.Volumes {
			if host, ok := bindMountHostPath(v); ok {
				if isBlockedBindMount(host) {
					return fmt.Errorf("service %q: bind-mounting %q is not allowed", name, host)
				}
			}
		}
	}
	return nil
}

// isHostMode reports a host-namespace share, i.e. network_mode / pid / ipc /
// userns_mode set to "host". (It deliberately does NOT match "host-gateway",
// which is an extra_hosts value, not a namespace mode — matching it here only
// produced misleading rejections.)
func isHostMode(v string) bool {
	return v == "host"
}

// isBlockedNamespaceMode reports a namespace mode that must be rejected for
// network_mode / pid / ipc: either host sharing ("host") or joining another
// container's / compose service's namespace ("container:<id>",
// "service:<name>"). The container/service joins are a lateral-movement and
// sandbox-escape vector — a malicious service could attach to a victim
// container's network or PID namespace.
func isBlockedNamespaceMode(v string) bool {
	return isHostMode(v) ||
		strings.HasPrefix(v, "container:") ||
		strings.HasPrefix(v, "service:")
}

// bindMountHostPath extracts the host-side path from a compose volume
// declaration. Compose accepts two shapes: a short string "src:dst[:mode]"
// and a long form map with a "source" key. Returns ok=false for named
// volumes (no host source).
func bindMountHostPath(v any) (string, bool) {
	switch t := v.(type) {
	case string:
		// "named:/in/container" has no '/' or '.' prefix on the source.
		if t == "" {
			return "", false
		}
		parts := strings.SplitN(t, ":", 3)
		src := parts[0]
		if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
			return src, true
		}
		return "", false
	case map[string]any:
		if typ, _ := t["type"].(string); typ != "" && typ != "bind" {
			return "", false
		}
		if src, ok := t["source"].(string); ok {
			if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
				return src, true
			}
		}
	}
	return "", false
}

// isBlockedBindMount returns true for paths that obviously escape the
// container's intended sandbox. Conservative deny-list — operators with
// legitimate bind-mount needs should write a dedicated Source plugin
// rather than tunnel them through compose.
func isBlockedBindMount(host string) bool {
	// Normalize trailing slash so "/var" and "/var/" both match.
	clean := strings.TrimRight(host, "/")
	if clean == "" || clean == "/" {
		return true
	}
	// Relative ("./x", "../x", ".") and home-relative ("~/...") sources are
	// resolved by Docker against the compose working directory (which
	// Tinyforge controls and never intends as a host-bind source) or left
	// unexpanded — and "../" can climb out of that directory entirely. The
	// absolute-prefix deny-list below can't see these, so reject them
	// outright rather than give a false sense of coverage.
	if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, "~") {
		return true
	}
	// Specific blocked files / sockets.
	switch clean {
	case "/var/run/docker.sock", "/run/docker.sock":
		return true
	}
	// Blocked prefixes (cover sub-paths too).
	blocked := []string{"/etc", "/var", "/proc", "/sys", "/root", "/home", "/boot", "/dev"}
	for _, p := range blocked {
		if clean == p || strings.HasPrefix(clean, p+"/") {
			return true
		}
	}
	return false
}
