package http

import (
	"context"
	"crypto/subtle"
	"net"
	"net/http"
	"net/netip"
	"runtime"
	"strings"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/metrics"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/version"
)

// handleMetrics serves the Prometheus exposition body. The route is
// only mounted when the operator has opted in via RM_METRICS_TOKEN
// or RM_METRICS_TRUSTED_CIDR (see Server.New + Cfg.MetricsAuthEnabled).
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
	if !authoriseMetricsScrape(r, s.deps.Cfg) {
		// 401 with no body; Prom respects this and surfaces the failed
		// scrape. WWW-Authenticate hints at bearer when the operator
		// actually configured a token.
		if s.deps.Cfg.MetricsToken != "" {
			w.Header().Set("WWW-Authenticate", `Bearer realm="restic-manager metrics"`)
		}
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	snap, err := s.gatherMetricsSnapshot(r.Context())
	if err != nil {
		http.Error(w, "snapshot: "+err.Error(), http.StatusInternalServerError)
		return
	}

	// 0.0.4 is the long-stable text-format version Prometheus accepts
	// without negotiation; OpenMetrics is intentionally not used here.
	w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
	if err := metrics.Render(w, snap); err != nil {
		// Body is partially written; nothing useful we can do beyond
		// dropping the connection (chi's recoverer will log).
		return
	}
}

// authoriseMetricsScrape applies bearer + CIDR gates per the spec.
// AND semantics when both are configured; either alone is sufficient
// when only it is configured.
func authoriseMetricsScrape(r *http.Request, cfg config.Config) bool {
	tokenOK := true
	if cfg.MetricsToken != "" {
		tokenOK = false
		hdr := r.Header.Get("Authorization")
		const prefix = "Bearer "
		if strings.HasPrefix(hdr, prefix) {
			got := []byte(strings.TrimPrefix(hdr, prefix))
			want := []byte(cfg.MetricsToken)
			if subtle.ConstantTimeCompare(got, want) == 1 {
				tokenOK = true
			}
		}
	}

	cidrOK := true
	if len(cfg.MetricsTrustedCIDRs) > 0 {
		cidrOK = false
		ip := callerIP(r, cfg.TrustedProxies)
		if ip.IsValid() {
			for _, c := range cfg.MetricsTrustedCIDRs {
				prefix, err := netip.ParsePrefix(c)
				if err != nil {
					continue
				}
				if prefix.Contains(ip) {
					cidrOK = true
					break
				}
			}
		}
	}
	return tokenOK && cidrOK
}

// callerIP resolves the client IP. When the request hit the server
// directly we use RemoteAddr; when the immediate hop is a trusted
// proxy we honour the right-most untrusted X-Forwarded-For entry
// (mirrors how realIP middlewares typically resolve).
func callerIP(r *http.Request, trustedProxies []string) netip.Addr {
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err != nil {
		host = r.RemoteAddr
	}
	directAddr, err := netip.ParseAddr(host)
	if err != nil {
		return netip.Addr{}
	}

	if !addrInAnyCIDR(directAddr, trustedProxies) {
		return directAddr
	}

	xff := r.Header.Get("X-Forwarded-For")
	if xff == "" {
		return directAddr
	}
	parts := strings.Split(xff, ",")
	// Walk right→left, skipping trusted proxies, until we land on the
	// first untrusted hop — that's the genuine client.
	for i := len(parts) - 1; i >= 0; i-- {
		p := strings.TrimSpace(parts[i])
		a, err := netip.ParseAddr(p)
		if err != nil {
			continue
		}
		if addrInAnyCIDR(a, trustedProxies) {
			continue
		}
		return a
	}
	return directAddr
}

func addrInAnyCIDR(a netip.Addr, cidrs []string) bool {
	for _, c := range cidrs {
		pre, err := netip.ParsePrefix(c)
		if err != nil {
			continue
		}
		if pre.Contains(a) {
			return true
		}
	}
	return false
}

// gatherMetricsSnapshot pulls the data the renderer needs. One
// indexed query per per-host or fleet-wide read; no N+1.
func (s *Server) gatherMetricsSnapshot(ctx context.Context) (metrics.Snapshot, error) {
	hosts, err := s.deps.Store.ListHosts(ctx)
	if err != nil {
		return metrics.Snapshot{}, err
	}
	hostRows := make([]metrics.HostRow, 0, len(hosts))
	for _, h := range hosts {
		row := metrics.HostRow{
			ID:             h.ID,
			Name:           h.Name,
			Online:         h.Status == "online",
			SnapshotCount:  h.SnapshotCount,
			OpenAlertCount: h.OpenAlertCount,
			RepoStatus:     h.RepoStatus,
		}
		if h.LastBackupAt != nil {
			ts := h.LastBackupAt.Unix()
			row.LastBackupUnix = &ts
		}
		if h.LastBackupStatus != nil {
			ok := *h.LastBackupStatus == "succeeded"
			row.LastBackupSucceeded = &ok
		}
		if h.RepoSizeBytes > 0 {
			sz := h.RepoSizeBytes
			row.RepoSizeBytes = &sz
		}
		hostRows = append(hostRows, row)
	}

	open, err := s.deps.Store.ListAlerts(ctx, store.AlertFilter{Status: "open"})
	if err != nil {
		return metrics.Snapshot{}, err
	}
	bySeverity := map[string]int{"info": 0, "warning": 0, "critical": 0}
	for _, a := range open {
		bySeverity[a.Severity]++
	}

	reg := s.deps.Metrics
	if reg == nil {
		reg = metrics.NewRegistry() // empty histogram block
	}
	return reg.SnapshotWith(hostRows, bySeverity, version.Version, version.Commit, runtime.Version()), nil
}
