package discoverer

import (
	"context"
	"net"
	"strconv"

	"github.com/blang/semver/v4"
	"github.com/cockroachdb/errors"
	clientv3 "go.etcd.io/etcd/client/v3"
	"google.golang.org/grpc/resolver"

	"github.com/milvus-io/milvus/internal/json"
	"github.com/milvus-io/milvus/internal/util/sessionutil"
	"github.com/milvus-io/milvus/internal/util/streamingutil/service/attributes"
	"github.com/milvus-io/milvus/internal/util/streamingutil/status"
	"github.com/milvus-io/milvus/pkg/v3/mlog"
	"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
)

// NewSessionDiscoverer returns a new Discoverer for the milvus session registration.
func NewSessionDiscoverer(etcdCli *clientv3.Client, opts ...SessionDiscovererOption) *sessionDiscoverer {
	sd := &sessionDiscoverer{
		etcdCli:      etcdCli,
		peerSessions: make(map[string]*sessionutil.SessionRaw),
	}
	for _, opt := range opts {
		opt(sd)
	}
	if sd.prefix == "" {
		panic("prefix is required")
	}
	if sd.versionRangeStr == "" {
		panic("version range is required")
	}
	sd.SetLogger(mlog.With(mlog.String("prefix", sd.prefix), mlog.Bool("exclusive", sd.exclusive), mlog.String("semver", sd.versionRangeStr)))
	return sd
}

// SessionDiscovererOption is a function that can be used to configure the session discoverer.
type SessionDiscovererOption func(sw *sessionDiscoverer)

// sessionDiscoverer is used to apply a session watch on etcd.
type sessionDiscoverer struct {
	mlog.Binder

	etcdCli         *clientv3.Client
	prefix          string
	exclusive       bool // if exclusive, only one session is allowed, not use the prefix, only use the role directly.
	versionRange    semver.Range
	versionRangeStr string
	revision        int64
	peerSessions    map[string]*sessionutil.SessionRaw // map[Key]SessionRaw, map the key path of session to session.
	forcePort       int                                // force the port to use when resolving.
}

// OptSDForcePort forces the port to use when resolving.
func OptSDForcePort(port int) SessionDiscovererOption {
	return func(sw *sessionDiscoverer) {
		sw.forcePort = port
	}
}

// OptSDPrefix sets the prefix to use when resolving.
func OptSDPrefix(prefix string) SessionDiscovererOption {
	return func(sw *sessionDiscoverer) {
		sw.prefix = prefix
	}
}

// OptSDExclusive sets the exclusive to use when resolving.
func OptSDExclusive() SessionDiscovererOption {
	return func(sw *sessionDiscoverer) {
		sw.exclusive = true
	}
}

// OptSDVersionRange sets the version range to use when resolving.
func OptSDVersionRange(versionRange string) SessionDiscovererOption {
	return func(sw *sessionDiscoverer) {
		sw.versionRange = semver.MustParseRange(versionRange)
		sw.versionRangeStr = versionRange
	}
}

// Discover watches the service discovery on these goroutine.
// It may be broken down if compaction happens on etcd server.
func (sw *sessionDiscoverer) Discover(ctx context.Context, cb func(VersionedState) error) error {
	// init the discoverer.
	if err := sw.initDiscover(ctx); err != nil {
		return err
	}

	// Always send the current state first.
	// Outside logic may lost the last state before retry Discover function.
	if err := cb(sw.parseState()); err != nil {
		return err
	}
	return sw.watch(ctx, cb)
}

// watch performs the watch on etcd.
func (sw *sessionDiscoverer) watch(ctx context.Context, cb func(VersionedState) error) error {
	opts := []clientv3.OpOption{clientv3.WithRev(sw.revision + 1)}
	if !sw.exclusive {
		opts = append(opts, clientv3.WithPrefix())
	}
	// start a watcher at background.
	eventCh := sw.etcdCli.Watch(
		ctx,
		sw.prefix,
		opts...,
	)

	for {
		// Watch the etcd events.
		select {
		case <-ctx.Done():
			return errors.Wrap(ctx.Err(), "cancel the discovery")
		case event, ok := <-eventCh:
			// Break the loop if the watch is failed.
			if !ok {
				return status.NewInner("etcd watch channel closed unexpectedly")
			}
			if err := sw.handleETCDEvent(event); err != nil {
				return err
			}
		}
		if err := cb(sw.parseState()); err != nil {
			return err
		}
	}
}

// handleETCDEvent handles the etcd event.
func (sw *sessionDiscoverer) handleETCDEvent(resp clientv3.WatchResponse) error {
	if resp.Err() != nil {
		sw.Logger().Warn(context.TODO(), "etcd watch failed with error", mlog.Err(resp.Err()))
		return resp.Err()
	}

	for _, ev := range resp.Events {
		logger := sw.Logger().With(mlog.String("event", ev.Type.String()),
			mlog.String("sessionKey", string(ev.Kv.Key)))
		switch ev.Type {
		case clientv3.EventTypePut:
			logger = logger.With(mlog.String("sessionValue", string(ev.Kv.Value)))
			session, err := sw.parseSession(ev.Kv.Value)
			if err != nil {
				logger.Warn(context.TODO(), "failed to parse session", mlog.Err(err))
				continue
			}
			logger.Info(context.TODO(), "new server modification")
			sw.peerSessions[string(ev.Kv.Key)] = session
		case clientv3.EventTypeDelete:
			logger.Info(context.TODO(), "old server removed")
			delete(sw.peerSessions, string(ev.Kv.Key))
		}
	}
	// Update last revision.
	sw.revision = resp.Header.Revision
	return nil
}

// initDiscover initializes the discoverer if needed.
func (sw *sessionDiscoverer) initDiscover(ctx context.Context) error {
	opts := []clientv3.OpOption{clientv3.WithSerializable()}
	if !sw.exclusive {
		opts = append(opts, clientv3.WithPrefix())
	}
	resp, err := sw.etcdCli.Get(ctx, sw.prefix, opts...)
	if err != nil {
		return err
	}
	// Clear stale sessions before re-populating.
	// On retry (e.g. after etcd watch compaction), sessions deleted during the
	// watch gap would otherwise persist forever because initDiscover only adds entries.
	sw.peerSessions = make(map[string]*sessionutil.SessionRaw, len(resp.Kvs))
	for _, kv := range resp.Kvs {
		logger := sw.Logger().With(mlog.String("sessionKey", string(kv.Key)), mlog.String("sessionValue", string(kv.Value)))
		session, err := sw.parseSession(kv.Value)
		if err != nil {
			logger.Warn(ctx, "fail to parse session when initializing discoverer", mlog.Err(err))
			continue
		}
		logger.Info(ctx, "new server initialization", mlog.Any("session", session))
		sw.peerSessions[string(kv.Key)] = session
	}
	sw.revision = resp.Header.Revision
	return nil
}

// parseSession parse the session from etcd value.
func (sw *sessionDiscoverer) parseSession(value []byte) (*sessionutil.SessionRaw, error) {
	session := new(sessionutil.SessionRaw)
	if err := json.Unmarshal(value, session); err != nil {
		return nil, err
	}
	return session, nil
}

// parseState parse the state from peerSessions.
// Always perform a copy here.
func (sw *sessionDiscoverer) parseState() VersionedState {
	addrs := make([]resolver.Address, 0, len(sw.peerSessions))
	for _, session := range sw.peerSessions {
		session := session
		v, err := semver.Parse(session.Version)
		if err != nil {
			sw.Logger().Error(context.TODO(), "failed to parse version for session", mlog.Int64("serverID", session.ServerID), mlog.String("version", session.Version), mlog.Err(err))
			continue
		}
		// filter low version.
		// !!! important, stopping nodes should not be removed here.
		if !sw.versionRange(v) {
			sw.Logger().Info(context.TODO(), "skip low version node", mlog.Int64("serverID", session.ServerID), mlog.String("version", session.Version))
			continue
		}
		address := session.Address
		if sw.forcePort != 0 {
			// replace the port with the force port in session address.
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				sw.Logger().Error(context.TODO(), "failed to split host and port for session", mlog.Int64("serverID", session.ServerID), mlog.String("address", address), mlog.Err(err))
				continue
			}
			address = net.JoinHostPort(host, strconv.Itoa(sw.forcePort))
		}
		addrs = append(addrs, resolver.Address{
			Addr: address,
			// resolverAttributes is important to use when resolving, server id to make resolver.Address with same adresss different.
			Attributes: attributes.WithServerID(new(attributes.Attributes), session.ServerID),
			// balancerAttributes can be seen by picker of grpc balancer.
			BalancerAttributes: attributes.WithSession(new(attributes.Attributes), session),
		})
	}

	// TODO: service config should be sent by resolver in future to achieve dynamic configuration for grpc.
	return VersionedState{
		Version: typeutil.VersionInt64(sw.revision),
		State:   resolver.State{Addresses: addrs},
	}
}

// Sessions returns the sessions in the state.
// Should only be called when using session discoverer.
func (s *VersionedState) Sessions() map[int64]*sessionutil.SessionRaw {
	sessions := make(map[int64]*sessionutil.SessionRaw)
	for _, v := range s.State.Addresses {
		session := attributes.GetSessionFromAttributes(v.BalancerAttributes)
		if session == nil {
			mlog.Error(context.TODO(), "no session found in resolver state, skip it", mlog.String("address", v.Addr))
			continue
		}
		sessions[session.ServerID] = session
	}
	return sessions
}
