// Package config provides configuration loading for the OwnCord server.
package config

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"log/slog"
	"net"
	"os"
	"strings"

	"github.com/knadh/koanf/parsers/yaml"
	"github.com/knadh/koanf/providers/env"
	"github.com/knadh/koanf/providers/file"
	"github.com/knadh/koanf/providers/structs"
	"github.com/knadh/koanf/v2"
	goyaml "go.yaml.in/yaml/v3"
)

// Config holds the full server configuration.
type Config struct {
	Server           ServerConfig           `koanf:"server"`
	Database         DatabaseConfig         `koanf:"database"`
	TLS              TLSConfig              `koanf:"tls"`
	Upload           UploadConfig           `koanf:"upload"`
	Voice            VoiceConfig            `koanf:"voice"`
	GitHub           GitHubConfig           `koanf:"github"`
	EventPersistence EventPersistenceConfig `koanf:"event_persistence"`
	Telemetry        TelemetryConfig        `koanf:"telemetry"`
	Plugins          PluginsConfig          `koanf:"plugins"`
	GIF              GIFConfig              `koanf:"gif"`
	Logging          LoggingConfig          `koanf:"logging"`
}

// LoggingConfig controls server log verbosity. Level gates both stdout and
// the in-memory ring buffer that backs the admin panel's live log view, so
// suppressed levels cost nothing anywhere on the hot path.
type LoggingConfig struct {
	// Level is the minimum level logged: "debug" | "info" | "warn" |
	// "error". Override at runtime without editing config.yaml via the
	// OWNCORD_LOGGING_LEVEL environment variable.
	Level string `koanf:"level"`
}

// ParseLevel maps a config log-level string to a slog.Level. It is
// case-insensitive and treats "" as info. The bool is false for an
// unrecognised value (in which case slog.LevelInfo is returned and the caller
// should warn) so a typo doesn't silently disable logging.
func ParseLevel(s string) (slog.Level, bool) {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "debug":
		return slog.LevelDebug, true
	case "", "info":
		return slog.LevelInfo, true
	case "warn", "warning":
		return slog.LevelWarn, true
	case "error":
		return slog.LevelError, true
	default:
		return slog.LevelInfo, false
	}
}

// GIFConfig holds the credentials for the server-side GIF (Klipy) proxy.
//
// The API key is deliberately server-only: the client never receives it and
// never talks to api.klipy.com directly, it calls /api/v1/gif/* on its own
// server instead. An empty APIKey means the feature is OFF — the proxy
// endpoints answer 503 GIF_DISABLED and the client hides the picker.
type GIFConfig struct {
	APIKey string `koanf:"api_key"`
}

// EventPersistenceConfig (Phase B Step 7) controls the tiered event log used
// for WebSocket reconnection replay.
type EventPersistenceConfig struct {
	// Enabled toggles cold-storage persistence. When false the server falls
	// back to ring-buffer-only behaviour (Phase A semantics).
	Enabled bool `koanf:"enabled"`
	// RetentionHours is how long persisted events are kept before pruning.
	RetentionHours int `koanf:"retention_hours"`
	// BatchSize is the maximum number of events per persister flush.
	BatchSize int `koanf:"batch_size"`
	// BatchFlushMs is the maximum delay between persister flushes.
	BatchFlushMs int `koanf:"batch_flush_ms"`
	// PrunerIntervalMinutes is how often the pruner goroutine wakes up.
	PrunerIntervalMinutes int `koanf:"pruner_interval_minutes"`
}

// TelemetryConfig (Phase B Step 8) controls the OpenTelemetry exporter.
type TelemetryConfig struct {
	// Enabled toggles the OTel SDK. When false the server uses no-op
	// tracer/meter providers and the legacy /metrics endpoint stays the
	// only metrics surface.
	Enabled bool `koanf:"enabled"`
	// Exporter is "none" | "prometheus" | "otlp".
	Exporter string `koanf:"exporter"`
	// OTLPEndpoint is the gRPC endpoint when Exporter == "otlp".
	OTLPEndpoint string `koanf:"otlp_endpoint"`
	// OTLPInsecure disables TLS for the OTLP gRPC connection. Only set
	// true in development / private-network deployments. Defaults to false
	// (TLS required) to avoid transmitting trace/metric data in plaintext.
	OTLPInsecure bool `koanf:"otlp_insecure"`
	// ServiceName is the resource service.name attribute.
	ServiceName string `koanf:"service_name"`
}

// PluginsConfig (Phase C Step 9) controls the Wazero plugin runtime.
type PluginsConfig struct {
	// Enabled toggles plugin loading at startup.
	Enabled bool `koanf:"enabled"`
	// Directory is the on-disk directory scanned for plugin packages.
	Directory string `koanf:"directory"`
	// MaxMemoryMB caps a single plugin's WASM linear memory.
	MaxMemoryMB int `koanf:"max_memory_mb"`
	// CPUBudgetMs caps a single plugin invocation's CPU time.
	CPUBudgetMs int `koanf:"cpu_budget_ms"`
	// HTTPAllowlist enumerates host suffixes plugins may reach via host_http.
	HTTPAllowlist []string `koanf:"http_allowlist"`
}

// GitHubConfig holds GitHub API settings for update checking.
//
// Owner/Repo point at the public releases repository. Server and client
// update checks fetch release assets from this repo, so it must stay
// publicly readable even when the source repository is private.
type GitHubConfig struct {
	Token string `koanf:"token"`
	Owner string `koanf:"owner"`
	Repo  string `koanf:"repo"`
}

// VoiceConfig holds LiveKit server connection and voice quality settings.
type VoiceConfig struct {
	LiveKitAPIKey     string `koanf:"livekit_api_key"`    // LiveKit API key
	LiveKitAPISecret  string `koanf:"livekit_api_secret"` // LiveKit API secret
	LiveKitURL        string `koanf:"livekit_url"`        // LiveKit server WebSocket URL (e.g. ws://localhost:7880)
	LiveKitBinaryPath string `koanf:"livekit_binary"`     // path to livekit-server binary; empty = don't auto-start
	// AutoDownloadLiveKit downloads a pinned, checksum-verified livekit-server
	// release from the official LiveKit GitHub releases into
	// <data_dir>/livekit/ and runs it as the companion process, when no
	// livekit_binary is configured. Fresh installs enable this in the
	// generated config.yaml so voice works out of the box; the compiled-in
	// default stays false so existing configs keep their behaviour.
	AutoDownloadLiveKit bool `koanf:"auto_download_livekit"`
	// LiveKitVersion overrides the pinned livekit-server release version used
	// by auto-download (e.g. "1.13.5"). Empty = the built-in pin.
	LiveKitVersion string `koanf:"livekit_version"`
	NodeIP         string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect
	// AdvertiseInternalIP makes LiveKit advertise internal (LAN) host candidates
	// in addition to the external node_ip mapping, so clients on the local
	// network can connect while remote clients use the public IP.
	AdvertiseInternalIP bool   `koanf:"advertise_internal_ip"`
	Quality             string `koanf:"quality"` // low | medium | high
}

// ServerConfig holds HTTP server settings.
type ServerConfig struct {
	Port              int      `koanf:"port"`
	Name              string   `koanf:"name"`
	DataDir           string   `koanf:"data_dir"`
	AllowedOrigins    []string `koanf:"allowed_origins"`
	TrustedProxies    []string `koanf:"trusted_proxies"`
	AdminAllowedCIDRs []string `koanf:"admin_allowed_cidrs"`
	WAFEnabled        bool     `koanf:"waf_enabled"`        // Enable Coraza WAF (default: false)
	WAFParanoiaLevel  int      `koanf:"waf_paranoia_level"` // OWASP CRS paranoia level 1-4 (default: 2)
	// WAFCRSMode selects the OWASP Core Rule Set layer mode when the WAF is
	// enabled: "off" (inline rules only), "detect" (CRS evaluated, matches
	// logged, never blocks) or "block" (CRS anomaly-scoring blocking).
	// Defaults to "detect": chat traffic routinely contains SQL-ish/HTML-ish
	// text the CRS false-positives on, so blocking needs tuning against real
	// traffic first. Unknown values fall back to "detect".
	WAFCRSMode string `koanf:"waf_crs_mode"`
}

// DatabaseConfig holds database settings.
//
// Type selects the backend: "sqlite" (default, zero-config) or "postgres"
// (community-hub scale, requires a running PostgreSQL server). The Path field
// is only used by sqlite. The remaining fields apply to postgres only.
//
// PostgreSQL support is currently scaffolding-only: the schema, config plumbing,
// and migrations are in place, but the store query layer is gated on the
// in-progress sqlc adoption (Phase A Step 2). Setting Type to "postgres" will
// cause the server to refuse to start with a clear error pointing at the
// follow-up work — see Server/main.go.
type DatabaseConfig struct {
	// Type selects the database backend. "sqlite" (or empty, which defaults
	// to it) is the only supported value.
	Type string `koanf:"type"`

	// Path is the SQLite database file path.
	Path string `koanf:"path"`
}

// TLSConfig holds TLS/certificate settings.
type TLSConfig struct {
	Mode         string `koanf:"mode"`
	CertFile     string `koanf:"cert_file"`
	KeyFile      string `koanf:"key_file"`
	Domain       string `koanf:"domain"`
	AcmeCacheDir string `koanf:"acme_cache_dir"`
}

// UploadConfig holds file upload settings.
type UploadConfig struct {
	MaxSizeMB  int    `koanf:"max_size_mb"`
	StorageDir string `koanf:"storage_dir"`
}

// defaults returns the default configuration.
func defaults() Config {
	return Config{
		Server: ServerConfig{
			Port:           8443,
			Name:           "OwnCord Server",
			DataDir:        "data",
			AllowedOrigins: []string{},
			TrustedProxies: []string{},
			AdminAllowedCIDRs: []string{
				"127.0.0.0/8",    // localhost IPv4
				"::1/128",        // localhost IPv6
				"10.0.0.0/8",     // private class A
				"172.16.0.0/12",  // private class B
				"192.168.0.0/16", // private class C
				"fc00::/7",       // IPv6 unique local
			},
			WAFCRSMode: "detect",
		},
		Database: DatabaseConfig{
			Type: "sqlite",
			Path: "data/chatserver.db",
		},
		TLS: TLSConfig{
			Mode:         "self_signed",
			CertFile:     "data/cert.pem",
			KeyFile:      "data/key.pem",
			AcmeCacheDir: "data/acme_certs",
		},
		Upload: UploadConfig{
			MaxSizeMB:  100,
			StorageDir: "data/uploads",
		},
		Voice: VoiceConfig{
			LiveKitURL: "ws://localhost:7880",
			Quality:    "medium",
		},
		GitHub: GitHubConfig{
			Owner: "J3vb",
			Repo:  "OwnCord",
		},
		EventPersistence: EventPersistenceConfig{
			Enabled:               true,
			RetentionHours:        24,
			BatchSize:             50,
			BatchFlushMs:          100,
			PrunerIntervalMinutes: 60,
		},
		Telemetry: TelemetryConfig{
			Enabled:     false,
			Exporter:    "none",
			ServiceName: "owncord-server",
		},
		Plugins: PluginsConfig{
			Enabled:       false,
			Directory:     "data/plugins",
			MaxMemoryMB:   64,
			CPUBudgetMs:   100,
			HTTPAllowlist: []string{},
		},
		Logging: LoggingConfig{
			Level: "info",
		},
	}
}

// defaultYAML is the content written when no config file is present.
const defaultYAML = `# OwnCord Server Configuration
server:
  port: 8443
  name: "OwnCord Server"
  data_dir: "data"
  # allowed_origins: []       # browser origins allowed to connect; empty = deny cross-origin.
  #                           # The OwnCord desktop client is always accepted and needs no entry.
  # trusted_proxies: []       # CIDRs of the reverse-proxy HOPS only (e.g. ["10.0.0.2/32"]).
  #                           # Never list client networks here: a range that covers
  #                           # clients degrades per-client rate limiting and lets
  #                           # covered clients influence their own rate-limit key.
  # admin_allowed_cidrs:      # CIDRs allowed to access /admin (default: private networks only)
  #   - "127.0.0.0/8"
  #   - "::1/128"
  #   - "10.0.0.0/8"
  #   - "172.16.0.0/12"
  #   - "192.168.0.0/16"
  # waf_enabled: false        # Coraza WAF (inline rules + OWASP Core Rule Set)
  # waf_paranoia_level: 2     # OWASP CRS paranoia level 1-4
  # waf_crs_mode: "detect"    # off | detect | block — CRS layer mode; "detect" logs
  #                           # CRS matches without blocking (safe default for chat traffic)

database:
  type: "sqlite"          # "sqlite" is the only supported backend
  path: "data/chatserver.db"

tls:
  mode: "self_signed"  # self_signed, acme, manual, off
  cert_file: "data/cert.pem"
  key_file: "data/key.pem"
  domain: ""              # required for acme mode (e.g. "chat.example.com")
  acme_cache_dir: "data/acme_certs"  # where Let's Encrypt certs are cached

upload:
  max_size_mb: 100
  storage_dir: "data/uploads"

voice:
  # livekit_api_key: ""       # LiveKit API key (REQUIRED for voice — generate a unique key)
  # livekit_api_secret: ""    # LiveKit API secret (REQUIRED, min 32 chars — generate a unique secret)
  livekit_url: "ws://localhost:7880"  # LiveKit server WebSocket URL
  auto_download_livekit: true # download and run livekit-server automatically when
                              # no livekit_binary is set (verified against the
                              # official LiveKit release checksums; stored in data/livekit/)
  # livekit_version: ""            # override the pinned livekit-server version (e.g. "1.13.5")
  # livekit_binary: ""             # path to an existing livekit-server binary; set this to
  #                                # skip auto-download and run your own build
  # node_ip: ""                    # public IP for WebRTC media (required for remote users behind NAT)
  # advertise_internal_ip: false   # also advertise LAN IPs so local-network clients can connect
  # quality: "medium"              # low | medium | high

# github:
#   token: ""  # optional: GitHub API token for higher rate limits (5000 req/hr vs 60)

# Phase B Step 7 — cold-tier event log used by the WebSocket reconnect path.
# When the in-memory ring buffer can't cover a client's last_seq the server
# falls back to these rows before forcing a full re-sync. Rows older than
# retention_hours are pruned by a background goroutine.
# event_persistence:
#   enabled: true             # set false to disable cold-tier replay entirely
#   retention_hours: 24       # how long to keep persisted broadcast events
#   batch_size: 50            # flush after this many events buffered
#   batch_flush_ms: 100       # OR after this many milliseconds, whichever first
#   pruner_interval_minutes: 60  # how often the retention pruner runs

# Phase B Step 8 — OpenTelemetry exporter. The default build ships a no-op
# provider; building with -tags otel enables the real SDK.
# telemetry:
#   enabled: false            # master switch
#   exporter: "none"          # none | prometheus | otlp
#   otlp_endpoint: ""         # required when exporter == "otlp" (host:port of collector)
#   otlp_insecure: false      # set true only for dev/private networks (disables TLS)
#   service_name: "owncord-server"

# Phase C Step 9 — Wazero plugin runtime. Disabled by default so existing
# operators are unaffected. Plugins live in subdirectories of the configured
# directory; see Server/plugin/examples/hello for the manifest format.
# plugins:
#   enabled: false
#   directory: "data/plugins"
#   max_memory_mb: 64         # per-plugin memory cap
#   cpu_budget_ms: 100        # per-invocation CPU budget
#   http_allowlist: []        # hostnames plugins may reach via the http capability

# GIF picker (Klipy). Disabled by default: with no api_key the /api/v1/gif/*
# endpoints answer 503 GIF_DISABLED and the client hides its GIF button. The
# key stays on the server — it is never sent to clients.
# Get a key at https://partner.klipy.com
# gif:
#   api_key: ""

# Logging. "level" gates what is logged, to stdout and the admin panel's live
# log view alike. Override without editing this file via the
# OWNCORD_LOGGING_LEVEL environment variable.
# logging:
#   level: "info"             # debug | info | warn | error
`

// Load reads configuration from the given YAML file path, merging with
// defaults and environment variable overrides. If the file does not exist,
// a default config.yaml is written and defaults are returned.
func Load(cfgPath string) (*Config, error) {
	k := koanf.New(".")

	// Layer 1: built-in defaults via struct provider.
	def := defaults()
	if err := k.Load(structs.Provider(def, "koanf"), nil); err != nil {
		return nil, fmt.Errorf("loading defaults: %w", err)
	}

	// Layer 2: YAML file (create default if missing). The freshly written
	// default file is loaded like any other so the first boot runs with
	// exactly the configuration the file documents (the generated template
	// enables options — e.g. voice.auto_download_livekit — that the
	// compiled-in defaults deliberately leave off for pre-existing configs).
	if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
		if writeErr := os.WriteFile(cfgPath, []byte(defaultYAML), 0o600); writeErr != nil {
			return nil, fmt.Errorf("writing default config: %w", writeErr)
		}
	}
	// Read the file and try to parse it ourselves to detect invalid YAML.
	raw, readErr := os.ReadFile(cfgPath) //nolint:gosec // G304: path from trusted wiring
	if readErr != nil {
		return nil, fmt.Errorf("reading config file %s: %w", cfgPath, readErr)
	}
	if parseErr := validateYAML(raw); parseErr != nil {
		return nil, fmt.Errorf("loading config file %s: %w", cfgPath, parseErr)
	}
	if err := k.Load(file.Provider(cfgPath), yaml.Parser()); err != nil {
		return nil, fmt.Errorf("loading config file %s: %w", cfgPath, err)
	}

	// Layer 3: environment variable overrides.
	// OWNCORD_SERVER_PORT -> server.port, OWNCORD_TLS_MODE -> tls.mode, etc.
	envProvider := env.Provider("OWNCORD_", ".", func(s string) string {
		// Strip prefix, lowercase, replace _ with . except within a key segment.
		// OWNCORD_SERVER_PORT -> server.port
		// OWNCORD_DATABASE_PATH -> database.path
		// OWNCORD_UPLOAD_MAX_SIZE_MB -> upload.max_size_mb
		s = strings.TrimPrefix(s, "OWNCORD_")
		s = strings.ToLower(s)
		// Split into at most 2 parts on the first underscore to get
		// section.key. We need smarter splitting because keys can have
		// underscores (e.g. max_size_mb, data_dir, storage_dir).
		return envKeyToKoanf(s)
	})
	if err := k.Load(envProvider, nil); err != nil {
		return nil, fmt.Errorf("loading env vars: %w", err)
	}

	var cfg Config
	if err := k.Unmarshal("", &cfg); err != nil {
		return nil, fmt.Errorf("unmarshalling config: %w", err)
	}

	// Apply voice defaults for zero-value fields (koanf loses defaults when
	// the YAML section is present but fields are commented out / omitted).
	if err := applyVoiceDefaults(&cfg.Voice); err != nil {
		return nil, fmt.Errorf("applying voice defaults: %w", err)
	}

	// Warn if using default dev credentials — these are public and insecure.
	// Clear credentials so downstream consumers (e.g. NewLiveKitClient) see
	// empty values and refuse to start voice.
	if IsDefaultVoiceCredentials(&cfg.Voice) {
		slog.Warn("using default LiveKit dev credentials — voice will be disabled; set voice.livekit_api_key and voice.livekit_api_secret in config.yaml")
		cfg.Voice.LiveKitAPIKey = ""
		cfg.Voice.LiveKitAPISecret = ""
	}

	// Invalid CIDR entries are skipped at request time (they must not crash
	// handling), which silently un-trusts a misconfigured proxy — warn once
	// at startup instead. Common mistake: a bare IP without the /32 mask.
	warnInvalidCIDRs("server.trusted_proxies", cfg.Server.TrustedProxies)
	warnInvalidCIDRs("server.admin_allowed_cidrs", cfg.Server.AdminAllowedCIDRs)

	return &cfg, nil
}

// warnInvalidCIDRs logs a startup warning for each list entry that is not
// valid CIDR notation.
func warnInvalidCIDRs(key string, cidrs []string) {
	for _, c := range cidrs {
		if _, _, err := net.ParseCIDR(c); err != nil {
			slog.Warn("config: ignoring invalid CIDR entry (use address/prefix notation, e.g. 10.0.0.1/32)",
				"key", key, "entry", c)
		}
	}
}

// defaultLiveKitAPIKey and defaultLiveKitAPISecret are the well-known dev
// credentials that ship in the default config. They must never be used in
// production — NewLiveKitClient rejects them.
const (
	DefaultLiveKitAPIKey    = "devkey"
	DefaultLiveKitAPISecret = "owncord-dev-secret-key-min-32chars" //nolint:gosec // G101: false positive — config key name, not a credential
)

// IsDefaultVoiceCredentials returns true when the voice config still uses
// the well-known default dev credentials shipped in the source code.
func IsDefaultVoiceCredentials(v *VoiceConfig) bool {
	return v.LiveKitAPIKey == DefaultLiveKitAPIKey ||
		v.LiveKitAPISecret == DefaultLiveKitAPISecret
}

// generateRandomKey returns a crypto-random hex string of the given byte length.
func generateRandomKey(byteLen int) (string, error) {
	b := make([]byte, byteLen)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("crypto/rand: %w", err)
	}
	return hex.EncodeToString(b), nil
}

// applyVoiceDefaults fills in zero-value voice fields with sensible defaults.
// This guards against the koanf merge behaviour where an empty YAML section
// overwrites struct defaults with Go zero values.
// When API key/secret are empty, unique random credentials are generated
// so voice works out of the box without shipping known-public defaults.
func applyVoiceDefaults(v *VoiceConfig) error {
	if v.LiveKitAPIKey == "" {
		key, err := generateRandomKey(8)
		if err != nil {
			return fmt.Errorf("generating LiveKit API key: %w", err)
		}
		v.LiveKitAPIKey = "key-" + key
		slog.Warn("generated random LiveKit API key — voice tokens will break on restart; set voice.livekit_api_key in config.yaml for stable operation")
	}
	if v.LiveKitAPISecret == "" {
		secret, err := generateRandomKey(32)
		if err != nil {
			return fmt.Errorf("generating LiveKit API secret: %w", err)
		}
		v.LiveKitAPISecret = secret
		slog.Warn("generated random LiveKit API secret — set voice.livekit_api_secret in config.yaml for stable operation")
	}
	if v.LiveKitURL == "" {
		v.LiveKitURL = "ws://localhost:7880"
	}
	if v.Quality == "" {
		v.Quality = "medium"
	}
	return nil
}

// validateYAML checks that raw bytes are valid YAML.
func validateYAML(raw []byte) error {
	var v any
	return goyaml.Unmarshal(raw, &v)
}

// envKeyToKoanf converts a lower-case env key (without OWNCORD_ prefix) to a
// koanf dotted path. The first segment (up to the first underscore) is the
// section; the remainder is the key (with underscores preserved).
//
// Examples:
//
//	server_port        -> server.port
//	server_name        -> server.name
//	server_data_dir    -> server.data_dir
//	database_path      -> database.path
//	tls_mode           -> tls.mode
//	tls_cert_file      -> tls.cert_file
//	upload_max_size_mb -> upload.max_size_mb
func envKeyToKoanf(s string) string {
	before, after, ok := strings.Cut(s, "_")
	if !ok {
		return s
	}
	return before + "." + after
}
