package server

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"crypto/tls"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/mail"
	"net/netip"
	"net/smtp"
	"net/url"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"vocat/internal/store"
)

var (
	errUnsafeDestination = errors.New("notification destination is not public")
	errProviderRejected  = errors.New("notification provider rejected the test")
	telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`)
)

var notificationChannels = []string{
	"telegram",
	"email",
	"webhook",
	"bark",
	"pushplus",
}

var notificationFields = map[string]map[string]string{
	"telegram": {
		"bot_token": "string", "chat_id": "string", "admin_id": "string",
		"base_url": "string", "proxy": "string",
	},
	"email": {
		"use_ssl": "boolean", "smtp_host": "string", "smtp_port": "integer", "username": "string",
		"password": "string", "from_address": "string", "to_addresses": "strings",
	},
	"webhook": {
		"urls": "strings", "secret": "string", "timeout_ms": "integer",
		"retry_max": "integer", "text_template": "string", "headers": "string_map",
	},
	"bark": {
		"urls": "strings", "group": "string", "icon": "string", "level": "string",
	},
	"pushplus": {
		"token": "string", "topic": "string", "channel": "string",
	},
}

// routeSettingsAPI is intentionally independent of the main router so it can
// be wired after the surrounding authentication and CSRF checks.
func (s *Server) routeSettingsAPI(
	w http.ResponseWriter,
	r *http.Request,
	cleanPath string,
) bool {
	cleanPath = strings.Trim(cleanPath, "/")
	switch cleanPath {
	case "settings/notifications":
		s.handleNotificationSettings(w, r)
		return true
	case "traffic/analysis":
		s.handleTrafficAnalysis(w, r)
		return true
	case "cards/policies":
		s.handleCardPolicies(w, r)
		return true
	case "settings/security":
		s.handleSecuritySettings(w, r)
		return true
	case "settings/logging":
		s.handleLoggingSettings(w, r)
		return true
	}
	segments := splitAPIPath(cleanPath)
	if len(segments) == 4 &&
		segments[0] == "settings" &&
		segments[1] == "notifications" &&
		segments[3] == "test" {
		s.handleNotificationTest(w, r, segments[2])
		return true
	}
	if len(segments) == 3 && segments[0] == "cards" && segments[2] == "policy" {
		s.handleCardPolicy(w, r, segments[1])
		return true
	}
	return false
}

func (s *Server) handleNotificationSettings(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		s.writeNotificationSettings(w, r)
	case http.MethodPut:
		var request map[string]json.RawMessage
		if err := s.decodeJSON(w, r, &request); err != nil {
			writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
			return
		}
		if request == nil {
			writeError(w, http.StatusBadRequest, "invalid_request", "request body must be a JSON object")
			return
		}
		values := make([]store.NotificationSetting, 0, len(request))
		for _, channel := range notificationChannels {
			raw, present := request[channel]
			if !present {
				continue
			}
			enabled, config, err := decodeNotificationConfig(channel, raw, true)
			if err != nil {
				writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error())
				return
			}
			values = append(values, store.NotificationSetting{
				Channel:         channel,
				Enabled:         enabled,
				Config:          config,
				SensitiveFields: store.DefaultNotificationSensitiveFields(channel),
			})
		}
		for channel := range request {
			if !knownNotificationChannel(channel) {
				writeError(
					w,
					http.StatusBadRequest,
					"invalid_notification_channel",
					fmt.Sprintf("unsupported notification channel %q", channel),
				)
				return
			}
		}
		if err := s.store.SaveNotificationSettings(r.Context(), values); err != nil {
			s.writeStoreError(w, err)
			return
		}
		s.writeNotificationSettings(w, r)
	default:
		w.Header().Set("Allow", "GET, PUT")
		writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
	}
}

func (s *Server) writeNotificationSettings(w http.ResponseWriter, r *http.Request) {
	settings, err := s.store.ListNotificationSettings(r.Context())
	if err != nil {
		s.writeStoreError(w, err)
		return
	}
	stored := make(map[string]store.NotificationSetting, len(settings))
	for _, setting := range settings {
		stored[setting.Channel] = setting
	}
	response := make(map[string]any, len(notificationChannels))
	for _, channel := range notificationChannels {
		document := map[string]any{"enabled": false}
		if setting, ok := stored[channel]; ok {
			redacted := setting.Redacted()
			if err := json.Unmarshal(redacted.Config, &document); err != nil {
				s.logger.Error(
					"notification setting contains invalid JSON",
					"channel",
					channel,
					"error",
					err,
				)
				writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed")
				return
			}
			document["enabled"] = setting.Enabled
		}
		response[channel] = document
	}
	writeJSON(w, http.StatusOK, map[string]any{"data": response})
}

func decodeNotificationConfig(
	channel string,
	raw json.RawMessage,
	requireEnabled bool,
) (bool, json.RawMessage, error) {
	if !knownNotificationChannel(channel) {
		return false, nil, fmt.Errorf("unsupported notification channel %q", channel)
	}
	var document map[string]json.RawMessage
	if err := json.Unmarshal(raw, &document); err != nil || document == nil {
		return false, nil, fmt.Errorf("%s notification config must be an object", channel)
	}
	enabled := false
	enabledRaw, hasEnabled := document["enabled"]
	if requireEnabled && !hasEnabled {
		return false, nil, fmt.Errorf("%s.enabled is required", channel)
	}
	if hasEnabled {
		if err := json.Unmarshal(enabledRaw, &enabled); err != nil {
			return false, nil, fmt.Errorf("%s.enabled must be a boolean", channel)
		}
		delete(document, "enabled")
	}

	fields := notificationFields[channel]
	for name, value := range document {
		kind, known := fields[name]
		if !known {
			return false, nil, fmt.Errorf("%s.%s is not supported", channel, name)
		}
		if err := validateNotificationField(channel, name, kind, value); err != nil {
			return false, nil, err
		}
	}
	config, err := json.Marshal(document)
	if err != nil {
		return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
	}
	return enabled, config, nil
}

func validateNotificationField(
	channel string,
	name string,
	kind string,
	raw json.RawMessage,
) error {
	field := channel + "." + name
	switch kind {
	case "boolean":
		var value bool
		if err := json.Unmarshal(raw, &value); err != nil {
			return fmt.Errorf("%s must be a boolean", field)
		}
	case "string":
		var value string
		if err := json.Unmarshal(raw, &value); err != nil {
			return fmt.Errorf("%s must be a string", field)
		}
		limit := 4096
		if name == "text_template" {
			limit = 32768
		}
		if len(value) > limit || strings.ContainsAny(value, "\x00") {
			return fmt.Errorf("%s is too long or contains invalid characters", field)
		}
		if name == "base_url" && value != "" {
			if _, err := parseOutboundURL(value, true); err != nil {
				return fmt.Errorf("%s must be an absolute HTTPS URL", field)
			}
		}
		if name == "proxy" && value != "" {
			if _, err := parseOutboundURL(value, false); err != nil {
				return fmt.Errorf("%s is not a valid HTTP URL", field)
			}
		}
		if channel == "telegram" && name == "chat_id" && strings.TrimSpace(value) != "" {
			chatID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
			if err != nil || chatID == 0 {
				return fmt.Errorf("%s must be a non-zero integer", field)
			}
		}
		if channel == "telegram" && name == "admin_id" && strings.TrimSpace(value) != "" {
			adminID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
			if err != nil || adminID <= 0 {
				return fmt.Errorf("%s must be a positive integer", field)
			}
		}
		if name == "from_address" && value != "" {
			if _, err := mail.ParseAddress(value); err != nil {
				return fmt.Errorf("%s is not a valid email address", field)
			}
		}
	case "integer":
		var value int
		if err := json.Unmarshal(raw, &value); err != nil {
			return fmt.Errorf("%s must be an integer", field)
		}
		switch name {
		case "smtp_port":
			if value < 0 || value > 65535 {
				return fmt.Errorf("%s must be between 0 and 65535", field)
			}
		case "timeout_ms":
			if value != 0 && (value < 100 || value > 60000) {
				return fmt.Errorf("%s must be 0 or between 100 and 60000", field)
			}
		case "retry_max":
			if value < 0 || value > 10 {
				return fmt.Errorf("%s must be between 0 and 10", field)
			}
		}
	case "strings":
		var values []string
		if err := json.Unmarshal(raw, &values); err != nil {
			return fmt.Errorf("%s must be an array of strings", field)
		}
		if len(values) > 32 {
			return fmt.Errorf("%s cannot contain more than 32 values", field)
		}
		for _, value := range values {
			if strings.TrimSpace(value) == "" || len(value) > 4096 ||
				strings.ContainsAny(value, "\r\n\x00") {
				return fmt.Errorf("%s contains an invalid value", field)
			}
			if name == "urls" {
				if _, err := parseOutboundURL(value, false); err != nil {
					return fmt.Errorf("%s contains an invalid HTTP URL", field)
				}
			}
			if name == "to_addresses" {
				if _, err := mail.ParseAddress(value); err != nil {
					return fmt.Errorf("%s contains an invalid email address", field)
				}
			}
		}
	case "string_map":
		var values map[string]string
		if err := json.Unmarshal(raw, &values); err != nil {
			return fmt.Errorf("%s must be an object of strings", field)
		}
		if len(values) > 32 {
			return fmt.Errorf("%s cannot contain more than 32 entries", field)
		}
		for key, value := range values {
			if strings.TrimSpace(key) == "" || len(key) > 128 ||
				strings.ContainsAny(key, "\r\n:\x00") {
				return fmt.Errorf("%s contains an invalid header name", field)
			}
			if len(value) > 4096 || strings.ContainsAny(value, "\r\n\x00") {
				return fmt.Errorf("%s contains an invalid header value", field)
			}
		}
	default:
		return fmt.Errorf("%s has an unsupported field type", field)
	}
	return nil
}

func knownNotificationChannel(channel string) bool {
	_, ok := notificationFields[channel]
	return ok
}

func (s *Server) handleNotificationTest(
	w http.ResponseWriter,
	r *http.Request,
	channel string,
) {
	if !requireMethod(w, r, http.MethodPost) {
		return
	}
	channel = strings.ToLower(strings.TrimSpace(channel))
	if !knownNotificationChannel(channel) {
		writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
		return
	}
	if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
		writeError(
			w,
			http.StatusNotImplemented,
			"notification_test_unsupported",
			"this notification channel does not support a connectivity test",
		)
		return
	}
	var raw json.RawMessage
	if err := s.decodeJSON(w, r, &raw); err != nil {
		writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
		return
	}
	_, incoming, err := decodeNotificationConfig(channel, raw, false)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error())
		return
	}
	resolved, provider, err := s.resolveNotificationTestConfig(
		r.Context(),
		channel,
		incoming,
	)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			writeError(
				w,
				http.StatusBadRequest,
				"notification_not_configured",
				"notification channel is not configured",
			)
			return
		}
		s.writeStoreError(w, err)
		return
	}
	if err := validateNotificationTestConfig(channel, resolved); err != nil {
		writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error())
		return
	}

	switch channel {
	case "webhook":
		err = sendWebhookNotificationTest(r.Context(), resolved)
	case "telegram":
		err = sendTelegramNotificationTest(r.Context(), resolved)
	case "email":
		err = sendEmailNotificationTest(r.Context(), resolved)
	case "bark":
		err = sendBarkNotificationTest(r.Context(), resolved)
	}
	if err != nil {
		redacted := store.RedactText(err.Error(), provider)
		if s.logger != nil {
			s.logger.Warn(
				"notification connectivity test failed",
				"channel",
				channel,
				"error",
				redacted,
			)
		}
		switch {
		case errors.Is(err, errUnsafeDestination):
			writeError(
				w,
				http.StatusBadRequest,
				"unsafe_destination",
				"notification destination must resolve only to public network addresses",
			)
		case errors.Is(err, errProviderRejected):
			writeError(
				w,
				http.StatusBadGateway,
				"notification_provider_rejected",
				"notification provider rejected the test message",
			)
		default:
			writeError(
				w,
				http.StatusBadGateway,
				"notification_test_failed",
				"notification provider could not be reached or the test message failed",
			)
		}
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"data": map[string]any{
			"channel":   channel,
			"success":   true,
			"tested_at": time.Now().UTC(),
		},
	})
}

func (s *Server) resolveNotificationTestConfig(
	ctx context.Context,
	channel string,
	incoming json.RawMessage,
) (map[string]any, store.NotificationSetting, error) {
	current, err := s.store.NotificationSetting(ctx, channel)
	notConfigured := errors.Is(err, store.ErrNotFound)
	if err != nil && !errors.Is(err, store.ErrNotFound) {
		return nil, store.NotificationSetting{}, err
	}
	if notConfigured {
		current = store.NotificationSetting{
			Channel:         channel,
			Config:          json.RawMessage(`{}`),
			SensitiveFields: store.DefaultNotificationSensitiveFields(channel),
		}
	}
	var resolved map[string]any
	if err := json.Unmarshal(current.Config, &resolved); err != nil {
		return nil, store.NotificationSetting{}, fmt.Errorf("decode stored notification config: %w", err)
	}
	var overlay map[string]any
	if err := json.Unmarshal(incoming, &overlay); err != nil {
		return nil, store.NotificationSetting{}, fmt.Errorf("decode notification test config: %w", err)
	}
	sensitive := make(map[string]struct{})
	for _, field := range store.DefaultNotificationSensitiveFields(channel) {
		sensitive[field] = struct{}{}
	}
	for key, value := range overlay {
		if _, secret := sensitive[key]; secret {
			if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
				continue
			}
		}
		resolved[key] = value
	}
	encoded, err := json.Marshal(resolved)
	if err != nil {
		return nil, store.NotificationSetting{}, err
	}
	if len(resolved) == 0 && notConfigured {
		return nil, store.NotificationSetting{}, store.ErrNotFound
	}
	_, normalized, err := decodeNotificationConfig(channel, encoded, false)
	if err != nil {
		return nil, store.NotificationSetting{}, err
	}
	if err := json.Unmarshal(normalized, &resolved); err != nil {
		return nil, store.NotificationSetting{}, err
	}
	provider := store.NotificationSetting{
		Channel:         channel,
		Config:          normalized,
		SensitiveFields: store.DefaultNotificationSensitiveFields(channel),
	}
	return resolved, provider, nil
}

func validateNotificationTestConfig(channel string, config map[string]any) error {
	switch channel {
	case "webhook":
		urls := configStrings(config, "urls")
		if len(urls) == 0 {
			return errors.New("webhook.urls must contain at least one URL")
		}
		if len(urls) > 8 {
			return errors.New("webhook test is limited to 8 URLs")
		}
	case "bark":
		urls := configStrings(config, "urls")
		if len(urls) == 0 {
			return errors.New("bark.urls must contain at least one URL")
		}
		if len(urls) > 8 {
			return errors.New("bark test is limited to 8 URLs")
		}
	case "telegram":
		token := configString(config, "bot_token")
		if token == "" || token == store.SecretMask {
			return errors.New("telegram.bot_token is required")
		}
		if !telegramTokenPattern.MatchString(token) {
			return errors.New("telegram.bot_token has an invalid format")
		}
		if configString(config, "chat_id") == "" {
			return errors.New("telegram.chat_id is required")
		}
		if baseURL := configString(config, "base_url"); baseURL != "" {
			if _, err := parseOutboundURL(baseURL, true); err != nil {
				return errors.New("telegram.base_url must be an absolute HTTPS URL")
			}
		}
	case "email":
		if configString(config, "smtp_host") == "" {
			return errors.New("email.smtp_host is required")
		}
		if configString(config, "from_address") == "" {
			return errors.New("email.from_address is required")
		}
		if len(configStrings(config, "to_addresses")) == 0 {
			return errors.New("email.to_addresses must contain at least one address")
		}
		if configString(config, "password") != "" && configString(config, "username") == "" {
			return errors.New("email.username is required when a password is configured")
		}
	}
	return nil
}

func sendWebhookNotificationTest(ctx context.Context, config map[string]any) error {
	timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second)
	client, err := restrictedHTTPClient(ctx, timeout, "")
	if err != nil {
		return err
	}
	payload, _ := json.Marshal(map[string]any{
		"event":     "test",
		"message":   "vocat notification test",
		"timestamp": time.Now().UTC().Format(time.RFC3339),
	})
	for _, destination := range configStrings(config, "urls") {
		parsed, err := validateOutboundURL(ctx, destination, false)
		if err != nil {
			return err
		}
		request, err := http.NewRequestWithContext(
			ctx,
			http.MethodPost,
			parsed.String(),
			bytes.NewReader(payload),
		)
		if err != nil {
			return fmt.Errorf("create webhook test request: %w", err)
		}
		for name, value := range configStringMap(config, "headers") {
			request.Header.Set(name, value)
		}
		request.Header.Set("Content-Type", "application/json")
		request.Header.Set("User-Agent", "vocat-notification-test/1")
		if secret := configString(config, "secret"); secret != "" {
			signature := hmac.New(sha256.New, []byte(secret))
			_, _ = signature.Write(payload)
			request.Header.Set(
				"X-vocat-Signature",
				"sha256="+hex.EncodeToString(signature.Sum(nil)),
			)
		}
		if err := performNotificationRequest(client, request, false); err != nil {
			return err
		}
	}
	return nil
}

func sendBarkNotificationTest(ctx context.Context, config map[string]any) error {
	client, err := restrictedHTTPClient(ctx, 6*time.Second, "")
	if err != nil {
		return err
	}
	message := map[string]any{
		"title": "vocat",
		"body":  "vocat notification test",
	}
	if group := configString(config, "group"); group != "" {
		message["group"] = group
	}
	if icon := configString(config, "icon"); icon != "" {
		message["icon"] = icon
	}
	if level := configString(config, "level"); level != "" {
		message["level"] = level
	}
	payload, _ := json.Marshal(message)
	for _, destination := range configStrings(config, "urls") {
		parsed, err := validateOutboundURL(ctx, destination, false)
		if err != nil {
			return err
		}
		request, err := http.NewRequestWithContext(
			ctx,
			http.MethodPost,
			parsed.String(),
			bytes.NewReader(payload),
		)
		if err != nil {
			return fmt.Errorf("create bark test request: %w", err)
		}
		request.Header.Set("Content-Type", "application/json; charset=utf-8")
		request.Header.Set("User-Agent", "vocat-notification-test/1")
		if err := performNotificationRequest(client, request, false); err != nil {
			return err
		}
	}
	return nil
}

func sendTelegramNotificationTest(ctx context.Context, config map[string]any) error {
	baseURL := configString(config, "base_url")
	if baseURL == "" {
		baseURL = "https://api.telegram.org"
	}
	parsed, err := validateOutboundURL(ctx, baseURL, true)
	if err != nil {
		return err
	}
	token := configString(config, "bot_token")
	parsed.Path = strings.TrimRight(parsed.Path, "/") + "/bot" + token + "/sendMessage"
	parsed.RawPath = ""
	parsed.RawQuery = ""
	parsed.Fragment = ""
	client, err := restrictedHTTPClient(ctx, 6*time.Second, configString(config, "proxy"))
	if err != nil {
		return err
	}
	payload, _ := json.Marshal(map[string]any{
		"chat_id": configString(config, "chat_id"),
		"text":    "vocat notification test",
	})
	request, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		parsed.String(),
		bytes.NewReader(payload),
	)
	if err != nil {
		return fmt.Errorf("create Telegram test request: %w", err)
	}
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("User-Agent", "vocat-notification-test/1")
	return performNotificationRequest(client, request, true)
}

func performNotificationRequest(
	client *http.Client,
	request *http.Request,
	requireTelegramOK bool,
) error {
	response, err := client.Do(request)
	if err != nil {
		return fmt.Errorf("send notification test: %w", err)
	}
	defer response.Body.Close()
	body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
	if readErr != nil {
		return fmt.Errorf("read notification response: %w", readErr)
	}
	if response.StatusCode < 200 || response.StatusCode >= 300 {
		return fmt.Errorf("%w: HTTP %d", errProviderRejected, response.StatusCode)
	}
	if requireTelegramOK {
		var result struct {
			OK bool `json:"ok"`
		}
		if json.Unmarshal(body, &result) != nil || !result.OK {
			return fmt.Errorf("%w: Telegram response was not successful", errProviderRejected)
		}
	}
	return nil
}

func sendEmailNotificationTest(ctx context.Context, config map[string]any) error {
	host := strings.TrimSpace(configString(config, "smtp_host"))
	port := configInt(config, "smtp_port")
	if port == 0 {
		port = 587
	}
	timeout := 8 * time.Second
	address := net.JoinHostPort(host, strconv.Itoa(port))
	connection, err := dialRestricted(ctx, "tcp", address, timeout)
	if err != nil {
		return fmt.Errorf("connect SMTP server: %w", err)
	}
	defer connection.Close()
	if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil {
		return fmt.Errorf("set SMTP deadline: %w", err)
	}
	tlsConfig := &tls.Config{
		MinVersion: tls.VersionTLS12,
		ServerName: host,
	}
	if port == 465 {
		secure := tls.Client(connection, tlsConfig)
		if err := secure.HandshakeContext(ctx); err != nil {
			return fmt.Errorf("establish SMTP TLS: %w", err)
		}
		connection = secure
	}
	client, err := smtp.NewClient(connection, host)
	if err != nil {
		return fmt.Errorf("start SMTP session: %w", err)
	}
	defer client.Close()
	if port != 465 {
		if available, _ := client.Extension("STARTTLS"); !available {
			return errors.New("SMTP server does not offer STARTTLS")
		}
		if err := client.StartTLS(tlsConfig); err != nil {
			return fmt.Errorf("start SMTP TLS: %w", err)
		}
	}
	username := configString(config, "username")
	password := configString(config, "password")
	if username != "" {
		if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
			return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected)
		}
	}
	from, err := mail.ParseAddress(configString(config, "from_address"))
	if err != nil {
		return fmt.Errorf("parse sender address: %w", err)
	}
	recipients := make([]*mail.Address, 0)
	for _, item := range configStrings(config, "to_addresses") {
		address, err := mail.ParseAddress(item)
		if err != nil {
			return fmt.Errorf("parse recipient address: %w", err)
		}
		recipients = append(recipients, address)
	}
	if err := client.Mail(from.Address); err != nil {
		return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected)
	}
	for _, recipient := range recipients {
		if err := client.Rcpt(recipient.Address); err != nil {
			return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected)
		}
	}
	writer, err := client.Data()
	if err != nil {
		return fmt.Errorf("%w: SMTP message rejected", errProviderRejected)
	}
	message := strings.Join([]string{
		"Date: " + time.Now().UTC().Format(time.RFC1123Z),
		"From: " + from.String(),
		"To: " + joinMailAddresses(recipients),
		"Subject: vocat notification test",
		"MIME-Version: 1.0",
		"Content-Type: text/plain; charset=UTF-8",
		"",
		"This is a vocat notification test.",
		"",
	}, "\r\n")
	if _, err := io.WriteString(writer, message); err != nil {
		_ = writer.Close()
		return fmt.Errorf("write SMTP test message: %w", err)
	}
	if err := writer.Close(); err != nil {
		return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected)
	}
	if err := client.Quit(); err != nil {
		return fmt.Errorf("finish SMTP session: %w", err)
	}
	return nil
}

func joinMailAddresses(values []*mail.Address) string {
	result := make([]string, 0, len(values))
	for _, value := range values {
		result = append(result, value.String())
	}
	return strings.Join(result, ", ")
}

func restrictedHTTPClient(
	ctx context.Context,
	timeout time.Duration,
	proxy string,
) (*http.Client, error) {
	timeout = clampNotificationTimeout(timeout)
	transport := &http.Transport{
		Proxy:                 nil,
		DialContext:           restrictedDialer(timeout),
		ForceAttemptHTTP2:     true,
		DisableKeepAlives:     true,
		MaxIdleConns:          0,
		TLSHandshakeTimeout:   timeout,
		ResponseHeaderTimeout: timeout,
		ExpectContinueTimeout: time.Second,
		TLSClientConfig: &tls.Config{
			MinVersion: tls.VersionTLS12,
		},
	}
	if strings.TrimSpace(proxy) != "" {
		parsed, err := validateOutboundURL(ctx, proxy, false)
		if err != nil {
			return nil, fmt.Errorf("validate notification proxy: %w", err)
		}
		transport.Proxy = http.ProxyURL(parsed)
	}
	return &http.Client{
		Transport: transport,
		Timeout:   timeout,
		CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
			return errors.New("notification provider redirects are not allowed")
		},
	}, nil
}

func clampNotificationTimeout(timeout time.Duration) time.Duration {
	if timeout < 100*time.Millisecond {
		return 100 * time.Millisecond
	}
	if timeout > 10*time.Second {
		return 10 * time.Second
	}
	return timeout
}

func durationMilliseconds(value int, fallback time.Duration) time.Duration {
	if value == 0 {
		return fallback
	}
	return time.Duration(value) * time.Millisecond
}

func validateOutboundURL(
	ctx context.Context,
	raw string,
	requireHTTPS bool,
) (*url.URL, error) {
	parsed, err := parseOutboundURL(raw, requireHTTPS)
	if err != nil {
		return nil, err
	}
	if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
		return nil, err
	}
	return parsed, nil
}

func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) {
	parsed, err := url.Parse(strings.TrimSpace(raw))
	if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false {
		return nil, errors.New("destination must be an absolute HTTP URL")
	}
	if parsed.User != nil {
		return nil, errors.New("destination URL cannot contain user information")
	}
	if parsed.Scheme != "http" && parsed.Scheme != "https" {
		return nil, errors.New("destination URL must use HTTP or HTTPS")
	}
	if requireHTTPS && parsed.Scheme != "https" {
		return nil, errors.New("destination URL must use HTTPS")
	}
	if parsed.Port() != "" {
		port, err := strconv.Atoi(parsed.Port())
		if err != nil || port < 1 || port > 65535 {
			return nil, errors.New("destination URL has an invalid port")
		}
	}
	return parsed, nil
}

func restrictedDialer(timeout time.Duration) func(
	context.Context,
	string,
	string,
) (net.Conn, error) {
	return func(ctx context.Context, network string, address string) (net.Conn, error) {
		return dialRestricted(ctx, network, address, timeout)
	}
}

func dialRestricted(
	ctx context.Context,
	network string,
	address string,
	timeout time.Duration,
) (net.Conn, error) {
	host, port, err := net.SplitHostPort(address)
	if err != nil {
		return nil, fmt.Errorf("parse outbound address: %w", err)
	}
	addresses, err := resolvePublicAddresses(ctx, host)
	if err != nil {
		return nil, err
	}
	dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)}
	var failures []error
	for _, ip := range addresses {
		connection, err := dialer.DialContext(
			ctx,
			network,
			net.JoinHostPort(ip.String(), port),
		)
		if err == nil {
			return connection, nil
		}
		failures = append(failures, err)
	}
	return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...))
}

func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) {
	normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
	if normalized == "" || normalized == "localhost" ||
		strings.HasSuffix(normalized, ".localhost") ||
		normalized == "metadata" ||
		strings.HasSuffix(normalized, ".internal") ||
		strings.HasSuffix(normalized, ".local") {
		return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination)
	}
	if literal, err := netip.ParseAddr(normalized); err == nil {
		literal = literal.Unmap()
		if !publicNotificationAddress(literal) {
			return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal)
		}
		return []netip.Addr{literal}, nil
	}
	addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", normalized)
	if err != nil {
		return nil, fmt.Errorf("resolve notification destination: %w", err)
	}
	if len(addresses) == 0 {
		return nil, errors.New("notification destination did not resolve")
	}
	result := make([]netip.Addr, 0, len(addresses))
	for _, address := range addresses {
		address = address.Unmap()
		if !publicNotificationAddress(address) {
			return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address)
		}
		result = append(result, address)
	}
	return result, nil
}

var blockedNotificationNetworks = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/8"),
	netip.MustParsePrefix("10.0.0.0/8"),
	netip.MustParsePrefix("100.64.0.0/10"),
	netip.MustParsePrefix("127.0.0.0/8"),
	netip.MustParsePrefix("169.254.0.0/16"),
	netip.MustParsePrefix("172.16.0.0/12"),
	netip.MustParsePrefix("192.0.0.0/24"),
	netip.MustParsePrefix("192.0.2.0/24"),
	netip.MustParsePrefix("192.88.99.0/24"),
	netip.MustParsePrefix("192.168.0.0/16"),
	netip.MustParsePrefix("198.18.0.0/15"),
	netip.MustParsePrefix("198.51.100.0/24"),
	netip.MustParsePrefix("203.0.113.0/24"),
	netip.MustParsePrefix("224.0.0.0/4"),
	netip.MustParsePrefix("240.0.0.0/4"),
	netip.MustParsePrefix("::/128"),
	netip.MustParsePrefix("::1/128"),
	netip.MustParsePrefix("64:ff9b:1::/48"),
	netip.MustParsePrefix("100::/64"),
	netip.MustParsePrefix("2001:db8::/32"),
	netip.MustParsePrefix("fc00::/7"),
	netip.MustParsePrefix("fe80::/10"),
	netip.MustParsePrefix("ff00::/8"),
}

func publicNotificationAddress(address netip.Addr) bool {
	if !address.IsValid() || !address.IsGlobalUnicast() {
		return false
	}
	address = address.Unmap()
	for _, blocked := range blockedNotificationNetworks {
		if blocked.Contains(address) {
			return false
		}
	}
	return true
}

func configString(config map[string]any, key string) string {
	value, _ := config[key].(string)
	return strings.TrimSpace(value)
}

func configStrings(config map[string]any, key string) []string {
	switch value := config[key].(type) {
	case []string:
		return value
	case []any:
		result := make([]string, 0, len(value))
		for _, item := range value {
			text, ok := item.(string)
			if ok {
				result = append(result, strings.TrimSpace(text))
			}
		}
		return result
	default:
		return nil
	}
}

func configStringMap(config map[string]any, key string) map[string]string {
	object, ok := config[key].(map[string]any)
	if !ok {
		return nil
	}
	result := make(map[string]string, len(object))
	for name, value := range object {
		text, ok := value.(string)
		if ok {
			result[name] = text
		}
	}
	return result
}

func configInt(config map[string]any, key string) int {
	switch value := config[key].(type) {
	case float64:
		return int(value)
	case json.Number:
		result, _ := value.Int64()
		return int(result)
	case int:
		return value
	default:
		return 0
	}
}

// handleCardPolicies returns every stored card policy (VoHive: GET /cards/policies).
func (s *Server) handleCardPolicies(w http.ResponseWriter, r *http.Request) {
	if !requireMethod(w, r, http.MethodGet) {
		return
	}
	policies, err := s.store.ListCardPolicies(r.Context())
	if err != nil {
		s.writeStoreError(w, err)
		return
	}
	result := make([]map[string]any, 0, len(policies))
	for _, policy := range policies {
		result = append(result, cardPolicyResponse(policy))
	}
	writeJSON(w, http.StatusOK, map[string]any{"data": result})
}

// liveCardPolicyFlags resolves the current VoWiFi/airplane state for the
// device that presently hosts the given SIM (matched by live ICCID), so the card
// policy toggles reflect what the card is actually doing now rather than a stale
// stored value. ok is false when no present device reports this ICCID.
func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi, airplane, ok bool) {
	configs, err := s.store.ListDevices(ctx)
	if err != nil {
		return false, false, false
	}
	clean := strings.TrimSpace(iccid)
	for _, config := range configs {
		entry, _, present := s.physicalForConfig(config)
		if !present || entry.Snapshot == nil {
			continue
		}
		if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) {
			continue
		}
		// VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS
		// path owns service. That physical CFUN state is not the user's separate
		// airplane-mode policy; exposing both toggles as enabled is contradictory
		// and makes the UI unable to represent the active policy correctly.
		return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true
	}
	return false, false, false
}

func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid string) {
	iccid = strings.TrimSpace(iccid)
	if !validICCID(iccid) {
		writeError(
			w,
			http.StatusBadRequest,
			"invalid_iccid",
			"ICCID must contain between 10 and 32 decimal digits",
		)
		return
	}
	switch r.Method {
	case http.MethodGet:
		policy, err := s.store.CardPolicy(r.Context(), iccid)
		if errors.Is(err, store.ErrNotFound) {
			policy = store.CardPolicy{
				ICCID:     iccid,
				IPVersion: "IPV4V6",
				Source:    "default",
			}
		} else if err != nil {
			s.writeStoreError(w, err)
			return
		}
		// Reflect the SIM's live current state in the toggles (APN / IP version
		// remain stored preferences); fall back to the stored policy when the card
		// is not currently present in any device.
		if vowifi, airplane, ok := s.liveCardPolicyFlags(r.Context(), iccid); ok {
			policy.VoWiFiEnabled = vowifi
			policy.AirplaneEnabled = airplane
		}
		writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
	case http.MethodPut:
		var request struct {
			VoWiFiEnabled   *bool  `json:"vowifi_enabled"`
			AirplaneEnabled *bool  `json:"airplane_enabled"`
			APN             string `json:"apn"`
			IPVersion       string `json:"ip_version"`
		}
		if err := s.decodeJSON(w, r, &request); err != nil {
			writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
			return
		}
		if request.VoWiFiEnabled == nil ||
			request.AirplaneEnabled == nil {
			writeError(
				w,
				http.StatusBadRequest,
				"invalid_card_policy",
				"all card policy switches are required",
			)
			return
		}
		request.APN = strings.TrimSpace(request.APN)
		if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") {
			writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid")
			return
		}
		request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion))
		if request.IPVersion == "" {
			request.IPVersion = "IPV4V6"
		}
		if request.IPVersion != "IP" &&
			request.IPVersion != "IPV6" &&
			request.IPVersion != "IPV4V6" {
			writeError(
				w,
				http.StatusBadRequest,
				"invalid_card_policy",
				"IP version must be IP, IPV6, or IPV4V6",
			)
			return
		}
		if *request.VoWiFiEnabled && *request.AirplaneEnabled {
			writeError(
				w,
				http.StatusBadRequest,
				"invalid_card_policy",
				"VoWiFi and airplane mode cannot both be enabled",
			)
			return
		}
		policy := store.CardPolicy{
			ICCID:           iccid,
			NetworkEnabled:  false,
			VoWiFiEnabled:   *request.VoWiFiEnabled,
			AirplaneEnabled: *request.AirplaneEnabled,
			APN:             request.APN,
			IPVersion:       request.IPVersion,
			Source:          "manual",
		}
		if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil {
			s.writeStoreError(w, err)
			return
		}
		policy, err := s.store.CardPolicy(r.Context(), iccid)
		if err != nil {
			s.writeStoreError(w, err)
			return
		}
		writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
	default:
		w.Header().Set("Allow", "GET, PUT")
		writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
	}
}

func validICCID(value string) bool {
	if len(value) < 10 || len(value) > 32 {
		return false
	}
	for _, character := range value {
		if character < '0' || character > '9' {
			return false
		}
	}
	return true
}

func cardPolicyResponse(policy store.CardPolicy) map[string]any {
	response := map[string]any{
		"iccid":            policy.ICCID,
		"network_enabled":  false,
		"vowifi_enabled":   policy.VoWiFiEnabled,
		"airplane_enabled": policy.AirplaneEnabled,
		"apn":              policy.APN,
		"ip_version":       policy.IPVersion,
		"source":           policy.Source,
	}
	if !policy.CreatedAt.IsZero() {
		response["created_at"] = policy.CreatedAt
	}
	if !policy.UpdatedAt.IsZero() {
		response["updated_at"] = policy.UpdatedAt
	}
	return response
}

func (s *Server) handleTrafficAnalysis(w http.ResponseWriter, r *http.Request) {
	if !requireMethod(w, r, http.MethodGet) {
		return
	}
	rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range")))
	if rangeName == "" {
		rangeName = "day"
	}
	var window time.Duration
	switch rangeName {
	case "hour":
		window = time.Hour
	case "day":
		window = 24 * time.Hour
	case "week":
		window = 7 * 24 * time.Hour
	case "month":
		window = 30 * 24 * time.Hour
	default:
		writeError(
			w,
			http.StatusBadRequest,
			"invalid_range",
			"traffic range must be hour, day, week, or month",
		)
		return
	}
	deviceID := strings.TrimSpace(r.URL.Query().Get("device_id"))
	if len(deviceID) > 128 || strings.ContainsAny(deviceID, "\x00\r\n") {
		writeError(w, http.StatusBadRequest, "invalid_device", "device ID is invalid")
		return
	}
	now := time.Now().UTC()
	rows, err := s.store.ListTrafficBuckets(r.Context(), store.TrafficFilter{
		DeviceID: deviceID,
		Bucket:   rangeName,
		Since:    now.Add(-window),
		Until:    now.Add(time.Minute),
		Limit:    1000,
	})
	if err != nil {
		s.writeStoreError(w, err)
		return
	}
	type aggregate struct {
		period time.Time
		rx     int64
		tx     int64
	}
	byPeriod := make(map[int64]*aggregate)
	for _, row := range rows {
		key := row.PeriodStart.Unix()
		value := byPeriod[key]
		if value == nil {
			value = &aggregate{period: row.PeriodStart}
			byPeriod[key] = value
		}
		value.rx += row.RXBytes
		value.tx += row.TXBytes
	}
	values := make([]*aggregate, 0, len(byPeriod))
	for _, value := range byPeriod {
		values = append(values, value)
	}
	sort.Slice(values, func(left, right int) bool {
		return values[left].period.Before(values[right].period)
	})
	buckets := make([]map[string]any, 0, len(values))
	for _, value := range values {
		buckets = append(buckets, map[string]any{
			"bucket":       rangeName,
			"period_start": value.period,
			"rx_bytes":     value.rx,
			"tx_bytes":     value.tx,
			"total_bytes":  value.rx + value.tx,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"data": map[string]any{
			"status":  "ok",
			"range":   rangeName,
			"buckets": buckets,
		},
	})
}
