// Package adapter turns an alert dispatched by alertstore into a usher session.
//
// It is the far end of alertstore's agent webhook and nothing more. It never
// waits for the agent and never reads its output: the agent reports its own
// verdict straight back to alertstore, so this process holds no state and can be
// restarted mid-flight without losing a run.
//
// Nothing here knows what the agent will do with the alert. The prompt is read
// from disk per dispatch rather than compiled in, because it is the part that
// gets tuned most and a restart to change a paragraph is a loop nobody keeps
// using.
package adapter

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// Dispatch is alertstore's outbound contract, mirrored. It is not shared as a
// package: this payload is something alertstore sends rather than an API it
// serves, so it appears in no generated client, and the two definitions are
// kept in step by hand. Everything optional is optional here too — an alert
// whose rule carries no runbook_url still dispatches.
type Dispatch struct {
	AlertID     string `json:"alert_id"`
	RunID       int64  `json:"run_id"`
	Agent       string `json:"agent"`
	TriggerKind string `json:"trigger_kind"`

	// New contract (v1): alert-level generated columns.
	Alertname      string `json:"alertname"`
	Classification string `json:"classification"`
	ClusterID      string `json:"cluster_id"`
	TenantID       string `json:"tenant_id"`
	Description    string `json:"description"`

	// Old contract: embedded alert + runbook_url. Kept for backwards
	// compatibility during rollout — the adapter accepts either format.
	Alert      json.RawMessage `json:"alert"`
	RunbookURL string          `json:"runbook_url"`

	Disclosure string   `json:"disclosure"`
	Callback   Callback `json:"callback"`
}

// Callback is where the agent writes this run's result.
type Callback struct {
	URL string `json:"url"`
}

// backfillFromAlert fills the v1 fields from the old embedded alert JSON when
// they are absent, so the adapter handles both old and new dispatch formats.
func (d *Dispatch) backfillFromAlert() {
	if d.Alertname != "" || len(d.Alert) == 0 {
		return
	}
	var a struct {
		Alertname      string          `json:"alertname"`
		Classification string          `json:"classification"`
		ClusterID      string          `json:"cluster_id"`
		TenantID       string          `json:"tenant_id"`
		Attrs          json.RawMessage `json:"attrs"`
	}
	if json.Unmarshal(d.Alert, &a) != nil {
		return
	}
	d.Alertname = a.Alertname
	d.Classification = a.Classification
	d.ClusterID = a.ClusterID
	d.TenantID = a.TenantID
	if a.Attrs != nil {
		var attrs struct {
			Description string `json:"description"`
		}
		if json.Unmarshal(a.Attrs, &attrs) == nil {
			d.Description = attrs.Description
		}
	}
}

func (d *Dispatch) valid() error {
	switch {
	case d.AlertID == "":
		return fmt.Errorf("alert_id is required")
	case d.Callback.URL == "":
		return fmt.Errorf("callback.url is required")
	}
	return nil
}

// Config is everything the adapter needs to reach the two services around it.
type Config struct {
	// UsherURL is usher's API as this process reaches it. Inside RAP that is
	// 127.0.0.1:3001 — nginx on :3000 is the browser surface.
	UsherURL string
	// UsherPublicURL is usher as a human clicking session_ref reaches it.
	UsherPublicURL string
	// UsherPassword is empty when usher runs without one, in which case no
	// login happens at all.
	UsherPassword string
	// PromptPath is re-read on every dispatch.
	PromptPath string
	// Cwd is sent only when set. usher lets a request override the agent
	// profile's own cwd, so anything non-empty here wins over the profile —
	// which is why this defaults to empty rather than to a home directory.
	Cwd       string
	AgentName string
	Backend   string
	Model     string
	// ProxyURL is quoted into the prompt: every query the agent makes goes
	// through the datasource proxy, which holds the credentials so the agent
	// never has to.
	ProxyURL string
	// AllowCIDRs is a comma-separated allowlist of sources permitted to POST
	// /dispatch, matched against the caller's X-Forwarded-For. Empty disables
	// the check — in-cluster the port is only reachable pod to pod and the
	// network is the boundary; the list is for a deployment that fronts
	// /dispatch with a public ingress.
	AllowCIDRs string
	// DispatchHMACSecret, when set, requires every /dispatch to carry a valid
	// HMAC signature from alertstore over its body and a fresh timestamp. Empty
	// disables the check — correct in-cluster, where the network is the boundary;
	// set it behind a public ingress, where the allowlist alone trusts a
	// spoofable X-Forwarded-For and cannot tell a real dispatch from a replayed
	// or tampered one.
	DispatchHMACSecret string
	// AlertstoreBase, when set, is how *this* agent reaches alertstore's API to
	// report back — the base the callback URL is built on, replacing the one in
	// the dispatch. How an agent reaches alertstore (direct, or through which
	// proxy) is a property of where the agent runs, not of alertstore, so it
	// belongs here rather than in alertstore's global callback_base_url. Empty
	// keeps the dispatch's own callback URL (correct for an in-cluster agent).
	AlertstoreBase string
}

type Adapter struct {
	cfg       Config
	usher     *usherClient
	http      *http.Client
	sessions  *sessionTracker
	allowNets []*net.IPNet
	logger    *slog.Logger
}

func New(cfg Config, logger *slog.Logger) *Adapter {
	cfg.UsherURL = strings.TrimRight(cfg.UsherURL, "/")
	cfg.UsherPublicURL = strings.TrimRight(cfg.UsherPublicURL, "/")
	if cfg.UsherPublicURL == "" {
		cfg.UsherPublicURL = cfg.UsherURL
	}
	client := &http.Client{Timeout: 30 * time.Second}
	return &Adapter{
		cfg:       cfg,
		usher:     newUsherClient(cfg.UsherURL, cfg.UsherPassword, client, logger),
		http:      client,
		sessions:  newSessionTracker(),
		allowNets: parseCIDRs(cfg.AllowCIDRs, logger),
		logger:    logger,
	}
}

// parseCIDRs turns the comma-separated allowlist into networks, accepting bare
// IPs as /32 or /128. An invalid entry is logged and skipped rather than
// failing startup, so one typo does not take the adapter down.
func parseCIDRs(raw string, logger *slog.Logger) []*net.IPNet {
	var nets []*net.IPNet
	for _, c := range strings.Split(raw, ",") {
		c = strings.TrimSpace(c)
		if c == "" {
			continue
		}
		if !strings.Contains(c, "/") {
			if strings.Contains(c, ":") {
				c += "/128"
			} else {
				c += "/32"
			}
		}
		_, n, err := net.ParseCIDR(c)
		if err != nil {
			logger.Warn("ignoring invalid dispatch-allow-cidr", "value", c, "err", err)
			continue
		}
		nets = append(nets, n)
	}
	return nets
}

// callbackURL builds the agent-run callback from an alertstore base and the
// run's identifiers, matching alertstore's own route shape.
func callbackURL(base, alertID string, runID int64) string {
	return fmt.Sprintf("%s/api/v1/alertstore/alerts/%s/agent-runs/%d",
		strings.TrimRight(base, "/"), alertID, runID)
}

// Handler is the whole HTTP surface: one route alertstore posts to, and a
// health check.
func (a *Adapter) Handler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	})
	// The allowlist guards only /dispatch, never /healthz: the load balancer
	// health check comes from inside the VPC and must always pass. The signature
	// check sits inside the allowlist so a caller that clears the cheap IP gate
	// still has to prove the dispatch is a real, fresh one from alertstore.
	mux.HandleFunc("POST /dispatch", a.allowlist(a.verifySignature(a.handleDispatch)))
	return mux
}

// allowlist rejects a dispatch whose source is not in AllowCIDRs. An empty list
// disables the check.
func (a *Adapter) allowlist(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if len(a.allowNets) > 0 {
			ip := clientIP(r)
			if ip == nil || !a.allowed(ip) {
				a.logger.Warn("dispatch from disallowed source",
					"remote", r.RemoteAddr, "xff", r.Header.Get("X-Forwarded-For"))
				writeJSON(w, http.StatusForbidden, map[string]string{"error": "source not allowed"})
				return
			}
		}
		next(w, r)
	}
}

func (a *Adapter) allowed(ip net.IP) bool {
	for _, n := range a.allowNets {
		if n.Contains(ip) {
			return true
		}
	}
	return false
}

// clientIP is the caller's address for allowlisting. Behind the ALB the real
// source is the right-most X-Forwarded-For entry — the one the ALB appended for
// the TCP peer it accepted — so a client-supplied XFF cannot spoof it. With no
// XFF (direct pod to pod) it falls back to the connection peer.
func clientIP(r *http.Request) net.IP {
	if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		parts := strings.Split(xff, ",")
		if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
			return ip
		}
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		host = r.RemoteAddr
	}
	return net.ParseIP(host)
}

// handleDispatch starts the session and answers immediately.
//
// The status codes are chosen against alertstore's retry taxonomy and should
// not be changed casually. 4xx is permanent there and stops the outbox, which
// is right for a payload that will not parse on the eighth attempt either; 5xx
// is retried with backoff, which is exactly what usher being down deserves.
func (a *Adapter) handleDispatch(w http.ResponseWriter, r *http.Request) {
	var d Dispatch
	if err := json.NewDecoder(io.LimitReader(r.Body, 4<<20)).Decode(&d); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: " + err.Error()})
		return
	}
	d.backfillFromAlert()
	if err := d.valid(); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}

	// Build the callback URL from this agent's own alertstore base rather than
	// trusting the one alertstore put in the dispatch, which is global and can
	// name a route this agent cannot reach. Empty base keeps the dispatch's URL.
	if a.cfg.AlertstoreBase != "" {
		d.Callback.URL = callbackURL(a.cfg.AlertstoreBase, d.AlertID, d.RunID)
	}

	a.logger.Info("dispatch",
		"alert_id", d.AlertID, "run_id", d.RunID, "agent", d.Agent, "disclosure", d.Disclosure)

	prompt, err := a.buildPrompt(&d)
	if err != nil {
		// An unreadable prompt file is this deployment being broken, not the
		// request. Retrying is the right thing: the file may come back.
		a.logger.Error("build prompt", "err", err, "alert_id", d.AlertID)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}

	// One session per run, however many times alertstore delivers it. Its outbox
	// retries a dispatch that timed out, and usher spawning an agent process can
	// take longer than that timeout — so without this a slow success turns into
	// two agents investigating the same alert and overwriting each other.
	sessionID, created, err := a.sessions.start(r.Context(), d.AlertID, d.RunID,
		func(ctx context.Context) (string, error) {
			return a.usher.startSession(ctx, sessionRequest{
				Agent:          a.cfg.AgentName,
				Cwd:            a.cfg.Cwd,
				Backend:        a.cfg.Backend,
				Model:          a.cfg.Model,
				InitialMessage: prompt,
				// Nobody is watching this session, so a permission prompt would
				// hang it forever rather than pausing it for someone to answer.
				AutoApprove: boolPtr(true),
			})
		})
	if err != nil {
		a.logger.Error("start session", "err", err, "alert_id", d.AlertID)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
		return
	}

	sessionRef := a.cfg.UsherPublicURL + "/#/s/" + url.PathEscape(sessionID)
	if !created {
		// A redelivery of a dispatch already in flight. Answer with the session
		// it already has and do nothing else: the running report went out with
		// the first one, and repeating it would only overwrite a session_ref
		// that is already correct.
		a.logger.Info("dispatch already in flight, returning existing session",
			"alert_id", d.AlertID, "run_id", d.RunID, "session", sessionID)
		writeJSON(w, http.StatusAccepted, map[string]string{
			"session_id":  sessionID,
			"session_ref": sessionRef,
		})
		return
	}
	a.logger.Info("session started", "alert_id", d.AlertID, "session", sessionID, "ref", sessionRef)

	// Reporting `running` before the agent has done anything proves the
	// callback works — the URL and the network path — while a failure is still
	// cheap to read, instead of surfacing it after a long diagnosis. It also
	// fills in session_ref centrally, so the link a human clicks is right even
	// when the agent forgets to send one.
	//
	// Detached and best-effort: the session is already running, and alertstore
	// being briefly unreachable must not undo work that has begun.
	go func() {
		ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 15*time.Second)
		defer cancel()
		if err := a.reportRunning(ctx, d.Callback, sessionRef); err != nil {
			a.logger.Warn("running report failed", "err", err, "alert_id", d.AlertID)
		}
	}()

	writeJSON(w, http.StatusAccepted, map[string]string{
		"session_id":  sessionID,
		"session_ref": sessionRef,
	})
}

func (a *Adapter) reportRunning(ctx context.Context, cb Callback, sessionRef string) error {
	body, err := json.Marshal(map[string]string{"status": "running", "session_ref": sessionRef})
	if err != nil {
		return err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPatch, cb.URL, bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := a.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
		return fmt.Errorf("callback rejected the running report: %d %s",
			resp.StatusCode, strings.TrimSpace(string(snippet)))
	}
	return nil
}

// buildPrompt reads the template fresh so a retuned prompt takes effect on the
// next alert rather than the next restart.
func (a *Adapter) buildPrompt(d *Dispatch) (string, error) {
	raw, err := os.ReadFile(a.cfg.PromptPath)
	if err != nil {
		return "", fmt.Errorf("read prompt %s: %w", a.cfg.PromptPath, err)
	}
	return renderPrompt(string(raw), d, a.cfg.ProxyURL), nil
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func boolPtr(b bool) *bool { return &b }
