package envpool

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"os/exec"
	"strings"
	"sync"
	"time"
)

type CommandRunner interface {
	CombinedOutput(ctx context.Context, name string, args ...string) ([]byte, error)
}

type execRunner struct{}

func (r execRunner) CombinedOutput(
	ctx context.Context,
	name string,
	args ...string,
) ([]byte, error) {
	cmd := exec.CommandContext(ctx, name, args...)
	return cmd.CombinedOutput()
}

type Pool struct {
	runner      CommandRunner
	cache       map[string]cacheEntry
	imagePrefix string
	cacheTTL    time.Duration
	cacheMu     sync.Mutex
}

type cacheEntry struct {
	expires time.Time
	exists  bool
}

func New(imagePrefix string) *Pool {
	prefix := strings.TrimSpace(imagePrefix)
	if prefix == "" {
		prefix = "fetchml-prewarm"
	}
	return &Pool{
		runner:      execRunner{},
		imagePrefix: prefix,
		cache:       make(map[string]cacheEntry),
		cacheTTL:    30 * time.Second,
	}
}

func (p *Pool) WithRunner(r CommandRunner) *Pool {
	if r != nil {
		p.runner = r
	}
	return p
}

func (p *Pool) WithCacheTTL(ttl time.Duration) *Pool {
	if ttl > 0 {
		p.cacheTTL = ttl
	}
	return p
}

func (p *Pool) WarmImageTag(depsManifestSHA256 string) (string, error) {
	sha := strings.TrimSpace(depsManifestSHA256)
	if sha == "" {
		return "", fmt.Errorf("missing deps sha256")
	}
	if !isLowerHexLen(sha, 64) {
		return "", fmt.Errorf("invalid deps sha256")
	}
	return fmt.Sprintf("%s:%s", p.imagePrefix, sha[:12]), nil
}

func (p *Pool) ImageExists(ctx context.Context, imageRef string) (bool, error) {
	ref := strings.TrimSpace(imageRef)
	if ref == "" {
		return false, fmt.Errorf("missing image ref")
	}

	p.cacheMu.Lock()
	if ent, ok := p.cache[ref]; ok && time.Now().Before(ent.expires) {
		exists := ent.exists
		p.cacheMu.Unlock()
		return exists, nil
	}
	p.cacheMu.Unlock()

	out, err := p.runner.CombinedOutput(ctx, "podman", "image", "inspect", ref)
	if err == nil {
		p.setCache(ref, true)
		return true, nil
	}
	if looksLikeImageNotFound(out) {
		p.setCache(ref, false)
		return false, nil
	}
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		p.setCache(ref, false)
		return false, nil
	}
	return false, err
}

func looksLikeImageNotFound(out []byte) bool {
	s := strings.ToLower(strings.TrimSpace(string(out)))
	if s == "" {
		return false
	}
	return strings.Contains(s, "no such") ||
		strings.Contains(s, "not found") ||
		strings.Contains(s, "does not exist")
}

func (p *Pool) setCache(imageRef string, exists bool) {
	p.cacheMu.Lock()
	p.cache[imageRef] = cacheEntry{exists: exists, expires: time.Now().Add(p.cacheTTL)}
	p.cacheMu.Unlock()
}

type PrepareRequest struct {
	BaseImage           string
	TargetImage         string
	HostWorkspace       string
	ContainerWorkspace  string
	DepsPathInContainer string
}

func (p *Pool) PruneImages(ctx context.Context, olderThan time.Duration) error {
	if olderThan <= 0 {
		return fmt.Errorf("invalid olderThan")
	}
	h := int(olderThan.Round(time.Hour).Hours())
	if h < 1 {
		h = 1
	}
	until := fmt.Sprintf("%dh", h)
	_, err := p.runner.CombinedOutput(
		ctx,
		"podman",
		"image",
		"prune",
		"-a",
		"-f",
		"--filter",
		"label=fetchml.prewarm=true",
		"--filter",
		"until="+until,
	)
	return err
}

func (p *Pool) Prepare(ctx context.Context, req PrepareRequest) error {
	baseImage := strings.TrimSpace(req.BaseImage)
	targetImage := strings.TrimSpace(req.TargetImage)
	hostWS := strings.TrimSpace(req.HostWorkspace)
	containerWS := strings.TrimSpace(req.ContainerWorkspace)
	depsInContainer := strings.TrimSpace(req.DepsPathInContainer)

	if baseImage == "" {
		return fmt.Errorf("missing base image")
	}
	if targetImage == "" {
		return fmt.Errorf("missing target image")
	}
	if hostWS == "" {
		return fmt.Errorf("missing host workspace")
	}
	if containerWS == "" {
		return fmt.Errorf("missing container workspace")
	}
	if depsInContainer == "" {
		return fmt.Errorf("missing deps path")
	}
	if !strings.HasPrefix(depsInContainer, containerWS) {
		return fmt.Errorf("deps path must be under container workspace")
	}

	exists, err := p.ImageExists(ctx, targetImage)
	if err != nil {
		return err
	}
	if exists {
		return nil
	}

	containerName, err := randomContainerName("fetchml-prewarm")
	if err != nil {
		return err
	}

	// Do not use --rm since we need a container to commit.
	runArgs := []string{
		"run",
		"--name", containerName,
		"--security-opt", "no-new-privileges",
		"--cap-drop", "ALL",
		"--userns", "keep-id",
		"-v", fmt.Sprintf("%s:%s:rw", hostWS, containerWS),
		baseImage,
		"--workspace", containerWS,
		"--deps", depsInContainer,
		"--prepare-only",
	}

	if out, err := p.runner.CombinedOutput(ctx, "podman", runArgs...); err != nil {
		_ = p.cleanupContainer(context.Background(), containerName)
		return fmt.Errorf("podman run prewarm failed: %w", scrubOutput(out, err))
	}

	if out, err := p.runner.CombinedOutput(
		ctx,
		"podman",
		"commit",
		containerName,
		targetImage,
	); err != nil {
		_ = p.cleanupContainer(context.Background(), containerName)
		return fmt.Errorf("podman commit prewarm failed: %w", scrubOutput(out, err))
	}
	_, _ = p.runner.CombinedOutput(
		ctx,
		"podman",
		"image",
		"label",
		targetImage,
		"fetchml.prewarm=true",
	)

	_ = p.cleanupContainer(context.Background(), containerName)
	p.setCache(targetImage, true)
	return nil
}

func (p *Pool) cleanupContainer(ctx context.Context, name string) error {
	n := strings.TrimSpace(name)
	if n == "" {
		return nil
	}
	_, err := p.runner.CombinedOutput(ctx, "podman", "rm", n)
	return err
}

func randomContainerName(prefix string) (string, error) {
	p := strings.TrimSpace(prefix)
	if p == "" {
		p = "fetchml-prewarm"
	}
	b := make([]byte, 6)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return fmt.Sprintf("%s-%s", p, hex.EncodeToString(b)), nil
}

func isLowerHexLen(s string, want int) bool {
	if len(s) != want {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
			continue
		}
		return false
	}
	return true
}

func scrubOutput(out []byte, err error) error {
	if len(out) == 0 {
		return err
	}
	s := strings.TrimSpace(string(out))
	if len(s) > 400 {
		s = s[:400]
	}
	return fmt.Errorf("%w (output=%q)", err, s)
}
