package webhook

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"strings"

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

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

// Local aliases — keep the interface surface small and avoid leaking
// plugin types into every consumer of the webhook package's API.
type (
	pluginWorkload = plugin.Workload
	pluginIntent   = plugin.DeploymentIntent
	pluginDeps     = plugin.Deps
)

// signatureHeader is the canonical Gitea/GitHub-compatible header name for
// HMAC-SHA256 signatures over the raw request body. Tinyforge accepts the
// same header so existing CI integrations work unchanged.
const signatureHeader = "X-Hub-Signature-256"

// signature verification states recorded in the webhook delivery log.
const (
	sigStateUnconfigured = "unconfigured"
	sigStateMissing      = "missing"
	sigStateInvalid      = "invalid"
	sigStateValid        = "valid"
)

// outcome values for the delivery log. Stable identifiers — frontend keys
// off these for badge colouring + i18n.
const (
	outcomeDeploy     = "deploy"
	outcomeSkip       = "skip"
	outcomeRejected   = "rejected"
	outcomeNotFound   = "not_found"
	outcomeBadRequest = "bad_request"
	outcomeError      = "error"
)

// signatureStateFor classifies the HMAC verification result for the delivery
// log: distinguishes "no signing secret configured" from "secret configured
// but caller sent nothing" so users can spot mis-configured CIs.
func signatureStateFor(signingSecret, header string, verified, attempted bool) string {
	if signingSecret == "" {
		return sigStateUnconfigured
	}
	if header == "" {
		return sigStateMissing
	}
	if attempted && verified {
		return sigStateValid
	}
	return sigStateInvalid
}

// recordDelivery persists a single inbound webhook delivery as a best-effort
// audit record. Errors are logged but never propagate — the user-visible
// response must not be affected by audit-log churn.
func (h *Handler) recordDelivery(d store.WebhookDelivery) {
	if err := h.store.InsertWebhookDelivery(d); err != nil {
		slog.Warn("webhook: record delivery", "error", err)
	}
}

// clientIP returns the most-trusted source IP for logging. Strips the
// Forwarded-For chain to its first hop and falls back to RemoteAddr.
func clientIP(r *http.Request) string {
	if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
		if i := strings.IndexByte(fwd, ','); i >= 0 {
			return strings.TrimSpace(fwd[:i])
		}
		return strings.TrimSpace(fwd)
	}
	return r.RemoteAddr
}

// verifyHMAC validates the X-Hub-Signature-256 header against the raw body
// using HMAC-SHA256. The function does the comparison in constant time.
//
// Behavior:
//   - signingSecret == "": signing not configured for this entity. The
//     function returns (false, false) — the caller decides whether to
//     enforce based on the require_signature flag.
//   - header missing: returns (false, true) — caller-decided.
//   - header malformed or signature mismatch: returns (false, true).
//   - signature valid: returns (true, true).
//
// First return: whether the signature was successfully verified.
// Second return: whether the verification was attempted (i.e., a header was
// present or signing is configured). The caller uses this to distinguish
// "no signature submitted" from "wrong signature submitted".
func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified, attempted bool) {
	if signingSecret == "" {
		return false, false
	}
	if headerValue == "" {
		return false, false
	}
	const prefix = "sha256="
	if !strings.HasPrefix(headerValue, prefix) {
		return false, true
	}
	provided, err := hex.DecodeString(headerValue[len(prefix):])
	if err != nil {
		return false, true
	}
	mac := hmac.New(sha256.New, []byte(signingSecret))
	mac.Write(body)
	expected := mac.Sum(nil)
	return hmac.Equal(provided, expected), true
}

// maxWebhookBodyBytes caps the request body size for webhook payloads. The
// /api routes already wrap the body with MaxBytesReader, but the webhook
// router relies on its own limit so changes to the parent middleware can't
// silently increase the cap.
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB

// PluginDispatcher is what the plugin-workload webhook handler needs from
// the deployer: the canonical Source-dispatch entry point plus access to
// the same Deps bundle so Trigger.Match can read store / crypto.
//
// DispatchTeardown is required so the preview-deploy flow can tear down
// an ephemeral per-branch child workload when its upstream branch is
// deleted. Same teardown path the API /workloads/{id} DELETE route uses;
// nil error on a clean teardown lets the caller delete the workload row.
type PluginDispatcher interface {
	DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error
	DispatchTeardown(ctx context.Context, w pluginWorkload) error
	PluginDeps() pluginDeps
}

// parsedImage holds the components extracted from a full image reference
// string. Package-private — the only callers are buildInboundEvent and the
// vendor parsers in this package.
type parsedImage struct {
	// Registry is the hostname, e.g. "git.dolgolyov-family.by".
	Registry string
	// Owner is the namespace/org, e.g. "alexei".
	Owner string
	// Name is the repository name, e.g. "web-app-launcher".
	Name string
	// Tag is the image tag, e.g. "dev-abc123". Empty string means "latest".
	Tag string
}

// fullName returns "owner/name" (the image path without registry and tag).
func (p parsedImage) fullName() string {
	if p.Owner != "" {
		return p.Owner + "/" + p.Name
	}
	return p.Name
}

// parseImageRef splits a full image reference into its components.
// Accepted formats:
//
//	registry.example.com/owner/name:tag
//	registry.example.com/owner/name
//	owner/name:tag
//	name:tag
func parseImageRef(ref string) (parsedImage, error) {
	ref = strings.TrimSpace(ref)
	if ref == "" {
		return parsedImage{}, fmt.Errorf("empty image reference")
	}

	var parsed parsedImage

	// Split off tag.
	if idx := strings.LastIndex(ref, ":"); idx != -1 {
		// Make sure the colon is not inside the registry host (e.g. "localhost:5000/img").
		afterColon := ref[idx+1:]
		if !strings.Contains(afterColon, "/") {
			parsed.Tag = afterColon
			ref = ref[:idx]
		}
	}

	parts := strings.Split(ref, "/")
	switch len(parts) {
	case 1:
		// "name"
		parsed.Name = parts[0]
	case 2:
		// "owner/name"
		parsed.Owner = parts[0]
		parsed.Name = parts[1]
	default:
		// "registry/owner/name" or "registry/owner/sub/name" — first segment is registry.
		parsed.Registry = parts[0]
		parsed.Owner = strings.Join(parts[1:len(parts)-1], "/")
		parsed.Name = parts[len(parts)-1]
	}

	if parsed.Name == "" {
		return parsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
	}

	return parsed, nil
}

// Handler is the HTTP handler for webhook requests. After the legacy
// project / site webhook routes were dropped, the only inbound path is
// the trigger fan-out — every project / site / stack webhook was lifted
// into a first-class Trigger row by the boot backfill.
type Handler struct {
	store   *store.Store
	plugins PluginDispatcher // optional; nil disables /triggers/{secret}
}

// NewHandler creates a new webhook Handler bound to a store.
func NewHandler(st *store.Store) *Handler {
	return &Handler{store: st}
}

// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
// is called the /triggers/{secret} route returns 503 — preventing partial
// initialization from silently dropping deploys.
func (h *Handler) SetPluginDispatcher(d PluginDispatcher) {
	h.plugins = d
}

// Drain is a no-op kept for symmetry with the previous shutdown path.
// The trigger fan-out runs synchronously inside the request goroutine,
// so there is nothing to drain at the handler level.
func (h *Handler) Drain() {}

// Route returns a chi router with the single inbound webhook endpoint
// mounted at /triggers/{secret}. Legacy /{secret} and /sites/{secret}
// routes were removed in the hard cutover; their secrets were lifted
// into Trigger rows on boot.
func (h *Handler) Route() chi.Router {
	r := chi.NewRouter()
	r.Post("/triggers/{secret}", h.handleTriggerWebhook)
	return r
}

// respondWebhookJSON writes a JSON response for webhook handlers.
func respondWebhookJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data) //nolint:errcheck
}

// respondWebhookError writes a JSON error response for webhook handlers.
func respondWebhookError(w http.ResponseWriter, status int, msg string) {
	respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
}

// buildInboundEvent normalizes the incoming webhook body into the
// plugin.InboundEvent shape. The dispatch order is:
//
//  1. Empty body → manual event (used by the test-trigger UI button).
//  2. Vendor-specific parsers (Gitea package, GitHub registry_package,
//     GitHub/Gitea/GitLab push) — short-circuit on a recognized
//     X-*-Event header. Vendor parsers can fully populate richer fields
//     (image digest, vendor tag, branch) the generic parser cannot.
//  3. Generic simple-body parser: top-level `image` for registry pushes,
//     top-level `ref` for git pushes. This is what the legacy webhook
//     CIs already send and what the operator-facing API surface
//     documents.
//
// RawBody and Headers are always attached so trigger plugins can do
// their own vendor-specific parsing if they need fields outside this
// normalized envelope.
func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, error) {
	evt := plugin.InboundEvent{
		RawBody: body,
		Headers: headers,
	}
	if len(body) == 0 {
		evt.Kind = "manual"
		evt.Manual = &plugin.ManualEvent{Actor: "webhook"}
		return evt, nil
	}

	// Try vendor-specific parsers first. A vendor parser claiming the
	// request (ok=true) is authoritative — we don't fall through even
	// if it returned an error, because the operator's CI is sending a
	// known vendor payload and silently re-parsing as generic would
	// hide the real cause.
	if res := tryVendorParsers(body, headers); res.ok {
		if res.err != nil {
			return plugin.InboundEvent{}, res.err
		}
		res.event.RawBody = body
		res.event.Headers = headers
		return res.event, nil
	}

	// Generic simple-body fallback: covers the canonical `{image: ...}`
	// and `{ref: ..., repository: {...}}` payloads documented in
	// docs/webhooks.md.
	var probe struct {
		Image string `json:"image"`
	}
	if err := json.Unmarshal(body, &probe); err != nil {
		return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
	}
	if probe.Image != "" {
		parsed, err := parseImageRef(probe.Image)
		if err != nil {
			return plugin.InboundEvent{}, fmt.Errorf("invalid image reference")
		}
		evt.Kind = "image-push"
		evt.Image = &plugin.ImagePushEvent{
			Registry: parsed.Registry,
			Repo:     parsed.fullName(),
			Tag:      parsed.Tag,
		}
		return evt, nil
	}

	gitEvt, err := parseGenericGitPush(body)
	if err != nil {
		// "missing ref" here means we got JSON with neither `image` nor
		// `ref` — surface the operator-facing message documented in
		// docs/webhooks.md rather than the lower-level parser error.
		if strings.Contains(err.Error(), "missing ref") {
			return plugin.InboundEvent{}, fmt.Errorf("payload must include either 'image' or 'ref'")
		}
		return plugin.InboundEvent{}, err
	}
	gitEvt.RawBody = body
	gitEvt.Headers = headers
	return gitEvt, nil
}
