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

package feasible

import (
	"fmt"
	"strings"

	"math"

	"github.com/hashicorp/go-set/v3"
	"github.com/hashicorp/nomad/client/lib/numalib"
	"github.com/hashicorp/nomad/nomad/structs"
	psstructs "github.com/hashicorp/nomad/plugins/shared/structs"
)

// deviceAllocator is used to allocate devices to allocations. The allocator
// tracks availability as to not double allocate devices.
type deviceAllocator struct {
	*structs.DeviceAccounter

	ctx Context
}

// newDeviceAllocator returns a new device allocator. The node is used to
// populate the set of available devices based on what healthy device instances
// exist on the node.
func newDeviceAllocator(ctx Context, n *structs.Node) *deviceAllocator {
	return &deviceAllocator{
		ctx:             ctx,
		DeviceAccounter: structs.NewDeviceAccounter(n),
	}
}

func (d *deviceAllocator) Copy() *deviceAllocator {
	accounter := d.DeviceAccounter.Copy()
	allocator := &deviceAllocator{accounter, d.ctx}
	return allocator
}

type memoryNodeMatcher struct {
	memoryNode int               // the target memory node (-1 indicates don't care)
	topology   *numalib.Topology // the topology of the candidate node
	devices    *set.Set[string]  // the set of devices requiring numa associativity
}

// equalBusID will compare the instance specific device bus id values in a way
// that handles non-uniform domain strings (e.g. "0000" vs "00000000").
//
// e.g. 0000:03:00.1 is equal to 00000000:03.00.1
func equalBusID(a, b string) bool {
	if a == b {
		return true
	}
	noDomainA := strings.TrimLeft(a, "0")
	noDomainB := strings.TrimLeft(b, "0")
	return noDomainA == noDomainB
}

// Matches returns whether the given device instance is on a PCI bus that is
// on the same NUMA node as the memory node of the matcher.
//
// instanceID is something like "GPU-6b5fa173-5fa6-2d38-54fe-d64c1fe4fe10"
//
// device is the grouping of device instance this instance belongs to and is
// how we find the pci bus locality.
func (m *memoryNodeMatcher) Matches(instanceID string, device *structs.NodeDeviceResource) bool {
	// -1 is the sentinel value for not caring about the associated memory
	// node, in which case we simply treat the device as a match
	if m.memoryNode == -1 {
		return true
	}

	// if the device is not listed in the numa block of the task resources then
	// we do not care about what node is is on
	if !m.devices.Contains(device.ID().String()) {
		return true
	}

	// check if the hardware locality of the device matches the nume node of this
	// memoryNodeMatcher instance. we do so by finding the specific device of
	// the given instance id, looking at its locality, and comparing the locality
	// using equalBusID because direct == equality does not work, due to
	// differences in pci bus domain representations
	for _, instance := range device.Instances {
		if instance.ID == instanceID {
			if instance.Locality != nil {
				instanceBusID := instance.Locality.PciBusID
				for busID, node := range m.topology.BusAssociativity {
					if equalBusID(busID, instanceBusID) {
						result := int(node) == m.memoryNode
						return result
					}
				}
			}
		}
	}

	return false
}

// createOffer takes a device request and returns an assignment as well as a
// score for the assignment. If no assignment is possible, an error is
// returned explaining why.
func (d *deviceAllocator) createOffer(mem *memoryNodeMatcher, ask *structs.RequestedDevice) (out *structs.AllocatedDeviceResource, score float64, err error) {
	// Try to hot path
	if len(d.Devices) == 0 {
		return nil, 0.0, fmt.Errorf("no devices available")
	}
	if ask.Count == 0 {
		return nil, 0.0, fmt.Errorf("invalid request of zero devices")
	}

	// Hold the current best offer
	var offer *structs.AllocatedDeviceResource
	var offerScore float64
	var matchedWeights float64

	// Determine the devices that are feasible based on availability and
	// constraints
	for id, devInst := range d.Devices {
		// Check if the device works
		if !nodeDeviceMatches(d.ctx, devInst.Device, ask) {
			continue
		}

		// Check if we have enough unused instances to use this
		assignable := []string{}
		for instanceID, v := range devInst.Instances {
			if v != 0 {
				continue
			}
			if !mem.Matches(instanceID, devInst.Device) {
				continue
			}
			if d.deviceIDMatchesConstraint(instanceID, ask.Constraints, devInst.Device) {
				assignable = append(assignable, instanceID)
			}

			// Don't assign more than the ask
			if len(assignable) == int(ask.Count) {
				break
			}
		}

		// This device doesn't have enough instances
		if len(assignable) < int(ask.Count) {
			continue
		}

		// Score the choice
		var choiceScore float64

		// Track the sum of matched affinity weights in a separate variable
		// We return this if this device had the best score compared to other devices considered
		var sumMatchedWeights float64
		if l := len(ask.Affinities); l != 0 {
			totalWeight := 0.0
			for _, a := range ask.Affinities {
				// Resolve the targets
				lVal, lOk := resolveDeviceTarget(a.LTarget, devInst.Device)
				rVal, rOk := resolveDeviceTarget(a.RTarget, devInst.Device)

				totalWeight += math.Abs(float64(a.Weight))

				// Check if satisfied
				if !checkAttributeAffinity(d.ctx, a.Operand, lVal, rVal, lOk, rOk) {
					continue
				}
				choiceScore += float64(a.Weight)
				sumMatchedWeights += float64(a.Weight)
			}

			// normalize
			choiceScore /= totalWeight
		}

		// Only use the device if it is a higher score than we have already seen
		if offer != nil && choiceScore < offerScore {
			continue
		}

		// Set the new highest score
		offerScore = choiceScore

		// Set the new sum of matching affinity weights
		matchedWeights = sumMatchedWeights

		// Build the choice
		offer = &structs.AllocatedDeviceResource{
			Vendor:    id.Vendor,
			Type:      id.Type,
			Name:      id.Name,
			DeviceIDs: assignable,
		}
	}

	// Failed to find a match
	if offer == nil {
		return nil, 0.0, fmt.Errorf("no devices match request")
	}

	return offer, matchedWeights, nil
}

// deviceIDMatchesConstraint checks a device instance ID against the constraints
// to ensure we're only assigning instance IDs that match. This is a narrower
// check than nodeDeviceMatches because we've already asserted that the device
// matches and now need to filter by instance ID.
func (d *deviceAllocator) deviceIDMatchesConstraint(id string, constraints structs.Constraints, device *structs.NodeDeviceResource) bool {

	// There are no constraints to consider
	if len(constraints) == 0 {
		return true
	}

	deviceID := psstructs.NewStringAttribute(id)

	for _, c := range constraints {
		var target *psstructs.Attribute
		if c.LTarget == "${device.ids}" {
			target, _ = resolveDeviceTarget(c.RTarget, device)
		} else if c.RTarget == "${device.ids}" {
			target, _ = resolveDeviceTarget(c.LTarget, device)
		} else {
			continue
		}

		if !checkAttributeConstraint(d.ctx, c.Operand, target, deviceID, true, true) {
			return false
		}
	}

	return true
}
