// Package kernelcat is the on-disk catalog of Firecracker-ready kernel
// bundles. Each entry lives at <kernelsDir>/<name>/ and contains a
// manifest.json alongside the vmlinux, optional initrd.img, and optional
// modules/ tree. The package owns the layout, manifest read/write, and
// validation; it does not talk to the network (remote pulls are layered on
// later).
package kernelcat

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"time"
)

// Filenames used inside an entry directory.
const (
	manifestFilename = "manifest.json"
	kernelFilename   = "vmlinux"
	initrdFilename   = "initrd.img"
	modulesDirName   = "modules"
)

// Entry describes a cataloged kernel bundle. Paths are absolute and
// populated from the entry's on-disk layout when read via ReadLocal /
// ListLocal; they are never written into the manifest itself.
type Entry struct {
	Name          string    `json:"name"`
	Distro        string    `json:"distro,omitempty"`
	Arch          string    `json:"arch,omitempty"`
	KernelVersion string    `json:"kernel_version,omitempty"`
	SHA256        string    `json:"sha256,omitempty"`
	Source        string    `json:"source,omitempty"`
	ImportedAt    time.Time `json:"imported_at"`

	// Populated on read, not persisted:
	KernelPath string `json:"-"`
	InitrdPath string `json:"-"`
	ModulesDir string `json:"-"`
}

// namePattern matches names that are safe as single filesystem components.
// Intentionally strict so entry names stay short and script-friendly.
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)

// ValidateName returns an error unless name is a non-empty identifier made
// of alphanumerics, dots, hyphens, and underscores, starting with an
// alphanumeric and at most 64 characters long.
func ValidateName(name string) error {
	if strings.TrimSpace(name) == "" {
		return errors.New("kernel name is required")
	}
	if !namePattern.MatchString(name) {
		return fmt.Errorf("invalid kernel name %q: use alphanumerics, dots, hyphens, underscores (<=64 chars, starts with alphanumeric)", name)
	}
	return nil
}

// EntryDir returns the absolute directory path for name under kernelsDir.
func EntryDir(kernelsDir, name string) string {
	return filepath.Join(kernelsDir, name)
}

// ReadLocal reads the manifest for name and resolves per-artifact paths.
// Returns os.ErrNotExist-compatible error if the entry is missing.
func ReadLocal(kernelsDir, name string) (Entry, error) {
	if err := ValidateName(name); err != nil {
		return Entry{}, err
	}
	dir := EntryDir(kernelsDir, name)
	data, err := os.ReadFile(filepath.Join(dir, manifestFilename))
	if err != nil {
		return Entry{}, err
	}
	var entry Entry
	if err := json.Unmarshal(data, &entry); err != nil {
		return Entry{}, fmt.Errorf("parse manifest for %q: %w", name, err)
	}
	if entry.Name == "" {
		entry.Name = name
	}
	if entry.Name != name {
		return Entry{}, fmt.Errorf("manifest name %q does not match directory %q", entry.Name, name)
	}
	entry.KernelPath = filepath.Join(dir, kernelFilename)
	if fi, err := os.Stat(filepath.Join(dir, initrdFilename)); err == nil && !fi.IsDir() {
		entry.InitrdPath = filepath.Join(dir, initrdFilename)
	}
	if fi, err := os.Stat(filepath.Join(dir, modulesDirName)); err == nil && fi.IsDir() {
		entry.ModulesDir = filepath.Join(dir, modulesDirName)
	}
	return entry, nil
}

// ListLocal returns every entry under kernelsDir with a readable manifest,
// sorted by name. Directories without a manifest are skipped silently so
// partial imports don't break the list.
func ListLocal(kernelsDir string) ([]Entry, error) {
	dirEntries, err := os.ReadDir(kernelsDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	entries := make([]Entry, 0, len(dirEntries))
	for _, de := range dirEntries {
		if !de.IsDir() {
			continue
		}
		name := de.Name()
		if err := ValidateName(name); err != nil {
			continue
		}
		entry, err := ReadLocal(kernelsDir, name)
		if err != nil {
			if os.IsNotExist(err) {
				continue
			}
			return nil, err
		}
		entries = append(entries, entry)
	}
	sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
	return entries, nil
}

// WriteLocal persists entry's manifest.json. The caller is responsible for
// placing vmlinux / initrd.img / modules/ under the entry dir first.
func WriteLocal(kernelsDir string, entry Entry) error {
	if err := ValidateName(entry.Name); err != nil {
		return err
	}
	dir := EntryDir(kernelsDir, entry.Name)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	if entry.ImportedAt.IsZero() {
		entry.ImportedAt = time.Now().UTC()
	}
	data, err := json.MarshalIndent(entry, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(dir, manifestFilename), append(data, '\n'), 0o644)
}

// DeleteLocal removes the entry directory entirely. Missing entries are a
// no-op so callers can idempotently clean up.
func DeleteLocal(kernelsDir, name string) error {
	if err := ValidateName(name); err != nil {
		return err
	}
	dir := EntryDir(kernelsDir, name)
	if _, err := os.Stat(dir); err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	return os.RemoveAll(dir)
}

// SumFile returns the hex-encoded SHA256 of the file at path.
func SumFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	hasher := sha256.New()
	if _, err := io.Copy(hasher, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(hasher.Sum(nil)), nil
}
