package infra

import (
	"bytes"
	"context"
	"fmt"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"
	"time"
)

// ShellExecOpts configura la ejecucion de un comando shell con whitelist de binarios.
type ShellExecOpts struct {
	// Cmd es el argv completo. Cmd[0] es el binario (absoluto o nombre en PATH).
	Cmd []string
	// BinariesAllowed es la whitelist de binarios permitidos.
	// EMPTY = rechaza todo (defense in depth). Obligatorio.
	BinariesAllowed []string
	// Env son variables de entorno KEY=VAL adicionales.
	// Si vacio, se usa un entorno minimo: PATH=/usr/bin:/bin, HOME, USER, LANG.
	Env []string
	// WorkingDir es el directorio de trabajo. Si vacio usa HOME del usuario actual.
	WorkingDir string
	// TimeoutSeconds es el timeout maximo. Default 30. Hard kill al cumplir.
	TimeoutSeconds int
	// StdinPayload es el contenido a pasar como stdin al proceso.
	StdinPayload []byte
	// MaxOutputBytes es el limite de stdout+stderr combinado (cada uno).
	// Default 1 MB. Trunca la salida y activa Truncated=true.
	MaxOutputBytes int
	// User es el usuario con el que ejecutar el proceso (requiere uid=0).
	// Vacio = usuario actual.
	User string
}

// ShellExecResult contiene el resultado de la ejecucion shell.
type ShellExecResult struct {
	ExitCode  int    // Codigo de salida del proceso.
	Stdout    string // Salida estandar capturada (puede estar truncada).
	Stderr    string // Salida de error capturada (puede estar truncada).
	Duration  int64  // Duracion real de ejecucion en milisegundos.
	Truncated bool   // true si stdout o stderr fue truncado por MaxOutputBytes.
	TimedOut  bool   // true si el proceso fue matado por timeout.
}

const (
	defaultTimeoutSeconds = 30
	defaultMaxOutputBytes = 1 * 1024 * 1024 // 1 MB
	sigkillWait           = time.Second
)

// ShellExecWhitelist ejecuta un comando shell con whitelist obligatoria de binarios,
// sin shell expansion, timeout context-cancellable con SIGTERM+SIGKILL,
// stdout/stderr separados con truncate opcional.
//
// Validaciones previas al spawn (ninguna hace I/O):
//   - Cmd vacio → error.
//   - BinariesAllowed vacio → error (defense in depth; NUNCA pasar [] en prod).
//   - Cmd[0] debe estar en la whitelist: entry absoluta (/usr/bin/ls) se compara
//     con el path resolvido de Cmd[0] via exec.LookPath; entry bare name (ls)
//     se compara con filepath.Base(resolvido). Basta con que una entry haga match.
//   - User != "" con uid != 0 → error (se necesita root para cambiar usuario).
func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error) {
	// --- Validacion de seguridad (sin I/O) ---
	if len(opts.Cmd) == 0 {
		return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: Cmd must not be empty")
	}
	if len(opts.BinariesAllowed) == 0 {
		return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: no binaries whitelisted: refusing exec")
	}

	// Resolver el binario real (LookPath solo si no es path absoluto).
	resolvedBin, err := exec.LookPath(opts.Cmd[0])
	if err != nil {
		return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q not found in PATH: %w", opts.Cmd[0], err)
	}

	baseName := filepath.Base(resolvedBin)
	inWhitelist := false
	for _, entry := range opts.BinariesAllowed {
		if strings.HasPrefix(entry, "/") {
			// Entry es path absoluto: comparar con el path resolvido.
			if entry == resolvedBin {
				inWhitelist = true
				break
			}
		} else {
			// Entry es bare name: comparar con el basename del resolvido.
			if entry == baseName {
				inWhitelist = true
				break
			}
		}
	}
	if !inWhitelist {
		return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q (resolved: %q) not in whitelist %v",
			opts.Cmd[0], resolvedBin, opts.BinariesAllowed)
	}

	// --- Validacion de user switch ---
	if opts.User != "" {
		if os.Getuid() != 0 {
			return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: need root to switch user to %q", opts.User)
		}
	}

	// --- Defaults ---
	timeout := opts.TimeoutSeconds
	if timeout <= 0 {
		timeout = defaultTimeoutSeconds
	}
	maxOut := opts.MaxOutputBytes
	if maxOut <= 0 {
		maxOut = defaultMaxOutputBytes
	}

	// Working dir
	workDir := opts.WorkingDir
	if workDir == "" {
		if h := os.Getenv("HOME"); h != "" {
			workDir = h
		} else {
			workDir = "/"
		}
	}

	// Env
	env := opts.Env
	if len(env) == 0 {
		lang := os.Getenv("LANG")
		if lang == "" {
			lang = "C.UTF-8"
		}
		home := os.Getenv("HOME")
		if home == "" {
			home = "/"
		}
		usr := os.Getenv("USER")
		if usr == "" {
			usr = "root"
		}
		env = []string{
			"PATH=/usr/bin:/bin",
			"HOME=" + home,
			"USER=" + usr,
			"LANG=" + lang,
		}
	}

	// --- Contexto con timeout ---
	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
	defer cancel()

	// --- Construir comando ---
	argv := append([]string{resolvedBin}, opts.Cmd[1:]...)
	cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // whitelist validated above
	cmd.Env = env
	cmd.Dir = workDir

	// SysProcAttr para user switching (solo si root y User != "").
	if opts.User != "" {
		cred, err := buildCredential(opts.User)
		if err != nil {
			return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: resolving user %q: %w", opts.User, err)
		}
		cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred}
	} else {
		// Asegurar que el proceso puede ser matado como grupo.
		cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	}

	// Buffers de captura.
	var stdoutBuf, stderrBuf bytes.Buffer
	cmd.Stdout = &stdoutBuf
	cmd.Stderr = &stderrBuf

	// Stdin opcional.
	if len(opts.StdinPayload) > 0 {
		cmd.Stdin = bytes.NewReader(opts.StdinPayload)
	}

	start := time.Now()

	// --- Ejecucion ---
	runErr := cmd.Run()
	duration := time.Since(start).Milliseconds()

	// Determinar timedOut y exitCode.
	timedOut := false
	exitCode := 0
	if runErr != nil {
		if ctx.Err() == context.DeadlineExceeded {
			timedOut = true
			// SIGTERM ya fue enviado por exec.CommandContext; esperar 1s y SIGKILL.
			if cmd.Process != nil {
				time.Sleep(sigkillWait)
				_ = cmd.Process.Kill()
			}
		}
		if exitErr, ok := runErr.(*exec.ExitError); ok {
			exitCode = exitErr.ExitCode()
		} else if !timedOut {
			// Error de spawn u otro — no es de exit.
			return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: running %q: %w", opts.Cmd[0], runErr)
		}
	}

	// Truncar salida si supera el limite.
	truncated := false
	stdout := stdoutBuf.String()
	stderr := stderrBuf.String()
	if len(stdout) > maxOut {
		stdout = stdout[:maxOut]
		truncated = true
	}
	if len(stderr) > maxOut {
		stderr = stderr[:maxOut]
		truncated = true
	}

	return ShellExecResult{
		ExitCode:  exitCode,
		Stdout:    stdout,
		Stderr:    stderr,
		Duration:  duration,
		Truncated: truncated,
		TimedOut:  timedOut,
	}, nil
}

// buildCredential construye un syscall.Credential para el usuario dado.
// Acepta nombre de usuario ("www-data") o "uid:gid" ("1000:1000").
func buildCredential(userStr string) (*syscall.Credential, error) {
	// Intentar formato "uid:gid".
	if strings.Contains(userStr, ":") {
		parts := strings.SplitN(userStr, ":", 2)
		uid, err := strconv.ParseUint(parts[0], 10, 32)
		if err != nil {
			return nil, fmt.Errorf("invalid uid %q: %w", parts[0], err)
		}
		gid, err := strconv.ParseUint(parts[1], 10, 32)
		if err != nil {
			return nil, fmt.Errorf("invalid gid %q: %w", parts[1], err)
		}
		return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
	}

	// Nombre de usuario.
	u, err := user.Lookup(userStr)
	if err != nil {
		return nil, fmt.Errorf("user %q not found: %w", userStr, err)
	}
	uid, _ := strconv.ParseUint(u.Uid, 10, 32)
	gid, _ := strconv.ParseUint(u.Gid, 10, 32)
	return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
}
