package cmd

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"os"
	"strconv"
	"time"

	"github.com/ory/viper"
	"github.com/spf13/cobra"

	"knative.dev/func/pkg/config"
	"knative.dev/func/pkg/docker"
	fn "knative.dev/func/pkg/functions"
	"knative.dev/func/pkg/oci"
)

func NewRunCmd(newClient ClientFactory) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "run",
		Short: "Run the function locally",
		Long: `
NAME
	{{rootCmdUse}} run - Run a function locally

SYNOPSIS
	{{rootCmdUse}} run [-r|--registry] [-i|--image] [-e|--env] [--build]
				 [-b|--builder] [--builder-image] [-c|--confirm]
	             [--address] [--json] [-v|--verbose]

DESCRIPTION
	Run the function locally.

	Values provided for flags are not persisted to the function's metadata.

	Containerized Runs
	  You can build your function in a container using the Pack or S2i builders.
	  On the contrary, non-containerized run is achieved via Host builder which
	  will use your host OS' environment to build the function. This builder is
	  currently enabled for Go and Python. Building defaults to using the Host
	  builder when available. You can alter this by using the --builder flag
	  eg: --builder=s2i.

	Process Scaffolding
	  This is an Experimental Feature currently available only to Go and Python
	  projects. When running a function with --builder=host, the function is
	  first wrapped with code which presents it as a process. This "scaffolding"
	  is transient, written for each build or run, and should in most cases be
	  transparent to a function author.

EXAMPLES

	o Run the function locally from within its container.
	  $ {{rootCmdUse}} run

	o Run the function locally from within its container, forcing a rebuild
	  of the container even if no filesystem changes are detected. There are 2
	  builders available for containerized build - 'pack' and 's2i'.
	  $ {{rootCmdUse}} run --build=<builder>

	o Run the function locally on the host with no containerization (Go/Python only).
	  $ {{rootCmdUse}} run --builder=host

	o Run the function locally on a specific address.
	  $ {{rootCmdUse}} run --address='[::]:8081'

	o Run the function locally and output JSON with the service address.
	  $ {{rootCmdUse}} run --json
`,
		SuggestFor: []string{"rnu"},
		PreRunE: bindEnv("build", "builder", "builder-image", "base-image",
			"confirm", "env", "image", "path", "registry",
			"start-timeout", "verbose", "address", "json"),
		RunE: func(cmd *cobra.Command, _ []string) error {
			return runRun(cmd, newClient)
		},
	}

	// Global Config
	cfg, err := config.NewDefault()
	if err != nil {
		fmt.Fprintf(cmd.OutOrStdout(), "error loading config at '%v'. %v\n", config.File(), err)
	}

	// Function Context
	f, _ := fn.NewFunction(effectivePath())
	if f.Initialized() {
		cfg = cfg.Apply(f)
	}

	// Flags
	//
	// Globally-Configurable Flags:
	cmd.Flags().StringP("builder", "b", cfg.Builder,
		fmt.Sprintf("Builder to use when creating the function's container. Currently supported builders are %s.", KnownBuilders()))
	cmd.Flags().StringP("registry", "r", cfg.Registry,
		"Container registry + registry namespace. (ex 'ghcr.io/myuser').  The full image name is automatically determined using this along with function name. ($FUNC_REGISTRY)")

	// Function-Context Flags:
	//   Options whose value is available on the function with context only
	//   (persisted but not globally configurable)
	builderImage := f.Build.BuilderImages[f.Build.Builder]
	cmd.Flags().String("builder-image", builderImage,
		"Specify a custom builder image for use by the builder other than its default. ($FUNC_BUILDER_IMAGE)")
	cmd.Flags().StringP("base-image", "", f.Build.BaseImage,
		"Override the base image for your function (host builder only)")
	cmd.Flags().StringP("image", "i", f.Image,
		"Full image name in the form [registry]/[namespace]/[name]:[tag]. This option takes precedence over --registry. Specifying tag is optional. ($FUNC_IMAGE)")
	cmd.Flags().StringArrayP("env", "e", []string{},
		"Environment variable to set in the form NAME=VALUE. "+
			"You may provide this flag multiple times for setting multiple environment variables. "+
			"To unset, specify the environment variable name followed by a \"-\" (e.g., NAME-).")
	cmd.Flags().Duration("start-timeout", f.Run.StartTimeout, fmt.Sprintf("time this function needs in order to start. If not provided, the client default %v will be in effect. ($FUNC_START_TIMEOUT)", fn.DefaultStartTimeout))

	// TODO: Without the "Host" builder enabled, this code-path is unreachable,
	// so remove hidden flag when either the Host builder path is available,
	// or when containerized runs support start-timeout (and ideally both).
	// Also remember to add it to the command help text's synopsis section.
	_ = cmd.Flags().MarkHidden("start-timeout")

	// Static Flags:
	//  Options which have static defaults only
	//  (not globally configurable nor persisted as function metadata)
	cmd.Flags().String("build", "auto",
		"Build the function. [auto|true|false]. ($FUNC_BUILD)")
	cmd.Flags().Lookup("build").NoOptDefVal = "true" // register `--build` as equivalient to `--build=true`
	cmd.Flags().String("address", "",
		"Interface and port on which to bind and listen. Default is 127.0.0.1:8080, or an available port if 8080 is not available. ($FUNC_ADDRESS)")
	cmd.Flags().Bool("json", false, "Output as JSON. ($FUNC_JSON)")

	// Oft-shared flags:
	addConfirmFlag(cmd, cfg.Confirm)
	addPathFlag(cmd)
	addVerboseFlag(cmd, cfg.Verbose)

	// Tab Completion
	if err := cmd.RegisterFlagCompletionFunc("builder", CompleteBuilderList); err != nil {
		fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
	}

	if err := cmd.RegisterFlagCompletionFunc("builder-image", CompleteBuilderImageList); err != nil {
		fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
	}

	return cmd
}

func runRun(cmd *cobra.Command, newClient ClientFactory) (err error) {
	var (
		cfg runConfig
		f   fn.Function
	)
	cfg = newRunConfig(cmd) // Will add Prompt on upcoming UX refactor

	if f, err = fn.NewFunction(cfg.Path); err != nil {
		return
	}
	if !f.Initialized() {
		return fn.NewErrNotInitialized(f.Root)
	}

	if err = cfg.Validate(cmd, f); err != nil {
		return
	}

	if f, err = cfg.Configure(f); err != nil { // Updates f with deploy cfg
		return
	}

	container := f.Build.Builder != "host"

	// Ignore the verbose flag if JSON output
	if cfg.JSON {
		cfg.Verbose = false
	}

	// Client
	clientOptions, err := cfg.clientOptions()
	if err != nil {
		return
	}
	if container {
		clientOptions = append(clientOptions, fn.WithRunner(docker.NewRunner(cfg.Verbose, os.Stdout, os.Stderr)))
	}
	if cfg.StartTimeout != 0 {
		clientOptions = append(clientOptions, fn.WithStartTimeout(cfg.StartTimeout))
	}

	client, done := newClient(ClientConfig{Verbose: cfg.Verbose}, clientOptions...)
	defer done()

	// Build
	//
	// If requesting to run via the container, build the container if it is
	// either out-of-date or a build was explicitly requested.
	if container {
		var digested bool

		buildOptions, err := cfg.buildOptions()
		if err != nil {
			return err
		}

		// if image was specified, check if its digested and do basic validation
		if cfg.Image != "" {
			digested, err = isDigested(cfg.Image)
			if err != nil {
				return err
			}
			// image was parsed and both digested AND undigested imgs are valid
			f.Build.Image = cfg.Image
		}

		// actual build step
		if !digested {
			if f, _, err = build(cmd, cfg.Build, f, client, buildOptions); err != nil {
				return err
			}
		}
	} else { // if !container
		// dont run digested image without a container
		if cfg.Image != "" {
			digested, err := isDigested(cfg.Image)
			if err != nil {
				return err
			}
			if digested {
				return fmt.Errorf("cannot use digested image with non-containerized builds (--builder=host)")
			}
		}
	}

	// Run
	//
	// Runs the code either via a container or the default host-based runner.
	// For the former, build is required and a container runtime.  For the
	// latter, scaffolding is first applied and the local host must be
	// configured to build/run the language of the function.
	job, err := client.Run(cmd.Context(), f, fn.RunWithAddress(cfg.Address))
	if err != nil {
		return
	}
	defer func() {
		if err = job.Stop(); err != nil {
			fmt.Fprintf(cmd.OutOrStderr(), "Job stop error. %v", err)
		}
	}()

	// Output based on format
	if cfg.JSON {
		// Create JSON output structure
		output := struct {
			Address string `json:"address"`
			Host    string `json:"host"`
			Port    string `json:"port"`
		}{
			Address: fmt.Sprintf("http://%s:%s", job.Host, job.Port),
			Host:    job.Host,
			Port:    job.Port,
		}

		jsonData, err := json.Marshal(output)
		if err != nil {
			return fmt.Errorf("failed to marshal JSON output: %w", err)
		}
		fmt.Fprintln(cmd.OutOrStdout(), string(jsonData))
	} else {
		fmt.Fprintf(cmd.OutOrStderr(), "Function running on %s\n", net.JoinHostPort(job.Host, job.Port))
	}

	select {
	case <-cmd.Context().Done():
		if !errors.Is(cmd.Context().Err(), context.Canceled) {
			err = cmd.Context().Err()
		}
	case err = <-job.Errors:
		return
		// Bubble up runtime errors on the optional channel used for async job
		// such as docker containers.
	}

	// NOTE: we do not f.Write() here unlike deploy (and build).
	// running is ephemeral: a run is not affecting the function itself,
	// as opposed to deploy commands, which are actually mutating the current
	// state of the function as it exists on the network.
	// Another way to think of this is that runs are development-centric tests,
	// and thus most likely values changed such as environment variables,
	// builder, etc. would not be expected to persist and affect the next deploy.
	// Run is ephemeral, deploy is persistent.
	return
}

type runConfig struct {
	buildConfig // further embeds config.Global

	// Built instructs building to happen or not
	// Can be 'auto' or a truthy value.
	Build string

	// Env variables.  may include removals using a "-"
	Env []string

	// StartTimeout optionally adjusts the startup timeout from the client's
	// default of fn.DefaultStartTimeout.
	StartTimeout time.Duration

	// Address is the interface and port to bind (e.g. "0.0.0.0:8081")
	Address string

	// JSON output format
	JSON bool
}

func newRunConfig(cmd *cobra.Command) (c runConfig) {
	c = runConfig{
		buildConfig:  newBuildConfig(),
		Build:        viper.GetString("build"),
		Env:          viper.GetStringSlice("env"),
		StartTimeout: viper.GetDuration("start-timeout"),
		Address:      viper.GetString("address"),
		JSON:         viper.GetBool("json"),
	}
	// NOTE: .Env should be viper.GetStringSlice, but this returns unparsed
	// results and appears to be an open issue since 2017:
	// https://github.com/spf13/viper/issues/380
	var err error
	if c.Env, err = cmd.Flags().GetStringArray("env"); err != nil {
		fmt.Fprintf(cmd.OutOrStdout(), "error reading envs: %v", err)
	}
	return
}

// Configure the given function.  Updates a function struct with all
// configurable values.  Note that the config alrady includes function's
// current state, as they were passed through via flag defaults.
func (c runConfig) Configure(f fn.Function) (fn.Function, error) {
	var err error
	f = c.buildConfig.Configure(f)

	f.Run.StartTimeout = c.StartTimeout

	f.Run.Envs, err = applyEnvs(f.Run.Envs, c.Env)

	// The other members; build and path; are not part of function
	// state, so are not mentioned here in Configure.
	return f, err
}

func (c runConfig) Prompt() (runConfig, error) {
	var err error

	if c.buildConfig, err = c.buildConfig.Prompt(); err != nil {
		return c, err
	}

	if !interactiveTerminal() || !c.Confirm {
		return c, nil
	}

	// TODO:  prompt for additional settings here
	return c, nil
}

func (c runConfig) Validate(cmd *cobra.Command, f fn.Function) (err error) {
	// Bubble
	if err = c.buildConfig.Validate(); err != nil {
		return
	}

	// --build can be "auto"|true|false
	if c.Build != "auto" {
		if _, err := strconv.ParseBool(c.Build); err != nil {
			return fmt.Errorf("unrecognized value for --build '%v'.  Accepts 'auto', 'true' or 'false' (or similarly truthy value)", c.Build)
		}
	}

	if f.Build.Builder == "host" && !oci.IsSupported(f.Runtime) {
		return fmt.Errorf("the %q runtime currently requires being run in a container", f.Runtime)
	}

	// When the docker runner respects the StartTimeout, this validation check
	// can be removed
	if c.StartTimeout != 0 && f.Build.Builder != "host" {
		return errors.New("the ability to specify the startup timeout for containerized runs is coming soon")
	}

	return
}
