// Package admin provides REST API endpoints for administrative operations.
package admin

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	httpswagger "github.com/swaggo/http-swagger/v2"

	"github.com/txn2/mcp-data-platform/pkg/audit"
	"github.com/txn2/mcp-data-platform/pkg/auth"
	"github.com/txn2/mcp-data-platform/pkg/authevents"
	"github.com/txn2/mcp-data-platform/pkg/browsersession"
	"github.com/txn2/mcp-data-platform/pkg/configstore"
	"github.com/txn2/mcp-data-platform/pkg/connoauth"
	"github.com/txn2/mcp-data-platform/pkg/embedding"
	"github.com/txn2/mcp-data-platform/pkg/persona"
	"github.com/txn2/mcp-data-platform/pkg/platform"
	"github.com/txn2/mcp-data-platform/pkg/portal"
	"github.com/txn2/mcp-data-platform/pkg/prompt"
	"github.com/txn2/mcp-data-platform/pkg/registry"
	apicatalog "github.com/txn2/mcp-data-platform/pkg/toolkits/apigateway/catalog"
	"github.com/txn2/mcp-data-platform/pkg/toolkits/apigateway/embedjobs"
	"github.com/txn2/mcp-data-platform/pkg/toolkits/gateway/enrichment"
)

// AuditQuerier queries audit events.
type AuditQuerier interface {
	Query(ctx context.Context, filter audit.QueryFilter) ([]audit.Event, error)
	Count(ctx context.Context, filter audit.QueryFilter) (int, error)
	Distinct(ctx context.Context, column string, startTime, endTime *time.Time) ([]string, error)
	DistinctPairs(ctx context.Context, col1, col2 string, startTime, endTime *time.Time) (map[string]string, error)
}

// AuditMetricsQuerier provides aggregate audit metrics.
type AuditMetricsQuerier interface {
	Timeseries(ctx context.Context, filter audit.TimeseriesFilter) ([]audit.TimeseriesBucket, error)
	Breakdown(ctx context.Context, filter audit.BreakdownFilter) ([]audit.BreakdownEntry, error)
	Overview(ctx context.Context, filter audit.MetricsFilter) (*audit.Overview, error)
	Performance(ctx context.Context, filter audit.MetricsFilter) (*audit.PerformanceStats, error)
	Enrichment(ctx context.Context, startTime, endTime *time.Time) (*audit.EnrichmentStats, error)
	Discovery(ctx context.Context, startTime, endTime *time.Time) (*audit.DiscoveryStats, error)
}

// PersonaRegistry abstracts persona.Registry for testability.
type PersonaRegistry interface {
	All() []*persona.Persona
	Get(name string) (*persona.Persona, bool)
	Register(p *persona.Persona) error
	Unregister(name string) error
	DefaultName() string
}

// APIKeyManager manages API keys at runtime.
type APIKeyManager interface {
	ListKeys() []auth.APIKeySummary
	GenerateKey(def auth.APIKey) (string, error)
	RemoveByName(name string) bool
}

// PromptRegistrar registers/unregisters prompts with the live MCP server.
type PromptRegistrar interface {
	RegisterRuntimePrompt(p *prompt.Prompt)
	UnregisterRuntimePrompt(name string)
}

// PromptInfoProvider returns metadata about platform-registered prompts
// (auto, workflow, toolkit, custom config). These are system prompts not
// stored in the database.
type PromptInfoProvider interface {
	AllPromptInfos() []registry.PromptInfo
}

// ToolkitRegistry abstracts registry.Registry for testability.
type ToolkitRegistry interface {
	All() []registry.Toolkit
	AllTools() []string
	GetToolkitForTool(toolName string) registry.ToolkitMatch
}

// ConfigStore abstracts configstore.Store for testability.
type ConfigStore interface {
	Get(ctx context.Context, key string) (*configstore.Entry, error)
	Set(ctx context.Context, key, value, author string) error
	Delete(ctx context.Context, key, author string) error
	List(ctx context.Context) ([]configstore.Entry, error)
	Changelog(ctx context.Context, limit int) ([]configstore.ChangelogEntry, error)
	Mode() string
}

// Deps holds dependencies for the admin handler.
type Deps struct {
	Config              *platform.Config
	ConfigStore         ConfigStore
	FileDefaults        map[string]string
	PersonaRegistry     PersonaRegistry
	ToolkitRegistry     ToolkitRegistry
	MCPServer           *mcp.Server
	AuditQuerier        AuditQuerier
	AuditMetricsQuerier AuditMetricsQuerier
	Knowledge           *KnowledgeHandler
	Memory              *MemoryHandler
	APIKeyManager       APIKeyManager
	BrowserAuth         *browsersession.Authenticator
	DatabaseAvailable   bool
	PlatformTools       []platform.ToolInfo
	AssetStore          portal.AssetStore
	ShareStore          portal.ShareStore
	VersionStore        portal.VersionStore
	S3Client            portal.S3Client
	S3Bucket            string
	ConnectionStore     ConnectionStore
	ConnectionSources   *platform.ConnectionSourceMap
	ToolkitsConfig      map[string]any
	PersonaStore        platform.PersonaStore
	APIKeyStore         platform.APIKeyStore
	PromptStore         prompt.Store
	PromptRegistrar     PromptRegistrar
	PromptInfoProvider  PromptInfoProvider
	FilePersonaNames    map[string]bool
	EnrichmentStore     EnrichmentStore
	EnrichmentEngine    EnrichmentEngine
	// PKCEStore holds in-flight authorization_code+PKCE state. When nil,
	// the handler falls back to a process-singleton in-memory store
	// (single-replica only). Set this to a PostgresPKCEStore for HA
	// deployments where oauth-start and the callback may land on
	// different replicas.
	PKCEStore PKCEStore
	// ConnOAuthStore persists OAuth tokens for every connection kind
	// in one shared table (migration 000039's connection_oauth_tokens).
	// The unified connection OAuth handler reads and writes through
	// this; toolkit Authenticators read through it on every outbound
	// request. nil disables the unified OAuth routes.
	ConnOAuthStore connoauth.Store
	// OAuthKinds maps connection kind ("mcp", "api", future kinds) to
	// the per-kind config extractor + post-auth side-effect hook. The
	// unified handler dispatches on the {kind} path parameter through
	// this registry. New connection kinds register here at startup —
	// they do NOT add parallel handler files or token stores.
	OAuthKinds OAuthKindHandlers
	// AuthEvents writes the durable per-connection OAuth-lifecycle
	// audit trail (connect, refresh, rotation, revocation, deletion).
	// nil disables event writes — the admin endpoints still work; the
	// History panel and "what killed my token at 17:54" diagnostics
	// just won't have data. Set this when ConnOAuthStore is set; a
	// platform with no DB has no place to write events anyway.
	AuthEvents *authevents.Writer
	// AuthEventStore is the read surface exposed via the History
	// admin endpoint. Distinct from AuthEvents because writes are
	// nil-safe (best-effort) while reads need a real implementation
	// to return 200 instead of an empty list.
	AuthEventStore authevents.Store
	// APICatalogStore manages OpenAPI spec bundles referenced by
	// api-kind connections via config.catalog_id. nil disables the
	// /api/v1/admin/api-catalogs routes; connection saves still
	// succeed but catalog_id is unvalidated. Wire this in lockstep
	// with the apigateway toolkit's SetCatalogStore so admin writes
	// and toolkit reads share one store.
	APICatalogStore APICatalogStore
	// Embedder is the embedding provider used by the api-catalog
	// admin path to compute per-operation vectors at spec-upsert
	// time. Nil disables the compute-and-store step: spec writes
	// still succeed, but the api-gateway toolkit's semantic and
	// hybrid ranking modes fall back to lexical until the operator
	// wires an embedder and re-saves (or re-embeds) the spec.
	Embedder embedding.Provider

	// EmbedJobs is the Postgres-backed job queue for api-catalog
	// embedding work. The admin handler enqueues jobs on every
	// spec write and lets the worker / reconciler / reaper run
	// the actual embedding pass off the request path. nil when
	// the platform was built without a database; spec writes
	// still succeed in that mode but no embeddings are persisted.
	EmbedJobs EmbedJobsStore
}

// EmbedJobsStore is the subset of embedjobs.Store the admin
// handler uses (enqueue + read-side queries for the UI). Declared
// here so admin does not import embedjobs transitively for tests
// that mock the queue.
type EmbedJobsStore interface {
	Enqueue(ctx context.Context, key embedjobs.SpecKey, kind embedjobs.Kind) (bool, error)
	List(ctx context.Context, filter embedjobs.ListFilter) ([]embedjobs.Job, error)
	Get(ctx context.Context, id int64) (*embedjobs.Job, error)
	SpecStatuses(ctx context.Context, catalogID string) ([]embedjobs.SpecStatusRow, error)
	Health(ctx context.Context, catalogID string) (*embedjobs.CatalogHealth, error)
}

// APICatalogStore is the subset of apigateway/catalog.Store that the
// admin handler needs. Declared here to avoid pulling the
// apigateway package into pkg/admin's import surface (the apigateway
// toolkit's other dependencies — auth-events writer, embedding
// provider — should not become admin's transitive concern).
type APICatalogStore interface {
	CreateCatalog(ctx context.Context, c apicatalog.Catalog) error
	GetCatalog(ctx context.Context, id string) (*apicatalog.Catalog, error)
	ListCatalogs(ctx context.Context) ([]apicatalog.Catalog, error)
	UpdateCatalog(ctx context.Context, id string, u apicatalog.Update) error
	DeleteCatalog(ctx context.Context, id string) error
	UpsertSpec(ctx context.Context, catalogID string, spec apicatalog.SpecEntry) error
	GetSpec(ctx context.Context, catalogID, specName string) (*apicatalog.SpecEntry, error)
	ListSpecs(ctx context.Context, catalogID string) ([]apicatalog.SpecEntry, error)
	DeleteSpec(ctx context.Context, catalogID, specName string) error
	ReferencingConnections(ctx context.Context, catalogID string) ([]apicatalog.ConnectionRef, error)
	UpsertOperationEmbeddings(ctx context.Context, catalogID, specName string, rows []apicatalog.OperationEmbedding) error
	ListOperationEmbeddings(ctx context.Context, catalogID, specName string) ([]apicatalog.OperationEmbedding, error)
	DeleteOperationEmbeddings(ctx context.Context, catalogID, specName string) error
	SetOperationCount(ctx context.Context, catalogID, specName string, count int) error
}

// EnrichmentEngine is the admin-facing surface of an enrichment.Engine.
// Defined as a small interface here so tests don't need to construct a
// real Engine.
type EnrichmentEngine interface {
	Sources() *enrichment.SourceRegistry
}

// docsPrefix is the path prefix for the public Swagger UI.
const docsPrefix = "/api/v1/admin/docs/"

// publicPrefix is the path prefix for unauthenticated public endpoints.
const publicPrefix = "/api/v1/admin/public/"

// oauthCallbackPrefix is the URL the gateway's OAuth flow redirects back
// to. Lives outside publicPrefix because operators register the exact
// URL with their OAuth provider; renaming would break every connected
// upstream. Authenticated by the per-flow PKCE state, not by an admin
// session.
const oauthCallbackPrefix = "/api/v1/admin/oauth/"

// apiGatewayOAuthCallbackPrefix is the parallel callback URL for the
// HTTP API gateway's authorization-code flow. Kept distinct from the
// MCP gateway's prefix so an operator can read the URL and tell which
// connection family the redirect belongs to. Same admin-session
// bypass rationale as oauthCallbackPrefix — PKCE state, not session,
// authenticates the callback.
const apiGatewayOAuthCallbackPrefix = "/api/v1/admin/api-gateway/oauth/"

// Handler provides admin REST API endpoints.
type Handler struct {
	mux        *http.ServeMux
	publicMux  *http.ServeMux
	deps       Deps
	authMiddle func(http.Handler) http.Handler
	// toolsDenyMu serializes read-modify-write of the tools.deny config
	// entry across concurrent setToolVisibility calls. Without it, two
	// admins toggling visibility on different tools could each load the
	// same starting list, modify their own copy, and the second writer
	// overwrites the first — silently losing one of the changes (#343).
	//
	// In-process serialization is sufficient for single-replica
	// deployments. Multi-replica deployments would additionally need a
	// row-level DB lock or version-token CAS at the config_entries layer;
	// that's a separate concern not addressed here.
	toolsDenyMu sync.Mutex
}

// statusResponse is a generic status response.
type statusResponse struct {
	Status string `json:"status" example:"ok"`
}

// @title MCP Data Platform API
// @version 1.0
// @description REST API for the MCP Data Platform. Covers admin endpoints (system, config, personas, auth keys, audit, knowledge, memory, connections), portal endpoints (assets, collections, shares, prompts, activity), and resource management.
//
// @host localhost:8080
// @BasePath /api/v1
//
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name X-API-Key
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization

// NewHandler creates a new admin API handler.
func NewHandler(deps Deps, authMiddle func(http.Handler) http.Handler) *Handler {
	h := &Handler{
		mux:        http.NewServeMux(),
		publicMux:  http.NewServeMux(),
		deps:       deps,
		authMiddle: authMiddle,
	}
	h.registerRoutes()
	return h
}

// ServeHTTP implements http.Handler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if strings.HasPrefix(r.URL.Path, docsPrefix) ||
		strings.HasPrefix(r.URL.Path, publicPrefix) ||
		strings.HasPrefix(r.URL.Path, oauthCallbackPrefix) ||
		strings.HasPrefix(r.URL.Path, apiGatewayOAuthCallbackPrefix) {
		h.publicMux.ServeHTTP(w, r)
		return
	}
	if h.authMiddle != nil {
		h.authMiddle(h.mux).ServeHTTP(w, r)
		return
	}
	h.mux.ServeHTTP(w, r)
}

// registerRoutes registers all admin API routes.
func (h *Handler) registerRoutes() {
	h.registerKnowledgeRoutes()
	h.registerMemoryRoutes()
	h.registerSystemRoutes()
	h.registerAuditRoutes()
	h.registerAuditMetricsRoutes()
	h.registerConfigRoutes()
	h.registerPersonaRoutes()
	h.registerAuthKeyRoutes()
	h.registerAssetRoutes()
	h.registerConnectionRoutes()
	h.registerCatalogRoutes()
	h.registerGatewayRoutes()
	// The unified connection OAuth handler replaces both prior per-kind
	// handlers. It activates only when the platform has wired the shared
	// connoauth store + per-kind handlers; otherwise we fall back to the
	// legacy per-kind routes so older deployments continue to work
	// during the rollout. Migration 000039 + platform startup wiring
	// together flip this to the unified path.
	if h.deps.ConnOAuthStore != nil && len(h.deps.OAuthKinds) > 0 {
		h.registerConnectionOAuthRoutes()
	} else {
		h.registerGatewayOAuthRoutes()
		h.registerAPIGatewayOAuthRoutes()
	}
	h.registerEnrichmentRoutes()
	h.registerPromptRoutes()
}

// registerKnowledgeRoutes registers knowledge management endpoints or a
// 409 fallback when the feature is enabled in config but unavailable.
func (h *Handler) registerKnowledgeRoutes() {
	if h.deps.Knowledge != nil {
		h.mux.HandleFunc("GET /api/v1/admin/knowledge/insights", h.deps.Knowledge.ListInsights)
		h.mux.HandleFunc("GET /api/v1/admin/knowledge/insights/stats", h.deps.Knowledge.GetStats)
		h.mux.HandleFunc("GET /api/v1/admin/knowledge/insights/{id}", h.deps.Knowledge.GetInsight)
		h.mux.HandleFunc("PUT /api/v1/admin/knowledge/insights/{id}/status", h.deps.Knowledge.UpdateInsightStatus)
		h.mux.HandleFunc("PUT /api/v1/admin/knowledge/insights/{id}", h.deps.Knowledge.UpdateInsight)
		h.mux.HandleFunc("GET /api/v1/admin/knowledge/changesets", h.deps.Knowledge.ListChangesets)
		h.mux.HandleFunc("GET /api/v1/admin/knowledge/changesets/{id}", h.deps.Knowledge.GetChangeset)
		h.mux.HandleFunc("POST /api/v1/admin/knowledge/changesets/{id}/rollback", h.deps.Knowledge.RollbackChangeset)
	} else if h.deps.Config != nil && (h.deps.Config.Knowledge.Enabled == nil || *h.deps.Config.Knowledge.Enabled) {
		h.mux.HandleFunc("/api/v1/admin/knowledge/", h.featureUnavailable("knowledge", "database"))
	}
}

// registerMemoryRoutes registers memory record management endpoints or a
// 409 fallback when the feature is enabled in config but unavailable.
func (h *Handler) registerMemoryRoutes() {
	if h.deps.Memory != nil {
		h.mux.HandleFunc("GET /api/v1/admin/memory/records", h.deps.Memory.ListRecords)
		h.mux.HandleFunc("GET /api/v1/admin/memory/records/stats", h.deps.Memory.GetStats)
		h.mux.HandleFunc("GET /api/v1/admin/memory/records/{id}", h.deps.Memory.GetRecord)
		h.mux.HandleFunc("PUT /api/v1/admin/memory/records/{id}", h.deps.Memory.UpdateRecord)
		h.mux.HandleFunc("DELETE /api/v1/admin/memory/records/{id}", h.deps.Memory.DeleteRecord)
	} else if h.deps.Config != nil && (h.deps.Config.Memory.Enabled == nil || *h.deps.Config.Memory.Enabled) {
		h.mux.HandleFunc("/api/v1/admin/memory/", h.featureUnavailable("memory", "database"))
	}
}

// registerSystemRoutes registers system info, tools, and connections endpoints.
func (h *Handler) registerSystemRoutes() {
	h.mux.HandleFunc("GET /api/v1/admin/system/info", h.getSystemInfo)
	h.mux.HandleFunc("GET /api/v1/admin/tools", h.listTools)
	h.mux.HandleFunc("GET /api/v1/admin/tools/schemas", h.getToolSchemas)
	h.mux.HandleFunc("GET /api/v1/admin/tools/{name}", h.getToolDetail)
	h.mux.HandleFunc("POST /api/v1/admin/tools/call", h.callTool)
	if h.isMutable() {
		h.mux.HandleFunc("PUT /api/v1/admin/tools/{name}/visibility", h.setToolVisibility)
	}
	h.mux.HandleFunc("GET /api/v1/admin/connections", h.listConnections)
	h.mux.HandleFunc("GET /api/v1/admin/embedding/status", h.getEmbeddingStatus)
	h.publicMux.HandleFunc("GET /api/v1/admin/public/branding", h.getPublicBranding)
	h.publicMux.Handle(docsPrefix, httpswagger.Handler(
		httpswagger.URL(docsPrefix+"doc.json"),
	))
}

// registerAuditRoutes registers audit event endpoints or a 409 fallback
// when audit is enabled in config but no database is available.
func (h *Handler) registerAuditRoutes() {
	if h.deps.AuditQuerier != nil {
		h.mux.HandleFunc("GET /api/v1/admin/audit/events/filters", h.listAuditEventFilters)
		h.mux.HandleFunc("GET /api/v1/admin/audit/events", h.listAuditEvents)
		h.mux.HandleFunc("GET /api/v1/admin/audit/events/{id}", h.getAuditEvent)
		h.mux.HandleFunc("GET /api/v1/admin/audit/stats", h.getAuditStats)
	} else if h.deps.Config != nil && (h.deps.Config.Audit.Enabled == nil || *h.deps.Config.Audit.Enabled) {
		h.mux.HandleFunc("/api/v1/admin/audit/", h.featureUnavailable("audit", "database"))
	}
}

// registerConfigRoutes registers config read/write endpoints.
func (h *Handler) registerConfigRoutes() {
	if h.deps.Config != nil {
		h.mux.HandleFunc("GET /api/v1/admin/config", h.getConfig)
	}
	h.mux.HandleFunc("GET /api/v1/admin/config/mode", h.configMode)
	h.mux.HandleFunc("GET /api/v1/admin/config/export", h.exportConfig)
	if h.deps.ConfigStore != nil {
		h.mux.HandleFunc("GET /api/v1/admin/config/effective", h.listEffectiveConfig)
		h.mux.HandleFunc("GET /api/v1/admin/config/entries", h.listConfigEntries)
		h.mux.HandleFunc("GET /api/v1/admin/config/entries/{key}", h.getConfigEntry)
	}
	if h.isMutable() {
		h.mux.HandleFunc("PUT /api/v1/admin/config/entries/{key}", h.setConfigEntry)
		h.mux.HandleFunc("DELETE /api/v1/admin/config/entries/{key}", h.deleteConfigEntry)
		h.mux.HandleFunc("GET /api/v1/admin/config/changelog", h.getConfigChangelog)
	}
}

// registerPersonaRoutes registers persona read endpoints and conditional write
// endpoints (only in database config mode).
func (h *Handler) registerPersonaRoutes() {
	if h.deps.PersonaRegistry == nil {
		return
	}
	h.mux.HandleFunc("GET /api/v1/admin/personas", h.listPersonas)
	h.mux.HandleFunc("GET /api/v1/admin/personas/{name}", h.getPersona)
	h.mux.HandleFunc("POST /api/v1/admin/personas/{name}/test-access", h.testPersonaAccess)
	if h.isMutable() {
		h.mux.HandleFunc("POST /api/v1/admin/personas", h.createPersona)
		h.mux.HandleFunc("PUT /api/v1/admin/personas/{name}", h.updatePersona)
		h.mux.HandleFunc("DELETE /api/v1/admin/personas/{name}", h.deletePersona)
	}
}

// registerAuthKeyRoutes registers auth key read endpoints and conditional write
// endpoints (only in database config mode).
func (h *Handler) registerAuthKeyRoutes() {
	if h.deps.APIKeyManager == nil {
		return
	}
	h.mux.HandleFunc("GET /api/v1/admin/auth/keys", h.listAuthKeys)
	if h.isMutable() {
		h.mux.HandleFunc("POST /api/v1/admin/auth/keys", h.createAuthKey)
		h.mux.HandleFunc("DELETE /api/v1/admin/auth/keys/{name}", h.deleteAuthKey)
	} else {
		// Register write patterns as read-only so the mux returns 405
		// instead of 404 (POST already gets 405 from the GET pattern,
		// but DELETE /auth/keys/{name} has no matching GET pattern).
		h.mux.HandleFunc("POST /api/v1/admin/auth/keys", h.readOnlyMethod())
		h.mux.HandleFunc("DELETE /api/v1/admin/auth/keys/{name}", h.readOnlyMethod())
	}
}

// configModeFile is the config store mode value for read-only file mode.
const configModeFile = "file"

// isMutable returns true if the config store supports mutations (DB mode).
func (h *Handler) isMutable() bool {
	return h.deps.ConfigStore != nil && h.deps.ConfigStore.Mode() != configModeFile
}

// readOnlyMethod returns a handler that responds with 405 Method Not Allowed
// for write operations that are not available in file config mode.
func (*Handler) readOnlyMethod() http.HandlerFunc {
	return func(w http.ResponseWriter, _ *http.Request) {
		writeError(w, http.StatusMethodNotAllowed, "not available in file config mode")
	}
}

// featureUnavailable returns a handler that responds with 409 Conflict
// when a feature is enabled in config but unavailable in the current
// operating mode (e.g., knowledge enabled but no database configured).
func (*Handler) featureUnavailable(feature, requires string) http.HandlerFunc {
	return func(w http.ResponseWriter, _ *http.Request) {
		writeError(w, http.StatusConflict,
			fmt.Sprintf("%s is enabled but not available without %s configuration", feature, requires))
	}
}

// problemDetail represents an RFC 9457 Problem Details response.
type problemDetail struct {
	Type   string `json:"type" example:"about:blank"`
	Title  string `json:"title" example:"Not Found"`
	Status int    `json:"status" example:"404"`
	Detail string `json:"detail,omitempty" example:"resource not found"`
}

// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// sanitizeLogValue strips all ASCII control characters from user-supplied
// values to prevent log injection attacks.
func sanitizeLogValue(s string) string {
	return strings.Map(func(r rune) rune {
		if r < 32 || r == 127 { // ASCII control chars + DEL
			return -1
		}
		return r
	}, s)
}

// writeError writes a JSON error response using RFC 9457 Problem Details.
func writeError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(problemDetail{
		Type:   "about:blank",
		Title:  http.StatusText(status),
		Status: status,
		Detail: msg,
	})
}
