// Package engine — dispatcher.go implements the cross-node job
// dispatch logic (v0.2 P02). The dispatcher is the bridge between
// the local "should I run this?" decision (scheduler.PickNode) and
// the remote "please run this" call (transport.DispatchClient).
//
// Flow:
//
//  1. Receive a job spec (HCL bytes from the CLI).
//  2. Parse the spec into a JobSpec (cpu/mem/disk).
//  3. Check local capacity. If it fits, run locally via the local
//     executor. If not, pick a peer and dispatch.
//  4. Return the job ID and the node that actually accepted it.
package engine

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"sync"

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

// Dispatcher is the public surface; constructed via NewDispatcher.
//
// Deprecated: v0.9 re-architecture replaces peer dispatch with a CLI-side
// scheduler + SSH-push (no orca binary on servers per R-001). The
// Dispatcher is retained for the dual-write window and scheduled for
// deletion in v0.10-P14. See .ciagent/PRD_v0.9.md R-001/R-006.
type Dispatcher struct {
	log      *slog.Logger
	capacity *store.CapacityRepo
	peers    *PeerRegistry
	executor LocalExecutor
	dedupe   *transport.IdempotencyStore
	mu       sync.Mutex
}

// LocalExecutor is the contract the dispatcher uses to run jobs on
// the local node. The engine.Executor satisfies this.
type LocalExecutor interface {
	Submit(ctx context.Context, specBytes []byte) (jobID string, err error)
	Status(ctx context.Context, jobID string) (state string, err error)
}

// NewDispatcher builds a Dispatcher.
func NewDispatcher(log *slog.Logger, capacity *store.CapacityRepo, peers *PeerRegistry, exec LocalExecutor) *Dispatcher {
	if log == nil {
		log = slog.Default()
	}
	return &Dispatcher{
		log:      log,
		capacity: capacity,
		peers:    peers,
		executor: exec,
		dedupe:   transport.NewIdempotencyStore(),
	}
}

// Dedupe exposes the in-memory dedupe store for testing.
func (d *Dispatcher) Dedupe() *transport.IdempotencyStore { return d.dedupe }

// Submit runs the spec locally if it fits, otherwise dispatches to a
// peer. Returns the (jobID, chosenNodeID) pair. If `target` is
// non-empty, it overrides bin-packing.
func (d *Dispatcher) Submit(ctx context.Context, target string, specBytes []byte, idempotencyKey string) (jobID, nodeID string, err error) {
	if len(specBytes) == 0 {
		return "", "", errors.New("Dispatcher.Submit: empty spec")
	}
	if idempotencyKey != "" {
		if jid, ok := d.dedupe.Get(idempotencyKey); ok {
			return jid, "self", nil
		}
	}

	parsed, err := parseInlineSpec(specBytes)
	if err != nil {
		return "", "", fmt.Errorf("Dispatcher.Submit: parse spec: %w", err)
	}

	// 1. Explicit target: dispatch there.
	if target != "" {
		return d.dispatchTo(ctx, target, specBytes, idempotencyKey)
	}

	// 2. Check local capacity.
	if d.capacity != nil {
		local, err := d.capacity.Get(ctx, "self")
		if err == nil && parsed.Fits(local) {
			jid, lerr := d.executor.Submit(ctx, specBytes)
			if lerr != nil {
				return "", "", fmt.Errorf("Dispatcher.Submit: local: %w", lerr)
			}
			if idempotencyKey != "" {
				d.dedupe.Put(idempotencyKey, jid)
			}
			d.log.Info("dispatch.local",
				slog.String("event", "dispatch.local"),
				slog.String("job_id", jid),
				slog.String("node_id", "self"),
			)
			return jid, "self", nil
		}
	}

	// 3. Pick a peer.
	if d.peers == nil {
		return "", "", errors.New("Dispatcher.Submit: no local capacity and no peer registry")
	}
	peers, err := d.peers.All(ctx)
	if err != nil {
		return "", "", fmt.Errorf("Dispatcher.Submit: list peers: %w", err)
	}
	if len(peers) == 0 {
		return "", "", errors.New("Dispatcher.Submit: no peers registered")
	}
	var caps []*store.NodeCapacity
	for _, p := range peers {
		caps = append(caps, p.Capacity)
	}
	best, _, err := PickNode(parsed, caps)
	if err != nil {
		return "", "", fmt.Errorf("Dispatcher.Submit: %w", err)
	}
	var chosen *Peer
	for _, p := range peers {
		if p.NodeID == best.NodeID {
			chosen = p
			break
		}
	}
	if chosen == nil {
		return "", "", fmt.Errorf("Dispatcher.Submit: chosen node %s has no peer record", best.NodeID)
	}
	return d.dispatchToPeer(ctx, chosen, specBytes, idempotencyKey)
}

// dispatchTo sends a Submit to a specific node id (looked up in the peer registry).
func (d *Dispatcher) dispatchTo(ctx context.Context, targetNode string, specBytes []byte, idempotencyKey string) (string, string, error) {
	if d.peers == nil {
		return "", "", errors.New("dispatchTo: no peer registry")
	}
	peers, err := d.peers.All(ctx)
	if err != nil {
		return "", "", fmt.Errorf("dispatchTo: list peers: %w", err)
	}
	for _, p := range peers {
		if p.NodeID == targetNode {
			return d.dispatchToPeer(ctx, p, specBytes, idempotencyKey)
		}
	}
	return "", "", fmt.Errorf("dispatchTo: target node %q not found in peer registry", targetNode)
}

// dispatchToPeer opens an mTLS client and calls Submit on the peer.
func (d *Dispatcher) dispatchToPeer(ctx context.Context, p *Peer, specBytes []byte, idempotencyKey string) (string, string, error) {
	if p.CAPath == "" || p.ServerName == "" {
		return "", "", fmt.Errorf("dispatchToPeer: peer %s missing CA or server name", p.NodeID)
	}
	client, err := transport.NewDispatchClient(p.CAPath, p.ServerName, "https://"+p.Address)
	if err != nil {
		return "", "", fmt.Errorf("dispatchToPeer: %w", err)
	}
	resp, err := client.Submit(ctx, specBytes, idempotencyKey)
	if err != nil {
		return "", "", fmt.Errorf("dispatchToPeer: %w", err)
	}
	if idempotencyKey != "" {
		d.dedupe.Put(idempotencyKey, resp.JobID)
	}
	d.log.Info("dispatch.peer",
		slog.String("event", "dispatch.peer"),
		slog.String("job_id", resp.JobID),
		slog.String("node_id", p.NodeID),
	)
	return resp.JobID, p.NodeID, nil
}

// LocalSubmit / LocalStatus satisfy the transport.Dispatcher
// interface (the server-side counterpart of DispatchClient).
func (d *Dispatcher) LocalSubmit(ctx context.Context, specBytes []byte) (string, error) {
	if d.executor == nil {
		return "", errors.New("Dispatcher.LocalSubmit: no local executor")
	}
	return d.executor.Submit(ctx, specBytes)
}

func (d *Dispatcher) LocalStatus(ctx context.Context, jobID string) (string, error) {
	if d.executor == nil {
		return "", errors.New("Dispatcher.LocalStatus: no local executor")
	}
	return d.executor.Status(ctx, jobID)
}

// parseInlineSpec parses a minimal JSON spec with cpu_millicores,
// memory_mib, disk_mib fields. The CLI uses this as the wire format
// for cross-node dispatch; full HCL parsing is in internal/jobspec.
func parseInlineSpec(b []byte) (JobSpec, error) {
	type wire struct {
		CPUMillicores int64 `json:"cpu_millicores"`
		MemoryMiB     int64 `json:"memory_mib"`
		DiskMiB       int64 `json:"disk_mib"`
	}
	var w wire
	if err := json.Unmarshal(b, &w); err != nil {
		return JobSpec{}, fmt.Errorf("parseInlineSpec: %w", err)
	}
	return JobSpec{
		CPUMillicores: w.CPUMillicores,
		MemoryMiB:     w.MemoryMiB,
		DiskMiB:       w.DiskMiB,
	}, nil
}
