package notifications

import (
	"context"
	"sync"
	"time"

	"github.com/lightninglabs/aperture/l402"
	"github.com/lightninglabs/loop/swapserverrpc"
	"github.com/lightningnetwork/lnd/lntypes"
	"google.golang.org/grpc"
)

// NotificationType is the type of notification that the manager can handle.
type NotificationType int

const (
	// NotificationTypeUnknown is the default notification type.
	NotificationTypeUnknown NotificationType = iota

	// NotificationTypeReservation is the notification type for reservation
	// notifications.
	NotificationTypeReservation

	// NotificationTypeStaticLoopInSweepRequest is the notification type for
	// static loop in sweep requests.
	NotificationTypeStaticLoopInSweepRequest

	// NotificationTypeStaticLoopInRiskAccepted is the notification type for
	// static loop in confirmation risk acceptance.
	NotificationTypeStaticLoopInRiskAccepted

	// NotificationTypeStaticLoopInRiskRejected is the notification type for
	// static loop in confirmation risk rejection.
	NotificationTypeStaticLoopInRiskRejected

	// NotificationTypeUnfinishedSwap is the notification type for unfinished
	// swap notifications.
	NotificationTypeUnfinishedSwap

	// NotificationTypeHtlcConfirmed is the notification type for HTLC
	// confirmed notifications.
	NotificationTypeHtlcConfirmed
)

const (
	// defaultMinAliveConnTime is the default minimum time that the
	// connection to the server needs to be alive before we consider it a
	// successful connection.
	defaultMinAliveConnTime = time.Minute

	// htlcConfirmedSubscriberSendTimeout is how long we wait for a busy
	// htlc-confirmed subscriber before dropping the notification.
	htlcConfirmedSubscriberSendTimeout = 200 * time.Millisecond

	// defaultMaxQueuedNotifications is the default number of notifications
	// we queue per subscriber before dropping new notifications.
	defaultMaxQueuedNotifications = 1024

	// current_version is the current version of the notification listener.
	current_version = swapserverrpc.SubscribeNotificationsRequest_V1
)

// Client is the interface that the notification manager needs to implement in
// order to be able to subscribe to notifications.
type Client interface {
	// SubscribeNotifications subscribes to the notifications from the server.
	SubscribeNotifications(ctx context.Context,
		in *swapserverrpc.SubscribeNotificationsRequest,
		opts ...grpc.CallOption) (
		swapserverrpc.SwapServer_SubscribeNotificationsClient, error)
}

// Config contains all the services that the notification manager needs to
// operate.
type Config struct {
	// Client is the client used to communicate with the swap server.
	Client Client

	// CurrentToken returns the token that is currently contained in the
	// store or an l402.ErrNoToken error if there is none.
	CurrentToken func() (*l402.Token, error)

	// MinAliveConnTime is the minimum time that the connection to the
	// server needs to be alive before we consider it a successful.
	MinAliveConnTime time.Duration

	// MaxQueuedNotifications is the maximum number of notifications that
	// can wait in each subscriber's delivery queue.
	MaxQueuedNotifications int

	// PersistStaticLoopInRiskDecision durably records static loop-in
	// confirmation-risk decisions. If this fails, the notification is still
	// cached and forwarded so a later subscriber can process it after the swap
	// row exists.
	PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash,
		bool) error
}

// Manager is a manager for notifications that the swap server sends to the
// client.
type Manager struct {
	sync.Mutex

	cfg *Config

	hasL402 bool

	// subscribers holds active notification subscribers by notification
	// type. It is guarded by the Manager mutex.
	subscribers map[NotificationType][]subscriber

	// staticLoopInRiskAccepted caches accepted risk decisions by swap hash
	// so a later matching subscriber can receive a previously delivered
	// server decision.
	staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
					ServerStaticLoopInRiskAcceptedNotification

	// staticLoopInRiskRejected caches rejected risk decisions by swap hash
	// so a later matching subscriber can receive a previously delivered
	// server decision.
	staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
					ServerStaticLoopInRiskRejectedNotification

	// staticLoopInRiskPersisted records whether the cached risk decision for
	// a swap hash was durably persisted. Unpersisted decisions remain cached
	// after subscriber cancellation so they can be replayed.
	staticLoopInRiskPersisted map[lntypes.Hash]bool
}

// NewManager creates a new notification manager.
func NewManager(cfg *Config) *Manager {
	// Set the default minimum alive connection time if it's not set.
	if cfg.MinAliveConnTime == 0 {
		cfg.MinAliveConnTime = defaultMinAliveConnTime
	}
	if cfg.MaxQueuedNotifications <= 0 {
		cfg.MaxQueuedNotifications = defaultMaxQueuedNotifications
	}

	return &Manager{
		cfg:         cfg,
		subscribers: make(map[NotificationType][]subscriber),
		staticLoopInRiskAccepted: make(
			map[lntypes.Hash]*swapserverrpc.
				ServerStaticLoopInRiskAcceptedNotification,
		),
		staticLoopInRiskRejected: make(
			map[lntypes.Hash]*swapserverrpc.
				ServerStaticLoopInRiskRejectedNotification,
		),
		staticLoopInRiskPersisted: make(map[lntypes.Hash]bool),
	}
}

type subscriber struct {
	subCtx   context.Context
	recvChan any
	swapHash *lntypes.Hash
	enqueue  func(any)
}

// newNotificationQueue creates a per-subscriber FIFO delivery function.
func newNotificationQueue[T any](ctx context.Context,
	recvChan chan T, maxPending int) func(any) {

	type queue struct {
		sync.Mutex

		pending []T
		notify  chan struct{}
		closed  bool
	}

	q := &queue{
		notify: make(chan struct{}, 1),
	}

	closeQueue := func() {
		q.Lock()
		q.closed = true
		q.pending = nil
		q.Unlock()
	}

	go func() {
		defer close(recvChan)
		defer closeQueue()

		for {
			select {
			case <-ctx.Done():
				return
			default:
			}

			q.Lock()
			if len(q.pending) == 0 {
				q.Unlock()

				select {
				case <-q.notify:
					continue

				case <-ctx.Done():
					return
				}
			}

			ntfn := q.pending[0]
			var zero T
			q.pending[0] = zero
			q.pending = q.pending[1:]
			q.Unlock()

			select {
			case recvChan <- ntfn:
			case <-ctx.Done():
				return
			}
		}
	}()

	return func(ntfn any) {
		typedNtfn, ok := ntfn.(T)
		if !ok {
			log.Warnf("unexpected notification type %T", ntfn)
			return
		}

		q.Lock()
		if q.closed {
			q.Unlock()
			return
		}
		if len(q.pending) >= maxPending {
			q.Unlock()
			log.Warnf("dropping notification for slow subscriber: "+
				"queue depth %d reached", maxPending)
			return
		}

		q.pending = append(q.pending, typedNtfn)
		q.Unlock()

		select {
		case q.notify <- struct{}{}:
		default:
		}
	}
}

// queueNotification queues or synchronously sends a must-deliver notification.
func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) {
	if sub.enqueue != nil {
		sub.enqueue(ntfn)
		return
	}

	log.Warnf("subscriber has no notification queue, falling back to " +
		"blocking send")

	select {
	case recvChan <- ntfn:
	case <-sub.subCtx.Done():
	}
}

// dropNotification sends a best-effort notification to a subscriber.
func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T,
	description string) {

	select {
	case recvChan <- ntfn:
	case <-sub.subCtx.Done():
	default:
		log.Debugf("Dropping %s notification for slow subscriber",
			description)
	}
}

// SubscribeReservations subscribes to the reservation notifications.
func (m *Manager) SubscribeReservations(ctx context.Context,
) <-chan *swapserverrpc.ServerReservationNotification {

	notifChan := make(chan *swapserverrpc.ServerReservationNotification, 1)
	sub := subscriber{
		subCtx:   ctx,
		recvChan: notifChan,
	}

	m.addSubscriber(NotificationTypeReservation, sub)

	context.AfterFunc(ctx, func() {
		m.removeSubscriber(NotificationTypeReservation, sub)
		close(notifChan)
	})

	return notifChan
}

// SubscribeStaticLoopInSweepRequests subscribes to the static loop in sweep
// requests.
func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification {

	notifChan := make(
		chan *swapserverrpc.ServerStaticLoopInSweepNotification, 1,
	)

	sub := subscriber{
		subCtx:   ctx,
		recvChan: notifChan,
		enqueue: newNotificationQueue(
			ctx, notifChan, m.cfg.MaxQueuedNotifications,
		),
	}

	m.addSubscriber(NotificationTypeStaticLoopInSweepRequest, sub)

	context.AfterFunc(ctx, func() {
		m.removeSubscriber(
			NotificationTypeStaticLoopInSweepRequest,
			sub,
		)
	})

	return notifChan
}

func subscribeStaticLoopInRiskDecision[T any](m *Manager, ctx context.Context,
	swapHash lntypes.Hash, notifType NotificationType,
	notifications map[lntypes.Hash]T) <-chan T {

	notifChan := make(chan T, 1)
	sub := subscriber{
		subCtx:   ctx,
		recvChan: notifChan,
		swapHash: &swapHash,
	}

	m.Lock()
	m.subscribers[notifType] = append(m.subscribers[notifType], sub)
	if ntfn, ok := notifications[swapHash]; ok {
		notifChan <- ntfn
		if m.staticLoopInRiskPersisted[swapHash] {
			delete(notifications, swapHash)
			delete(m.staticLoopInRiskPersisted, swapHash)
		}
	}
	m.Unlock()

	context.AfterFunc(ctx, func() {
		m.removeSubscriber(notifType, sub)
		m.Lock()
		if _, ok := notifications[swapHash]; ok &&
			m.staticLoopInRiskPersisted[swapHash] {

			delete(notifications, swapHash)
			delete(m.staticLoopInRiskPersisted, swapHash)
		}
		m.Unlock()
		close(notifChan)
	})

	return notifChan
}

// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
	swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {

	return subscribeStaticLoopInRiskDecision(
		m, ctx, swapHash, NotificationTypeStaticLoopInRiskAccepted,
		m.staticLoopInRiskAccepted,
	)
}

// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
	swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {

	return subscribeStaticLoopInRiskDecision(
		m, ctx, swapHash, NotificationTypeStaticLoopInRiskRejected,
		m.staticLoopInRiskRejected,
	)
}

// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {

	notifChan := make(
		chan *swapserverrpc.ServerUnfinishedSwapNotification, 1,
	)
	sub := subscriber{
		subCtx:   ctx,
		recvChan: notifChan,
		enqueue: newNotificationQueue(
			ctx, notifChan, m.cfg.MaxQueuedNotifications,
		),
	}

	m.addSubscriber(NotificationTypeUnfinishedSwap, sub)
	context.AfterFunc(ctx, func() {
		m.removeSubscriber(NotificationTypeUnfinishedSwap, sub)
	})

	return notifChan
}

// SubscribeHtlcConfirmed subscribes to the HTLC confirmed notifications.
func (m *Manager) SubscribeHtlcConfirmed(ctx context.Context,
) <-chan *swapserverrpc.ServerHtlcConfirmedNotification {

	notifChan := make(
		chan *swapserverrpc.ServerHtlcConfirmedNotification, 1,
	)
	sub := subscriber{
		subCtx:   ctx,
		recvChan: notifChan,
	}

	m.addSubscriber(NotificationTypeHtlcConfirmed, sub)
	context.AfterFunc(ctx, func() {
		m.removeSubscriber(NotificationTypeHtlcConfirmed, sub)
		close(notifChan)
	})

	return notifChan
}

// Run starts the notification manager. It will keep on running until the
// context is canceled. It will subscribe to notifications and forward them to
// the subscribers. On a first successful connection to the server, it will
// close the readyChan to signal that the manager is ready.
func (m *Manager) Run(ctx context.Context) error {
	// Initially we want to immediately try to connect to the server.
	var (
		waitTime time.Duration
		backoff  time.Duration
		attempts int
		timer    = time.NewTimer(0)
	)

	// Start the notification runloop.
	for {
		// Increase the wait time for the next iteration.
		backoff = waitTime + time.Duration(attempts)*time.Second
		waitTime = 0

		// Reset the timer with the new backoff time.
		timer.Reset(backoff)

		// Return if the context has been canceled.
		select {
		case <-ctx.Done():
			return nil

		case <-timer.C:
		}

		// In order to create a valid l402 we first are going to call
		// the FetchL402 method. As a client might not have outbound
		// capacity yet, we'll retry until we get a valid response.
		if !m.hasL402 {
			token, err := m.cfg.CurrentToken()
			if err != nil {
				// We only log the error if it's not the case
				// that we don't have a token yet to avoid
				// spamming the logs.
				if err != l402.ErrNoToken {
					log.Errorf("Error getting L402 from "+
						"the store: %v", err)
				}

				// Use a default of 1 second wait time to avoid
				// hogging the CPU.
				waitTime = time.Second
				continue
			}

			// If the preimage is empty, we don't have a valid L402
			// yet so we'll continue to retry with the incremental
			// backoff.
			emptyPreimage := lntypes.Preimage{}
			if token.Preimage == emptyPreimage {
				attempts++
				continue
			}

			attempts = 0
			m.hasL402 = true
		}

		connectAttempted := time.Now()
		err := m.subscribeNotifications(ctx)
		if err != nil {
			log.Errorf("Error subscribing to notifications: %v",
				err)
		}
		connectionAliveTime := time.Since(connectAttempted)

		// Note that we may be able to connet to the stream but not
		// able to use it if the client is unable to pay for their
		// L402. In this case the subscription will fail on the first
		// read immediately after connecting. We'll therefore only
		// consider the connection successful if we were able to use
		// the stream for at least the minimum alive connection time
		// (which defaults to 1 minute).
		if connectionAliveTime > m.cfg.MinAliveConnTime {
			// Reset the backoff to 10 seconds and the connect
			// attempts to zero if we were really connected for a
			// considerable amount of time (1 minute).
			waitTime = time.Second * 10
			attempts = 0
		} else {
			// We either failed to connect or the stream
			// disconnected immediately, so we just increase the
			// backoff.
			attempts++
		}
	}
}

// subscribeNotifications subscribes to the notifications from the server.
func (m *Manager) subscribeNotifications(ctx context.Context) error {
	callCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	notifStream, err := m.cfg.Client.SubscribeNotifications(
		callCtx, &swapserverrpc.SubscribeNotificationsRequest{
			Version: current_version,
		},
	)
	if err != nil {
		return err
	}

	log.Debugf("Successfully subscribed to server notifications")

	for {
		notification, err := notifStream.Recv()
		if err == nil && notification != nil {
			log.Tracef("Received notification: %v", notification)
			m.handleNotification(ctx, notification)
			continue
		}

		log.Errorf("Error receiving notification: %v", err)

		return err
	}
}

// staticLoopInRiskDecisionName returns the log label for a risk decision.
func staticLoopInRiskDecisionName(accepted bool) string {
	if accepted {
		return "accepted"
	}

	return "rejected"
}

// handleStaticLoopInRiskDecision persists, caches, and forwards a risk
// decision notification to the matching subscriber.
func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context,
	swapHashBytes []byte, accepted bool, notifType NotificationType,
	cacheDecision func(lntypes.Hash, bool),
	notifySubscriber func(subscriber)) {

	decision := staticLoopInRiskDecisionName(accepted)
	persisted := m.cfg.PersistStaticLoopInRiskDecision == nil

	var (
		swapHash    lntypes.Hash
		hasSwapHash bool
	)
	if swapHashBytes != nil {
		hash, err := lntypes.MakeHash(swapHashBytes)
		if err != nil {
			log.Warnf("Received invalid static loop in risk "+
				"%s notification: %v", decision, err)
		} else {
			swapHash = hash
			hasSwapHash = true
		}
	}

	if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil {
		err := m.cfg.PersistStaticLoopInRiskDecision(
			ctx, swapHash, accepted,
		)
		if err != nil {
			log.Errorf("Unable to persist static loop in risk "+
				"%s notification: %v", decision, err)
		} else {
			persisted = true
		}
	}

	m.Lock()
	defer m.Unlock()

	if hasSwapHash {
		cacheDecision(swapHash, persisted)
	}

	for _, sub := range m.subscribers[notifType] {
		if !hasSwapHash || sub.swapHash == nil ||
			*sub.swapHash != swapHash {

			continue
		}

		notifySubscriber(sub)
	}
}

// handleNotification handles an incoming notification from the server,
// forwarding it to the appropriate subscribers.
func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc.
	SubscribeNotificationsResponse) {

	switch ntfn.Notification.(type) {
	case *swapserverrpc.SubscribeNotificationsResponse_ReservationNotification: // nolint: lll
		// We'll forward the reservation notification to all subscribers.
		reservationNtfn := ntfn.GetReservationNotification()
		m.Lock()
		defer m.Unlock()

		for _, sub := range m.subscribers[NotificationTypeReservation] {
			recvChan := sub.recvChan.(chan *swapserverrpc.
				ServerReservationNotification)

			select {
			case recvChan <- reservationNtfn:
			case <-sub.subCtx.Done():
			default:
				log.Debugf("Dropping reservation " +
					"notification for slow subscriber")
			}
		}
	case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInSweep: // nolint: lll
		// We'll forward the static loop in sweep request to all
		// subscribers.
		staticLoopInSweepRequestNtfn := ntfn.GetStaticLoopInSweep()
		m.Lock()
		defer m.Unlock()

		for _, sub := range m.subscribers[NotificationTypeStaticLoopInSweepRequest] { // nolint: lll
			recvChan := sub.recvChan.(chan *swapserverrpc.
				ServerStaticLoopInSweepNotification)

			queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn)
		}

	case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll
		// We'll forward the static loop in risk accepted notification to the
		// subscriber for the matching swap.
		riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted()
		var swapHashBytes []byte
		if riskAcceptedNtfn != nil {
			swapHashBytes = riskAcceptedNtfn.SwapHash
		}

		m.handleStaticLoopInRiskDecision(
			ctx, swapHashBytes, true,
			NotificationTypeStaticLoopInRiskAccepted,
			func(swapHash lntypes.Hash, persisted bool) {
				m.staticLoopInRiskAccepted[swapHash] =
					riskAcceptedNtfn
				m.staticLoopInRiskPersisted[swapHash] = persisted
				delete(m.staticLoopInRiskRejected, swapHash)
			},
			func(sub subscriber) {
				recvChan := sub.recvChan.(chan *swapserverrpc.
					ServerStaticLoopInRiskAcceptedNotification)
				dropNotification(
					sub, recvChan, riskAcceptedNtfn,
					"static loop in risk accepted",
				)
			},
		)

	case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll
		// We'll forward the static loop in risk rejected notification to the
		// subscriber for the matching swap.
		riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected()
		var swapHashBytes []byte
		if riskRejectedNtfn != nil {
			swapHashBytes = riskRejectedNtfn.SwapHash
		}

		m.handleStaticLoopInRiskDecision(
			ctx, swapHashBytes, false,
			NotificationTypeStaticLoopInRiskRejected,
			func(swapHash lntypes.Hash, persisted bool) {
				m.staticLoopInRiskRejected[swapHash] =
					riskRejectedNtfn
				m.staticLoopInRiskPersisted[swapHash] = persisted
				delete(m.staticLoopInRiskAccepted, swapHash)
			},
			func(sub subscriber) {
				recvChan := sub.recvChan.(chan *swapserverrpc.
					ServerStaticLoopInRiskRejectedNotification)
				dropNotification(
					sub, recvChan, riskRejectedNtfn,
					"static loop in risk rejected",
				)
			},
		)

	case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
		// We'll forward the unfinished swap notification to all
		// subscribers.
		unfinishedSwapNtfn := ntfn.GetUnfinishedSwap()
		m.Lock()
		defer m.Unlock()

		for _, sub := range m.subscribers[NotificationTypeUnfinishedSwap] {
			recvChan := sub.recvChan.(chan *swapserverrpc.
				ServerUnfinishedSwapNotification)

			queueNotification(sub, recvChan, unfinishedSwapNtfn)
		}

	case *swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed:
		// We'll forward the htlc confirmed notification to all
		// subscribers. We wait briefly for a slow subscriber and
		// then drop to avoid stalling the notification pipeline.
		htlcConfirmedNtfn := ntfn.GetHtlcConfirmed()
		m.Lock()
		defer m.Unlock()

		subscribers := m.subscribers[NotificationTypeHtlcConfirmed]
		for _, sub := range subscribers {
			recvChan := sub.recvChan.(chan *swapserverrpc.
				ServerHtlcConfirmedNotification)

			select {
			case recvChan <- htlcConfirmedNtfn:

			case <-time.After(htlcConfirmedSubscriberSendTimeout):
				log.Infof("Dropping htlc confirmed " +
					"notification, subscriber busy")
			}
		}

	default:
		log.Debugf("Received unknown notification type: %v", ntfn)
	}
}

// addSubscriber adds a subscriber to the manager.
func (m *Manager) addSubscriber(notifType NotificationType, sub subscriber) {
	m.Lock()
	defer m.Unlock()
	m.subscribers[notifType] = append(m.subscribers[notifType], sub)
}

// removeSubscriber removes a subscriber from the manager.
func (m *Manager) removeSubscriber(notifType NotificationType, sub subscriber) {
	m.Lock()
	defer m.Unlock()
	subs := m.subscribers[notifType]
	newSubs := make([]subscriber, 0, len(subs))
	for _, s := range subs {
		if s.recvChan != sub.recvChan {
			newSubs = append(newSubs, s)
		}
	}
	m.subscribers[notifType] = newSubs
}
