package services

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
	"github.com/google/uuid"
)

// Commands travel over the bus even when the sender is also the owner.
//
// An agent's CommandStream terminates on exactly one process, and with several
// replicas that is almost never the process handling the REST request that
// wants to talk to it. Publishing unconditionally — rather than checking for a
// local stream first and falling back — means one code path, exercised on every
// deployment including the single-replica ones, instead of a rare cross-pod
// path that only fails in production.
const (
	// How long a caller waits for the owning pod to acknowledge. Generous
	// enough to cross a loaded cluster, short enough that a REST handler
	// answering 503 does not look hung.
	dispatchAckTimeout = 5 * time.Second

	// Presence must outlive a renew or two, or a momentarily slow pod would
	// look offline and its agent would be declared unreachable.
	presenceTTL   = 30 * time.Second
	presenceRenew = 10 * time.Second
)

// CommandEnvelope is what actually crosses the bus. It is the command plus the
// small amount of context the owning pod needs to act on it locally.
type CommandEnvelope struct {
	ServerID string            `json:"server_id"`
	Command  *pb.ServerCommand `json:"command"`
	ReplyTo  string            `json:"reply_to"`
	Log      *LogRequest       `json:"log,omitempty"`

	// Node names the pod this envelope is for: the presence holder at the time
	// it was published. The command channel is a fan-out, so during a reconnect
	// two pods can be subscribed for one agent — the pod with the live stream,
	// and a pod whose stream is half-open and has not yet noticed. Both would
	// receive the envelope, and the first to ack wins the request. If that is
	// the stale one, the command is queued onto a dead stream and acked OK: the
	// operator is told it worked and the agent never sees it.
	Node string `json:"node,omitempty"`
}

// LogRequest asks the owner pod to open a step log before it dispatches.
//
// Step output arrives on the owner pod's gRPC stream, so that is where it is
// masked and written. Shipping raw output back to the run's pod first would put
// unmasked bytes on the bus for no gain.
type LogRequest struct {
	RunID    string   `json:"run_id"`
	ServerID string   `json:"server_id"`
	Mask     []string `json:"mask,omitempty"`
}

// CommandAck is the owner pod's answer. It reports only that the command
// reached the agent's stream.
//
// A console relay listener used to be bound here and its address returned in
// this ack. It no longer is: the agent's ProxyStream does not necessarily
// arrive at the pod holding its command stream, so the listener is bound by
// whichever pod receives that stream and announced on bus.ProxyAddrChannel.
type CommandAck struct {
	OK    bool   `json:"ok"`
	Error string `json:"error,omitempty"`
	Node  string `json:"node,omitempty"`
}

type commandDispatcher struct{}

var Dispatcher = &commandDispatcher{}

// ErrAgentNotConnected is returned when no pod holds the agent's stream.
var ErrAgentNotConnected = errors.New("agent is not connected")

// Serve subscribes this pod to serverID's command channel and claims presence
// for it, returning the channel the gRPC handler should send from and a
// teardown function.
//
// send is the local delivery: it is called on the bus goroutine and must not
// block for long, which is why it pushes onto a buffered channel rather than
// writing to the gRPC stream directly.
func (d *commandDispatcher) Serve(ctx context.Context, serverID string) (<-chan *pb.ServerCommand, func()) {
	out := make(chan *pb.ServerCommand, 16)

	envelopes, unsub, err := bus.Subscribe(ctx, bus.CommandChannel+serverID)
	if err != nil {
		// Without a subscription this pod cannot receive commands for the
		// agent. Claiming presence anyway would advertise a stream nobody can
		// reach, which is worse than the agent appearing offline.
		log.Printf("dispatch: subscribe for %s failed, commands will not reach it: %v", serverID, err)
		close(out)
		return out, func() {}
	}

	runCtx, cancel := context.WithCancel(ctx)

	if err := bus.SetPresence(runCtx, serverID, presenceTTL); err != nil {
		log.Printf("dispatch: claim presence for %s: %v", serverID, err)
	}
	go func() {
		t := time.NewTicker(presenceRenew)
		defer t.Stop()
		for {
			select {
			case <-runCtx.Done():
				return
			case <-t.C:
				// Renew only while this pod still holds the claim. Losing it
				// means a newer stream for the same agent was established
				// elsewhere, and this one is a half-open leftover: it must stop
				// renewing rather than overwrite the live owner every 10s.
				if !bus.RenewPresence(runCtx, serverID, presenceTTL) {
					log.Printf("dispatch: presence for %s is held elsewhere, stopping renewal", serverID)
					return
				}
			}
		}
	}()

	go func() {
		for {
			select {
			case <-runCtx.Done():
				return
			case raw, ok := <-envelopes:
				if !ok {
					return
				}
				d.handleEnvelope(runCtx, raw, out)
			}
		}
	}()

	return out, func() {
		cancel()
		unsub()
		relCtx, relCancel := context.WithTimeout(context.Background(), 2*time.Second)
		if err := bus.ClearPresence(relCtx, serverID); err != nil {
			log.Printf("dispatch: release presence for %s: %v", serverID, err)
		}
		relCancel()
	}
}

// handleEnvelope performs the owner-pod side of a dispatch: any local setup the
// command needs, then queueing it for the stream, then the ack.
func (d *commandDispatcher) handleEnvelope(ctx context.Context, raw []byte, out chan *pb.ServerCommand) {
	var env CommandEnvelope
	if err := json.Unmarshal(raw, &env); err != nil {
		log.Printf("dispatch: undecodable envelope: %v", err)
		return
	}
	if env.Command == nil || env.ReplyTo == "" {
		return
	}

	// Not addressed to this pod: stay silent rather than ack. Answering would
	// win the race against the pod that actually holds the agent's stream, and
	// the caller would be told a command succeeded that was queued onto a
	// stream nobody is reading. Silence lets the real owner answer, or lets the
	// request time out as ErrNoResponder, which fails loudly and correctly.
	if env.Node != "" && env.Node != bus.NodeID() {
		return
	}

	ack := CommandAck{OK: true, Node: bus.NodeID()}

	if env.Log != nil {
		if err := StepLogs.Open(env.Command.CommandId, env.Log.RunID, env.Log.ServerID, env.Log.Mask); err != nil {
			log.Printf("dispatch: open step log for %s: %v", env.Command.CommandId, err)
		}
	}

	select {
	case out <- env.Command:
	default:
		ack = CommandAck{OK: false, Error: "command queue full", Node: bus.NodeID()}
	}

	if err := bus.Reply(ctx, env.ReplyTo, ack); err != nil {
		log.Printf("dispatch: reply on %s: %v", env.ReplyTo, err)
	}
}

// IsConnected reports whether any pod holds the agent's command stream.
func (d *commandDispatcher) IsConnected(serverID string) bool {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	return bus.IsConnected(ctx, serverID)
}

func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) error {
	_, err := d.send(CommandEnvelope{ServerID: serverID, Command: cmd})
	return err
}

// send publishes the envelope and waits for the owning pod's ack. A missing
// responder and a refusing responder are both errors: in neither case did the
// command reach the agent.
func (d *commandDispatcher) send(env CommandEnvelope) (CommandAck, error) {
	if env.Command == nil || env.Command.CommandId == "" {
		return CommandAck{}, fmt.Errorf("command id is required")
	}
	env.ReplyTo = bus.AckChannelFor(env.Command.CommandId)

	ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
	defer cancel()

	// Resolved once, here, and carried in the envelope. Reading it at publish
	// time rather than letting subscribers self-select is what makes a stale
	// subscriber harmless: it will see an envelope addressed elsewhere and
	// ignore it.
	env.Node = bus.PresenceHolder(ctx, env.ServerID)
	if env.Node == "" {
		return CommandAck{}, fmt.Errorf("%w: %s", ErrAgentNotConnected, env.ServerID)
	}

	raw, err := bus.Request(ctx, bus.CommandChannel+env.ServerID, env.ReplyTo, env, dispatchAckTimeout)
	if err != nil {
		if errors.Is(err, bus.ErrNoResponder) {
			return CommandAck{}, fmt.Errorf("%w: %s", ErrAgentNotConnected, env.ServerID)
		}
		return CommandAck{}, err
	}

	var ack CommandAck
	if err := json.Unmarshal(raw, &ack); err != nil {
		return CommandAck{}, fmt.Errorf("undecodable ack: %w", err)
	}
	if !ack.OK {
		return ack, fmt.Errorf("agent for server %s: %s", env.ServerID, ack.Error)
	}
	return ack, nil
}

// DispatchRunStep sends a step to the agent and asks the owning pod to capture
// its output. mask holds the secret values that must not reach the log.
func DispatchRunStep(serverID, commandID, runID string, mask []string, cmd *pb.RunStepCmd) error {
	_, err := Dispatcher.send(CommandEnvelope{
		ServerID: serverID,
		Command:  &pb.ServerCommand{CommandId: commandID, RunStep: cmd},
		Log:      &LogRequest{RunID: runID, ServerID: serverID, Mask: mask},
	})
	return err
}

func DispatchCleanupWorkspace(serverID, workspaceID string) {
	if !Dispatcher.IsConnected(serverID) {
		return
	}
	_ = Dispatcher.dispatch(serverID, &pb.ServerCommand{
		CommandId:        uuid.New().String(),
		CleanupWorkspace: &pb.CleanupWorkspaceCmd{WorkspaceId: workspaceID},
	})
}

type KeyGenParams struct {
	Label      string
	KeyType    string
	KeySize    int
	Passphrase string
	Comment    string
}

func GetLatestAgentVersion() (string, error) {
	giteaHost := "gitea.hostxtra.co.uk"
	url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
	resp, err := http.Get(url)
	if err != nil {
		return "", fmt.Errorf("fetch releases: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("Gitea API returned HTTP %d", resp.StatusCode)
	}

	var releases []struct {
		TagName string `json:"tag_name"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
		return "", fmt.Errorf("decode releases: %w", err)
	}

	for _, r := range releases {
		if strings.HasPrefix(r.TagName, "agent/v") {
			return strings.TrimPrefix(r.TagName, "agent/v"), nil
		}
	}
	return "", fmt.Errorf("no agent release found")
}

func DispatchUpdateAgent(serverID string) (string, error) {
	if !Dispatcher.IsConnected(serverID) {
		return "", fmt.Errorf("agent is not connected to the command stream")
	}

	version, err := GetLatestAgentVersion()
	if err != nil {
		return "", fmt.Errorf("get latest version: %w", err)
	}

	giteaHost := "gitea.hostxtra.co.uk"

	cmdID := uuid.New().String()
	cmd := &pb.ServerCommand{
		CommandId: cmdID,
		UpdateAgent: &pb.UpdateAgentCmd{
			Version:      version,
			GiteaBaseURL: "https://" + giteaHost,
		},
	}
	if err := Dispatcher.dispatch(serverID, cmd); err != nil {
		return "", err
	}
	return version, nil
}

func DispatchApplyUpdates(serverID string) error {
	if !Dispatcher.IsConnected(serverID) {
		return fmt.Errorf("agent is not connected to the command stream")
	}
	cmd := &pb.ServerCommand{
		CommandId:    uuid.New().String(),
		ApplyUpdates: &pb.ApplyUpdatesCmd{},
	}
	return Dispatcher.dispatch(serverID, cmd)
}

func DispatchDeleteKey(serverID, label string) {
	if !Dispatcher.IsConnected(serverID) {
		return
	}
	cmd := &pb.ServerCommand{
		CommandId: uuid.New().String(),
		DeleteKey: &pb.DeleteKeyCmd{Label: label},
	}
	if err := Dispatcher.dispatch(serverID, cmd); err != nil {

		_ = err
	}
}

func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
	if !Dispatcher.IsConnected(serverID) {
		return "", fmt.Errorf("agent is not connected to the command stream")
	}
	cmdID := uuid.New().String()
	cmd := &pb.ServerCommand{
		CommandId: cmdID,
		GenerateKey: &pb.GenerateKeyCmd{
			Label:      p.Label,
			KeyType:    p.KeyType,
			KeySize:    p.KeySize,
			Passphrase: p.Passphrase,
			Comment:    p.Comment,
		},
	}
	if err := Dispatcher.dispatch(serverID, cmd); err != nil {
		return "", err
	}
	return cmdID, nil
}
