package service

import (
	"context"
	"fmt"
	"sort"
	"sync"
	"time"

	edgeevents "iop/apps/edge/internal/events"
	edgenode "iop/apps/edge/internal/node"
	"iop/packages/go/config"
	eventpkg "iop/packages/go/events"
	iop "iop/proto/gen/iop"
)

const (
	defaultNodeCapacity  = 1
	defaultGroupMaxQueue = 16
	defaultQueueTimeout  = 30 * time.Second
)

var (
	errQueueFull    = fmt.Errorf("queue is full")
	errQueueTimeout = fmt.Errorf("queue timeout")
)

// candidateNode pairs a registry entry with the per-request capacity derived
// from the node's adapter config (or Runtime.Concurrency as fallback).
// For provider-pool candidates, providerID, adapter, and servedTarget carry
// the globally-unique provider id, the dispatch adapter key, and the concrete
// model name to dispatch. Priority is the provider's dispatch priority (lower
// is preferred) and is used as a tie-break when in_flight counts are equal.
type candidateNode struct {
	entry        *edgenode.NodeEntry
	capacity     int
	priority     int
	providerID   string // non-empty for provider-pool candidates
	adapter      string // non-empty for provider-pool candidates; dispatch adapter key
	servedTarget string // concrete served model name; used for target rewrite
}

// slotKey returns a unique slot key for inflight accounting.
// For provider-pool candidates (providerID non-empty) it uses "nodeID:providerID"
// so that multiple providers on the same node are tracked independently.
// For legacy candidates it falls back to nodeID only.
func (c *candidateNode) slotKey() string {
	if c.providerID != "" {
		return c.entry.NodeID + ":" + c.providerID
	}
	return c.entry.NodeID
}

type groupPolicy struct {
	maxQueue     int
	queueTimeout time.Duration
}

type inflightRec struct {
	groupKey   string
	nodeID     string
	providerID string // non-empty for provider-pool dispatches
}

type admitResult struct {
	candidate *candidateNode
	err       error
}

type queueItem struct {
	candidates []candidateNode
	waitCh     chan admitResult
	deadline   time.Time
}

type modelQueueGroup struct {
	key              string
	policy           groupPolicy
	queue            []*queueItem
	inflight         map[string]int // slotKey → current in-flight count
	lastSelectedSlot string
	adapter          string
	target           string
}

type modelQueueManager struct {
	mu            sync.Mutex
	groups        map[string]*modelQueueGroup
	inflightByRun map[string]inflightRec // runID → {groupKey, nodeID}
	store         *edgenode.NodeStore
}

func newModelQueueManager(store *edgenode.NodeStore) *modelQueueManager {
	return &modelQueueManager{
		groups:        make(map[string]*modelQueueGroup),
		inflightByRun: make(map[string]inflightRec),
		store:         store,
	}
}

func (m *modelQueueManager) setStore(store *edgenode.NodeStore) {
	m.mu.Lock()
	m.store = store
	m.mu.Unlock()
}

// startEventWatcher subscribes to all run and node events, releasing in-flight
// slots when runs terminate or nodes disconnect. Returns a stop function.
func (m *modelQueueManager) startEventWatcher(bus *edgeevents.Bus) func() {
	runCh, unsubRun := bus.SubscribeAllRuns(256)
	nodeCh, unsubNode := bus.SubscribeAllNodes(64)
	done := make(chan struct{})
	go func() {
		defer unsubRun()
		defer unsubNode()
		for {
			select {
			case e, ok := <-runCh:
				if !ok {
					return
				}
				if isTerminalRunEvent(e) {
					m.releaseRun(e.GetRunId(), e.GetType())
				}
			case e, ok := <-nodeCh:
				if !ok {
					return
				}
				if e.GetType() == eventpkg.TypeNodeDisconnected {
					m.releaseNode(e.GetNodeId(), "disconnected")
				}
			case <-done:
				return
			}
		}
	}()
	return func() { close(done) }
}

func isTerminalRunEvent(e *iop.RunEvent) bool {
	t := e.GetType()
	return t == "complete" || t == "error" || t == "cancelled"
}

// getOrCreateGroupLocked returns an existing group or creates one with the
// given policy (applying defaults for zero fields). Policy is only applied
// at creation; existing groups retain their current policy until an explicit
// updateGroupPolicyLocked call (used for config-refresh policy propagation).
func (m *modelQueueManager) getOrCreateGroupLocked(key string, policy groupPolicy) *modelQueueGroup {
	g, ok := m.groups[key]
	if !ok {
		if policy.maxQueue <= 0 {
			policy.maxQueue = defaultGroupMaxQueue
		}
		if policy.queueTimeout <= 0 {
			policy.queueTimeout = defaultQueueTimeout
		}
		g = &modelQueueGroup{
			key:      key,
			policy:   policy,
			inflight: make(map[string]int),
		}
		m.groups[key] = g
	}
	return g
}

// updateGroupPolicyLocked refreshes an existing group's queue policy so that
// provider max_queue / queue_timeout_ms changes applied via config refresh take
// effect on subsequent admissions. Only non-zero policy fields override the
// current values; in-flight counts and queued items are preserved untouched.
func (m *modelQueueManager) updateGroupPolicyLocked(g *modelQueueGroup, policy groupPolicy) {
	if g == nil {
		return
	}
	if policy.maxQueue > 0 {
		g.policy.maxQueue = policy.maxQueue
	}
	if policy.queueTimeout > 0 {
		g.policy.queueTimeout = policy.queueTimeout
	}
}

// findAvailableNodeLocked returns the available candidate with the lowest
// absolute in_flight count. When in_flight is equal, the candidate with the
// lower priority value wins. When both in_flight and priority are equal,
// all tied candidates remain in best[] so that the rotation logic in the
// caller picks the next slot after the last selected one.
func (m *modelQueueManager) findAvailableNodeLocked(group *modelQueueGroup, candidates []candidateNode) *candidateNode {
	var best []candidateNode
	bestInflight := 0
	bestPriority := 0
	for i := range candidates {
		c := candidates[i]
		if c.capacity <= 0 {
			continue
		}
		slot := c.slotKey()
		inflight := group.inflight[slot]
		if inflight >= c.capacity {
			continue
		}
		switch {
		case len(best) == 0 || inflight < bestInflight:
			best = []candidateNode{c}
			bestInflight = inflight
			bestPriority = c.priority
		case inflight == bestInflight:
			if c.priority < bestPriority {
				best = []candidateNode{c}
				bestPriority = c.priority
			} else if c.priority == bestPriority {
				best = append(best, c)
			}
		}
	}
	if len(best) == 0 {
		return nil
	}
	sort.Slice(best, func(i, j int) bool {
		return candidateLess(&best[i], &best[j])
	})
	idx := 0
	if group.lastSelectedSlot != "" {
		for i := range best {
			if best[i].slotKey() == group.lastSelectedSlot {
				idx = (i + 1) % len(best)
				break
			}
		}
	}
	return &best[idx]
}

// candidateLess provides a deterministic ordering for equal-inflight/priority rotation:
// providerID first, then nodeID.
func candidateLess(a, b *candidateNode) bool {
	if b == nil {
		return true
	}
	if a.providerID != b.providerID {
		return a.providerID < b.providerID
	}
	return a.entry.NodeID < b.entry.NodeID
}

// admit selects an available candidate for the given model group, or queues
// the request until a slot opens. Blocks until a candidate is assigned, the
// queue timeout expires, or ctx is cancelled. Returns the selected candidateNode
// so callers can rewrite the dispatch target for provider-pool requests.
func (m *modelQueueManager) admit(ctx context.Context, groupKey, adapter, target string, candidates []candidateNode, policy groupPolicy) (*candidateNode, error) {
	m.mu.Lock()

	group := m.getOrCreateGroupLocked(groupKey, policy)
	// Refresh the policy on an already-existing group so capacity/queue changes
	// applied via config refresh (and recomputed per-request from the live store)
	// take effect on this and subsequent admissions.
	m.updateGroupPolicyLocked(group, policy)
	if group.adapter == "" {
		group.adapter = adapter
	}
	if group.target == "" {
		group.target = target
	}

	candidate := m.findAvailableNodeLocked(group, candidates)
	if candidate != nil {
		slot := candidate.slotKey()
		group.inflight[slot]++
		group.lastSelectedSlot = slot
		m.mu.Unlock()
		return candidate, nil
	}

	if len(group.queue) >= group.policy.maxQueue {
		m.mu.Unlock()
		return nil, fmt.Errorf("model group %q: %w", groupKey, errQueueFull)
	}

	item := &queueItem{
		candidates: candidates,
		waitCh:     make(chan admitResult, 1),
		deadline:   time.Now().Add(group.policy.queueTimeout),
	}
	group.queue = append(group.queue, item)
	timeout := group.policy.queueTimeout
	m.mu.Unlock()

	timer := time.NewTimer(timeout)
	defer timer.Stop()

	select {
	case res := <-item.waitCh:
		return res.candidate, res.err
	case <-timer.C:
		m.mu.Lock()
		m.removeItemLocked(groupKey, item)
		m.mu.Unlock()
		// Handle race: dispatch may have sent to waitCh just before timeout fired.
		select {
		case res := <-item.waitCh:
			if res.candidate != nil {
				// Use public releaseSlot (which acquires m.mu) because we are
				// outside the lock here.  This avoids shared-state mutation
				// without mutex that the original releaseSlotByCandidate call
				// would cause.
				slot := res.candidate.slotKey()
				var nodeID, providerID string
				if colonIdx := findLastColon(slot); colonIdx > 0 {
					nodeID = slot[:colonIdx]
					providerID = slot[colonIdx+1:]
				} else {
					nodeID = slot
				}
				m.releaseSlot(groupKey, nodeID, providerID)
			}
		default:
		}
		return nil, fmt.Errorf("model group %q: %w", groupKey, errQueueTimeout)
	case <-ctx.Done():
		m.mu.Lock()
		m.removeItemLocked(groupKey, item)
		m.mu.Unlock()
		// Handle race: dispatch may have sent to waitCh just before cancellation.
		select {
		case res := <-item.waitCh:
			if res.candidate != nil {
				// Same fix as timeout path: use public releaseSlot with
				// explicit mutex to avoid concurrent map/slice mutation.
				slot := res.candidate.slotKey()
				var nodeID, providerID string
				if colonIdx := findLastColon(slot); colonIdx > 0 {
					nodeID = slot[:colonIdx]
					providerID = slot[colonIdx+1:]
				} else {
					nodeID = slot
				}
				m.releaseSlot(groupKey, nodeID, providerID)
			}
		default:
		}
		return nil, ctx.Err()
	}
}

// trackInflight records a dispatched run so release events can find it.
// providerID is non-empty for provider-pool dispatches and is used to
// attribute in-flight counts to the correct provider snapshot.
func (m *modelQueueManager) trackInflight(groupKey, runID, nodeID, providerID string) {
	m.mu.Lock()
	m.inflightByRun[runID] = inflightRec{groupKey: groupKey, nodeID: nodeID, providerID: providerID}
	m.mu.Unlock()
}

// releaseRun releases the in-flight slot for a terminated run and dispatches
// the next queued item if one is waiting.
func (m *modelQueueManager) releaseRun(runID, reason string) {
	m.mu.Lock()
	rec, ok := m.inflightByRun[runID]
	if !ok {
		m.mu.Unlock()
		return
	}
	delete(m.inflightByRun, runID)
	m.releaseSlotLocked(rec.groupKey, rec.nodeID, rec.providerID)
	m.mu.Unlock()
}

// releaseNode resets all in-flight slots on a disconnected node, removes it
// from all queued items' candidate lists, and tries to re-dispatch to any
// remaining live candidates.
func (m *modelQueueManager) releaseNode(nodeID, reason string) {
	m.mu.Lock()
	defer m.mu.Unlock()

	for runID, rec := range m.inflightByRun {
		if rec.nodeID == nodeID {
			delete(m.inflightByRun, runID)
		}
	}

	for _, group := range m.groups {
		// Remove the disconnected node from all queued items so a future
		// tryDispatch cannot pick it as a stale available candidate.
		for _, item := range group.queue {
			filtered := make([]candidateNode, 0, len(item.candidates))
			for _, c := range item.candidates {
				if c.entry.NodeID != nodeID {
					filtered = append(filtered, c)
				}
			}
			item.candidates = filtered
		}

		// Clear all slot keys that start with this nodeID.
		// For provider-pool: "nodeID:providerID". For legacy: "nodeID".
		for slot := range group.inflight {
			if colonIdx := findLastColon(slot); colonIdx > 0 {
				candidateNodeID := slot[:colonIdx]
				// Only clear if the node part matches.
				if candidateNodeID == nodeID {
					delete(group.inflight, slot)
					continue
				}
			} else {
				// Legacy slot: exact nodeID match.
				if slot == nodeID {
					delete(group.inflight, slot)
				}
			}
		}

		// tryDispatchLocked will handle remaining queued items, but we need
		// to call it if we cleared any inflight.
		// Since we already deleted entries, tryDispatchLocked will find available slots.
		// We need to re-check: if any slots were cleared and there are queued items.
		if len(group.queue) > 0 {
			m.tryDispatchLocked(group)
		}
	}
}

// releaseSlot decrements the in-flight count and tries to dispatch the next
// queued item. Used when admit-path I/O fails after the slot was reserved.
// For provider-pool dispatches (nodeID:providerID slot), pass providerID.
// For legacy dispatches, pass empty providerID so nodeID is used directly.
func (m *modelQueueManager) releaseSlot(groupKey, nodeID string, providerID ...string) {
	m.mu.Lock()
	var pid string
	if len(providerID) > 0 {
		pid = providerID[0]
	}
	m.releaseSlotLocked(groupKey, nodeID, pid)
	m.mu.Unlock()
}

func (m *modelQueueManager) releaseSlotLocked(groupKey, nodeID string, providerID string) {
	group, ok := m.groups[groupKey]
	if !ok {
		return
	}
	// Use provider-aware slot key for provider-pool dispatches.
	slot := nodeID
	if providerID != "" {
		slot = nodeID + ":" + providerID
	}
	if group.inflight[slot] > 0 {
		group.inflight[slot]--
	}
	m.tryDispatchLocked(group)
}

// releaseSlotByCandidate releases the slot identified by the candidate directly.
// IMPORTANT: This function MUST be called with m.mu held because it calls
// releaseSlotLocked which modifies shared state (group.inflight, tryDispatchLocked).
// Use releaseSlot instead when releasing from an unlocked context.
func (m *modelQueueManager) releaseSlotByCandidate(groupKey string, candidate *candidateNode) {
	slot := candidate.slotKey()
	// For provider-pool candidates, slot is "nodeID:providerID".
	// We need to split it back for the locked release.
	var nodeID, providerID string
	if colonIdx := findLastColon(slot); colonIdx > 0 {
		nodeID = slot[:colonIdx]
		providerID = slot[colonIdx+1:]
	} else {
		nodeID = slot
	}
	m.releaseSlotLocked(groupKey, nodeID, providerID)
}

// findLastColon returns the index of the last ':' in s, or -1 if not found.
func findLastColon(s string) int {
	for i := len(s) - 1; i >= 0; i-- {
		if s[i] == ':' {
			return i
		}
	}
	return -1
}

// tryDispatchLocked attempts to dispatch the head of the queue to an available
// node. Must be called with m.mu held.
func (m *modelQueueManager) tryDispatchLocked(group *modelQueueGroup) {
	for len(group.queue) > 0 {
		head := group.queue[0]

		if time.Now().After(head.deadline) {
			group.queue = group.queue[1:]
			select {
			case head.waitCh <- admitResult{err: fmt.Errorf("model group %q: %w", group.key, errQueueTimeout)}:
			default:
			}
			continue
		}

		candidate := m.findAvailableNodeLocked(group, head.candidates)
		if candidate == nil {
			break
		}

		group.queue = group.queue[1:]
		slot := candidate.slotKey()
		group.inflight[slot]++

		select {
		case head.waitCh <- admitResult{candidate: candidate}:
			group.lastSelectedSlot = slot
			return
		default:
			// Caller already timed out or cancelled; release the reserved slot and try next.
			group.inflight[slot]--
		}
	}
}

func (m *modelQueueManager) removeItemLocked(groupKey string, item *queueItem) {
	group, ok := m.groups[groupKey]
	if !ok {
		return
	}
	for i, it := range group.queue {
		if it == item {
			group.queue = append(group.queue[:i], group.queue[i+1:]...)
			return
		}
	}
}

func (m *modelQueueManager) getSnapshotForNode(nodeID string, rec *edgenode.NodeRecord) []*iop.ProviderSnapshot {
	m.mu.Lock()
	defer m.mu.Unlock()

	var snaps []*iop.ProviderSnapshot

	// Catalog-first: nodes with a providers[] catalog emit only catalog snapshots.
	// Adapter snapshots (sections 1-4) are omitted to prevent duplicate metrics
	// when a node has both adapter instances and provider-pool catalog entries.
	if len(rec.Providers) > 0 {
		for _, prov := range rec.Providers {
			if prov.ID == "" {
				continue
			}

			servedModels := make([]string, len(prov.Models))
			copy(servedModels, prov.Models)
			lifecycleCaps := make([]string, len(prov.LifecycleCapabilities))
			copy(lifecycleCaps, prov.LifecycleCapabilities)

			// Disabled providers appear in the snapshot with status=disabled and
			// effective capacity 0 so operators can see the switch state.
			if !config.ProviderEnabled(prov) {
				snaps = append(snaps, &iop.ProviderSnapshot{
					Adapter:               prov.Adapter,
					Status:                "disabled",
					Capacity:              0,
					InFlight:              0,
					Queued:                0,
					Id:                    prov.ID,
					Type:                  prov.Type,
					Category:              string(prov.Category),
					ServedModels:          servedModels,
					Health:                "disabled",
					LoadRatio:             0,
					LifecycleCapabilities: lifecycleCaps,
				})
				continue
			}

			capVal := prov.Capacity
			inflight, queued := m.getStatsForProviderLocked(nodeID, prov.ID)

			var loadRatio float32
			if capVal > 0 {
				loadRatio = float32(inflight) / float32(capVal)
			}

			snaps = append(snaps, &iop.ProviderSnapshot{
				Adapter:               prov.Adapter,
				Status:                "available",
				Capacity:              int32(capVal),
				InFlight:              int32(inflight),
				Queued:                int32(queued),
				Id:                    prov.ID,
				Type:                  prov.Type,
				Category:              string(prov.Category),
				ServedModels:          servedModels,
				Health:                prov.Health,
				LoadRatio:             loadRatio,
				LifecycleCapabilities: lifecycleCaps,
			})
		}
		return snaps
	}

	// Legacy adapter snapshots for nodes with no providers catalog.
	concurrencyFallback := 1
	if rec.Runtime.Concurrency > 0 {
		concurrencyFallback = rec.Runtime.Concurrency
	}

	// 1. CLI
	if rec.Adapters.CLI.Enabled {
		capVal := concurrencyFallback
		inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, "cli")
		snaps = append(snaps, &iop.ProviderSnapshot{
			Adapter:  "cli",
			Status:   "available",
			Capacity: int32(capVal),
			InFlight: int32(inflight),
			Queued:   int32(queued),
		})
	}

	// 2. Ollama
	for _, inst := range rec.Adapters.OllamaInstances {
		if !inst.Enabled {
			continue
		}
		name := inst.Name
		if name == "" {
			name = "ollama"
		}
		capVal := inst.Capacity
		if capVal <= 0 {
			capVal = concurrencyFallback
		}
		inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, name)
		snaps = append(snaps, &iop.ProviderSnapshot{
			Adapter:  name,
			Status:   "available",
			Capacity: int32(capVal),
			InFlight: int32(inflight),
			Queued:   int32(queued),
		})
	}

	// 3. vLLM
	for _, inst := range rec.Adapters.VllmInstances {
		if !inst.Enabled {
			continue
		}
		name := inst.Name
		if name == "" {
			name = "vllm"
		}
		capVal := inst.Capacity
		if capVal <= 0 {
			capVal = concurrencyFallback
		}
		inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, name)
		snaps = append(snaps, &iop.ProviderSnapshot{
			Adapter:  name,
			Status:   "available",
			Capacity: int32(capVal),
			InFlight: int32(inflight),
			Queued:   int32(queued),
		})
	}

	// 4. OpenAI Compat
	for _, inst := range rec.Adapters.OpenAICompatInstances {
		if !inst.Enabled {
			continue
		}
		name := inst.Name
		if name == "" {
			name = "openai_compat"
		}
		capVal := inst.Capacity
		if capVal <= 0 {
			capVal = concurrencyFallback
		}
		inflight, queued := m.getStatsForAdapterLocked(nodeID, rec, name)
		snaps = append(snaps, &iop.ProviderSnapshot{
			Adapter:  name,
			Status:   "available",
			Capacity: int32(capVal),
			InFlight: int32(inflight),
			Queued:   int32(queued),
		})
	}

	return snaps
}

// getStatsForProviderLocked returns in-flight and queued counts for a
// provider-pool provider identified by (nodeID, providerID). Must be called
// with m.mu held.
func (m *modelQueueManager) getStatsForProviderLocked(nodeID, providerID string) (inFlight, queued int) {
	for _, rec := range m.inflightByRun {
		if rec.nodeID == nodeID && rec.providerID == providerID {
			inFlight++
		}
	}
	for _, group := range m.groups {
		for _, item := range group.queue {
			for _, c := range item.candidates {
				if c.entry.NodeID == nodeID && c.providerID == providerID {
					queued++
					break
				}
			}
		}
	}
	return
}

func (m *modelQueueManager) getStatsForAdapterLocked(nodeID string, rec *edgenode.NodeRecord, adapterName string) (inFlight, queued int) {
	for _, group := range m.groups {
		canonical, ok := resolveSnapshotAdapterName(rec, group.adapter, group.target)
		if ok && canonical == adapterName {
			if val, ok := group.inflight[nodeID]; ok {
				inFlight += val
			}
			for _, item := range group.queue {
				for _, c := range item.candidates {
					if c.entry.NodeID == nodeID {
						queued++
						break
					}
				}
			}
		}
	}
	return
}

// resolveSnapshotAdapterName returns the adapter/instance key used to match
// queue state for a provider snapshot.
// When adapterType is empty it falls back to the provider id (target) so that
// queue stats are still resolved instead of being dropped on an empty key.
func resolveSnapshotAdapterName(rec *edgenode.NodeRecord, adapterType, target string) (string, bool) {
	// Fallback to provider id when adapter is not set.
	if adapterType == "" {
		adapterType = target
	}
	if rec == nil {
		return adapterType, true
	}

	// 1. Exact instance Name match (highest priority).
	for _, inst := range rec.Adapters.OllamaInstances {
		if inst.Name == adapterType {
			return adapterType, true
		}
	}
	for _, inst := range rec.Adapters.VllmInstances {
		if inst.Name == adapterType {
			return adapterType, true
		}
	}
	for _, inst := range rec.Adapters.OpenAICompatInstances {
		if inst.Name == adapterType {
			return adapterType, true
		}
	}

	// 2. Type-name route.
	switch adapterType {
	case "ollama":
		var enabled []string
		for _, inst := range rec.Adapters.OllamaInstances {
			if inst.Enabled {
				name := inst.Name
				if name == "" {
					name = "ollama"
				}
				enabled = append(enabled, name)
			}
		}
		switch len(enabled) {
		case 0:
			return "ollama", true
		case 1:
			return enabled[0], true
		default:
			return "", false
		}

	case "vllm":
		var enabled []string
		for _, inst := range rec.Adapters.VllmInstances {
			if inst.Enabled {
				name := inst.Name
				if name == "" {
					name = "vllm"
				}
				enabled = append(enabled, name)
			}
		}
		switch len(enabled) {
		case 0:
			return "vllm", true
		case 1:
			return enabled[0], true
		default:
			return "", false
		}

	case "openai_compat":
		var enabled []string
		for _, inst := range rec.Adapters.OpenAICompatInstances {
			if inst.Enabled {
				name := inst.Name
				if name == "" {
					name = "openai_compat"
				}
				enabled = append(enabled, name)
			}
		}
		switch len(enabled) {
		case 0:
			return "openai_compat", true
		case 1:
			return enabled[0], true
		default:
			return "", false
		}

	case "cli":
		return "cli", true

	default:
		return adapterType, true
	}
}
