package web

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"time"

	"pete/internal/safehttp"
	"pete/internal/storage"

	webpush "github.com/SherClockHolmes/webpush-go"
)

// digestScan caps how many new stories the sender inspects per subscriber in one
// pass. Well past MinStories; the digest only needs a count and one headline.
const digestScan = 60

// pushSendTimeout bounds one push delivery. The endpoint is user-supplied, so a
// hostile or dead push service must not be able to wedge the (serial) digest
// loop and starve every other subscriber.
const pushSendTimeout = 15 * time.Second

// runPushSender periodically builds and delivers a "N new stories" digest to
// each subscriber, respecting their disabled-sources preference. It's started
// only when push is configured. Best-effort throughout: a failed send never
// stops the loop, and a permanently-gone endpoint is pruned.
func (s *Server) runPushSender(ctx context.Context) {
	interval := time.Duration(s.cfg.Push.IntervalMinutes) * time.Minute
	if interval <= 0 {
		interval = 6 * time.Hour
	}
	slog.Info("web: push digest sender started", "interval", interval, "min_stories", s.cfg.Push.MinStories)
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			s.sendDigests()
		}
	}
}

// sendDigests walks every subscription once, notifying those with enough new
// stories since their last digest.
func (s *Server) sendDigests() {
	subs, err := storage.ListPushSubscriptions()
	if err != nil {
		slog.Error("push: list subscriptions failed", "err", err)
		return
	}
	if len(subs) == 0 {
		return
	}
	// Disabled-source sets are per user; cache within a pass so a user with
	// several devices only parses prefs once.
	disabledByUser := make(map[string]map[string]bool)
	sent, pruned := 0, 0

	for _, sub := range subs {
		stories, err := storage.NewClassifiedSince(sub.LastNotifiedAt, digestScan)
		if err != nil {
			slog.Error("push: scan new stories failed", "sub", sub.UserSub, "err", err)
			continue
		}
		if len(stories) == 0 {
			continue
		}
		disabled, ok := disabledByUser[sub.UserSub]
		if !ok {
			disabled = disabledSourcesFor(sub.UserSub)
			disabledByUser[sub.UserSub] = disabled
		}
		count, top := 0, ""
		for _, st := range stories {
			if disabled[st.Source] {
				continue
			}
			if count == 0 {
				top = st.Headline
			}
			count++
		}
		if count < s.cfg.Push.MinStories {
			// If a full scan window filled entirely with stories the user has
			// hidden, the non-hidden count can stay below the threshold forever
			// while the watermark never advances — the same hidden window is
			// re-scanned every pass and the subscriber is permanently starved of
			// digests. When the window was capped (a genuine glut, not a quiet
			// spell), step the watermark past it so the next pass sees fresh
			// stories. A non-full window is a real lull; leave it to accumulate.
			if len(stories) == digestScan {
				if derr := storage.TouchPushSubscription(sub.Endpoint, stories[0].SeenAt); derr != nil {
					slog.Error("push: advance watermark past hidden glut failed", "err", derr)
				}
			}
			continue
		}

		payload := buildDigestPayload(count, top)
		gone, err := s.sendPush(sub, payload)
		if gone {
			if derr := storage.RemovePushSubscription(sub.Endpoint); derr != nil {
				slog.Error("push: prune gone subscription failed", "err", derr)
			} else {
				pruned++
			}
			continue
		}
		if err != nil {
			slog.Warn("push: send failed", "sub", sub.UserSub, "err", err)
			continue
		}
		// Advance to the newest story this digest actually accounted for, not a
		// pass-start "now": the loop can run for minutes (one slow endpoint per
		// send), so stories arriving mid-pass would otherwise be re-counted next
		// pass. stories[0] is the newest in the scanned window (seen_at DESC).
		if derr := storage.TouchPushSubscription(sub.Endpoint, stories[0].SeenAt); derr != nil {
			slog.Error("push: advance watermark failed", "err", derr)
		}
		sent++
	}
	if sent > 0 || pruned > 0 {
		slog.Info("push: digest pass complete", "sent", sent, "pruned", pruned, "subscriptions", len(subs))
	}
}

// buildDigestPayload renders the notification JSON the service worker expects.
func buildDigestPayload(count int, top string) []byte {
	body := fmt.Sprintf("%d new stories", count)
	if count == 1 {
		body = "1 new story"
	}
	if top != "" {
		body += ": " + top
	}
	b, _ := json.Marshal(map[string]string{
		"title": "Pete has fresh news",
		"body":  body,
		"url":   "/",
		"tag":   "pete-digest",
	})
	return b
}

// sendPush encrypts and delivers one notification. It reports gone=true when the
// push service says the endpoint no longer exists (404/410) so the caller can
// prune it; err is set for other, likely-transient failures.
func (s *Server) sendPush(sub storage.PushSubscription, payload []byte) (gone bool, err error) {
	resp, err := webpush.SendNotification(payload, &webpush.Subscription{
		Endpoint: sub.Endpoint,
		Keys:     webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
	}, &webpush.Options{
		// The endpoint URL comes from the browser and is attacker-influenceable,
		// so deliver through the SSRF-guarded client (blocks loopback/RFC1918/
		// link-local/metadata targets) with a hard timeout — never the library's
		// unguarded default http.Client.
		HTTPClient:      s.pushClient(),
		Subscriber:      s.cfg.Push.Subject,
		VAPIDPublicKey:  s.cfg.Push.VAPIDPublicKey,
		VAPIDPrivateKey: s.cfg.Push.VAPIDPrivateKey,
		TTL:             24 * 60 * 60,
		Urgency:         webpush.UrgencyNormal,
	})
	if err != nil {
		return false, err
	}
	defer resp.Body.Close()
	if resp.StatusCode == 404 || resp.StatusCode == 410 {
		return true, nil
	}
	if resp.StatusCode >= 400 {
		return false, fmt.Errorf("push service returned %d", resp.StatusCode)
	}
	return false, nil
}

// disabledSourcesFor returns the set of source names a user has hidden, read
// from their stored prefs blob. The blob mirrors localStorage: a JSON object
// whose "pete.disabledSources.v1" value is itself a JSON string encoding a
// {sourceName: true} map. Any parse failure yields an empty (deny-nothing) set.
func disabledSourcesFor(sub string) map[string]bool {
	out := map[string]bool{}
	blob, err := storage.GetUserPrefs(sub)
	if err != nil || blob == "" {
		return out
	}
	var prefs map[string]json.RawMessage
	if err := json.Unmarshal([]byte(blob), &prefs); err != nil {
		return out
	}
	raw, ok := prefs["pete.disabledSources.v1"]
	if !ok {
		return out
	}
	// The value is normally a JSON *string* containing JSON; unwrap that first,
	// but tolerate a bare object too.
	inner := []byte(raw)
	var asStr string
	if err := json.Unmarshal(raw, &asStr); err == nil {
		inner = []byte(asStr)
	}
	var set map[string]bool
	if err := json.Unmarshal(inner, &set); err != nil {
		return out
	}
	for name, on := range set {
		if on {
			out[name] = true
		}
	}
	return out
}

// pushClient returns the shared SSRF-guarded, timeout-bounded HTTP client used
// for Web Push delivery, building it once on first use. The digest loop is a
// single goroutine, so the lazy init needs no lock.
func (s *Server) pushClient() *http.Client {
	if s.pushHTTP == nil {
		s.pushHTTP = safehttp.NewClient(pushSendTimeout)
	}
	return s.pushHTTP
}

// StartPushSender launches the digest loop if push is enabled. Safe to call
// unconditionally; it's a no-op when push is off.
func (s *Server) StartPushSender(ctx context.Context) {
	if !s.cfg.Push.Enabled || s.auth == nil {
		return
	}
	go s.runPushSender(ctx)
}
