package server

import (
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io/fs"
	"net/netip"
	"reflect"
	"text/template"
	"time"

	"heckel.io/ntfy/v2/ban"
	"heckel.io/ntfy/v2/user"
)

// Defines default config settings (excluding limits, see below)
const (
	DefaultListenHTTP                           = ":80"
	DefaultCacheDuration                        = 12 * time.Hour
	DefaultCacheBatchTimeout                    = time.Duration(0)
	DefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
	DefaultManagerInterval                      = time.Minute
	DefaultManagerBatchSize                     = 30000
	DefaultDelayedSenderInterval                = 10 * time.Second
	DefaultMessageDelayMin                      = 10 * time.Second
	DefaultMessageDelayMax                      = 3 * 24 * time.Hour
	DefaultFirebaseKeepaliveInterval            = 3 * time.Hour    // ~control topic (Android), not too frequently to save battery
	DefaultFirebasePollInterval                 = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
	DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
	DefaultStripePriceCacheDuration             = 3 * time.Hour    // Time to keep Stripe prices cached in memory before a refresh is needed
)

// Platform-specific default paths (set in config_unix.go or config_windows.go)
var (
	DefaultConfigFile  string
	DefaultTemplateDir string
)

// Defines default Web Push settings
const (
	DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
	DefaultWebPushExpiryDuration        = 60 * 24 * time.Hour
)

// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
const (
	DefaultBanWindow    = 10 * time.Minute
	DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
)

// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor).
var DefaultBanWeights = []string{
	banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
}

// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
// the errHTTP definitions instead of being duplicated as string literals.
func banWeight(err *errHTTP, weight int) string {
	return fmt.Sprintf("%d:%d", err.Code, weight)
}

// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall
// - various attachment limits
const (
	DefaultMessageSizeLimit            = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
	DefaultTotalTopicLimit             = 15000
	DefaultAttachmentTotalSizeLimit    = int64(5 * 1024 * 1024 * 1024) // 5 GB
	DefaultAttachmentFileSizeLimit     = int64(15 * 1024 * 1024)       // 15 MB
	DefaultAttachmentExpiryDuration    = 3 * time.Hour
	DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads

	// DefaultMessagePollSizeLimit caps what one cache replay returns per topic. It is a backstop
	// against a single request materializing an entire topic cache, not a tunable: on ntfy.sh it
	// would fire on 2 of ~98k cached topics. See docs/subscribe/api.md#replay-limits.
	DefaultMessagePollSizeLimit = 10 * 1024 * 1024

	// messageTitleSizeLimit and messageTagsSizeLimit cap two publisher-controlled fields that
	// otherwise have no limit of their own. Sized off ntfy.sh's own cache: title p999 is 212 bytes
	// (16 of ~3M messages exceed 1 KB), tags p999 is 244 (197 exceed 512).
	messageTitleSizeLimit = 1024
	messageTagsSizeLimit  = 512
)

// Defines all per-visitor limits
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 5 seconds)
// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
// - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server
// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server
const (
	DefaultVisitorSubscriptionLimit             = 30
	DefaultVisitorRequestLimitBurst             = 60
	DefaultVisitorRequestLimitReplenish         = 5 * time.Second
	DefaultVisitorMessageDailyLimit             = 0
	DefaultVisitorEmailLimitBurst               = 16
	DefaultVisitorEmailLimitReplenish           = time.Hour
	DefaultVisitorTopicCreationLimitBurst       = 100
	DefaultVisitorTopicCreationLimitReplenish   = time.Minute
	DefaultVisitorAccountCreationLimitBurst     = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
	DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
	DefaultVisitorAuthFailureLimitBurst         = 30
	DefaultVisitorAuthFailureLimitReplenish     = time.Minute
	DefaultVisitorAttachmentTotalSizeLimit      = 100 * 1024 * 1024 // 100 MB
	DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
	DefaultVisitorPrefixBitsIPv4                = 32                // Use the entire IPv4 address for rate limiting
	DefaultVisitorPrefixBitsIPv6                = 64                // Use /64 for IPv6 rate limiting
)

var (
	// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
	DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)

	// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
	// extended using the server.yml config. If updated, also update in Android and web app.
	DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "metrics", "account", "settings", "signup", "login", "v1"}
)

// Config is the main config struct for the application. Use New to instantiate a default config struct.
type Config struct {
	File                                 string // Config file, only used for testing
	BaseURL                              string
	ListenHTTP                           string
	ListenHTTPS                          string
	ListenUnix                           string
	ListenUnixMode                       fs.FileMode
	KeyFile                              string
	CertFile                             string
	DatabaseURL                          string   // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
	DatabaseReplicaURLs                  []string // PostgreSQL read replica connection strings
	FirebaseKeyFile                      string
	CacheFile                            string
	CacheDuration                        time.Duration
	CacheStartupQueries                  string
	CacheBatchSize                       int
	CacheBatchTimeout                    time.Duration
	AuthFile                             string
	AuthStartupQueries                   string
	AuthDefault                          user.Permission
	AuthUsers                            []*user.User `hash:"-"`
	AuthAccess                           map[string][]*user.Grant
	AuthTokens                           map[string][]*user.Token `hash:"-"`
	AuthBcryptCost                       int
	AuthStatsQueueWriterInterval         time.Duration
	AuthAccessCacheEnabled               bool          // Enables the in-memory ACL cache (high volume servers only)
	AuthAccessCacheReloadInterval        time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
	AttachmentCacheDir                   string
	AttachmentTotalSizeLimit             int64
	AttachmentFileSizeLimit              int64
	AttachmentExpiryDuration             time.Duration
	AttachmentOrphanGracePeriod          time.Duration
	TemplateDir                          string // Directory to load named templates from
	KeepaliveInterval                    time.Duration
	ManagerInterval                      time.Duration
	ManagerBatchSize                     int
	DisallowedTopics                     []string
	WebRoot                              string // empty to disable
	DelayedSenderInterval                time.Duration
	FirebaseKeepaliveInterval            time.Duration
	FirebasePollInterval                 time.Duration
	FirebaseQuotaExceededPenaltyDuration time.Duration
	UpstreamBaseURL                      string
	UpstreamAccessToken                  string `hash:"-"`
	SMTPSenderAddr                       string
	SMTPSenderUser                       string
	SMTPSenderPass                       string `hash:"-"`
	SMTPSenderFrom                       string
	SMTPSenderVerify                     bool
	SMTPServerListen                     string
	SMTPServerDomain                     string
	SMTPServerAddrPrefix                 string
	TwilioAccount                        string
	TwilioAuthToken                      string `hash:"-"`
	TwilioPhoneNumber                    string
	TwilioCallsBaseURL                   string
	TwilioVerifyBaseURL                  string
	TwilioVerifyService                  string
	TwilioCallFormat                     *template.Template
	MetricsListenHTTP                    string
	ProfileListenHTTP                    string
	MessageDelayMin                      time.Duration
	MessageDelayMax                      time.Duration
	MessageSizeLimit                     int
	MessagePollSizeLimit                 int64
	TotalTopicLimit                      int
	TotalAttachmentSizeLimit             int64
	VisitorSubscriptionLimit             int
	VisitorAttachmentTotalSizeLimit      int64
	VisitorAttachmentDailyBandwidthLimit int64
	VisitorRequestLimitBurst             int
	VisitorRequestLimitReplenish         time.Duration
	VisitorRequestExemptPrefixes         []netip.Prefix
	VisitorMessageDailyLimit             int
	VisitorEmailLimitBurst               int
	VisitorEmailLimitReplenish           time.Duration
	VisitorTopicCreationLimitBurst       int           // Burst of new topic creations per visitor
	VisitorTopicCreationLimitReplenish   time.Duration // Interval at which topic-creation tokens are refilled
	VisitorAccountCreationLimitBurst     int
	VisitorAccountCreationLimitReplenish time.Duration
	VisitorAuthFailureLimitBurst         int
	VisitorAuthFailureLimitReplenish     time.Duration
	VisitorStatsResetTime                time.Time      // Time of the day at which to reset visitor stats
	VisitorSubscriberRateLimiting        bool           // Enable subscriber-based rate limiting for UnifiedPush topics
	VisitorPrefixBitsIPv4                int            // Number of bits for IPv4 rate limiting (default: 32)
	VisitorPrefixBitsIPv6                int            // Number of bits for IPv6 rate limiting (default: 64)
	BehindProxy                          bool           // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
	ProxyForwardedHeader                 string         // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
	ProxyTrustedPrefixes                 []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
	StripeSecretKey                      string         `hash:"-"`
	StripeWebhookKey                     string         `hash:"-"`
	StripePriceCacheDuration             time.Duration
	BillingContact                       string
	EnableSignup                         bool // Enable creation of accounts via API and UI
	EnableLogin                          bool
	RequireLogin                         bool
	EnableReservations                   bool // Allow users with role "user" to own/reserve topics
	EnableMetrics                        bool
	AccessControlAllowOrigin             string // CORS header field to restrict access from web clients
	WebPushPrivateKey                    string `hash:"-"`
	WebPushPublicKey                     string
	WebPushFile                          string
	WebPushEmailAddress                  string
	WebPushStartupQueries                string
	WebPushExpiryDuration                time.Duration
	WebPushExpiryWarningDuration         time.Duration
	BanFile                              string        // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
	BanWindow                            time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
	BanThreshold                         int           // Abuse ban-feed: weighted strikes per window before a prefix is banned
	BanWeights                           ban.Weights   // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
	BuildVersion                         string        // Injected by App
	BuildDate                            string        // Injected by App
	BuildCommit                          string        // Injected by App
}

// NewConfig instantiates a default new server config
func NewConfig() *Config {
	return &Config{
		File:                                 DefaultConfigFile, // Only used for testing
		BaseURL:                              "",
		ListenHTTP:                           DefaultListenHTTP,
		ListenHTTPS:                          "",
		ListenUnix:                           "",
		ListenUnixMode:                       0,
		KeyFile:                              "",
		CertFile:                             "",
		DatabaseURL:                          "",
		FirebaseKeyFile:                      "",
		CacheFile:                            "",
		CacheDuration:                        DefaultCacheDuration,
		CacheStartupQueries:                  "",
		CacheBatchSize:                       0,
		CacheBatchTimeout:                    0,
		AuthFile:                             "",
		AuthStartupQueries:                   "",
		AuthDefault:                          user.PermissionReadWrite,
		AuthBcryptCost:                       user.DefaultUserPasswordBcryptCost,
		AuthStatsQueueWriterInterval:         user.DefaultUserStatsQueueWriterInterval,
		AuthAccessCacheEnabled:               user.DefaultAccessCacheEnabled,
		AuthAccessCacheReloadInterval:        user.DefaultAccessCacheReloadInterval,
		AttachmentCacheDir:                   "",
		AttachmentTotalSizeLimit:             DefaultAttachmentTotalSizeLimit,
		AttachmentFileSizeLimit:              DefaultAttachmentFileSizeLimit,
		AttachmentExpiryDuration:             DefaultAttachmentExpiryDuration,
		AttachmentOrphanGracePeriod:          DefaultAttachmentOrphanGracePeriod,
		TemplateDir:                          DefaultTemplateDir,
		KeepaliveInterval:                    DefaultKeepaliveInterval,
		ManagerInterval:                      DefaultManagerInterval,
		ManagerBatchSize:                     DefaultManagerBatchSize,
		DisallowedTopics:                     DefaultDisallowedTopics,
		WebRoot:                              "/",
		DelayedSenderInterval:                DefaultDelayedSenderInterval,
		FirebaseKeepaliveInterval:            DefaultFirebaseKeepaliveInterval,
		FirebasePollInterval:                 DefaultFirebasePollInterval,
		FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
		UpstreamBaseURL:                      "",
		UpstreamAccessToken:                  "",
		SMTPSenderAddr:                       "",
		SMTPSenderUser:                       "",
		SMTPSenderPass:                       "",
		SMTPSenderFrom:                       "",
		SMTPSenderVerify:                     false,
		SMTPServerListen:                     "",
		SMTPServerDomain:                     "",
		SMTPServerAddrPrefix:                 "",
		TwilioCallsBaseURL:                   "https://api.twilio.com", // Override for tests
		TwilioAccount:                        "",
		TwilioAuthToken:                      "",
		TwilioPhoneNumber:                    "",
		TwilioVerifyBaseURL:                  "https://verify.twilio.com", // Override for tests
		TwilioVerifyService:                  "",
		TwilioCallFormat:                     nil,
		MessageSizeLimit:                     DefaultMessageSizeLimit,
		MessagePollSizeLimit:                 DefaultMessagePollSizeLimit,
		MessageDelayMin:                      DefaultMessageDelayMin,
		MessageDelayMax:                      DefaultMessageDelayMax,
		TotalTopicLimit:                      DefaultTotalTopicLimit,
		TotalAttachmentSizeLimit:             0,
		VisitorSubscriptionLimit:             DefaultVisitorSubscriptionLimit,
		VisitorSubscriberRateLimiting:        false,
		VisitorAttachmentTotalSizeLimit:      DefaultVisitorAttachmentTotalSizeLimit,
		VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
		VisitorRequestLimitBurst:             DefaultVisitorRequestLimitBurst,
		VisitorRequestLimitReplenish:         DefaultVisitorRequestLimitReplenish,
		VisitorRequestExemptPrefixes:         make([]netip.Prefix, 0),
		VisitorMessageDailyLimit:             DefaultVisitorMessageDailyLimit,
		VisitorEmailLimitBurst:               DefaultVisitorEmailLimitBurst,
		VisitorEmailLimitReplenish:           DefaultVisitorEmailLimitReplenish,
		VisitorTopicCreationLimitBurst:       DefaultVisitorTopicCreationLimitBurst,
		VisitorTopicCreationLimitReplenish:   DefaultVisitorTopicCreationLimitReplenish,
		VisitorAccountCreationLimitBurst:     DefaultVisitorAccountCreationLimitBurst,
		VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
		VisitorAuthFailureLimitBurst:         DefaultVisitorAuthFailureLimitBurst,
		VisitorAuthFailureLimitReplenish:     DefaultVisitorAuthFailureLimitReplenish,
		VisitorStatsResetTime:                DefaultVisitorStatsResetTime,
		VisitorPrefixBitsIPv4:                DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address
		VisitorPrefixBitsIPv6:                DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
		BehindProxy:                          false,                        // If true, the server will trust the proxy client IP header to determine the client IP address
		ProxyForwardedHeader:                 "X-Forwarded-For",            // Default header for reverse proxy client IPs
		StripeSecretKey:                      "",
		StripeWebhookKey:                     "",
		StripePriceCacheDuration:             DefaultStripePriceCacheDuration,
		BillingContact:                       "",
		EnableSignup:                         false,
		EnableLogin:                          false,
		EnableReservations:                   false,
		RequireLogin:                         false,
		AccessControlAllowOrigin:             "*",
		WebPushPrivateKey:                    "",
		WebPushPublicKey:                     "",
		WebPushFile:                          "",
		WebPushEmailAddress:                  "",
		WebPushExpiryDuration:                DefaultWebPushExpiryDuration,
		WebPushExpiryWarningDuration:         DefaultWebPushExpiryWarningDuration,
		BanFile:                              "",
		BanWindow:                            DefaultBanWindow,
		BanThreshold:                         DefaultBanThreshold,
		BanWeights:                           nil,
		BuildVersion:                         "",
		BuildDate:                            "",
		BuildCommit:                          "",
	}
}

// Hash computes an SHA-256 hash of the configuration. This is used to detect
// configuration changes for the web app version check feature. It uses reflection
// to include all JSON-serializable fields automatically.
func (c *Config) Hash() string {
	v := reflect.ValueOf(*c)
	t := v.Type()
	var result string
	for i := 0; i < v.NumField(); i++ {
		field := v.Field(i)
		fieldName := t.Field(i).Name
		// Secrets must not feed the hash
		if t.Field(i).Tag.Get("hash") == "-" {
			continue
		}
		// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
		if b, err := json.Marshal(field.Interface()); err == nil {
			result += fmt.Sprintf("%s:%s|", fieldName, string(b))
		}
	}
	return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
}
