//go:build linux

package microvm

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

const imageSpecFileName = "spec.json"

type RunnerConfig struct {
	CPU       string   `json:"cpu,omitempty"`
	Machine   string   `json:"machine,omitempty"`
	Console   string   `json:"console,omitempty"`
	ExtraArgs []string `json:"extraArgs,omitempty"`
}

type ImageSpec struct {
	Arch              string             `json:"arch"`
	BootArgs          string             `json:"bootArgs"`
	Initrd            string             `json:"initrd"`
	Kernel            string             `json:"kernel"`
	RunnerType        string             `json:"runnerType"`
	RunnerConfig      RunnerConfig       `json:"runnerConfig"`
	MemoryMiB         int                `json:"memoryMiB"`
	NetworkInterfaces []NetworkInterface `json:"networkInterfaces"`
	StoreDisk         string             `json:"storeDisk"`
	StoreDiskType     string             `json:"storeDiskType"`
	// baseConfigHash identifies the base nixos configuration baked into the
	// image. its only for nixos images as other images won't have a system
	// to rebuild.
	BaseConfigHash string `json:"baseConfigHash,omitempty"`
	// shell is the login shell used to run workflow step commands in the guest.
	Shell   string   `json:"shell"`
	VCPUs   int      `json:"vcpus"`
	Volumes []Volume `json:"volumes"`
}

func (s ImageSpec) SupportsConfigActivation() bool {
	return s.BaseConfigHash != ""
}

type NetworkInterface struct {
	Type string `json:"type"`
	ID   string `json:"id"`
	MAC  string `json:"mac"`
}

type Volume struct {
	FSType     string `json:"fsType"`
	Image      string `json:"image"`
	ImageType  string `json:"imageType"`
	MountPoint string `json:"mountPoint"`
	ReadOnly   bool   `json:"readOnly"`
	SizeMiB    int64  `json:"sizeMiB"`
}

func LoadImageSpec(path string) (ImageSpec, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return ImageSpec{}, fmt.Errorf("read microvm image spec: %w", err)
	}

	var spec ImageSpec
	if err := json.Unmarshal(data, &spec); err != nil {
		return ImageSpec{}, fmt.Errorf("parse microvm image spec: %w", err)
	}

	base := filepath.Dir(path)
	spec.Kernel = resolveImageSpecPath(base, spec.Kernel)
	spec.Initrd = resolveImageSpecPath(base, spec.Initrd)
	spec.StoreDisk = resolveImageSpecPath(base, spec.StoreDisk)

	if err := spec.Validate(); err != nil {
		return ImageSpec{}, err
	}
	return spec, nil
}

func (s ImageSpec) Validate() error {
	if s.Kernel == "" {
		return fmt.Errorf("microvm image spec missing kernel")
	}
	if s.Initrd == "" {
		return fmt.Errorf("microvm image spec missing initrd")
	}
	if s.StoreDisk == "" {
		return fmt.Errorf("microvm image spec missing storeDisk")
	}
	if s.BootArgs == "" {
		return fmt.Errorf("microvm image spec missing bootArgs")
	}
	if s.Shell == "" {
		return fmt.Errorf("microvm image spec missing shell")
	}
	if s.RunnerType == "qemu" || s.RunnerType == "" {
		if s.RunnerConfig.Machine == "" {
			return fmt.Errorf("microvm image spec missing runnerConfig.machine for qemu runner")
		}
	}
	if s.MemoryMiB <= 0 {
		return fmt.Errorf("microvm image spec memoryMiB must be positive")
	}
	if s.VCPUs <= 0 {
		return fmt.Errorf("microvm image spec vcpus must be positive")
	}
	for _, networkInterface := range s.NetworkInterfaces {
		if networkInterface.Type == "" {
			return fmt.Errorf("microvm image spec network interface missing type")
		}
		if networkInterface.ID == "" {
			return fmt.Errorf("microvm image spec network interface missing id")
		}
		if networkInterface.MAC == "" {
			return fmt.Errorf("microvm image spec network interface %q missing mac", networkInterface.ID)
		}
	}
	for _, volume := range s.Volumes {
		if volume.Image == "" {
			return fmt.Errorf("microvm image spec volume missing image")
		}
		if volume.FSType == "" {
			return fmt.Errorf("microvm image spec volume %q missing fsType", volume.Image)
		}
		if volume.SizeMiB <= 0 {
			return fmt.Errorf("microvm image spec volume %q sizeMiB must be positive", volume.Image)
		}
	}
	return nil
}

func (s ImageSpec) RunnerCmd() string {
	switch s.RunnerType {
	case "qemu", "":
		return "qemu-system-" + s.Arch
	case "firecracker":
		return "firecracker"
	default:
		return ""
	}
}

// also see Runner.Validate for where Runner specific files are validated
func (s ImageSpec) validateImageFiles() error {
	required := map[string]string{
		"kernel":    s.Kernel,
		"initrd":    s.Initrd,
		"storeDisk": s.StoreDisk,
	}
	for name, path := range required {
		if !filepath.IsAbs(path) {
			continue
		}
		if _, err := os.Stat(path); err != nil {
			return fmt.Errorf("required image spec file %s not found at %q: %w", name, path, err)
		}
	}

	return nil
}

func resolveImageSpecPath(base, path string) string {
	if path == "" || filepath.IsAbs(path) {
		return path
	}
	return filepath.Join(base, path)
}

func (e *Engine) resolveImage(name string) (ImageSpec, string, string, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		name = strings.TrimSpace(e.cfg.MicroVMPipelines.DefaultImage)
	}
	if name == "" {
		return ImageSpec{}, "", "", fmt.Errorf("no image specified in workflow and SPINDLE_MICROVM_PIPELINES_DEFAULT_IMAGE is not set")
	}
	if !isPlainImageName(name) {
		return ImageSpec{}, "", "", fmt.Errorf("invalid microVM image name %q: must be a plain name, not a path", name)
	}

	imageDir := strings.TrimSpace(e.cfg.MicroVMPipelines.ImageDir)
	if imageDir == "" {
		return ImageSpec{}, "", "", fmt.Errorf("microVM workflows require SPINDLE_MICROVM_PIPELINES_IMAGE_DIR")
	}

	candidates := imageCandidates(imageDir, name)
	for _, candidate := range candidates {
		path, ok, err := imageSpecPath(candidate)
		if err != nil {
			return ImageSpec{}, "", "", err
		}
		if !ok {
			continue
		}
		imageSpec, err := LoadImageSpec(path)
		if err != nil {
			return ImageSpec{}, "", "", err
		}
		return imageSpec, path, name, nil
	}

	return ImageSpec{}, "", "", fmt.Errorf("microVM image %q was not found; looked in: %s", name, strings.Join(candidates, ", "))
}

// check if image name is not a path
func isPlainImageName(name string) bool {
	if name == "" || name == "." || name == ".." {
		return false
	}
	if filepath.IsAbs(name) || strings.ContainsRune(name, '/') || strings.ContainsRune(name, filepath.Separator) {
		return false
	}
	return true
}

// returns candidates, which is either a directory or spec file itself
func imageCandidates(imageDir, name string) []string {
	if imageDir == "" {
		return nil
	}
	return []string{
		filepath.Join(imageDir, name),
		filepath.Join(imageDir, name+".json"),
	}
}

// resolve the candidate to a spec:
// - first check if its a file, if yes, return
// - otherwise assume its a directory and check and return `/spec.json`
func imageSpecPath(candidate string) (string, bool, error) {
	info, err := os.Stat(candidate)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", false, nil
		}
		return "", false, err
	}
	if !info.IsDir() {
		return candidate, true, nil
	}

	spec := filepath.Join(candidate, imageSpecFileName)
	info, err = os.Stat(spec)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", false, fmt.Errorf("microVM image directory %q does not contain %s", candidate, imageSpecFileName)
		}
		return "", false, err
	}
	// this only happens if there is a directory named `spec.json` which would be very silly.
	// but better output an error for it anyway :p
	if info.IsDir() {
		return "", false, fmt.Errorf("microVM image spec %q is a directory", spec)
	}
	return spec, true, nil
}
