package osexec

import (
	"context"
	"errors"
	"fmt"
	"os/exec"
	"regexp"
	"time"
)

// Typed errors
var (
	ErrTimeout = errors.New("command timeout")
)

// ErrExitNonZero is returned when a command returned a non-zero exit status.
type ErrExitNonZero struct {
	ExitCode int
	Stdout   string
	Stderr   string
}

func (e ErrExitNonZero) Error() string {
	return fmt.Sprintf("command exit status %d", e.ExitCode)
}

// Runner defines the behavior for executing a command. Implementations can be injected for tests.
type Runner interface {
	Run(ctx context.Context, name string, args ...string) (stdout string, stderr string, exitCode int, err error)
}

// DefaultMaxOutputBytes limits stdout/stderr returned by Exec.
const DefaultMaxOutputBytes = 64 * 1024

// DefaultRunner executes real OS commands.
type DefaultRunner struct {
	RedactPatterns []*regexp.Regexp
	MaxOutputBytes int
}

func NewDefaultRunner(patterns []string) *DefaultRunner {
	var compiled []*regexp.Regexp
	for _, p := range patterns {
		r, err := regexp.Compile(p)
		if err == nil {
			compiled = append(compiled, r)
		}
	}
	return &DefaultRunner{RedactPatterns: compiled, MaxOutputBytes: DefaultMaxOutputBytes}
}

func (r *DefaultRunner) redact(s string) string {
	if len(r.RedactPatterns) == 0 {
		return s
	}
	out := s
	for _, re := range r.RedactPatterns {
		out = re.ReplaceAllString(out, "[REDACTED]")
	}
	return out
}

func (r *DefaultRunner) truncate(s string) (string, bool) {
	max := r.MaxOutputBytes
	if max <= 0 || len(s) <= max {
		return s, false
	}
	return s[:max] + "... (truncated)", true
}

// Run executes a command using os/exec and returns captured output and exit code.
func (r *DefaultRunner) Run(ctx context.Context, name string, args ...string) (string, string, int, error) {
	start := time.Now()
	// use exec.CommandContext to respect context deadlines
	cmd := exec.CommandContext(ctx, name, args...)
	outBytes, err := cmd.Output() // only captures stdout; we rely on combined output for simplicity
	var stderr []byte
	if err != nil {
		if ee, ok := err.(*exec.ExitError); ok {
			stderr = ee.Stderr
		}
	}
	// If context timed out
	if ctx.Err() == context.DeadlineExceeded || ctx.Err() == context.Canceled {
		return string(outBytes), string(stderr), -1, ErrTimeout
	}

	stdout := string(outBytes)
	sErrStr := string(stderr)
	// Truncate outputs if needed
	stdout, _ = r.truncate(stdout)
	sErrStr, _ = r.truncate(sErrStr)

	// If command failed with non-zero exit
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			code := exitErr.ExitCode()
			return stdout, sErrStr, code, ErrExitNonZero{ExitCode: code, Stdout: stdout, Stderr: sErrStr}
		}
		return stdout, sErrStr, -1, err
	}

	_ = time.Since(start) // measure duration; can be logged by caller
	return stdout, sErrStr, 0, nil
}

// Exec runs using DefaultRunner; default patterns are warmed up empty
var Default = NewDefaultRunner(nil)

// Exec is a convenience function using the Default runner.
func Exec(ctx context.Context, name string, args ...string) (string, string, int, error) {
	return Default.Run(ctx, name, args...)
}

// ExecWithRunner uses the provided Runner to execute and normalizes errors.
func ExecWithRunner(r Runner, ctx context.Context, name string, args ...string) (string, string, int, error) {
	out, errOut, code, err := r.Run(ctx, name, args...)
	// timeout
	if ctx.Err() == context.DeadlineExceeded || ctx.Err() == context.Canceled {
		return out, errOut, -1, ErrTimeout
	}
	// runner returned an error
	if err != nil {
		// If error implements ErrExitNonZero (already constructed by runner), return as is
		var exitErr ErrExitNonZero
		if errors.As(err, &exitErr) {
			return exitErr.Stdout, exitErr.Stderr, exitErr.ExitCode, exitErr
		}
		// If it's an exec.ExitError, wrap in ErrExitNonZero
		var execExit *exec.ExitError
		if errors.As(err, &execExit) {
			return out, errOut, execExit.ExitCode(), ErrExitNonZero{ExitCode: execExit.ExitCode(), Stdout: out, Stderr: errOut}
		}
		return out, errOut, code, err
	}
	// no error, but a non-zero exit code
	if code != 0 {
		return out, errOut, code, ErrExitNonZero{ExitCode: code, Stdout: out, Stderr: errOut}
	}
	return out, errOut, code, nil
}
