package script

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/dop251/goja"
	"github.com/dop251/goja_nodejs/eventloop"
)

const (
	DefaultTimeout    = 30 * time.Second
	PreprocessTimeout = 10 * time.Second
)

type Logger interface {
	Debug(msg string, args ...any)
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}

type Executor struct {
	HTTPClient *http.Client
	Logger     Logger
}

type Options struct {
	Script              string
	Args                []any
	ContextData         map[string]any
	ScriptName          string
	PreferredEntryNames []string
	Timeout             time.Duration
	HTTPClient          *http.Client
}

type Error struct {
	Code    string
	Message string
}

func (e *Error) Error() string {
	if e == nil {
		return ""
	}
	if strings.TrimSpace(e.Message) != "" {
		return e.Message
	}
	return e.Code
}

func (e *Error) ErrorCode() string {
	if e == nil || strings.TrimSpace(e.Code) == "" {
		return "script_error"
	}
	return e.Code
}

type result struct {
	value any
	err   error
}

var (
	functionDeclarationPattern = regexp.MustCompile(`(?:^|\n)\s*(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(`)
	assignedFunctionPattern    = regexp.MustCompile(`(?:^|\n)\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)`)
)

func (e Executor) Execute(ctx context.Context, opts Options) (any, error) {
	scriptText := strings.TrimSpace(opts.Script)
	if scriptText == "" {
		return nil, &Error{Code: "script_empty", Message: "script is empty"}
	}
	scriptName := strings.TrimSpace(opts.ScriptName)
	if scriptName == "" {
		scriptName = "script"
	}
	timeout := opts.Timeout
	if timeout <= 0 {
		timeout = DefaultTimeout
	}
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	loop := eventloop.NewEventLoop(eventloop.EnableConsole(false))
	loop.Start()
	defer loop.Terminate()

	resultCh := make(chan result, 1)
	var once sync.Once
	finish := func(value any, err error) {
		once.Do(func() {
			resultCh <- result{value: value, err: err}
			loop.StopNoWait()
		})
	}

	ok := loop.RunOnLoop(func(vm *goja.Runtime) {
		e.installRuntime(ctx, loop, vm, opts.HTTPClient, scriptName)
		for key, value := range opts.ContextData {
			_ = vm.Set(key, value)
		}
		value, err := e.invoke(vm, scriptText, opts.Args, opts.PreferredEntryNames, scriptName)
		if err != nil {
			finish(nil, err)
			return
		}
		e.resolveValue(vm, value, finish)
	})
	if !ok {
		return nil, &Error{Code: "script_runtime_error", Message: "script event loop is not available"}
	}

	select {
	case out := <-resultCh:
		if out.err != nil {
			return nil, out.err
		}
		return normalizeExport(out.value), nil
	case <-ctx.Done():
		loop.Terminate()
		code := "script_timeout"
		if errors.Is(ctx.Err(), context.Canceled) {
			code = "script_cancelled"
		}
		return nil, &Error{Code: code, Message: fmt.Sprintf("%s exceeded %s", scriptName, timeout)}
	}
}

func (e Executor) invoke(vm *goja.Runtime, scriptText string, args []any, preferred []string, scriptName string) (goja.Value, error) {
	if fnValue, err := vm.RunString("(" + scriptText + ")"); err == nil {
		if fn, ok := goja.AssertFunction(fnValue); ok {
			return fn(goja.Undefined(), values(vm, args)...)
		}
	}

	if _, err := vm.RunString(scriptText); err != nil {
		return nil, &Error{Code: "script_compile_error", Message: err.Error()}
	}

	for _, name := range entryCandidates(scriptText, preferred) {
		fnValue, err := vm.RunString(fmt.Sprintf("(typeof %s === 'function' ? %s : undefined)", name, name))
		if err != nil || goja.IsUndefined(fnValue) || goja.IsNull(fnValue) {
			continue
		}
		fn, ok := goja.AssertFunction(fnValue)
		if !ok {
			continue
		}
		return fn(goja.Undefined(), values(vm, args)...)
	}

	return nil, &Error{Code: "script_entry_missing", Message: fmt.Sprintf("%s must expose an executable function", scriptName)}
}

func (e Executor) resolveValue(vm *goja.Runtime, value goja.Value, finish func(any, error)) {
	if value == nil {
		finish(nil, nil)
		return
	}
	if promise, ok := value.Export().(*goja.Promise); ok {
		switch promise.State() {
		case goja.PromiseStateFulfilled:
			finish(exportValue(promise.Result()), nil)
		case goja.PromiseStateRejected:
			finish(nil, &Error{Code: "script_error", Message: stringify(promise.Result())})
		default:
			obj := value.ToObject(vm)
			thenFn, ok := goja.AssertFunction(obj.Get("then"))
			if !ok {
				finish(nil, &Error{Code: "script_error", Message: "promise.then is not callable"})
				return
			}
			onResolve := func(call goja.FunctionCall) goja.Value {
				finish(exportValue(call.Argument(0)), nil)
				return goja.Undefined()
			}
			onReject := func(call goja.FunctionCall) goja.Value {
				finish(nil, &Error{Code: "script_error", Message: stringify(call.Argument(0))})
				return goja.Undefined()
			}
			_, _ = thenFn(obj, vm.ToValue(onResolve), vm.ToValue(onReject))
		}
		return
	}
	finish(exportValue(value), nil)
}

func (e Executor) installRuntime(ctx context.Context, loop *eventloop.EventLoop, vm *goja.Runtime, client *http.Client, scriptName string) {
	vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
	e.installConsole(vm, scriptName)
	e.installHTTP(ctx, loop, vm, firstHTTPClient(client, e.HTTPClient), scriptName)
	_ = vm.Set("FormData", formDataConstructor(vm))
	_, _ = vm.RunString(`
		function __easyaiGotRequest(method, url, options) {
			return {
				json: function() { return __easyaiHTTP(method, url, options || {}).then(function(resp) { return resp.json(); }); },
				text: function() { return __easyaiHTTP(method, url, options || {}).then(function(resp) { return resp.text(); }); }
			};
		}
		var got = {
			get: function(url, options) { return __easyaiGotRequest("GET", url, options); },
			post: function(url, options) { return __easyaiGotRequest("POST", url, options); },
			put: function(url, options) { return __easyaiGotRequest("PUT", url, options); },
			patch: function(url, options) { return __easyaiGotRequest("PATCH", url, options); },
			delete: function(url, options) { return __easyaiGotRequest("DELETE", url, options); },
			extend: function() { return this; }
		};
		function fetch(url, options) {
			options = options || {};
			return __easyaiHTTP(options.method || "GET", url, options);
		}
	`)
}

func (e Executor) installConsole(vm *goja.Runtime, scriptName string) {
	log := func(level string, args ...any) {
		if e.Logger == nil {
			return
		}
		values := make([]any, 0, len(args)+1)
		values = append(values, "script", scriptName)
		values = append(values, args...)
		switch level {
		case "error":
			e.Logger.Error("script console", values...)
		case "warn":
			e.Logger.Warn("script console", values...)
		case "info":
			e.Logger.Info("script console", values...)
		default:
			e.Logger.Debug("script console", values...)
		}
	}
	_ = vm.Set("console", map[string]any{
		"log":   func(args ...any) { log("debug", args...) },
		"debug": func(args ...any) { log("debug", args...) },
		"info":  func(args ...any) { log("info", args...) },
		"warn":  func(args ...any) { log("warn", args...) },
		"error": func(args ...any) { log("error", args...) },
	})
}

func (e Executor) installHTTP(ctx context.Context, loop *eventloop.EventLoop, vm *goja.Runtime, client *http.Client, scriptName string) {
	_ = vm.Set("__easyaiHTTP", func(call goja.FunctionCall) goja.Value {
		method := strings.ToUpper(strings.TrimSpace(call.Argument(0).String()))
		if method == "" {
			method = http.MethodGet
		}
		url := strings.TrimSpace(call.Argument(1).String())
		options := exportMap(call.Argument(2))
		promise, resolve, reject := vm.NewPromise()
		go func() {
			response, err := doHTTPRequest(ctx, client, method, url, options)
			loop.RunOnLoop(func(runtime *goja.Runtime) {
				if err != nil {
					_ = reject(err.Error())
					return
				}
				_ = resolve(httpResponseObject(runtime, response))
			})
		}()
		return vm.ToValue(promise)
	})
}

func doHTTPRequest(ctx context.Context, client *http.Client, method string, url string, options map[string]any) (httpScriptResponse, error) {
	if strings.TrimSpace(url) == "" {
		return httpScriptResponse{}, errors.New("url is required")
	}
	var body io.Reader
	headers := map[string]string{}
	if rawHeaders, ok := options["headers"].(map[string]any); ok {
		for key, value := range rawHeaders {
			if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
				headers[key] = text
			}
		}
	}
	if jsonBody, ok := options["json"]; ok {
		raw, err := json.Marshal(jsonBody)
		if err != nil {
			return httpScriptResponse{}, err
		}
		body = bytes.NewReader(raw)
		if _, ok := headers["Content-Type"]; !ok {
			headers["Content-Type"] = "application/json"
		}
	} else if rawBody, ok := options["body"]; ok {
		body, headers = requestBody(rawBody, headers)
	}
	req, err := http.NewRequestWithContext(ctx, method, url, body)
	if err != nil {
		return httpScriptResponse{}, err
	}
	for key, value := range headers {
		req.Header.Set(key, value)
	}
	resp, err := client.Do(req)
	if err != nil {
		return httpScriptResponse{}, err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
	out := httpScriptResponse{
		Status:     resp.Status,
		StatusCode: resp.StatusCode,
		OK:         resp.StatusCode >= 200 && resp.StatusCode < 300,
		Headers:    map[string]any{},
		Body:       string(raw),
	}
	for key, values := range resp.Header {
		if len(values) == 1 {
			out.Headers[key] = values[0]
		} else {
			out.Headers[key] = values
		}
	}
	if len(raw) > 0 {
		var parsed any
		if json.Unmarshal(raw, &parsed) == nil {
			out.JSON = parsed
		}
	}
	return out, nil
}

type httpScriptResponse struct {
	Status     string
	StatusCode int
	OK         bool
	Headers    map[string]any
	Body       string
	JSON       any
}

func httpResponseObject(vm *goja.Runtime, response httpScriptResponse) map[string]any {
	return map[string]any{
		"status":     response.StatusCode,
		"statusCode": response.StatusCode,
		"ok":         response.OK,
		"headers":    response.Headers,
		"text": func() string {
			return response.Body
		},
		"json": func() any {
			if response.JSON != nil {
				return response.JSON
			}
			var parsed any
			if json.Unmarshal([]byte(response.Body), &parsed) == nil {
				return parsed
			}
			panic(vm.NewTypeError("response body is not valid JSON"))
		},
	}
}

func requestBody(value any, headers map[string]string) (io.Reader, map[string]string) {
	switch typed := value.(type) {
	case string:
		return strings.NewReader(typed), headers
	case []byte:
		return bytes.NewReader(typed), headers
	case map[string]any:
		if fields, ok := typed["__easyaiFormData"].([]any); ok {
			var buf bytes.Buffer
			writer := multipart.NewWriter(&buf)
			for _, rawField := range fields {
				field, ok := rawField.(map[string]any)
				if !ok {
					continue
				}
				_ = writer.WriteField(strings.TrimSpace(fmt.Sprint(field["name"])), fmt.Sprint(field["value"]))
			}
			_ = writer.Close()
			headers["Content-Type"] = writer.FormDataContentType()
			return &buf, headers
		}
		raw, _ := json.Marshal(typed)
		headers["Content-Type"] = "application/json"
		return bytes.NewReader(raw), headers
	default:
		raw, _ := json.Marshal(typed)
		headers["Content-Type"] = "application/json"
		return bytes.NewReader(raw), headers
	}
}

func formDataConstructor(vm *goja.Runtime) func(goja.ConstructorCall) *goja.Object {
	return func(call goja.ConstructorCall) *goja.Object {
		obj := call.This
		_ = obj.Set("__easyaiFormData", []any{})
		_ = obj.Set("append", func(name string, value any) {
			fields := exportSlice(obj.Get("__easyaiFormData"))
			fields = append(fields, map[string]any{"name": name, "value": value})
			_ = obj.Set("__easyaiFormData", fields)
		})
		return obj
	}
}

func entryCandidates(scriptText string, preferred []string) []string {
	values := make([]string, 0, len(preferred)+4)
	appendUnique := func(value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		for _, existing := range values {
			if existing == value {
				return
			}
		}
		values = append(values, value)
	}
	for _, value := range preferred {
		appendUnique(value)
	}
	for _, match := range functionDeclarationPattern.FindAllStringSubmatch(scriptText, -1) {
		appendUnique(match[1])
	}
	for _, match := range assignedFunctionPattern.FindAllStringSubmatch(scriptText, -1) {
		appendUnique(match[1])
	}
	appendUnique("main")
	appendUnique("handler")
	return values
}

func values(vm *goja.Runtime, input []any) []goja.Value {
	out := make([]goja.Value, 0, len(input))
	for _, item := range input {
		out = append(out, toValue(vm, item))
	}
	return out
}

func toValue(vm *goja.Runtime, item any) goja.Value {
	if values, ok := item.(map[string]any); ok {
		copied := map[string]any{}
		for key, value := range values {
			if key == "__easyaiScriptContext" {
				continue
			}
			copied[key] = value
		}
		obj := vm.ToValue(copied).ToObject(vm)
		if marker, _ := values["__easyaiScriptContext"].(bool); marker {
			_ = obj.Set("got", vm.Get("got"))
			_ = obj.Set("fetch", vm.Get("fetch"))
			_ = obj.Set("FormData", vm.Get("FormData"))
		}
		return obj
	}
	return vm.ToValue(item)
}

func exportValue(value goja.Value) any {
	if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
		return nil
	}
	return value.Export()
}

func exportMap(value goja.Value) map[string]any {
	if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
		return map[string]any{}
	}
	if typed, ok := normalizeExport(value.Export()).(map[string]any); ok {
		return typed
	}
	return map[string]any{}
}

func exportSlice(value goja.Value) []any {
	if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
		return []any{}
	}
	if typed, ok := normalizeExport(value.Export()).([]any); ok {
		return typed
	}
	return []any{}
}

func normalizeExport(value any) any {
	raw, err := json.Marshal(value)
	if err != nil {
		return value
	}
	var out any
	if json.Unmarshal(raw, &out) != nil {
		return value
	}
	return out
}

func firstHTTPClient(values ...*http.Client) *http.Client {
	for _, value := range values {
		if value != nil {
			return value
		}
	}
	return http.DefaultClient
}

func stringify(value goja.Value) string {
	if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
		return "script rejected"
	}
	if exported, ok := normalizeExport(value.Export()).(map[string]any); ok {
		for _, key := range []string{"message", "error", "error_message"} {
			if message := strings.TrimSpace(fmt.Sprint(exported[key])); message != "" && message != "<nil>" {
				return message
			}
		}
	}
	return strings.TrimSpace(value.String())
}
