// Copyright IBM Corp. 2015, 2025
// SPDX-License-Identifier: BUSL-1.1

// Package dynamic provides a way of allocating UID/GID to be used by Nomad
// tasks with no associated service users managed by the operating system.
package dynamic

import (
	"errors"
	"math/rand"
	"strconv"
	"sync"

	"github.com/hashicorp/go-set/v3"
	"github.com/hashicorp/nomad/helper"
)

var (
	ErrPoolExhausted = errors.New("users: uid/gid pool exhausted")
	ErrReleaseUnused = errors.New("users: release of unused uid/gid")
	ErrCannotParse   = errors.New("users: unable to parse uid/gid from username")
)

// none indicates no dynamic user
const none = 0

// doNotEnable indicates functionality should be disabled
const doNotEnable = -1

// A UGID is a combination User (UID) and Group (GID). Since Nomad is
// allocating these values together from the same pool it can ensure they are
// always matching values, thus encoding them with one value.
type UGID int

// String returns the string representation of a UGID.
//
// It's just the numbers.
func (id UGID) String() string {
	return strconv.Itoa(int(id))
}

// A Pool is used to manage a reserved set of UID/GID values. A UGID can be
// acquired from the pool and released back to the pool. To support client
// restarts, specific UGIDs can be marked as in-use and can later be released
// back into the pool.
type Pool interface {
	// Restore a UGID currently in use by a Task during a Nomad client restore.
	Restore(UGID)

	// Acquire returns a UGID that is not currently in use.
	Acquire() (UGID, error)

	// Release returns a UGID no longer being used into the pool.
	Release(UGID) error
}

// PoolConfig contains options for creating a new Pool.
type PoolConfig struct {
	// MinUGID is the minimum value for a UGID allocated from the pool.
	MinUGID int

	// MaxUGID is the maximum value for a UGID allocated from the pool.
	MaxUGID int
}

// disable will return true if either min or max is set to Disable (-1),
// indicating the client should not enable the dynamic workload users
// functionality
func (p *PoolConfig) disable() bool {
	return p.MinUGID == doNotEnable || p.MaxUGID == doNotEnable
}

// New creates a Pool with the given PoolConfig options.
func New(opts *PoolConfig) Pool {
	if opts == nil {
		panic("bug: users pool cannot be nil")
	}
	if opts.disable() {
		return new(noopPool)
	}
	if opts.MinUGID < 0 {
		panic("bug: users pool min must be >= 0")
	}
	if opts.MaxUGID < opts.MinUGID {
		panic("bug: users pool max must be >= min")
	}
	// a small but reasonable number of tasks to expect
	const defaultPoolCapacity = 32
	return &pool{
		min:  UGID(opts.MinUGID),
		max:  UGID(opts.MaxUGID),
		lock: new(sync.Mutex),
		used: set.New[UGID](defaultPoolCapacity),
	}
}

// noopPool is an implementation of Pool that does not allow acquiring ugids
type noopPool struct{}

func (*noopPool) Restore(UGID) {}
func (*noopPool) Acquire() (UGID, error) {
	return 0, errors.New("dynamic workload users disabled")
}
func (*noopPool) Release(UGID) error {
	// avoid giving an error if a client is restarted with a new config
	// that disables dynamic workload users but still has a task running
	// making use of one
	return nil
}

type pool struct {
	min UGID
	max UGID

	lock *sync.Mutex
	used *set.Set[UGID]
}

func (p *pool) Restore(id UGID) {
	helper.WithLock(p.lock, func() {
		p.used.Insert(id)
	})
}

func (p *pool) Acquire() (UGID, error) {
	p.lock.Lock()
	defer p.lock.Unlock()

	// optimize the case where the pool is exhausted
	if p.used.Size() == int((p.max-p.min)+1) {
		return none, ErrPoolExhausted
	}

	// attempt to select a random ugid
	if id := p.random(); id != none {
		return id, nil
	}

	// slow case where we iterate each id looking for one that is not used
	for id := p.min; id <= p.max; id++ {
		if !p.used.Contains(id) {
			p.used.Insert(id)
			return id, nil
		}
	}

	// we checked for this case up top; if we get here there is a bug
	panic("bug: pool exhausted")
}

// random will attempt to select a random UGID from the pool
func (p *pool) random() UGID {
	// make up to 10 attempts to find a random unused UGID
	// if all 10 attempts fail, return the sentinel indicating as much
	const maxAttempts = 10
	size := int64(p.max - p.min)
	tries := int(min(maxAttempts, size))
	for attempt := 0; attempt < tries; attempt++ {
		id := UGID(rand.Int63n(size)) + p.min
		if p.used.Insert(id) {
			return id
		}
	}
	return none
}

func (p *pool) Release(id UGID) error {
	p.lock.Lock()
	defer p.lock.Unlock()

	if !p.used.Remove(id) {
		return ErrReleaseUnused
	}

	return nil
}
