// Package api: event-trigger HTTP handlers. The dispatcher itself
// lives in internal/events; this file is the REST surface that lets
// operators create, edit, and test triggers from the UI.
package api

import (
	"context"
	"errors"
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strconv"
	"time"

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

	"github.com/alexei/tinyforge/internal/events"
	"github.com/alexei/tinyforge/internal/notify"
	"github.com/alexei/tinyforge/internal/store"
)

// triggerInput is the JSON shape accepted by POST + PATCH. Pointers
// distinguish "absent" from a zero/empty value so PATCH can leave a
// field unchanged. Required fields on POST are validated explicitly.
type triggerInput struct {
	Name               *string `json:"name"`
	FilterSeverity     *string `json:"filter_severity"`
	FilterSource       *string `json:"filter_source"`
	FilterMessageRegex *string `json:"filter_message_regex"`
	ActionType         *string `json:"action_type"`
	ActionTarget       *string `json:"action_target"`
	ActionSecret       *string `json:"action_secret"` // omit = leave unchanged; "" = clear
	Enabled            *bool   `json:"enabled"`
}

// actionSecretPlaceholder is what we return on read to signal "a secret
// is configured" without exposing the actual value. The edit page
// preserves this placeholder verbatim (or replaces it with a new value)
// — the API treats the placeholder as "no change" on PATCH. This is
// the same shape Stripe / GitHub use for their secret read APIs.
const actionSecretPlaceholder = "********"

// listEventTriggers handles GET /api/event-triggers. Secrets are
// redacted to avoid exposing them on read; the edit page shows a
// "configured" indicator when a placeholder is present.
func (s *Server) listEventTriggers(w http.ResponseWriter, r *http.Request) {
	out, err := s.store.ListEventTriggers()
	if err != nil {
		respondError(w, http.StatusInternalServerError, "list event triggers")
		return
	}
	for i := range out {
		out[i] = redactTriggerSecret(out[i])
	}
	respondJSON(w, http.StatusOK, out)
}

// getEventTrigger handles GET /api/event-triggers/{id}.
func (s *Server) getEventTrigger(w http.ResponseWriter, r *http.Request) {
	id, ok := parseTriggerID(w, r)
	if !ok {
		return
	}
	t, err := s.store.GetEventTrigger(id)
	if err != nil {
		mapStoreError(w, err, "event trigger")
		return
	}
	respondJSON(w, http.StatusOK, redactTriggerSecret(t))
}

// createEventTrigger handles POST /api/event-triggers.
func (s *Server) createEventTrigger(w http.ResponseWriter, r *http.Request) {
	var in triggerInput
	if !decodeJSON(w, r, &in) {
		return
	}
	t := store.EventTrigger{
		Name:               derefString(in.Name),
		FilterSeverity:     derefString(in.FilterSeverity),
		FilterSource:       derefString(in.FilterSource),
		FilterMessageRegex: derefString(in.FilterMessageRegex),
		ActionType:         firstNonEmpty(derefString(in.ActionType), store.EventTriggerActionWebhook),
		ActionTarget:       derefString(in.ActionTarget),
		ActionSecret:       derefString(in.ActionSecret),
		Enabled:            in.Enabled == nil || *in.Enabled,
	}
	if msg := validateTrigger(t); msg != "" {
		respondError(w, http.StatusBadRequest, msg)
		return
	}
	out, err := s.store.CreateEventTrigger(t)
	if err != nil {
		// CreateEventTrigger returns validation-shaped errors plus
		// raw DB errors. Validation already ran above, so anything
		// here is a server-side problem — surface as 500 and avoid
		// echoing driver text to the client.
		respondError(w, http.StatusInternalServerError, "create event trigger")
		return
	}
	respondJSON(w, http.StatusCreated, redactTriggerSecret(out))
}

// updateEventTrigger handles PATCH /api/event-triggers/{id}. Each
// field on the input is optional (pointer); absent fields are left
// unchanged. ActionSecret receives special treatment so the read-side
// placeholder round-trips safely.
func (s *Server) updateEventTrigger(w http.ResponseWriter, r *http.Request) {
	id, ok := parseTriggerID(w, r)
	if !ok {
		return
	}
	existing, err := s.store.GetEventTrigger(id)
	if err != nil {
		mapStoreError(w, err, "event trigger")
		return
	}

	var in triggerInput
	if !decodeJSON(w, r, &in) {
		return
	}
	if in.Name != nil {
		existing.Name = *in.Name
	}
	if in.FilterSeverity != nil {
		existing.FilterSeverity = *in.FilterSeverity
	}
	if in.FilterSource != nil {
		existing.FilterSource = *in.FilterSource
	}
	if in.FilterMessageRegex != nil {
		existing.FilterMessageRegex = *in.FilterMessageRegex
	}
	if in.ActionType != nil && *in.ActionType != "" {
		existing.ActionType = *in.ActionType
	}
	if in.ActionTarget != nil {
		existing.ActionTarget = *in.ActionTarget
	}
	// Secret round-trip: the read API returns a placeholder when a
	// secret is configured. If the client echoes the placeholder back
	// unchanged we leave the stored secret alone; any other value
	// (including the empty string) is treated as a deliberate update.
	if in.ActionSecret != nil && *in.ActionSecret != actionSecretPlaceholder {
		existing.ActionSecret = *in.ActionSecret
	}
	if in.Enabled != nil {
		existing.Enabled = *in.Enabled
	}

	if msg := validateTrigger(existing); msg != "" {
		respondError(w, http.StatusBadRequest, msg)
		return
	}

	out, err := s.store.UpdateEventTrigger(existing)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "event trigger")
			return
		}
		respondError(w, http.StatusInternalServerError, "update event trigger")
		return
	}
	respondJSON(w, http.StatusOK, redactTriggerSecret(out))
}

// deleteEventTrigger handles DELETE /api/event-triggers/{id}.
func (s *Server) deleteEventTrigger(w http.ResponseWriter, r *http.Request) {
	id, ok := parseTriggerID(w, r)
	if !ok {
		return
	}
	if err := s.store.DeleteEventTrigger(id); err != nil {
		mapStoreError(w, err, "event trigger")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// testEventTrigger handles POST /api/event-triggers/{id}/test. Sends
// a real TriggerWebhookPayload to the action target so receivers see
// the same shape they'll see at runtime. Routes through the dedicated
// SendSyncForTestPayload path that preserves the payload through the
// HMAC+HTTP core unchanged.
func (s *Server) testEventTrigger(w http.ResponseWriter, r *http.Request) {
	id, ok := parseTriggerID(w, r)
	if !ok {
		return
	}
	t, err := s.store.GetEventTrigger(id)
	if err != nil {
		mapStoreError(w, err, "event trigger")
		return
	}
	if t.ActionType != store.EventTriggerActionWebhook {
		respondError(w, http.StatusBadRequest, "action_type not testable")
		return
	}

	now := time.Now().UTC().Format(time.RFC3339)
	payload := events.TriggerWebhookPayload{
		Type:      "event_trigger",
		TriggerID: t.ID,
		Trigger:   t.Name,
		Event: events.EventLogPayload{
			ID:        -1,
			Source:    "test",
			Severity:  "info",
			Message:   "Test event from Tinyforge — trigger=" + t.Name,
			Metadata:  `{"synthetic":true}`,
			CreatedAt: now,
		},
		Timestamp: now,
	}
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()
	result := s.notifier.SendSyncForTestPayload(ctx, t.ActionTarget, t.ActionSecret,
		notify.TierEventTrigger, "event_trigger", payload)
	respondJSON(w, http.StatusOK, result)
}

// validateTrigger runs the full set of invariants over a fully-merged
// trigger row. Called by both create and update so the contract is
// enforced once. Returns an empty string for a valid trigger.
func validateTrigger(t store.EventTrigger) string {
	if t.Name == "" {
		return "name is required"
	}
	if t.ActionType != "" && t.ActionType != store.EventTriggerActionWebhook {
		return "action_type must be 'webhook'"
	}
	if t.ActionTarget == "" {
		return "action_target is required"
	}
	if msg := validateWebhookURL(t.ActionTarget); msg != "" {
		return msg
	}
	if t.FilterMessageRegex != "" {
		if _, err := regexp.Compile(t.FilterMessageRegex); err != nil {
			return "filter_message_regex invalid: " + err.Error()
		}
	}
	return ""
}

// validateWebhookURL guards against the most common SSRF vectors that
// admin-controlled webhook URLs enable: non-http(s) schemes, missing
// host, and internal-network targets (loopback / link-local / RFC1918
// when the hostname resolves to a literal). Hostname-based lookups
// are NOT resolved here — DNS rebinding is out of scope and would
// require enforcement at dispatch time too. Admin gating remains the
// primary control; this is defense-in-depth.
func validateWebhookURL(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return "action_target invalid URL: " + err.Error()
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return "action_target must be http:// or https://"
	}
	host := u.Hostname()
	if host == "" {
		return "action_target missing host"
	}
	// Literal-IP guard: block loopback / link-local / unspecified
	// addresses outright. RFC1918 private ranges are intentionally
	// allowed since same-LAN receivers are a legitimate Tinyforge
	// deployment pattern.
	if ip := net.ParseIP(host); ip != nil {
		if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
			return "action_target points at a reserved/loopback address"
		}
	}
	return ""
}

// redactTriggerSecret returns a copy of t with ActionSecret replaced
// by the placeholder string when a secret is configured. Empty secret
// stays empty so the UI can distinguish "no signing" from "signing
// configured."
func redactTriggerSecret(t store.EventTrigger) store.EventTrigger {
	if t.ActionSecret != "" {
		t.ActionSecret = actionSecretPlaceholder
	}
	return t
}

// mapStoreError translates a store-layer error into an HTTP status +
// generic message. ErrNotFound → 404; everything else → 500 without
// echoing driver text to the client (avoids leaking schema details
// or transient error states to API consumers).
func mapStoreError(w http.ResponseWriter, err error, resource string) {
	if errors.Is(err, store.ErrNotFound) {
		respondNotFound(w, resource)
		return
	}
	respondError(w, http.StatusInternalServerError, "get "+resource)
}

func parseTriggerID(w http.ResponseWriter, r *http.Request) (int64, bool) {
	raw := chi.URLParam(r, "id")
	id, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || id <= 0 {
		respondError(w, http.StatusBadRequest, "invalid event trigger id")
		return 0, false
	}
	return id, true
}

func derefString(p *string) string {
	if p == nil {
		return ""
	}
	return *p
}

func firstNonEmpty(a, b string) string {
	if a != "" {
		return a
	}
	return b
}
