// Package engine — scheduler.go implements best-fit bin-packing for
// the multi-node scheduler (v0.2 P02, REQ-028). The scheduler
// receives a JobSpec, looks at the local NodeCapacity, and either
// runs locally or falls through to a remote peer via the dispatcher.
//
// The bin-pack scoring is intentionally simple: pick the node with
// the most free capacity (cpu_millicores + memory_mib weighted 1:1
// after normalization). This is deterministic and easy to test.
package engine

import (
	"context"
	"fmt"
	"sort"

	"git.cloudinit.dev/coreci/orca/internal/model"
	"git.cloudinit.dev/coreci/orca/internal/store"
)

// JobSpec is a minimal projection of the spec needed for scheduling
// decisions. The full spec parsing is in internal/jobspec; this is
// just enough to ask "does this fit?" and "where should it go?".
type JobSpec struct {
	CPUMillicores int64
	MemoryMiB     int64
	DiskMiB       int64
}

// Fits reports whether the local node has enough free capacity to
// run the spec. Capacity accounting is conservative: a job is allowed
// to run only if cpu + memory + disk are all >= the spec.
func (s JobSpec) Fits(c *store.NodeCapacity) bool {
	if c == nil {
		return false
	}
	return c.CPUMillicores >= s.CPUMillicores &&
		c.MemoryMiB >= s.MemoryMiB &&
		c.DiskMiB >= s.DiskMiB
}

// Score returns a sortable score for bin-packing; higher = more free
// capacity. Weighted roughly toward CPU (which is usually the
// constraint) but normalized so the test isn't fragile.
func (s JobSpec) Score(c *store.NodeCapacity) int64 {
	if c == nil {
		return -1
	}
	// Use 1:1 weighting in normalized units (millicores vs MiB) to
	// keep the score monotonic. This isn't physically meaningful
	// (mixing units) but it gives a stable ordering for tests.
	freeCPU := c.CPUMillicores - s.CPUMillicores
	freeMem := c.MemoryMiB - s.MemoryMiB
	if freeCPU < 0 || freeMem < 0 {
		return -1
	}
	return freeCPU + freeMem
}

// PickNode selects the best-fit node from a slice of capacities.
// Returns the chosen *store.NodeCapacity and its index, or an error
// if none can fit. Ties are broken by NodeID (lexicographic) for
// determinism.
func PickNode(spec JobSpec, capacities []*store.NodeCapacity) (*store.NodeCapacity, int, error) {
	if len(capacities) == 0 {
		return nil, -1, fmt.Errorf("PickNode: no nodes available")
	}
	type scored struct {
		c     *store.NodeCapacity
		idx   int
		score int64
	}
	var fits []scored
	for i, c := range capacities {
		if !spec.Fits(c) {
			continue
		}
		fits = append(fits, scored{c: c, idx: i, score: spec.Score(c)})
	}
	if len(fits) == 0 {
		return nil, -1, fmt.Errorf("PickNode: no node can fit the spec (cpu=%d mem=%d disk=%d)",
			spec.CPUMillicores, spec.MemoryMiB, spec.DiskMiB)
	}
	sort.SliceStable(fits, func(i, j int) bool {
		if fits[i].score != fits[j].score {
			return fits[i].score > fits[j].score
		}
		return fits[i].c.NodeID < fits[j].c.NodeID
	})
	return fits[0].c, fits[0].idx, nil
}

// LocalNode is a minimal abstraction of the local node for the
// scheduler. The concrete implementation reads from the
// store.CapacityRepo.
type LocalNode interface {
	Capacity(ctx context.Context) (*store.NodeCapacity, error)
}

// memLocalNode returns capacity from a fixed *store.NodeCapacity.
// Useful for tests; production code wraps CapacityRepo.
type memLocalNode struct{ c *store.NodeCapacity }

// MemLocalNode returns a LocalNode backed by a fixed capacity. Test-only.
func MemLocalNode(c *store.NodeCapacity) LocalNode {
	return &memLocalNode{c: c}
}

func (m *memLocalNode) Capacity(_ context.Context) (*store.NodeCapacity, error) {
	if m.c == nil {
		return nil, store.ErrNotFound
	}
	return m.c, nil
}

// ensure model import compiles even if unused above (placeholder for
// future scheduler fields that take *model.Node).
var _ = model.NodeStateReady
