package actions

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/dop251/goja"
	"github.com/zitadel/logging"

	z_errs "github.com/zitadel/zitadel/internal/errors"
)

func WithHTTP(ctx context.Context) Option {
	return func(c *runConfig) {
		c.modules["zitadel/http"] = func(runtime *goja.Runtime, module *goja.Object) {
			requireHTTP(ctx, &http.Client{Transport: new(transport)}, runtime, module)
		}
	}
}

type HTTP struct {
	runtime *goja.Runtime
	client  *http.Client
}

func requireHTTP(ctx context.Context, client *http.Client, runtime *goja.Runtime, module *goja.Object) {
	c := &HTTP{
		client:  client,
		runtime: runtime,
	}
	o := module.Get("exports").(*goja.Object)
	logging.OnError(o.Set("fetch", c.fetch(ctx))).Warn("unable to set module")
}

type fetchConfig struct {
	Method  string
	Headers http.Header
	Body    io.Reader
}

var defaultFetchConfig = fetchConfig{
	Method: http.MethodGet,
	Headers: http.Header{
		"Content-Type": []string{"application/json"},
		"Accept":       []string{"application/json"},
	},
}

func (c *HTTP) fetchConfigFromArg(arg *goja.Object, config *fetchConfig) (err error) {
	for _, key := range arg.Keys() {
		switch key {
		case "headers":
			config.Headers = parseHeaders(arg.Get(key).ToObject(c.runtime))
		case "method":
			config.Method = arg.Get(key).String()
		case "body":
			body, err := arg.Get(key).ToObject(c.runtime).MarshalJSON()
			if err != nil {
				return err
			}
			config.Body = bytes.NewReader(body)
		default:
			return z_errs.ThrowInvalidArgument(nil, "ACTIO-OfUeA", "key is invalid")
		}
	}
	return nil
}

type response struct {
	Body    string
	Status  int
	Headers map[string][]string
	runtime *goja.Runtime
}

func (r *response) Json() goja.Value {
	var val interface{}

	if err := json.Unmarshal([]byte(r.Body), &val); err != nil {
		panic(err)
	}

	return r.runtime.ToValue(val)
}

func (r *response) Text() goja.Value {
	return r.runtime.ToValue(r.Body)
}

func (c *HTTP) fetch(ctx context.Context) func(call goja.FunctionCall) goja.Value {
	return func(call goja.FunctionCall) goja.Value {
		req := c.buildHTTPRequest(ctx, call.Arguments)
		if deadline, ok := ctx.Deadline(); ok {
			c.client.Timeout = time.Until(deadline)
		}

		res, err := c.client.Do(req)
		if err != nil {
			logging.WithError(err).Debug("call failed")
			panic(err)
		}
		defer res.Body.Close()

		body, err := io.ReadAll(res.Body)
		if err != nil {
			logging.WithError(err).Warn("unable to parse body")
			panic("unable to read response body")
		}
		return c.runtime.ToValue(&response{Status: res.StatusCode, Body: string(body), runtime: c.runtime})
	}
}

// the first argument has to be a string and is required
// the second agrument is optional and an object with the following fields possible:
// - `Headers`: map with string key and value of type string or string array
// - `Body`: json body of the request
// - `Method`: http method type
func (c *HTTP) buildHTTPRequest(ctx context.Context, args []goja.Value) (req *http.Request) {
	if len(args) > 2 {
		logging.WithFields("count", len(args)).Debug("more than 2 args provided")
		panic("too many args")
	}

	if len(args) == 0 {
		panic("no url provided")
	}

	config := defaultFetchConfig
	var err error
	if len(args) == 2 {
		if err = c.fetchConfigFromArg(args[1].ToObject(c.runtime), &config); err != nil {
			panic(err)
		}
	}

	req, err = http.NewRequestWithContext(ctx, config.Method, args[0].Export().(string), config.Body)
	if err != nil {
		panic(err)
	}
	req.Header = config.Headers

	return req
}

func parseHeaders(headers *goja.Object) http.Header {
	h := make(http.Header, len(headers.Keys()))
	for _, k := range headers.Keys() {
		header := headers.Get(k).Export()
		var values []string

		switch headerValue := header.(type) {
		case string:
			values = strings.Split(headerValue, ",")
		case []any:
			for _, v := range headerValue {
				values = append(values, v.(string))
			}
		}

		for _, v := range values {
			h.Add(k, strings.TrimSpace(v))
		}
	}
	return h
}

type transport struct{}

func (*transport) RoundTrip(req *http.Request) (*http.Response, error) {
	if httpConfig == nil {
		return http.DefaultTransport.RoundTrip(req)
	}
	if isHostBlocked(httpConfig.DenyList, req.URL) {
		return nil, z_errs.ThrowInvalidArgument(nil, "ACTIO-N72d0", "host is denied")
	}
	return http.DefaultTransport.RoundTrip(req)
}

func isHostBlocked(denyList []AddressChecker, address *url.URL) bool {
	for _, blocked := range denyList {
		if blocked.Matches(address.Hostname()) {
			return true
		}
	}
	return false
}

type AddressChecker interface {
	Matches(string) bool
}