// bundle.go prepares an After Effects template for rendering. A template may be a
// plain .aep/.aepx file, or a .zip bundle containing the .aep plus its footage and
// fonts. Bundles are extracted so aerender can resolve relative footage paths.
//
// Prepared templates are cached on disk keyed by the bundle's MD5, so when many
// renders use the same template the node downloads + extracts it only once.
package runner

import (
	"archive/zip"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// PrepareTemplate ensures the template referenced by url is available locally and
// returns the absolute path to the .aep/.aepx file aerender should open.
//
//   - isBundle=false: url is a raw project file; it is downloaded as-is.
//   - isBundle=true:  url is a .zip; it is downloaded and extracted, then the .aep
//     inside is located.
//
// md5 (when non-empty) is the cache key: a prepared template with the same md5 is
// reused without re-downloading. baseDir is the agent work dir; jobID is the
// fallback cache key when md5 is empty.
func PrepareTemplate(ctx context.Context, url string, isBundle bool, md5, baseDir, jobID string) (string, error) {
	cacheKey := md5
	if cacheKey == "" {
		cacheKey = jobID // no md5 → per-job, no reuse
	}
	cacheDir := filepath.Join(baseDir, "templates", "cache", sanitize(cacheKey))
	if err := os.MkdirAll(cacheDir, 0o755); err != nil {
		return "", fmt.Errorf("mkdir cache: %w", err)
	}

	if !isBundle {
		aepPath := filepath.Join(cacheDir, "template.aep")
		if fileExists(aepPath) {
			return aepPath, nil // cache hit
		}
		if _, err := DownloadFile(ctx, url, aepPath); err != nil {
			return "", fmt.Errorf("download aep: %w", err)
		}
		return aepPath, nil
	}

	// Bundle: the resolved .aep path is remembered in a marker so cache hits skip
	// both download and extraction.
	marker := filepath.Join(cacheDir, "aep_path.txt")
	if b, err := os.ReadFile(marker); err == nil {
		if p := strings.TrimSpace(string(b)); p != "" && fileExists(p) {
			return p, nil // cache hit
		}
	}

	zipPath := filepath.Join(cacheDir, "bundle.zip")
	if _, err := DownloadFile(ctx, url, zipPath); err != nil {
		return "", fmt.Errorf("download bundle: %w", err)
	}
	extractDir := filepath.Join(cacheDir, "extracted")
	if err := os.RemoveAll(extractDir); err != nil {
		return "", fmt.Errorf("clean extract dir: %w", err)
	}
	if err := ExtractZip(zipPath, extractDir); err != nil {
		return "", fmt.Errorf("extract bundle: %w", err)
	}
	aepPath, err := FindAEP(extractDir)
	if err != nil {
		return "", err
	}
	_ = os.WriteFile(marker, []byte(aepPath), 0o644)
	_ = os.Remove(zipPath) // free space — the extracted tree is what we render from
	return aepPath, nil
}

// ExtractZip unpacks the zip at zipPath into destDir, guarding against path
// traversal ("zip slip"). Directories and parents are created as needed.
func ExtractZip(zipPath, destDir string) error {
	zr, err := zip.OpenReader(zipPath)
	if err != nil {
		return fmt.Errorf("open zip: %w", err)
	}
	defer zr.Close()

	if err := os.MkdirAll(destDir, 0o755); err != nil {
		return err
	}
	destAbs, err := filepath.Abs(destDir)
	if err != nil {
		return err
	}

	for _, f := range zr.File {
		// Clean and confine the target path to destDir.
		target := filepath.Join(destDir, f.Name) // #nosec G305 — validated below
		targetAbs, aerr := filepath.Abs(target)
		if aerr != nil {
			return aerr
		}
		if targetAbs != destAbs && !strings.HasPrefix(targetAbs, destAbs+string(os.PathSeparator)) {
			return fmt.Errorf("illegal path in zip: %q", f.Name)
		}

		if f.FileInfo().IsDir() {
			if err := os.MkdirAll(targetAbs, 0o755); err != nil {
				return err
			}
			continue
		}
		if err := os.MkdirAll(filepath.Dir(targetAbs), 0o755); err != nil {
			return err
		}
		if err := extractOne(f, targetAbs); err != nil {
			return err
		}
	}
	return nil
}

func extractOne(f *zip.File, target string) error {
	rc, err := f.Open()
	if err != nil {
		return err
	}
	defer rc.Close()

	out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer out.Close()

	// #nosec G110 — template bundles are admin-uploaded, not untrusted user input.
	if _, err := io.Copy(out, rc); err != nil {
		return err
	}
	return nil
}

// FindAEP locates the After Effects project file within an extracted bundle. It
// prefers the shallowest file (fewest path segments), .aep over .aepx, and ignores
// macOS resource-fork siblings ("._name") and __MACOSX entries.
func FindAEP(root string) (string, error) {
	var matches []string
	err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			if info.Name() == "__MACOSX" {
				return filepath.SkipDir
			}
			return nil
		}
		name := info.Name()
		if strings.HasPrefix(name, "._") {
			return nil
		}
		ext := strings.ToLower(filepath.Ext(name))
		if ext == ".aep" || ext == ".aepx" {
			matches = append(matches, p)
		}
		return nil
	})
	if err != nil {
		return "", fmt.Errorf("walk bundle: %w", err)
	}
	if len(matches) == 0 {
		return "", fmt.Errorf("no .aep file found in bundle")
	}
	sort.Slice(matches, func(i, j int) bool {
		di, dj := strings.Count(matches[i], string(os.PathSeparator)), strings.Count(matches[j], string(os.PathSeparator))
		if di != dj {
			return di < dj // shallower first
		}
		ei, ej := strings.ToLower(filepath.Ext(matches[i])), strings.ToLower(filepath.Ext(matches[j]))
		if ei != ej {
			return ei == ".aep" // .aep before .aepx
		}
		return matches[i] < matches[j]
	})
	return matches[0], nil
}

func fileExists(p string) bool {
	info, err := os.Stat(p)
	return err == nil && !info.IsDir()
}

// sanitize keeps a cache key safe for use as a directory name.
func sanitize(s string) string {
	return strings.Map(func(r rune) rune {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
			return r
		default:
			return '_'
		}
	}, s)
}
