package delivery

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"hash/fnv"
	"io"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/tidbcloud/alertstore/pkg/agent"
	"github.com/tidbcloud/alertstore/pkg/alert"
)

// AgentSender posts one alert to one agent and records the run it belongs to.
//
// It is the whole of alertstore's knowledge about agent runtimes: a URL, a JSON
// body, and a callback the far side writes its answer to. Nothing here knows
// whether the endpoint fronts a usher session, a team's own service or a shell
// script, which is what lets that choice change without touching this service.
type AgentSender struct {
	subs *agent.SubscriptionStore
	runs *agent.RunStore
	http *http.Client
	// callbackBaseURL is alertstore's own API root as the agent can reach it.
	// Empty disables dispatch outright rather than sending a run the agent has
	// no way to answer.
	callbackBaseURL string
	// holdRate is the share of runs whose verdict is withheld from the human
	// until they have recorded their own resolution.
	//
	// It is here from the first line of agent code rather than from the first
	// dashboard because the sample it produces cannot be created retroactively.
	// A verdict on the card anchors the human's own judgement, so once cards
	// carry verdicts, agreement between the two stops measuring accuracy and
	// starts measuring persuasion — and every label already collected is
	// contaminated the same way. The held slice is the only population where
	// the two are independent, and there is no way back to make one for the
	// past.
	holdRate float64
	// hmacSecret signs the dispatch body. Empty leaves it unsigned — correct for
	// an in-cluster agent reachable only over the private network; set it for an
	// agent behind a public ingress so a forged or replayed dispatch is refused.
	hmacSecret string
	logger     *slog.Logger
}

func NewAgentSender(
	subs *agent.SubscriptionStore,
	runs *agent.RunStore,
	client *http.Client,
	callbackBaseURL string,
	holdRate float64,
	hmacSecret string,
	logger *slog.Logger,
) *AgentSender {
	return &AgentSender{
		subs:            subs,
		runs:            runs,
		http:            client,
		callbackBaseURL: strings.TrimRight(callbackBaseURL, "/"),
		holdRate:        holdRate,
		hmacSecret:      hmacSecret,
		logger:          logger,
	}
}

// Enabled reports whether dispatch is configured. Unconfigured is a supported
// state, not a broken one: alertstore runs its whole alert path with no agent
// attached, and did so before there were any.
func (s *AgentSender) Enabled() bool {
	return s != nil && s.callbackBaseURL != ""
}

// dispatchBody is the contract with every agent, first-party or not. It carries
// identifiers, a handful of alert fields the agent always needs, and where to
// write the answer. The full alert is not embedded — agents that need labels,
// instances, or annotations beyond description pull them via GET /alerts/{id}
// with whatever base URL they know for alertstore, so this contract stays
// independent of the Alert struct's shape and of network topology.
type dispatchBody struct {
	AlertID     string `json:"alert_id"`
	RunID       int64  `json:"run_id"`
	Agent       string `json:"agent"`
	TriggerKind string `json:"trigger_kind"`

	// Alert-level identity from generated columns, stable across instances.
	// Agents needing per-instance labels or annotations pull the full alert
	// by ID.
	Alertname      string `json:"alertname"`
	Classification string `json:"classification"`
	ClusterID      string `json:"cluster_id,omitempty"`
	TenantID       string `json:"tenant_id,omitempty"`
	Description    string `json:"description,omitempty"`
	// Disclosure tells the agent whether its verdict will be shown to the human
	// before they resolve. It is informational — alertstore renders, not the
	// agent — but an agent that knows it is in the held sample can say so in its
	// own logs, which is what makes a contaminated experiment debuggable.
	Disclosure string           `json:"disclosure"`
	Callback   dispatchCallback `json:"callback"`
}

// dispatchCallback is where the agent writes its result. Just an address: the
// endpoint takes no credential, so anything that can reach alertstore can
// report on any run.
type dispatchCallback struct {
	URL string `json:"url"`
}

// Send executes one dispatch task: resolve the agent, ensure its run exists,
// and post. Returns a permanent error for anything a retry cannot fix.
func (s *AgentSender) Send(ctx context.Context, a *alert.Alert, p agentDispatchPayload) (*agent.Run, error) {
	if !s.Enabled() {
		return nil, permanent(fmt.Errorf("agent dispatch is not configured"))
	}

	sub, err := s.findAgent(ctx, p.Agent)
	if err != nil {
		return nil, err
	}
	if sub == nil {
		// Deleted or disabled between enqueue and execution. A real answer:
		// nobody is meant to run this any more.
		return nil, permanent(fmt.Errorf("agent %q is not registered or is disabled", p.Agent))
	}

	disclosure := agent.DisclosureShown
	if s.shouldHold(a.ID, sub.Name) {
		disclosure = agent.DisclosureHeld
	}

	run, err := s.runs.EnsureRun(ctx, a.ID, sub.Name, p.TriggerKind, "", p.ParentRunID, disclosure)
	if err != nil {
		return nil, err
	}

	body := dispatchBody{
		AlertID:        a.ID,
		RunID:          run.ID,
		Agent:          sub.Name,
		TriggerKind:    run.TriggerKind,
		Alertname:      a.Alertname,
		Classification: a.Classification,
		ClusterID:      a.ClusterID,
		TenantID:       a.TenantID,
		Description:    alertDescription(a),
		Disclosure:     run.Disclosure,
		Callback: dispatchCallback{
			URL: fmt.Sprintf("%s/api/v1/alertstore/alerts/%s/agent-runs/%d", s.callbackBaseURL, a.ID, run.ID),
		},
	}
	if err := s.post(ctx, sub.Endpoint, body); err != nil {
		return run, err
	}
	return run, nil
}

func (s *AgentSender) findAgent(ctx context.Context, name string) (*agent.Subscription, error) {
	subs, err := s.subs.List(ctx)
	if err != nil {
		return nil, err
	}
	for i := range subs {
		if subs[i].Name == name && subs[i].Enabled {
			return &subs[i], nil
		}
	}
	return nil, nil
}

func (s *AgentSender) post(ctx context.Context, endpoint string, body dispatchBody) error {
	// The same check the cards use for the links they render, deliberately not a
	// private copy of it: this is the one place alertstore makes an outbound
	// request to an address out of the database, so if that check is ever
	// hardened — link-local and metadata addresses are the obvious next step —
	// the hardening has to reach here rather than a weaker twin.
	if safeHTTPURL(endpoint) == "" {
		return permanent(fmt.Errorf("agent %q has an unusable endpoint %q", body.Agent, endpoint))
	}
	payload, err := json.Marshal(body)
	if err != nil {
		return permanent(fmt.Errorf("marshal agent dispatch: %w", err))
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
	if err != nil {
		return permanent(fmt.Errorf("build agent dispatch request: %w", err))
	}
	req.Header.Set("Content-Type", "application/json")
	if s.hmacSecret != "" {
		req.Header.Set(dispatchSignatureHeader, signDispatch(s.hmacSecret, time.Now().Unix(), payload))
	}

	resp, err := s.http.Do(req)
	if err != nil {
		return fmt.Errorf("dispatch to agent %s: %w", body.Agent, err)
	}
	defer resp.Body.Close()
	// Drain enough to describe the failure without letting an agent that
	// answers with a stack trace decide how much memory this costs.
	snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))

	switch {
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		return nil
	case resp.StatusCode == http.StatusTooManyRequests:
		// The agent is up and asking for time, which is exactly what the
		// outbox's backoff is for.
		return fmt.Errorf("agent %s is rate limiting (429)", body.Agent)
	case resp.StatusCode >= 400 && resp.StatusCode < 500:
		// The agent understood and refused. Eight retries across forty-five
		// minutes will be refused identically.
		return permanent(fmt.Errorf("agent %s rejected the dispatch: %d %s",
			body.Agent, resp.StatusCode, strings.TrimSpace(string(snippet))))
	default:
		return fmt.Errorf("agent %s returned %d: %s",
			body.Agent, resp.StatusCode, strings.TrimSpace(string(snippet)))
	}
}

// shouldHold decides the blind sample, deterministically from the alert and the
// agent so a retry cannot flip a run between shown and held — which would put
// the same run in both populations and make the comparison meaningless.
func (s *AgentSender) shouldHold(alertID, agentName string) bool {
	if s.holdRate <= 0 {
		return false
	}
	if s.holdRate >= 1 {
		return true
	}
	h := fnv.New32a()
	_, _ = h.Write([]byte(alertID + "\x00" + agentName))
	return float64(h.Sum32()%10000)/10000 < s.holdRate
}

func alertDescription(a *alert.Alert) string {
	var attrs alert.AlertAttrs
	if err := json.Unmarshal(a.Attrs, &attrs); err != nil {
		return ""
	}
	return attrs.Description
}
