package cmdrunner

import (
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"

	"golang.org/x/sync/errgroup"

	"github.com/ignite/cli/v29/ignite/pkg/cmdrunner/step"
	"github.com/ignite/cli/v29/ignite/pkg/env"
	"github.com/ignite/cli/v29/ignite/pkg/goenv"
)

// Runner is an object to run commands.
type Runner struct {
	endSignal   os.Signal
	stdout      io.Writer
	stderr      io.Writer
	stdin       io.Reader
	workdir     string
	runParallel bool
	debug       bool
}

// Option defines option to run commands.
type Option func(*Runner)

// DefaultStdout provides the default stdout for the commands to run.
func DefaultStdout(writer io.Writer) Option {
	return func(r *Runner) {
		r.stdout = writer
	}
}

// DefaultStderr provides the default stderr for the commands to run.
func DefaultStderr(writer io.Writer) Option {
	return func(r *Runner) {
		r.stderr = writer
	}
}

// DefaultStdin provides the default stdin for the commands to run.
func DefaultStdin(reader io.Reader) Option {
	return func(r *Runner) {
		r.stdin = reader
	}
}

// DefaultWorkdir provides the default working directory for the commands to run.
func DefaultWorkdir(path string) Option {
	return func(r *Runner) {
		r.workdir = path
	}
}

// RunParallel allows commands to run concurrently.
func RunParallel() Option {
	return func(r *Runner) {
		r.runParallel = true
	}
}

// EndSignal configures s to be signaled to the processes to end them.
func EndSignal(s os.Signal) Option {
	return func(r *Runner) {
		r.endSignal = s
	}
}

func EnableDebug() Option {
	return func(r *Runner) {
		r.debug = true
	}
}

// New returns a new command runner.
func New(options ...Option) *Runner {
	runner := &Runner{
		endSignal: os.Interrupt,
		debug:     env.IsDebug(),
	}
	for _, apply := range options {
		apply(runner)
	}
	return runner
}

// Run blocks until all steps have completed their executions.
func (r *Runner) Run(ctx context.Context, steps ...*step.Step) error {
	if len(steps) == 0 {
		return nil
	}
	g, ctx := errgroup.WithContext(ctx)
	for i, step := range steps {
		// copy s to a new variable to allocate a new address,
		// so we can safely use it inside goroutines spawned in this loop.
		if r.debug {
			var cd string
			if step.Workdir != "" {
				cd = fmt.Sprintf("cd %s;", step.Workdir)
			}
			fmt.Printf("Step %d: %s%s %s %s\n", i, cd, strings.Join(step.Env, " "),
				step.Exec.Command,
				strings.Join(step.Exec.Args, " "))
		}
		if err := ctx.Err(); err != nil {
			return err
		}
		if err := step.PreExec(); err != nil {
			return err
		}
		runPostExecs := func(processErr error) error {
			// if context is canceled, then we can ignore exit error of the
			// process because it should be exited because of the cancellation.
			var err error
			ctxErr := ctx.Err()
			if ctxErr != nil {
				err = ctxErr
			} else {
				err = processErr
			}
			for _, exec := range step.PostExecs {
				if err := exec(err); err != nil {
					return err
				}
			}
			if len(step.PostExecs) > 0 {
				return nil
			}
			return err
		}
		command := r.newCommand(step)
		startErr := command.Start()
		if startErr != nil {
			if err := runPostExecs(startErr); err != nil {
				return err
			}
			continue
		}
		go func() {
			<-ctx.Done()
			command.Signal(r.endSignal)
		}()
		if err := step.InExec(); err != nil {
			return err
		}
		if len(step.WriteData) > 0 {
			if _, err := command.Write(step.WriteData); err != nil {
				return err
			}
		}
		if r.runParallel {
			g.Go(func() error {
				return runPostExecs(command.Wait())
			})
		} else if err := runPostExecs(command.Wait()); err != nil {
			return err
		}
	}
	return g.Wait()
}

// Executor represents a command to execute.
type Executor interface {
	Wait() error
	Start() error
	Signal(os.Signal)
	Write(data []byte) (n int, err error)
}

// dummyExecutor is an executor that does nothing.
type dummyExecutor struct{}

func (e *dummyExecutor) Start() error { return nil }

func (e *dummyExecutor) Wait() error { return nil }

func (e *dummyExecutor) Signal(os.Signal) {}

func (e *dummyExecutor) Write([]byte) (int, error) { return 0, nil }

// cmdSignal is an executor with signal processing.
type cmdSignal struct {
	*exec.Cmd
}

func (e *cmdSignal) Signal(s os.Signal) { _ = e.Cmd.Process.Signal(s) }

func (e *cmdSignal) Write([]byte) (n int, err error) { return 0, nil }

// cmdSignalWithWriter is an executor with signal processing and that can write into stdin.
type cmdSignalWithWriter struct {
	*exec.Cmd
	w io.WriteCloser
}

func (e *cmdSignalWithWriter) Signal(s os.Signal) { _ = e.Cmd.Process.Signal(s) }

func (e *cmdSignalWithWriter) Write(data []byte) (n int, err error) {
	defer e.w.Close()
	return e.w.Write(data)
}

// newCommand returns a new command to execute.
func (r *Runner) newCommand(step *step.Step) Executor {
	// Return a dummy executor in case of an empty command
	if step.Exec.Command == "" {
		return &dummyExecutor{}
	}
	var (
		stdout = step.Stdout
		stderr = step.Stderr
		stdin  = step.Stdin
		dir    = step.Workdir
	)

	// Define standard input and outputs
	if stdout == nil {
		stdout = r.stdout
	}
	if stderr == nil {
		stderr = r.stderr
	}
	if stdin == nil {
		stdin = r.stdin
	}
	if dir == "" {
		dir = r.workdir
	}

	// Initialize command
	command := exec.Command(step.Exec.Command, step.Exec.Args...) //nolint:gosec
	command.Stdout = stdout
	command.Stderr = stderr
	command.Dir = dir
	command.Env = append(os.Environ(), step.Env...)
	command.Env = append(command.Env, Env("PATH", goenv.Path()))

	// If a custom stdin is provided it will be as the stdin for the command
	if stdin != nil {
		command.Stdin = stdin
		return &cmdSignal{command}
	}

	// If no custom stdin, the executor can write into the stdin of the program
	writer, err := command.StdinPipe()
	if err != nil {
		// TODO do not panic
		panic(err)
	}
	return &cmdSignalWithWriter{command, writer}
}

// Env returns a new env var value from key and val.
func Env(key, val string) string {
	return fmt.Sprintf("%s=%s", key, val)
}
