// Package config loads and validates the gateway's runtime configuration from
// the process environment. Every variable is prefixed GATEWAY_.
package config

import (
	"fmt"
	"os"
	"strconv"
	"strings"
	"time"

	"scrabble/gateway/internal/clientver"
	pkgtel "scrabble/pkg/telemetry"
)

// Config holds the gateway's runtime configuration.
type Config struct {
	// HTTPAddr is the public Connect/h2c listener address (host:port). It also
	// serves the admin console at /_gm when admin credentials are configured.
	HTTPAddr string
	// LogLevel is the zap log level: "debug", "info", "warn" or "error".
	LogLevel string
	// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
	BackendHTTPURL string
	// BackendGRPCAddr is the backend push gRPC address the gateway subscribes to.
	BackendGRPCAddr string
	// BackendTimeout bounds a single backend REST call.
	BackendTimeout time.Duration
	// AdminUser and AdminPassword are the Basic-Auth credentials the gateway
	// checks before proxying admin traffic to the backend. Empty disables admin.
	AdminUser     string
	AdminPassword string
	// ValidatorAddr is the gRPC address of the Telegram validator side-service (home,
	// plaintext, internal). The gateway calls it to validate Mini App initData and
	// Login Widget data. Empty disables the telegram auth path.
	ValidatorAddr string
	// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
	// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
	// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
	VKAppSecret string
	// VKID configures the VK ID web login used to link a VK identity from a browser
	// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
	// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
	// Any field empty disables the VK web-link ops (link.vk.*).
	VKID VKIDConfig
	// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
	// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
	BotLink BotLinkConfig
	// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
	// caps the number of cached sessions.
	SessionTTL      time.Duration
	SessionCacheMax int
	// PushHeartbeatInterval is the idle keep-alive cadence on a client live stream.
	PushHeartbeatInterval time.Duration
	// MaxBodyBytes caps one inbound request body on the public listener and one
	// Connect message read; oversized requests are refused without buffering.
	MaxBodyBytes int
	// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
	// the edge serves. A client reporting an older X-Client-Version is turned away with an
	// "update required" signal before its payload is decoded. Empty leaves the gate dormant
	// (every client is served) — the default for web-only deployments.
	MinClientVersion string
	// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
	// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
	// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
	// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
	RecommendedClientVersion string
	// RateLimit configures the in-memory anti-abuse limiter.
	RateLimit RateLimitConfig
	// Abuse configures the temporary IP ban and the honeytoken (prod-only).
	Abuse AbuseConfig
	// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
	Blocklist BlocklistConfig
	// Telemetry configures the OpenTelemetry providers (shared bootstrap).
	Telemetry pkgtel.Config
}

// BotLinkConfig configures the gateway's reverse bot-link: the mTLS listener the
// remote Telegram bot dials, the plaintext listener the backend admin relay calls,
// and the mTLS material. The main host is already public, so exposing the bot-link
// listener on a dedicated port adds no static IP; the channel is guarded solely by
// mTLS (the bot has no fixed address to allow-list).
type BotLinkConfig struct {
	// Addr is the mTLS gRPC listener the bot dials (e.g. ":9443"). Empty disables
	// the whole bot channel.
	Addr string
	// RelayAddr is the plaintext internal gRPC listener that serves the backend
	// admin SendToUser/SendToGameChannel relay (e.g. ":9092"). Empty disables it.
	RelayAddr string
	// CertFile, KeyFile and CAFile are the gateway server certificate, its key and
	// the CA bundle that signs the accepted bot client certificates. Required when
	// Addr is set.
	CertFile string
	KeyFile  string
	CAFile   string
	// SendTimeout bounds the admin relay's wait for the bot Ack before reporting
	// the send as not delivered.
	SendTimeout time.Duration
}

// RateLimitConfig holds the token-bucket limits per class. Public and admin are
// keyed per client IP; the authenticated class is keyed per user id; the email
// sub-limit guards the costly email-code path per IP.
type RateLimitConfig struct {
	PublicPerMinute int
	PublicBurst     int
	UserPerMinute   int
	UserBurst       int
	AdminPerMinute  int
	AdminBurst      int
	EmailPer10Min   int
	EmailBurst      int
}

// AbuseConfig configures the gateway's temporary IP ban (fail2ban-style) and the
// honeytoken trap. BanEnabled gates the ban action and is off by default: it is
// only safe where the real client IP is visible (i.e. in prod, not behind the
// shared-NAT test contour). Detection of honeypot/honeytoken hits is logged
// regardless of BanEnabled — only the ban action is gated.
type AbuseConfig struct {
	// BanEnabled turns the IP ban on. Off by default (prod-only).
	BanEnabled bool
	// BanThreshold is the rate-limiter rejection count within BanWindow that bans
	// a client IP.
	BanThreshold int
	// BanWindow is the rolling window the rejection strikes accumulate over.
	BanWindow time.Duration
	// BanDuration is the length of a rejection-earned ban (tripwire and honeytoken
	// bans use their own, longer, fixed durations).
	BanDuration time.Duration
	// Honeytoken, when non-empty, is a planted bearer value: presenting it bans the
	// caller and raises a high-severity alarm. Empty disables the trap.
	Honeytoken string
}

// Defaults applied when the corresponding environment variable is unset.
const (
	defaultAbuseBanThreshold     = 100
	defaultAbuseBanWindow        = 2 * time.Minute
	defaultAbuseBanDuration      = 15 * time.Minute
	defaultHTTPAddr              = ":8081"
	defaultLogLevel              = "info"
	defaultBackendHTTPURL        = "http://localhost:8080"
	defaultBackendGRPCAddr       = "localhost:9090"
	defaultBackendTimeout        = 5 * time.Second
	defaultSessionTTL            = 10 * time.Minute
	defaultSessionCacheMax       = 50000
	defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout
	defaultServiceName           = "scrabble-gateway"
	defaultBotLinkSendTimeout    = 5 * time.Second
)

// DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES):
// 1 MiB — far above any legitimate edge payload (drafts and chat are a few KB)
// yet small enough to stop a cheap memory-amplification upload.
const DefaultMaxBodyBytes = 1 << 20

// DefaultRateLimit returns the built-in anti-abuse limits.
func DefaultRateLimit() RateLimitConfig {
	return RateLimitConfig{
		PublicPerMinute: 30, PublicBurst: 10,
		// Per-user (not per-IP): one user may run several devices, each holding a
		// Subscribe stream and reloading state on every live event, so the authenticated
		// budget is generous (a per-user cap cannot DoS the service). It is raised
		// because multi-device play tripped the old 120/40.
		UserPerMinute: 300, UserBurst: 80,
		AdminPerMinute: 60, AdminBurst: 20,
		// Email-code path (per IP), defence-in-depth over the backend's own per-code guards
		// (a 5-attempt cap + 15-min TTL + per-recipient send throttle). Burst 4 so the honest flow
		// — request a code, mistype once or twice, then enter the right one — is not throttled
		// mid-login: a request + wrong-code + right-code sequence exhausted the old burst of 2 and
		// tripped the limit on the correct code, which the client mis-read as going offline.
		EmailPer10Min: 5, EmailBurst: 4,
	}
}

// DefaultAbuse returns the built-in anti-abuse settings: the ban disabled
// (prod-only) with the agreed thresholds, and no honeytoken.
func DefaultAbuse() AbuseConfig {
	return AbuseConfig{
		BanEnabled:   false,
		BanThreshold: defaultAbuseBanThreshold,
		BanWindow:    defaultAbuseBanWindow,
		BanDuration:  defaultAbuseBanDuration,
	}
}

// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
// test contour). Only IPv4 is matched.
type BlocklistConfig struct {
	// Enabled turns edge blocklisting on. Off by default (prod-only).
	Enabled bool
	// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
	// operator sets it explicitly so no feed URL is assumed.
	URL string
	// Refresh is how often the feed is re-fetched.
	Refresh time.Duration
	// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
	// (fail-open — a legitimate client is never blocked on a frozen feed).
	MaxStaleness time.Duration
	// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
	// the feed can never block. Parsed at startup.
	Allow []string
}

// Blocklist defaults; the feed URL has no default (the operator sets it).
const (
	defaultBlocklistRefresh      = 6 * time.Hour
	defaultBlocklistMaxStaleness = 48 * time.Hour
)

// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
// the agreed refresh / staleness windows.
func DefaultBlocklist() BlocklistConfig {
	return BlocklistConfig{
		Enabled:      false,
		Refresh:      defaultBlocklistRefresh,
		MaxStaleness: defaultBlocklistMaxStaleness,
	}
}

// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
// with the app and the one the frontend uses. All three are required to enable the flow.
type VKIDConfig struct {
	AppID        string
	ClientSecret string
	RedirectURI  string
}

// Enabled reports whether VK ID web login is fully configured. When false the gateway
// leaves the VK web-link ops (link.vk.*) unregistered.
func (c VKIDConfig) Enabled() bool {
	return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
}

// Load reads the configuration from the environment, applies defaults, and
// validates the result.
func Load() (Config, error) {
	var err error
	c := Config{
		HTTPAddr:                 envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
		LogLevel:                 envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
		BackendHTTPURL:           envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
		BackendGRPCAddr:          envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
		AdminUser:                os.Getenv("GATEWAY_ADMIN_USER"),
		AdminPassword:            os.Getenv("GATEWAY_ADMIN_PASSWORD"),
		ValidatorAddr:            os.Getenv("GATEWAY_VALIDATOR_ADDR"),
		VKAppSecret:              os.Getenv("GATEWAY_VK_APP_SECRET"),
		MinClientVersion:         os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
		RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
		VKID: VKIDConfig{
			AppID:        os.Getenv("GATEWAY_VK_ID_APP_ID"),
			ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
			RedirectURI:  os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
		},
		SessionCacheMax: defaultSessionCacheMax,
		RateLimit:       DefaultRateLimit(),
		Abuse:           DefaultAbuse(),
		Blocklist:       DefaultBlocklist(),
		BotLink: BotLinkConfig{
			Addr:      os.Getenv("GATEWAY_BOTLINK_ADDR"),
			RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
			CertFile:  os.Getenv("GATEWAY_BOTLINK_TLS_CERT"),
			KeyFile:   os.Getenv("GATEWAY_BOTLINK_TLS_KEY"),
			CAFile:    os.Getenv("GATEWAY_BOTLINK_TLS_CA"),
		},
	}
	tel := pkgtel.DefaultConfig(defaultServiceName)
	tel.ServiceName = envOr("GATEWAY_SERVICE_NAME", tel.ServiceName)
	tel.TracesExporter = envOr("GATEWAY_OTEL_TRACES_EXPORTER", tel.TracesExporter)
	tel.MetricsExporter = envOr("GATEWAY_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
	c.Telemetry = tel
	if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
		return Config{}, err
	}
	if c.SessionTTL, err = envDuration("GATEWAY_SESSION_TTL", defaultSessionTTL); err != nil {
		return Config{}, err
	}
	if c.SessionCacheMax, err = envInt("GATEWAY_SESSION_CACHE_MAX", defaultSessionCacheMax); err != nil {
		return Config{}, err
	}
	if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
		return Config{}, err
	}
	if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
		return Config{}, err
	}
	c.Abuse.Honeytoken = os.Getenv("GATEWAY_HONEYTOKEN")
	if c.Abuse.BanEnabled, err = envBool("GATEWAY_ABUSE_BAN_ENABLED", c.Abuse.BanEnabled); err != nil {
		return Config{}, err
	}
	if c.Abuse.BanThreshold, err = envInt("GATEWAY_ABUSE_BAN_THRESHOLD", c.Abuse.BanThreshold); err != nil {
		return Config{}, err
	}
	if c.Abuse.BanWindow, err = envDuration("GATEWAY_ABUSE_BAN_WINDOW", c.Abuse.BanWindow); err != nil {
		return Config{}, err
	}
	if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
		return Config{}, err
	}
	if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
		return Config{}, err
	}
	c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
	if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
		return Config{}, err
	}
	if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
		return Config{}, err
	}
	c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
	if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
		return Config{}, err
	}
	if err := c.validate(); err != nil {
		return Config{}, err
	}
	return c, nil
}

// BotLinkEnabled reports whether the reverse bot-link channel is configured.
func (c Config) BotLinkEnabled() bool { return c.BotLink.Addr != "" }

// AdminEnabled reports whether the admin console proxy should be mounted (both
// Basic-Auth credentials are configured).
func (c Config) AdminEnabled() bool {
	return c.AdminUser != "" && c.AdminPassword != ""
}

// validate reports whether the configuration values are acceptable.
func (c Config) validate() error {
	switch c.LogLevel {
	case "debug", "info", "warn", "error":
	default:
		return fmt.Errorf("config: invalid GATEWAY_LOG_LEVEL %q", c.LogLevel)
	}
	if c.HTTPAddr == "" {
		return fmt.Errorf("config: GATEWAY_HTTP_ADDR must not be empty")
	}
	if c.BackendHTTPURL == "" {
		return fmt.Errorf("config: GATEWAY_BACKEND_HTTP_URL must not be empty")
	}
	if c.BackendGRPCAddr == "" {
		return fmt.Errorf("config: GATEWAY_BACKEND_GRPC_ADDR must not be empty")
	}
	if c.MaxBodyBytes <= 0 {
		return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
	}
	if c.MinClientVersion != "" {
		if _, ok := clientver.Parse(c.MinClientVersion); !ok {
			return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
		}
	}
	if c.RecommendedClientVersion != "" {
		rec, ok := clientver.Parse(c.RecommendedClientVersion)
		if !ok {
			return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
		}
		// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
		// misconfiguration (every client below min is already turned away). An empty/unparseable
		// minimum imposes no lower bound, so the recommended may stand alone.
		if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
			return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
		}
	}
	if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
		return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
	}
	if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
		return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
	}
	if c.BotLink.Addr != "" {
		if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
			return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
		}
	}
	if err := c.Telemetry.Validate(); err != nil {
		return fmt.Errorf("config: %w", err)
	}
	return nil
}

// envOr returns the value of the environment variable named key, or fallback
// when the variable is unset or empty.
func envOr(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

// splitList splits a comma-separated environment value into trimmed, non-empty items.
func splitList(v string) []string {
	if v == "" {
		return nil
	}
	parts := strings.Split(v, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// envBool parses the environment variable named key as a bool, returning fallback
// when it is unset and an error when it is set but malformed.
func envBool(key string, fallback bool) (bool, error) {
	v := os.Getenv(key)
	if v == "" {
		return fallback, nil
	}
	b, err := strconv.ParseBool(v)
	if err != nil {
		return false, fmt.Errorf("config: %s: %w", key, err)
	}
	return b, nil
}

// envInt parses the environment variable named key as an int, returning fallback
// when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
	v := os.Getenv(key)
	if v == "" {
		return fallback, nil
	}
	n, err := strconv.Atoi(v)
	if err != nil {
		return 0, fmt.Errorf("config: %s: %w", key, err)
	}
	return n, nil
}

// envDuration parses the environment variable named key as a Go duration,
// returning fallback when it is unset and an error when it is set but malformed.
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
	v := os.Getenv(key)
	if v == "" {
		return fallback, nil
	}
	d, err := time.ParseDuration(v)
	if err != nil {
		return 0, fmt.Errorf("config: %s: %w", key, err)
	}
	return d, nil
}
