package luaplugin

import (
	"bufio"
	"context"
	"errors"
	"io"
	"os"
	"os/exec"
	"sync"
	"sync/atomic"
	"time"

	lua "github.com/yuin/gopher-lua"
)

// Default binaries plugins may invoke via cliamp.exec.run(). Users can widen
// this via [plugins] allowed_binaries = "yt-dlp,ffmpeg,ffprobe" in config.toml.
var defaultAllowedBinaries = []string{"yt-dlp", "ffmpeg"}

// Per-process output cap (stdout+stderr). Once exceeded, further output is
// dropped and the process is still allowed to run to completion. Prevents a
// chatty subprocess from OOMing the player.
const execMaxOutputBytes = 4 << 20 // 4 MiB

// Per-plugin concurrency cap. Runaway plugins can't fork-bomb past this.
const execMaxPerPlugin = 4

// Hard timeout cap. Plugins may pass a smaller value; larger values clamp.
const execMaxTimeout = 30 * time.Minute

// execEntry tracks a single running subprocess.
type execEntry struct {
	id     int64
	plugin *Plugin
	cmd    *exec.Cmd
	cancel context.CancelFunc
	done   chan struct{}
}

// execManager owns all running plugin subprocesses.
type execManager struct {
	mu      sync.Mutex
	entries map[int64]*execEntry
	nextID  atomic.Int64
	perPlug map[*Plugin]int
	allowed map[string]struct{}
	allowMu sync.RWMutex
}

func newExecManager(allowed []string) *execManager {
	em := &execManager{
		entries: make(map[int64]*execEntry),
		perPlug: make(map[*Plugin]int),
		allowed: make(map[string]struct{}),
	}
	em.setAllowed(allowed)
	return em
}

func (em *execManager) setAllowed(names []string) {
	em.allowMu.Lock()
	defer em.allowMu.Unlock()
	em.allowed = make(map[string]struct{}, len(names))
	for _, n := range names {
		em.allowed[n] = struct{}{}
	}
}

func (em *execManager) isAllowed(name string) bool {
	em.allowMu.RLock()
	defer em.allowMu.RUnlock()
	_, ok := em.allowed[name]
	return ok
}

func (em *execManager) add(e *execEntry) {
	em.mu.Lock()
	em.entries[e.id] = e
	em.perPlug[e.plugin]++
	em.mu.Unlock()
}

func (em *execManager) remove(e *execEntry) {
	em.mu.Lock()
	if _, ok := em.entries[e.id]; ok {
		delete(em.entries, e.id)
		em.perPlug[e.plugin]--
		if em.perPlug[e.plugin] <= 0 {
			delete(em.perPlug, e.plugin)
		}
	}
	em.mu.Unlock()
}

// canStart returns true if the plugin is under its concurrency cap.
func (em *execManager) canStart(p *Plugin) bool {
	em.mu.Lock()
	defer em.mu.Unlock()
	return em.perPlug[p] < execMaxPerPlugin
}

// stopPlugin cancels every process owned by the given plugin and blocks
// until each one has exited. Called from Manager.cleanupPlugin.
func (em *execManager) stopPlugin(p *Plugin) {
	em.mu.Lock()
	var victims []*execEntry
	for _, e := range em.entries {
		if e.plugin == p {
			victims = append(victims, e)
		}
	}
	em.mu.Unlock()

	for _, e := range victims {
		e.cancel()
	}
	for _, e := range victims {
		<-e.done
	}
}

// stopAll cancels every process. Called from Manager.Close.
func (em *execManager) stopAll() {
	em.mu.Lock()
	var all []*execEntry
	for _, e := range em.entries {
		all = append(all, e)
	}
	em.mu.Unlock()

	for _, e := range all {
		e.cancel()
	}
	for _, e := range all {
		<-e.done
	}
}

// registerExecAPI adds cliamp.exec.run(binary, args, opts?) -> handle, err.
// The exec API is only functional for plugins declaring permissions = {"exec"}.
// Without the permission, cliamp.exec is a no-op table that logs once.
func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plugin, logger *pluginLogger) {
	tbl := L.NewTable()

	warned := false
	guard := func() bool {
		if p.perms[PermExec] {
			return true
		}
		if !warned {
			logger.log(p.Name, "warn", "cliamp.exec requires permissions = {\"exec\"} — further warnings suppressed")
			warned = true
		}
		return false
	}

	L.SetField(tbl, "run", L.NewFunction(func(L *lua.LState) int {
		if !guard() {
			L.Push(lua.LNil)
			L.Push(lua.LString("exec permission required"))
			return 2
		}

		binary := L.CheckString(1)
		argsTbl := L.CheckTable(2)
		optsTbl := L.OptTable(3, nil)

		if !em.isAllowed(binary) {
			L.Push(lua.LNil)
			L.Push(lua.LString("binary not in allowlist: " + binary))
			return 2
		}

		path, err := exec.LookPath(binary)
		if err != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString("binary not found on PATH: " + binary))
			return 2
		}

		// Flatten argv. Every entry must be a string; reject non-strings rather
		// than coercing, so plugins can't sneak nested tables into argv.
		var argv []string
		var argErr error
		argsTbl.ForEach(func(_, v lua.LValue) {
			if argErr != nil {
				return
			}
			if v.Type() != lua.LTString {
				argErr = errors.New("args must all be strings")
				return
			}
			argv = append(argv, v.String())
		})
		if argErr != nil {
			L.Push(lua.LNil)
			L.Push(lua.LString(argErr.Error()))
			return 2
		}

		var onStdout, onStderr, onExit *lua.LFunction
		cwd := ""
		timeout := execMaxTimeout

		if optsTbl != nil {
			if fn, ok := optsTbl.RawGetString("on_stdout").(*lua.LFunction); ok {
				onStdout = fn
			}
			if fn, ok := optsTbl.RawGetString("on_stderr").(*lua.LFunction); ok {
				onStderr = fn
			}
			if fn, ok := optsTbl.RawGetString("on_exit").(*lua.LFunction); ok {
				onExit = fn
			}
			if s, ok := optsTbl.RawGetString("cwd").(lua.LString); ok {
				cwd = string(s)
			}
			if n, ok := optsTbl.RawGetString("timeout").(lua.LNumber); ok && float64(n) > 0 {
				t := time.Duration(float64(n) * float64(time.Second))
				if t < timeout {
					timeout = t
				}
			}
		}

		if cwd != "" && !isWriteAllowed(cwd) {
			L.Push(lua.LNil)
			L.Push(lua.LString("cwd not in write allowlist"))
			return 2
		}

		if !em.canStart(p) {
			L.Push(lua.LNil)
			L.Push(lua.LString("per-plugin exec concurrency cap reached"))
			return 2
		}

		ctx, cancel := context.WithTimeout(context.Background(), timeout)
		cmd := exec.CommandContext(ctx, path, argv...)
		if cwd != "" {
			cmd.Dir = cwd
		}
		// Empty env by default — plugins should not inherit secrets like
		// AWS_*, SSH_*, etc. yt-dlp and ffmpeg both run fine with a minimal env.
		cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin", "HOME=" + homeEnv(), "LANG=C.UTF-8"}

		stdout, err := cmd.StdoutPipe()
		if err != nil {
			cancel()
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		stderr, err := cmd.StderrPipe()
		if err != nil {
			cancel()
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}
		if err := cmd.Start(); err != nil {
			cancel()
			L.Push(lua.LNil)
			L.Push(lua.LString(err.Error()))
			return 2
		}

		id := em.nextID.Add(1)
		entry := &execEntry{
			id:     id,
			plugin: p,
			cmd:    cmd,
			cancel: cancel,
			done:   make(chan struct{}),
		}
		em.add(entry)

		// Shared output budget across stdout+stderr.
		var outUsed atomic.Int64

		pipeStream := func(r io.Reader, fn *lua.LFunction) {
			scanner := bufio.NewScanner(r)
			// Allow longer lines than default 64KiB for noisy tools like ffmpeg.
			scanner.Buffer(make([]byte, 64*1024), 1<<20)
			for scanner.Scan() {
				line := scanner.Text()
				if outUsed.Add(int64(len(line)+1)) > execMaxOutputBytes {
					// Budget exhausted — drain silently.
					for scanner.Scan() {
					}
					return
				}
				if fn == nil {
					continue
				}
				p.mu.Lock()
				_ = p.L.CallByParam(lua.P{
					Fn:      fn,
					NRet:    0,
					Protect: true,
				}, lua.LString(line))
				p.mu.Unlock()
			}
		}

		var wg sync.WaitGroup
		wg.Add(2)
		go func() { defer wg.Done(); pipeStream(stdout, onStdout) }()
		go func() { defer wg.Done(); pipeStream(stderr, onStderr) }()

		go func() {
			wg.Wait()
			waitErr := cmd.Wait()
			cancel()
			em.remove(entry)

			code := 0
			if waitErr != nil {
				var exitErr *exec.ExitError
				if errors.As(waitErr, &exitErr) {
					code = exitErr.ExitCode()
				} else if ctx.Err() != nil {
					code = -1 // cancelled or timed out
				} else {
					code = -2 // other error
				}
			}

			if onExit != nil {
				p.mu.Lock()
				_ = p.L.CallByParam(lua.P{
					Fn:      onExit,
					NRet:    0,
					Protect: true,
				}, lua.LNumber(code))
				p.mu.Unlock()
			}
			close(entry.done)
		}()

		// Build a Lua handle with :cancel() and :alive().
		handle := L.NewTable()
		L.SetField(handle, "cancel", L.NewFunction(func(L *lua.LState) int {
			entry.cancel()
			return 0
		}))
		L.SetField(handle, "alive", L.NewFunction(func(L *lua.LState) int {
			select {
			case <-entry.done:
				L.Push(lua.LFalse)
			default:
				L.Push(lua.LTrue)
			}
			return 1
		}))
		L.SetField(handle, "id", lua.LNumber(id))

		L.Push(handle)
		return 1
	}))

	L.SetField(cliamp, "exec", tbl)
}

// homeEnv returns the user's home directory for subprocess HOME, or "/" if
// unset. yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
func homeEnv() string {
	if h, err := os.UserHomeDir(); err == nil {
		return h
	}
	return "/"
}
