// announce.go — agent-side announce-and-approve enrolment (P2-18c).
//
// Run path: when the agent has no AgentToken set but RM_SERVER is
// configured (and no -enroll-token was supplied), main() switches
// into announce mode:
//  1. Load (or mint+persist) an Ed25519 keypair in agent.yaml.
//  2. POST {hostname, os, arch, agent_version, restic_version,
//     public_key} to /api/agents/announce.
//  3. Print the fingerprint to stderr in a copy-friendly banner so
//     the operator can compare it against the dashboard.
//  4. Open /ws/agent/pending?pending_id=…, sign the nonce with our
//     private key, wait for an `enrolled` message.
//  5. On enrolled: persist the bearer + repo creds, return; main()
//     then drops into the normal WS run loop with the new bearer.
//  6. On reject: server closes the socket with code 4001; we exit
//     with a clear message.
package main

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	stdhttp "net/http"
	"os"
	"strings"
	"time"

	"github.com/coder/websocket"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/config"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/secrets"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/agent/sysinfo"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// announceRequest mirrors the server's announceRequest. Duplicated
// here so cmd/agent stays decoupled from the http package.
type announceRequest struct {
	Hostname      string `json:"hostname"`
	OS            string `json:"os"`
	Arch          string `json:"arch"`
	AgentVersion  string `json:"agent_version"`
	ResticVersion string `json:"restic_version"`
	PublicKey     string `json:"public_key"`
}

type announceResponse struct {
	PendingID         string `json:"pending_id"`
	Fingerprint       string `json:"fingerprint"`
	HostnameCollision bool   `json:"hostname_collision"`
}

type pendingNonceMessage struct {
	Type  string `json:"type"`
	Nonce string `json:"nonce"`
}

type pendingSignedMessage struct {
	Type      string `json:"type"`
	Signature string `json:"signature"`
}

type pendingEnrolledMessage struct {
	Type   string `json:"type"`
	HostID string `json:"host_id"`
	Bearer string `json:"bearer"`
}

// doAnnounce runs the full announce → wait-for-accept flow. On
// success, persists the bearer + host_id into cfg + writes secrets
// for the repo creds the admin supplied at accept time. Returns
// only after the bearer has landed (or on hard error / reject).
func doAnnounce(serverURL string, cfg *config.Config, agentVersion string) error {
	ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour)
	defer cancel()

	// Ensure we have a keypair.
	priv, pub, err := loadOrMintAnnounceKey(cfg)
	if err != nil {
		return fmt.Errorf("announce: keypair: %w", err)
	}
	fingerprint := store.FingerprintForKey(pub)

	snap, err := sysinfo.Collect(ctx, cfg.ResticPath)
	if err != nil {
		return fmt.Errorf("announce: sysinfo: %w", err)
	}

	// POST /api/agents/announce.
	body, _ := json.Marshal(announceRequest{
		Hostname: snap.Hostname, OS: string(snap.OS), Arch: string(snap.Arch),
		AgentVersion: agentVersion, ResticVersion: snap.ResticVersion,
		PublicKey: base64.StdEncoding.EncodeToString(pub),
	})
	req, _ := stdhttp.NewRequestWithContext(ctx, "POST",
		strings.TrimRight(serverURL, "/")+"/api/agents/announce",
		strings.NewReader(string(body)))
	req.Header.Set("Content-Type", "application/json")
	res, err := stdhttp.DefaultClient.Do(req)
	if err != nil {
		return fmt.Errorf("announce: POST: %w", err)
	}
	rawBody := readAllShort(res)
	_ = res.Body.Close()
	if res.StatusCode != stdhttp.StatusOK {
		return fmt.Errorf("announce: server returned %d: %s", res.StatusCode, rawBody)
	}
	var ar announceResponse
	if err := json.Unmarshal(rawBody, &ar); err != nil {
		return fmt.Errorf("announce: parse response: %w", err)
	}

	// Print the fingerprint banner.
	fmt.Fprintln(os.Stderr, strings.Repeat("=", 64))
	fmt.Fprintln(os.Stderr, "  Restic-manager: announce-and-approve enrolment")
	fmt.Fprintln(os.Stderr, "")
	fmt.Fprintln(os.Stderr, "  Hostname    : "+snap.Hostname)
	fmt.Fprintln(os.Stderr, "  Server      : "+serverURL)
	fmt.Fprintln(os.Stderr, "  Pending ID  : "+ar.PendingID)
	fmt.Fprintln(os.Stderr, "  Fingerprint : "+fingerprint)
	if ar.HostnameCollision {
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "  WARNING: another pending host already uses this hostname.")
		fmt.Fprintln(os.Stderr, "  Confirm the fingerprint above matches what you see in the UI.")
	}
	fmt.Fprintln(os.Stderr, "")
	fmt.Fprintln(os.Stderr, "  Compare the fingerprint with the one in the UI before accepting.")
	fmt.Fprintln(os.Stderr, "  Waiting for an admin to accept (1 hour timeout)…")
	fmt.Fprintln(os.Stderr, strings.Repeat("=", 64))

	// Open /ws/agent/pending and run the nonce-sign handshake.
	wsURL := wsURLFromHTTP(serverURL) + "/ws/agent/pending?pending_id=" + ar.PendingID
	dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
	c, dialRes, err := websocket.Dial(dialCtx, wsURL, nil)
	dialCancel()
	if err != nil {
		return fmt.Errorf("announce: dial pending ws: %w", err)
	}
	if dialRes != nil && dialRes.Body != nil {
		_ = dialRes.Body.Close()
	}
	defer func() { _ = c.CloseNow() }()

	// Read nonce.
	rctx, rcancel := context.WithTimeout(ctx, 30*time.Second)
	_, raw, err := c.Read(rctx)
	rcancel()
	if err != nil {
		return fmt.Errorf("announce: read nonce: %w", err)
	}
	var nm pendingNonceMessage
	if err := json.Unmarshal(raw, &nm); err != nil {
		return fmt.Errorf("announce: parse nonce: %w", err)
	}
	nonce, err := base64.StdEncoding.DecodeString(nm.Nonce)
	if err != nil {
		return fmt.Errorf("announce: decode nonce: %w", err)
	}
	sig := ed25519.Sign(priv, nonce)
	reply, _ := json.Marshal(pendingSignedMessage{
		Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig),
	})
	wctx, wcancel := context.WithTimeout(ctx, 10*time.Second)
	if err := c.Write(wctx, websocket.MessageText, reply); err != nil {
		wcancel()
		return fmt.Errorf("announce: write signed nonce: %w", err)
	}
	wcancel()

	// Block until enrolled (or reject / disconnect).
	rctx2, rcancel2 := context.WithTimeout(ctx, 1*time.Hour)
	defer rcancel2()
	_, raw2, err := c.Read(rctx2)
	if err != nil {
		// CloseError with our reject code 4001 = admin rejected.
		var ce websocket.CloseError
		if errors.As(err, &ce) && ce.Code == 4001 {
			return errors.New("announce: rejected by admin")
		}
		return fmt.Errorf("announce: wait for enrolled: %w", err)
	}
	var em pendingEnrolledMessage
	if err := json.Unmarshal(raw2, &em); err != nil {
		return fmt.Errorf("announce: parse enrolled: %w", err)
	}
	if em.Type != "enrolled" || em.Bearer == "" {
		return fmt.Errorf("announce: bad enrolled payload: %s", raw2)
	}

	// Persist the bearer + host_id.
	cfg.ServerURL = serverURL
	cfg.HostID = em.HostID
	cfg.AgentToken = em.Bearer
	if err := cfg.EnsureSecretsKey(); err != nil {
		return fmt.Errorf("announce: mint secrets key: %w", err)
	}
	// Note: repo creds aren't pushed in the enrolled message — the
	// server pushes them via `config.update` on first WS hello. The
	// secrets store will start empty and fill in then.
	if err := cfg.Save(); err != nil {
		return fmt.Errorf("announce: save config: %w", err)
	}
	// Touch the secrets store so it exists with the right perms.
	keyBytes, _ := cfg.SecretsKeyBytes()
	if _, err := secrets.New(cfg.ResolvedSecretsPath(), keyBytes); err != nil {
		return fmt.Errorf("announce: open secrets store: %w", err)
	}
	fmt.Fprintln(os.Stderr, "Accepted. Bearer persisted; reconnecting via the standard WS.")
	return nil
}

// loadOrMintAnnounceKey returns the (priv, pub) keypair, generating
// + persisting one when AnnounceKey is empty. The private key holds
// the public half in its tail 32 bytes per ed25519 convention.
func loadOrMintAnnounceKey(cfg *config.Config) (ed25519.PrivateKey, ed25519.PublicKey, error) {
	if cfg.AnnounceKey != "" {
		raw, err := base64.StdEncoding.DecodeString(cfg.AnnounceKey)
		if err != nil {
			return nil, nil, fmt.Errorf("decode AnnounceKey: %w", err)
		}
		if len(raw) != ed25519.PrivateKeySize {
			return nil, nil, fmt.Errorf("AnnounceKey must be %d bytes, got %d",
				ed25519.PrivateKeySize, len(raw))
		}
		priv := ed25519.PrivateKey(raw)
		pub := priv.Public().(ed25519.PublicKey)
		return priv, pub, nil
	}
	pub, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, nil, fmt.Errorf("generate keypair: %w", err)
	}
	cfg.AnnounceKey = base64.StdEncoding.EncodeToString(priv)
	if err := cfg.Save(); err != nil {
		return nil, nil, fmt.Errorf("persist AnnounceKey: %w", err)
	}
	return priv, pub, nil
}

// wsURLFromHTTP swaps the http(s) scheme for ws(s).
func wsURLFromHTTP(httpURL string) string {
	switch {
	case strings.HasPrefix(httpURL, "https://"):
		return "wss://" + strings.TrimPrefix(httpURL, "https://")
	case strings.HasPrefix(httpURL, "http://"):
		return "ws://" + strings.TrimPrefix(httpURL, "http://")
	default:
		return httpURL
	}
}

// readAllShort reads up to 64KB of the response body. The announce
// response is small; we cap to avoid pathological server replies.
func readAllShort(res *stdhttp.Response) []byte {
	buf := make([]byte, 64*1024)
	n, _ := res.Body.Read(buf)
	return buf[:n]
}
