// Package config loads and validates the backend's runtime configuration from
// the process environment.
package config

import (
	"fmt"
	"net/url"
	"os"
	"strconv"
	"time"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/lobby"
	"scrabble/backend/internal/mynalog"
	"scrabble/backend/internal/mynalogsync"
	"scrabble/backend/internal/postgres"
	"scrabble/backend/internal/ratewatch"
	"scrabble/backend/internal/robokassa"
	"scrabble/backend/internal/robot"
	"scrabble/backend/internal/telemetry"
	"scrabble/backend/internal/yookassa"
)

// Config holds the backend's runtime configuration.
type Config struct {
	// HTTPAddr is the listen address of the HTTP listener (host:port).
	HTTPAddr string
	// GRPCAddr is the listen address of the gRPC push listener (host:port) that
	// streams live events to the gateway.
	GRPCAddr string
	// LogLevel is the zap log level: "debug", "info", "warn" or "error".
	LogLevel string
	// Postgres configures the primary database pool.
	Postgres postgres.Config
	// Telemetry configures the OpenTelemetry providers.
	Telemetry telemetry.Config
	// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
	Game game.Config
	// Lobby configures matchmaking robot substitution (wait window, reaper cadence).
	Lobby lobby.Config
	// Robot configures the robot opponent driver (scan cadence).
	Robot robot.Config
	// RateWatch tunes the conservative high-rate auto-flag applied to the
	// gateway's rate-limiter rejection reports.
	RateWatch ratewatch.Config
	// SMTP configures the email relay used for confirm-codes. An empty Host
	// selects the development log mailer (the code is logged, not sent).
	SMTP account.SMTPConfig
	// PublicBaseURL is the canonical public origin (scheme + host, e.g.
	// https://erudit-game.ru) used to build absolute links in outgoing email — the
	// confirm deeplink and the footer landing link. It is deliberately not derived
	// from a request Host header, which would let an attacker inject a phishing link
	// into the email. Required whenever an SMTP relay is configured.
	PublicBaseURL string
	// ConnectorAddr is the gRPC address of the Telegram platform connector
	// side-service, used by the admin console to send operator broadcasts. Empty
	// disables broadcasts (the admin broadcast actions report "not configured").
	ConnectorAddr string
	// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
	GuestReapInterval time.Duration
	// GuestRetention is the account age past which an unused guest (no game seat)
	// is eligible for deletion by the reaper.
	GuestRetention time.Duration
	// ExportSignKey signs the finished-game export download URLs. Empty leaves
	// the export-URL endpoints disabled (503 on mint, 404 on download).
	ExportSignKey string
	// RendererURL is the base URL of the internal image-render sidecar (e.g.
	// http://renderer:8090). Empty disables the PNG export artifact.
	RendererURL string
	// YooKassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
	// (D42). An empty set leaves the direct order and notification endpoints unregistered and falls
	// the direct rail back to Robokassa.
	YooKassa yookassa.Shops
	// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
	// Unset (0) sends no receipt at all — the state a merchant outside 54-ФЗ runs in, which is the
	// default; setting a rate turns fiscal receipts back on without a code change.
	YooKassaVatCode int
	// Robokassa configures the retired direct-rail payment provider — one merchant shop per channel
	// (D42). It is dormant: no deployment sets its credentials, so the set is empty and the rail
	// resolves to YooKassa. Kept wired so restoring Robokassa is a credentials change, not a code
	// change — see backend/internal/robokassa/README.md.
	Robokassa robokassa.Shops
	// MyNalog configures the professional-income tax export. An empty Key leaves the whole rail
	// dormant: without somewhere safe to keep the cabinet's refresh token there is no way to stay
	// signed in, so the console section and both workers stay unregistered.
	MyNalog mynalogsync.Config
}

// Defaults applied when the corresponding environment variable is unset.
const (
	defaultHTTPAddr          = ":8080"
	defaultGRPCAddr          = ":9090"
	defaultLogLevel          = "info"
	defaultGuestReapInterval = time.Hour
	defaultGuestRetention    = 30 * 24 * time.Hour
	// defaultMyNalogTZ is the taxpayer's time zone. It decides which tax month a near-midnight
	// payment is filed under, so it follows the taxpayer's registration, not the server's clock.
	defaultMyNalogTZ = "Europe/Moscow"
)

// Load reads the configuration from the environment, applies defaults for unset
// variables, and validates the result.
func Load() (Config, error) {
	pg := postgres.DefaultConfig()
	pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN")
	var err error
	if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil {
		return Config{}, err
	}
	if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil {
		return Config{}, err
	}
	if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil {
		return Config{}, err
	}
	if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil {
		return Config{}, err
	}

	tel := telemetry.DefaultConfig()
	tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName)
	tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter)
	tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter)

	gm := game.DefaultConfig()
	gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
	gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
	gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
	if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
		return Config{}, err
	}
	if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil {
		return Config{}, err
	}

	lb := lobby.DefaultConfig()
	if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil {
		return Config{}, err
	}
	if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil {
		return Config{}, err
	}
	if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil {
		return Config{}, err
	}

	rb := robot.DefaultConfig()
	if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil {
		return Config{}, err
	}

	rw := ratewatch.DefaultConfig()
	if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil {
		return Config{}, err
	}
	if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil {
		return Config{}, err
	}

	guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
	if err != nil {
		return Config{}, err
	}
	guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
	if err != nil {
		return Config{}, err
	}

	smtp := account.SMTPConfig{
		Host:      os.Getenv("BACKEND_SMTP_HOST"),
		Port:      envOr("BACKEND_SMTP_PORT", "587"),
		Username:  os.Getenv("BACKEND_SMTP_USERNAME"),
		Password:  os.Getenv("BACKEND_SMTP_PASSWORD"),
		From:      envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
		TLS:       os.Getenv("BACKEND_SMTP_TLS"),
		AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"),
		AdminTo:   os.Getenv("BACKEND_ADMIN_EMAIL"),
	}

	// YooKassa direct rail: one merchant shop per channel (D42). A shop missing either credential is
	// dropped, so the rail stays dormant until a channel is fully configured.
	ykShops := yookassa.Shops{}
	for channel, prefix := range map[string]string{
		yookassa.ChannelWeb:     "BACKEND_YOOKASSA_WEB",
		yookassa.ChannelAndroid: "BACKEND_YOOKASSA_ANDROID",
	} {
		if shop := yookassaShop(prefix); shop.Configured() {
			ykShops[channel] = shop
		}
	}
	vatCode, err := envInt("BACKEND_YOOKASSA_VAT_CODE", yookassa.VatCodeOff)
	if err != nil {
		return Config{}, err
	}

	// Robokassa direct rail: retired but kept wired (see backend/internal/robokassa/README.md). No
	// deployment sets these, so the set is empty and the direct rail resolves to YooKassa; restoring
	// the credentials revives it without a code change. The legacy single-shop vars seed the web
	// channel; the per-channel vars add the rest.
	shops := robokassa.Shops{}
	web := robokassaShop("BACKEND_ROBOKASSA_WEB")
	if web.MerchantLogin == "" {
		web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
	}
	if web.MerchantLogin != "" {
		shops[robokassa.ChannelWeb] = web
	}
	if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
		shops[robokassa.ChannelAndroid] = android
	}

	// Professional-income tax export. The key seals the cabinet's refresh token at rest; without it
	// the rail is dormant, which is the state every deployment starts in.
	mn := mynalogsync.Config{BaseURL: os.Getenv("BACKEND_MYNALOG_BASE_URL")}
	if raw := os.Getenv("BACKEND_MYNALOG_KEY"); raw != "" {
		if mn.Key, err = mynalog.ParseKey(raw); err != nil {
			return Config{}, fmt.Errorf("config: BACKEND_MYNALOG_KEY: %w", err)
		}
	}
	// The offset a receipt carries decides which tax period an income falls into, so this is the
	// taxpayer's own zone, not the server's.
	if mn.Location, err = time.LoadLocation(envOr("BACKEND_MYNALOG_TZ", defaultMyNalogTZ)); err != nil {
		return Config{}, fmt.Errorf("config: BACKEND_MYNALOG_TZ: %w", err)
	}

	c := Config{
		HTTPAddr:          envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
		GRPCAddr:          envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
		LogLevel:          envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
		Postgres:          pg,
		Telemetry:         tel,
		Game:              gm,
		Lobby:             lb,
		Robot:             rb,
		RateWatch:         rw,
		SMTP:              smtp,
		PublicBaseURL:     os.Getenv("BACKEND_PUBLIC_BASE_URL"),
		ConnectorAddr:     os.Getenv("BACKEND_CONNECTOR_ADDR"),
		GuestReapInterval: guestReapInterval,
		GuestRetention:    guestRetention,
		ExportSignKey:     os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
		RendererURL:       os.Getenv("BACKEND_RENDERER_URL"),
		YooKassa:          ykShops,
		YooKassaVatCode:   vatCode,
		Robokassa:         shops,
		MyNalog:           mn,
	}
	if err := c.validate(); err != nil {
		return Config{}, err
	}
	return c, nil
}

// 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 BACKEND_LOG_LEVEL %q", c.LogLevel)
	}
	if c.HTTPAddr == "" {
		return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty")
	}
	if c.GRPCAddr == "" {
		return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty")
	}
	if err := c.Postgres.Validate(); err != nil {
		return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err)
	}
	if err := c.Telemetry.Validate(); err != nil {
		return fmt.Errorf("config: %w", err)
	}
	if err := c.Game.Validate(); err != nil {
		return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err)
	}
	if err := c.Lobby.Validate(); err != nil {
		return fmt.Errorf("config: %w", err)
	}
	if err := c.Robot.Validate(); err != nil {
		return fmt.Errorf("config: %w", err)
	}
	if err := c.RateWatch.Validate(); err != nil {
		return fmt.Errorf("config: %w", err)
	}
	if c.GuestReapInterval <= 0 {
		return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
	}
	if c.GuestRetention <= 0 {
		return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
	}
	if c.SMTP.Host != "" {
		if c.PublicBaseURL == "" {
			return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured")
		}
		if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
			return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
		}
	}
	for channel, shop := range c.Robokassa {
		if shop.Password1 == "" || shop.Password2 == "" {
			return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
		}
	}
	if !yookassa.ValidVatCode(c.YooKassaVatCode) {
		return fmt.Errorf("config: BACKEND_YOOKASSA_VAT_CODE %d is not a 54-ФЗ VAT rate code (1..%d); leave it unset to send no receipts", c.YooKassaVatCode, yookassa.VatCodeMax)
	}
	if c.YooKassa.Configured() {
		// The YooKassa rail sends the customer to a hosted payment page and needs an absolute return
		// URL to bring them back, which only the public base URL can supply.
		if c.PublicBaseURL == "" {
			return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when a YooKassa shop is configured")
		}
		if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
			return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
		}
	}
	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
}

// 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
}

// yookassaShop reads a YooKassa shop's credentials from the environment under prefix (e.g.
// "BACKEND_YOOKASSA_WEB" → _SHOP_ID / _SECRET_KEY / _TEST). _TEST marks a test shop, which lets the
// intake refuse to credit a live payment against test credentials and vice versa. A shop missing
// either credential yields a Config the caller drops.
func yookassaShop(prefix string) yookassa.Config {
	return yookassa.Config{
		ShopID:    os.Getenv(prefix + "_SHOP_ID"),
		SecretKey: os.Getenv(prefix + "_SECRET_KEY"),
		IsTest:    os.Getenv(prefix+"_TEST") == "1",
	}
}

// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
// MerchantLogin yields a zero Config the caller drops.
func robokassaShop(prefix string) robokassa.Config {
	return robokassa.Config{
		MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
		Password1:     os.Getenv(prefix + "_PASSWORD1"),
		Password2:     os.Getenv(prefix + "_PASSWORD2"),
		IsTest:        os.Getenv(prefix+"_TEST") == "1",
	}
}
