package runlocal

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"reflect"
	"time"

	"github.com/spf13/cobra"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/cmd"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/autoflow/engine"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/autoflow/flow"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/httpz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/pkg/event"
	"go.starlark.net/starlark"
	"google.golang.org/protobuf/encoding/protojson"
)

const defaultGitLabURL = "https://gitlab.com"

type options struct {
	log                *slog.Logger
	flowScriptFilePath string
	eventStream        io.Reader
	gitlabURL          string
	gitlabToken        string
}

func newOptions(log *slog.Logger) *options {
	return &options{
		log:         log,
		gitlabURL:   defaultGitLabURL,
		gitlabToken: os.Getenv("GITLAB_TOKEN"),
	}
}

func NewCommand(log *slog.Logger) *cobra.Command {
	o := newOptions(log)
	c := &cobra.Command{
		Use:   "run-local",
		Short: "Run an AutoFlow script locally for a given event",
		Long: `This command can be used to locally run an event handler from an AutoFlow script given an event.

The event data is received in stdin while the AutoFlow script must be provided as a script file with the --flow-script flag.

The GitLab URL and token are injected from either environment variables or the --gitlab-url and --gitlab-token flags, respectively.
`,
		Example: `cat test-event | autoflow run-local -f flow.star`,
		Run: func(c *cobra.Command, args []string) {
			o.complete(c)
			cmd.LogAndExitOnError(o.log, o.validate())
			cmd.LogAndExitOnError(o.log, o.run(c.Context()))
		},
	}
	f := c.Flags()
	f.StringVarP(&o.flowScriptFilePath, "flow-script", "f", o.flowScriptFilePath, "Filepath to the AutoFlow script to run")
	f.StringVarP(&o.gitlabURL, "gitlab-url", "u", o.gitlabURL, "URL to the GitLab instance")
	cobra.CheckErr(c.MarkFlagRequired("flow-script"))
	return c
}

func (o *options) complete(cmd *cobra.Command) {
	o.eventStream = cmd.InOrStdin()
}

func (o *options) validate() error {
	if o.gitlabToken == "" {
		return errors.New("missing GitLab token. Use $GITLAB_TOKEN to set it")
	}
	return nil
}

func consumeEvent(in io.Reader) (*event.CloudEvent, error) {
	eventData, err := io.ReadAll(in)
	if err != nil {
		return nil, err
	}

	event := &event.CloudEvent{}
	err = protojson.Unmarshal(eventData, event)
	if err != nil {
		return nil, err
	}
	return event, nil
}

func (o *options) run(ctx context.Context) error {
	event, err := consumeEvent(o.eventStream)
	if err != nil {
		return err
	}

	log := o.log.With(logz.FlowScript(o.flowScriptFilePath))
	log.Info("Running flow script for event", logz.EventType(event.Type))

	flowScript, err := os.ReadFile(o.flowScriptFilePath)
	if err != nil {
		return err
	}

	modules := []flow.ModuleSource{
		{
			SourceName: "",
			File:       o.flowScriptFilePath,
			Data:       flowScript,
		},
	}
	builtins := &localFlowBuiltins{
		log: log,
	}
	runtime, err := flow.NewRuntimeFromSource(ctx, flow.RuntimeOptions{
		Entrypoint: o.flowScriptFilePath,
		Builtins:   builtins,
		Source:     flow.NewTestingSource(modules),
	})
	if err != nil {
		return err
	}

	eventDict, err := engine.EventToDict(event)
	if err != nil {
		return err
	}
	eventHandlerIDs, err := runtime.EvaluateEventHandlerIDs(event.Type, eventDict)
	if err != nil {
		return err
	}

	log.Info("Found event handlers", logz.NumberOfEventHandlers(len(eventHandlerIDs)))

	fc := &localFlowContext{
		ctx: ctx,
		rt:  http.DefaultTransport,
	}
	vars := starlark.StringDict{
		"gitlab_url":   starlark.String(o.gitlabURL),
		"gitlab_token": starlark.String(o.gitlabToken),
	}
	for i, eventHandlerID := range eventHandlerIDs {
		l := log.With(logz.EventHandler(eventHandlerID))
		l.Info("Running event handler")
		runtimeErr := runtime.Execute(event.Type, uint32(i), fc, vars, eventDict) //nolint:gosec
		if runtimeErr != nil {
			l.Error("Executed event handler with error", logz.Error(runtimeErr))
		} else {
			l.Info("Executed event handler successfully")
		}
	}
	return nil
}

type localFlowBuiltins struct {
	log *slog.Logger
}

func (b *localFlowBuiltins) Now() time.Time {
	return time.Now()
}

func (b *localFlowBuiltins) Print(msg string) {
	b.log.Debug(msg)
}

type localFlowContext struct {
	ctx context.Context
	rt  http.RoundTripper
}

func (f *localFlowContext) HTTPDo(reqURL, method string, query url.Values, header http.Header, body []byte) (*flow.HTTPResponse, error) {
	u, err := url.Parse(reqURL)
	if err != nil {
		return nil, err
	}

	url := httpz.MergeURLPathAndQuery(u, "", query)
	statusCode, status, header, data, err := engine.HTTPDo(f.ctx, f.rt, url, method, body, header)
	if err != nil {
		return nil, err
	}

	return &flow.HTTPResponse{
		Status:     status,
		StatusCode: uint32(statusCode), //nolint:gosec
		Header:     header,
		Body:       data,
	}, nil
}

func (f *localFlowContext) Select(cases *starlark.Dict, defaultCase starlark.Value, timeout time.Duration, timeoutValue starlark.Value) (starlark.Value, error) {
	selectCases := make([]reflect.SelectCase, 0, cases.Len())
	lookup := make([]starlark.Value, 0, cases.Len())
	for k, v := range cases.Entries() {
		_, ok := k.(starlark.String)
		if !ok {
			return nil, fmt.Errorf("key must be a string, got %s", k.Type())
		}
		switch v := v.(type) {
		// NOTE: I think we need another abstraction, not future - but timer or channel or something.
		case *flow.FutureType:
			// In this case it's a timer ...
			timer, ok := v.Unwrap().(*time.Timer)
			if !ok {
				return nil, errors.New("internal error: future type did not return a wrapped timer")
			}
			selectCases = append(selectCases, reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(timer.C)})
			lookup = append(lookup, k)
		case *flow.ReceiveChannelType:
			// TODO: support receiving from channel
			return nil, errors.New("the run-local runtime does not implement receiving from a channel yet")
		default:
			return nil, fmt.Errorf("unsupported value type %s for the select key %q", v.Type(), k)
		}
	}

	if timeout != 0 {
		timer := time.NewTimer(timeout)
		selectCases = append(selectCases, reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(timer.C)})
		lookup = append(lookup, timeoutValue)
	}

	if defaultCase != starlark.None {
		selectCases = append(selectCases, reflect.SelectCase{Dir: reflect.SelectDefault})
		lookup = append(lookup, defaultCase)
	}
	chosen, _, ok := reflect.Select(selectCases)
	if !ok {
		// we have the default case
		return defaultCase, nil
	}

	if chosen >= len(lookup) {
		return nil, errors.New("selected case out of range")
	}

	selected := lookup[chosen]
	return selected, nil
}

func (f *localFlowContext) Signal(workflowID, signalName, payloadJSON, runID string) (bool /* signaled */, error) {
	// TODO: support signal
	return false, errors.New("the run-local runtime does not implement the signal feature yet")
}

func (f *localFlowContext) SignalChannel(signalName string) (flow.ReceiveChannel, error) {
	// TODO: support signal channel
	return nil, errors.New("the run-local runtime does not implement the signal channel feature yet")
}

func (f *localFlowContext) Sleep(duration time.Duration) error {
	select {
	case <-f.ctx.Done():
		return fmt.Errorf("aborted sleep: %w", f.ctx.Err())
	case <-time.After(duration):
		return nil
	}
}

func (f *localFlowContext) Timer(duration time.Duration) flow.Future {
	return &localTimer{
		ctx: f.ctx,
		t:   time.NewTimer(duration),
	}
}

type localTimer struct {
	ctx context.Context
	t   *time.Timer
}

func (t *localTimer) Get() error {
	select {
	case <-t.ctx.Done():
		return t.ctx.Err()
	case <-t.t.C:
		return nil
	}
}

func (t *localTimer) Unwrap() any {
	return t.t
}
