package web

import (
	"context"
	"crypto/rand"
	"crypto/subtle"
	"embed"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log"
	mrand "math/rand/v2"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/sartoopjj/thefeed/internal/client"
	"github.com/sartoopjj/thefeed/internal/protocol"
	"github.com/sartoopjj/thefeed/internal/update"
	"github.com/sartoopjj/thefeed/internal/version"
)

//go:embed static
var staticFS embed.FS

// Config holds the client configuration saved in the data directory.
type Config struct {
	Domain    string   `json:"domain"`
	Key       string   `json:"key"`
	Resolvers []string `json:"resolvers"`
	QueryMode string   `json:"queryMode"`
	RateLimit float64  `json:"rateLimit"`
	// Timeout is the per-query DNS timeout in seconds (0 = default 15 s).
	// Also used as the resolver health-check probe timeout.
	Timeout float64 `json:"timeout,omitempty"`
	// Scatter is the number of resolvers queried simultaneously per DNS block request
	// (0 or 1 = sequential, 4 = default parallel pair).
	Scatter int `json:"scatter,omitempty"`
	// AutoScan enables hourly automatic resolver health-check scans.
	// nil means enabled (default); set to a false pointer to disable.
	AutoScan *bool `json:"autoScan,omitempty"`
}

// Profile wraps a Config with a user-chosen nickname and a unique ID.
type Profile struct {
	ID                 string   `json:"id"`
	Nickname           string   `json:"nickname"`
	Config             Config   `json:"config"`
	AutoUpdate         []string `json:"autoUpdate,omitempty"`
	AutoUpdateInterval int      `json:"autoUpdateInterval,omitempty"`
}

const (
	// minAutoUpdateInterval is the floor — never tick faster than once per
	// minute, even if the user sets something silly. The DNS path is
	// expensive and the server's own fetch cycle is much longer.
	minAutoUpdateInterval = 60 * time.Second
	// serverFetchSettleDelay is how long after nextFetch we wait before
	// asking the server for fresh data — gives it time to process the
	// upstream Telegram fetch and have a coherent metadata snapshot.
	serverFetchSettleDelay = 30 * time.Second
	// autoUpdateStartupDelay defers the first tick so the initial metadata
	// + resolver checks have a chance to land before we start polling.
	autoUpdateStartupDelay = 30 * time.Second
)

// SavedResolverScore stores persistent resolver performance data.
type SavedResolverScore struct {
	Success int64 `json:"success"`
	Failure int64 `json:"failure"`
	TotalMs int64 `json:"totalMs"`
}

// ActiveList is a named subset of the resolver bank that the user can
// switch between — e.g., "Home", "Office", "Mobile". The currently
// selected list is what the fetcher uses; switching to a different list
// hot-swaps the active resolvers without rescanning.
type ActiveList struct {
	Name      string   `json:"name"`
	Resolvers []string `json:"resolvers"`
	LastUsed  int64    `json:"lastUsed,omitempty"`
}

// ProfileList is the on-disk structure for profiles.json.
type ProfileList struct {
	Active   string    `json:"active"` // ID of active profile
	Profiles []Profile `json:"profiles"`
	// FontSize stores user's preferred font size (0 = default 14).
	FontSize int    `json:"fontSize,omitempty"`
	Debug    bool   `json:"debug,omitempty"`
	Theme    string `json:"theme,omitempty"`
	Lang     string `json:"lang,omitempty"`
	// SkipUpdateVersion is the latest release the user dismissed.
	SkipUpdateVersion string `json:"skipUpdateVersion,omitempty"`
	// ResolverBank is the shared pool of DNS resolvers used by all profiles.
	ResolverBank []string `json:"resolverBank,omitempty"`
	// ResolverScores stores accumulated performance data for bank resolvers.
	ResolverScores map[string]*SavedResolverScore `json:"resolverScores,omitempty"`
	// ActiveLists holds named resolver subsets so the user can keep one
	// list per situation (home, office, mobile data) and switch by name
	// instead of rescanning. The currently-selected list is named in
	// SelectedList; its resolvers are what the fetcher uses.
	//
	// Migration: legacy installs without ActiveLists are upgraded on
	// first load — their last_scan.json (or current bank) becomes a
	// single list named "Default".
	ActiveLists  []ActiveList `json:"activeLists,omitempty"`
	SelectedList string       `json:"selectedList,omitempty"`
	// ScanPromptOff suppresses the startup "scan resolvers?" prompt.
	// Persisted server-side so it survives Android's per-launch port
	// changes (each launch picks a fresh port → different localStorage
	// origin → flag was lost on every restart).
	ScanPromptOff bool `json:"scanPromptOff,omitempty"`

	// ProfilePicsEnabled enables fetching avatars over DNS when the
	// GitHub relay can't serve them. Off by default.
	ProfilePicsEnabled bool `json:"profilePicsEnabled,omitempty"`
}

// lastScanData is the on-disk structure for last_scan.json.
type lastScanData struct {
	Resolvers []string `json:"resolvers"`
	ScannedAt int64    `json:"scannedAt"`
}

// channelsCacheEntry is one profile's startup snapshot.
type channelsCacheEntry struct {
	Channels  []protocol.ChannelInfo `json:"channels"`
	NextFetch uint32                 `json:"nextFetch"`
	SavedAt   int64                  `json:"savedAt"`
}

// channelsCacheFile maps profile ID → snapshot.
type channelsCacheFile map[string]*channelsCacheEntry

// Server is the web UI server for thefeed client.
type Server struct {
	dataDir  string
	port     int
	host     string
	password string // admin password; empty means no auth

	mu               sync.RWMutex
	config           *Config
	fetcher          *client.Fetcher
	cache            *client.Cache
	channels         []protocol.ChannelInfo
	messages         map[int][]protocol.Message
	telegramLoggedIn bool
	nextFetch        uint32
	latestVersion    string
	lastMsgIDs       map[int]uint32 // last seen message IDs per channel
	lastHashes       map[int]uint32 // last seen content hashes per channel

	// checker is the active resolver health-checker; set by initFetcher.
	checker *client.ResolverChecker

	// metaFetchedAt is when channels/nextFetch were last fetched from DNS.
	// refreshChannel reuses the in-memory metadata when it is younger than metaCacheTTL.
	metaFetchedAt time.Time
	metaCacheTTL  time.Duration

	// fetcherCtx/fetcherCancel control the lifetime of the active fetcher's
	// background goroutines (rate limiter, noise, resolver checker).
	// They are cancelled and recreated each time the config changes.
	fetcherCtx    context.Context
	fetcherCancel context.CancelFunc

	// refreshMu / refreshCancels allow a new refresh to cancel an in-progress one.
	// Each channel gets its own cancel func so concurrent channel refreshes are allowed.
	// Key 0 is reserved for metadata-only refreshes.
	refreshMu      sync.Mutex
	refreshCancels map[int]context.CancelFunc

	logMu    sync.RWMutex
	logLines []string

	sseMu   sync.Mutex
	clients map[chan string]struct{}

	stopRefresh chan struct{}

	scanner *client.ResolverScanner

	// titlesMu guards the background title-fetch state.
	// Only one goroutine fetches titles at a time; errors impose a 5-minute backoff.
	titlesMu           sync.Mutex
	titlesLoading      bool
	titlesBackoffUntil time.Time

	// dlMu guards dlProgress. Active media downloads register their block
	// counter here so the frontend can poll /api/media/progress and show
	// per-block updates instead of waiting for byte chunks.
	dlMu       sync.Mutex
	dlProgress map[string]*mediaDLProgress

	// relayInfo caches the latest answer from RelayInfoChannel so the fast
	// media path doesn't pay a DNS round trip per file.
	relayInfo *relayCache

	// mediaCache is a disk-backed store for downloaded media bytes so that
	// multiple devices on the same network share a single DNS-tunnelled
	// fetch. Entries expire after 7 days.
	mediaCache *mediaDiskCache

	// rescanReplaceList: set by handleRescan, consumed (and cleared)
	// by the next SetOnScanDone callback so an explicit user rescan
	// overwrites the selected list instead of just topping it up.
	rescanFlagMu      sync.Mutex
	rescanReplaceList bool

	// profilesMu serialises read-modify-write cycles on profiles.json.
	profilesMu sync.Mutex

	// Optional, removable backup feed (Telegram-via-Translate proxy).
	telemirror *telemirrorHub

	// Optional per-channel profile pictures cache.
	profilePics *profilePicsHub
}

// New creates a new web server.
func New(dataDir string, port int, host string, password string) (*Server, error) {
	if err := os.MkdirAll(dataDir, 0700); err != nil {
		return nil, fmt.Errorf("create data dir: %w", err)
	}

	// Remove stale cache files on every startup, even before a config is loaded.
	go func() {
		if c, err := client.NewCache(filepath.Join(dataDir, "cache")); err == nil {
			_ = c.Cleanup()
		}
	}()

	scanner := client.NewResolverScanner()

	mediaCache, mcErr := newMediaDiskCache(filepath.Join(dataDir, "media-cache"), 7*24*time.Hour)
	if mcErr != nil {
		log.Printf("Warning: media disk cache disabled: %v", mcErr)
	}

	s := &Server{
		dataDir:        dataDir,
		port:           port,
		host:           host,
		password:       password,
		messages:       make(map[int][]protocol.Message),
		clients:        make(map[chan string]struct{}),
		refreshCancels: make(map[int]context.CancelFunc),
		lastMsgIDs:     make(map[int]uint32),
		lastHashes:     make(map[int]uint32),
		scanner:        scanner,
		mediaCache:     mediaCache,
		dlProgress:     make(map[string]*mediaDLProgress),
		relayInfo:      newRelayCache(),
		profilePics: newProfilePicsHub(dataDir),
	}
	// Set up telemirror with an onUpdate hook so background refreshes
	// push an SSE event; the frontend re-fetches the active channel
	// when it sees the matching event.
	s.telemirror = newTelemirrorHub(dataDir, func(username string) {
		s.broadcast("event: update\ndata: \"telemirror:" + username + "\"\n\n")
	})

	if mediaCache != nil {
		go mediaCache.Cleanup()
		go s.runMediaCacheSweep()
	}

	// Migrate per-profile resolvers into the shared bank on first run.
	s.migrateResolverBank()

	cfg, err := s.loadConfig()
	if err == nil {
		s.config = cfg
		if err := s.initFetcher(); err != nil {
			log.Printf("Warning: could not initialize fetcher: %v", err)
		}
	} else {
		// config.json missing — try to bootstrap from the active profile
		if pl, plErr := s.loadProfiles(); plErr == nil && pl.Active != "" {
			for _, p := range pl.Profiles {
				if p.ID == pl.Active {
					_ = s.saveConfig(&p.Config)
					s.config = &p.Config
					if err := s.initFetcher(); err != nil {
						log.Printf("Warning: could not initialize fetcher from profile: %v", err)
					}
					break
				}
			}
		}
	}

	if cc := s.loadChannelsCache(); cc != nil {
		s.channels = cc.Channels
		s.nextFetch = cc.NextFetch
	}

	return s, nil
}

// Run starts the web server, binding to s.host:s.port.
func (s *Server) Run() error { return s.serve(nil) }

// Serve runs the web server on an already-bound listener. Used by the
// mobile entry where the listener is opened first to discover the
// kernel-assigned port.
func (s *Server) Serve(ln net.Listener) error { return s.serve(ln) }

func (s *Server) serve(ln net.Listener) error {
	mux := http.NewServeMux()

	staticSub, _ := fs.Sub(staticFS, "static")
	mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))

	mux.HandleFunc("/api/status", s.handleStatus)
	mux.HandleFunc("/api/config", s.handleConfig)
	mux.HandleFunc("/api/channels", s.handleChannels)
	mux.HandleFunc("/api/messages/", s.handleMessages)
	mux.HandleFunc("/api/refresh", s.handleRefresh)
	mux.HandleFunc("/api/rescan", s.handleRescan)
	mux.HandleFunc("/api/send", s.handleSend)
	mux.HandleFunc("/api/admin", s.handleAdmin)
	mux.HandleFunc("/api/events", s.handleSSE)
	mux.HandleFunc("/api/profiles", s.handleProfiles)
	mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch)
	mux.HandleFunc("/api/auto-update", s.handleAutoUpdate)
	mux.HandleFunc("/api/auto-update/toggle", s.handleAutoUpdateToggle)
	mux.HandleFunc("/api/settings", s.handleSettings)
	mux.HandleFunc("/api/version-check", s.handleVersionCheck)
	mux.HandleFunc("/api/update/github", s.handleGitHubUpdateCheck)
	mux.HandleFunc("/api/cache/clear", s.handleClearCache)
	mux.HandleFunc("/api/bg-image", s.handleBgImage)
	mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers)
	mux.HandleFunc("/api/resolvers/active", s.handleActiveResolvers)
	mux.HandleFunc("/api/resolvers/remove", s.handleRemoveResolver)
	mux.HandleFunc("/api/resolvers/reset-stats", s.handleResetResolverStats)
	mux.HandleFunc("/api/resolvers/bank", s.handleResolverBank)
	mux.HandleFunc("/api/resolvers/bank/cleanup", s.handleResolverBankCleanup)
	mux.HandleFunc("/api/scanner/start", s.handleScannerStart)
	mux.HandleFunc("/api/scanner/stop", s.handleScannerStop)
	mux.HandleFunc("/api/scanner/pause", s.handleScannerPause)
	mux.HandleFunc("/api/scanner/resume", s.handleScannerResume)
	mux.HandleFunc("/api/scanner/progress", s.handleScannerProgress)
	mux.HandleFunc("/api/scanner/apply", s.handleScannerApply)
	mux.HandleFunc("/api/scanner/presets", s.handleScannerPresets)
	mux.HandleFunc("/api/resolvers/lists", s.handleResolverLists)
	mux.HandleFunc("/api/resolvers/lists/select", s.handleResolverListSelect)
	mux.HandleFunc("/api/resolvers/lists/save", s.handleResolverListSave)
	mux.HandleFunc("/api/resolvers/lists/rename", s.handleResolverListRename)
	mux.HandleFunc("/api/resolvers/lists/add", s.handleResolverListAdd)
	// Media (image/file) downloader: assembles a binary blob from a media
	// channel and streams it back. See internal/web/media.go for the param
	// contract.
	mux.HandleFunc("/api/media/get", s.handleMediaGet)
	mux.HandleFunc("/api/media/progress", s.handleMediaProgress)
	// Optional telemirror feature — see internal/telemirror/.
	mux.HandleFunc("/api/telemirror/channels", s.telemirror.handleChannels)
	mux.HandleFunc("/api/telemirror/channel/", s.telemirror.handleChannel)
	mux.HandleFunc("/api/telemirror/img", s.telemirror.handleImg)
	mux.HandleFunc("/api/telemirror/avatar/", s.telemirror.handleAvatar)
	mux.HandleFunc("/api/telemirror/older/", s.telemirror.handleOlder)
	// Profile-pics cache + control endpoints.
	mux.HandleFunc("/api/profile-pics/", s.profilePics.handleProfilePic)
	mux.HandleFunc("/api/profile-pics", s.handleProfilePicsList)
	mux.HandleFunc("/api/profile-pics/refresh", s.handleProfilePicsRefresh)
	mux.HandleFunc("/api/profile-pics/progress", s.handleProfilePicsProgress)
	mux.HandleFunc("/", s.handleIndex)

	// Listen on the specified host (default 127.0.0.1)
	addr := fmt.Sprintf("%s:%d", s.host, s.port)
	log.Printf("thefeed client %s", version.Version)
	fmt.Printf("\n  Open in browser: http://%s:%d\n\n", s.host, s.port)

	if s.fetcher != nil {
		// Boot-time fast path for the resolver list:
		//   1. The user's currently-selected named list (instant, no
		//      probing) — populated by the migration on first load.
		//   2. last_scan.json (legacy fast path for old installs).
		//   3. Full health-check scan.
		if applied := s.applySelectedList(); applied {
			s.checker.StartPeriodic(s.fetcherCtx)
			go s.refreshMetadataOnly()
		} else if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 {
			// Pin the runtime pool to the saved scan and propagate
			// those resolvers into the selected list and bank when
			// either is empty — without this, the user sees
			// counts of "0" in the UI even though the fetcher is
			// happily using the saved resolvers.
			s.fetcher.UpdateResolverPool(ls.Resolvers)
			s.fetcher.SetActiveResolvers(ls.Resolvers)
			s.persistLastScanToProfiles(ls.Resolvers)
			s.checker.StartPeriodic(s.fetcherCtx)
			go s.refreshMetadataOnly()
		} else {
			s.startCheckerThenRefresh()
		}
	}

	var handler http.Handler = mux
	if s.password != "" {
		pw := s.password
		handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			_, pass, ok := r.BasicAuth()
			if !ok || subtle.ConstantTimeCompare([]byte(pass), []byte(pw)) != 1 {
				w.Header().Set("WWW-Authenticate", `Basic realm="thefeed"`)
				http.Error(w, "Unauthorized", http.StatusUnauthorized)
				return
			}
			mux.ServeHTTP(w, r)
		})
	}

	srv := &http.Server{
		Addr:    addr,
		Handler: handler,
		// ReadHeaderTimeout protects against slow-loris on the request
		// header. The body itself can be large (Telegram send-message
		// uploads), and the response can be slow (DNS-tunneled media
		// streams take many minutes for multi-block files in censored
		// networks). So zero out ReadTimeout/WriteTimeout and bound the
		// idle period on the connection itself.
		ReadHeaderTimeout: 30 * time.Second,
		IdleTimeout:       30 * time.Minute,
	}
	if ln != nil {
		return srv.Serve(ln)
	}
	return srv.ListenAndServe()
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}
	data, err := staticFS.ReadFile("static/index.html")
	if err != nil {
		http.Error(w, "internal error", 500)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Write(data)
}

func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	status := map[string]any{
		"configured":  s.config != nil,
		"version":     version.Version,
		"hasPassword": s.password != "",
	}
	if s.config != nil {
		status["domain"] = s.config.Domain
		status["channels"] = s.channels
		status["telegramLoggedIn"] = s.telegramLoggedIn
		status["nextFetch"] = s.nextFetch
		status["latestVersion"] = s.latestVersion
		// Include last resolver scan if recent (<24 h) so the frontend can offer a quick-start.
		if ls := s.loadLastScan(); ls != nil {
			status["lastScan"] = map[string]any{
				"resolvers": ls.Resolvers,
				"scannedAt": ls.ScannedAt,
				"count":     len(ls.Resolvers),
			}
		}
	}
	writeJSON(w, status)
}

// handleConfig handles GET (read) and POST (write) of client configuration.
// POST is authenticated when a global password is set (via the middleware).
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		s.mu.RLock()
		defer s.mu.RUnlock()
		if s.config == nil {
			writeJSON(w, map[string]any{"configured": false})
			return
		}
		writeJSON(w, s.config)

	case http.MethodPost:
		var cfg Config
		if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
			http.Error(w, "invalid JSON", 400)
			return
		}
		if cfg.Domain == "" || cfg.Key == "" || len(cfg.Resolvers) == 0 {
			http.Error(w, "domain, key, and resolvers are required", 400)
			return
		}
		// Add config resolvers to the shared bank.
		if len(cfg.Resolvers) > 0 {
			pl, _ := s.loadProfiles()
			if pl == nil {
				pl = &ProfileList{}
			}
			addToBank(pl, cfg.Resolvers)
			_ = s.saveProfiles(pl)
		}
		if err := s.saveConfig(&cfg); err != nil {
			http.Error(w, fmt.Sprintf("save config: %v", err), 500)
			return
		}
		s.mu.Lock()
		s.config = &cfg
		s.mu.Unlock()

		if err := s.initFetcher(); err != nil {
			http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
			return
		}
		s.startCheckerThenRefresh()
		writeJSON(w, map[string]any{"ok": true})

	default:
		http.Error(w, "method not allowed", 405)
	}
}

func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	writeJSON(w, s.channels)
}

func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
	parts := strings.Split(r.URL.Path, "/")
	if len(parts) < 4 {
		http.Error(w, "missing channel number", 400)
		return
	}
	chNum, err := strconv.Atoi(parts[3])
	if err != nil || chNum < 1 {
		http.Error(w, "invalid channel number", 400)
		return
	}

	s.mu.RLock()
	msgs := s.messages[chNum]
	chs := s.channels
	cache := s.cache
	s.mu.RUnlock()

	// Serve the persistent on-disk cache when available —
	// it contains the full merged history (up to 200 messages) keyed by channel name.
	if cache != nil && chNum >= 1 && chNum <= len(chs) {
		if result := cache.GetMessages(chs[chNum-1].Name); result != nil {
			writeJSON(w, result)
			return
		}
	}

	// Fall back to the in-memory fresh fetch (no accumulated history).
	writeJSON(w, client.NewMessagesResult(msgs))
}

func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	// Background (quiet) metadata-only refreshes skip silently if one is already running,
	// so the auto-refresh timer never cancels a slow in-progress fetch.
	// Channel refreshes are NOT skipped here — refreshChannel has its own duplicate guard.
	chParam := r.URL.Query().Get("channel")
	if r.URL.Query().Get("quiet") == "1" && chParam == "" {
		s.refreshMu.Lock()
		running := len(s.refreshCancels) > 0
		s.refreshMu.Unlock()
		if running {
			writeJSON(w, map[string]any{"ok": true, "skipped": true})
			return
		}
	}
	if chParam != "" {
		chNum, err := strconv.Atoi(chParam)
		if err != nil || chNum < 1 {
			http.Error(w, "invalid channel", 400)
			return
		}
		go s.refreshChannel(chNum)
	} else {
		go s.refreshMetadataOnly()
	}
	writeJSON(w, map[string]any{"ok": true})
}

func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	s.mu.RLock()
	checker := s.checker
	fetcher := s.fetcher
	baseCtx := s.fetcherCtx
	s.mu.RUnlock()
	if checker == nil || baseCtx == nil {
		http.Error(w, "not configured", 400)
		return
	}
	// Widen the fetcher's pool to the full bank so we probe every
	// known resolver, not just the currently-active subset.
	// rescanReplaceList tells SetOnScanDone to overwrite the list.
	pl, _ := s.loadProfiles()
	if pl != nil && len(pl.ResolverBank) > 0 && fetcher != nil {
		fetcher.UpdateResolverPool(pl.ResolverBank)
	}
	s.rescanFlagMu.Lock()
	s.rescanReplaceList = true
	s.rescanFlagMu.Unlock()

	go func() {
		// Cancel any in-progress metadata refresh so it doesn't race with the
		// scan — we want fresh resolver data before we hit DNS again.
		s.refreshMu.Lock()
		for k, cancel := range s.refreshCancels {
			cancel()
			delete(s.refreshCancels, k)
		}
		s.refreshMu.Unlock()

		if checker.CheckNow(baseCtx) {
			// Cool-down: give resolvers time to recover from the scan's DNS
			// queries before we immediately hit them again with a fetch.
			sleep := 3*time.Second + time.Duration(mrand.IntN(13))*time.Second // 3–15 s
			select {
			case <-baseCtx.Done():
				return
			case <-time.After(sleep):
			}
			s.refreshMetadataOnly()
		}
	}()
	writeJSON(w, map[string]any{"ok": true})
}

func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}

	var req struct {
		Channel int    `json:"channel"`
		Text    string `json:"text"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	if req.Channel < 1 || req.Text == "" {
		http.Error(w, "channel and text are required", 400)
		return
	}
	if len(req.Text) > 4000 {
		http.Error(w, "message too long (max 4000 chars)", 400)
		return
	}

	s.mu.RLock()
	fetcher := s.fetcher
	basectx := s.fetcherCtx
	s.mu.RUnlock()

	if fetcher == nil || basectx == nil {
		http.Error(w, "not configured", 400)
		return
	}

	ctx, cancel := context.WithTimeout(basectx, 5*time.Minute)
	defer cancel()

	s.addLog(fmt.Sprintf("Sending message to channel %d (%d chars)...", req.Channel, len(req.Text)))

	if err := fetcher.SendMessage(ctx, req.Channel, req.Text); err != nil {
		log.Printf("[web] send error ch=%d: %v", req.Channel, err)
		s.addLog("Error: failed to send message")
		http.Error(w, "failed to send message", 500)
		return
	}

	s.addLog("Message sent successfully")
	writeJSON(w, map[string]any{"ok": true})
}

func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}

	var req struct {
		Command string `json:"command"`
		Arg     string `json:"arg"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	if req.Command == "" {
		http.Error(w, "command is required", 400)
		return
	}

	s.mu.RLock()
	fetcher := s.fetcher
	basectx := s.fetcherCtx
	s.mu.RUnlock()

	if fetcher == nil || basectx == nil {
		http.Error(w, "not configured", 400)
		return
	}

	ctx, cancel := context.WithTimeout(basectx, 5*time.Minute)
	defer cancel()

	s.addLog(fmt.Sprintf("Admin command: %s %s", req.Command, req.Arg))

	var cmd protocol.AdminCmd
	switch req.Command {
	case "add_channel":
		cmd = protocol.AdminCmdAddChannel
	case "remove_channel":
		cmd = protocol.AdminCmdRemoveChannel
	case "list_channels":
		cmd = protocol.AdminCmdListChannels
	case "refresh":
		cmd = protocol.AdminCmdRefresh
	default:
		http.Error(w, "unknown command", 400)
		return
	}

	result, err := fetcher.SendAdminCommand(ctx, cmd, req.Arg)
	if err != nil {
		log.Printf("[web] admin error: %v", err)
		s.addLog(fmt.Sprintf("Admin error: %v", err))
		http.Error(w, "admin command failed", 500)
		return
	}

	s.addLog(fmt.Sprintf("Admin result: %s", result))
	writeJSON(w, map[string]any{"ok": true, "result": result})
}

func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming not supported", 500)
		return
	}

	// Disable the server-wide WriteTimeout for this long-lived SSE connection.
	rc := http.NewResponseController(w)
	_ = rc.SetWriteDeadline(time.Time{})

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")

	ch := make(chan string, 500)
	s.sseMu.Lock()
	s.clients[ch] = struct{}{}
	s.sseMu.Unlock()

	defer func() {
		s.sseMu.Lock()
		delete(s.clients, ch)
		s.sseMu.Unlock()
	}()

	s.logMu.RLock()
	for _, line := range s.logLines {
		data, _ := json.Marshal(line)
		fmt.Fprintf(w, "event: log\ndata: %s\n\n", data)
	}
	s.logMu.RUnlock()
	flusher.Flush()

	ctx := r.Context()
	ping := time.NewTicker(30 * time.Second)
	defer ping.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ping.C:
			// SSE comment line as heartbeat — keeps the connection alive and
			// lets us detect a dead client (write error).
			if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
				return
			}
			flusher.Flush()
		case msg := <-ch:
			if _, err := fmt.Fprint(w, msg); err != nil {
				return
			}
			flusher.Flush()
		}
	}
}

func (s *Server) broadcast(event string) {
	s.sseMu.Lock()
	defer s.sseMu.Unlock()
	for ch := range s.clients {
		select {
		case ch <- event:
		default:
		}
	}
}

func (s *Server) addLog(msg string) {
	ts := time.Now().Format("15:04:05")
	line := fmt.Sprintf("%s %s", ts, msg)

	s.logMu.Lock()
	s.logLines = append(s.logLines, line)
	if len(s.logLines) > 200 {
		s.logLines = s.logLines[len(s.logLines)-200:]
	}
	s.logMu.Unlock()

	data, _ := json.Marshal(line)
	s.broadcast(fmt.Sprintf("event: log\ndata: %s\n\n", data))
}

func (s *Server) initFetcher() error {
	s.mu.Lock()
	defer s.mu.Unlock()

	// Cancel goroutines from the previous fetcher configuration.
	// This also cancels any in-progress manual rescan (via the context chain).
	// Preserve resolver stats across fetcher re-creation (e.g. profile switch).
	var prevStats map[string][3]int64
	if s.fetcher != nil {
		prevStats = s.fetcher.ExportStats()
		// Persist accumulated stats before destroying the old fetcher.
		s.persistResolverScores(prevStats)
	}
	if s.fetcherCancel != nil {
		s.fetcherCancel()
	}

	cfg := s.config
	if cfg == nil {
		return fmt.Errorf("no config")
	}

	// Load the shared resolver bank and preferences from profiles.json.
	var bankResolvers []string
	var debug bool
	var savedScores map[string]*SavedResolverScore
	if pl, plErr := s.loadProfiles(); plErr == nil {
		debug = pl.Debug
		if len(pl.ResolverBank) > 0 {
			bankResolvers = pl.ResolverBank
		}
		savedScores = pl.ResolverScores
	}

	// Use resolver bank; fall back to per-profile resolvers for backward compat.
	resolvers := cfg.Resolvers
	if len(bankResolvers) > 0 {
		resolvers = bankResolvers
	}

	cacheDir := filepath.Join(s.dataDir, "cache")
	cache, err := client.NewCache(cacheDir)
	if err != nil {
		return fmt.Errorf("create cache: %w", err)
	}

	fetcher, err := client.NewFetcher(cfg.Domain, cfg.Key, resolvers)
	if err != nil {
		return fmt.Errorf("create fetcher: %w", err)
	}

	// Restore resolver stats: prefer in-memory stats from the previous fetcher,
	// fall back to persisted scores for fresh starts.
	if prevStats != nil {
		fetcher.ImportStats(prevStats)
	} else if len(savedScores) > 0 {
		m := make(map[string][3]int64)
		for addr, ss := range savedScores {
			key := addr
			if !strings.Contains(key, ":") {
				key += ":53"
			}
			m[key] = [3]int64{ss.Success, ss.Failure, ss.TotalMs}
		}
		fetcher.ImportStats(m)
	}

	if cfg.QueryMode == "double" {
		fetcher.SetQueryMode(protocol.QueryMultiLabel)
	}
	fetcher.SetDebug(debug)
	s.scanner.SetDebug(debug)
	if cfg.RateLimit > 0 {
		fetcher.SetRateLimit(cfg.RateLimit)
	}
	if cfg.Scatter > 1 {
		fetcher.SetScatter(cfg.Scatter)
	}

	timeout := 15 * time.Second
	if cfg.Timeout > 0 {
		timeout = time.Duration(cfg.Timeout * float64(time.Second))
	}
	fetcher.SetTimeout(timeout)

	fetcher.SetLogFunc(func(msg string) {
		s.addLog(msg)
	})

	// Create a shared context for this fetcher's lifetime.
	ctx, cancel := context.WithCancel(context.Background())
	s.fetcherCtx = ctx
	s.fetcherCancel = cancel

	// Start rate limiter and noise goroutines.
	fetcher.Start(ctx)

	// Initialise resolver health-checker; start it (with initial scan → then refresh)
	// via startCheckerThenRefresh, called by every initFetcher call site.
	checker := client.NewResolverChecker(fetcher, timeout)
	checker.SetLogFunc(func(msg string) {
		s.addLog(msg)
	})
	checker.SetOnScanDone(func(healthy []string) {
		if len(healthy) > 0 {
			s.saveLastScan(healthy)
		}
		s.persistScanResultsToList(healthy)
	})
	// nil means enabled (the default); only an explicit false pointer disables it.
	autoScan := cfg.AutoScan == nil || *cfg.AutoScan
	checker.SetAutoScan(autoScan)
	s.checker = checker

	s.fetcher = fetcher
	s.cache = cache
	go cache.Cleanup() // remove channel files not updated in 7 days

	// Goroutine dies with fetcherCtx, so a profile switch / config change
	// stops it cleanly.
	go s.runAutoUpdateLoop(ctx)
	return nil
}

// runAutoUpdateLoop refreshes the active profile's AutoUpdate channels on a
// schedule that follows the server's own fetch cycle — there's no point
// polling more often than the server actually pulls fresh data from
// Telegram. User-set Profile.AutoUpdateInterval is honoured if it's >= the
// 60s floor; otherwise we align with nextFetch + settle delay.
func (s *Server) runAutoUpdateLoop(ctx context.Context) {
	select {
	case <-time.After(autoUpdateStartupDelay):
	case <-ctx.Done():
		return
	}

	var lastTick time.Time
	for {
		wait := s.nextAutoUpdateWait(lastTick)
		select {
		case <-ctx.Done():
			return
		case <-time.After(wait):
		}
		if !s.canAutoUpdate() {
			continue
		}
		s.tickAutoUpdate()
		lastTick = time.Now()
	}
}

// nextAutoUpdateWait returns how long to sleep before the next tick. Honours
// user override when set sensibly; otherwise sleeps until just after the
// server's next Telegram fetch so we always pull just-refreshed data.
func (s *Server) nextAutoUpdateWait(lastTick time.Time) time.Duration {
	pl, _ := s.loadProfiles()
	if pl != nil && pl.Active != "" {
		for _, p := range pl.Profiles {
			if p.ID != pl.Active {
				continue
			}
			if p.AutoUpdateInterval > 0 {
				user := time.Duration(p.AutoUpdateInterval) * time.Second
				if user < minAutoUpdateInterval {
					user = minAutoUpdateInterval
				}
				return user
			}
			break
		}
	}

	s.mu.RLock()
	nf := s.nextFetch
	s.mu.RUnlock()
	if nf == 0 {
		return minAutoUpdateInterval
	}
	target := time.Unix(int64(nf), 0).Add(serverFetchSettleDelay)
	delay := time.Until(target)
	if delay < minAutoUpdateInterval {
		delay = minAutoUpdateInterval
	}
	if !lastTick.IsZero() {
		if since := time.Since(lastTick); since < minAutoUpdateInterval {
			if rem := minAutoUpdateInterval - since; rem > delay {
				delay = rem
			}
		}
	}
	return delay
}

// canAutoUpdate returns false when we should skip a tick: server hasn't
// produced metadata yet (channel list empty), or the resolver scanner is
// busy (it'd race with our DNS fetches), or there's no fetcher.
func (s *Server) canAutoUpdate() bool {
	s.mu.RLock()
	channels := s.channels
	fetcher := s.fetcher
	scanner := s.scanner
	s.mu.RUnlock()
	if fetcher == nil || len(channels) == 0 {
		return false
	}
	if scanner != nil {
		switch scanner.State() {
		case client.ScannerRunning, client.ScannerPaused:
			return false
		}
	}
	return true
}

func (s *Server) tickAutoUpdate() {
	pl, err := s.loadProfiles()
	if err != nil || pl == nil || pl.Active == "" {
		return
	}
	var watch []string
	for _, p := range pl.Profiles {
		if p.ID == pl.Active {
			watch = p.AutoUpdate
			break
		}
	}
	if len(watch) == 0 {
		return
	}

	s.mu.RLock()
	channels := s.channels
	s.mu.RUnlock()
	if len(channels) == 0 {
		return
	}

	wantSet := make(map[string]bool, len(watch))
	for _, name := range watch {
		wantSet[strings.TrimPrefix(strings.TrimSpace(name), "@")] = true
	}

	for i, ch := range channels {
		if !wantSet[ch.Name] {
			continue
		}
		go s.refreshChannel(i + 1) // 1-indexed
	}
}

func (s *Server) checkLatestVersion(ctx context.Context) (string, error) {
	s.mu.RLock()
	cfg := s.config
	s.mu.RUnlock()
	if cfg == nil {
		return "", fmt.Errorf("no config")
	}

	// Use resolver bank; fall back to per-profile resolvers.
	resolvers := cfg.Resolvers
	var debug bool
	if pl, plErr := s.loadProfiles(); plErr == nil {
		debug = pl.Debug
		if len(pl.ResolverBank) > 0 {
			resolvers = pl.ResolverBank
		}
	}

	fetcher, err := client.NewFetcher(cfg.Domain, cfg.Key, resolvers)
	if err != nil {
		return "", fmt.Errorf("create fetcher: %w", err)
	}
	if cfg.QueryMode == "double" {
		fetcher.SetQueryMode(protocol.QueryMultiLabel)
	}
	fetcher.SetDebug(debug)
	s.scanner.SetDebug(debug)
	if cfg.RateLimit > 0 {
		fetcher.SetRateLimit(cfg.RateLimit)
	}
	if cfg.Scatter > 1 {
		fetcher.SetScatter(cfg.Scatter)
	}
	timeout := 15 * time.Second
	if cfg.Timeout > 0 {
		timeout = time.Duration(cfg.Timeout * float64(time.Second))
	}
	fetcher.SetTimeout(timeout)
	fetcher.SetLogFunc(func(msg string) {
		s.addLog(msg)
	})
	fetcher.SetActiveResolvers(resolvers)

	checkCtx, cancel := context.WithTimeout(ctx, timeout*3)
	defer cancel()
	fetcher.Start(checkCtx)

	v, err := fetcher.FetchLatestVersion(checkCtx)
	if err != nil {
		return "", err
	}

	s.mu.Lock()
	s.latestVersion = v
	s.mu.Unlock()
	return v, nil
}

// startCheckerThenRefresh runs the resolver health-check pass synchronously
// (in a new goroutine), then starts the periodic checker and fetches metadata.
// This ensures fresh resolver data is used for the very first metadata query.
func (s *Server) startCheckerThenRefresh() {
	s.mu.RLock()
	checker := s.checker
	ctx := s.fetcherCtx
	s.mu.RUnlock()
	if checker == nil {
		return
	}

	checker.StartAndNotify(ctx, func() {
		s.refreshMetadataOnly()
	})
}

// skipCheckerUseSaved uses saved resolvers from the last scan and starts
// periodic health checks without an initial scan pass.
// If no saved resolvers are available, falls back to a full scan with
// retry-every-minute until at least one resolver is found.
func (s *Server) skipCheckerUseSaved() {
	s.mu.RLock()
	checker := s.checker
	ctx := s.fetcherCtx
	fetcher := s.fetcher
	s.mu.RUnlock()
	if checker == nil || fetcher == nil {
		return
	}
	if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 {
		fetcher.SetActiveResolvers(ls.Resolvers)
		checker.StartPeriodic(ctx)
		go s.refreshMetadataOnly()
	} else {
		// No saved resolvers — do a full scan (with retry-every-minute).
		checker.StartAndNotify(ctx, func() {
			s.refreshMetadataOnly()
		})
	}
}

// nextFetchDeadline returns the Time when the server will next fetch from Telegram.
// Returns zero value if nextFetch is not set or has already passed.
func (s *Server) nextFetchDeadline() time.Time {
	s.mu.RLock()
	nf := s.nextFetch
	s.mu.RUnlock()
	if nf == 0 {
		return time.Time{}
	}
	t := time.Unix(int64(nf), 0)
	if time.Until(t) <= 0 {
		return time.Time{} // already passed
	}
	return t
}

// waitForServerFetch blocks until the server's Telegram fetch is likely complete
// (nextFetch + 30 s), emitting a countdown progress event each second so the UI
// can render a live progress bar. Returns true on completion, false if ctx cancelled.
func (s *Server) waitForServerFetch(ctx context.Context, nf uint32) bool {
	const serverFetchDuration = 30 * time.Second
	deadline := time.Unix(int64(nf), 0).Add(serverFetchDuration)
	totalWait := time.Until(deadline)
	if totalWait <= 0 {
		totalWait = serverFetchDuration
	}
	totalSec := int(totalWait.Seconds()) + 1

	s.addLog(fmt.Sprintf("SERVER_FETCH_WAIT start %d", totalSec))

	timer := time.NewTimer(totalWait)
	defer timer.Stop()
	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	start := time.Now()
	for {
		select {
		case <-ctx.Done():
			s.addLog("SERVER_FETCH_WAIT done")
			return false
		case <-timer.C:
			s.addLog("SERVER_FETCH_WAIT done")
			return true
		case <-ticker.C:
			remaining := int((totalWait - time.Since(start)).Seconds())
			if remaining < 0 {
				remaining = 0
			}
			s.addLog(fmt.Sprintf("SERVER_FETCH_WAIT tick %d/%d", remaining, totalSec))
		}
	}
}

func (s *Server) refreshMetadataOnly() {
	// Don't fetch before resolver scanning has found at least one healthy resolver.
	// The onFirstDone callback in startCheckerThenRefresh is the canonical first trigger.
	s.mu.RLock()
	fetcherEarly := s.fetcher
	s.mu.RUnlock()
	if fetcherEarly != nil && len(fetcherEarly.Resolvers()) == 0 {
		s.addLog("Waiting for resolver scan to complete...")
		return
	}

	// Cancel any in-progress metadata refresh and start a new cancellable one.
	const metaKey = 0
	s.refreshMu.Lock()
	if prev, ok := s.refreshCancels[metaKey]; ok {
		prev()
	}

	s.mu.RLock()
	basectx := s.fetcherCtx
	fetcher := s.fetcher
	cache := s.cache
	s.mu.RUnlock()

	if fetcher == nil || basectx == nil {
		delete(s.refreshCancels, metaKey)
		s.refreshMu.Unlock()
		return
	}

	// Child context: cancelled either by the next refresh call or by a config change.
	ctx, cancel := context.WithCancel(basectx)
	s.refreshCancels[metaKey] = cancel
	s.refreshMu.Unlock()
	defer func() {
		cancel()
		s.refreshMu.Lock()
		delete(s.refreshCancels, metaKey)
		s.refreshMu.Unlock()
	}()

	s.addLog(fmt.Sprintf("Fetching metadata... (%d active resolvers)", len(fetcher.Resolvers())))

	// If the server's next Telegram fetch is imminent (within 15 s), wait for it first.
	if dl := s.nextFetchDeadline(); !dl.IsZero() && time.Until(dl) < 15*time.Second {
		s.mu.RLock()
		nf := s.nextFetch
		s.mu.RUnlock()
		if !s.waitForServerFetch(ctx, nf) {
			return
		}
	}

	meta, err := fetcher.FetchMetadata(ctx)
	if err != nil {
		if ctx.Err() != nil {
			s.addLog("Refresh cancelled")
			return
		}
		// Detect invalid passphrase from crypto errors
		errStr := err.Error()
		if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") {
			s.addLog("Error: Invalid passphrase — check your encryption key in Settings")
		} else {
			s.addLog(fmt.Sprintf("Error: %v", err))
		}
		return
	}

	channels := meta.Channels
	if cache != nil {
		if cached := cache.GetAllTitles(); len(cached) > 0 {
			for i := range channels {
				if t := cached[channels[i].Name]; t != "" {
					channels[i].DisplayName = t
				}
			}
		}
	}

	s.mu.Lock()
	s.channels = channels
	s.telegramLoggedIn = meta.TelegramLoggedIn
	s.nextFetch = meta.NextFetch
	s.metaFetchedAt = time.Now()
	s.mu.Unlock()

	if cache != nil {
		_ = cache.PutMetadata(meta)
	}
	s.saveChannelsCache(channels, meta.NextFetch)

	s.broadcast("event: update\ndata: \"channels\"\n\n")

	needsFetch := false
	for _, ch := range channels {
		if ch.DisplayName == "" {
			needsFetch = true
			break
		}
	}
	if needsFetch {
		go s.ensureTitlesFetched(basectx)
	}

	go s.maybeRefreshProfilePics(basectx)
}

// maybeRefreshProfilePics fires a refresh when GitHub relay is up or
// the user has opted into the DNS path. No-op otherwise; hub coalesces.
func (s *Server) maybeRefreshProfilePics(parentCtx context.Context) {
	s.mu.RLock()
	hub := s.profilePics
	fetcher := s.fetcher
	rc := s.relayInfo
	s.mu.RUnlock()
	if hub == nil || fetcher == nil {
		return
	}
	dnsAllowed := s.profilePicsEnabled()
	githubLikelyUp := false
	if rc != nil {
		ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
		info, err := rc.get(ctx, fetcher)
		cancel()
		if err == nil && info.GitHubRepo != "" {
			githubLikelyUp = true
		}
	}
	if !dnsAllowed && !githubLikelyUp {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()
	onStored := func(string) {
		s.broadcast("event: update\ndata: \"profile-pics\"\n\n")
	}
	if err := hub.refresh(ctx, fetcher, dnsAllowed, s.fetchFromGitHubRelayBytes, onStored); err == nil {
		s.broadcast("event: update\ndata: \"profile-pics\"\n\n")
	}
}

// ensureTitlesFetched fetches channel display names from TitlesChannel in the background.
// At most one fetch runs at a time; errors impose a 5-minute backoff so that an
// outdated server does not cause endless retries.
func (s *Server) ensureTitlesFetched(ctx context.Context) {
	s.titlesMu.Lock()
	if s.titlesLoading || time.Now().Before(s.titlesBackoffUntil) {
		s.titlesMu.Unlock()
		return
	}
	s.titlesLoading = true
	s.titlesMu.Unlock()

	defer func() {
		s.titlesMu.Lock()
		s.titlesLoading = false
		s.titlesMu.Unlock()
	}()

	s.mu.RLock()
	fetcher := s.fetcher
	cache := s.cache
	s.mu.RUnlock()

	if fetcher == nil {
		return
	}

	titles, err := fetcher.FetchTitles(ctx)
	if err != nil && ctx.Err() == nil {
		s.titlesMu.Lock()
		s.titlesBackoffUntil = time.Now().Add(5 * time.Minute)
		s.titlesMu.Unlock()
		return
	}
	if len(titles) == 0 {
		// Server doesn't support TitlesChannel or has no titles yet; back off.
		s.titlesMu.Lock()
		s.titlesBackoffUntil = time.Now().Add(5 * time.Minute)
		s.titlesMu.Unlock()
		return
	}

	if cache != nil {
		for name, title := range titles {
			_ = cache.PutTitle(name, title)
		}
	}

	s.mu.Lock()
	channels := s.channels
	updated := false
	for i := range channels {
		if t, ok := titles[channels[i].Name]; ok && t != "" && channels[i].DisplayName != t {
			channels[i].DisplayName = t
			updated = true
		}
	}
	s.channels = channels
	nextFetch := s.nextFetch
	s.mu.Unlock()

	if updated {
		s.saveChannelsCache(channels, nextFetch)
		s.broadcast("event: update\ndata: \"channels\"\n\n")
	}
}

func (s *Server) refreshChannel(channelNum int) {
	// Prevent duplicate fetches for the same channel
	s.refreshMu.Lock()
	if _, running := s.refreshCancels[channelNum]; running {
		s.refreshMu.Unlock()
		return
	}

	s.mu.RLock()
	basectx := s.fetcherCtx
	fetcher := s.fetcher
	cache := s.cache
	s.mu.RUnlock()

	if fetcher == nil || basectx == nil {
		s.refreshMu.Unlock()
		return
	}

	ctx, cancel := context.WithCancel(basectx)
	s.refreshCancels[channelNum] = cancel
	s.refreshMu.Unlock()
	defer func() {
		cancel()
		s.refreshMu.Lock()
		delete(s.refreshCancels, channelNum)
		s.refreshMu.Unlock()
	}()

	// Use the cached in-memory metadata if it is fresh enough (< metaCacheTTL, default 30 sec).
	// This avoids a redundant metadata DNS fetch for every channel refresh.
	// If the metadata is stale (or was never fetched), fetch it from DNS now.
	s.mu.RLock()
	ttl := s.metaCacheTTL
	if ttl <= 0 {
		ttl = 30 * time.Second
	}
	// Cap TTL at the time remaining until the server's next Telegram fetch.
	// If nextFetch is sooner than our TTL the cached metadata may already be stale.
	if nf := s.nextFetch; nf > 0 {
		if rem := time.Until(time.Unix(int64(nf), 0)); rem > 0 && rem < ttl {
			ttl = rem
		}
	}
	cachedChannels := s.channels
	cachedAge := time.Since(s.metaFetchedAt)
	s.mu.RUnlock()

	var meta *protocol.Metadata
	if len(cachedChannels) > 0 && cachedAge < ttl {
		// Build a lightweight Metadata from the cached fields to keep the rest of the
		// function unchanged.
		s.mu.RLock()
		meta = &protocol.Metadata{
			Channels:         s.channels,
			TelegramLoggedIn: s.telegramLoggedIn,
			NextFetch:        s.nextFetch,
		}
		s.mu.RUnlock()
	} else {
		var err error
		meta, err = fetcher.FetchMetadata(ctx)
		if err != nil {
			if ctx.Err() != nil {
				s.addLog("Refresh cancelled")
				return
			}
			errStr := err.Error()
			if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") {
				s.addLog("Error: Invalid passphrase — check your encryption key in Settings")
			} else {
				s.addLog(fmt.Sprintf("Error: %v", err))
			}
			return
		}
		channels := meta.Channels
		if cache != nil {
			if cached := cache.GetAllTitles(); len(cached) > 0 {
				for i := range channels {
					if t := cached[channels[i].Name]; t != "" {
						channels[i].DisplayName = t
					}
				}
			}
		}
		meta.Channels = channels
		s.mu.Lock()
		s.channels = channels
		s.telegramLoggedIn = meta.TelegramLoggedIn
		s.nextFetch = meta.NextFetch
		s.metaFetchedAt = time.Now()
		s.mu.Unlock()
		if cache != nil {
			_ = cache.PutMetadata(meta)
		}
		s.saveChannelsCache(channels, meta.NextFetch)
		s.broadcast("event: update\ndata: \"channels\"\n\n")
		needsFetch := false
		for _, ch := range channels {
			if ch.DisplayName == "" {
				needsFetch = true
				break
			}
		}
		if needsFetch {
			go s.ensureTitlesFetched(basectx)
		}
	}

	channels := meta.Channels
	if channelNum < 1 || channelNum > len(channels) {
		s.addLog(fmt.Sprintf("Warning: channel %d is not available", channelNum))
		return
	}

	ch := channels[channelNum-1]

	// Skip refresh if the last message ID and content hash haven't changed
	// AND we already have messages stored for this channel.
	s.mu.RLock()
	prevID := s.lastMsgIDs[channelNum]
	prevHash := s.lastHashes[channelNum]
	prevMsgs := s.messages[channelNum]
	s.mu.RUnlock()
	if prevID > 0 && ch.LastMsgID == prevID && ch.ContentHash == prevHash && len(prevMsgs) > 0 {
		s.addLog(fmt.Sprintf("Channel %s: no changes (last ID: %d)", ch.Name, prevID))
		s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"no_changes\",\"channel\":%d}\n\n", channelNum))
		return
	}

	blockCount := int(ch.Blocks)
	if blockCount <= 0 {
		s.mu.Lock()
		s.messages[channelNum] = nil
		s.lastMsgIDs[channelNum] = ch.LastMsgID
		s.lastHashes[channelNum] = ch.ContentHash
		s.mu.Unlock()
		s.addLog(fmt.Sprintf("Updated %s: 0 messages", ch.Name))
		s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
		return
	}

	// Wrap the context with a deadline at the server's next Telegram fetch.
	// If the server starts fetching during our block download we cancel early,
	// wait for the fresh data to land, then restart this channel fetch.
	fetchCtx := ctx
	var fetchCancel context.CancelFunc
	var fetchNF uint32
	if dl := s.nextFetchDeadline(); !dl.IsZero() {
		s.mu.RLock()
		fetchNF = s.nextFetch
		s.mu.RUnlock()

		// If the server's next refresh is within 15 seconds, wait for it rather
		// than risking a block-version race (metadata says N blocks but the
		// server regenerates them mid-download).
		if time.Until(dl) < 15*time.Second {
			s.addLog("Server refresh imminent — waiting before fetching blocks")
			if !s.waitForServerFetch(ctx, fetchNF) {
				return
			}
			// Re-fetch metadata after the server refresh to get fresh block counts.
			freshMeta, freshErr := fetcher.FetchMetadata(ctx)
			if freshErr != nil {
				if ctx.Err() != nil {
					s.addLog("Refresh cancelled")
					return
				}
				s.addLog(fmt.Sprintf("Channel %s error refreshing metadata: %v", ch.Name, freshErr))
				return
			}
			s.mu.Lock()
			s.channels = freshMeta.Channels
			s.telegramLoggedIn = freshMeta.TelegramLoggedIn
			s.nextFetch = freshMeta.NextFetch
			s.metaFetchedAt = time.Now()
			s.mu.Unlock()
			if cache != nil {
				_ = cache.PutMetadata(freshMeta)
			}
			s.saveChannelsCache(freshMeta.Channels, freshMeta.NextFetch)
			if channelNum < 1 || channelNum > len(freshMeta.Channels) {
				return
			}
			ch = freshMeta.Channels[channelNum-1]
			blockCount = int(ch.Blocks)
			if blockCount <= 0 {
				return
			}
		}

		// Refresh the deadline after potential wait.
		dl = s.nextFetchDeadline()
		if !dl.IsZero() {
			fetchCtx, fetchCancel = context.WithDeadline(ctx, dl)
			defer fetchCancel()
		}
	}

	// Fetch blocks with content-hash verification.  On hash mismatch (the
	// server regenerated blocks between our metadata fetch and block fetch)
	// re-fetch metadata and retry up to 2 times.
	const maxHashRetries = 2
	var msgs []protocol.Message
	var err error
	for attempt := 0; ; attempt++ {
		msgs, err = fetcher.FetchChannelVerified(fetchCtx, channelNum, blockCount, ch.ContentHash)
		if err == nil {
			break
		}
		if errors.Is(err, client.ErrContentHashMismatch) && attempt < maxHashRetries {
			s.addLog(fmt.Sprintf("Channel %s: block-version race detected, re-fetching metadata (attempt %d/%d)", ch.Name, attempt+1, maxHashRetries))
			freshMeta, freshErr := fetcher.FetchMetadata(ctx)
			if freshErr != nil {
				s.addLog(fmt.Sprintf("Channel %s error refreshing metadata: %v", ch.Name, freshErr))
				return
			}
			s.mu.Lock()
			s.channels = freshMeta.Channels
			s.telegramLoggedIn = freshMeta.TelegramLoggedIn
			s.nextFetch = freshMeta.NextFetch
			s.metaFetchedAt = time.Now()
			s.mu.Unlock()
			if cache != nil {
				_ = cache.PutMetadata(freshMeta)
			}
			s.saveChannelsCache(freshMeta.Channels, freshMeta.NextFetch)
			if channelNum < 1 || channelNum > len(freshMeta.Channels) {
				return
			}
			ch = freshMeta.Channels[channelNum-1]
			blockCount = int(ch.Blocks)
			if blockCount <= 0 {
				return
			}
			continue // retry with fresh metadata
		}
		if fetchCancel != nil && fetchCtx.Err() == context.DeadlineExceeded {
			// nextFetch fired mid-download — wait for the server, then re-fetch.
			fetchCancel()
			if s.waitForServerFetch(ctx, fetchNF) {
				go s.refreshChannel(channelNum)
			}
			return
		}
		if ctx.Err() != nil {
			s.addLog("Refresh cancelled")
			return
		}
		s.addLog(fmt.Sprintf("Channel %s error: %v", ch.Name, err))
		return
	}

	s.mu.Lock()
	s.messages[channelNum] = msgs
	// Only store the metadata IDs when we actually received messages.
	// If the fetch returned 0 messages but the channel has content (LastMsgID > 0),
	// keep the old IDs so the next refresh will try a full fetch instead of skipping.
	if len(msgs) > 0 || ch.LastMsgID == 0 {
		s.lastMsgIDs[channelNum] = ch.LastMsgID
		s.lastHashes[channelNum] = ch.ContentHash
	}
	s.mu.Unlock()

	if cache != nil {
		if result, mergeErr := cache.MergeAndPut(ch.Name, msgs); mergeErr == nil {
			// Replace the in-memory store with the full merged history.
			s.mu.Lock()
			s.messages[channelNum] = result.Messages
			s.mu.Unlock()
		}
	}

	s.addLog(fmt.Sprintf("Updated %s: %d messages", ch.Name, len(msgs)))
	s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum))
}

func (s *Server) loadConfig() (*Config, error) {
	path := filepath.Join(s.dataDir, "config.json")
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

// saveLastScan persists the healthy resolver list from the most recent scan.
func (s *Server) saveLastScan(resolvers []string) {
	d := lastScanData{Resolvers: resolvers, ScannedAt: time.Now().Unix()}
	b, err := json.MarshalIndent(d, "", "  ")
	if err != nil {
		return
	}
	_ = os.WriteFile(filepath.Join(s.dataDir, "last_scan.json"), b, 0600)
}

func (s *Server) activeProfileID() string {
	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		return ""
	}
	return pl.Active
}

func (s *Server) channelsCachePath() string {
	return filepath.Join(s.dataDir, "channels_cache.json")
}

func (s *Server) readChannelsCacheFile() channelsCacheFile {
	b, err := os.ReadFile(s.channelsCachePath())
	if err != nil {
		return channelsCacheFile{}
	}
	var f channelsCacheFile
	if err := json.Unmarshal(b, &f); err != nil || f == nil {
		return channelsCacheFile{}
	}
	return f
}

func (s *Server) saveChannelsCache(channels []protocol.ChannelInfo, nextFetch uint32) {
	id := s.activeProfileID()
	if id == "" || len(channels) == 0 {
		return
	}
	f := s.readChannelsCacheFile()
	f[id] = &channelsCacheEntry{
		Channels:  channels,
		NextFetch: nextFetch,
		SavedAt:   time.Now().Unix(),
	}
	b, err := json.MarshalIndent(f, "", "  ")
	if err != nil {
		return
	}
	_ = os.WriteFile(s.channelsCachePath(), b, 0600)
}

func (s *Server) loadChannelsCache() *channelsCacheEntry {
	id := s.activeProfileID()
	if id == "" {
		return nil
	}
	return s.readChannelsCacheFile()[id]
}

func (s *Server) dropChannelsCacheEntry(profileID string) {
	if profileID == "" {
		return
	}
	f := s.readChannelsCacheFile()
	if _, ok := f[profileID]; !ok {
		return
	}
	delete(f, profileID)
	if len(f) == 0 {
		_ = os.Remove(s.channelsCachePath())
		return
	}
	if b, err := json.MarshalIndent(f, "", "  "); err == nil {
		_ = os.WriteFile(s.channelsCachePath(), b, 0600)
	}
}

// loadLastScan reads the most recent resolver scan result.
// Returns nil when the file doesn't exist or is older than 24 hours.
func (s *Server) loadLastScan() *lastScanData {
	b, err := os.ReadFile(filepath.Join(s.dataDir, "last_scan.json"))
	if err != nil {
		return nil
	}
	var d lastScanData
	if err := json.Unmarshal(b, &d); err != nil {
		return nil
	}
	if len(d.Resolvers) == 0 || time.Since(time.Unix(d.ScannedAt, 0)) > 24*time.Hour {
		return nil
	}
	return &d
}

// handleApplySavedResolvers immediately activates the resolvers from the last
// scan file, letting the UI skip the current scan and start fetching channels.
func (s *Server) handleApplySavedResolvers(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	ls := s.loadLastScan()
	if ls == nil {
		http.Error(w, "no saved scan", 400)
		return
	}
	s.mu.RLock()
	fetcher := s.fetcher
	s.mu.RUnlock()
	if fetcher == nil {
		http.Error(w, "not configured", 400)
		return
	}
	fetcher.SetActiveResolvers(ls.Resolvers)
	go s.refreshMetadataOnly()
	writeJSON(w, map[string]any{"ok": true, "count": len(ls.Resolvers)})
}

func (s *Server) handleActiveResolvers(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", 405)
		return
	}
	s.mu.RLock()
	fetcher := s.fetcher
	s.mu.RUnlock()
	if fetcher == nil {
		writeJSON(w, map[string]any{"resolvers": []string{}, "scoreboard": []client.ResolverInfo{}})
		return
	}
	writeJSON(w, map[string]any{
		"resolvers":  fetcher.Resolvers(),
		"all":        fetcher.AllResolvers(),
		"scoreboard": fetcher.ResolverScoreboard(),
	})
}

func (s *Server) handleRemoveResolver(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var req struct {
		Addr string `json:"addr"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Addr == "" {
		http.Error(w, "addr required", 400)
		return
	}
	s.mu.RLock()
	fetcher := s.fetcher
	s.mu.RUnlock()
	if fetcher == nil {
		http.Error(w, "no active fetcher", 400)
		return
	}
	fetcher.RemoveActiveResolver(req.Addr)

	// Persist the removal in the currently-selected named list. Without
	// this, the resolver pops back on next start because applySelectedList
	// re-applies the on-disk list verbatim. Scope is intentionally narrow:
	// only the active list is touched — the bank and other named lists
	// keep the resolver until the user removes it from the bank.
	listChanged := false
	if pl, err := s.loadProfiles(); err == nil && pl != nil {
		if list := findList(pl, pl.SelectedList); list != nil {
			out := list.Resolvers[:0]
			for _, r := range list.Resolvers {
				if r == req.Addr {
					listChanged = true
					continue
				}
				out = append(out, r)
			}
			if listChanged {
				list.Resolvers = out
				_ = s.saveProfiles(pl)
			}
		}
	}
	if listChanged {
		// Push tab counts to any open Resolver Bank modal — without
		// this, the badge stays at the old count until the user
		// switches lists.
		s.broadcast("event: update\ndata: \"resolver-lists\"\n\n")
	}
	writeJSON(w, map[string]any{"ok": true})
}

func (s *Server) handleResetResolverStats(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	s.mu.RLock()
	fetcher := s.fetcher
	s.mu.RUnlock()
	if fetcher == nil {
		http.Error(w, "no active fetcher", 400)
		return
	}
	fetcher.ResetStats()
	writeJSON(w, map[string]any{"ok": true})
}

// handleResolverBank manages the shared resolver bank.
// GET: returns all bank resolvers with scores.
// POST: adds resolvers to the bank.
// DELETE: removes specific resolvers from the bank.
func (s *Server) handleResolverBank(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		pl, _ := s.loadProfiles()
		if pl == nil {
			pl = &ProfileList{}
		}

		// Get live stats from the current fetcher.
		var liveStats map[string][3]int64
		var activeSet map[string]bool
		s.mu.RLock()
		if s.fetcher != nil {
			liveStats = s.fetcher.ExportStats()
			activeSet = make(map[string]bool)
			for _, r := range s.fetcher.Resolvers() {
				k := r
				if !strings.Contains(k, ":") {
					k += ":53"
				}
				activeSet[k] = true
			}
		}
		s.mu.RUnlock()
		if activeSet == nil {
			activeSet = make(map[string]bool)
		}

		type bankResolver struct {
			Addr    string  `json:"addr"`
			Score   float64 `json:"score"`
			Success int64   `json:"success"`
			Failure int64   `json:"failure"`
			AvgMs   float64 `json:"avgMs"`
			Active  bool    `json:"active"`
		}

		var bank []bankResolver
		for _, addr := range pl.ResolverBank {
			key := addr
			if !strings.Contains(key, ":") {
				key += ":53"
			}
			br := bankResolver{Addr: addr, Active: activeSet[key]}
			// Prefer live stats, fall back to saved scores.
			if liveStats != nil {
				if st, ok := liveStats[key]; ok {
					br.Success = st[0]
					br.Failure = st[1]
					if st[0] > 0 {
						br.AvgMs = float64(st[2]) / float64(st[0])
					}
					br.Score = computeResolverScore(st[0], st[1], st[2])
				} else if ss, ok := pl.ResolverScores[addr]; ok {
					br.Success = ss.Success
					br.Failure = ss.Failure
					if ss.Success > 0 {
						br.AvgMs = float64(ss.TotalMs) / float64(ss.Success)
					}
					br.Score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs)
				} else {
					br.Score = 0.2
				}
			} else if ss, ok := pl.ResolverScores[addr]; ok {
				br.Success = ss.Success
				br.Failure = ss.Failure
				if ss.Success > 0 {
					br.AvgMs = float64(ss.TotalMs) / float64(ss.Success)
				}
				br.Score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs)
			} else {
				br.Score = 0.2
			}
			bank = append(bank, br)
		}

		sort.Slice(bank, func(i, j int) bool { return bank[i].Score > bank[j].Score })
		writeJSON(w, map[string]any{"bank": bank, "count": len(pl.ResolverBank)})

	case http.MethodPost:
		var req struct {
			Resolvers []string `json:"resolvers"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON", 400)
			return
		}
		pl, _ := s.loadProfiles()
		if pl == nil {
			pl = &ProfileList{}
		}
		added := addToBank(pl, req.Resolvers)
		if err := s.saveProfiles(pl); err != nil {
			http.Error(w, "save failed", 500)
			return
		}
		// Update the fetcher's resolver pool.
		s.mu.RLock()
		f := s.fetcher
		s.mu.RUnlock()
		if f != nil {
			f.UpdateResolverPool(pl.ResolverBank)
		}
		writeJSON(w, map[string]any{"ok": true, "added": added, "total": len(pl.ResolverBank)})

	case http.MethodDelete:
		var req struct {
			Addrs []string `json:"addrs"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Addrs) == 0 {
			http.Error(w, "addrs required", 400)
			return
		}
		pl, _ := s.loadProfiles()
		if pl == nil {
			writeJSON(w, map[string]any{"ok": true, "removed": 0, "remaining": 0})
			return
		}
		removeSet := make(map[string]bool)
		for _, a := range req.Addrs {
			removeSet[a] = true
		}
		filtered := make([]string, 0, len(pl.ResolverBank))
		for _, r := range pl.ResolverBank {
			if !removeSet[r] {
				filtered = append(filtered, r)
			}
		}
		removed := len(pl.ResolverBank) - len(filtered)
		pl.ResolverBank = filtered
		for _, a := range req.Addrs {
			delete(pl.ResolverScores, a)
		}
		// Strip these resolvers from every saved active list — keeping
		// a list pointing at a resolver no longer in the bank would
		// surface as a "ghost" resolver the fetcher would try to use.
		listsTouched := pruneResolversFromLists(pl, removeSet)
		_ = s.saveProfiles(pl)
		s.mu.RLock()
		f := s.fetcher
		s.mu.RUnlock()
		if f != nil {
			f.UpdateResolverPool(pl.ResolverBank)
			// If the selected list lost members, reapply it so the
			// fetcher's active set matches the trimmed list.
			if listsTouched {
				if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) > 0 {
					f.SetActiveResolvers(list.Resolvers)
				}
			}
		}
		s.broadcast("event: update\ndata: \"resolver-lists\"\n\n")
		writeJSON(w, map[string]any{"ok": true, "removed": removed, "remaining": len(pl.ResolverBank)})

	default:
		http.Error(w, "method not allowed", 405)
	}
}

// handleResolverBankCleanup removes resolvers with score below a threshold.
func (s *Server) handleResolverBankCleanup(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var req struct {
		MinScore float64 `json:"minScore"`
		DryRun   bool    `json:"dryRun"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	if req.MinScore <= 0 {
		http.Error(w, "minScore must be > 0", 400)
		return
	}

	pl, _ := s.loadProfiles()
	if pl == nil {
		writeJSON(w, map[string]any{"ok": true, "removed": 0, "remaining": 0})
		return
	}

	// Get live stats for score computation.
	var liveStats map[string][3]int64
	s.mu.RLock()
	if s.fetcher != nil {
		liveStats = s.fetcher.ExportStats()
	}
	s.mu.RUnlock()

	var filtered []string
	removed := 0
	for _, addr := range pl.ResolverBank {
		key := addr
		if !strings.Contains(key, ":") {
			key += ":53"
		}
		var score float64
		if liveStats != nil {
			if st, ok := liveStats[key]; ok {
				score = computeResolverScore(st[0], st[1], st[2])
			} else if ss, ok := pl.ResolverScores[addr]; ok {
				score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs)
			} else {
				score = 0.2
			}
		} else if ss, ok := pl.ResolverScores[addr]; ok {
			score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs)
		} else {
			score = 0.2
		}
		if score >= req.MinScore {
			filtered = append(filtered, addr)
		} else {
			removed++
		}
	}

	if req.DryRun {
		writeJSON(w, map[string]any{"ok": true, "removed": removed, "remaining": len(filtered)})
		return
	}

	// Apply the cleanup.
	for _, addr := range pl.ResolverBank {
		key := addr
		if !strings.Contains(key, ":") {
			key += ":53"
		}
		var score float64
		if liveStats != nil {
			if st, ok := liveStats[key]; ok {
				score = computeResolverScore(st[0], st[1], st[2])
			} else if ss, ok := pl.ResolverScores[addr]; ok {
				score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs)
			} else {
				score = 0.2
			}
		} else if ss, ok := pl.ResolverScores[addr]; ok {
			score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs)
		} else {
			score = 0.2
		}
		if score < req.MinScore {
			delete(pl.ResolverScores, addr)
		}
	}
	// Build the removed-set for list pruning (addresses that didn't
	// make the score cut).
	removedSet := make(map[string]bool)
	keep := make(map[string]bool, len(filtered))
	for _, k := range filtered {
		keep[k] = true
	}
	for _, addr := range pl.ResolverBank {
		if !keep[addr] {
			removedSet[addr] = true
		}
	}
	pl.ResolverBank = filtered
	listsTouched := pruneResolversFromLists(pl, removedSet)
	_ = s.saveProfiles(pl)
	s.mu.RLock()
	f := s.fetcher
	s.mu.RUnlock()
	if f != nil {
		f.UpdateResolverPool(pl.ResolverBank)
		if listsTouched {
			if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) > 0 {
				f.SetActiveResolvers(list.Resolvers)
			}
		}
	}
	s.broadcast("event: update\ndata: \"resolver-lists\"\n\n")
	writeJSON(w, map[string]any{"ok": true, "removed": removed, "remaining": len(filtered)})
}

func (s *Server) saveConfig(cfg *Config) error {
	path := filepath.Join(s.dataDir, "config.json")
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0600)
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(v)
}

func (s *Server) loadProfiles() (*ProfileList, error) {
	path := filepath.Join(s.dataDir, "profiles.json")
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var pl ProfileList
	if err := json.Unmarshal(data, &pl); err != nil {
		return nil, err
	}
	return &pl, nil
}

// loadProfilesExisting returns (nil, nil) only when the file truly
// doesn't exist; other errors are surfaced so callers don't overwrite
// with an empty struct.
func (s *Server) loadProfilesExisting() (*ProfileList, error) {
	path := filepath.Join(s.dataDir, "profiles.json")
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var pl ProfileList
	if err := json.Unmarshal(data, &pl); err != nil {
		return nil, err
	}
	return &pl, nil
}

func (s *Server) saveProfiles(pl *ProfileList) error {
	if err := os.MkdirAll(s.dataDir, 0700); err != nil {
		return err
	}
	path := filepath.Join(s.dataDir, "profiles.json")
	data, err := json.MarshalIndent(pl, "", "  ")
	if err != nil {
		return err
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0600); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

func generateID() string {
	b := make([]byte, 8)
	rand.Read(b)
	return hex.EncodeToString(b)
}

// migrateResolverBank merges per-profile resolvers into the shared bank on first run.
func (s *Server) migrateResolverBank() {
	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		return
	}
	if len(pl.ResolverBank) > 0 {
		return // already migrated
	}
	seen := make(map[string]bool)
	for _, p := range pl.Profiles {
		for _, r := range p.Config.Resolvers {
			addr := r
			if !strings.Contains(addr, ":") {
				addr += ":53"
			}
			if !seen[addr] {
				seen[addr] = true
				pl.ResolverBank = append(pl.ResolverBank, addr)
			}
		}
	}
	if len(pl.ResolverBank) > 0 {
		for i := range pl.Profiles {
			pl.Profiles[i].Config.Resolvers = nil
		}
		_ = s.saveProfiles(pl)
	}
}

// addToBank adds resolvers to the shared bank (deduplicated, normalized with :53).
func addToBank(pl *ProfileList, resolvers []string) int {
	seen := make(map[string]bool)
	for _, r := range pl.ResolverBank {
		seen[r] = true
	}
	added := 0
	for _, r := range resolvers {
		addr := r
		if !strings.Contains(addr, ":") {
			addr += ":53"
		}
		if !seen[addr] {
			seen[addr] = true
			pl.ResolverBank = append(pl.ResolverBank, addr)
			added++
		}
	}
	return added
}

// persistResolverScores saves the current fetcher stats to profiles.json.
// Not serialised by profilesMu — initFetcher holds s.mu while calling this,
// and grabbing profilesMu here would risk AB-BA with handlers that take
// profilesMu first. The score map-merge is benign under last-writer-wins.
func (s *Server) persistResolverScores(stats map[string][3]int64) {
	if len(stats) == 0 {
		return
	}
	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		return
	}
	if pl.ResolverScores == nil {
		pl.ResolverScores = make(map[string]*SavedResolverScore)
	}
	for addr, st := range stats {
		pl.ResolverScores[addr] = &SavedResolverScore{
			Success: st[0],
			Failure: st[1],
			TotalMs: st[2],
		}
	}
	_ = s.saveProfiles(pl)
}

// computeResolverScore mirrors the scoring formula from fetcher.go.
func computeResolverScore(success, failure, totalMs int64) float64 {
	total := success + failure
	if total == 0 {
		return 0.2
	}
	successRate := float64(success) / float64(total)
	var avgMs float64
	if success > 0 {
		avgMs = float64(totalMs) / float64(success)
	} else {
		avgMs = 30000
	}
	score := successRate * successRate / (avgMs/5000.0 + 1.0)
	if score < 0.001 {
		score = 0.001
	}
	return score
}

// handleProfiles manages CRUD for config profiles.
// GET: returns profile list. POST: create/update/delete profiles.
func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		s.profilesMu.Lock()
		pl, err := s.loadProfilesExisting()
		if err != nil {
			s.profilesMu.Unlock()
			http.Error(w, fmt.Sprintf("load: %v", err), 500)
			return
		}
		if pl == nil {
			// First-run migration from config.json.
			pl = &ProfileList{}
			if s.config != nil {
				p := Profile{
					ID:       generateID(),
					Nickname: s.config.Domain,
					Config:   *s.config,
				}
				pl.Profiles = []Profile{p}
				pl.Active = p.ID
				_ = s.saveProfiles(pl)
			}
		}
		s.profilesMu.Unlock()
		writeJSON(w, pl)

	case http.MethodPost:
		var req struct {
			Action    string   `json:"action"` // "create", "update", "delete", "reorder"
			Profile   Profile  `json:"profile"`
			Order     []string `json:"order"` // for reorder
			SkipCheck bool     `json:"skipCheck"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON", 400)
			return
		}
		s.profilesMu.Lock()
		pl, err := s.loadProfilesExisting()
		if err != nil {
			s.profilesMu.Unlock()
			http.Error(w, fmt.Sprintf("load: %v", err), 500)
			return
		}
		if pl == nil {
			pl = &ProfileList{}
		}

		needsReinit := false

		switch req.Action {
		case "create":
			req.Profile.ID = generateID()
			if req.Profile.Nickname == "" {
				req.Profile.Nickname = req.Profile.Config.Domain
			}
			// Move resolvers to the shared bank.
			if len(req.Profile.Config.Resolvers) > 0 {
				addToBank(pl, req.Profile.Config.Resolvers)
				req.Profile.Config.Resolvers = nil
			}
			pl.Profiles = append(pl.Profiles, req.Profile)
			if len(pl.Profiles) == 1 {
				pl.Active = req.Profile.ID
				needsReinit = true
			}

		case "update":
			for i, p := range pl.Profiles {
				if p.ID == req.Profile.ID {
					// Move resolvers to the shared bank.
					if len(req.Profile.Config.Resolvers) > 0 {
						addToBank(pl, req.Profile.Config.Resolvers)
						req.Profile.Config.Resolvers = nil
					}
					// Carry over fields the edit-profile UI doesn't manage so
					// they don't get wiped on save (auto-update list etc.).
					req.Profile.AutoUpdate = p.AutoUpdate
					req.Profile.AutoUpdateInterval = p.AutoUpdateInterval
					pl.Profiles[i] = req.Profile
					if p.ID == pl.Active {
						needsReinit = true
					}
					break
				}
			}

		case "delete":
			for i, p := range pl.Profiles {
				if p.ID == req.Profile.ID {
					pl.Profiles = append(pl.Profiles[:i], pl.Profiles[i+1:]...)
					if pl.Active == req.Profile.ID {
						pl.Active = ""
						if len(pl.Profiles) > 0 {
							pl.Active = pl.Profiles[0].ID
							needsReinit = true
						}
					}
					s.dropChannelsCacheEntry(req.Profile.ID)
					break
				}
			}

		case "reorder":
			if len(req.Order) > 0 {
				ordered := make([]Profile, 0, len(pl.Profiles))
				byID := make(map[string]Profile)
				for _, p := range pl.Profiles {
					byID[p.ID] = p
				}
				for _, id := range req.Order {
					if p, ok := byID[id]; ok {
						ordered = append(ordered, p)
					}
				}
				pl.Profiles = ordered
			}

		default:
			s.profilesMu.Unlock()
			http.Error(w, "unknown action", 400)
			return
		}

		saveErr := s.saveProfiles(pl)
		var activeConfig *Config
		if needsReinit && pl.Active != "" {
			for _, p := range pl.Profiles {
				if p.ID == pl.Active {
					cfg := p.Config
					activeConfig = &cfg
					break
				}
			}
		}
		s.profilesMu.Unlock()

		if saveErr != nil {
			http.Error(w, fmt.Sprintf("save profiles: %v", saveErr), 500)
			return
		}

		// initFetcher takes s.mu — call it OUTSIDE profilesMu so handlers
		// that need both don't AB-BA against it.
		if activeConfig != nil {
			_ = s.saveConfig(activeConfig)
			s.mu.Lock()
			s.config = activeConfig
			s.mu.Unlock()
			if err := s.initFetcher(); err != nil {
				log.Printf("[web] re-init fetcher after profile change: %v", err)
			} else if req.SkipCheck {
				s.skipCheckerUseSaved()
			} else {
				s.startCheckerThenRefresh()
			}
		}

		s.broadcast("event: update\ndata: \"profiles\"\n\n")
		writeJSON(w, map[string]any{"ok": true, "profiles": pl})

	default:
		http.Error(w, "method not allowed", 405)
	}
}

// handleProfileSwitch switches the active profile and re-initializes the fetcher.
func (s *Server) handleProfileSwitch(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var req struct {
		ID        string `json:"id"`
		SkipCheck bool   `json:"skipCheck"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	s.profilesMu.Lock()
	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		s.profilesMu.Unlock()
		http.Error(w, "no profiles", 400)
		return
	}
	var found *Profile
	for i, p := range pl.Profiles {
		if p.ID == req.ID {
			found = &pl.Profiles[i]
			break
		}
	}
	if found == nil {
		s.profilesMu.Unlock()
		http.Error(w, "profile not found", 404)
		return
	}
	pl.Active = found.ID
	saveErr := s.saveProfiles(pl)
	s.profilesMu.Unlock()
	if err := saveErr; err != nil {
		http.Error(w, fmt.Sprintf("save: %v", err), 500)
		return
	}
	if err := s.saveConfig(&found.Config); err != nil {
		http.Error(w, fmt.Sprintf("save config: %v", err), 500)
		return
	}

	// Reset state and seed channels from the new profile's cache (if any).
	cc := s.loadChannelsCache()
	s.mu.Lock()
	s.config = &found.Config
	if cc != nil {
		s.channels = cc.Channels
		s.nextFetch = cc.NextFetch
	} else {
		s.channels = nil
	}
	s.messages = make(map[int][]protocol.Message)
	if s.relayInfo != nil {
		s.relayInfo.invalidate()
	}
	s.lastMsgIDs = make(map[int]uint32)
	s.lastHashes = make(map[int]uint32)
	s.mu.Unlock()
	// Tell every connected client (other tabs / devices) that the active
	// profile changed so they refresh their UI instead of pointing at the
	// old one.
	s.broadcast("event: update\ndata: \"profiles\"\n\n")
	if cc != nil {
		s.broadcast("event: update\ndata: \"channels\"\n\n")
	}

	if err := s.initFetcher(); err != nil {
		http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500)
		return
	}
	// Same fallback chain as boot: selected list → last_scan → full scan.
	if req.SkipCheck && s.applySelectedList() {
		s.mu.RLock()
		checker := s.checker
		ctx := s.fetcherCtx
		s.mu.RUnlock()
		if checker != nil && ctx != nil {
			checker.StartPeriodic(ctx)
		}
		go s.refreshMetadataOnly()
	} else if req.SkipCheck {
		s.skipCheckerUseSaved()
	} else {
		s.startCheckerThenRefresh()
	}
	writeJSON(w, map[string]any{"ok": true})
}

// handleAutoUpdate exposes the active profile's auto-update channel list.
// GET → {channels, intervalSeconds, defaultIntervalSeconds}.
// POST {channels, intervalSeconds?} replaces both. Names are stripped and
// dedup'd before saving.
func (s *Server) handleAutoUpdate(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		pl, _ := s.loadProfiles()
		channels := []string{}
		interval := 0
		if pl != nil && pl.Active != "" {
			for _, p := range pl.Profiles {
				if p.ID == pl.Active {
					if p.AutoUpdate != nil {
						channels = p.AutoUpdate
					}
					interval = p.AutoUpdateInterval
					break
				}
			}
		}
		writeJSON(w, map[string]any{
			"channels":               channels,
			"intervalSeconds":        interval,
			"defaultIntervalSeconds": int(minAutoUpdateInterval / time.Second),
		})

	case http.MethodPost:
		var req struct {
			Channels        []string `json:"channels"`
			IntervalSeconds *int     `json:"intervalSeconds,omitempty"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON", 400)
			return
		}
		pl, err := s.loadProfiles()
		if err != nil || pl == nil || pl.Active == "" {
			http.Error(w, "no active profile", 400)
			return
		}
		idx := -1
		for i, p := range pl.Profiles {
			if p.ID == pl.Active {
				idx = i
				break
			}
		}
		if idx < 0 {
			http.Error(w, "active profile not found", 400)
			return
		}
		pl.Profiles[idx].AutoUpdate = normaliseAutoUpdateList(req.Channels)
		if req.IntervalSeconds != nil {
			v := *req.IntervalSeconds
			if v < 0 {
				v = 0
			}
			minSec := int(minAutoUpdateInterval / time.Second)
			if v > 0 && v < minSec {
				v = minSec // floor: never poll faster than the server fetches
			}
			pl.Profiles[idx].AutoUpdateInterval = v
		}
		if err := s.saveProfiles(pl); err != nil {
			http.Error(w, fmt.Sprintf("save: %v", err), 500)
			return
		}
		writeJSON(w, map[string]any{
			"ok":              true,
			"channels":        pl.Profiles[idx].AutoUpdate,
			"intervalSeconds": pl.Profiles[idx].AutoUpdateInterval,
		})

	default:
		http.Error(w, "method not allowed", 405)
	}
}

// handleAutoUpdateToggle flips one channel's membership. Body {channel}.
// Returns {enabled, channels}.
func (s *Server) handleAutoUpdateToggle(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var req struct {
		Channel string `json:"channel"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON", 400)
		return
	}
	name := strings.TrimPrefix(strings.TrimSpace(req.Channel), "@")
	if name == "" {
		http.Error(w, "channel required", 400)
		return
	}
	pl, err := s.loadProfiles()
	if err != nil || pl == nil || pl.Active == "" {
		http.Error(w, "no active profile", 400)
		return
	}
	idx := -1
	for i, p := range pl.Profiles {
		if p.ID == pl.Active {
			idx = i
			break
		}
	}
	if idx < 0 {
		http.Error(w, "active profile not found", 400)
		return
	}
	current := pl.Profiles[idx].AutoUpdate
	on := false
	hit := -1
	for i, n := range current {
		if strings.TrimPrefix(strings.TrimSpace(n), "@") == name {
			hit = i
			break
		}
	}
	if hit >= 0 {
		current = append(current[:hit], current[hit+1:]...)
	} else {
		current = append(current, name)
		on = true
	}
	pl.Profiles[idx].AutoUpdate = normaliseAutoUpdateList(current)
	if err := s.saveProfiles(pl); err != nil {
		http.Error(w, fmt.Sprintf("save: %v", err), 500)
		return
	}
	writeJSON(w, map[string]any{
		"ok":       true,
		"channel":  name,
		"enabled":  on,
		"channels": pl.Profiles[idx].AutoUpdate,
	})
}

// normaliseAutoUpdateList strips @ + whitespace, drops empties, dedupes
// while preserving order.
func normaliseAutoUpdateList(in []string) []string {
	seen := make(map[string]bool, len(in))
	out := make([]string, 0, len(in))
	for _, raw := range in {
		name := strings.TrimPrefix(strings.TrimSpace(raw), "@")
		if name == "" || seen[name] {
			continue
		}
		seen[name] = true
		out = append(out, name)
	}
	return out
}

// handleSettings manages user preferences (font size etc.).
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		pl, _ := s.loadProfiles()
		if pl == nil {
			pl = &ProfileList{}
		}
		writeJSON(w, map[string]any{
			"fontSize":           pl.FontSize,
			"debug":              pl.Debug,
			"theme":              pl.Theme,
			"lang":               pl.Lang,
			"scanPromptOff":      pl.ScanPromptOff,
			"profilePicsEnabled": pl.ProfilePicsEnabled,
			"skipUpdateVersion":  pl.SkipUpdateVersion,
			"version":            version.Version,
			"commit":             version.Commit,
		})

	case http.MethodPost:
		// Optional pointers so partial requests don't reset other fields.
		var req struct {
			FontSize           *int    `json:"fontSize"`
			Debug              *bool   `json:"debug"`
			Theme              *string `json:"theme"`
			Lang               *string `json:"lang"`
			ScanPromptOff      *bool   `json:"scanPromptOff"`
			ProfilePicsEnabled *bool   `json:"profilePicsEnabled"`
			SkipUpdateVersion  *string `json:"skipUpdateVersion"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "invalid JSON", 400)
			return
		}
		s.profilesMu.Lock()
		defer s.profilesMu.Unlock()
		pl, err := s.loadProfilesExisting()
		if err != nil {
			http.Error(w, fmt.Sprintf("load: %v", err), 500)
			return
		}
		if pl == nil {
			pl = &ProfileList{}
		}
		if req.FontSize != nil {
			fs := *req.FontSize
			if fs < 10 {
				fs = 0
			}
			if fs > 24 {
				fs = 24
			}
			pl.FontSize = fs
		}
		if req.Debug != nil {
			pl.Debug = *req.Debug
		}
		if req.Theme != nil && (*req.Theme == "dark" || *req.Theme == "light") {
			pl.Theme = *req.Theme
		}
		if req.Lang != nil && (*req.Lang == "fa" || *req.Lang == "en") {
			pl.Lang = *req.Lang
		}
		if req.ScanPromptOff != nil {
			pl.ScanPromptOff = *req.ScanPromptOff
		}
		if req.ProfilePicsEnabled != nil {
			pl.ProfilePicsEnabled = *req.ProfilePicsEnabled
		}
		if req.SkipUpdateVersion != nil {
			pl.SkipUpdateVersion = *req.SkipUpdateVersion
		}
		if err := s.saveProfiles(pl); err != nil {
			http.Error(w, fmt.Sprintf("save: %v", err), 500)
			return
		}
		// Apply debug to the current fetcher session immediately.
		if req.Debug != nil {
			s.mu.RLock()
			f := s.fetcher
			s.mu.RUnlock()
			if f != nil {
				f.SetDebug(*req.Debug)
			}
			s.scanner.SetDebug(*req.Debug)
		}
		writeJSON(w, map[string]any{"ok": true})

	default:
		http.Error(w, "method not allowed", 405)
	}
}

func (s *Server) handleBgImage(w http.ResponseWriter, r *http.Request) {
	bgPath := filepath.Join(s.dataDir, "bg_image")
	switch r.Method {
	case http.MethodGet:
		data, err := os.ReadFile(bgPath)
		if err != nil {
			w.Header().Set("Content-Type", "application/octet-stream")
			w.WriteHeader(204)
			return
		}
		// Detect content type from file data.
		ct := http.DetectContentType(data)
		w.Header().Set("Content-Type", ct)
		w.Header().Set("Cache-Control", "no-cache")
		w.Write(data)

	case http.MethodPost:
		// Limit upload to 10 MB.
		r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
		data, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "file too large (max 10MB)", 413)
			return
		}
		ct := http.DetectContentType(data)
		if !strings.HasPrefix(ct, "image/") {
			http.Error(w, "not an image", 400)
			return
		}
		if err := os.WriteFile(bgPath, data, 0600); err != nil {
			http.Error(w, fmt.Sprintf("save: %v", err), 500)
			return
		}
		writeJSON(w, map[string]any{"ok": true})

	case http.MethodDelete:
		os.Remove(bgPath)
		writeJSON(w, map[string]any{"ok": true})

	default:
		http.Error(w, "method not allowed", 405)
	}
}

func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}

	v, err := s.checkLatestVersion(r.Context())
	if err != nil {
		if strings.Contains(err.Error(), "no config") {
			http.Error(w, "no config", 400)
			return
		}
		errStr := err.Error()
		if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") {
			http.Error(w, "invalid passphrase", 400)
			return
		}
		http.Error(w, fmt.Sprintf("version check failed: %v", err), 502)
		return
	}

	writeJSON(w, map[string]any{"ok": true, "latestVersion": v})
}

// handleGitHubUpdateCheck queries the public thefeed-files repo for the
// latest published client version and returns a download URL tailored
// to this binary's platform. Independent of the DNS-protocol version
// check above — works without a configured profile.
func (s *Server) handleGitHubUpdateCheck(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet && r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second)
	defer cancel()
	st, err := update.Check(ctx)
	if err != nil {
		http.Error(w, fmt.Sprintf("update check failed: %v", err), 502)
		return
	}
	writeJSON(w, st)
}

// =====================================================================
// Named active resolver lists
//
// Users keep one ResolverBank (the master pool) but many named subsets
// of it — typically one per network situation (home, office, mobile).
// Switching the selected list hot-swaps the fetcher's active resolvers
// without rescanning. When a resolver is removed from the bank it's
// removed from every list too, so a list never references a resolver
// that no longer exists in the master pool.
// =====================================================================

const defaultListName = "Default"

// persistScanResultsToList commits checker healthy results to the
// selected list when (a) the list is empty (bank-scan fallback) or
// (b) handleRescan asked for an overwrite. Re-pins the runtime pool
// and broadcasts so the open modal repopulates.
func (s *Server) persistScanResultsToList(healthy []string) {
	if len(healthy) == 0 {
		return
	}
	s.rescanFlagMu.Lock()
	overwrite := s.rescanReplaceList
	s.rescanReplaceList = false
	s.rescanFlagMu.Unlock()

	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		return
	}
	list := findList(pl, pl.SelectedList)
	if list == nil {
		// First scan with no lists yet — seed a Default list so the
		// UI doesn't show empty after the very first scan completes.
		pl.ActiveLists = append(pl.ActiveLists, ActiveList{Name: defaultListName})
		list = &pl.ActiveLists[len(pl.ActiveLists)-1]
		pl.SelectedList = defaultListName
	}
	// Don't shrink a populated list on routine periodic checks.
	if !overwrite && len(list.Resolvers) > 0 {
		return
	}
	list.Resolvers = append([]string(nil), healthy...)
	list.LastUsed = time.Now().Unix()
	if err := s.saveProfiles(pl); err != nil {
		s.addLog(fmt.Sprintf("save profiles after scan: %v", err))
		return
	}
	s.mu.RLock()
	f := s.fetcher
	s.mu.RUnlock()
	if f != nil {
		f.UpdateResolverPool(list.Resolvers)
	}
	s.addLog(fmt.Sprintf("resolvers: list %q populated with %d healthy resolvers", list.Name, len(healthy)))
	s.broadcast("event: update\ndata: \"resolver-lists\"\n\n")
}

// persistLastScanToProfiles seeds an empty selected list and/or empty
// bank from boot-time last_scan.json so the UI counts match the
// runtime fetcher's active set.
func (s *Server) persistLastScanToProfiles(resolvers []string) {
	if len(resolvers) == 0 {
		return
	}
	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		return
	}
	changed := false
	if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) == 0 {
		list.Resolvers = append([]string(nil), resolvers...)
		list.LastUsed = time.Now().Unix()
		changed = true
	}
	if len(pl.ResolverBank) == 0 {
		pl.ResolverBank = append([]string(nil), resolvers...)
		changed = true
	}
	if !changed {
		return
	}
	if err := s.saveProfiles(pl); err != nil {
		s.addLog(fmt.Sprintf("save profiles after last_scan boot: %v", err))
	}
}

// applySelectedList is the boot-time short-circuit. Returns true when
// a populated saved list was applied; false routes the caller to the
// last_scan.json / full-scan fallback chain.
func (s *Server) applySelectedList() bool {
	pl, err := s.loadProfiles()
	if err != nil || pl == nil {
		return false
	}
	migrated := s.migrateActiveLists(pl)
	if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) > 0 {
		s.mu.RLock()
		f := s.fetcher
		s.mu.RUnlock()
		if f != nil {
			f.UpdateResolverPool(list.Resolvers)
			f.SetActiveResolvers(list.Resolvers)
			list.LastUsed = time.Now().Unix()
			if err := s.saveProfiles(pl); err != nil {
				s.addLog(fmt.Sprintf("save profiles after select: %v", err))
			}
			s.addLog(fmt.Sprintf("resolvers: applied list %q (%d resolvers)", list.Name, len(list.Resolvers)))
			return true
		}
	}
	if migrated {
		_ = s.saveProfiles(pl)
	}
	return false
}

// migrateActiveLists fills in ActiveLists for installs that pre-date
// this feature. The current last_scan.json (or, failing that, the
// resolver bank) becomes a single list named "Default" so users keep
// their current setup. Returns true if a write is needed.
func (s *Server) migrateActiveLists(pl *ProfileList) bool {
	if pl == nil || len(pl.ActiveLists) > 0 {
		// Even if lists exist, make sure SelectedList points at one.
		if findList(pl, pl.SelectedList) == nil && len(pl.ActiveLists) > 0 {
			pl.SelectedList = pl.ActiveLists[0].Name
			return true
		}
		return false
	}
	var seed []string
	if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 {
		seed = ls.Resolvers
	} else if len(pl.ResolverBank) > 0 {
		seed = pl.ResolverBank
	}
	if len(seed) == 0 {
		return false
	}
	pl.ActiveLists = []ActiveList{{
		Name:      defaultListName,
		Resolvers: append([]string(nil), seed...),
		LastUsed:  time.Now().Unix(),
	}}
	pl.SelectedList = defaultListName
	return true
}

// findList returns a pointer into pl.ActiveLists so callers can mutate
// the entry directly. Match is case-insensitive on the trimmed name.
func findList(pl *ProfileList, name string) *ActiveList {
	if pl == nil || name == "" {
		return nil
	}
	for i := range pl.ActiveLists {
		if strings.EqualFold(strings.TrimSpace(pl.ActiveLists[i].Name), strings.TrimSpace(name)) {
			return &pl.ActiveLists[i]
		}
	}
	return nil
}

// pruneResolverFromLists removes resolver from every named list. Called
// after the user removes a resolver from the bank so dangling
// references don't outlive their entry. Returns true if any list was
// modified.
func pruneResolverFromLists(pl *ProfileList, resolver string) bool {
	if pl == nil || resolver == "" {
		return false
	}
	changed := false
	for i := range pl.ActiveLists {
		out := pl.ActiveLists[i].Resolvers[:0]
		for _, r := range pl.ActiveLists[i].Resolvers {
			if r == resolver {
				changed = true
				continue
			}
			out = append(out, r)
		}
		pl.ActiveLists[i].Resolvers = out
	}
	return changed
}

// pruneResolversFromLists removes a set of resolvers from every list in
// one pass — used by the bank cleanup path that drops many at once.
func pruneResolversFromLists(pl *ProfileList, removed map[string]bool) bool {
	if pl == nil || len(removed) == 0 {
		return false
	}
	changed := false
	for i := range pl.ActiveLists {
		out := pl.ActiveLists[i].Resolvers[:0]
		for _, r := range pl.ActiveLists[i].Resolvers {
			if removed[r] {
				changed = true
				continue
			}
			out = append(out, r)
		}
		pl.ActiveLists[i].Resolvers = out
	}
	return changed
}

// sanitizeListName normalises and length-caps user-supplied names.
// Returns "" if the cleaned name is empty.
func sanitizeListName(raw string) string {
	name := strings.TrimSpace(raw)
	if name == "" {
		return ""
	}
	if len(name) > 32 {
		name = name[:32]
	}
	return name
}

type resolverListOut struct {
	Name      string   `json:"name"`
	Count     int      `json:"count"`
	Resolvers []string `json:"resolvers,omitempty"`
	LastUsed  int64    `json:"lastUsed,omitempty"`
	Selected  bool     `json:"selected,omitempty"`
}

// handleResolverLists supports GET (enumerate), POST (create), DELETE
// (remove). Body for POST: {name, resolvers?}; if resolvers omitted,
// snapshot the currently-active resolvers under the new name.
//
// GET ?include=resolvers also serialises each list's full resolver
// address list — used by the share panel's "source = list X" picker.
func (s *Server) handleResolverLists(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		s.writeListsInfo(w, r.URL.Query().Get("include") == "resolvers")
	case http.MethodPost:
		var body struct {
			Name      string   `json:"name"`
			Resolvers []string `json:"resolvers,omitempty"`
		}
		if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&body); err != nil {
			http.Error(w, "bad json", 400)
			return
		}
		name := sanitizeListName(body.Name)
		if name == "" {
			http.Error(w, "name required", 400)
			return
		}
		pl, err := s.loadProfiles()
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		s.migrateActiveLists(pl)
		if findList(pl, name) != nil {
			http.Error(w, "list already exists", 409)
			return
		}
		resolvers := body.Resolvers
		if len(resolvers) == 0 {
			// Default: copy the currently-active resolvers so the user
			// can label whatever's working right now.
			s.mu.RLock()
			f := s.fetcher
			s.mu.RUnlock()
			if f != nil {
				resolvers = append([]string(nil), f.Resolvers()...)
			}
		}
		pl.ActiveLists = append(pl.ActiveLists, ActiveList{
			Name:      name,
			Resolvers: append([]string(nil), resolvers...),
			LastUsed:  time.Now().Unix(),
		})
		if err := s.saveProfiles(pl); err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		s.writeListsInfo(w)
	case http.MethodDelete:
		var body struct {
			Name string `json:"name"`
		}
		if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil {
			http.Error(w, "bad json", 400)
			return
		}
		name := sanitizeListName(body.Name)
		if name == "" {
			http.Error(w, "name required", 400)
			return
		}
		pl, err := s.loadProfiles()
		if err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		idx := -1
		for i := range pl.ActiveLists {
			if strings.EqualFold(strings.TrimSpace(pl.ActiveLists[i].Name), name) {
				idx = i
				break
			}
		}
		if idx < 0 {
			http.Error(w, "no such list", 404)
			return
		}
		if len(pl.ActiveLists) == 1 {
			http.Error(w, "cannot delete the only list", 400)
			return
		}
		removed := pl.ActiveLists[idx].Name
		pl.ActiveLists = append(pl.ActiveLists[:idx], pl.ActiveLists[idx+1:]...)
		// If the deleted list was selected, pick the most recently used
		// remaining list as the new selection and reapply.
		if strings.EqualFold(strings.TrimSpace(pl.SelectedList), removed) {
			best := 0
			for i := 1; i < len(pl.ActiveLists); i++ {
				if pl.ActiveLists[i].LastUsed > pl.ActiveLists[best].LastUsed {
					best = i
				}
			}
			pl.SelectedList = pl.ActiveLists[best].Name
			s.mu.RLock()
			f := s.fetcher
			s.mu.RUnlock()
			if f != nil {
				// Match handleResolverListSelect — pin both the pool
				// and active to the new list so the checker doesn't
				// re-broaden after the swap.
				f.UpdateResolverPool(pl.ActiveLists[best].Resolvers)
				f.SetActiveResolvers(pl.ActiveLists[best].Resolvers)
			}
		}
		if err := s.saveProfiles(pl); err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		s.writeListsInfo(w)
	default:
		http.Error(w, "method not allowed", 405)
	}
}

// handleResolverListSelect switches the active list and hot-swaps the
// fetcher's resolver pool. No probing — the user is choosing a list
// they already trust to work in this situation. NoScan disables the
// empty-list bank-scan fallback so the user can deliberately switch
// to an empty list without kicking off a probe.
func (s *Server) handleResolverListSelect(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var body struct {
		Name   string `json:"name"`
		NoScan bool   `json:"noScan"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil {
		http.Error(w, "bad json", 400)
		return
	}
	name := sanitizeListName(body.Name)
	if name == "" {
		http.Error(w, "name required", 400)
		return
	}
	pl, err := s.loadProfiles()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	list := findList(pl, name)
	if list == nil {
		http.Error(w, "no such list", 404)
		return
	}
	pl.SelectedList = list.Name
	list.LastUsed = time.Now().Unix()
	if err := s.saveProfiles(pl); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	s.mu.RLock()
	f := s.fetcher
	checker := s.checker
	s.mu.RUnlock()
	// Cancel any in-progress bank scan first — the user is asking for
	// a different pool, so probing the previous one is wasted work.
	if checker != nil {
		checker.CancelCurrentScan()
	}
	if f != nil {
		switch {
		case len(list.Resolvers) > 0:
			f.UpdateResolverPool(list.Resolvers)
			f.SetActiveResolvers(list.Resolvers)
			s.addLog(fmt.Sprintf("resolvers: switched to list %q (%d resolvers)", list.Name, len(list.Resolvers)))
			go s.refreshMetadataOnly()
		case !body.NoScan && len(pl.ResolverBank) > 0:
			// Empty list, scan opted in: probe the bank as fallback.
			// CheckNow rather than StartAndNotify because the latter
			// has a one-shot guard that's already tripped post-boot.
			f.UpdateResolverPool(pl.ResolverBank)
			f.SetActiveResolvers(nil)
			s.addLog(fmt.Sprintf("resolvers: list %q is empty — scanning bank (%d) as fallback", list.Name, len(pl.ResolverBank)))
			s.mu.RLock()
			ctx := s.fetcherCtx
			s.mu.RUnlock()
			if checker != nil && ctx != nil {
				go func() {
					if checker.CheckNow(ctx) {
						s.refreshMetadataOnly()
					}
				}()
			}
		case body.NoScan:
			f.UpdateResolverPool(nil)
			f.SetActiveResolvers(nil)
			s.addLog(fmt.Sprintf("resolvers: switched to empty list %q (scan declined)", list.Name))
		default:
			f.UpdateResolverPool(nil)
			f.SetActiveResolvers(nil)
			s.addLog(fmt.Sprintf("resolvers: list %q is empty and bank is empty — run scanner first", list.Name))
		}
	}
	s.writeListsInfo(w)
}

// handleResolverListSave snapshots the currently-active fetcher
// resolvers into a list. Mode "create" fails if the name already
// exists; mode "overwrite" replaces an existing list's resolvers.
func (s *Server) handleResolverListSave(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var body struct {
		Name string `json:"name"`
		Mode string `json:"mode"` // "create" (default) or "overwrite"
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil {
		http.Error(w, "bad json", 400)
		return
	}
	name := sanitizeListName(body.Name)
	if name == "" {
		http.Error(w, "name required", 400)
		return
	}
	s.mu.RLock()
	f := s.fetcher
	s.mu.RUnlock()
	if f == nil {
		http.Error(w, "not configured", 400)
		return
	}
	resolvers := append([]string(nil), f.Resolvers()...)
	if len(resolvers) == 0 {
		http.Error(w, "no active resolvers to save", 400)
		return
	}
	pl, err := s.loadProfiles()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	s.migrateActiveLists(pl)
	existing := findList(pl, name)
	if existing != nil {
		if body.Mode != "overwrite" {
			http.Error(w, "list already exists", 409)
			return
		}
		existing.Resolvers = resolvers
		existing.LastUsed = time.Now().Unix()
	} else {
		pl.ActiveLists = append(pl.ActiveLists, ActiveList{
			Name:      name,
			Resolvers: resolvers,
			LastUsed:  time.Now().Unix(),
		})
	}
	if err := s.saveProfiles(pl); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	s.writeListsInfo(w)
}

// handleResolverListAdd appends resolvers (typically picked from the
// Bank tab) to a named list. Deduped; hot-swaps the fetcher pool when
// the list is currently selected.
func (s *Server) handleResolverListAdd(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var body struct {
		Name      string   `json:"name"`
		Resolvers []string `json:"resolvers"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&body); err != nil {
		http.Error(w, "bad json", 400)
		return
	}
	name := sanitizeListName(body.Name)
	if name == "" || len(body.Resolvers) == 0 {
		http.Error(w, "name and resolvers required", 400)
		return
	}
	pl, err := s.loadProfiles()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	list := findList(pl, name)
	if list == nil {
		http.Error(w, "no such list", 404)
		return
	}
	seen := map[string]bool{}
	for _, r := range list.Resolvers {
		seen[r] = true
	}
	added := 0
	for _, r := range body.Resolvers {
		r = strings.TrimSpace(r)
		if r == "" || seen[r] {
			continue
		}
		list.Resolvers = append(list.Resolvers, r)
		seen[r] = true
		added++
	}
	list.LastUsed = time.Now().Unix()
	if err := s.saveProfiles(pl); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	if added > 0 && strings.EqualFold(strings.TrimSpace(pl.SelectedList), name) {
		s.mu.RLock()
		f := s.fetcher
		s.mu.RUnlock()
		if f != nil {
			// Pool widens AND active gains the new entries —
			// UpdateResolverPool alone only prunes active to the new
			// pool, never adds, so the freshly-added resolver would
			// stay invisible in the Active panel until a checker tick.
			f.UpdateResolverPool(list.Resolvers)
			f.SetActiveResolvers(list.Resolvers)
		}
	}
	s.broadcast("event: update\ndata: \"resolver-lists\"\n\n")
	writeJSON(w, map[string]any{"ok": true, "added": added, "count": len(list.Resolvers)})
}

// handleResolverListRename changes a list's display name. Body:
// {name, newName}. The selection pointer follows the rename.
func (s *Server) handleResolverListRename(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	var body struct {
		Name    string `json:"name"`
		NewName string `json:"newName"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil {
		http.Error(w, "bad json", 400)
		return
	}
	from := sanitizeListName(body.Name)
	to := sanitizeListName(body.NewName)
	if from == "" || to == "" {
		http.Error(w, "name and newName required", 400)
		return
	}
	if strings.EqualFold(from, to) {
		writeJSON(w, map[string]any{"ok": true})
		return
	}
	pl, err := s.loadProfiles()
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	if findList(pl, to) != nil {
		http.Error(w, "name already used", 409)
		return
	}
	list := findList(pl, from)
	if list == nil {
		http.Error(w, "no such list", 404)
		return
	}
	list.Name = to
	if strings.EqualFold(strings.TrimSpace(pl.SelectedList), from) {
		pl.SelectedList = to
	}
	if err := s.saveProfiles(pl); err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	s.writeListsInfo(w)
}

// writeListsInfo serialises the named lists. includeResolvers=true
// expands each entry with its full address list (used by share panel;
// regular polls skip it to keep responses small).
func (s *Server) writeListsInfo(w http.ResponseWriter, includeResolvers ...bool) {
	pl, _ := s.loadProfiles()
	out := struct {
		Selected string            `json:"selected"`
		Lists    []resolverListOut `json:"lists"`
	}{}
	if pl == nil {
		writeJSON(w, out)
		return
	}
	withAddrs := len(includeResolvers) > 0 && includeResolvers[0]
	out.Selected = pl.SelectedList
	for _, l := range pl.ActiveLists {
		entry := resolverListOut{
			Name:     l.Name,
			Count:    len(l.Resolvers),
			LastUsed: l.LastUsed,
			Selected: strings.EqualFold(strings.TrimSpace(l.Name), strings.TrimSpace(pl.SelectedList)),
		}
		if withAddrs {
			entry.Resolvers = append([]string(nil), l.Resolvers...)
		}
		out.Lists = append(out.Lists, entry)
	}
	writeJSON(w, out)
}

// runMediaCacheSweep evicts expired media-cache entries every hour for the
// lifetime of the process.
func (s *Server) runMediaCacheSweep() {
	ticker := time.NewTicker(1 * time.Hour)
	defer ticker.Stop()
	for range ticker.C {
		if s.mediaCache == nil {
			return
		}
		s.mediaCache.Cleanup()
	}
}

// handleClearCache wipes both the per-channel message cache and the
// downloaded-media disk cache.
func (s *Server) handleClearCache(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", 405)
		return
	}
	deleted := 0
	cacheDir := filepath.Join(s.dataDir, "cache")
	if entries, err := os.ReadDir(cacheDir); err == nil {
		for _, e := range entries {
			if e.IsDir() {
				continue
			}
			if os.Remove(filepath.Join(cacheDir, e.Name())) == nil {
				deleted++
			}
		}
	}
	if s.telemirror != nil {
		s.telemirror.ClearCache()
	}
	if s.profilePics != nil {
		s.profilePics.Clear()
	}
	mediaDeleted := 0
	if s.mediaCache != nil {
		mediaDeleted = s.mediaCache.Clear()
	}
	_ = os.Remove(s.channelsCachePath())
	// Reset in-memory message state too so refreshChannel's "no changes"
	// guard doesn't skip the next fetch (prev IDs match what's on the
	// server, but our cache is gone).
	s.mu.Lock()
	s.messages = make(map[int][]protocol.Message)
	s.lastMsgIDs = make(map[int]uint32)
	s.lastHashes = make(map[int]uint32)
	s.mu.Unlock()
	s.addLog(fmt.Sprintf("Cache cleared: %d message files, %d media files", deleted, mediaDeleted))
	writeJSON(w, map[string]any{"ok": true, "deleted": deleted, "mediaDeleted": mediaDeleted})
}
