package daemon

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"strings"

	"banger/internal/api"
	"banger/internal/daemon/imagemgr"
	"banger/internal/imagecat"
	"banger/internal/imagepull"
	"banger/internal/model"
	"banger/internal/paths"
	"banger/internal/system"

	"github.com/google/go-containerregistry/pkg/name"
)

// minPullExt4Size keeps the floor consistent with imagepull.MinExt4Size
// when the caller doesn't override --size and the OCI tree is tiny.
const minPullExt4Size int64 = 1 << 30 // 1 GiB

// PullImage downloads an image and registers it as a managed banger
// image. Two paths:
//
//   - Bundle path: `ref` matches an entry in the embedded imagecat
//     catalog. The `.tar.zst` bundle is fetched, `rootfs.ext4` is
//     already flattened + ownership-fixed + agent-injected at build
//     time, so this path is strictly faster than the OCI one.
//   - OCI path: otherwise treat `ref` as an OCI reference, pull its
//     layers, flatten, fix ownership, inject agents.
//
// Kernel info falls back through: `params.KernelRef` → catalog entry's
// `kernel_ref` (bundle path only) → `params.Kernel/Initrd/ModulesDir`.
//
// Concurrency: the slow staging work (network fetch, ext4 build,
// ownership fixup, guest-agent injection) runs WITHOUT imageOpsMu so
// parallel pulls of different images interleave. imageOpsMu is taken
// only for the publish window — recheck name is free, rename the
// staging dir to the final artifact dir, insert the store row. If two
// pulls race to the same name, the loser fails fast at the recheck
// and its staging dir is cleaned up via defer.
func (s *ImageService) PullImage(ctx context.Context, params api.ImagePullParams) (model.Image, error) {
	ref := strings.TrimSpace(params.Ref)
	if ref == "" {
		return model.Image{}, errors.New("reference is required")
	}

	catalog, err := imagecat.LoadEmbedded()
	if err != nil {
		return model.Image{}, fmt.Errorf("load image catalog: %w", err)
	}
	if entry, lookupErr := catalog.Lookup(ref); lookupErr == nil {
		return s.pullFromBundle(ctx, params, entry)
	}
	return s.pullFromOCI(ctx, params)
}

// publishImage is the narrow critical section shared by every image-
// creation path (pull bundle/OCI, register, promote). It re-verifies
// that `image.Name` is still free, atomically renames the staging
// directory to its final home (when applicable), and persists the row.
// The caller owns stagingDir cleanup on failure via its own defer; on
// success, publishImage unsets it so the defer is a no-op.
//
// finalDir == "" means "already published" (the caller built artifacts
// in place, e.g. RegisterImage which only touches the store). When
// non-empty the rename is the publication atom: finalDir must not
// already exist before the rename fires.
func (s *ImageService) publishImage(ctx context.Context, image model.Image, stagingDir, finalDir string) (model.Image, error) {
	s.imageOpsMu.Lock()
	defer s.imageOpsMu.Unlock()

	if existing, err := s.store.GetImageByName(ctx, image.Name); err == nil {
		return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", image.Name, existing.ID)
	}
	if finalDir != "" {
		if err := os.Rename(stagingDir, finalDir); err != nil {
			return model.Image{}, fmt.Errorf("publish artifact dir: %w", err)
		}
	}
	if err := s.store.UpsertImage(ctx, image); err != nil {
		if finalDir != "" {
			_ = os.RemoveAll(finalDir)
		}
		return model.Image{}, err
	}
	return image, nil
}

// pullFromOCI is the original OCI-registry-pull path. See PullImage for
// the intent.
func (s *ImageService) pullFromOCI(ctx context.Context, params api.ImagePullParams) (image model.Image, err error) {
	ref := strings.TrimSpace(params.Ref)
	parsed, err := name.ParseReference(ref)
	if err != nil {
		return model.Image{}, fmt.Errorf("parse oci ref %q: %w", ref, err)
	}

	imgName := strings.TrimSpace(params.Name)
	if imgName == "" {
		imgName = defaultImageNameFromRef(parsed)
		if imgName == "" {
			return model.Image{}, errors.New("could not derive image name from ref; pass --name")
		}
	}
	if existing, lookupErr := s.store.GetImageByName(ctx, imgName); lookupErr == nil {
		return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", imgName, existing.ID)
	}

	kernelPath, initrdPath, modulesDir, err := s.resolveKernelInputs(ctx, params.KernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
	if err != nil {
		return model.Image{}, err
	}
	if err := imagemgr.ValidateKernelPaths(kernelPath, initrdPath, modulesDir); err != nil {
		return model.Image{}, err
	}

	id, err := model.NewID()
	if err != nil {
		return model.Image{}, err
	}
	finalDir := filepath.Join(s.layout.ImagesDir, id)
	stagingDir := finalDir + ".staging"
	if err := os.MkdirAll(stagingDir, 0o755); err != nil {
		return model.Image{}, err
	}
	cleanupStaging := true
	defer func() {
		if cleanupStaging {
			_ = os.RemoveAll(stagingDir)
		}
	}()

	// Extract OCI layers into a working tree under TempDir so the
	// state filesystem doesn't temporarily double in size.
	rootfsTree, err := os.MkdirTemp("", "banger-pull-")
	if err != nil {
		return model.Image{}, err
	}
	defer os.RemoveAll(rootfsTree)

	meta, err := s.runPullAndFlatten(ctx, ref, s.layout.OCICacheDir, rootfsTree)
	if err != nil {
		return model.Image{}, fmt.Errorf("pull oci image: %w", err)
	}

	sizeBytes := params.SizeBytes
	if sizeBytes <= 0 {
		treeSize, err := dirSizeBytes(rootfsTree)
		if err != nil {
			return model.Image{}, fmt.Errorf("size oci tree: %w", err)
		}
		sizeBytes = treeSize + treeSize/4 // +25% headroom
		if sizeBytes < minPullExt4Size {
			sizeBytes = minPullExt4Size
		}
	}

	rootfsExt4 := filepath.Join(stagingDir, "rootfs.ext4")
	if err := imagepull.BuildExt4(ctx, s.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil {
		return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err)
	}
	if err := s.runFinalizePulledRootfs(ctx, rootfsExt4, meta); err != nil {
		return model.Image{}, err
	}
	workSeedExt4 := s.runBuildWorkSeed(ctx, rootfsExt4, stagingDir)

	stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, s.runner, stagingDir, kernelPath, initrdPath, modulesDir)
	if err != nil {
		return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err)
	}

	now := model.Now()
	image = model.Image{
		ID:          id,
		Name:        imgName,
		Managed:     true,
		ArtifactDir: finalDir,
		RootfsPath:  filepath.Join(finalDir, filepath.Base(rootfsExt4)),
		KernelPath:  rebaseUnder(stagedKernel, stagingDir, finalDir),
		InitrdPath:  rebaseUnder(stagedInitrd, stagingDir, finalDir),
		ModulesDir:  rebaseUnder(stagedModules, stagingDir, finalDir),
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	if workSeedExt4 != "" {
		image.WorkSeedPath = filepath.Join(finalDir, filepath.Base(workSeedExt4))
	}
	published, err := s.publishImage(ctx, image, stagingDir, finalDir)
	if err != nil {
		return model.Image{}, err
	}
	cleanupStaging = false
	return published, nil
}

// pullFromBundle is the imagecat-backed path: download a ready-to-boot
// bundle (rootfs.ext4 already flattened + ownership-fixed + agent-
// injected at build time), verify its sha256, and register the result
// as a managed image. No flatten / mkfs / debugfs work on the daemon
// host.
func (s *ImageService) pullFromBundle(ctx context.Context, params api.ImagePullParams, entry imagecat.CatEntry) (image model.Image, err error) {
	imgName := strings.TrimSpace(params.Name)
	if imgName == "" {
		imgName = entry.Name
	}
	if existing, lookupErr := s.store.GetImageByName(ctx, imgName); lookupErr == nil {
		return model.Image{}, fmt.Errorf("image %q already exists (id=%s); pick a different --name or delete it first", imgName, existing.ID)
	}

	// Kernel resolution precedence: params > catalog entry's kernel_ref.
	kernelRef := strings.TrimSpace(params.KernelRef)
	if kernelRef == "" && strings.TrimSpace(params.KernelPath) == "" {
		kernelRef = strings.TrimSpace(entry.KernelRef)
	}
	kernelPath, initrdPath, modulesDir, err := s.resolveKernelInputs(ctx, kernelRef, params.KernelPath, params.InitrdPath, params.ModulesDir)
	if err != nil {
		return model.Image{}, err
	}
	if err := imagemgr.ValidateKernelPaths(kernelPath, initrdPath, modulesDir); err != nil {
		return model.Image{}, err
	}

	id, err := model.NewID()
	if err != nil {
		return model.Image{}, err
	}
	finalDir := filepath.Join(s.layout.ImagesDir, id)
	stagingDir := finalDir + ".staging"
	if err := os.MkdirAll(stagingDir, 0o755); err != nil {
		return model.Image{}, err
	}
	cleanupStaging := true
	defer func() {
		if cleanupStaging {
			_ = os.RemoveAll(stagingDir)
		}
	}()

	if _, err := s.runBundleFetch(ctx, stagingDir, entry); err != nil {
		return model.Image{}, fmt.Errorf("fetch bundle: %w", err)
	}
	// manifest.json is metadata we only need at fetch time; strip it
	// so the final artifact dir contains only boot-relevant files.
	_ = os.Remove(filepath.Join(stagingDir, imagecat.ManifestFilename))
	rootfsExt4 := filepath.Join(stagingDir, imagecat.RootfsFilename)
	workSeedExt4 := s.runBuildWorkSeed(ctx, rootfsExt4, stagingDir)

	stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, s.runner, stagingDir, kernelPath, initrdPath, modulesDir)
	if err != nil {
		return model.Image{}, fmt.Errorf("stage boot artifacts: %w", err)
	}

	now := model.Now()
	image = model.Image{
		ID:          id,
		Name:        imgName,
		Managed:     true,
		ArtifactDir: finalDir,
		RootfsPath:  filepath.Join(finalDir, filepath.Base(rootfsExt4)),
		KernelPath:  rebaseUnder(stagedKernel, stagingDir, finalDir),
		InitrdPath:  rebaseUnder(stagedInitrd, stagingDir, finalDir),
		ModulesDir:  rebaseUnder(stagedModules, stagingDir, finalDir),
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	if workSeedExt4 != "" {
		image.WorkSeedPath = filepath.Join(finalDir, filepath.Base(workSeedExt4))
	}
	published, err := s.publishImage(ctx, image, stagingDir, finalDir)
	if err != nil {
		return model.Image{}, err
	}
	cleanupStaging = false
	return published, nil
}

// runBundleFetch is the seam tests substitute. nil → real implementation.
func (s *ImageService) runBundleFetch(ctx context.Context, destDir string, entry imagecat.CatEntry) (imagecat.Manifest, error) {
	if s.bundleFetch != nil {
		return s.bundleFetch(ctx, destDir, entry)
	}
	return imagecat.Fetch(ctx, nil, destDir, entry)
}

// runPullAndFlatten is the seam tests substitute. nil → real implementation.
func (s *ImageService) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) {
	if s.pullAndFlatten != nil {
		return s.pullAndFlatten(ctx, ref, cacheDir, destDir)
	}
	pulled, err := imagepull.Pull(ctx, ref, cacheDir)
	if err != nil {
		return imagepull.Metadata{}, err
	}
	return imagepull.Flatten(ctx, pulled, destDir)
}

// runFinalizePulledRootfs applies ownership fixup and injects banger's
// guest agents. Tests substitute via s.finalizePulledRootfs; nil →
// real implementation using debugfs + the companion vsock-agent
// binary resolved via paths.CompanionBinaryPath.
func (s *ImageService) runFinalizePulledRootfs(ctx context.Context, ext4File string, meta imagepull.Metadata) error {
	if s.finalizePulledRootfs != nil {
		return s.finalizePulledRootfs(ctx, ext4File, meta)
	}
	if err := imagepull.ApplyOwnership(ctx, s.runner, ext4File, meta); err != nil {
		return fmt.Errorf("apply ownership: %w", err)
	}
	vsockBin, err := paths.CompanionBinaryPath("banger-vsock-agent")
	if err != nil {
		return fmt.Errorf("locate vsock agent binary: %w", err)
	}
	if err := imagepull.InjectGuestAgents(ctx, s.runner, ext4File, imagepull.GuestAgentAssets{
		VsockAgentBin: vsockBin,
	}); err != nil {
		return fmt.Errorf("inject guest agents: %w", err)
	}
	return nil
}

// runBuildWorkSeed extracts /root from the pulled rootfs into a
// sibling work-seed ext4 image. Any failure is treated as non-fatal:
// the image is still publishable without a seed, and VM create falls
// back to the empty-work-disk path (losing distro dotfiles but keeping
// every other guarantee). Returns the work-seed path on success, "" on
// failure (with a warn logged). Tests substitute via s.workSeedBuilder.
func (s *ImageService) runBuildWorkSeed(ctx context.Context, rootfsExt4, stagingDir string) string {
	outPath := filepath.Join(stagingDir, "work-seed.ext4")
	var err error
	if s.workSeedBuilder != nil {
		err = s.workSeedBuilder(ctx, rootfsExt4, outPath)
	} else {
		err = system.BuildWorkSeedImage(ctx, s.runner, rootfsExt4, outPath)
	}
	if err != nil {
		if s.logger != nil {
			s.logger.Warn("work-seed build failed; VMs using this image will start with an empty /root", "rootfs", rootfsExt4, "error", err.Error())
		}
		_ = os.Remove(outPath)
		return ""
	}
	return outPath
}

// nameSanitize keeps lowercase alphanumerics + hyphens, collapses runs.
var nameSanitizeRE = regexp.MustCompile(`[^a-z0-9]+`)

// defaultImageNameFromRef derives a friendly name like "debian-bookworm"
// from "docker.io/library/debian:bookworm". Returns "" if it can't.
func defaultImageNameFromRef(ref name.Reference) string {
	repo := ref.Context().RepositoryStr() // e.g. library/debian
	parts := strings.Split(repo, "/")
	base := parts[len(parts)-1]

	suffix := ""
	switch r := ref.(type) {
	case name.Tag:
		if t := r.TagStr(); t != "" && t != "latest" {
			suffix = "-" + t
		}
	case name.Digest:
		// take the first 12 hex chars after sha256:
		d := r.DigestStr()
		if i := strings.Index(d, ":"); i >= 0 && len(d) >= i+13 {
			suffix = "-" + d[i+1:i+13]
		}
	}

	out := nameSanitizeRE.ReplaceAllString(strings.ToLower(base+suffix), "-")
	out = strings.Trim(out, "-")
	return out
}

// rebaseUnder rewrites a path that points inside oldRoot to point inside
// newRoot. Empty input returns empty (kept by StageBootArtifacts when an
// optional artifact is absent).
func rebaseUnder(path, oldRoot, newRoot string) string {
	if path == "" {
		return ""
	}
	if rel, err := filepath.Rel(oldRoot, path); err == nil && !strings.HasPrefix(rel, "..") {
		return filepath.Join(newRoot, rel)
	}
	return path
}

// dirSizeBytes returns the sum of regular-file sizes under root, following
// no symlinks (lstat). Suitable for sizing an ext4 image.
func dirSizeBytes(root string) (int64, error) {
	var total int64
	err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.Type().IsRegular() {
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return err
		}
		total += info.Size()
		return nil
	})
	return total, err
}
