// Package httpapi implements the Oikos REST API. The contract is
// api/openapi.yaml (contract-first, ADR-0004); handlers implement the
// oapi-codegen strict-server interface in gen/. Errors map to RFC 9457
// problem+json via domain sentinels.
package httpapi

import (
	"context"
	"crypto/rsa"
	"crypto/subtle"
	"crypto/tls"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"math/big"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/dtoro/oikos/internal/actuator"
	"github.com/dtoro/oikos/internal/config"
	"github.com/dtoro/oikos/internal/adapters/postgres"
	"github.com/dtoro/oikos/internal/core/app"
	"github.com/dtoro/oikos/internal/core/ports"
	"github.com/dtoro/oikos/internal/httpapi/gen"
	mcphandler "github.com/dtoro/oikos/internal/mcp"
	"github.com/dtoro/oikos/internal/safego"
	"github.com/dtoro/oikos/internal/secrets"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/cors"
	"github.com/golang-jwt/jwt/v5"
	"github.com/google/uuid"
)

// contextKey for storing actor identity in request context.
type contextKey string

const actorKey contextKey = "oikos_actor"

// actor holds the resolved identity of the API caller.
type actor struct {
	Type      string // "operator", "agent", "system"
	Label     string // human-readable label
	ID        string // OIDC sub or static token identifier
	TokenType string // "static" or "oidc"
}

// Server implements gen.StrictServerInterface over the DB layer.
type Server struct {
	pool           *db.Pool
	cfg            config.Config
	secretsManager secrets.Backend
	entityCache    *db.EntityCache
	sseBroker      *sseBroker
	sseSubs        map[*sseSubscriber]struct{}
	sseMu          sync.Mutex

	// entities is the entity-aggregate use-case service (Phase 3 of the
	// hexagonal refactor); entityRepo exposes the idempotency reads the
	// replay path needs. Wired here until main becomes the composition root.
	entities    *app.EntityService
	entityRepo  *db.EntityRepo
	readModels  ports.ReadModels
	relService  *app.RelationshipService
	// provisioning owns the pct_create flow (Phase 7): spec defaults,
	// provisioner dispatch, guest registration in the graph.
	provisioning *app.ProvisioningService
	// seeds regenerates seed YAMLs for the export endpoint (Phase 7).
	seeds *app.SeedService
}

// NewHandler builds the full HTTP handler: /healthz (unauthenticated,
// SG18) + the OpenAPI surface under /api/v1 behind bearer auth.
//
// ctx governs the lifetime of the background SSE listener goroutine, which
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
// before closing the pool — otherwise the held connection never releases
// and pool.Close() deadlocks.
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService) http.Handler {
	s := &Server{
		pool:         pool,
		cfg:          cfg,
		entityCache:  db.NewEntityCache(60 * time.Second),
		sseBroker:    newSSEBroker(10000),
		sseSubs:      make(map[*sseSubscriber]struct{}),
		entities:     entities,
		entityRepo:   entityRepo,
		readModels:   readModels,
		relService:   relService,
		provisioning: provisioning,
		seeds:        seeds,
	}

	// Wire secrets backend: Infisical primary with SOPS DR fallback.
	if cfg.InfisicalSiteURL != "" {
		infCfg := secrets.InfisicalConfig{
			SiteURL:      cfg.InfisicalSiteURL,
			ClientID:     cfg.InfisicalClientID,
			ClientSecret: cfg.InfisicalClientSecret,
			ProjectID:    cfg.InfisicalProjectID,
			SecretPath:   "/",
			Env:          cfg.InfisicalEnv,
		}
		if infCfg.Env == "" {
			infCfg.Env = "dev"
		}
		primary := secrets.NewInfisicalBackend(infCfg)
		var fallback secrets.Backend
		if cfg.SecretsDir != "" {
			fallback = secrets.NewSOPSBackend(cfg.SecretsDir)
		}
		s.secretsManager = secrets.NewManager(primary, fallback)
		slog.Info("secrets backend wired", "backend", "infisical+sops", "site", cfg.InfisicalSiteURL)

		// Start background secret refresh loop (non-blocking).
		if mgr, ok := s.secretsManager.(*secrets.Manager); ok {
			safego.Go("secrets:refresh", func() { mgr.StartRefreshLoop(ctx) })
		}

		// Pre-load SSH host keys from Infisical for host verification.
		if hosts := actuator.ResolveSSHHosts(ctx, pool); len(hosts) > 0 {
			hkSrc := actuator.NewInfisicalHostKeySource(s.secretsManager)
			actuator.LoadHostKeys(ctx, hosts, hkSrc)
		}
	}

	// Start background SSE listener, tied to ctx for clean shutdown.
	// handleNotification (called per-message inside sseListener's loop) has
	// its own recover for the common case; this outer one covers the
	// connection-setup/reconnect code around it.
	safego.Go("httpapi:sse-listener", func() { s.sseListener(ctx) })

	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Use(middleware.RequestID)
	r.Use(requestLogger)
	// Per-IP rate limiting (plan D3). Applied before CORS/auth so a runaway
	// agent loop is throttled regardless of credentials. The middleware
	// exempts /healthz so liveness probes can't be throttled.
	limiter := newRateLimiterFromConfig(ctx, cfg.APIRateLimit, cfg.APIRateBurst)
	r.Use(limiter.middleware)
	r.Use(cors.Handler(cors.Options{
		AllowedOrigins: []string{cfg.CORSAllowedOrigin},
		AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
		AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"},
		MaxAge:         86400,
	}))

	// ─── Non-OpenAPI routes (carve-out) ────────────────────────────────
	//
	// These routes are registered manually on the chi router rather than
	// generated from api/openapi.yaml. Each has a structural reason it
	// can't go through the strict-server codegen:
	//
	//   /healthz               — infra liveness probe, no auth, no /api/v1 prefix
	//   /api/v1/auth/oidc-*    — auth flow, must run before auth middleware
	//   /oidc-callback         — standalone HTML page, not a JSON API
	//   /api/v1/events/stream  — in OpenAPI but re-registered for SSE Flush()
	//   /api/v1/knowledge/recent       — ad-hoc aggregation, no schema type yet
	//   /api/v1/knowledge/content/{id} — returns raw markdown, not a gen type
	//   /api/v1/knowledge/list         — full tree listing, ad-hoc aggregate
	//   /api/v1/knowledge (POST)       — markdown in, no gen type
	//   /api/v1/knowledge/content/{id} (PUT/DELETE) — markdown in, soft delete
	//   /api/v1/knowledge/trash       — soft-deleted notes, ad-hoc
	//   /api/v1/knowledge/restore/{id} — undo a soft delete, no gen type
	//   /api/v1/knowledge/revisions/{id} — version history, no schema type
	//   /api/v1/knowledge/tags{,/rename} — tag index + bulk rewrite
	//   /api/v1/knowledge/duplicates   — trigram clustering, ad-hoc
	//   /api/v1/knowledge/orphans      — derived maintenance view
	//   /api/v1/knowledge/merge        — bulk fold-in, ad-hoc
	//   /api/v1/activity/recent        — recency-ordered, not paginated
	//   /api/v1/activity/session/{id}  — session-scoped aggregation
	//   /api/v1/executions/{id}/logs   — streamed command output, no schema type
	//   /api/v1/learning/timeline      — derived view, no backing schema type
	//   /api/v1/learning/trend         — derived view, no backing schema type
	//   /api/v1/openapi.json           — API spec (embedded in binary), self-service
	//
	// See .agents/dev/CONTRIBUTING.md §OpenAPI codegen for the policy.

	// Liveness — no auth, no audit (plan SG18). Not exposed via Caddy.
	r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
		ctx, cancel := context.WithTimeout(req.Context(), 2*time.Second)
		defer cancel()
		if err := pool.Ping(ctx); err != nil {
			writeProblem(w, req, http.StatusServiceUnavailable, "database unreachable", "")
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"status":"ok"}`))
	})

	// Client enrollment — unauthenticated (IP-gated in handler).
	// Registered on base router BEFORE HandlerWithOptions so it
	// bypasses the combinedAuth middleware applied to all /api/v1/*.
	r.Post("/api/v1/clients/enroll", func(w http.ResponseWriter, req *http.Request) {
		var body gen.EnrollRequest
		if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
			writeProblem(w, req, http.StatusBadRequest, "invalid request body", err.Error())
			return
		}
		resp, err := s.EnrollClient(req.Context(), gen.EnrollClientRequestObject{Body: &body})
		if err != nil {
			writeProblemFromErr(w, req, err)
			return
		}
		if err := resp.VisitEnrollClientResponse(w); err != nil {
			writeProblem(w, req, http.StatusInternalServerError, "response encoding failed", err.Error())
		}
	})

	// OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id
	// to build the authorization URL, and uses the token proxy to exchange
	// authorization codes and refresh tokens without CORS issues.
	r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) {
		s.serveOIDCConfig(w, req, cfg)
	})
	r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) {
		s.serveOIDCToken(w, req, cfg)
	})

	// Desktop OIDC callback — standalone HTML page that exchanges the
	// authorization code for tokens and displays the access token to copy
	// into the desktop app's Config screen.
	r.Get("/oidc-callback", func(w http.ResponseWriter, req *http.Request) {
		s.serveOIDCCallback(w, req, cfg)
	})

	strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
		RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
			writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
		},
		ResponseErrorHandlerFunc: writeProblemFromErr,
	})

	gen.HandlerWithOptions(strict, gen.ChiServerOptions{
		BaseURL:     "/api/v1",
		BaseRouter:  r,
		Middlewares: []gen.MiddlewareFunc{combinedAuth(cfg, false)},
		ErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
			writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
		},
	})

	// SSE stream: override the generated /events/stream route with a raw
	// flushing handler (registered AFTER HandlerWithOptions so chi's last
	// registration wins). The strict-server path can't Flush() per event;
	// this one uses the real ResponseWriter for real-time delivery. It
	// inherits the router's base middleware and applies auth via With().
	// allowQueryToken=true: EventSource can't set custom headers, so the
	// SPA passes the token as ?token=... instead of Authorization.
	r.With(combinedAuth(cfg, true)).Get("/api/v1/events/stream", s.serveSSE)

	// Custom (non-OpenAPI) route: recency-ordered knowledge + stats for the
	// Knowledge page's "what the system has learned" view. Registered after
	// HandlerWithOptions so it wins over any generated catch-all.
	// (See "Non-OpenAPI routes" carve-out block above.)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)

	// Custom (non-OpenAPI) route: full markdown content for a knowledge
	// entity (document/investigation/runbook) by its own id or slug — the
	// generated /api/v1/knowledge/{id} route (GetEntityKnowledge) answers a
	// different question (knowledge referencing this entity), not this one.
	// (See "Non-OpenAPI routes" carve-out block above.)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/content/{id}", s.serveKnowledgeContent)

	// Custom (non-OpenAPI) routes: the operator-facing knowledge CRUD surface
	// (see internal/httpapi/knowledge_write.go) and the drift tooling (see
	// knowledge_drift.go). Before these, knowledge could only be written by
	// the agent through the MCP upsert_knowledge tool — the web UI had no way
	// to create, correct or retire a note.
	//
	// Registered on the base router rather than through the OpenAPI codegen
	// for the same reason as the read routes above: they trade in raw
	// markdown and ad-hoc aggregates, not generated schema types.
	// (See "Non-OpenAPI routes" carve-out block above.)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/list", s.serveKnowledgeList)
	r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge", s.serveCreateKnowledge)
	r.With(combinedAuth(cfg, false)).Put("/api/v1/knowledge/content/{id}", s.serveUpdateKnowledge)
	r.With(combinedAuth(cfg, false)).Delete("/api/v1/knowledge/content/{id}", s.serveDeleteKnowledge)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/trash", s.serveKnowledgeTrash)
	r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/restore/{id}", s.serveRestoreKnowledge)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/revisions/{id}", s.serveKnowledgeRevisions)

	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/tags", s.serveKnowledgeTags)
	r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/tags/rename", s.serveRenameKnowledgeTag)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/duplicates", s.serveKnowledgeDuplicates)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/knowledge/orphans", s.serveKnowledgeOrphans)
	r.With(combinedAuth(cfg, false)).Post("/api/v1/knowledge/merge", s.serveMergeKnowledge)

	// Drift audit: read-only DB-side report of orphan checks, checks on
	// retired targets, stuck down/unknown probes, unmonitored declared types,
	// and dangling edges. Companion to the knowledge-graph-audit skill.
	r.With(combinedAuth(cfg, false)).Get("/api/v1/audit/drift", s.serveAuditDrift)

	// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
	// unlike ListExecutions which sorts by target for pagination) and the
	// per-session "what did this session do" digest.
	// (See "Non-OpenAPI routes" carve-out block above.)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/recent", s.serveRecentActivity)
	// Streamed command output for one execution — a projection over
	// execution_logs with no schema type yet (same carve-out rationale as
	// /activity/recent above). Nests cleanly under the generated
	// /executions/{id} subtree: chi accepts sibling children on a param node.
	r.With(combinedAuth(cfg, false)).Get("/api/v1/executions/{id}/logs", s.serveExecutionLogs)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)

	// Learning view: capability timeline + success trend, both derived from
	// executions (real, growing data) rather than the patterns/skills tables,
	// which are correctly modeled but have no writers anywhere yet.
	// (See "Non-OpenAPI routes" carve-out block above.)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/timeline", s.serveLearningTimeline)
	r.With(combinedAuth(cfg, false)).Get("/api/v1/learning/trend", s.serveLearningTrend)

	// Serve the OpenAPI spec at a browseable endpoint (agents + humans)
	r.Get("/api/v1/openapi.json", func(w http.ResponseWriter, req *http.Request) {
		swagger, err := gen.GetSwagger()
		if err != nil {
			writeProblem(w, req, http.StatusInternalServerError, "failed to load spec", "")
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(swagger)
	})

	// Mount MCP at /mcp (plan R3-10)
	nomosAgentID := uuid.Nil
	if cfg.NomosAgentID != "" {
		if id, err := uuid.Parse(cfg.NomosAgentID); err == nil {
			nomosAgentID = id
		}
	}
	if nomosAgentID == uuid.Nil && cfg.NomosAgentSlug != "" {
		_ = pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", cfg.NomosAgentSlug).Scan(&nomosAgentID)
	}
	r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService))

	if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
		target, _ := url.Parse(nomosURL)
		proxy := httputil.NewSingleHostReverseProxy(target)
		// Was unauthenticated (pre-existing gap, predates the client/server
		// split — this mount was never wrapped in combinedAuth, unlike every
		// other custom route below). Harmless while dev-open was in effect;
		// a real hole now that every route needs a real credential.
		r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy)))
	}

	return r
}

// combinedAuth tries OIDC JWT validation first (if configured), then falls
// back to static bearer token validation. Every request needs a valid
// credential — there is no dev-open bypass (closed as part of the
// client/server split, plans/2026-07-12-wails-desktop-app.md 0.4: once the
// SPA is a separate client, a dev-open API is reachable from any origin).
// When allowQueryToken is set, a missing Authorization header falls back to
// a `?token=` query param — only used for the SSE route, since EventSource
// can't set custom headers.
func combinedAuth(cfg config.Config, allowQueryToken bool) func(http.Handler) http.Handler {
	hasOIDC := cfg.OIDCIssuer != "" && cfg.OIDCClientID != ""
	hasStatic := cfg.APIToken != "" || cfg.MCPBearerToken != ""

	// Cache JWKS for OIDC
	var jwksURL string
	var jwksCache []jwtVerificationKey
	var jwksMu sync.RWMutex
	if hasOIDC {
		// Fetch JWKS URI from OIDC discovery
		jwksURL = discoverJWKSURI(cfg.OIDCIssuer)
		if jwksURL != "" {
			keys, err := fetchJWKS(jwksURL)
			if err != nil {
				slog.Warn("oidc initial jwks fetch failed, will retry on demand", "error", err)
			} else {
				jwksCache = keys
			}
		}
	}

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			auth := r.Header.Get("Authorization")
			raw, ok := strings.CutPrefix(auth, "Bearer ")
			if (!ok || raw == "") && allowQueryToken {
				raw = r.URL.Query().Get("token")
				ok = raw != ""
			}
			if !ok || raw == "" {
				writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
					"missing bearer token")
				return
			}

			// Try OIDC first if configured
			if hasOIDC {
				jwksMu.RLock()
				keys := jwksCache
				jwksMu.RUnlock()

				// If cache is empty, try to refresh
				if len(keys) == 0 && jwksURL != "" {
					if freshKeys, err := fetchJWKS(jwksURL); err == nil {
						jwksMu.Lock()
						jwksCache = freshKeys
						keys = freshKeys
						jwksMu.Unlock()
					}
				}

				if act, err := validateOIDCToken(raw, cfg, keys); err == nil {
					ctx := context.WithValue(r.Context(), actorKey, act)
					next.ServeHTTP(w, r.WithContext(ctx))
					return
				} else {
					slog.Debug("oidc validation failed", "error", err)
				}
			}

			// Fall back to static tokens
			if hasStatic {
				if act, ok := staticTokenActor(cfg, raw); ok {
					ctx := context.WithValue(r.Context(), actorKey, act)
					next.ServeHTTP(w, r.WithContext(ctx))
					return
				}
			}

			writeProblem(w, r, http.StatusUnauthorized, "unauthorized",
				"invalid or expired bearer token")
		})
	}
}

// staticTokenActor validates raw against the configured static bearer
// tokens (API token, MCP token) in constant time and returns the resolved
// actor. Shared between combinedAuth's header-based check and serveSSE's
// query-param check (EventSource can't set custom headers, so the SSE
// stream takes the token as ?token=...).
func staticTokenActor(cfg config.Config, raw string) (actor, bool) {
	if raw == "" {
		return actor{}, false
	}
	idPrefix := raw
	if len(idPrefix) > 8 {
		idPrefix = idPrefix[:8]
	}
	if cfg.MCPBearerToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.MCPBearerToken)) == 1 {
		return actor{Type: "agent", Label: "agent:mcp", ID: idPrefix + "...", TokenType: "static"}, true
	}
	if cfg.APIToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(cfg.APIToken)) == 1 {
		return actor{Type: "operator", Label: "operator:api", ID: idPrefix + "...", TokenType: "static"}, true
	}
	return actor{}, false
}

// jwtVerificationKey holds a parsed RSA public key or HMAC secret for JWT
// verification, identified by its key ID (kid).
type jwtVerificationKey struct {
	Kid    string
	Alg    string
	Key    any // *rsa.PublicKey or []byte for HMAC
	IsHMAC bool
}

// discoverJWKSURI fetches the OIDC discovery document and extracts the
// jwks_uri field.
func discoverJWKSURI(issuerURL string) string {
	discURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
	client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
	}}
	resp, err := client.Get(discURL)
	if err != nil {
		slog.Warn("oidc discovery failed", "url", discURL, "error", err)
		return ""
	}
	defer resp.Body.Close()

	var disc struct {
		JWKSURI string `json:"jwks_uri"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&disc); err != nil {
		slog.Warn("oidc discovery decode failed", "error", err)
		return ""
	}
	if disc.JWKSURI == "" {
		slog.Warn("oidc discovery missing jwks_uri")
		return ""
	}
	return disc.JWKSURI
}

// fetchJWKS retrieves the JWK set from a URL and returns the parsed keys.
func fetchJWKS(jwksURL string) ([]jwtVerificationKey, error) {
	client := &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
	}}
	resp, err := client.Get(jwksURL)
	if err != nil {
		return nil, fmt.Errorf("fetch jwks: %w", err)
	}
	defer resp.Body.Close()

	var jwks struct {
		Keys []json.RawMessage `json:"keys"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
		return nil, fmt.Errorf("decode jwks: %w", err)
	}

	var keys []jwtVerificationKey
	for _, raw := range jwks.Keys {
		var header struct {
			Kty string `json:"kty"`
			Kid string `json:"kid,omitempty"`
			Alg string `json:"alg,omitempty"`
			Use string `json:"use,omitempty"`
		}
		if err := json.Unmarshal(raw, &header); err != nil {
			continue
		}
		// Skip keys not intended for signature verification
		if header.Use != "" && header.Use != "sig" {
			continue
		}

		var key jwtVerificationKey
		key.Kid = header.Kid
		key.Alg = header.Alg

		switch header.Kty {
		case "RSA":
			var rsaKey struct {
				N string `json:"n"`
				E string `json:"e"`
			}
			if err := json.Unmarshal(raw, &rsaKey); err != nil {
				continue
			}
			pubKey, err := parseRSAPublicKey(rsaKey.N, rsaKey.E)
			if err != nil {
				slog.Debug("oidc parse rsa key failed", "kid", header.Kid, "error", err)
				continue
			}
			key.Key = pubKey
		case "oct":
			// HMAC keys not expected for OIDC but handle gracefully
			key.IsHMAC = true
		default:
			continue
		}

		if key.Key != nil || key.IsHMAC {
			keys = append(keys, key)
		}
	}

	if len(keys) == 0 {
		return nil, fmt.Errorf("no usable keys in jwks")
	}
	return keys, nil
}

// parseRSAPublicKey decodes a base64url-encoded RSA modulus and exponent into
// an *rsa.PublicKey.
func parseRSAPublicKey(nB64, eB64 string) (any, error) {
	// Decode base64url modulus
	nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
	if err != nil {
		return nil, fmt.Errorf("decode modulus: %w", err)
	}
	// Decode base64url exponent
	eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
	if err != nil {
		return nil, fmt.Errorf("decode exponent: %w", err)
	}

	// Build RSA public key
	n := new(big.Int).SetBytes(nBytes)
	e := 0
	for _, b := range eBytes {
		e = (e << 8) | int(b)
	}
	return &rsa.PublicKey{N: n, E: e}, nil
}

// validateOIDCToken parses and validates a JWT Bearer token against the OIDC
// configuration. Returns the resolved actor on success.
func validateOIDCToken(rawToken string, cfg config.Config, keys []jwtVerificationKey) (actor, error) {
	keyFunc := func(token *jwt.Token) (any, error) {
		kid, ok := token.Header["kid"].(string)
		if !ok {
			return nil, fmt.Errorf("no kid in token header")
		}

		// Find matching key
		for _, k := range keys {
			// If kid is empty in JWK, try algorithm match
			if k.Kid == kid || (k.Kid == "" && k.Alg == token.Header["alg"]) {
				return k.Key, nil
			}
		}
		// Fall back to any RSA key if no kid match (some providers don't set kid)
		if !ok {
			for _, k := range keys {
				if k.Key != nil {
					return k.Key, nil
				}
			}
		}
		return nil, fmt.Errorf("no matching key for kid: %s", kid)
	}

	token, err := jwt.Parse(rawToken, keyFunc,
		jwt.WithIssuer(cfg.OIDCIssuer),
		jwt.WithAudience(cfg.OIDCClientID),
		jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}),
	)
	if err != nil {
		return actor{}, fmt.Errorf("jwt validation: %w", err)
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return actor{}, fmt.Errorf("invalid claims")
	}

	sub, _ := claims.GetSubject()
	if sub == "" {
		// Try the Azure/Entra ID oid claim fallback
		if oid, ok := claims["oid"].(string); ok {
			sub = oid
		}
	}

	preferredUsername, _ := claims["preferred_username"].(string)
	email, _ := claims["email"].(string)

	label := sub
	if preferredUsername != "" {
		label = preferredUsername
	} else if email != "" {
		label = email
	}

	return actor{
		Type:      "operator",
		Label:     label,
		ID:        sub,
		TokenType: "oidc",
	}, nil
}

// GetActor extracts the actor identity from the context. Returns nil if not
// set (should not happen for authenticated routes).
func GetActor(ctx context.Context) *actor {
	a, ok := ctx.Value(actorKey).(actor)
	if !ok {
		return nil
	}
	return &a
}

// requestLogger logs one line per request with method, path, status,
// duration, and the chi request id.
func requestLogger(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
		next.ServeHTTP(ww, r)
		slog.Info("http",
			"method", r.Method,
			"path", r.URL.Path,
			"status", ww.Status(),
			"duration_ms", time.Since(start).Milliseconds(),
			"request_id", middleware.GetReqID(r.Context()),
		)
	})
}

// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking
// up one path segment. Authentik's issuer is per-provider
// (e.g. .../application/o/oikos/) but shared endpoints live at the parent
// path (.../application/o/<suffix>).
func resolveOIDCEndpointURL(issuer, suffix string) string {
	u, err := url.Parse(issuer)
	if err != nil {
		return strings.TrimRight(issuer, "/") + suffix
	}
	u.Path = strings.TrimRight(u.Path, "/")
	if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
		u.Path = u.Path[:idx]
	}
	u.Path += suffix
	return u.String()
}

// resolveOIDCTokenURL derives the token endpoint URL from the issuer.
func resolveOIDCTokenURL(issuer string) string {
	return resolveOIDCEndpointURL(issuer, "/token/")
}

// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build
// authorization URLs without hardcoding them.
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{
		"issuer":                 cfg.OIDCIssuer,
		"client_id":              cfg.OIDCClientID,
		"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
	})
}

// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy.
type tokenExchangeBody struct {
	GrantType    string `json:"grant_type"`
	Code         string `json:"code,omitempty"`
	CodeVerifier string `json:"code_verifier,omitempty"`
	RedirectURI  string `json:"redirect_uri,omitempty"`
	RefreshToken string `json:"refresh_token,omitempty"`
}

// serveOIDCToken proxies authorization_code and refresh_token grants to the
// OIDC provider's token endpoint. The SPA can't POST directly to Authentik
// because of CORS; this proxy avoids the cross-origin problem entirely.
func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) {
	if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
		writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "")
		return
	}

	body, err := io.ReadAll(req.Body)
	if err != nil {
		writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error())
		return
	}

	var tb tokenExchangeBody
	if err := json.Unmarshal(body, &tb); err != nil {
		writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error())
		return
	}

	// Build the form-encoded body for Authentik's token endpoint
	form := url.Values{}
	form.Set("client_id", cfg.OIDCClientID)
	if cfg.OIDCClientSecret != "" {
		form.Set("client_secret", cfg.OIDCClientSecret)
	}

	switch tb.GrantType {
	case "authorization_code":
		form.Set("grant_type", "authorization_code")
		form.Set("code", tb.Code)
		form.Set("code_verifier", tb.CodeVerifier)
		form.Set("redirect_uri", tb.RedirectURI)
	case "refresh_token":
		form.Set("grant_type", "refresh_token")
		form.Set("refresh_token", tb.RefreshToken)
	default:
		writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType)
		return
	}

	tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer)
	client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
	}}
	resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
	if err != nil {
		slog.Error("oidc token proxy failed", "error", err)
		writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error())
		return
	}
	defer resp.Body.Close()

	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error())
		return
	}

	if resp.StatusCode >= 400 {
		slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody))
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(resp.StatusCode)
		w.Write(respBody)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Pragma", "no-cache")
	w.Write(respBody)
}

// serveOIDCCallback serves a standalone HTML page that completes the
// desktop OIDC login flow. Authentik redirects here with ?code=...&state=...
// after the user authorizes. The state carries the PKCE verifier
// (base64url-encoded, joined with "."). The page exchanges the code for
// tokens via the token proxy, then displays the access token for the user
// to copy into the desktop app.
func (s *Server) serveOIDCCallback(w http.ResponseWriter, req *http.Request, cfg config.Config) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oikos — Connect Desktop App</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: #0a0a0a; color: #e0e0e0;
    display: flex; align-items: center; justify-content: center;
    min-height: 100vh; padding: 24px;
  }
  .card {
    background: #1a1a1a; border: 1px solid #2a2a2a;
    border-radius: 12px; padding: 32px; max-width: 480px; width: 100%;
  }
  h1 { font-size: 20px; margin-bottom: 8px; }
  p { font-size: 14px; color: #888; margin-bottom: 20px; }
  .spinner { margin: 24px auto; width: 32px; height: 32px; border: 3px solid #2a2a2a; border-top-color: #3b82f6; border-radius: 50%; animation: spin 0.8s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }
  .token-box {
    background: #111; border: 1px solid #2a2a2a; border-radius: 8px;
    padding: 16px; font-family: monospace; font-size: 13px;
    word-break: break-all; margin-bottom: 16px; position: relative;
    max-height: 160px; overflow-y: auto;
  }
  .btn {
    display: block; width: 100%; padding: 12px; border: none; border-radius: 8px;
    font-size: 14px; font-weight: 600; cursor: pointer; text-align: center;
  }
  .btn-primary { background: #3b82f6; color: #fff; }
  .btn-primary:hover { background: #2563eb; }
  .btn-secondary { background: #1a1a1a; color: #e0e0e0; border: 1px solid #2a2a2a; margin-top: 8px; }
  .btn-secondary:hover { background: #222; }
  .success { color: #22c55e; margin-bottom: 8px; font-weight: 600; }
  .error { color: #ef4444; margin-bottom: 12px; }
  .copied { color: #22c55e; font-size: 13px; text-align: center; margin-top: 8px; }
</style>
</head>
<body>
<div class="card">
<h1>Connect Desktop App</h1>
<div id="loading">
  <p>Exchanging authorization code...</p>
  <div class="spinner"></div>
</div>
<div id="result" style="display:none"></div>
</div>
<script>
async function main() {
  const params = new URLSearchParams(location.search);
  const code = params.get('code');
  const state = params.get('state');

  if (!code || !state) {
    showError('Missing code or state parameter from Authentik redirect.');
    return;
  }

  const parts = state.split('.');
  if (parts.length !== 2) {
    showError('Invalid state format.');
    return;
  }
  const [csrf, verifier] = parts;

  const redirectURI = location.origin + '/oidc-callback';

  try {
    const resp = await fetch('/api/v1/auth/oidc-token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code: code,
        code_verifier: verifier,
        redirect_uri: redirectURI
      })
    });

    if (!resp.ok) {
      const err = await resp.json().catch(() => ({ error: resp.statusText }));
      showError(err.error || err.message || 'Token exchange failed (' + resp.status + ')');
      return;
    }

    const tokens = await resp.json();
    if (!tokens.access_token) {
      showError('No access token in response.');
      return;
    }

    document.getElementById('loading').style.display = 'none';
    const result = document.getElementById('result');
    result.style.display = 'block';
    result.innerHTML = '<div class="success">Authentication successful</div>' +
      '<p style="margin-bottom:8px">Copy this token into the Oikos desktop app Token tab:</p>' +
      '<div class="token-box" id="token">' + escapeHtml(tokens.access_token) + '</div>' +
      '<button class="btn btn-primary" id="copyBtn">Copy Token</button>' +
      '<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>' +
      '<div class="copied" id="copied" style="display:none">Copied!</div>';

    document.getElementById('copyBtn').addEventListener('click', () => {
      navigator.clipboard.writeText(tokens.access_token).then(() => {
        const el = document.getElementById('copied');
        el.style.display = 'block';
        setTimeout(() => el.style.display = 'none', 2000);
      });
    });
  } catch(e) {
    showError('Network error: ' + e.message);
  }
}

function showError(msg) {
  document.getElementById('loading').style.display = 'none';
  const result = document.getElementById('result');
  result.style.display = 'block';
  result.innerHTML = '<div class="error">' + escapeHtml(msg) + '</div>' +
    '<button class="btn btn-secondary" onclick="location.reload()">Try Again</button>';
}

function escapeHtml(s) {
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

main();
</script>
</body>
</html>`)
}

// ListenAndServe runs the API server with graceful shutdown on ctx cancel
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, entities *app.EntityService, entityRepo *db.EntityRepo, readModels ports.ReadModels, relService *app.RelationshipService, provisioning *app.ProvisioningService, seeds *app.SeedService) error {
	srv := &http.Server{
		Addr:              cfg.APIListen,
		Handler:           NewHandler(ctx, pool, cfg, entities, entityRepo, readModels, relService, provisioning, seeds),
		ReadHeaderTimeout: 10 * time.Second,
	}

	errCh := make(chan error, 1)
	go func() {
		// Recovers a panic in ListenAndServe (stdlib, so extremely unlikely,
		// but an unrecovered panic here would crash the whole process rather
		// than surfacing as a normal startup error) and reports it through
		// errCh instead — the select below would otherwise just hang waiting
		// for a value that never arrives.
		defer func() {
			if r := recover(); r != nil {
				errCh <- fmt.Errorf("panic in ListenAndServe: %v", r)
			}
		}()
		slog.Info("api listening", "addr", cfg.APIListen)
		errCh <- srv.ListenAndServe()
	}()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
		slog.Info("api shutting down")
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		return srv.Shutdown(shutdownCtx)
	}
}
