package runner

import (
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
)

//go:embed bind.jsx
var bindScript []byte

func isMediaBinding(t string) bool {
	switch strings.ToLower(strings.TrimSpace(t)) {
	case "media", "image", "video":
		return true
	case "audio", "voiceover":
		return true
	}
	return false
}

func bindMediaType(t string) string {
	switch strings.ToLower(strings.TrimSpace(t)) {
	case "audio", "voiceover":
		return "audio"
	default:
		return "media"
	}
}

func mediaExt(url string) string {
	u := url
	if i := strings.IndexAny(u, "?#"); i >= 0 {
		u = u[:i]
	}
	ext := filepath.Ext(u)
	if ext == "" || len(ext) > 6 {
		return ".bin"
	}
	return ext
}

func downloadFile(ctx context.Context, url, dst string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download %s: HTTP %d", url, resp.StatusCode)
	}
	f, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = io.Copy(f, resp.Body)
	return err
}

// ── bind-spec (matches bind.jsx) ──────────────────────────────────────────────
type bindLayerSpec struct {
	Key   string `json:"key"`
	Type  string `json:"type"` // "text" | "media" | "audio"
	Value string `json:"value"`
}
type bindColorSpec struct {
	Key   string `json:"key"`
	Value string `json:"value"`
}
type bindSpec struct {
	Comp   string          `json:"comp,omitempty"`
	Fps    int             `json:"fps,omitempty"`
	Colors []bindColorSpec `json:"colors,omitempty"`
	Layers []bindLayerSpec `json:"layers"`
}

// RunBinder writes the user's edited input values into the template (text layers'
// source text, media footage replacement) via bind.jsx, saving a "bound" .aep that
// aerender then renders. Returns the path to the bound .aep. Uses afterfx.exe.
//
// Media values are URLs; they are downloaded locally first (the JSX imports them by
// path). Text values are passed through the JSON bind-spec (json-escaped, never eval'd
// as code — bind.jsx eval()s valid JSON only).
func RunBinder(ctx context.Context, job *Job, workDir string) (string, error) {
	if job.AEPFilePath == "" {
		return "", fmt.Errorf("binder: empty aep path")
	}
	if job.AfterFxPath == "" {
		return "", fmt.Errorf("binder: empty afterfx path")
	}

	dir := filepath.Join(workDir, "bind", job.JobID)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", err
	}

	spec := bindSpec{Comp: job.CompName, Fps: job.FrameRate, Layers: []bindLayerSpec{}}
	for i, b := range job.Bindings {
		key := strings.TrimSpace(b.Key)
		if key == "" {
			continue
		}
		// Colours bind to frshare/frd_* text layers (hex source text) — bind.jsx
		// propagates them via template expressions, not as visible layer content.
		if strings.EqualFold(strings.TrimSpace(b.Type), "color") {
			if strings.TrimSpace(b.Value) == "" {
				continue
			}
			spec.Colors = append(spec.Colors, bindColorSpec{Key: key, Value: b.Value})
			continue
		}
		if isMediaBinding(b.Type) {
			if strings.TrimSpace(b.Value) == "" {
				continue
			}
			mp := filepath.Join(dir, fmt.Sprintf("m%d%s", i, mediaExt(b.Value)))
			if err := downloadFile(ctx, b.Value, mp); err != nil {
				log.Printf("[bind] media download failed for %s: %v", key, err)
				continue
			}
			spec.Layers = append(spec.Layers, bindLayerSpec{Key: key, Type: bindMediaType(b.Type), Value: mp})
		} else {
			spec.Layers = append(spec.Layers, bindLayerSpec{Key: key, Type: "text", Value: b.Value})
		}
	}

	specBytes, err := json.Marshal(spec)
	if err != nil {
		return "", err
	}
	specPath := filepath.Join(dir, "spec.json")
	if err := os.WriteFile(specPath, specBytes, 0o644); err != nil {
		return "", err
	}

	// Save the bound .aep next to the original so its relative footage/fonts resolve.
	savePath := filepath.Join(filepath.Dir(job.AEPFilePath), "bound.aep")
	donePath := savePath + ".done"
	errPath := savePath + ".error"
	_ = os.Remove(donePath)
	_ = os.Remove(errPath)
	_ = os.Remove(savePath)

	scriptPath := filepath.Join(workDir, "scripts", "bind.jsx")
	if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
		return "", err
	}
	if err := os.WriteFile(scriptPath, bindScript, 0o644); err != nil {
		return "", err
	}

	PrepareFreshAE() // kill stale AE + clear crash markers before launch

	bctx, cancel := context.WithTimeout(ctx, 12*time.Minute)
	defer cancel()

	// AE opens the project itself (bind.jsx app.open); launch with -r script.
	cmd := exec.CommandContext(bctx, job.AfterFxPath, "-r", scriptPath)
	cmd.Env = append(os.Environ(),
		"FR_BIND_AEP="+job.AEPFilePath,
		"FR_BIND_SPEC="+specPath,
		"FR_BIND_SAVE="+savePath,
	)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Start(); err != nil {
		return "", fmt.Errorf("start afterfx: %w", err)
	}
	exited := make(chan error, 1)
	go func() { exited <- cmd.Wait() }()

	for {
		if eb, rerr := os.ReadFile(errPath); rerr == nil && len(eb) > 0 {
			_ = cmd.Process.Kill()
			return "", fmt.Errorf("binder script error: %s", string(eb))
		}
		if st, serr := os.Stat(savePath); serr == nil && st.Size() > 0 {
			if _, derr := os.Stat(donePath); derr == nil {
				_ = cmd.Process.Kill()
				log.Printf("[bind] bound %d input(s) → %s", len(spec.Layers), savePath)
				return savePath, nil
			}
		}
		select {
		case <-exited:
			if st, serr := os.Stat(savePath); serr == nil && st.Size() > 0 {
				return savePath, nil
			}
			return "", fmt.Errorf("binder exited without producing %s", filepath.Base(savePath))
		case <-time.After(2 * time.Second):
		case <-bctx.Done():
			_ = cmd.Process.Kill()
			return "", bctx.Err()
		}
	}
}
