package api

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

	"github.com/go-chi/chi/v5"

	"github.com/alexei/tinyforge/internal/auth"
	"github.com/alexei/tinyforge/internal/store"
	"github.com/alexei/tinyforge/internal/webhook"
	"github.com/alexei/tinyforge/internal/workload/plugin"
)

// fireInFlight tracks trigger IDs that have a fire-now request actively
// running so a runaway script or rapid double-click doesn't queue
// duplicate deploys. Keyed by trigger ID; entries are added under the
// mutex and removed by the handler's defer. Sufficient for an admin
// gate — a real rate limiter belongs at the middleware layer, not here.
var (
	fireInFlightMu sync.Mutex
	fireInFlight   = map[string]struct{}{}
)

// triggerView is the response shape for /api/triggers. Webhook secrets
// are never serialized — read them via the dedicated /webhook subresource
// where the canonical URL is composed.
type triggerView struct {
	ID                      string          `json:"id"`
	Kind                    string          `json:"kind"`
	Name                    string          `json:"name"`
	Config                  json.RawMessage `json:"config"`
	WebhookEnabled          bool            `json:"webhook_enabled"`
	WebhookRequireSignature bool            `json:"webhook_require_signature"`
	BindingCount            int             `json:"binding_count"`
	// LastFiredAt is the RFC3339 wall-clock the scheduler last
	// dispatched this trigger. Always present in the response shape;
	// empty for triggers that have never fired or are not scheduler-
	// driven. The detail page renders it as "last fired" on schedule
	// triggers; other kinds ignore it.
	LastFiredAt string `json:"last_fired_at"`
	CreatedAt   string `json:"created_at"`
	UpdatedAt   string `json:"updated_at"`
}

func (s *Server) toTriggerView(t store.Trigger) triggerView {
	count, err := s.store.CountBindingsForTrigger(t.ID)
	if err != nil {
		slog.Warn("triggerView: count bindings", "trigger", t.ID, "error", err)
	}
	return triggerView{
		ID:                      t.ID,
		Kind:                    t.Kind,
		Name:                    t.Name,
		Config:                  json.RawMessage(t.Config),
		WebhookEnabled:          t.WebhookSecret != "",
		WebhookRequireSignature: t.WebhookRequireSignature,
		BindingCount:            count,
		LastFiredAt:             t.LastFiredAt,
		CreatedAt:               t.CreatedAt,
		UpdatedAt:               t.UpdatedAt,
	}
}

// toTriggerViewWithCount is the join-aware variant used by listTriggers
// to avoid one COUNT(*) per row. Kept distinct from toTriggerView so
// single-row paths (get/create/update) keep the simple call shape.
func toTriggerViewWithCount(row store.TriggerWithBindingCount) triggerView {
	return triggerView{
		ID:                      row.ID,
		Kind:                    row.Kind,
		Name:                    row.Name,
		Config:                  json.RawMessage(row.Config),
		WebhookEnabled:          row.WebhookSecret != "",
		WebhookRequireSignature: row.WebhookRequireSignature,
		BindingCount:            row.BindingCount,
		LastFiredAt:             row.LastFiredAt,
		CreatedAt:               row.CreatedAt,
		UpdatedAt:               row.UpdatedAt,
	}
}

// triggerRequest is the create/update body. Config is opaque per kind.
// Auto-generates a webhook secret on create when WebhookEnabled is true;
// the secret is exposed only via the /webhook subresource.
//
// WebhookRequireSignature is a *bool so we can distinguish "field omitted
// by client" (nil → apply secure default of true when webhook is enabled)
// from an explicit opt-out (false → respected).
type triggerRequest struct {
	Kind                    string          `json:"kind"`
	Name                    string          `json:"name"`
	Config                  json.RawMessage `json:"config"`
	WebhookEnabled          bool            `json:"webhook_enabled"`
	WebhookRequireSignature *bool           `json:"webhook_require_signature,omitempty"`
}

// Same per-blob caps used on the workload pluginWorkloadRequest path —
// triggers and workload trigger configs share the same plugin Validate()
// call, so the byte budget should match.
const maxTriggerStandaloneConfigBytes = 16 << 10

func (s *Server) listTriggers(w http.ResponseWriter, r *http.Request) {
	kind := r.URL.Query().Get("kind")
	rows, err := s.store.ListTriggersWithBindingCount(kind)
	if err != nil {
		slog.Error("list triggers", "error", err)
		respondError(w, http.StatusInternalServerError, "list triggers")
		return
	}
	out := make([]triggerView, 0, len(rows))
	for _, t := range rows {
		out = append(out, toTriggerViewWithCount(t))
	}
	respondJSON(w, http.StatusOK, out)
}

func (s *Server) getTrigger(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	t, err := s.store.GetTriggerByID(id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "get trigger")
		return
	}
	respondJSON(w, http.StatusOK, s.toTriggerView(t))
}

// buildTriggerFromRequest assembles a store.Trigger ready for insert.
// Centralized so the standalone create endpoint and the inline-bind
// endpoint cannot drift on secret-generation defaults.
//
// SECURITY: a new trigger with webhook enabled defaults to require_signature
// = true. Operators can opt out at create time for receivers that do not
// support HMAC, but the safer default avoids the "freshly-created trigger
// accepts unsigned posts to its URL" footgun.
func buildTriggerFromRequest(req triggerRequest) store.Trigger {
	// Secure default: if webhook is enabled and the operator did NOT
	// explicitly set require_signature, force it on. Explicit false is
	// preserved (legacy receivers without HMAC support still work).
	requireSig := false
	if req.WebhookRequireSignature != nil {
		requireSig = *req.WebhookRequireSignature
	} else if req.WebhookEnabled {
		requireSig = true
	}
	t := store.Trigger{
		Kind:                    req.Kind,
		Name:                    strings.TrimSpace(req.Name),
		Config:                  string(req.Config),
		WebhookRequireSignature: requireSig,
	}
	if req.WebhookEnabled {
		t.WebhookSecret = generateWebhookSecret()
		t.WebhookSigningSecret = generateWebhookSecret()
	}
	return t
}

func (s *Server) createTrigger(w http.ResponseWriter, r *http.Request) {
	var req triggerRequest
	if !decodeJSONStrict(w, r, &req) {
		return
	}
	if err := validateTriggerRequest(req); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	created, err := s.store.CreateTrigger(buildTriggerFromRequest(req))
	if err != nil {
		slog.Error("create trigger", "error", err)
		// UNIQUE name collision is the most common user-facing failure.
		if errors.Is(err, store.ErrUnique) {
			respondError(w, http.StatusConflict, "a trigger with this name already exists")
			return
		}
		respondError(w, http.StatusInternalServerError, "create trigger")
		return
	}
	respondJSON(w, http.StatusCreated, s.toTriggerView(created))
}

func (s *Server) updateTrigger(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	existing, err := s.store.GetTriggerByID(id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "get trigger")
		return
	}
	var req triggerRequest
	if !decodeJSONStrict(w, r, &req) {
		return
	}
	// Kind is immutable on update. Mirror the value from the existing
	// row so validateTriggerRequest can still verify the config blob.
	req.Kind = existing.Kind
	if err := validateTriggerRequest(req); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	if req.Name != "" {
		existing.Name = strings.TrimSpace(req.Name)
	}
	if len(req.Config) > 0 {
		existing.Config = string(req.Config)
	}
	if req.WebhookRequireSignature != nil {
		existing.WebhookRequireSignature = *req.WebhookRequireSignature
	} else if req.WebhookEnabled && !existing.WebhookRequireSignature {
		// Re-enabling webhook without specifying the signature flag —
		// take the secure default.
		existing.WebhookRequireSignature = true
	}
	wasEnabled := existing.WebhookSecret != ""
	if req.WebhookEnabled && !wasEnabled {
		// false→true transition: rotate both secrets so re-enabling
		// after a disable does not silently revive an old leaked URL.
		existing.WebhookSecret = generateWebhookSecret()
		existing.WebhookSigningSecret = generateWebhookSecret()
	}
	if !req.WebhookEnabled {
		existing.WebhookSecret = ""
		existing.WebhookSigningSecret = ""
	}
	if err := s.store.UpdateTrigger(existing); err != nil {
		slog.Error("update trigger", "error", err)
		if errors.Is(err, store.ErrUnique) {
			respondError(w, http.StatusConflict, "a trigger with this name already exists")
			return
		}
		respondError(w, http.StatusInternalServerError, "update trigger")
		return
	}
	respondJSON(w, http.StatusOK, s.toTriggerView(existing))
}

func (s *Server) deleteTrigger(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	if err := s.store.DeleteTrigger(id); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "delete trigger")
		return
	}
	respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}

// triggerWebhookView surfaces the inbound URL for a trigger. Returns
// empty path / secret when the trigger has webhook ingress disabled.
type triggerWebhookView struct {
	URL                     string `json:"url"`
	Secret                  string `json:"secret"`
	WebhookRequireSignature bool   `json:"webhook_require_signature"`
}

func (s *Server) getTriggerWebhook(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	t, err := s.store.GetTriggerByID(id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "get trigger")
		return
	}
	view := triggerWebhookView{
		Secret:                  t.WebhookSecret,
		WebhookRequireSignature: t.WebhookRequireSignature,
	}
	if t.WebhookSecret != "" {
		view.URL = "/api/webhook/triggers/" + t.WebhookSecret
	}
	respondJSON(w, http.StatusOK, view)
}

// fireTriggerNow dispatches a trigger immediately without waiting for
// its next natural fire window. Used by the /triggers/[id] "Fire now"
// button so an operator can re-test a fixed broken deploy without
// waiting one full schedule interval.
//
// Scope: schedule triggers only. Other kinds (registry / git / manual)
// already have their own dispatch paths — registry/git fire on real
// inbound events, manual fires from the workload Deploy button. Adding
// "fire-now" for those would duplicate those flows without adding new
// capability.
//
// Side effect: updates last_fired_at to "now" (same persist-before-
// dispatch ordering the scheduler uses) so the natural next-fire
// window shifts forward by exactly the interval. This is the
// principle-of-least-surprise behavior — an operator who fires now
// is intentionally resetting the cadence.
func (s *Server) fireTriggerNow(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")

	// Per-trigger in-flight guard. AdminOnly + UI throttle is the only
	// gate against rapid double-clicks; without this guard a runaway
	// script could queue parallel fans-out of the same schedule, each
	// holding up to maxTriggerFanOutConcurrency deployer slots.
	// Returning 429 lets the client distinguish "already running" from
	// a real validation error.
	fireInFlightMu.Lock()
	if _, busy := fireInFlight[id]; busy {
		fireInFlightMu.Unlock()
		respondError(w, http.StatusTooManyRequests,
			"a fire is already in progress for this trigger")
		return
	}
	fireInFlight[id] = struct{}{}
	fireInFlightMu.Unlock()
	defer func() {
		fireInFlightMu.Lock()
		delete(fireInFlight, id)
		fireInFlightMu.Unlock()
	}()

	trg, err := s.store.GetTriggerByID(id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "failed to load trigger")
		return
	}
	if trg.Kind != "schedule" {
		respondError(w, http.StatusBadRequest,
			"fire-now is only supported for schedule triggers")
		return
	}

	// AdminOnly middleware guarantees claims; treat their absence as a
	// boot-time wiring bug rather than fall back to an unattributable
	// "manual" string that collides with the `manual` trigger kind in
	// audit logs.
	claims, ok := auth.ClaimsFromContext(r.Context())
	if !ok || claims.Username == "" {
		slog.Error("fire-now: missing claims under AdminOnly", "trigger", trg.Name)
		respondError(w, http.StatusInternalServerError, "missing auth context")
		return
	}
	actor := claims.Username

	now := time.Now().UTC()
	if err := s.store.SetTriggerLastFired(trg.ID, now.Format(time.RFC3339)); err != nil {
		respondError(w, http.StatusInternalServerError, "persist last_fired_at")
		return
	}

	evt := plugin.InboundEvent{
		Kind:     "schedule",
		Schedule: &plugin.ScheduleEvent{FiredAt: now},
	}
	results, err := s.webhook.FanOutForTrigger(r.Context(), trg, evt)
	if err != nil {
		slog.Warn("fire-now: fan-out failed",
			"trigger", trg.Name, "actor", actor, "error", err)
		// Don't expose the raw error — it can carry registry-auth or
		// compose-stdout bytes (matches the manual-deploy handler).
		respondError(w, http.StatusInternalServerError, "fire failed; see server logs")
		return
	}

	var deployed, errored int
	for _, b := range results {
		switch {
		case b.Deployed:
			deployed++
		case b.Reason == webhook.ReasonBindingDisabled, b.Reason == webhook.ReasonNoMatch:
			// silent
		default:
			errored++
		}
	}
	// Empty fan-out (no bindings) is almost certainly an operator
	// mistake — the UI button is gated on binding_count>0, but the
	// counts can change between page load and click. Warn so the
	// no-op shows up in audit logs.
	if len(results) == 0 {
		slog.Warn("fire-now: no bindings to fire",
			"trigger", trg.Name, "actor", actor)
	} else {
		slog.Info("fire-now dispatched",
			"trigger", trg.Name, "actor", actor,
			"bindings", len(results), "deployed", deployed, "errored", errored)
	}

	respondJSON(w, http.StatusAccepted, map[string]any{
		"trigger":  trg.Name,
		"fired_at": now.Format(time.RFC3339),
		"bindings": len(results),
		"deployed": deployed,
		"errored":  errored,
	})
}

func (s *Server) regenerateTriggerWebhook(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	secret := generateWebhookSecret()
	if err := s.store.SetTriggerWebhookSecret(id, secret); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "rotate webhook secret")
		return
	}
	respondJSON(w, http.StatusOK, map[string]string{
		"secret": secret,
		"url":    "/api/webhook/triggers/" + secret,
	})
}

// maxBindingConfigBytes caps a per-binding override blob. Smaller than
// the full trigger config — bindings should be lightweight tweaks
// (tag pattern, branch filter), not whole replacement configs.
const maxBindingConfigBytes = 8 << 10

// validateBindingConfig enforces the size cap and runs the trigger
// plugin's Validate() against the merged (trigger.config + binding)
// shape so a malformed override is caught at write time instead of
// silently breaking webhook fan-out at deploy time.
func validateBindingConfig(trg store.Trigger, bindingConfig json.RawMessage) error {
	if len(bindingConfig) > maxBindingConfigBytes {
		return fmt.Errorf("binding_config exceeds %d bytes", maxBindingConfigBytes)
	}
	merged, err := plugin.MergeJSONConfig(json.RawMessage(trg.Config), bindingConfig)
	if err != nil {
		return fmt.Errorf("binding_config: %w", err)
	}
	tp, err := plugin.GetTrigger(trg.Kind)
	if err != nil {
		return err
	}
	return tp.Validate(merged)
}

// validateTriggerRequest type-checks the trigger via the registered
// plugin. Accepts an empty config only when the plugin allows it (e.g.
// the manual trigger).
func validateTriggerRequest(req triggerRequest) error {
	if strings.TrimSpace(req.Kind) == "" {
		return fmt.Errorf("kind is required")
	}
	if strings.TrimSpace(req.Name) == "" {
		return fmt.Errorf("name is required")
	}
	if len(req.Config) > maxTriggerStandaloneConfigBytes {
		return fmt.Errorf("config exceeds %d bytes", maxTriggerStandaloneConfigBytes)
	}
	tp, err := plugin.GetTrigger(req.Kind)
	if err != nil {
		return err
	}
	return tp.Validate(req.Config)
}

// bindingView shapes one binding for the /api/triggers/{id}/bindings
// listing. Includes the workload's name to avoid an N+1 round-trip on
// the frontend.
type bindingView struct {
	ID            string          `json:"id"`
	WorkloadID    string          `json:"workload_id"`
	WorkloadName  string          `json:"workload_name"`
	TriggerID     string          `json:"trigger_id"`
	BindingConfig json.RawMessage `json:"binding_config"`
	Enabled       bool            `json:"enabled"`
	SortOrder     int             `json:"sort_order"`
	CreatedAt     string          `json:"created_at"`
	UpdatedAt     string          `json:"updated_at"`
}

func (s *Server) toBindingView(b store.WorkloadTriggerBinding) bindingView {
	name := ""
	if w, err := s.store.GetWorkloadByID(b.WorkloadID); err == nil {
		name = w.Name
	}
	return bindingView{
		ID:            b.ID,
		WorkloadID:    b.WorkloadID,
		WorkloadName:  name,
		TriggerID:     b.TriggerID,
		BindingConfig: json.RawMessage(b.BindingConfig),
		Enabled:       b.Enabled,
		SortOrder:     b.SortOrder,
		CreatedAt:     b.CreatedAt,
		UpdatedAt:     b.UpdatedAt,
	}
}

func (s *Server) listBindingsForTrigger(w http.ResponseWriter, r *http.Request) {
	tid := chi.URLParam(r, "id")
	if _, err := s.store.GetTriggerByID(tid); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "get trigger")
		return
	}
	rows, err := s.store.ListBindingsForTriggerWithNames(tid)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "list bindings")
		return
	}
	out := make([]bindingView, 0, len(rows))
	for _, b := range rows {
		out = append(out, bindingView{
			ID:            b.ID,
			WorkloadID:    b.WorkloadID,
			WorkloadName:  b.WorkloadName,
			TriggerID:     b.TriggerID,
			BindingConfig: json.RawMessage(b.BindingConfig),
			Enabled:       b.Enabled,
			SortOrder:     b.SortOrder,
			CreatedAt:     b.CreatedAt,
			UpdatedAt:     b.UpdatedAt,
		})
	}
	respondJSON(w, http.StatusOK, out)
}

// bindingRequest is shared by trigger-side bind (POST .../bindings) and
// workload-side bind (POST workloads/{id}/triggers).
type bindingRequest struct {
	WorkloadID    string          `json:"workload_id"`
	TriggerID     string          `json:"trigger_id"`
	BindingConfig json.RawMessage `json:"binding_config"`
	Enabled       *bool           `json:"enabled"`
	SortOrder     int             `json:"sort_order"`
}

func (s *Server) bindWorkloadToTrigger(w http.ResponseWriter, r *http.Request) {
	tid := chi.URLParam(r, "id")
	var req bindingRequest
	if !decodeJSONStrict(w, r, &req) {
		return
	}
	if req.WorkloadID == "" {
		respondError(w, http.StatusBadRequest, "workload_id is required")
		return
	}
	trg, err := s.store.GetTriggerByID(tid)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "get trigger")
		return
	}
	if _, err := s.store.GetWorkloadByID(req.WorkloadID); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "workload")
			return
		}
		respondError(w, http.StatusInternalServerError, "get workload")
		return
	}
	if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	enabled := true
	if req.Enabled != nil {
		enabled = *req.Enabled
	}
	b := store.WorkloadTriggerBinding{
		WorkloadID:    req.WorkloadID,
		TriggerID:     tid,
		BindingConfig: string(req.BindingConfig),
		Enabled:       enabled,
		SortOrder:     req.SortOrder,
	}
	created, err := s.store.CreateBinding(b)
	if err != nil {
		if errors.Is(err, store.ErrUnique) {
			respondError(w, http.StatusConflict, "this workload is already bound to this trigger")
			return
		}
		slog.Error("create binding", "error", err)
		respondError(w, http.StatusInternalServerError, "create binding")
		return
	}
	respondJSON(w, http.StatusCreated, s.toBindingView(created))
}

func (s *Server) updateBinding(w http.ResponseWriter, r *http.Request) {
	bid := chi.URLParam(r, "bid")
	existing, err := s.store.GetBindingByID(bid)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "binding")
			return
		}
		respondError(w, http.StatusInternalServerError, "get binding")
		return
	}
	var req bindingRequest
	if !decodeJSONStrict(w, r, &req) {
		return
	}
	if len(req.BindingConfig) > 0 {
		trg, terr := s.store.GetTriggerByID(existing.TriggerID)
		if terr != nil {
			slog.Error("update binding: trigger lookup", "trigger", existing.TriggerID, "error", terr)
			respondError(w, http.StatusInternalServerError, "trigger lookup")
			return
		}
		if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
			respondError(w, http.StatusBadRequest, err.Error())
			return
		}
		existing.BindingConfig = string(req.BindingConfig)
	}
	if req.Enabled != nil {
		existing.Enabled = *req.Enabled
	}
	existing.SortOrder = req.SortOrder
	if err := s.store.UpdateBinding(existing); err != nil {
		respondError(w, http.StatusInternalServerError, "update binding")
		return
	}
	respondJSON(w, http.StatusOK, s.toBindingView(existing))
}

// listBindingsForWorkload is the workload-side mirror of
// listBindingsForTrigger. Returns every trigger bound to the workload
// in sort_order so the detail page can render them inline.
func (s *Server) listBindingsForWorkload(w http.ResponseWriter, r *http.Request) {
	wid := chi.URLParam(r, "id")
	if _, err := s.store.GetWorkloadByID(wid); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "workload")
			return
		}
		respondError(w, http.StatusInternalServerError, "get workload")
		return
	}
	rows, err := s.store.ListBindingsForWorkloadWithNames(wid)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "list bindings")
		return
	}
	type item struct {
		bindingView
		TriggerKind string `json:"trigger_kind"`
		TriggerName string `json:"trigger_name"`
	}
	out := make([]item, 0, len(rows))
	for _, b := range rows {
		out = append(out, item{
			bindingView: bindingView{
				ID:            b.ID,
				WorkloadID:    b.WorkloadID,
				TriggerID:     b.TriggerID,
				BindingConfig: json.RawMessage(b.BindingConfig),
				Enabled:       b.Enabled,
				SortOrder:     b.SortOrder,
				CreatedAt:     b.CreatedAt,
				UpdatedAt:     b.UpdatedAt,
			},
			TriggerKind: b.TriggerKind,
			TriggerName: b.TriggerName,
		})
	}
	respondJSON(w, http.StatusOK, out)
}

// workloadBindRequest covers the two UX flows: bind an existing trigger
// (TriggerID present) or inline-create one in the same call (TriggerID
// empty + Inline populated). The inline form keeps the 1:1 case feeling
// unchanged from the embedded-trigger era.
type workloadBindRequest struct {
	TriggerID     string          `json:"trigger_id"`
	BindingConfig json.RawMessage `json:"binding_config"`
	Enabled       *bool           `json:"enabled"`
	SortOrder     int             `json:"sort_order"`
	Inline        *triggerRequest `json:"inline"`
}

func (s *Server) bindTriggerToWorkload(w http.ResponseWriter, r *http.Request) {
	wid := chi.URLParam(r, "id")
	if _, err := s.store.GetWorkloadByID(wid); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "workload")
			return
		}
		respondError(w, http.StatusInternalServerError, "get workload")
		return
	}
	var req workloadBindRequest
	if !decodeJSONStrict(w, r, &req) {
		return
	}
	if req.TriggerID == "" && req.Inline == nil {
		respondError(w, http.StatusBadRequest, "either trigger_id or inline trigger is required")
		return
	}

	enabled := true
	if req.Enabled != nil {
		enabled = *req.Enabled
	}

	// Inline path: create trigger + binding atomically so a binding
	// failure cannot leak a half-built trigger row.
	if req.TriggerID == "" {
		if err := validateTriggerRequest(*req.Inline); err != nil {
			respondError(w, http.StatusBadRequest, err.Error())
			return
		}
		_, b, err := s.store.CreateTriggerWithBindingTx(
			buildTriggerFromRequest(*req.Inline),
			store.WorkloadTriggerBinding{
				WorkloadID:    wid,
				BindingConfig: string(req.BindingConfig),
				Enabled:       enabled,
				SortOrder:     req.SortOrder,
			},
		)
		if err != nil {
			if errors.Is(err, store.ErrUnique) {
				respondError(w, http.StatusConflict, "a trigger with this name already exists")
				return
			}
			slog.Error("inline trigger+binding tx", "error", err)
			respondError(w, http.StatusInternalServerError, "create inline trigger+binding")
			return
		}
		respondJSON(w, http.StatusCreated, s.toBindingView(b))
		return
	}

	// Existing-trigger path: just bind.
	trg, err := s.store.GetTriggerByID(req.TriggerID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "get trigger")
		return
	}
	if err := validateBindingConfig(trg, req.BindingConfig); err != nil {
		respondError(w, http.StatusBadRequest, err.Error())
		return
	}
	b, err := s.store.CreateBinding(store.WorkloadTriggerBinding{
		WorkloadID:    wid,
		TriggerID:     req.TriggerID,
		BindingConfig: string(req.BindingConfig),
		Enabled:       enabled,
		SortOrder:     req.SortOrder,
	})
	if err != nil {
		if errors.Is(err, store.ErrUnique) {
			respondError(w, http.StatusConflict, "this workload is already bound to this trigger")
			return
		}
		slog.Error("create binding from workload side", "error", err)
		respondError(w, http.StatusInternalServerError, "create binding")
		return
	}
	respondJSON(w, http.StatusCreated, s.toBindingView(b))
}

func (s *Server) deleteBinding(w http.ResponseWriter, r *http.Request) {
	bid := chi.URLParam(r, "bid")
	if err := s.store.DeleteBinding(bid); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "binding")
			return
		}
		respondError(w, http.StatusInternalServerError, "delete binding")
		return
	}
	respondJSON(w, http.StatusOK, map[string]string{"deleted": bid})
}
