package jwtauth

import (
	"context"
	"crypto/subtle"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"commilitia.net/cdrop/internal/config"
	"commilitia.net/cdrop/internal/db"
)

// Store is the subset of *db.Queries the auth middleware uses: refreshing a managed
// device's last_seen + tier on each request. Declared as an interface so tests can
// swap in a fake.
type Store interface {
	TouchDevice(ctx context.Context, arg db.TouchDeviceParams) error
}

// Authenticator turns each request's identity into Claims. After the Auth Broker
// migration (path A) cdrop no longer verifies tokens itself: in prod the broker
// authenticates at the edge and injects X-Auth-* headers this process trusts; in dev
// the claims are synthesised from the dev token.
type Authenticator struct {
	cfg   *config.Config
	store Store
}

func New(cfg *config.Config, store Store) *Authenticator {
	return &Authenticator{cfg: cfg, store: store}
}

func (a *Authenticator) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, err := a.authenticate(r)
		if err != nil {
			slog.Warn("auth failed", "err", err, "path", r.URL.Path)
			unauthorized(w, "unauthorized")
			return
		}

		deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name"))
		deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))

		// Keep the managed device's last_seen + tier fresh. Identity is the
		// broker-issued device_id (X-Auth-Meta); the row is created at scan-login
		// collect, so this only ever updates — an unmanaged caller (no device_id,
		// e.g. a global SSO browser) is skipped, and a missing row no-ops.
		if claims.DeviceID != "" {
			if err := a.store.TouchDevice(r.Context(), db.TouchDeviceParams{
				LastSeen: time.Now().Unix(),
				Tier:     claims.Tier(),
				DeviceID: claims.DeviceID,
				UserID:   claims.UserID,
			}); err != nil {
				// non-fatal: log and continue so a transient DB error doesn't 401 users
				slog.Error("device touch failed",
					"err", err, "user", claims.UserID, "device", claims.DeviceID)
			}
		}

		ctx := ContextWithClaims(r.Context(), claims)
		ctx = context.WithValue(ctx, deviceCtxKey, deviceName)
		ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// authenticate resolves the request identity. prod trusts the broker's edge-injected
// X-Auth-* headers; dev synthesises claims from the dev token.
func (a *Authenticator) authenticate(r *http.Request) (*Claims, error) {
	if a.cfg.AuthMode == "dev" {
		return a.devClaims(r)
	}
	// prod: the request reached cdrop only by passing broker /verify at the edge,
	// which injected these headers. Caddy strips any client-supplied X-Auth-* at the
	// trust boundary, so their presence is the broker's say-so. No subject → the
	// request did not authenticate.
	sub := r.Header.Get("X-Auth-Subject")
	if sub == "" {
		return nil, errors.New("missing X-Auth-Subject (request did not pass broker /verify)")
	}
	return &Claims{
		UserID:   sub,
		Name:     r.Header.Get("X-Auth-Name"),
		Avatar:   r.Header.Get("X-Auth-Avatar"),
		Groups:   splitRoles(r.Header.Get("X-Auth-Roles")),
		Scope:    r.Header.Get("X-Auth-Scope"),
		DeviceID: r.Header.Get("X-Auth-Meta"),
	}, nil
}

// devClaims authenticates the local dev token and synthesises claims. X-Dev-User sets
// the subject (default "dev-user"); X-Dev-Scope simulates a tier ("guest" → restricted,
// else full); X-Dev-Device optionally sets a device_id to exercise device flows.
func (a *Authenticator) devClaims(r *http.Request) (*Claims, error) {
	token, ok := bearerToken(r)
	if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 {
		return nil, errors.New("invalid dev token")
	}
	userID := r.Header.Get("X-Dev-User")
	if userID == "" {
		userID = "dev-user"
	}
	scope := r.Header.Get("X-Dev-Scope")
	if scope == "" {
		scope = "full"
	}
	return &Claims{
		UserID:   userID,
		Name:     userID,
		Groups:   []string{"dev"},
		Scope:    scope,
		DeviceID: r.Header.Get("X-Dev-Device"),
	}, nil
}

// splitRoles parses a comma-separated X-Auth-Roles header into a role slice,
// trimming whitespace and dropping empties.
func splitRoles(raw string) []string {
	if raw == "" {
		return nil
	}
	parts := strings.Split(raw, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		if s := strings.TrimSpace(p); s != "" {
			out = append(out, s)
		}
	}
	return out
}

func bearerToken(r *http.Request) (string, bool) {
	h := r.Header.Get("Authorization")
	const prefix = "Bearer "
	if !strings.HasPrefix(h, prefix) {
		return "", false
	}
	tok := strings.TrimPrefix(h, prefix)
	if tok == "" {
		return "", false
	}
	return tok, true
}

func unauthorized(w http.ResponseWriter, reason string) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("WWW-Authenticate", `Bearer realm="cdrop"`)
	w.WriteHeader(http.StatusUnauthorized)
	_ = json.NewEncoder(w).Encode(map[string]string{
		"error":  "unauthorized",
		"reason": reason,
	})
}

// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device names
// ride in the X-Device-Name HTTP header, which can't carry non-ASCII reliably (and
// browser fetch rejects such header values outright), so the name is restricted to
// printable ASCII everywhere. Here we keep only printable ASCII (0x20–0x7E), trim, and
// cap the length as a server-side backstop; clients also validate up front. Empty after
// sanitising → the caller falls back to a default.
func SanitizeDeviceName(raw string) string {
	var b strings.Builder
	for _, r := range raw {
		if r >= 0x20 && r <= 0x7E {
			b.WriteRune(r)
		}
	}
	name := strings.TrimSpace(b.String())
	if len(name) > 64 {
		name = strings.TrimSpace(name[:64])
	}
	return name
}

// normalizeDeviceType whitelists the client-declared X-Device-Type so a device row
// only ever carries a known kind; anything unrecognised (incl. empty) falls back to
// "browser". Native clients send macos/windows/linux/ios.
func normalizeDeviceType(raw string) string {
	switch t := strings.ToLower(strings.TrimSpace(raw)); t {
	case "macos", "windows", "linux", "ios", "browser":
		return t
	default:
		return "browser"
	}
}
