package kgo

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"iter"
	"math"
	"math/rand"
	"net"
	"regexp"
	"runtime/debug"
	"time"

	"github.com/twmb/franz-go/pkg/kgo/internal/xsync"
	"github.com/twmb/franz-go/pkg/kmsg"
	"github.com/twmb/franz-go/pkg/kversion"
	"github.com/twmb/franz-go/pkg/sasl"
)

////////////////////////////////////////////////////////
// NOTE:                                              //
// NOTE: Make sure new configs are added to OptValues //
// NOTE:                                              //
////////////////////////////////////////////////////////

// Opt is an option to configure a client.
type Opt interface {
	apply(*cfg)
}

// ProducerOpt is a producer specific option to configure a client.
// This is simply a namespaced Opt.
type ProducerOpt interface {
	Opt
	producerOpt()
}

// ConsumerOpt is a consumer specific option to configure a client.
// This is simply a namespaced Opt.
type ConsumerOpt interface {
	Opt
	consumerOpt()
}

// GroupOpt is a consumer group specific option to configure a client.
// This is simply a namespaced Opt.
type GroupOpt interface {
	Opt
	groupOpt()
}

type (
	clientOpt   struct{ fn func(*cfg) }
	producerOpt struct{ fn func(*cfg) }
	consumerOpt struct{ fn func(*cfg) }
	groupOpt    struct{ fn func(*cfg) }
)

func (opt clientOpt) apply(cfg *cfg)   { opt.fn(cfg) }
func (opt producerOpt) apply(cfg *cfg) { opt.fn(cfg) }
func (opt consumerOpt) apply(cfg *cfg) { opt.fn(cfg) }
func (opt groupOpt) apply(cfg *cfg)    { opt.fn(cfg) }
func (producerOpt) producerOpt()       {}
func (consumerOpt) consumerOpt()       {}
func (groupOpt) groupOpt()             {}

// A cfg can be written to while initializing a client, and after that it is
// (mostly) only ever read from. Some areas can continue to be modified --
// particularly reconfiguring what to consume from -- but most areas are
// static.
type cfg struct {
	/////////////////////
	// GENERAL SECTION //
	/////////////////////

	id                     *string // client ID
	ctx                    context.Context
	dialFn                 func(context.Context, string, string) (net.Conn, error)
	dialTimeout            time.Duration
	dialTLS                *tls.Config
	requestTimeoutOverhead time.Duration
	connIdleTimeout        time.Duration

	softwareName    string // KIP-511
	softwareVersion string // KIP-511

	logger Logger

	seedBrokers []string
	maxVersions *kversion.Versions
	minVersions *kversion.Versions

	onRebootstrapRequired func() ([]string, error)

	retryBackoff func(int) time.Duration
	retries      int64
	retryTimeout func(int16) time.Duration

	maxBrokerWriteBytes int32
	maxBrokerReadBytes  int32

	metadataMaxAge time.Duration
	metadataMinAge time.Duration

	sasls []sasl.Mechanism

	alwaysRetryEOF         bool
	allowAutoTopicCreation bool
	disableClientMetrics   bool
	userMetrics            func() iter.Seq[Metric]

	hooks hooks
	pools pools

	//////////////////////
	// PRODUCER SECTION //
	//////////////////////

	txnID                              *string
	txnTimeout                         time.Duration
	acks                               Acks
	disableIdempotency                 bool
	allowIdempotentProduceCancellation bool
	maxProduceInflight                 int                // if idempotency is disabled, we allow a configurable max inflight
	compression                        []CompressionCodec // order of preference

	defaultProduceTopic       string
	defaultProduceTopicAlways bool
	maxRecordBatchBytes       func(string) int32
	maxBufferedRecords        int64
	maxBufferedBytes          int64
	produceTimeout            time.Duration
	recordRetries             int64
	maxUnknownFailures        int64
	linger                    time.Duration
	recordTimeout             time.Duration
	manualFlushing            bool
	txnBackoff                time.Duration
	missingTopicDelete        time.Duration

	partitioner Partitioner
	compressor  Compressor

	stopOnDataLoss bool
	onDataLoss     func(string, int32)

	//////////////////////
	// CONSUMER SECTION //
	//////////////////////

	// maxWait holds the user-configured FetchMaxWait in milliseconds.
	// math.MinInt32 is a sigil meaning "unset": validate replaces it with
	// the default for the current mode (500ms for share groups, 5s
	// otherwise).
	maxWait        int32
	minBytes       int32
	maxBytes       lazyI32
	maxPartBytes   lazyI32
	startOffset    Offset
	resetOffset    Offset
	setStartOffset bool
	setResetOffset bool
	isolationLevel int8
	keepControl    bool
	rack           string
	preferLagFn    PreferLagFn
	decompressor   Decompressor

	maxConcurrentFetches      int
	disableFetchSessions      bool
	keepRetryableFetchErrors  bool
	disableFetchCRCValidation bool

	recheckPreferredReplicaInterval time.Duration

	topics        map[string]*regexp.Regexp   // topics to consume; if regex is true, values are compiled regular expressions
	excludeTopics map[string]*regexp.Regexp   // topics to exclude; only used if regex is true, values are compiled regular expressions
	partitions    map[string]map[int32]Offset // partitions to directly consume from
	regex         bool

	////////////////////////////
	// CONSUMER GROUP SECTION //
	////////////////////////////

	group                 string // group we are in
	shareGroup            string // share group we are in
	shareMaxRecords       int32  // MaxRecords and BatchSize for ShareFetch (KIP-1206)
	shareMaxRecordsStrict bool   // if true, ShareAcquireMode=1 (record-limit) per KIP-1206
	shareAckCallback      func(*Client, ShareAckResults)
	instanceID            *string         // optional group instance ID
	balancers             []GroupBalancer // balancers we can use
	protocol              string          // "consumer" by default, expected to never be overridden

	sessionTimeout    time.Duration
	rebalanceTimeout  time.Duration
	heartbeatInterval time.Duration

	onAssigned      func(context.Context, *Client, map[string][]int32)
	onRevoked       func(context.Context, *Client, map[string][]int32)
	onLost          func(context.Context, *Client, map[string][]int32)
	onBlocked       func(context.Context, *Client)
	onFetched       func(context.Context, *Client, *kmsg.OffsetFetchResponse) error
	userHasOnAssign bool // captured before initGroup wraps onAssigned non-nil

	adjustOffsetsBeforeAssign func(ctx context.Context, offsets map[string]map[int32]Offset) (map[string]map[int32]Offset, error)

	blockRebalanceOnPoll bool

	autocommitDisable  bool // true if autocommit was disabled or we are transactional
	autocommitGreedy   bool
	autocommitMarks    bool
	autocommitInterval time.Duration
	commitCallback     func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error)

	disableNextGenBalancer bool
}

func (cfg *cfg) validate() error {
	if len(cfg.seedBrokers) == 0 {
		return errors.New("config erroneously has no seed brokers")
	}

	if cfg.maxWait == math.MinInt32 {
		if cfg.shareGroup != "" {
			cfg.maxWait = 500
		} else {
			cfg.maxWait = 5000
		}
	}

	// We clamp maxPartBytes to maxBytes because some fake Kafka endpoints
	// (Oracle) cannot handle the mismatch correctly.
	if cfg.maxPartBytes > cfg.maxBytes {
		cfg.maxPartBytes = cfg.maxBytes
	}

	if cfg.allowIdempotentProduceCancellation && cfg.txnID != nil {
		return errors.New("cannot allow idempotent produce cancellation and use transactional IDs")
	}
	if cfg.disableIdempotency {
		if cfg.txnID != nil {
			return errors.New("cannot both disable idempotent writes and use transactional IDs")
		}
		if cfg.maxProduceInflight <= 0 {
			return fmt.Errorf("invalid max produce inflight %d with idempotency disabled", cfg.maxProduceInflight)
		}
	} else {
		if cfg.acks.val != -1 {
			return errors.New("idempotency requires acks=all")
		}
		if cfg.maxProduceInflight != 1 {
			return fmt.Errorf("invalid usage of MaxProduceRequestsInflightPerBroker with idempotency enabled")
		}
	}

	for _, limit := range []struct {
		name    string
		sp      **string // if field is a *string, we take addr to it
		s       string
		allowed int
	}{
		// A 256 byte ID / software name & version is good enough and
		// fits with our max broker write byte min of 1K.
		{name: "client id", sp: &cfg.id, allowed: 256},
		{name: "software name", s: cfg.softwareName, allowed: 256},
		{name: "software version", s: cfg.softwareVersion, allowed: 256},

		// The following is the limit transitioning from two byte
		// prefix for flexible stuff to three bytes; as with above, it
		// is more than reasonable.
		{name: "transactional id", sp: &cfg.txnID, allowed: 16382},

		{name: "rack", s: cfg.rack, allowed: 512},
		{name: "share group", s: cfg.shareGroup, allowed: 16382},
	} {
		s := limit.s
		if limit.sp != nil && *limit.sp != nil {
			s = **limit.sp
		}
		if len(s) > limit.allowed {
			return fmt.Errorf("%s length %d is larger than max allowed %d", limit.name, len(s), limit.allowed)
		}
	}

	i64lt := func(l, r int64) (bool, string) { return l < r, "less" }
	i64gt := func(l, r int64) (bool, string) { return l > r, "larger" }
	for _, limit := range []struct {
		name    string
		v       int64
		allowed int64
		badcmp  func(int64, int64) (bool, string)

		fmt  string
		durs bool
	}{
		// Min write of 1K and max of 1G is reasonable.
		{name: "max broker write bytes", v: int64(cfg.maxBrokerWriteBytes), allowed: 1 << 10, badcmp: i64lt},
		{name: "max broker write bytes", v: int64(cfg.maxBrokerWriteBytes), allowed: 1 << 30, badcmp: i64gt},

		// Same for read bytes.
		{name: "max broker read bytes", v: int64(cfg.maxBrokerReadBytes), allowed: 1 << 10, badcmp: i64lt},
		{name: "max broker read bytes", v: int64(cfg.maxBrokerReadBytes), allowed: 1 << 30, badcmp: i64gt},

		// For batches, we want at least 512 (reasonable), and the
		// upper limit is the max num when a uvarint transitions from 4
		// to 5 bytes. The upper limit is also more than reasonable (1G).
		{name: "max record batch bytes", v: int64(cfg.maxRecordBatchBytes("")), allowed: 512, badcmp: i64lt},
		{name: "max record batch bytes", v: int64(cfg.maxRecordBatchBytes("")), allowed: 1 << 30, badcmp: i64gt},

		// We do not want the broker write bytes to be less than the
		// record batch bytes, nor the read bytes to be less than what
		// we indicate to fetch.
		//
		// We cannot enforce if a single batch is larger than the max
		// fetch bytes limit, but hopefully we do not run into that.
		{v: int64(cfg.maxBrokerWriteBytes), allowed: int64(cfg.maxRecordBatchBytes("")), badcmp: i64lt, fmt: "max broker write bytes %v is erroneously less than max record batch bytes %v"},
		{v: int64(cfg.maxBrokerReadBytes), allowed: int64(cfg.maxBytes), badcmp: i64lt, fmt: "max broker read bytes %v is erroneously less than max fetch bytes %v"},

		// -1 <= allowed concurrency (-1 is unbounded)
		{name: "max concurrent fetches", v: int64(cfg.maxConcurrentFetches), allowed: -1, badcmp: i64lt},

		// 100ms <= request timeout overhead <= 15m
		{name: "request timeout max overhead", v: int64(cfg.requestTimeoutOverhead), allowed: int64(15 * time.Minute), badcmp: i64gt, durs: true},
		{name: "request timeout min overhead", v: int64(cfg.requestTimeoutOverhead), allowed: int64(100 * time.Millisecond), badcmp: i64lt, durs: true},

		// 100ms <= conn idle <= 15m
		{name: "conn min idle timeout", v: int64(cfg.connIdleTimeout), allowed: int64(100 * time.Millisecond), badcmp: i64lt, durs: true},
		{name: "conn max idle timeout", v: int64(cfg.connIdleTimeout), allowed: int64(15 * time.Minute), badcmp: i64gt, durs: true},

		// 10ms <= metadata <= 1hr
		{name: "metadata max age", v: int64(cfg.metadataMaxAge), allowed: int64(time.Hour), badcmp: i64gt, durs: true},
		{name: "metadata min age", v: int64(cfg.metadataMinAge), allowed: int64(10 * time.Millisecond), badcmp: i64lt, durs: true},
		{v: int64(cfg.metadataMaxAge), allowed: int64(cfg.metadataMinAge), badcmp: i64lt, fmt: "metadata max age %v is erroneously less than metadata min age %v", durs: true},

		// 10ms <= preferred recheck interval <= 7d
		{name: "recheck preferred replica interval", v: int64(cfg.recheckPreferredReplicaInterval), allowed: int64(10 * time.Millisecond), badcmp: i64lt, durs: true},
		{name: "recheck preferred replica interval", v: int64(cfg.recheckPreferredReplicaInterval), allowed: int64(7 * 24 * time.Hour), badcmp: i64gt, durs: true},

		// Some random producer settings.
		{name: "max buffered records", v: cfg.maxBufferedRecords, allowed: 1, badcmp: i64lt},
		{name: "max buffered bytes", v: cfg.maxBufferedBytes, allowed: 0, badcmp: i64lt},
		{name: "linger", v: int64(cfg.linger), allowed: int64(time.Minute), badcmp: i64gt, durs: true},
		{name: "produce timeout", v: int64(cfg.produceTimeout), allowed: int64(100 * time.Millisecond), badcmp: i64lt, durs: true},
		{name: "record timeout", v: int64(cfg.recordTimeout), allowed: int64(time.Second), badcmp: func(l, r int64) (bool, string) {
			if l == 0 {
				return false, "" // we print nothing when things are good
			}
			return l < r, "less"
		}, durs: true},

		// Consumer settings. maxWait is stored as int32 milliseconds,
		// but we want the error message to be in the nice
		// time.Duration string format.
		{name: "max fetch wait", v: int64(cfg.maxWait) * int64(time.Millisecond), allowed: int64(10 * time.Millisecond), badcmp: i64lt, durs: true},

		// Group settings.
		{name: "number of balancers", v: int64(len(cfg.balancers)), allowed: 1, badcmp: i64lt},
		{name: "consumer protocol length", v: int64(len(cfg.protocol)), allowed: 1, badcmp: i64lt},

		{name: "session timeout", v: int64(cfg.sessionTimeout), allowed: int64(100 * time.Millisecond), badcmp: i64lt, durs: true},
		{name: "rebalance timeout", v: int64(cfg.rebalanceTimeout), allowed: int64(100 * time.Millisecond), badcmp: i64lt, durs: true},
		{name: "autocommit interval", v: int64(cfg.autocommitInterval), allowed: int64(100 * time.Millisecond), badcmp: i64lt, durs: true},

		{v: int64(cfg.heartbeatInterval), allowed: int64(cfg.rebalanceTimeout) * int64(time.Millisecond), badcmp: i64gt, durs: true, fmt: "heartbeat interval %v is erroneously larger than the session timeout %v"},
	} {
		bad, cmp := limit.badcmp(limit.v, limit.allowed)
		if bad {
			if limit.fmt != "" {
				if limit.durs {
					return fmt.Errorf(limit.fmt, time.Duration(limit.v), time.Duration(limit.allowed))
				}
				return fmt.Errorf(limit.fmt, limit.v, limit.allowed)
			}
			if limit.durs {
				return fmt.Errorf("%s %v is %s than allowed %v", limit.name, time.Duration(limit.v), cmp, time.Duration(limit.allowed))
			}
			return fmt.Errorf("%s %v is %s than allowed %v", limit.name, limit.v, cmp, limit.allowed)
		}
	}

	if cfg.defaultProduceTopicAlways && cfg.defaultProduceTopic == "" {
		return errors.New("invalid empty DefaultProduceTopic when using DefaultProduceTopicAlways")
	}

	if cfg.dialFn != nil {
		if cfg.dialTLS != nil {
			return errors.New("cannot set both Dialer and DialTLSConfig")
		}
	}

	if len(cfg.group) > 0 {
		if len(cfg.partitions) != 0 {
			return errors.New("invalid direct-partition consuming option when consuming as a group")
		}
	}

	if len(cfg.shareGroup) > 0 {
		if len(cfg.group) > 0 {
			return errors.New("cannot use both ConsumerGroup and ShareGroup")
		}
		if len(cfg.partitions) != 0 {
			return errors.New("invalid direct-partition consuming option when consuming as a share group")
		}
		if cfg.autocommitGreedy || cfg.autocommitMarks || cfg.autocommitDisable || cfg.commitCallback != nil {
			return errors.New("autocommit options are not applicable to share groups")
		}
		if cfg.onLost != nil || cfg.onRevoked != nil || cfg.onAssigned != nil {
			return errors.New("partition lifecycle callbacks are not supported with share groups")
		}
		if cfg.shareMaxRecords < -1 || cfg.shareMaxRecords == 0 {
			return errors.New("ShareMaxRecords must be positive")
		}
		// In record-limit mode (ShareMaxRecordsStrict), restrict to
		// a single broker per poll round to respect the record limit.
		if cfg.shareMaxRecordsStrict && cfg.maxConcurrentFetches == -1 {
			cfg.maxConcurrentFetches = 0
		}
	} else if cfg.shareMaxRecords != -1 || cfg.shareMaxRecordsStrict || cfg.shareAckCallback != nil {
		return errors.New("ShareMaxRecords, ShareMaxRecordsStrict, and ShareAckCallback only apply when using ShareGroup")
	}

	if cfg.regex {
		if len(cfg.partitions) != 0 {
			return errors.New("invalid direct-partition consuming option when consuming as regex")
		}
		for re := range cfg.topics {
			compiled, err := regexp.Compile(re)
			if err != nil {
				return fmt.Errorf("invalid regular expression %q", re)
			}
			cfg.topics[re] = compiled
		}
		for re := range cfg.excludeTopics {
			compiled, err := regexp.Compile(re)
			if err != nil {
				return fmt.Errorf("invalid regular expression %q", re)
			}
			cfg.excludeTopics[re] = compiled
		}
	} else if len(cfg.excludeTopics) > 0 {
		return errors.New("invalid use of ConsumeExcludeTopics when not using ConsumeRegex")
	}

	if cfg.topics != nil && cfg.partitions != nil {
		for topic := range cfg.partitions {
			if _, exists := cfg.topics[topic]; exists {
				return fmt.Errorf("topic %q seen in both ConsumePartitions and ConsumeTopics; these options are a union, it is invalid to specify specific partitions for a topic while also consuming the entire topic", topic)
			}
		}
	}

	if cfg.autocommitDisable && cfg.autocommitGreedy {
		return errors.New("cannot both disable autocommitting and enable greedy autocommitting")
	}
	if cfg.autocommitDisable && cfg.autocommitMarks {
		return errors.New("cannot both disable autocommitting and enable marked autocommitting")
	}
	if cfg.autocommitGreedy && cfg.autocommitMarks {
		return errors.New("cannot enable both greedy autocommitting and marked autocommitting")
	}
	if (cfg.autocommitGreedy || cfg.autocommitDisable || cfg.autocommitMarks || cfg.commitCallback != nil) && len(cfg.group) == 0 && len(cfg.shareGroup) == 0 {
		return errors.New("invalid autocommit options specified when a group was not specified")
	}
	if (cfg.onLost != nil || cfg.onRevoked != nil || cfg.onAssigned != nil) && len(cfg.group) == 0 && len(cfg.shareGroup) == 0 {
		return errors.New("invalid group partition assigned/revoked/lost functions set when a group was not specified")
	}

	processedHooks, err := processHooks(cfg.hooks)
	if err != nil {
		return err
	}
	cfg.hooks = processedHooks

	processedPools, err := processPools(cfg.pools)
	if err != nil {
		return err
	}
	cfg.pools = processedPools

	return nil
}

// processHooks will inspect and recursively unpack slices of hooks stopping
// if the instance implements any hook interface. It will return an error on
// the first instance that implements no hook interface
func processHooks(hooks []Hook) ([]Hook, error) {
	var processedHooks []Hook
	for _, hook := range hooks {
		if implementsAnyHook(hook) {
			processedHooks = append(processedHooks, hook)
		} else if moreHooks, ok := hook.([]Hook); ok {
			more, err := processHooks(moreHooks)
			if err != nil {
				return nil, err
			}
			processedHooks = append(processedHooks, more...)
		} else {
			return nil, errors.New("found an argument that implements no hook interfaces")
		}
	}
	return processedHooks, nil
}

// Same as the above, but for pools.
func processPools(pools []Pool) ([]Pool, error) {
	var processedPools []Pool
	for _, pool := range pools {
		if implementsAnyPool(pool) {
			processedPools = append(processedPools, pool)
		} else if morePools, ok := pool.([]Pool); ok {
			more, err := processPools(morePools)
			if err != nil {
				return nil, err
			}
			processedPools = append(processedPools, more...)
		} else {
			return nil, errors.New("found an argument that implements no pool interfaces")
		}
	}
	return processedPools, nil
}

var reVersion = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$`)

func softwareVersion() string {
	info, ok := debug.ReadBuildInfo()
	if ok {
		for _, dep := range info.Deps {
			if dep.Path == "github.com/twmb/franz-go" {
				if reVersion.MatchString(dep.Version) {
					return dep.Version
				}
			}
		}
	}
	return "unknown"
}

func defaultCfg() cfg {
	defaultID := "kgo"
	return cfg{
		/////////////
		// general //
		/////////////
		id: &defaultID,

		dialTimeout:            10 * time.Second,
		requestTimeoutOverhead: 10 * time.Second,
		connIdleTimeout:        30 * time.Second,

		softwareName:    "kgo",
		softwareVersion: softwareVersion(),

		logger: new(nopLogger),

		seedBrokers: []string{"127.0.0.1"},
		maxVersions: kversion.Stable(), // kversion bumps what is returned from Stable on the same release we add support for new features to kgo

		retryBackoff: func() func(int) time.Duration {
			var rngMu xsync.Mutex
			rng := rand.New(rand.NewSource(time.Now().UnixNano()))
			return func(fails int) time.Duration {
				const (
					min = 250 * time.Millisecond
					max = 5 * time.Second
				)
				if fails <= 0 {
					return min
				}
				if fails > 10 {
					return max
				}

				backoff := min * time.Duration(1<<(fails-1))

				rngMu.Lock()
				jitter := 0.8 + 0.4*rng.Float64()
				rngMu.Unlock()

				backoff = time.Duration(float64(backoff) * jitter)

				if backoff > max {
					return max
				}
				return backoff
			}
		}(),
		retries: 20,

		maxBrokerWriteBytes: 100 << 20, // Kafka socket.request.max.bytes default is 100<<20
		maxBrokerReadBytes:  100 << 20,

		metadataMaxAge:     5 * time.Minute,
		metadataMinAge:     5 * time.Second,
		missingTopicDelete: 15 * time.Second,

		//////////////
		// producer //
		//////////////

		txnTimeout:          40 * time.Second,
		acks:                AllISRAcks(),
		maxProduceInflight:  1,
		compression:         []CompressionCodec{SnappyCompression(), NoCompression()},
		maxRecordBatchBytes: func(string) int32 { return 1000012 }, // Kafka max.message.bytes default is 1000012
		maxBufferedRecords:  10000,
		produceTimeout:      10 * time.Second,
		recordRetries:       math.MaxInt64, // effectively unbounded
		maxUnknownFailures:  4,
		linger:              10 * time.Millisecond,
		partitioner:         UniformBytesPartitioner(64<<10, true, true, nil),
		txnBackoff:          20 * time.Millisecond,

		//////////////
		// consumer //
		//////////////

		maxWait:        math.MinInt32, // sigil: resolved by validate based on shareGroup
		minBytes:       1,
		maxBytes:       50 << 20,
		maxPartBytes:   1 << 20,
		startOffset:    NewOffset().AtStart(),
		resetOffset:    NewOffset().AtStart(),
		isolationLevel: 0,

		maxConcurrentFetches: -1, // unbounded default

		recheckPreferredReplicaInterval: 30 * time.Minute,

		///////////
		// group //
		///////////

		balancers: []GroupBalancer{
			CooperativeStickyBalancer(),
		},
		protocol: "consumer",

		sessionTimeout:    45000 * time.Millisecond,
		rebalanceTimeout:  60000 * time.Millisecond,
		heartbeatInterval: 3000 * time.Millisecond,

		autocommitInterval: 5 * time.Second,

		shareMaxRecords: -1, // sigil: not set, ShareFetch sends 500
	}
}

//////////////////////////
// CLIENT CONFIGURATION //
//////////////////////////

// ClientID uses id for all requests sent to Kafka brokers, overriding the
// default "kgo".
func ClientID(id string) Opt {
	return clientOpt{func(cfg *cfg) { cfg.id = &id }}
}

// SoftwareNameAndVersion sets the client software name and version that will
// be sent to Kafka as part of the ApiVersions request as of Kafka 2.4,
// overriding the default "kgo" and internal version number.
//
// Kafka exposes this through metrics to help operators understand the impact
// of clients.
//
// It is generally not recommended to set this. As well, if you do, the name
// and version must match the following regular expression:
//
//	[a-zA-Z0-9](?:[a-zA-Z0-9\.-]*[a-zA-Z0-9])?
//
// Note this means neither the name nor version can be empty.
func SoftwareNameAndVersion(name, version string) Opt {
	return clientOpt{func(cfg *cfg) { cfg.softwareName = name; cfg.softwareVersion = version }}
}

// WithLogger sets the client to use the given logger, overriding the default
// to not use a logger.
//
// It is invalid to use a nil logger; doing so will cause panics.
func WithLogger(l Logger) Opt {
	return clientOpt{func(cfg *cfg) { cfg.logger = &wrappedLogger{l} }}
}

// WithContext sets the client to use a custom context.
//
// By default, the client uses context.Background.
func WithContext(ctx context.Context) Opt {
	return clientOpt{func(cfg *cfg) { cfg.ctx = ctx }}
}

// RequestTimeoutOverhead uses the given time as overhead while deadlining
// requests, overriding the default overhead of 10s.
//
// For most requests, the timeout is set to the overhead. However, for
// any request with a TimeoutMillis field, the overhead is added on top of the
// request's TimeoutMillis. This ensures that we give Kafka enough time to
// actually process the request given the timeout, while still having a
// deadline on the connection as a whole to ensure it does not hang.
//
// For writes, the timeout is always the overhead. We buffer writes in our
// client before one quick flush, so we always expect the write to be fast.
//
// Note that hitting the timeout kills a connection, which will fail any other
// active writes or reads on the connection.
//
// This option is roughly equivalent to request.timeout.ms, but grants
// additional time to requests that have timeout fields.
func RequestTimeoutOverhead(overhead time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.requestTimeoutOverhead = overhead }}
}

// ConnIdleTimeout sets the amount of time after which an idle connection will
// not be reused, overriding the default 30s. The connection may be closed after
// this timeout.
//
// In the worst case, a connection can be allowed to idle for up to 2x this
// time before being closed.
//
// It is possible that a connection can be reaped just as it is about to be
// written to, but the client internally retries in these cases.
//
// Connections are not reaped if they are actively being written to or read
// from; thus, a request can take a really long time itself and not be reaped
// (however, this may lead to the RequestTimeoutOverhead).
func ConnIdleTimeout(timeout time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.connIdleTimeout = timeout }}
}

// Dialer uses fn to dial addresses, overriding the default dialer that uses a
// 10s dial timeout and no TLS.
//
// The context passed to the dial function is the context used in the request
// that caused the dial. If the request is a client-internal request, the
// context is the context on the client itself (which is canceled when the
// client is closed).
//
// This function has the same signature as net.Dialer's DialContext and
// tls.Dialer's DialContext, meaning you can use this function like so:
//
//	kgo.Dialer((&net.Dialer{Timeout: 10*time.Second}).DialContext)
//
// or
//
//	kgo.Dialer((&tls.Dialer{...}).DialContext)
func Dialer(fn func(ctx context.Context, network, host string) (net.Conn, error)) Opt {
	return clientOpt{func(cfg *cfg) { cfg.dialFn = fn }}
}

// DialTimeout sets the dial timeout, overriding the default of 10s. This
// option is useful if you do not want to set a custom dialer, and is useful in
// tandem with DialTLSConfig.
func DialTimeout(timeout time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.dialTimeout = timeout }}
}

// DialTLSConfig opts into dialing brokers with the given TLS config with a
// 10s dial timeout. This is a shortcut for manually specifying a tls dialer
// using the Dialer option. You can also change the default 10s timeout with
// DialTimeout.
//
// Every dial, the input config is cloned. If the config's ServerName is not
// specified, this function uses net.SplitHostPort to extract the host from the
// broker being dialed and sets the ServerName. In short, it is not necessary
// to set the ServerName.
func DialTLSConfig(c *tls.Config) Opt {
	return clientOpt{func(cfg *cfg) { cfg.dialTLS = c }}
}

// DialTLS opts into dialing brokers with TLS. This is a shortcut for
// DialTLSConfig with an empty config. See DialTLSConfig for more details.
func DialTLS() Opt {
	return DialTLSConfig(new(tls.Config))
}

// SeedBrokers sets the seed brokers for the client to use, overriding the
// default 127.0.0.1:9092.
//
// Any seeds that are missing a port use the default Kafka port 9092.
func SeedBrokers(seeds ...string) Opt {
	return clientOpt{func(cfg *cfg) { cfg.seedBrokers = append(cfg.seedBrokers[:0], seeds...) }}
}

// MaxVersions sets the maximum Kafka version to try, overriding the
// internal unbounded (latest stable) versions.
//
// Note that specific max version pinning is required if trying to interact
// with versions pre 0.10.0. Otherwise, unless using more complicated requests
// that this client itself does not natively use, it is generally safe to opt
// for the latest version. If using the kmsg package directly to issue
// requests, it is recommended to pin versions so that new fields on requests
// do not get invalid default zero values before you update your usage.
func MaxVersions(versions *kversion.Versions) Opt {
	return clientOpt{func(cfg *cfg) { cfg.maxVersions = versions }}
}

// MinVersions sets the minimum Kafka version a request can be downgraded to,
// overriding the default of the lowest version.
//
// This option is useful if you are issuing requests that you absolutely do not
// want to be downgraded; that is, if you are relying on features in newer
// requests, and you are not sure if your brokers can handle those features.
// By setting a min version, if the client detects it needs to downgrade past
// the version, it will instead avoid issuing the request.
//
// Unlike MaxVersions, if a request is issued that is unknown to the min
// versions, the request is allowed. It is assumed that there is no lower bound
// for that request.
func MinVersions(versions *kversion.Versions) Opt {
	return clientOpt{func(cfg *cfg) { cfg.minVersions = versions }}
}

// RetryBackoffFn sets the backoff strategy for how long to backoff for a given
// amount of retries, overriding the default jittery exponential backoff that
// ranges from 250ms min to 2.5s max.
//
// This (roughly) corresponds to Kafka's retry.backoff.ms setting and
// retry.backoff.max.ms (which is being introduced with KIP-500).
func RetryBackoffFn(backoff func(int) time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.retryBackoff = backoff }}
}

// RequestRetries sets the number of tries that retryable requests are allowed,
// overriding the default of 20.
//
// This option does not apply to produce requests; to limit produce request
// retries / record retries, see RecordRetries.
func RequestRetries(n int) Opt {
	return clientOpt{func(cfg *cfg) { cfg.retries = int64(n) }}
}

// RetryTimeout sets the upper limit on how long we allow a request to be
// issued and then reissued on failure. That is, this control the total
// end-to-end maximum time we allow for trying a request, This overrides the
// default of:
//
//	JoinGroup: cfg.SessionTimeout (default 45s)
//	SyncGroup: cfg.SessionTimeout (default 45s)
//	Heartbeat: cfg.SessionTimeout (default 45s)
//	   others: 30s
//
// This timeout applies to any request issued through a client's Request
// function. It does not apply to fetches nor produces.
//
// A value of zero indicates no request timeout.
//
// The timeout is evaluated after a request errors. If the time since the start
// of the first request plus any backoff for the latest failure is less than
// the retry timeout, the request will be issued again.
func RetryTimeout(t time.Duration) Opt {
	return RetryTimeoutFn(func(int16) time.Duration { return t })
}

// RetryTimeoutFn sets the upper limit on how long we allow a request to be
// issued and then reissued on failure. That is, this control the total
// end-to-end maximum time we allow for trying a request, This overrides the
// default of:
//
//	JoinGroup: cfg.SessionTimeout (default 45s)
//	SyncGroup: cfg.SessionTimeout (default 45s)
//	Heartbeat: cfg.SessionTimeout (default 45s)
//	   others: 30s
//
// This timeout applies to any request issued through a client's Request
// function. It does not apply to fetches nor produces.
//
// The function is called with the request key that is being retried. While it
// is not expected that the request key will be used, including it gives users
// the opportunity to have different retry timeouts for different keys.
//
// If the function returns zero, there is no retry timeout.
//
// The timeout is evaluated after a request errors. If the time since the start
// of the first request plus any backoff for the latest failure is less than
// the retry timeout, the request will be issued again.
func RetryTimeoutFn(t func(int16) time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.retryTimeout = t }}
}

// AllowAutoTopicCreation enables topics to be auto created if they do
// not exist when fetching their metadata.
func AllowAutoTopicCreation() Opt {
	return clientOpt{func(cfg *cfg) { cfg.allowAutoTopicCreation = true }}
}

// BrokerMaxWriteBytes upper bounds the number of bytes written to a broker
// connection in a single write, overriding the default 100MiB.
//
// This number corresponds to the broker's socket.request.max.bytes, which
// defaults to 100MiB.
//
// The only Kafka request that could come reasonable close to hitting this
// limit should be produce requests, and thus this limit is only enforced for
// produce requests.
func BrokerMaxWriteBytes(v int32) Opt {
	return clientOpt{func(cfg *cfg) { cfg.maxBrokerWriteBytes = v }}
}

// BrokerMaxReadBytes sets the maximum response size that can be read from
// Kafka, overriding the default 100MiB.
//
// This is a safety measure to avoid OOMing on invalid responses. This is
// slightly double FetchMaxBytes; if bumping that, consider bump this. No other
// response should run the risk of hitting this limit.
func BrokerMaxReadBytes(v int32) Opt {
	return clientOpt{func(cfg *cfg) { cfg.maxBrokerReadBytes = v }}
}

// MetadataMaxAge sets the maximum age for the client's cached metadata,
// overriding the default 5m, to allow detection of new topics, partitions,
// etc.
//
// This corresponds to Kafka's metadata.max.age.ms.
func MetadataMaxAge(age time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.metadataMaxAge = age }}
}

// MetadataMinAge sets the minimum time between metadata queries, overriding
// the default 5s. You may want to raise or lower this to reduce the number of
// metadata queries the client will make. Notably, if metadata detects an error
// in any topic or partition, it triggers itself to update as soon as allowed.
func MetadataMinAge(age time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.metadataMinAge = age }}
}

// SASL appends sasl authentication options to use for all connections.
//
// SASL is tried in order; if the broker supports the first mechanism, all
// connections will use that mechanism. If the first mechanism fails, the
// client will pick the first supported mechanism. If the broker does not
// support any client mechanisms, connections will fail.
func SASL(sasls ...sasl.Mechanism) Opt {
	return clientOpt{func(cfg *cfg) { cfg.sasls = append(cfg.sasls, sasls...) }}
}

// WithHooks sets hooks to call whenever relevant.
//
// Hooks can be used to layer in metrics (such as Prometheus hooks) or anything
// else. The client will call all hooks in order. See the Hooks interface for
// more information, as well as any interface that contains "Hook" in the name
// to know the available hooks. A single hook can implement any or all hook
// interfaces, and only the hooks that it implements will be called.
func WithHooks(hooks ...Hook) Opt {
	return clientOpt{func(cfg *cfg) { cfg.hooks = append(cfg.hooks, hooks...) }}
}

// WithPools sets memory pools to use wherever relevant.
//
// Pools can be used to optimize memory usage for data that is frequently
// thrown away after a short usage. For a list of all supported pools, look at
// the documentation for any interface that begins with "Pool". Multiple pools
// may be used; the first pool that is received from is the first pull put back
// into. A single pool can implement any or all pool interfaces.
//
// If you use pools for fetching, the record Context field will be populated.
// This field is used for recycling the underlying memory once Recycle is
// called; do not clear the field.
func WithPools(pools ...Pool) Opt {
	return clientOpt{func(cfg *cfg) { cfg.pools = append(cfg.pools, pools...) }}
}

// ConcurrentTransactionsBackoff sets the backoff interval to use during
// transactional requests in case we encounter CONCURRENT_TRANSACTIONS error,
// overriding the default 20ms.
//
// Sometimes, when a client begins a transaction quickly enough after finishing
// a previous one, Kafka will return a CONCURRENT_TRANSACTIONS error. Clients
// are expected to backoff slightly and retry the operation. Lower backoffs may
// increase load on the brokers, while higher backoffs may increase transaction
// latency in clients.
//
// Note that if brokers are hanging in this concurrent transactions state for
// too long, the client progressively increases the backoff.
func ConcurrentTransactionsBackoff(backoff time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.txnBackoff = backoff }}
}

// ConsiderMissingTopicDeletedAfter sets the amount of time a topic can be
// missing from metadata responses _after_ loading it at least once before it
// is considered deleted, overriding the default of 15s. Note that for newer
// versions of Kafka, it may take a bit of time (~15s) for the cluster to fully
// recognize a newly created topic. If this option is set too low, there is
// some risk that the client will internally purge and re-see a topic a few
// times until the cluster fully broadcasts the topic creation.
func ConsiderMissingTopicDeletedAfter(t time.Duration) Opt {
	return clientOpt{func(cfg *cfg) { cfg.missingTopicDelete = t }}
}

// OnRebootstrapRequired sets the function to call when a metadata response has
// the REBOOTSTRAP_REQUIRED errored. The function should return new seed
// brokers for the client to use, or an error. Internally, the client will then
// call UpdateSeedBrokers with the seeds you return. All other live connections
// to brokers are stopped and active requests are failed.
//
// The REBOOTSTRAP_REQUIRED error was introduced in Kafka 4.0, as a way for
// Kafka to tell the client that the client needs to stop all non seed broker
// connections to stop and for the client to query the seed brokers again.
// Franz-go by default already periodically sends a request to a seed broker
// to prevent a scenario where all previously discovered brokers are down
// or unavailable, so this client does not have as much of a need for
// REBOOTSTRAP_REQUIRED. That said, this function can be useful if Kafka knows
// the client should specifically talk to seed brokers next, and this function
// allows you a chance to update your seed brokers at the same time. If you
// do not want to update your seed brokers, you can just return the same value
// that you use in your [SeedBrokers] configuration option.
//
// You can read KIP-1102 for more info about this option.
func OnRebootstrapRequired(fn func() ([]string, error)) Opt {
	return clientOpt{func(cfg *cfg) { cfg.onRebootstrapRequired = fn }}
}

// DisableClientMetrics opts out of collecting and sending client metrics to
// the broker (if the broker supports receiving client metrics). By default,
// clients are recommended to gather a small set of metrics to help cluster
// operators debug client issues (rather than relying on clients which may not
// be instrumented at all).
//
// For more details on client metrics, see KIP-714.
func DisableClientMetrics() Opt {
	return clientOpt{func(cfg *cfg) { cfg.disableClientMetrics = true }}
}

// UserMetricsFn sets the function to call to add user metrics when rolling up
// client metrics to send to the broker. Every metric rollup, fn is called and
// returns an iterator. All metrics returned from the iterator are included in
// the client metric aggregation and are sent to the broker. It is your
// responsibility to ensure the metric name is formatted correctly (namespaced
// and following OpenTelemetry format), and you need to ensure your Sum metrics
// are monotonically increasing. See the documentation on [Metric] for more
// details.
//
// For more details about the client sending metrics, see KIP-714. For more
// details about enhancing client metrics with user metrics, see KIP-1076.
func UserMetricsFn(fn func() iter.Seq[Metric]) Opt {
	return clientOpt{func(cfg *cfg) { cfg.userMetrics = fn }}
}

// AlwaysRetryEOF switches the client to *always* retry EOF.
//
// By default, if an EOF is experienced on the FIRST request being written to
// or read from a connection, the client does not retry on the error. EOFs are
// encountered for many reasons, and the client has no information available as
// to what exact reason the EOF was encountered. If your configuration is
// correct, then EOF is usually experienced during timeouts, or when you have
// some high load systems and connections are being cut for some reason, etc.
// If your configuration is incorrect (on the client or on the broker), EOF can
// be experienced due to some TLS settings mismatch or missing SASL
// credentials, and it's very hard to debug EOF in this case. Thus, after much
// feedback, the client was changed to assume an EOF experienced immediately
// means invalid configuration. If you *know* your configuration is correct,
// this option opts into always retrying EOF, allowing requests to retry and
// succeed as they normally should on very busy systems.
func AlwaysRetryEOF() Opt {
	return clientOpt{func(cfg *cfg) { cfg.alwaysRetryEOF = true }}
}

////////////////////////////
// PRODUCER CONFIGURATION //
////////////////////////////

// DefaultProduceTopic sets the default topic to produce to if the topic field
// is empty in a Record.
//
// If this option is not used, if a record has an empty topic, the record
// cannot be produced and will be failed immediately.
func DefaultProduceTopic(t string) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.defaultProduceTopic = t }}
}

// DefaultProduceTopicAlways sets the client to ALWAYS produce to the
// [DefaultProduceTopic], overriding any Topic field that may be present
// in the Record when producing.
func DefaultProduceTopicAlways() ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.defaultProduceTopicAlways = true }}
}

// Acks represents the number of acks a broker leader must have before
// a produce request is considered complete.
//
// This controls the durability of written records and corresponds to "acks" in
// Kafka's Producer Configuration documentation.
//
// The default is LeaderAck.
type Acks struct {
	val int16
}

// NoAck considers records sent as soon as they are written on the wire.
// The leader does not reply to records.
func NoAck() Acks { return Acks{0} }

// LeaderAck causes Kafka to reply that a record is written after only
// the leader has written a message. The leader does not wait for in-sync
// replica replies.
func LeaderAck() Acks { return Acks{1} }

// AllISRAcks ensures that all in-sync replicas have acknowledged they
// wrote a record before the leader replies success.
func AllISRAcks() Acks { return Acks{-1} }

// RequiredAcks sets the required acks for produced records,
// overriding the default RequireAllISRAcks.
func RequiredAcks(acks Acks) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.acks = acks }}
}

// DisableIdempotentWrite disables idempotent produce requests, opting out of
// Kafka server-side deduplication in the face of reissued requests due to
// transient network problems. Disabling idempotent write by default
// upper-bounds the number of in-flight produce requests per broker to 1, vs.
// the default of 5 when using idempotency.
//
// Idempotent production is strictly a win, but does require the
// IDEMPOTENT_WRITE permission on CLUSTER (pre Kafka 3.0), and not all clients
// can have that permission.
//
// If the goal is to allow cancellation of in-flight records while keeping
// idempotent deduplication, see [AllowIdempotentProduceCancellation] instead.
//
// This option is incompatible with specifying a transactional id.
func DisableIdempotentWrite() ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.disableIdempotency = true }}
}

// AllowIdempotentProduceCancellation permits cancellation of in-flight
// idempotent records, at the cost of breaking idempotency's
// duplicate-avoidance guarantee.
//
// When a record is in-flight, the client cannot tell "the broker never
// received it" from "the broker wrote it but the reply was lost".
// Cancelling at this point leaves the client's idempotent sequence
// window inconsistent with the broker: once cancelled records are
// failed to the user, the next produce either silently gap-accepts (if
// the broker wrote them) or hits OUT_OF_ORDER_SEQUENCE and forces the
// client to reload its producer ID (new epoch, reset sequence). A
// subsequent application-level retry of the cancelled record races
// against what the broker may already have stored - the broker cannot
// dedupe it, and you can get duplicates. By default, the client refuses
// to cancel in this state and instead waits for the record's outcome
// so idempotency holds.
//
// With this option, context cancellation, RecordDeliveryTimeout, and
// RecordRetries exhaustion are allowed to fail in-flight records.
// Idempotent dedupe continues to protect the client's own internal
// retry path, but any cancelled record that the application re-produces
// may land on the broker twice.
//
// Use this when time-bounded delivery matters more than
// duplicate-avoidance.
//
// This option is incompatible with specifying a transactional id.
func AllowIdempotentProduceCancellation() ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.allowIdempotentProduceCancellation = true }}
}

// MaxProduceRequestsInflightPerBroker changes the number of allowed produce
// requests in flight per broker if you disable idempotency, overriding the
// default of 1. If using idempotency, this option has no effect: the maximum
// in flight for Kafka v0.11 is 1, and from v1 onward is 5.
//
// Using more than 1 may result in out of order records and may result in
// duplicates if there are connection issues.
func MaxProduceRequestsInflightPerBroker(n int) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.maxProduceInflight = n }}
}

// ProducerBatchCompression sets the compression codec to use for producing
// records.
//
// Compression is chosen in the order preferred based on broker support. For
// example, zstd compression was introduced in Kafka 2.1, so the preference can
// be first zstd, fallback snappy, fallback none.
//
// The default preference is [snappy, none], which should be fine for all old
// consumers since snappy compression has existed since Kafka 0.8.0.  To use
// zstd, your brokers must be at least 2.1 and all consumers must be upgraded
// to support decoding zstd records.
//
// Alternatively, if you want finer control over compression you can use
// [WithCompressor] for complete control.
func ProducerBatchCompression(preference ...CompressionCodec) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.compression = preference }}
}

// WithCompressor allows you to completely control how produce batches are
// compressed, allowing you to use alternative libraries than what franz-go
// supports, allowing you to have more control over memory & pooling, and
// other benefits. It is recommended to just use [ProducerBatchCompression]
// for simplicity (or specify nothing, which opts into snappy by default).
// The client default compressor is the [DefaultCompressor].
func WithCompressor(compressor Compressor) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.compressor = compressor }}
}

// ProducerBatchMaxBytes upper bounds the size of a record batch, overriding
// the default 1,000,012 bytes. This mirrors Kafka's max.message.bytes.
//
// Record batches are independent of a ProduceRequest: a record batch is
// specific to a topic and partition, whereas the produce request can contain
// many record batches for many topics.
//
// If a single record encodes larger than this number (before compression), it
// will not be written and a callback will have the appropriate error.
//
// Note that this is the maximum size of a record batch before compression. If
// a batch compresses poorly and actually grows the batch, the uncompressed
// form will be used.
//
// For per-topic control, see [ProducerBatchMaxBytesFn].
func ProducerBatchMaxBytes(v int32) ProducerOpt {
	return ProducerBatchMaxBytesFn(func(string) int32 { return v })
}

// ProducerBatchMaxBytesFn is the functional form of [ProducerBatchMaxBytes]:
// it returns the per-batch byte cap for the given topic, overriding the
// default 1,000,012 bytes. This is useful when different topics have
// different max.message.bytes configurations on the broker.
//
// The function is called once when a partition is first discovered and the
// result is cached on the per-partition record buffer - it is NOT consulted
// per record or per batch, and topic-config changes at runtime are not
// picked up until partition state is rebuilt. Return a value in
// [512, 1<<30]; returning a value outside that range for a specific topic
// will cause produces to that topic to fail (config validation only checks
// the return for the empty-topic at client construction).
//
// For a static limit applied to all topics, see [ProducerBatchMaxBytes].
func ProducerBatchMaxBytesFn(fn func(string) int32) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.maxRecordBatchBytes = fn }}
}

// MaxBufferedRecords sets the max amount of records the client will buffer,
// blocking produces until records are finished if this limit is reached.
// This overrides the default of 10,000.
func MaxBufferedRecords(n int) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.maxBufferedRecords = int64(n) }}
}

// MaxBufferedBytes sets the max amount of bytes that the client will buffer
// while producing, blocking produces until records are finished if this limit
// is reached. This overrides the unlimited default.
//
// Note that this option does _not_ apply for consuming: the client cannot
// limit bytes buffered for consuming because of decompression. You can roughly
// control consuming memory by using [MaxConcurrentFetches], [FetchMaxBytes],
// and [FetchMaxPartitionBytes].
//
// If you produce a record that is larger than n, the record is immediately
// failed with kerr.MessageTooLarge.
//
// Note that this limit applies after [MaxBufferedRecords].
func MaxBufferedBytes(n int) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.maxBufferedBytes = int64(n) }}
}

// RecordPartitioner uses the given partitioner to partition records, overriding
// the default UniformBytesPartitioner(64KiB, true, true, nil).
func RecordPartitioner(partitioner Partitioner) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.partitioner = partitioner }}
}

// ProduceRequestTimeout sets how long Kafka broker's are allowed to respond to
// produce requests, overriding the default 10s. If a broker exceeds this
// duration, it will reply with a request timeout error.
//
// This somewhat corresponds to Kafka's request.timeout.ms setting, but only
// applies to produce requests. This settings sets the TimeoutMillis field in
// the produce request itself. The RequestTimeoutOverhead is applied as a write
// limit and read limit in addition to this.
func ProduceRequestTimeout(limit time.Duration) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.produceTimeout = limit }}
}

// RecordRetries sets the number of tries for producing records, overriding the
// unlimited default.
//
// If idempotency is enabled (as it is by default), this option is only
// enforced if it is safe to do so without creating invalid sequence numbers.
// It is safe to enforce if a record was never issued in a request to Kafka, or
// if it was requested and received a response.
//
// If a record fails due to retries, all records buffered in the same partition
// are failed as well. This ensures gapless ordering: the client will not fail
// one record only to produce a later one successfully. This also allows for
// easier sequence number ordering internally.
//
// If a topic repeatedly fails to load with UNKNOWN_TOPIC_OR_PARTITION, it has
// a different limit (the UnknownTopicRetries option). All records for a topic
// that repeatedly cannot be loaded are failed when that limit is hit.
//
// This option is different from RequestRetries to allow finer grained control
// of when to fail when producing records.
func RecordRetries(n int) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.recordRetries = int64(n) }}
}

// UnknownTopicRetries sets the number of times a record can fail with
// UNKNOWN_TOPIC_OR_PARTITION, overriding the default 4.
//
// This is a separate limit from RecordRetries because unknown topic or
// partition errors should only happen if the topic does not exist. It is
// pointless for the client to continue producing to a topic that does not
// exist, and if we repeatedly see that the topic does not exist across
// multiple metadata queries (which are going to different brokers), then we
// may as well stop trying and fail the records.
//
// If this is -1, the client never fails records with this error.
func UnknownTopicRetries(n int) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.maxUnknownFailures = int64(n) }}
}

// StopProducerOnDataLossDetected sets the client to stop producing if data
// loss is detected, overriding the default false.
//
// Note that if using this option, it is strongly recommended to not have a
// retry limit. Doing so may lead to errors where the client fails a batch on a
// recoverable error, which internally bumps the idempotent sequence number
// used for producing, which may then later cause an inadvertent out of order
// sequence number and false "data loss" detection.
func StopProducerOnDataLossDetected() ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.stopOnDataLoss = true }}
}

// ProducerOnDataLossDetected sets a function to call if data loss is detected
// when producing records if the client is configured to continue on data loss.
// Thus, this option is mutually exclusive with StopProducerOnDataLossDetected.
//
// The passed function will be called with the topic and partition that data
// loss was detected on.
func ProducerOnDataLossDetected(fn func(string, int32)) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.onDataLoss = fn }}
}

// ProducerLinger sets how long individual topic partitions will linger waiting
// for more records before triggering a request to be built.
//
// If a produce request is triggered by any topic partition, all partitions
// with a possible batch to be sent are used and all lingers are reset.
func ProducerLinger(linger time.Duration) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.linger = linger }}
}

// ManualFlushing disables auto-flushing when producing. While you can still
// set lingering, it would be useless to do so.
//
// With manual flushing, producing while MaxBufferedRecords or MaxBufferedBytes
// have already been produced and not flushed will return ErrMaxBuffered.
func ManualFlushing() ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.manualFlushing = true }}
}

// RecordDeliveryTimeout sets a rough time of how long a record can sit around
// in a batch before timing out, overriding the unlimited default.
//
// If idempotency is enabled (as it is by default), this option is only
// enforced if it is safe to do so without creating invalid sequence numbers.
// It is safe to enforce if a record was never issued in a request to Kafka, or
// if it was requested and received a response.
//
// The timeout for all records in a batch inherit the timeout of the first
// record in that batch. That is, once the first record's timeout expires, all
// records in the batch are expired. This generally is a non-issue unless using
// this option with lingering. In that case, simply add the linger to the
// record timeout to avoid problems.
//
// If a record times out, all records buffered in the same partition are failed
// as well. This ensures gapless ordering: the client will not fail one record
// only to produce a later one successfully. This also allows for easier
// sequence number ordering internally.
//
// The timeout is only evaluated before writing a request or after a
// produce response. Thus, a sink backoff may delay record timeout slightly.
//
// This option is roughly equivalent to delivery.timeout.ms.
func RecordDeliveryTimeout(timeout time.Duration) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.recordTimeout = timeout }}
}

// TransactionalID sets a transactional ID for the client, ensuring that
// records are produced transactionally under this ID (exactly once semantics).
//
// For Kafka-to-Kafka transactions, the transactional ID is only one half of
// the equation. You must also assign a group to consume from.
//
// To produce transactionally, you first [BeginTransaction], then produce records
// consumed from a group, then you [EndTransaction]. All records produced outside
// of a transaction will fail immediately with an error.
//
// After producing a batch, you must commit what you consumed. Auto committing
// offsets is disabled during transactional consuming / producing.
//
// Note that unless using Kafka 2.5, a consumer group rebalance may be
// problematic. Production should finish and be committed before the client
// rejoins the group. It may be safer to use an eager group balancer and just
// abort the transaction. Alternatively, any time a partition is revoked, you
// could abort the transaction and reset offsets being consumed.
//
// If the client detects an unrecoverable error, all records produced
// thereafter will fail.
//
// Lastly, the default read level is READ_UNCOMMITTED. Be sure to use the
// ReadIsolationLevel option if you want to only read committed.
func TransactionalID(id string) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.txnID = &id }}
}

// TransactionTimeout sets the allowed for a transaction, overriding the
// default 40s. It is a good idea to keep this less than a group's session
// timeout, so that a group member will always be alive for the duration of a
// transaction even if connectivity dies. This helps prevent a transaction
// finishing after a rebalance, which is problematic pre-Kafka 2.5. If you
// are on Kafka 2.5+, then you can use the RequireStableFetchOffsets option
// when assigning the group, and you can set this to whatever you would like.
//
// Transaction timeouts begin when the first record is produced within a
// transaction, not when a transaction begins.
func TransactionTimeout(timeout time.Duration) ProducerOpt {
	return producerOpt{func(cfg *cfg) { cfg.txnTimeout = timeout }}
}

////////////////////////////
// CONSUMER CONFIGURATION //
////////////////////////////

// FetchMaxWait sets the maximum amount of time a broker will wait for a
// fetch response to hit the minimum number of required bytes before returning,
// overriding the default 5s (or 500ms when [ShareGroup] is set).
//
// This corresponds to the Java fetch.max.wait.ms setting.
//
// For share consumers, values above 500ms are not recommended: while a
// ShareFetch is in flight, no acks can be sent to the broker, so a long
// MaxWait stalls ack delivery for that entire window.
func FetchMaxWait(wait time.Duration) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.maxWait = int32(wait.Milliseconds()) }}
}

// FetchMaxBytes sets the maximum amount of bytes a broker will try to send
// during a fetch, overriding the default 50MiB. Note that brokers may not obey
// this limit if it has records larger than this limit. Also note that this
// client sends a fetch to each broker concurrently, meaning the client will
// buffer up to <brokers * max bytes> worth of memory.
//
// This corresponds to the Java fetch.max.bytes setting.
//
// If bumping this, consider bumping BrokerMaxReadBytes.
//
// If what you are consuming is compressed, and compressed well, it is strongly
// recommended to set this option so that decompression does not eat all of
// your RAM.
func FetchMaxBytes(b int32) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.maxBytes = lazyI32(b) }}
}

// FetchMinBytes sets the minimum amount of bytes a broker will try to send
// during a fetch, overriding the default 1 byte.
//
// With the default of 1, data is sent as soon as it is available. By bumping
// this, the broker will try to wait for more data, which may improve server
// throughput at the expense of added latency.
//
// This corresponds to the Java fetch.min.bytes setting.
func FetchMinBytes(b int32) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.minBytes = b }}
}

// FetchMaxPartitionBytes sets the maximum amount of bytes that will be
// consumed for a single partition in a fetch request, overriding the default
// 1MiB. Note that if a single batch is larger than this number, that batch
// will still be returned so the client can make progress.
//
// This corresponds to the Java max.partition.fetch.bytes setting.
func FetchMaxPartitionBytes(b int32) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.maxPartBytes = lazyI32(b) }}
}

// MaxConcurrentFetches sets the maximum number of fetch requests to allow in
// flight or buffered at once, overriding the unbounded (i.e. number of
// brokers) default.
//
// This setting, paired with FetchMaxBytes, can upper bound the maximum amount
// of memory that the client can use for consuming.
//
// Requests are issued to brokers in a FIFO order: once the client is ready to
// issue a request to a broker, it registers that request and issues it in
// order with other registrations.
//
// If Kafka replies with any data, the client does not track the fetch as
// completed until the user has polled the buffered fetch. Thus, a concurrent
// fetch is not considered complete until all data from it is done being
// processed and out of the client itself.
//
// Note that brokers are allowed to hang for up to FetchMaxWait before replying
// to a request, so if this option is too constrained and you are consuming a
// low throughput topic, the client may take a long time before requesting a
// broker that has new data. For high throughput topics, or if the allowed
// concurrent fetches is large enough, this should not be a concern.
//
// Negative values imply unlimited concurrent fetches (bounded by the number of
// brokers in the cluster). A value of 0 means that a single fetch is allowed
// ONLY when you poll - there is no fetch buffering.
func MaxConcurrentFetches(n int) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.maxConcurrentFetches = n }}
}

// ConsumeStartOffset sets the offset to start consuming from when consuming a
// partition for the first time. If you do not set [ConsumeResetOffset], this
// is also the offset to reset to if the client sees an OffsetOutOfRange error
// while consuming a partition. The default is NewOffset().AtStart(), i.e.,
// start processing a partition from the earliest offset. If using this option,
// it is strongly recommended to also set ConsumeResetOffset.
//
// If you use an exact or relative offsets and the offset ends up out of range,
// the client chooses the nearest of either the log start offset or the log end
// offset. For example, using At(3) when the partition starts at 8 results in
// the partition being consumed from offset 8.
//
// For group consuming, you can use [Offset.AtCommitted] to prevent starting
// consuming a partition in a group if the partition has no prior commits.
//
// The following determines the offset for when a partition is seen for the
// first time:
//
//	at start?                         => start at the log start offset
//	at end?                           => start at the log end offset
//	at exact?                         => start at an exact offset (3 means offset 3)
//	relative?                         => start at the above, + / - the relative amount
//	exact/relative are out of bounds? => start at the nearest boundary (start or end)
//	after millisec?                   => start at first offset after millisec if one exists, else log end offset
//
// To match Kafka's auto.offset.reset which is used for both the start offset
// and the reset offset,
//
//	NewOffset().AtStart()     == auto.offset.reset "earliest"
//	NewOffset().AtEnd()       == auto.offset.reset "latest"
//	NewOffset().AtCommitted() == auto.offset.reset "none"
//
// Be sure to check the documentation for [ConsumeResetOffset], especially if
// you rely on this option as the reset offset as well.
func ConsumeStartOffset(offset Offset) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.startOffset, cfg.setStartOffset = offset, true }}
}

// ConsumeResetOffset sets the offset to reset to if the client ever sees
// OffsetOutOfRange while fetching. If you do not set [ConsumeStartOffset],
// this is also the offset to start consuming from when consuming a partition
// for the first time. The default is NewOffset().AtStart(), i.e., reset to the
// earliest offset. If using this option, it is strongly recommended to also
// set ConsumeStartOffset.
//
// This option is *only* used if a consumer seeds OffsetOutOfRange on the
// *first* fetch of a partition. If the consumer has consumed the partition at
// all and sees the error, it will automatically reset to the first offset
// after the timestamp of the last successfully consumed offset. If data loss
// occurred such that even the last successfully consumed offset is lost, the
// client automatically resets to the new current end offset. If you want to
// disable offset resetting entirely, you can use [NoResetOffset].
//
// If you use an exact or relative offsets and the offset ends up out of range,
// the client chooses the nearest of either the log start offset or the log end
// offset. For example, using At(3) when the partition starts at 8 results in
// the partition being consumed from offset 8.
//
// The following determines the offset for when a partition is seen for the
// first time, or reset while fetching:
//
//	at start?                         => reset to the log start offset
//	at end?                           => reset to the log end offset
//	at exact?                         => reset to an exact offset (3 means offset 3)
//	relative?                         => reset to the above, + / - the relative amount
//	exact/relative are out of bounds? => reset to the nearest boundary (start or end)
//	after millisec?                   => reset to the first offset after millisec if one exists, else the log end offset
//
// To match Kafka's auto.offset.reset,
//
//	NewOffset().AtStart()     == auto.offset.reset "earliest"
//	NewOffset().AtEnd()       == auto.offset.reset "latest"
//	NewOffset().AtCommitted() == auto.offset.reset "none"
//
// With the above, make sure to use [NoResetOffset] if you want to stop
// consuming when you encounter OffsetOutOfRange. It is highly recommended
// to read the docs for all Offset methods.
//
// Be sure to check the documentation for [ConsumeStartOffset], especially if
// you rely on this option as the start offset as well.
func ConsumeResetOffset(offset Offset) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.resetOffset, cfg.setResetOffset = offset, true }}
}

// Rack specifies where the client is physically located and changes fetch
// requests to consume from the closest replica as opposed to the leader
// replica.
//
// Consuming from a preferred replica can increase latency but can decrease
// cross datacenter costs. See KIP-392 for more information.
func Rack(rack string) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.rack = rack }}
}

// IsolationLevel controls whether uncommitted or only committed records are
// returned from fetch requests.
type IsolationLevel struct {
	level int8
}

// ReadUncommitted (the default) is an isolation level that returns the latest
// produced records, be they committed or not.
func ReadUncommitted() IsolationLevel { return IsolationLevel{0} }

// ReadCommitted is an isolation level to only fetch committed records.
func ReadCommitted() IsolationLevel { return IsolationLevel{1} }

// FetchIsolationLevel sets the "isolation level" used for fetching
// records, overriding the default ReadUncommitted.
func FetchIsolationLevel(level IsolationLevel) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.isolationLevel = level.level }}
}

// KeepControlRecords sets the client to keep control messages and return
// them with fetches, overriding the default that discards them.
//
// Generally, control messages are not useful.
func KeepControlRecords() ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.keepControl = true }}
}

// ConsumeTopics adds topics to use for consuming.
//
// By default, consuming will start at the beginning of partitions. To change
// this, use the ConsumeResetOffset option.
func ConsumeTopics(topics ...string) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) {
		cfg.topics = make(map[string]*regexp.Regexp, len(topics))
		for _, topic := range topics {
			cfg.topics[topic] = nil
		}
	}}
}

// ConsumePartitions sets partitions to consume from directly and the offsets
// to start consuming those partitions from.
//
// This option is basically a way to explicitly consume from subsets of
// partitions in topics, or to consume at exact offsets. Offsets from this
// option have higher precedence than the ConsumeResetOffset.
//
// This option is not compatible with group consuming and regex consuming. If
// you want to assign partitions directly, but still use Kafka to commit
// offsets, check out the kadm package's FetchOffsets and CommitOffsets
// methods. These will allow you to commit as a group outside the context of a
// Kafka group.
func ConsumePartitions(partitions map[string]map[int32]Offset) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.partitions = partitions }}
}

// ConsumeRegex sets the client to parse all topics passed to ConsumeTopics as
// regular expressions. You can further use ConsumeExcludeTopics to exclude
// topics that would match any ConsumeTopics regex.
//
// When consuming via regex, every metadata request loads *all* topics, so that
// all topics can be passed to any regular expressions. Every topic is
// evaluated only once ever across all regular expressions; either it
// permanently is known to match, or is permanently known to not match.
func ConsumeRegex() ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.regex = true }}
}

// ConsumeExcludeTopics sets topics to exclude when using regex consumption.
// This option only has effect when ConsumeRegex is enabled.
//
// Topics matching any of the provided regular expressions will be excluded from
// consumption, even if they match patterns provided to ConsumeTopics.
//
// When using the next-gen consumer group protocol (not yet enabled by default),
// the heartbeat's SubscribedTopicRegex field is include-only with no exclude
// counterpart. If exclude topics are configured, the client resolves regex
// matching locally and sends explicit topic names instead. This means new
// topics matching the include regex are discovered on the next metadata
// refresh rather than immediately by the broker.
func ConsumeExcludeTopics(topics ...string) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) {
		if cfg.excludeTopics == nil {
			cfg.excludeTopics = make(map[string]*regexp.Regexp, len(topics))
		}
		for _, topic := range topics {
			cfg.excludeTopics[topic] = nil
		}
	}}
}

// DisableFetchSessions sets the client to not use fetch sessions (Kafka 1.0+).
//
// A "fetch session" is a way to reduce bandwidth for fetch requests &
// responses, and to potentially reduce the amount of work that brokers have to
// do to handle fetch requests. A fetch session opts into the broker tracking
// some state of what the client is interested in. For example, say that you
// are interested in thousands of topics, and most of these topics are
// receiving data only rarely. A fetch session allows the client to register
// that it is interested in those thousands of topics on the first request.  On
// future requests, if the offsets for these topics have not changed, those
// topics will be elided from the request. The broker knows to reply with the
// extra topics if any new data is available, otherwise the topics are also
// elided from the response. This massively reduces the amount of information
// that needs to be included in requests or responses.
//
// Using fetch sessions means more state is stored on brokers. Maintaining this
// state eats some memory. If you have thousands of consumers, you may not want
// fetch sessions to be used for everything. Brokers intelligently handle this
// by not creating sessions if they are at their configured limit, but you may
// consider disabling sessions if they are generally not useful to you. Brokers
// have metrics for the number of fetch sessions active, so you can monitor
// that to determine whether enabling or disabling sessions is beneficial or
// not.
//
// For more details on fetch sessions, see KIP-227.
func DisableFetchSessions() ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.disableFetchSessions = true }}
}

// ConsumePreferringLagFn allows you to re-order partitions before they are
// fetched, given each partition's current lag.
//
// By default, the client rotates partitions fetched by one after every fetch
// request. Kafka answers fetch requests in the order that partitions are
// requested, filling the fetch response until FetchMaxBytes and
// FetchMaxPartitionBytes are hit. All partitions eventually rotate to the
// front, ensuring no partition is starved.
//
// With this option, you can return topic order and per-topic partition
// ordering. These orders will sort to the front (first by topic, then by
// partition). Any topic or partitions that you do not return are added to the
// end, preserving their original ordering.
//
// For a simple lag preference that sorts the laggiest topics and partitions
// first, use `kgo.ConsumePreferringLagFn(kgo.PreferLagAt(50))` (or some other
// similar lag number).
func ConsumePreferringLagFn(fn PreferLagFn) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.preferLagFn = fn }}
}

// WithDecompressor allows you to completely control how fetch batches are
// decompressed, allowing you to use alternative libraries than what franz-go
// supports, allowing you to have more control over memory & pooling, and other
// benefits. The client default compressor is the [DefaultDecompressor].
func WithDecompressor(decompressor Decompressor) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.decompressor = decompressor }}
}

// KeepRetryableFetchErrors switches the client to always return any retryable
// broker error when fetching, rather than stripping them. By default, the
// client strips retryable errors from fetch responses; these are usually
// signals that a client needs to update its metadata to learn of where a
// partition has moved to (from one broker to another), or they are signals
// that one broker is temporarily unhealthy (broker not available). You can opt
// into keeping these errors if you want to specifically react to certain
// events. For example, if you want to react to you yourself deleting a topic,
// you can watch for either UNKNOWN_TOPIC_OR_PARTITION or UNKNOWN_TOPIC_ID
// errors being returned in fetches (and ignore the other errors).
func KeepRetryableFetchErrors() ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.keepRetryableFetchErrors = true }}
}

// DisableFetchCRCValidation disables crc32 checksum validation when fetching.
// This should only be used if you are working with a broker that does not
// properly support CRCs in record batches.
func DisableFetchCRCValidation() ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.disableFetchCRCValidation = true }}
}

// RecheckPreferredReplicaInterval configures how long the consumer should
// fetch from a preferred replica before switching back to the leader.
// Periodically switching back to the leader allows the leader to re-choose a
// perhaps better preferred replica (say you added a new cluster, or added
// nodes to an existing cluster, or something else changed). For implementation
// simplicity, the interval is checked after fetch responses, meaning one more
// request can be issued after the interval has elapsed.
//
// The default interval is 30 minutes.
func RecheckPreferredReplicaInterval(interval time.Duration) ConsumerOpt {
	return consumerOpt{func(cfg *cfg) { cfg.recheckPreferredReplicaInterval = interval }}
}

//////////////////////////////////
// CONSUMER GROUP CONFIGURATION //
//////////////////////////////////

// ConsumerGroup sets the consumer group for the client to join and consume in.
// This option is required if using any other group options.
//
// Note that when group consuming, the default is to autocommit every 5s. To be
// safe, autocommitting only commits what is *previously* polled. If you poll
// once, nothing will be committed. If you poll again, the first poll is
// available to be committed. This ensures at-least-once processing, but does
// mean there is likely some duplicate processing during rebalances. When your
// client shuts down, you should issue one final synchronous commit before
// leaving the group (because you will not be polling again, and you are not
// waiting for an autocommit).
func ConsumerGroup(group string) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.group = group }}
}

// ShareGroup sets the share group for the client to join and consume in.
// Share groups (KIP-932) provide queue-like semantics: the broker controls
// record delivery and offset management.
//
// This is mutually exclusive with ConsumerGroup and ConsumePartitions.
//
// For existing topics to be consumable via a share group, the group-level
// configuration share.auto.offset.reset must be set using
// IncrementalAlterConfigs with resource type GROUP. Without this, share
// groups default to "latest" and only records produced after the group
// begins consuming are delivered.
func ShareGroup(group string) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.shareGroup = group }}
}

// ShareMaxRecords sets the MaxRecords and BatchSize fields in ShareFetch
// requests, overriding the default of 500.
//
// This option only applies when using [ShareGroup].
func ShareMaxRecords(n int32) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.shareMaxRecords = n }}
}

// ShareMaxRecordsStrict opts into strict record-count limiting for share
// fetch requests. By default, the broker may return more records than
// [ShareMaxRecords] to avoid splitting record batches. With this option
// the broker returns at most [ShareMaxRecords] records per fetch,
// splitting batches if necessary.
//
// As a secondary effect, this option also flips the [MaxConcurrentFetches]
// default from unbounded to 0 (one fetch per poll). Setting
// [MaxConcurrentFetches] to a positive value re-enables pre-fetching;
// note that the per-fetch limit still holds but in-flight concurrent
// fetches can cumulatively return more.
//
// This option only applies when using [ShareGroup].
func ShareMaxRecordsStrict() GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.shareMaxRecordsStrict = true }}
}

// ShareAckCallback sets a callback invoked with the results of share-group
// acknowledgements. The broker reports per-partition outcomes for every
// ack the client sends.
//
// Without a callback, ack errors are invisible: retryable errors are
// retried internally and non-retryable errors are dropped. The callback
// lets you observe all ack outcomes. The client has already handled
// retries where possible, so errors surfaced here are mostly
// informational.
//
// This option only applies when using [ShareGroup].
func ShareAckCallback(fn func(*Client, ShareAckResults)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.shareAckCallback = fn }}
}

// Balancers sets the group balancers to use for dividing topic partitions
// among group members, overriding the current default [cooperative-sticky].
// This option is equivalent to Kafka's partition.assignment.strategies option.
//
// For balancing, Kafka chooses the first protocol that all group members agree
// to support.
//
// Note that if you opt into cooperative-sticky rebalancing, cooperative group
// balancing is incompatible with eager (classical) rebalancing and requires a
// careful rollout strategy (see KIP-429).
func Balancers(balancers ...GroupBalancer) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.balancers = balancers }}
}

// SessionTimeout sets how long a member in the group can go between
// heartbeats, overriding the default 45,000ms. If a member does not heartbeat
// in this timeout, the broker will remove the member from the group and
// initiate a rebalance.
//
// If you are using a [GroupTransactSession] for EOS, wish to lower this, and are
// talking to a Kafka cluster pre 2.5, consider lowering the
// TransactionTimeout. If you do not, you risk a transaction finishing after a
// group has rebalanced, which could lead to duplicate processing. If you are
// talking to a Kafka 2.5+ cluster, you can safely use the
// RequireStableFetchOffsets group option and prevent any problems.
//
// This option corresponds to Kafka's session.timeout.ms setting and must be
// within the broker's group.min.session.timeout.ms and
// group.max.session.timeout.ms.
func SessionTimeout(timeout time.Duration) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.sessionTimeout = timeout }}
}

// RebalanceTimeout sets how long group members are allowed to take when a a
// rebalance has begun, overriding the default 60,000ms. This timeout is how
// long all members are allowed to complete work and commit offsets, minus the
// time it took to detect the rebalance (from a heartbeat).
//
// Kafka uses the largest rebalance timeout of all members in the group. If a
// member does not rejoin within this timeout, Kafka will kick that member from
// the group.
//
// This corresponds to Kafka's rebalance.timeout.ms.
func RebalanceTimeout(timeout time.Duration) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.rebalanceTimeout = timeout }}
}

// HeartbeatInterval sets how long a group member goes between heartbeats to
// Kafka, overriding the default 3,000ms.
//
// Kafka uses heartbeats to ensure that a group member's session stays active.
// This value can be any value lower than the session timeout, but should be no
// higher than 1/3rd the session timeout.
//
// This corresponds to Kafka's heartbeat.interval.ms.
func HeartbeatInterval(interval time.Duration) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.heartbeatInterval = interval }}
}

// RequireStableFetchOffsets previously set the group consumer to require
// "stable" fetch offsets before consuming from the group.
//
// Deprecated: RequireStable is now permanently enabled for all group
// consumers. This function is a no-op.
func RequireStableFetchOffsets() GroupOpt {
	return groupOpt{func(*cfg) {}}
}

// BlockRebalanceOnPoll switches the client to block rebalances whenever you
// receive a non-empty result from polling until you explicitly call
// AllowRebalance. This option also ensures that any
// OnPartitions{Assigned,Revoked,Lost} callbacks are only called when you allow
// rebalances; they cannot be called if you have polled and are processing
// records.
//
// By default, a consumer group is managed completely independently of
// consuming. A rebalance may occur at any moment. If you poll records, and
// then a rebalance happens, and then you commit, you may be committing to
// partitions you no longer own. This will result in duplicates. In the worst
// case, you could rewind commits that a different member has already made
// (risking duplicates if another rebalance were to happen before that other
// member commits again).
//
// By blocking rebalancing after you poll until you call AllowRebalances, you
// can be sure that you commit records that your member currently owns.
// However, the big tradeoff is that by blocking rebalances, you put your group
// member at risk of waiting so long that the group member is kicked from the
// group because it exceeded the rebalance timeout. To compare clients, Sarama
// takes the default choice of blocking rebalancing; this option makes kgo more
// similar to Sarama.
//
// If you use this option, you should ensure that you always process records
// quickly, and that your OnPartitions{Assigned,Revoked,Lost} callbacks are
// fast. If your record processing may be slow, it is recommended you also use
// PollRecords rather than PollFetches so that you can bound how many records
// you process at once. You must always AllowRebalances when you are done
// processing the records you received.
//
// Polls and rebalance callbacks gate each other so that you cannot commit
// offsets across a rebalance: polls block while a callback is running, and
// callbacks wait for in-flight polls to release via AllowRebalance before
// running. The assign phase is gated only when you registered
// [OnPartitionsAssigned]; otherwise it runs concurrent with poll because
// adding partitions cannot cause a wrong-ownership commit.
//
// You can use [OnPartitionsCallbackBlocked] as a signal that a rebalance WANTS
// to happen, but you are currently blocking it, and that you need to either
// finish processing or abort processing to allow the rebalance to continue.
func BlockRebalanceOnPoll() GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.blockRebalanceOnPoll = true }}
}

// AdjustFetchOffsetsFn sets the function to be called when a group is joined
// after offsets are fetched so that a user can adjust offsets before
// consumption begins.
//
// This function should not exceed the rebalance interval. It is possible
// for the group, immediately after finishing a balance, to re-enter a new balancing
// session. This function is passed a context that is canceled if the current group
// session finishes (i.e., after revoking).
//
// If you are resetting the position of the offset, you may want to clear any existing
// "epoch" with WithEpoch(-1). If the epoch is non-negative, the client performs
// data loss detection, which may result in errors and unexpected behavior.
//
// This function is called after OnPartitionsAssigned and may be called before
// or after OnPartitionsRevoked.
func AdjustFetchOffsetsFn(adjustOffsetsBeforeAssign func(context.Context, map[string]map[int32]Offset) (map[string]map[int32]Offset, error)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.adjustOffsetsBeforeAssign = adjustOffsetsBeforeAssign }}
}

// OnPartitionsAssigned sets the function to be called when a group is joined
// after partitions are assigned before fetches for those partitions begin.
//
// This function, combined with OnPartitionsRevoked, should not exceed the
// rebalance interval. It is possible for the group to re-enter a new balancing
// session immediately after finishing a balance.
//
// This function is passed the client's context, which is only canceled if the
// client is closed.
//
// This function is not called concurrent with any other OnPartitions callback,
// and this function is given a new map that the user is free to modify.
//
// This function can be called at any time you are polling or processing
// records. If you want to ensure this function is called serially with
// processing, consider the BlockRebalanceOnPoll option.
func OnPartitionsAssigned(onAssigned func(context.Context, *Client, map[string][]int32)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.onAssigned = onAssigned }}
}

// OnPartitionsRevoked sets the function to be called once this group member
// has partitions revoked.
//
// This function, combined with [OnPartitionsAssigned], should not exceed the
// rebalance interval. It is possible for the group to re-enter a new balancing
// session immediately after finishing a balance.
//
// If autocommit is enabled, the default OnPartitionsRevoked is a blocking
// commit of all non-dirty offsets (where "dirty" is the most recent poll).
//
// The OnPartitionsRevoked function is passed the client's context, which is
// only canceled if the client is closed. OnPartitionsRevoked function is
// called at the end of a group session even if there are no partitions being
// revoked. If you are committing offsets manually (have disabled
// autocommitting), it is highly recommended to do a proper blocking commit in
// OnPartitionsRevoked.
//
// This function is not called concurrent with any other OnPartitions callback,
// and this function is given a new map that the user is free to modify.
//
// This function can be called at any time you are polling or processing
// records. If you want to ensure this function is called serially with
// processing, consider the BlockRebalanceOnPoll option. It is guaranteed
// that once the callback has completed any subsequent polls will not return
// records for the revoked partitions.
//
// This function is called if a "fatal" group error is encountered and you have
// not set [OnPartitionsLost]. See OnPartitionsLost for more details.
func OnPartitionsRevoked(onRevoked func(context.Context, *Client, map[string][]int32)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.onRevoked = onRevoked }}
}

// OnPartitionsLost sets the function to be called on "fatal" group errors,
// such as IllegalGeneration, UnknownMemberID, and authentication failures.
// This function differs from [OnPartitionsRevoked] in that it is unlikely that
// commits will succeed when partitions are outright lost, whereas commits
// likely will succeed when revoking partitions.
//
// Because this function is called on any fatal group error, it is possible for
// this function to be called without the group ever being joined.
//
// This function is not called concurrent with any other OnPartitions callback,
// and this function is given a new map that the user is free to modify.
//
// This function can be called at any time you are polling or processing
// records. If you want to ensure this function is called serially with
// processing, consider the BlockRebalanceOnPoll option.
func OnPartitionsLost(onLost func(context.Context, *Client, map[string][]int32)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.onLost = onLost }}
}

// OnPartitionsCallbackBlocked sets a function to be called just before any
// [OnPartitionsAssigned], [OnPartitionsRevoked], or [OnPartitionsLost]
// callbacks are blocked from [BlockRebalanceOnPoll]. You can use this as a
// signal in your processing function to hurry up and unblock rebalancing
// before your group member is kicked from the group at the session timeout.
//
// Since this is meant to be a signal to blocking operations, and to ensure
// nothing can block this signal, this callback is called in a goroutine.
func OnPartitionsCallbackBlocked(fn func(context.Context, *Client)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.onBlocked = fn }}
}

// OnOffsetsFetched sets a function to be called after offsets have been
// fetched after a group has been balanced. This function is meant to allow
// users to inspect offset commit metadata. An error can be returned to exit
// this group session and exit back to join group.
//
// This function should not exceed the rebalance interval. It is possible for
// the group, immediately after finishing a balance, to re-enter a new
// balancing session. This function is passed a context that is canceled if the
// current group session finishes (i.e., after revoking).
//
// This function is called after OnPartitionsAssigned and may be called before
// or after OnPartitionsRevoked.
func OnOffsetsFetched(onFetched func(context.Context, *Client, *kmsg.OffsetFetchResponse) error) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.onFetched = onFetched }}
}

// DisableAutoCommit disable auto committing.
//
// If you disable autocommitting, you may want to use a custom
// OnPartitionsRevoked, otherwise you may end up doubly processing records
// (which is fine, just leads to duplicate processing). Consider the scenario:
// you, member A, are processing partition 0, and previously committed offset 4
// and have now locally processed through offset 30. A rebalance happens, and
// partition 0 moves to member B. If you use OnPartitionsRevoked, you can
// detect that you are losing this partition and commit your work through
// offset 30, so that member B can start processing at offset 30. If you do not
// commit (i.e. you do not use a custom OnPartitionsRevoked), the other member
// will start processing at offset 4. It may process through offset 50, leading
// to double processing of offsets 4 through 29. Worse, you, member A, can
// rewind member B's commit, because member B may commit offset 50 and you may
// finally eventually commit offset 30. If a rebalance happens, then even more
// duplicate processing will occur of offsets 30 through 49.
//
// Again, OnPartitionsRevoked is not necessary, and not using it just means
// double processing, which for most workloads is fine since a simple group
// consumer is not EOS / transactional, only at-least-once. But, this is
// something to be aware of.
func DisableAutoCommit() GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.autocommitDisable = true }}
}

// GreedyAutoCommit opts into committing everything that has been polled when
// autocommitting (the dirty offsets), rather than committing what has
// previously been polled. This option may result in message loss if your
// application crashes.
func GreedyAutoCommit() GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.autocommitGreedy = true }}
}

// AutoCommitInterval sets how long to go between autocommits, overriding the
// default 5s.
func AutoCommitInterval(interval time.Duration) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.autocommitInterval = interval }}
}

// AutoCommitMarks switches the autocommitting behavior to only commit "marked"
// records, which can be done with the MarkCommitRecords method.
//
// This option is basically a halfway point between autocommitting and manually
// committing. If you have slow batch processing of polls, then you can
// manually mark records to be autocommitted before you poll again. This way,
// if you usually take a long time between polls, your partial work can still
// be automatically checkpointed through autocommitting.
func AutoCommitMarks() GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.autocommitMarks = true }}
}

// InstanceID sets the group consumer's instance ID, switching the group member
// from "dynamic" to "static".
//
// Prior to Kafka 2.3, joining a group gave a group member a new member ID.
// The group leader could not tell if this was a rejoining member. Thus, any
// join caused the group to rebalance.
//
// Kafka 2.3 introduced the concept of an instance ID, which can persist across
// restarts. This allows for avoiding many costly rebalances and allows for
// stickier rebalancing for rejoining members (since the ID for balancing stays
// the same). The main downsides are that you, the user of a client, have to
// manage instance IDs properly, and that it may take longer to rebalance in
// the event that a client legitimately dies.
//
// When using an instance ID, the client does NOT send a leave group request
// when closing. This allows for the client to restart with the same instance
// ID and rejoin the group to avoid a rebalance. It is strongly recommended to
// increase the session timeout enough to allow time for the restart (remember
// that the default session timeout is 45s).
//
// To actually leave the group, you must use an external admin command that
// issues a leave group request on behalf of this instance ID (see kcl), or you
// can manually use the kmsg package with a proper LeaveGroupRequest.
//
// NOTE: Leaving a group with an instance ID is only supported in Kafka 2.4+.
//
// NOTE: If you restart a consumer group leader that is using an instance ID,
// it will not cause a rebalance even if you change which topics the leader is
// consuming. If your cluster is 3.2+, this client internally works around this
// limitation and you do not need to trigger a rebalance manually.
func InstanceID(id string) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.instanceID = &id }}
}

// GroupProtocol sets the group's join protocol, overriding the default value
// "consumer". The only reason to override this is if you are implementing
// custom join and sync group logic.
func GroupProtocol(protocol string) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.protocol = protocol }}
}

// AutoCommitCallback sets the callback to use if autocommitting is enabled.
// This overrides the default callback that logs errors and continues.
func AutoCommitCallback(fn func(*Client, *kmsg.OffsetCommitRequest, *kmsg.OffsetCommitResponse, error)) GroupOpt {
	return groupOpt{func(cfg *cfg) { cfg.commitCallback = fn }}
}

// !!! Only uncomment once we trust the broker implementation!
// !!! And add this option to Opt!
//
// DisableNextGenRebalancer opts out of the "next gen" rebalancer that is
// the default as of Kafka 4.0+. The client opts in to the next gen rebalancer
// automatically if the broker supports it AND if you are using either the
// [RangeBalancer] or [StickyBalancer] or [CooperativeStickyBalancer]. If you
// use your own rebalancer or use the [RoundRobinBalancer] or are talking to
// a broker that does not support the next gen balancer, the client uses the
// old client-driven group balancing behavior.
//
// You may want to use this function if you notice a regression or run into
// a broker or client bug, or if you prefer the performance of the old
// client driven rebalancers.
//  func DisableNextGenRebalancer() GroupOpt {
//  	return groupOpt{func(cfg *cfg) { cfg.disableNextGenBalancer = true }}
//  }
//
