package engine

import (
	"bufio"
	"bytes"
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os/exec"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
	"tangled.org/core/spindle/db"

	"tangled.org/core/spindle/models"
	"tangled.org/core/spindle/storage"
)

// cache log steps live below the setup step (-1)
const (
	CacheRestoreStepIdx = -2
	CacheSaveStepIdx    = -3
)

type cacheStep struct {
	name    string
	command string
}

func (s cacheStep) Name() string          { return s.name }
func (s cacheStep) Command() string       { return s.command }
func (s cacheStep) Kind() models.StepKind { return models.StepKindSystem }

var (
	CacheRestoreStep models.Step = cacheStep{name: "restore cache", command: "restore cached paths"}
	CacheSaveStep    models.Step = cacheStep{name: "save cache", command: "persist changed paths"}
)

type CacheRunner interface {
	RestoreCache(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, store storage.Storage, caches []ResolvedCache, wfLogger models.WorkflowLogger) error
	SaveCache(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, store storage.Storage, caches []ResolvedCache, wfLogger models.WorkflowLogger) error
}

type ResolvedCache struct {
	Paths            []string
	Key              string
	Hash             string
	SaveKey          string
	RestoreID        string
	RestoreKey       string
	RestoreName      string
	CompressionLevel int
	When             string
}

func (rc ResolvedCache) saveOn(failed bool) bool {
	return rc.When == "always" || !failed
}

const CacheExitNoPaths = 42

// avoids storing an empty archive when zstd is missing
const CacheExitNoCompressor = 43

func AbsolutizePaths(paths []string, workspaceRoot string) []string {
	abs := make([]string, 0, len(paths))
	for _, p := range paths {
		if !strings.HasPrefix(p, "/") {
			p = workspaceRoot + "/" + p
		}
		abs = append(abs, p)
	}
	return abs
}

// run tar from / so absolute paths survive extraction
// tar exits nonzero on missing paths, so only existing ones reach it
func CacheSaveScript(paths []string, workspaceRoot string, compressionLevel int) string {
	trimmed := make([]string, 0, len(paths))
	for _, p := range AbsolutizePaths(paths, workspaceRoot) {
		trimmed = append(trimmed, strings.TrimPrefix(p, "/"))
	}
	tail := fmt.Sprintf(`tar -cf - -C / "$@" | %s`, CacheCompressCmd(compressionLevel))
	return fmt.Sprintf(`set -o pipefail
command -v zstd >/dev/null 2>&1 || { echo "zstd not found in image; cannot save cache" >&2; exit %d; }
set --
for p in %s; do [ -e "/$p" ] && set -- "$@" "$p"; done
if [ $# -eq 0 ]; then echo "no cache paths exist; skipping" >&2; exit %d; fi
%s`, CacheExitNoCompressor, strings.Join(trimmed, " "), CacheExitNoPaths, tail)
}

func CacheCompressCmd(level int) string {
	if level == 0 {
		return "zstd -T0 -5"
	}
	return fmt.Sprintf("zstd -T0 -%d", level)
}

// old entries might still be gzip, so detect them instead of trusting config
func CacheDecompressCmd(br *bufio.Reader) string {
	head, _ := br.Peek(4)
	if bytes.HasPrefix(head, []byte{0x1f, 0x8b}) {
		return "gzip -dc"
	}
	return "zstd -dc"
}

// Put keeps draining the pipe after it returns, so the guest writer never
// blocks on a full pipe.
type CacheUpload struct {
	Writer *io.PipeWriter
	done   chan error
}

func NewCacheUpload(ctx context.Context, store storage.Storage, key string) *CacheUpload {
	pr, pw := io.Pipe()
	u := &CacheUpload{Writer: pw, done: make(chan error, 1)}
	go func() {
		err := store.Put(ctx, key, pr)
		_, _ = io.Copy(io.Discard, pr)
		u.done <- err
	}()
	return u
}

func (u *CacheUpload) Abort(err error) {
	u.Writer.CloseWithError(err)
	<-u.done
}

// storage treats EOF as a complete archive, so only a clean exec may Finish
func (u *CacheUpload) Finish() error {
	u.Writer.Close()
	return <-u.done
}

type indexedCacheStore struct {
	storage.Storage
	index   *db.DB
	logger  *slog.Logger
	mu      sync.Mutex
	entries map[string]string // storage key -> cache entry id
}

func (s *indexedCacheStore) Get(ctx context.Context, key string) (io.ReadCloser, error) {
	s.mu.Lock()
	id, ok := s.entries[key]
	s.mu.Unlock()
	r, err := s.Storage.Get(ctx, key)
	if err != nil {
		if ok && errors.Is(err, storage.ErrNotExist) {
			_ = s.index.DeleteCacheEntry(context.WithoutCancel(ctx), id)
		}
		return nil, err
	}
	if ok {
		if err := s.index.TouchCacheEntry(ctx, id, time.Now()); err != nil {
			s.logger.Warn("cache usage update failed", "id", id, "err", err)
		}
	}
	return r, nil
}

func (s *indexedCacheStore) Put(ctx context.Context, key string, r io.Reader) error {
	s.mu.Lock()
	id, ok := s.entries[key]
	s.mu.Unlock()
	if !ok {
		return fmt.Errorf("cache metadata missing for %q", key)
	}
	counted := &countingReader{r: r}
	if err := s.Storage.Put(ctx, key, counted); err != nil {
		return err
	}
	superseded, err := s.index.MarkCacheEntryReady(ctx, id, counted.n, time.Now())
	if err != nil {
		_ = s.Storage.Delete(context.WithoutCancel(ctx), key)
		return fmt.Errorf("mark cache ready: %w", err)
	}
	// completed saves leave the pending map so cleanup keeps their object
	s.mu.Lock()
	delete(s.entries, key)
	s.mu.Unlock()
	cleanupCtx := context.WithoutCancel(ctx)
	for _, old := range superseded {
		s.deleteEntry(cleanupCtx, old.StorageKey, old.ID, "replaced")
	}
	return nil
}

func (s *indexedCacheStore) cleanup(ctx context.Context) {
	s.mu.Lock()
	defer s.mu.Unlock()
	for key, id := range s.entries {
		s.deleteEntry(ctx, key, id, "incomplete")
	}
}

func (s *indexedCacheStore) deleteEntry(ctx context.Context, key, id, reason string) {
	if err := s.Storage.Delete(ctx, key); err != nil {
		s.logger.Warn("delete "+reason+" cache failed", "key", key, "err", err)
		return
	}
	if err := s.index.DeleteCacheEntry(ctx, id); err != nil {
		s.logger.Warn("delete "+reason+" cache metadata failed", "id", id, "err", err)
	}
}

type countingReader struct {
	r io.Reader
	n int64
}

func (r *countingReader) Read(p []byte) (int, error) {
	n, err := r.r.Read(p)
	r.n += int64(n)
	return n, err
}

func cacheStoreForRestore(base storage.Storage, index *db.DB, l *slog.Logger, entries []ResolvedCache) storage.Storage {
	mapped := make(map[string]string, len(entries))
	for _, entry := range entries {
		if entry.RestoreKey != "" {
			mapped[entry.RestoreKey] = entry.RestoreID
		}
	}
	return &indexedCacheStore{Storage: base, index: index, logger: l, entries: mapped}
}

func prepareCacheSaves(ctx context.Context, base storage.Storage, index *db.DB, l *slog.Logger, ownerDID, repoDID, engineName string, entries []ResolvedCache) (*indexedCacheStore, error) {
	saveStore := &indexedCacheStore{
		Storage: base,
		index:   index,
		logger:  l,
		entries: make(map[string]string, len(entries)),
	}
	now := time.Now()
	for i := range entries {
		id := uuid.NewString()
		entries[i].SaveKey = "objects/" + id
		if err := index.InsertCacheEntry(ctx, db.CacheEntry{
			ID:         id,
			StorageKey: entries[i].SaveKey,
			OwnerDID:   ownerDID,
			RepoDID:    repoDID,
			Engine:     engineName,
			CacheKey:   entries[i].Key,
			CacheHash:  entries[i].Hash,
			State:      "pending",
			CreatedAt:  now,
			LastUsedAt: now,
		}); err != nil {
			saveStore.cleanup(context.WithoutCancel(ctx))
			return nil, err
		}
		saveStore.entries[entries[i].SaveKey] = id
	}
	return saveStore, nil
}

// on a hash miss, the newest older generation still warms the build
// unusable entries degrade to a plain miss
func ResolveCaches(ctx context.Context, l *slog.Logger, index *db.DB, repoDID, engine, repoPath, rev string, entries []models.CacheEntry) []ResolvedCache {
	resolved := make([]ResolvedCache, 0, len(entries))
	for _, entry := range entries {
		hash := ""
		if len(entry.Hash) > 0 && repoPath != "" {
			sum, missing := hashKeyFiles(ctx, repoPath, rev, entry.Hash)
			for _, m := range missing {
				l.Warn("cache hash file not in repo", "key", entry.Key, "path", m)
			}
			hash = sum
		}
		rc := ResolvedCache{
			Paths:            entry.Paths,
			Key:              entry.Key,
			Hash:             hash,
			CompressionLevel: entry.CompressionLevel,
			When:             entry.When,
		}

		found, err := index.FindCacheEntry(ctx, repoDID, engine, entry.Key, hash)
		if errors.Is(err, sql.ErrNoRows) && hash != "" {
			found, err = index.FindFallbackCacheEntry(ctx, repoDID, engine, entry.Key, hash)
			if err == nil {
				rc.RestoreName = entry.Key + "-" + found.CacheHash
			}
		}
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			l.Warn("cache lookup failed; entry will save but not restore", "key", entry.Key, "err", err)
		} else if err == nil {
			rc.RestoreID = found.ID
			rc.RestoreKey = found.StorageKey
		}
		resolved = append(resolved, rc)
	}
	return resolved
}

func hashKeyFiles(ctx context.Context, repoPath, rev string, paths []string) (string, []string) {
	h := sha256.New()
	var missing []string
	hashed := 0
	for _, p := range paths {
		blob, err := gitBlobId(ctx, repoPath, rev, p)
		if err != nil {
			missing = append(missing, p)
			continue
		}
		fmt.Fprintf(h, "%s=%s\n", p, blob)
		hashed++
	}
	if hashed == 0 {
		return "", missing
	}
	return hex.EncodeToString(h.Sum(nil))[:12], missing
}

// sparse checkouts might not have the file, but the object database does
func gitBlobId(ctx context.Context, repoPath, rev, path string) (string, error) {
	out, err := exec.CommandContext(ctx, "git", "-C", repoPath, "rev-parse", rev+":"+path).Output()
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(out)), nil
}
