package convert

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"sync"
	"time"
)

// Runner executes a conversion binary and returns its stdout. The
// production implementation (localRunner) just exec's the binary
// directly. Tests use a fake.
//
// binary is the PATH-resolvable name (or absolute path) of the
// conversion tool — typically "pandoc" or "chromium-browser". In the
// production runtime image those names resolve to wrapper scripts at
// /usr/local/bin/ that put the real binary into a cgroup + bwrap
// sandbox before exec'ing it. From zddc-server's perspective, that
// indirection is invisible: it just sees pandoc behavior.
//
// stdin is piped to the binary's stdin. scratchDir is an optional
// host directory the binary needs to read from / write to (template
// + intermediate HTML + PDF output); passed to the child via the
// ZDDC_SCRATCH env var, which the wrapper script bind-mounts into
// the sandbox at the same path. Empty means "no scratch dir
// needed" (DOCX flow — stdin to stdout, no files).
//
// cmd is the argv passed to the binary. Same shape across all
// runners; no shell quoting; no engine-specific flags.
//
// All exec calls in this package go through Runner.Run.
type Runner interface {
	Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error)
}

// ErrUnavailable means the conversion binary couldn't be found on
// PATH. Handlers translate to HTTP 503.
var ErrUnavailable = errors.New("conversion unavailable")

// ConvertError carries the failure surface from a non-zero exit.
// Stderr is captured (truncated to 4 KiB by the runner) so callers
// can surface the binary's own complaint.
type ConvertError struct {
	Tool     string // binary name, used only for logging
	ExitCode int
	Stderr   string
	Cause    error
}

func (e *ConvertError) Error() string {
	if e == nil {
		return "<nil>"
	}
	if e.Stderr != "" {
		return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, e.Stderr)
	}
	return fmt.Sprintf("%s exit %d: %v", e.Tool, e.ExitCode, e.Cause)
}

func (e *ConvertError) Unwrap() error { return e.Cause }

// localRunner exec's the conversion binary directly. The runtime
// image's wrapper script (at /usr/local/bin/<binary>) handles
// sandboxing + resource limits BETWEEN this exec and the real
// binary — invisible to this Runner.
//
// Resource limits stored here are advisory only; the wrapper reads
// them via env (ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX) and applies
// them to its transient cgroup. Wall-clock timeout IS enforced
// here via context.WithTimeout.
type localRunner struct {
	mu      sync.RWMutex
	memMiB  int
	pids    int
	timeout time.Duration
}

func newLocalRunner() *localRunner {
	return &localRunner{
		memMiB:  1024, // 1 GiB — matches the wrapper's default
		pids:    256,
		timeout: 60 * time.Second,
	}
}

// SetLimits updates the resource ceilings advertised to the wrapper
// script via env vars + the wall-clock timeout enforced here.
// Zero values keep the previous setting (or constructor defaults).
// Safe to call from multiple goroutines.
func (lr *localRunner) SetLimits(memMiB int, pids int, timeout time.Duration) {
	lr.mu.Lock()
	defer lr.mu.Unlock()
	if memMiB > 0 {
		lr.memMiB = memMiB
	}
	if pids > 0 {
		lr.pids = pids
	}
	if timeout > 0 {
		lr.timeout = timeout
	}
}

func (lr *localRunner) Run(ctx context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) {
	lr.mu.RLock()
	memMiB := lr.memMiB
	pids := lr.pids
	timeout := lr.timeout
	lr.mu.RUnlock()

	if binary == "" {
		return nil, ErrUnavailable
	}

	runCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	c := exec.CommandContext(runCtx, binary, cmd...)
	c.Cancel = func() error {
		if c.Process == nil {
			return nil
		}
		return c.Process.Kill()
	}
	c.WaitDelay = 2 * time.Second
	c.SysProcAttr = sysProcAttr()

	// Minimal env passed to the wrapper. The wrapper does
	// --clearenv inside the bwrap sandbox so the real binary
	// sees only what bwrap re-injects (HOME, PATH, LANG). These
	// vars are read by the WRAPPER itself, not the binary, to
	// drive its cgroup setup + scratch-dir bind mount.
	env := []string{
		"PATH=" + os.Getenv("PATH"),
		"HOME=" + os.TempDir(),
		fmt.Sprintf("ZDDC_CONV_MEM_MAX=%dM", memMiB),
		fmt.Sprintf("ZDDC_CONV_PIDS_MAX=%d", pids),
	}
	if scratchDir != "" {
		env = append(env, "ZDDC_SCRATCH="+scratchDir)
	}
	c.Env = env
	c.Stdin = bytes.NewReader(stdin)

	var stdoutBuf bytes.Buffer
	c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
	stderr := newRingWriter(4 << 10)
	c.Stderr = stderr

	if err := c.Run(); err != nil {
		exitCode := -1
		if ee, ok := err.(*exec.ExitError); ok {
			exitCode = ee.ExitCode()
		}
		if runCtx.Err() == context.DeadlineExceeded {
			return nil, &ConvertError{
				Tool:     binary,
				ExitCode: exitCode,
				Stderr:   stderr.String(),
				Cause:    fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
			}
		}
		return nil, &ConvertError{
			Tool:     binary,
			ExitCode: exitCode,
			Stderr:   stderr.String(),
			Cause:    err,
		}
	}
	return stdoutBuf.Bytes(), nil
}

var (
	// shared default runner, populated by InstallRunner (called from
	// the health probe at startup once the binaries are confirmed).
	defaultRunnerMu sync.RWMutex
	defaultRunner   Runner
)

// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/
// ToPDF. Tests inject a fake; production code lets the health probe
// install a localRunner. Safe to call from multiple goroutines.
func InstallRunner(r Runner) {
	defaultRunnerMu.Lock()
	defaultRunner = r
	defaultRunnerMu.Unlock()
}

// ConfigureLimits applies resource limits to the package-level
// Runner, if it's a localRunner. No-op when no runner is installed
// yet (the probe failed) or when the installed runner doesn't accept
// limits (e.g. a test fake). Zero values keep the previous setting.
//
// Called from cmd/zddc-server/main.go after Probe so the limits
// from the operator's flags take effect before any conversion
// request lands.
func ConfigureLimits(memMiB int, pids int, timeout time.Duration) {
	defaultRunnerMu.RLock()
	r := defaultRunner
	defaultRunnerMu.RUnlock()
	if lr, ok := r.(*localRunner); ok {
		lr.SetLimits(memMiB, pids, timeout)
	}
}

func currentRunner() Runner {
	defaultRunnerMu.RLock()
	r := defaultRunner
	defaultRunnerMu.RUnlock()
	return r
}

// limitWriter caps the underlying buffer at max bytes. Writes past
// the cap return an error which surfaces as a Run() error — the
// caller then maps to 422 (output too large) at the handler edge.
type limitWriter struct {
	w   io.Writer
	max int64
	n   int64
}

func (l *limitWriter) Write(p []byte) (int, error) {
	if l.n >= l.max {
		return 0, fmt.Errorf("output exceeded %d bytes", l.max)
	}
	rem := l.max - l.n
	if int64(len(p)) > rem {
		n, _ := l.w.Write(p[:rem])
		l.n += int64(n)
		return n, fmt.Errorf("output exceeded %d bytes", l.max)
	}
	n, err := l.w.Write(p)
	l.n += int64(n)
	return n, err
}

// ringWriter keeps only the tail of what's written — useful for
// stderr capture where the most-recent bytes carry the actual error
// message and earlier output is usually progress noise.
type ringWriter struct {
	mu  sync.Mutex
	buf []byte
	max int
}

func newRingWriter(max int) *ringWriter {
	return &ringWriter{max: max}
}

func (r *ringWriter) Write(p []byte) (int, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if len(p) >= r.max {
		r.buf = append(r.buf[:0], p[len(p)-r.max:]...)
		return len(p), nil
	}
	r.buf = append(r.buf, p...)
	if len(r.buf) > r.max {
		r.buf = r.buf[len(r.buf)-r.max:]
	}
	return len(p), nil
}

func (r *ringWriter) String() string {
	r.mu.Lock()
	defer r.mu.Unlock()
	return string(r.buf)
}

// writeScratchFiles materialises a set of named byte buffers (template +
// partials, or a lua filter) into a fresh scratch dir and returns the host
// path. Caller is responsible for os.RemoveAll(dir) when done. pandoc resolves
// `$partial()$` includes and --lua-filter paths from this dir, so everything
// lands flat alongside the entry file.
//
// scratchRoot controls where the temp dir lands. Empty means "use $TMPDIR".
//
// Files are written world-readable so the binary's default user can read them
// through the wrapper's bind mount regardless of the host's umask. Keys are
// reduced to base names only (no path separators).
func writeScratchFiles(scratchRoot string, files map[string][]byte) (string, error) {
	dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-")
	if err != nil {
		return "", fmt.Errorf("scratch dir: %w", err)
	}
	for name, b := range files {
		if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), b, 0o644); err != nil {
			os.RemoveAll(dir)
			return "", fmt.Errorf("write scratch file %q: %w", name, err)
		}
	}
	if err := chmodTree(dir, 0o755, 0o644); err != nil {
		os.RemoveAll(dir)
		return "", err
	}
	return dir, nil
}

func chmodTree(root string, dirMode, fileMode os.FileMode) error {
	return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return os.Chmod(p, dirMode)
		}
		return os.Chmod(p, fileMode)
	})
}
