package http

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/nikoksr/notify"
)

type (
	// PreSendHookFn defines a function signature for a pre-send hook.
	PreSendHookFn func(req *http.Request) error

	// PostSendHookFn defines a function signature for a post-send hook.
	PostSendHookFn func(req *http.Request, resp *http.Response) error

	// BuildPayloadFn defines a function signature for a function that builds a payload.
	BuildPayloadFn func(subject, message string) (payload any)

	// Serializer is used to serialize the payload to a byte slice.
	Serializer interface {
		Marshal(contentType string, payload any) (payloadRaw []byte, err error)
	}

	// Webhook represents a single webhook receiver. It contains all the information needed to send a valid request to
	// the receiver. The BuildPayload function is used to build the payload that will be sent to the receiver from the
	// given subject and message.
	Webhook struct {
		ContentType  string
		Header       http.Header
		Method       string
		URL          string
		BuildPayload BuildPayloadFn
	}

	// Service is the main struct of this package. It contains all the information needed to send notifications to a
	// list of receivers. The receivers are represented by Webhooks and are expected to be valid HTTP endpoints. The
	// Service also allows.
	Service struct {
		client        *http.Client
		webhooks      []*Webhook
		preSendHooks  []PreSendHookFn
		postSendHooks []PostSendHookFn
		Serializer    Serializer
	}
)

const (
	defaultUserAgent     = "notify/" + notify.Version
	defaultContentType   = "application/json; charset=utf-8"
	defaultRequestMethod = http.MethodPost

	// Defining these as constants for testing purposes.
	defaultSubjectKey = "subject"
	defaultMessageKey = "message"
)

type defaultMarshaller struct{}

// Marshal takes a payload and serializes it to a byte slice. The content type is used to determine the serialization
// format. If the content type is not supported, an error is returned. The default marshaller supports the following
// content types: application/json, text/plain.
// NOTE: should we expand the default marshaller to support more content types?
func (defaultMarshaller) Marshal(contentType string, payload any) ([]byte, error) {
	var out []byte
	var err error

	switch {
	case strings.HasPrefix(contentType, "application/json"):
		out, err = json.Marshal(payload)
		if err != nil {
			return nil, fmt.Errorf("marshal payload: %w", err)
		}
	case strings.HasPrefix(contentType, "text/plain"):
		str, ok := payload.(string)
		if !ok {
			return nil, fmt.Errorf("payload was expected to be of type string, got %T", payload)
		}
		out = []byte(str)
	default:
		return nil, errors.New("unsupported content type")
	}

	return out, nil
}

// buildDefaultPayload is the default payload builder. It builds a payload that is a map with the keys "subject" and
// "message".
func buildDefaultPayload(subject, message string) any {
	return map[string]string{
		defaultSubjectKey: subject,
		defaultMessageKey: message,
	}
}

// New returns a new instance of a Service notification service. Parameter 'tag' is used as a log prefix and may be left
// empty, it has a fallback value.
func New() *Service {
	return &Service{
		client:        http.DefaultClient,
		webhooks:      []*Webhook{},
		preSendHooks:  []PreSendHookFn{},
		postSendHooks: []PostSendHookFn{},
		Serializer:    defaultMarshaller{},
	}
}

func newWebhook(url string) *Webhook {
	return &Webhook{
		ContentType:  defaultContentType,
		Header:       http.Header{},
		Method:       defaultRequestMethod,
		URL:          url,
		BuildPayload: buildDefaultPayload,
	}
}

// String returns a string representation of the webhook. It implements the fmt.Stringer interface.
func (w *Webhook) String() string {
	if w == nil {
		return ""
	}

	return strings.TrimSpace(fmt.Sprintf("%s %s %s", strings.ToUpper(w.Method), w.URL, w.ContentType))
}

// AddReceivers accepts a list of Webhooks and adds them as receivers. The Webhooks are expected to be valid HTTP
// endpoints.
func (s *Service) AddReceivers(webhooks ...*Webhook) {
	s.webhooks = append(s.webhooks, webhooks...)
}

// AddReceiversURLs accepts a list of URLs and adds them as receivers. Internally it converts the URLs to Webhooks by
// using the default content-type ("application/json") and request method ("POST").
func (s *Service) AddReceiversURLs(urls ...string) {
	for _, url := range urls {
		s.AddReceivers(newWebhook(url))
	}
}

// WithClient sets the http client to be used for sending requests. Calling this method is optional, the default client
// will be used if this method is not called.
func (s *Service) WithClient(client *http.Client) {
	if client != nil {
		s.client = client
	}
}

// doPreSendHooks executes all the pre-send hooks. If any of the hooks returns an error, the execution is stopped and
// the error is returned.
func (s *Service) doPreSendHooks(req *http.Request) error {
	for _, hook := range s.preSendHooks {
		if err := hook(req); err != nil {
			return err
		}
	}

	return nil
}

// doPostSendHooks executes all the post-send hooks. If any of the hooks returns an error, the execution is stopped and
// the error is returned.
func (s *Service) doPostSendHooks(req *http.Request, resp *http.Response) error {
	for _, hook := range s.postSendHooks {
		if err := hook(req, resp); err != nil {
			return err
		}
	}

	return nil
}

// PreSend adds a pre-send hook to the service. The hook will be executed before sending a request to a receiver.
func (s *Service) PreSend(hook PreSendHookFn) {
	s.preSendHooks = append(s.preSendHooks, hook)
}

// PostSend adds a post-send hook to the service. The hook will be executed after sending a request to a receiver.
func (s *Service) PostSend(hook PostSendHookFn) {
	s.postSendHooks = append(s.postSendHooks, hook)
}

// newRequest creates a new http request with the given method, content-type, url and payload. Request created by this
// function will usually be passed to the Service.do method.
func newRequest(ctx context.Context, hook *Webhook, payload io.Reader) (*http.Request, error) {
	req, err := http.NewRequestWithContext(ctx, hook.Method, hook.URL, payload)
	if err != nil {
		return nil, err
	}

	req.Header = hook.Header

	if req.Header.Get("User-Agent") == "" {
		req.Header.Set("User-Agent", defaultUserAgent)
	}
	if req.Header.Get("Content-Type") == "" {
		req.Header.Set("Content-Type", hook.ContentType)
	}

	return req, nil
}

// do sends the given request and returns an error if the request failed. A failed request gets identified by either
// an unsuccessful status code or a non-nil error. The given request is expected to be valid and was usually created
// by the newRequest function.
func (s *Service) do(req *http.Request) error {
	// Execute all pre-send hooks in order.
	if err := s.doPreSendHooks(req); err != nil {
		return fmt.Errorf("pre-send hooks: %w", err)
	}

	// Actually send the HTTP request.
	resp, err := s.client.Do(req)
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()

	// Execute all post-send hooks in order.
	if err = s.doPostSendHooks(req, resp); err != nil {
		return fmt.Errorf("post-send hooks: %w", err)
	}

	// Check if response code is 2xx. Should this be configurable?
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("responded with status code: %d", resp.StatusCode)
	}

	return nil
}

// send is a helper method that sends a message to a single webhook. It wraps the core logic of the Send method, which
// is creating a new request for the given webhook and sending it.
func (s *Service) send(ctx context.Context, webhook *Webhook, payload []byte) error {
	// Create a new HTTP request for the given webhook.
	req, err := newRequest(ctx, webhook, bytes.NewReader(payload))
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	defer func() { _ = req.Body.Close() }()

	return s.do(req)
}

// Send takes a message and sends it to all webhooks.
func (s *Service) Send(ctx context.Context, subject, message string) error {
	// Send message to all webhooks.
	for _, webhook := range s.webhooks {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			// Skip webhook if it is nil.
			if webhook == nil {
				continue
			}

			// Build the payload for the current webhook.
			payload := webhook.BuildPayload(subject, message)

			// Marshal the message into a payload.
			payloadRaw, err := s.Serializer.Marshal(webhook.ContentType, payload)
			if err != nil {
				return fmt.Errorf("marshal payload: %w", err)
			}

			// Send the payload to the webhook.
			if err = s.send(ctx, webhook, payloadRaw); err != nil {
				return fmt.Errorf("send to %s: %w", webhook.URL, err)
			}
		}
	}

	return nil
}