package ws

import (
	"bytes"
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/puzpuzpuz/xsync/v3"
	"lol.mleku.dev/chk"
	"lol.mleku.dev/log"
	"next.orly.dev/pkg/encoders/envelopes"
	"next.orly.dev/pkg/encoders/envelopes/authenvelope"
	"next.orly.dev/pkg/encoders/envelopes/closedenvelope"
	"next.orly.dev/pkg/encoders/envelopes/eoseenvelope"
	"next.orly.dev/pkg/encoders/envelopes/eventenvelope"
	"next.orly.dev/pkg/encoders/envelopes/noticeenvelope"
	"next.orly.dev/pkg/encoders/envelopes/okenvelope"
	"next.orly.dev/pkg/encoders/event"
	"next.orly.dev/pkg/encoders/filter"
	"next.orly.dev/pkg/encoders/hex"
	"next.orly.dev/pkg/encoders/kind"
	"next.orly.dev/pkg/encoders/tag"
	"next.orly.dev/pkg/interfaces/codec"
	"next.orly.dev/pkg/interfaces/signer"
	"next.orly.dev/pkg/utils/normalize"
)

var subscriptionIDCounter atomic.Int64

// Client represents a connection to a Nostr relay.
type Client struct {
	closeMutex sync.Mutex

	URL           string
	requestHeader http.Header // e.g. for origin header

	Connection    *Connection
	Subscriptions *xsync.MapOf[string, *Subscription]

	ConnectionError         error
	connectionContext       context.Context // will be canceled when the connection closes
	connectionContextCancel context.CancelCauseFunc

	challenge                     []byte       // NIP-42 challenge, we only keep the last
	notices                       chan []byte  // NIP-01 NOTICEs
	customHandler                 func(string) // nonstandard unparseable messages
	okCallbacks                   *xsync.MapOf[string, func(bool, string)]
	writeQueue                    chan writeRequest
	subscriptionChannelCloseQueue chan []byte

	// custom things that aren't often used
	//
	AssumeValid bool // this will skip verifying signatures for events received from this relay
}

type writeRequest struct {
	msg    []byte
	answer chan error
}

// NewRelay returns a new relay. It takes a context that, when canceled, will close the relay connection.
func NewRelay(ctx context.Context, url string, opts ...RelayOption) *Client {
	ctx, cancel := context.WithCancelCause(ctx)
	r := &Client{
		URL:                     string(normalize.URL(url)),
		connectionContext:       ctx,
		connectionContextCancel: cancel,
		Subscriptions:           xsync.NewMapOf[string, *Subscription](),
		okCallbacks: xsync.NewMapOf[string, func(
			bool, string,
		)](),
		writeQueue:                    make(chan writeRequest),
		subscriptionChannelCloseQueue: make(chan []byte),
		requestHeader:                 nil,
	}

	for _, opt := range opts {
		opt.ApplyRelayOption(r)
	}

	return r
}

// RelayConnect returns a relay object connected to url.
//
// The given subscription is only used during the connection phase. Once successfully connected, cancelling ctx has no effect.
//
// The ongoing relay connection uses a background context. To close the connection, call r.Close().
// If you need fine grained long-term connection contexts, use NewRelay() instead.
func RelayConnect(ctx context.Context, url string, opts ...RelayOption) (
	*Client, error,
) {
	r := NewRelay(context.Background(), url, opts...)
	err := r.Connect(ctx)
	return r, err
}

// RelayOption is the type of the argument passed when instantiating relay connections.
type RelayOption interface {
	ApplyRelayOption(*Client)
}

var (
	_ RelayOption = (WithCustomHandler)(nil)
	_ RelayOption = (WithRequestHeader)(nil)
)

// WithCustomHandler must be a function that handles any relay message that couldn't be
// parsed as a standard envelope.
type WithCustomHandler func(data string)

func (ch WithCustomHandler) ApplyRelayOption(r *Client) {
	r.customHandler = ch
}

// WithRequestHeader sets the HTTP request header of the websocket preflight request.
type WithRequestHeader http.Header

func (ch WithRequestHeader) ApplyRelayOption(r *Client) {
	r.requestHeader = http.Header(ch)
}

// String just returns the relay URL.
func (r *Client) String() string {
	return r.URL
}

// Context retrieves the context that is associated with this relay connection.
// It will be closed when the relay is disconnected.
func (r *Client) Context() context.Context { return r.connectionContext }

// IsConnected returns true if the connection to this relay seems to be active.
func (r *Client) IsConnected() bool { return r.connectionContext.Err() == nil }

// ConnectionCause returns the cancel cause for the relay connection context.
func (r *Client) ConnectionCause() error { return context.Cause(r.connectionContext) }

// LastError returns the last connection error observed by the reader loop.
func (r *Client) LastError() error { return r.ConnectionError }

// Connect tries to establish a websocket connection to r.URL.
// If the context expires before the connection is complete, an error is returned.
// Once successfully connected, context expiration has no effect: call r.Close
// to close the connection.
//
// The given context here is only used during the connection phase. The long-living
// relay connection will be based on the context given to NewRelay().
func (r *Client) Connect(ctx context.Context) error {
	return r.ConnectWithTLS(ctx, nil)
}

func extractSubID(jsonStr string) string {
	// look for "EVENT" pattern
	start := strings.Index(jsonStr, `"EVENT"`)
	if start == -1 {
		return ""
	}

	// move to the next quote
	offset := strings.Index(jsonStr[start+7:], `"`)
	if offset == -1 {
		return ""
	}

	start += 7 + offset + 1

	// find the ending quote
	end := strings.Index(jsonStr[start:], `"`)

	// get the contents
	return jsonStr[start : start+end]
}

func subIdToSerial(subId string) int64 {
	n := strings.Index(subId, ":")
	if n < 0 || n > len(subId) {
		return -1
	}
	serialId, _ := strconv.ParseInt(subId[0:n], 10, 64)
	return serialId
}

// ConnectWithTLS is like Connect(), but takes a special tls.Config if you need that.
func (r *Client) ConnectWithTLS(
	ctx context.Context, tlsConfig *tls.Config,
) error {
	if r.connectionContext == nil || r.Subscriptions == nil {
		return fmt.Errorf("relay must be initialized with a call to NewRelay()")
	}

	if r.URL == "" {
		return fmt.Errorf("invalid relay URL '%s'", r.URL)
	}

	if _, ok := ctx.Deadline(); !ok {
		// if no timeout is set, force it to 7 seconds
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeoutCause(
			ctx, 7*time.Second, errors.New("connection took too long"),
		)
		defer cancel()
	}

	conn, err := NewConnection(ctx, r.URL, r.requestHeader, tlsConfig)
	if err != nil {
		return fmt.Errorf("error opening websocket to '%s': %w", r.URL, err)
	}
	r.Connection = conn

	// ping every 29 seconds
	ticker := time.NewTicker(29 * time.Second)

	// queue all write operations here so we don't do mutex spaghetti
	go func() {
		var err error
		for {
			select {
			case <-r.connectionContext.Done():
				log.T.F(
					"WS.Client: connection context done for %s: cause=%v lastErr=%v",
					r.URL, context.Cause(r.connectionContext),
					r.ConnectionError,
				)
				ticker.Stop()
				r.Connection = nil

				for _, sub := range r.Subscriptions.Range {
					sub.unsub(
						fmt.Errorf(
							"relay connection closed: %w / %w",
							context.Cause(r.connectionContext),
							r.ConnectionError,
						),
					)
				}
				return

			case <-ticker.C:
				err = r.Connection.Ping(r.connectionContext)
				if err != nil && !strings.Contains(
					err.Error(), "failed to wait for pong",
				) {
					log.I.F(
						"{%s} error writing ping: %v; closing websocket", r.URL,
						err,
					)
					r.CloseWithReason(
						fmt.Errorf(
							"ping failed: %w", err,
						),
					) // this should trigger a context cancelation
					return
				}

			case wr := <-r.writeQueue:
				// all write requests will go through this to prevent races
				// log.D.F("{%s} sending %v\n", r.URL, string(wr.msg))
				log.T.F("WS.Client: outbound message to %s: %s", r.URL, string(wr.msg))
				if err = r.Connection.WriteMessage(
					r.connectionContext, wr.msg,
				); err != nil {
					wr.answer <- err
				}
				close(wr.answer)
			}
		}
	}()

	// general message reader loop
	go func() {

		for {
			buf := new(bytes.Buffer)
			for {
				buf.Reset()
				if err := conn.ReadMessage(
					r.connectionContext, buf,
				); err != nil {
					r.ConnectionError = err
					log.T.F(
						"WS.Client: reader loop error on %s: %v; closing connection",
						r.URL, err,
					)
					r.CloseWithReason(fmt.Errorf("reader loop error: %w", err))
					return
				}
				message := buf.Bytes()
				var t string
				if t, message, err = envelopes.Identify(message); chk.E(err) {
					continue
				}
				switch t {
				case noticeenvelope.L:
					env := noticeenvelope.New()
					if env, message, err = noticeenvelope.Parse(message); chk.E(err) {
						continue
					}
					// see WithNoticeHandler
					if r.notices != nil {
						r.notices <- env.Message
					} else {
						log.E.F("NOTICE from %s: '%s'", r.URL, env.Message)
					}
				case authenvelope.L:
					env := authenvelope.NewChallenge()
					if env, message, err = authenvelope.ParseChallenge(message); chk.E(err) {
						continue
					}
					if len(env.Challenge) == 0 {
						continue
					}
					r.challenge = env.Challenge
				case eventenvelope.L:
					env := eventenvelope.NewResult()
					if env, message, err = eventenvelope.ParseResult(message); chk.E(err) {
						continue
					}
					if len(env.Subscription) == 0 {
						continue
					}
					if sub, ok := r.Subscriptions.Load(string(env.Subscription)); !ok {
						log.D.F(
							"{%s} no subscription with id '%s'\n", r.URL,
							env.Subscription,
						)
						continue
					} else {
						// check if the event matches the desired filter, ignore otherwise
						if !sub.Filters.Match(env.Event) {
							// log.D.F(
							// 	"{%s} filter does not match: %v ~ %v\n", r.URL,
							// 	sub.Filters, env.Event,
							// )
							continue
						}
						// check signature, ignore invalid, except from trusted (AssumeValid) relays
						if !r.AssumeValid {
							if ok, err = env.Event.Verify(); !ok {
								log.E.F(
									"{%s} bad signature on %s\n", r.URL,
									env.Event,
								)
								continue
							}
						}
						// dispatch this to the internal .events channel of the subscription
						sub.dispatchEvent(env.Event)
					}
				case eoseenvelope.L:
					env := eoseenvelope.New()
					if env, message, err = eoseenvelope.Parse(message); chk.E(err) {
						continue
					}
					if subscription, ok := r.Subscriptions.Load(string(env.Subscription)); ok {
						subscription.dispatchEose()
					}
				case closedenvelope.L:
					env := closedenvelope.New()
					if env, message, err = closedenvelope.Parse(message); chk.E(err) {
						continue
					}
					if subscription, ok := r.Subscriptions.Load(string(env.Subscription)); ok {
						subscription.handleClosed(env.ReasonString())
					}
				case okenvelope.L:
					env := okenvelope.New()
					if env, message, err = okenvelope.Parse(message); chk.E(err) {
						continue
					}
					eventIDHex := hex.Enc(env.EventID)
					if okCallback, exist := r.okCallbacks.Load(eventIDHex); exist {
						okCallback(env.OK, env.ReasonString())
					}
				}
			}
		}
	}()

	return nil
}

// Write queues an arbitrary message to be sent to the relay.
func (r *Client) Write(msg []byte) <-chan error {
	ch := make(chan error)
	select {
	case r.writeQueue <- writeRequest{msg: msg, answer: ch}:
	case <-r.connectionContext.Done():
		go func() { ch <- fmt.Errorf("connection closed") }()
	}
	return ch
}

// Publish sends an "EVENT" command to the relay r as in NIP-01 and waits for an OK response.
func (r *Client) Publish(ctx context.Context, ev *event.E) error {
	return r.publish(
		ctx, hex.Enc(ev.ID), eventenvelope.NewSubmissionWith(ev),
	)
}

// Auth sends an "AUTH" command client->relay as in NIP-42 and waits for an OK response.
//
// You don't have to build the AUTH event yourself, this function takes a function to which the
// event that must be signed will be passed, so it's only necessary to sign that.
func (r *Client) Auth(
	ctx context.Context, sign signer.I,
) (err error) {
	authEvent := &event.E{
		CreatedAt: time.Now().Unix(),
		Kind:      kind.ClientAuthentication.K,
		Tags: tag.NewS(
			tag.NewFromAny("relay", r.URL),
			tag.NewFromAny("challenge", r.challenge),
		),
		Content: nil,
	}
	if err = authEvent.Sign(sign); err != nil {
		return fmt.Errorf("error signing auth event: %w", err)
	}

	return r.publish(
		ctx, hex.Enc(authEvent.ID), authenvelope.NewResponseWith(authEvent),
	)
}

func (r *Client) publish(
	ctx context.Context, id string, env codec.Envelope,
) error {
	var err error
	var cancel context.CancelFunc

	if _, ok := ctx.Deadline(); !ok {
		// if no timeout is set, force it to 7 seconds
		ctx, cancel = context.WithTimeoutCause(
			ctx, 7*time.Second, fmt.Errorf("given up waiting for an OK"),
		)
		defer cancel()
	} else {
		// otherwise make the context cancellable so we can stop everything upon receiving an "OK"
		ctx, cancel = context.WithCancel(ctx)
		defer cancel()
	}

	// listen for an OK callback
	gotOk := false
	r.okCallbacks.Store(
		id, func(ok bool, reason string) {
			gotOk = true
			if !ok {
				err = fmt.Errorf("msg: %s", reason)
			}
			cancel()
		},
	)
	defer r.okCallbacks.Delete(id)

	// publish event
	envb := env.Marshal(nil)
	if err = <-r.Write(envb); err != nil {
		return err
	}

	for {
		select {
		case <-ctx.Done():
			// this will be called when we get an OK or when the context has been canceled
			if gotOk {
				return err
			}
			return ctx.Err()
		case <-r.connectionContext.Done():
			// this is caused when we lose connectivity
			return err
		}
	}
}

// Subscribe sends a "REQ" command to the relay r as in NIP-01.
// Events are returned through the channel sub.Events.
// The subscription is closed when context ctx is cancelled ("CLOSE" in NIP-01).
//
// Remember to cancel subscriptions, either by calling `.Unsub()` on them or ensuring their `context.Context` will be canceled at some point.
// Failure to do that will result in a huge number of halted goroutines being created.
func (r *Client) Subscribe(
	ctx context.Context, ff *filter.S, opts ...SubscriptionOption,
) (*Subscription, error) {
	sub := r.PrepareSubscription(ctx, ff, opts...)

	if r.Connection == nil {
		log.T.F(
			"WS.Subscribe: not connected to %s; aborting sub id=%s", r.URL,
			sub.GetID(),
		)
		return nil, fmt.Errorf("not connected to %s", r.URL)
	}

	log.T.F(
		"WS.Subscribe: firing subscription id=%s to %s with %d filters",
		sub.GetID(), r.URL, len(*ff),
	)
	if err := sub.Fire(); err != nil {
		log.T.F(
			"WS.Subscribe: Fire failed id=%s to %s: %v", sub.GetID(), r.URL,
			err,
		)
		return nil, fmt.Errorf(
			"couldn't subscribe to %v at %s: %w", ff, r.URL, err,
		)
	}
	log.T.F("WS.Subscribe: Fire succeeded id=%s to %s", sub.GetID(), r.URL)

	return sub, nil
}

// PrepareSubscription creates a subscription, but doesn't fire it.
//
// Remember to cancel subscriptions, either by calling `.Unsub()` on them or ensuring their `context.Context` will be canceled at some point.
// Failure to do that will result in a huge number of halted goroutines being created.
func (r *Client) PrepareSubscription(
	ctx context.Context, ff *filter.S, opts ...SubscriptionOption,
) (sub *Subscription) {
	current := subscriptionIDCounter.Add(1)
	ctx, cancel := context.WithCancelCause(ctx)
	sub = &Subscription{
		Client:            r,
		Context:           ctx,
		cancel:            cancel,
		counter:           current,
		Events:            make(event.C),
		EndOfStoredEvents: make(chan struct{}, 1),
		ClosedReason:      make(chan string, 1),
		Filters:           ff,
		match:             ff.Match,
	}
	label := ""
	for _, opt := range opts {
		switch o := opt.(type) {
		case WithLabel:
			label = string(o)
		case WithCheckDuplicate:
			sub.checkDuplicate = o
		case WithCheckDuplicateReplaceable:
			sub.checkDuplicateReplaceable = o
		}
	}
	// subscription id computation
	buf := subIdPool.Get().([]byte)[:0]
	buf = strconv.AppendInt(buf, sub.counter, 10)
	buf = append(buf, ':')
	buf = append(buf, label...)
	defer subIdPool.Put(buf)
	sub.id = buf
	r.Subscriptions.Store(string(buf), sub)
	// start handling events, eose, unsub etc:
	go sub.start()
	return sub
}

// QueryEvents subscribes to events matching the given filter and returns a channel of events.
//
// In most cases it's better to use SimplePool instead of this method.
func (r *Client) QueryEvents(
	ctx context.Context, f *filter.F, opts ...SubscriptionOption,
) (
	evc event.C, err error,
) {
	var sub *Subscription
	if sub, err = r.Subscribe(ctx, filter.NewS(f), opts...); chk.E(err) {
		return
	}
	go func() {
		for {
			select {
			case <-sub.ClosedReason:
			case <-sub.EndOfStoredEvents:
			case <-ctx.Done():
			case <-r.Context().Done():
			}
			sub.unsub(errors.New("QueryEvents() ended"))
			return
		}
	}()
	evc = sub.Events
	return
}

// QuerySync subscribes to events matching the given filter and returns a slice of events.
// This method blocks until all events are received or the context is canceled.
//
// In most cases it's better to use SimplePool instead of this method.
func (r *Client) QuerySync(
	ctx context.Context, ff *filter.F, opts ...SubscriptionOption,
) (
	[]*event.E, error,
) {
	if _, ok := ctx.Deadline(); !ok {
		// if no timeout is set, force it to 7 seconds
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeoutCause(
			ctx, 7*time.Second, errors.New("QuerySync() took too long"),
		)
		defer cancel()
	}
	var lim int
	if ff.Limit != nil {
		lim = int(*ff.Limit)
	}
	events := make([]*event.E, 0, max(lim, 250))
	ch, err := r.QueryEvents(ctx, ff, opts...)
	if err != nil {
		return nil, err
	}

	for evt := range ch {
		events = append(events, evt)
	}

	return events, nil
}

// Close closes the relay connection.
func (r *Client) Close() error { return r.CloseWithReason(errors.New("Close() called")) }

// CloseWithReason closes the relay connection with a specific reason that will be stored as the context cancel cause.
func (r *Client) CloseWithReason(reason error) error { return r.close(reason) }

func (r *Client) close(reason error) error {
	r.closeMutex.Lock()
	defer r.closeMutex.Unlock()

	if r.connectionContextCancel == nil {
		return fmt.Errorf("relay already closed")
	}
	log.T.F(
		"WS.Client: closing connection to %s: reason=%v lastErr=%v", r.URL,
		reason, r.ConnectionError,
	)
	r.connectionContextCancel(reason)
	r.connectionContextCancel = nil

	if r.Connection == nil {
		return fmt.Errorf("relay not connected")
	}

	err := r.Connection.Close()
	if err != nil {
		return err
	}

	return nil
}

var subIdPool = sync.Pool{
	New: func() any { return make([]byte, 0, 15) },
}
