// Package bus is the control plane's inter-process message bus.
//
// It exists because an agent's CommandStream lands on exactly one server
// process. With a single replica that process is also the one handling every
// REST request, so an in-memory map was enough. With several replicas it is
// not: the pod asked to run a workflow step is almost never the pod holding
// that agent's stream, and a map cannot reach across the gap.
//
// Redis is already a hard dependency (sessions), so the bus adds no new
// infrastructure. It carries three things:
//
//	presence  which pod, if any, currently holds an agent's stream
//	commands  a request/ack exchange delivering a ServerCommand to that pod
//	results   step results travelling back to the pod driving the run
//
// Everything here is deliberately best-effort delivery with an explicit ack
// rather than a queue. A command whose owner pod died between the presence
// check and the publish must fail loudly and immediately — the caller answers
// 503 and the operator retries — not sit in a queue waiting for a stream that
// no longer exists.
package bus

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"time"

	"github.com/redis/go-redis/v9"
)

// ErrNoResponder means nothing was subscribed to the channel, or the subscriber
// did not ack within the timeout. Either way the command did not reach an agent.
var ErrNoResponder = errors.New("no responder on channel")

var (
	rdb    *redis.Client
	nodeID string
)

// Init connects the bus. It takes its own client rather than sharing the
// session store's: a subscription occupies its connection for as long as it
// lives, and every agent stream on this pod holds one, so they must not come
// out of the pool that ordinary session reads depend on.
func Init(addr, username, password string) error {
	rdb = redis.NewClient(&redis.Options{
		Addr:     addr,
		Username: username,
		Password: password,
	})
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := rdb.Ping(ctx).Err(); err != nil {
		return err
	}

	// Hostname is the pod name under Kubernetes, which makes a log line naming
	// a node directly actionable. The random suffix keeps two processes on one
	// host (or a reused pod name) from claiming each other's locks.
	host, _ := os.Hostname()
	if host == "" {
		host = "server"
	}
	b := make([]byte, 4)
	_, _ = rand.Read(b)
	nodeID = host + "-" + hex.EncodeToString(b)
	return nil
}

// NodeID identifies this process on the bus. Stable for the process lifetime.
func NodeID() string { return nodeID }

// Client exposes the underlying Redis client for the leader election and
// presence helpers in this package. It is nil before Init.
func Client() *redis.Client { return rdb }

// Channel names. Every key and channel is prefixed so a Redis shared with the
// session store (which uses km:) stays legible.
const (
	prefix = "vantage:"

	// CommandChannel carries envelopes to whichever pod holds serverID's stream.
	CommandChannel = prefix + "cmd:"
	// ackChannel carries the owner pod's answer back to the requesting pod.
	ackChannel = prefix + "ack:"
	// ResultChannel carries a StepResult back to the pod driving the run.
	ResultChannel = prefix + "res:"
	// ProxyEndChannel carries a console relay's terminal reason back to the pod
	// serving the WebSocket, which is the pod that has to write the audit event.
	ProxyEndChannel = prefix + "proxyend:"
	// ProxyAddrChannel carries the address of a console relay listener back to
	// the pod serving the WebSocket.
	//
	// The listener cannot be bound in advance on any particular pod. An agent's
	// ProxyStream is a separate HTTP/2 request from its CommandStream, and an
	// L7 proxy (Traefik) balances requests, not connections — so it may land on
	// any replica, not the one holding the command stream. The pod it does land
	// on binds the listener and announces it here.
	ProxyAddrChannel = prefix + "proxyaddr:"

	// PresenceKey records which node holds an agent's command stream.
	PresenceKey = prefix + "agent:"
	// ProxyPendingKey authorises one not-yet-opened ProxyStream. It is the only
	// state tying a proxy_id to the instance and server it was minted for, and
	// it must be visible to every replica because any of them may receive the
	// stream.
	ProxyPendingKey = prefix + "proxypending:"
	// leaderKey records the holder of a named singleton job.
	leaderKey = prefix + "leader:"
)

// Publish sends v, JSON-encoded, to channel. It reports how many subscribers
// received it, which is the only signal Redis pub/sub gives that anyone was
// listening.
func Publish(ctx context.Context, channel string, v any) (int64, error) {
	b, err := json.Marshal(v)
	if err != nil {
		return 0, err
	}
	return rdb.Publish(ctx, channel, b).Result()
}

// Subscribe returns a channel of raw payloads and a function that unsubscribes.
// It blocks until Redis confirms the subscription, so a caller may publish
// immediately afterwards without racing its own subscriber.
func Subscribe(ctx context.Context, channel string) (<-chan []byte, func(), error) {
	ps := rdb.Subscribe(ctx, channel)
	if _, err := ps.Receive(ctx); err != nil {
		_ = ps.Close()
		return nil, nil, err
	}

	out := make(chan []byte, 64)
	go func() {
		defer close(out)
		for msg := range ps.Channel() {
			select {
			case out <- []byte(msg.Payload):
			default:
				// A subscriber too slow to keep up would otherwise stall every
				// other subscriber sharing this connection. Dropping is correct
				// here: commands are acked, and a dropped ack fails the caller
				// loudly rather than hanging it.
			}
		}
	}()

	return out, func() { _ = ps.Close() }, nil
}

// Request publishes req to channel and waits for a single reply on replyTo.
//
// The reply subscription is established before the publish, so a responder that
// answers instantly cannot beat the subscriber into place. A publish that
// reaches no subscriber fails immediately with ErrNoResponder rather than
// burning the whole timeout: nobody is going to answer.
func Request(ctx context.Context, channel, replyTo string, req any, timeout time.Duration) ([]byte, error) {
	waitCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	replies, unsub, err := Subscribe(waitCtx, replyTo)
	if err != nil {
		return nil, fmt.Errorf("subscribe reply channel: %w", err)
	}
	defer unsub()

	n, err := Publish(waitCtx, channel, req)
	if err != nil {
		return nil, err
	}
	if n == 0 {
		return nil, ErrNoResponder
	}

	select {
	case b, ok := <-replies:
		if !ok {
			return nil, ErrNoResponder
		}
		return b, nil
	case <-waitCtx.Done():
		return nil, ErrNoResponder
	}
}

// Reply answers a Request on its reply channel.
func Reply(ctx context.Context, replyTo string, v any) error {
	_, err := Publish(ctx, replyTo, v)
	return err
}

// AckChannelFor names the reply channel for a command. The command ID is
// already unique per dispatch, so it needs no further qualification.
func AckChannelFor(commandID string) string { return ackChannel + commandID }

// SetPresence claims serverID for this node for ttl. Called repeatedly by the
// pod holding the stream; the TTL is what bounds how long a crashed pod keeps
// claiming an agent it can no longer reach.
func SetPresence(ctx context.Context, serverID string, ttl time.Duration) error {
	return rdb.Set(ctx, PresenceKey+serverID, nodeID, ttl).Err()
}

// RenewPresence extends serverID's claim, but only while this node still holds
// it, and reports whether it did.
//
// A blind SET here is wrong, not merely untidy. When an agent reconnects, its
// previous stream can stay half-open on another pod for the length of a
// keepalive cycle, and that pod goes on renewing. Two processes then overwrite
// each other's claim every renewal interval and the key names whichever wrote
// last rather than whichever holds the live stream. A superseded pod must lose
// quietly instead.
func RenewPresence(ctx context.Context, serverID string, ttl time.Duration) bool {
	n, err := renewPresenceIfOwner.Run(ctx, rdb,
		[]string{PresenceKey + serverID}, nodeID, int64(ttl/time.Millisecond)).Int64()
	return err == nil && n == 1
}

// ClearPresence releases serverID, but only if this node still holds it. A
// blind DEL would let a pod whose stream had already been re-established
// elsewhere delete the new owner's claim on its way out.
func ClearPresence(ctx context.Context, serverID string) error {
	return releaseIfOwner.Run(ctx, rdb, []string{PresenceKey + serverID}, nodeID).Err()
}

// PresenceHolder returns the node holding serverID's stream, or "" if none does.
func PresenceHolder(ctx context.Context, serverID string) string {
	v, err := rdb.Get(ctx, PresenceKey+serverID).Result()
	if err != nil {
		return ""
	}
	return v
}

// IsConnected reports whether any pod currently holds serverID's stream.
func IsConnected(ctx context.Context, serverID string) bool {
	n, err := rdb.Exists(ctx, PresenceKey+serverID).Result()
	return err == nil && n > 0
}

// SetPendingProxy records that proxyID has been minted for instanceID and
// serverID, for ttl. Written before the OpenProxyCmd is dispatched, so it is in
// place before any agent can act on it.
func SetPendingProxy(ctx context.Context, proxyID, instanceID, serverID string, ttl time.Duration) error {
	b, err := json.Marshal(map[string]string{"instance_id": instanceID, "server_id": serverID})
	if err != nil {
		return err
	}
	return rdb.Set(ctx, ProxyPendingKey+proxyID, b, ttl).Err()
}

// ClaimPendingProxy consumes proxyID's pending record and returns the instance
// and server it was minted for. Get and delete are one Lua call rather than two
// round trips: single use is the whole security property, and two agents
// racing the same proxy_id must not both be served.
//
// A missing record is reported as "", "" rather than an error — an unknown
// proxy_id, an expired one and a second claim are all the same refusal.
func ClaimPendingProxy(ctx context.Context, proxyID string) (instanceID, serverID string) {
	v, err := claimPending.Run(ctx, rdb, []string{ProxyPendingKey + proxyID}).Text()
	if err != nil || v == "" {
		return "", ""
	}
	var rec struct {
		InstanceID string `json:"instance_id"`
		ServerID   string `json:"server_id"`
	}
	if err := json.Unmarshal([]byte(v), &rec); err != nil {
		return "", ""
	}
	return rec.InstanceID, rec.ServerID
}

// ClearPendingProxy drops a pending record whose command never reached an
// agent, so a dead proxy_id is not left claimable for the rest of its TTL.
func ClearPendingProxy(ctx context.Context, proxyID string) {
	_ = rdb.Del(ctx, ProxyPendingKey+proxyID).Err()
}

var claimPending = redis.NewScript(`
local v = redis.call("GET", KEYS[1])
if v then
	redis.call("DEL", KEYS[1])
	return v
end
return ""
`)

var renewPresenceIfOwner = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
	return redis.call("PEXPIRE", KEYS[1], ARGV[2])
end
return 0
`)

var releaseIfOwner = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
	return redis.call("DEL", KEYS[1])
end
return 0
`)
