// Package push delivers Web Push (RFC 8291/8292) notifications to a user's
// browser / PWA devices whose page is closed — i.e. has no live SSE connection.
// The "page open → in-app toast, page closed → system notification" rule lives
// at the call sites: each notify-worthy event is first handed to hub.SendTo; the
// push fallback fires only when SendTo reports the device offline.
//
// Notification text is rendered here, per subscription, in the device's stored
// locale (the Service Worker only displays the title/body it receives). Callers
// describe intent (Type + Params), not pre-rendered strings.
//
// Desktop clients never appear here — their Go process is resident and decides
// locally (window visibility) whether to raise a native OS notification.
package push

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"time"

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

	"commilitia.net/cdrop/internal/db"
)

// pushTimeout bounds a single Notify call (all of one device's subscriptions),
// so a hung push service can't leak goroutines when callers fire-and-forget.
const pushTimeout = 15 * time.Second

// notificationTTL is how long the push service should retain an undelivered
// message before dropping it. A day is plenty for "you have an incoming file":
// past that the transfer has long since expired anyway.
const notificationTTL = 24 * 60 * 60

// Notification kinds. These also travel to the SW as the wire `type` so the
// client can route a notificationclick (e.g. deep-link to a transfer).
const (
	KindTransferIncoming = "transfer:incoming"
	KindTransferDone     = "transfer:done"
	KindTransferFailed   = "transfer:failed"
	KindMessage          = "message"
)

// Notification is an intent to notify; the sender localizes it per subscription.
// Params carries the substitution values a kind needs:
//   - transfer:incoming → {sender, filename}
//   - transfer:done / transfer:failed → {filename}
//   - message → {sender, text}
type Notification struct {
	Type   string
	Params map[string]string
	Tag    string // client-side collapse key; same tag replaces in place
	URL    string // where notificationclick navigates; defaults to "/"
}

// wirePayload is the JSON the Service Worker receives and shows verbatim.
type wirePayload struct {
	Type  string `json:"type"`
	Title string `json:"title"`
	Body  string `json:"body"`
	Tag   string `json:"tag,omitempty"`
	URL   string `json:"url"`
}

// Sender delivers notifications and prunes dead subscriptions. A Sender built
// without VAPID keys is inert: Enabled reports false and Notify is a no-op, so
// callers can invoke it unconditionally. Safe for concurrent use.
type Sender struct {
	queries *db.Queries
	pubKey  string
	privKey string
	subject string
	client  *http.Client
}

// NewSender returns a Sender. With either VAPID key empty it is inert (Web Push
// disabled). subject is the VAPID contact identity (a mailto: or site URL).
func NewSender(queries *db.Queries, pubKey, privKey, subject string) *Sender {
	if pubKey == "" || privKey == "" {
		return &Sender{}
	}
	return &Sender{
		queries: queries,
		pubKey:  pubKey,
		privKey: privKey,
		subject: subject,
		client:  &http.Client{Timeout: pushTimeout},
	}
}

// Enabled reports whether Web Push is configured.
func (s *Sender) Enabled() bool { return s != nil && s.privKey != "" }

// PublicKey returns the VAPID public key the browser needs as
// applicationServerKey when subscribing. Empty when disabled.
func (s *Sender) PublicKey() string {
	if s == nil {
		return ""
	}
	return s.pubKey
}

// Notify delivers n to every push subscription of (userID, deviceName).
// Best-effort and safe to call in a goroutine: failures are logged, a
// subscription the push service reports gone (404/410) is pruned, and a no-op
// results when disabled or the device has no subscriptions.
func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n Notification) {
	if !s.Enabled() {
		return
	}

	ctx, cancel := context.WithTimeout(ctx, pushTimeout)
	defer cancel()

	subs, err := s.queries.ListPushSubscriptionsByUserDevice(ctx, db.ListPushSubscriptionsByUserDeviceParams{
		UserID:     userID,
		DeviceName: deviceName,
	})
	if err != nil {
		slog.Error("push: list subscriptions failed",
			"user", userID, "device", deviceName, "err", err)
		return
	}

	for _, sub := range subs {
		payload, err := json.Marshal(render(n, sub.Locale))
		if err != nil {
			slog.Error("push: marshal payload failed", "err", err)
			continue
		}
		s.send(ctx, sub, payload)
	}
}

func (s *Sender) send(ctx context.Context, sub db.PushSubscription, payload []byte) {
	resp, err := webpush.SendNotificationWithContext(ctx, payload, &webpush.Subscription{
		Endpoint: sub.Endpoint,
		Keys:     webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
	}, &webpush.Options{
		HTTPClient:      s.client,
		Subscriber:      s.subject,
		VAPIDPublicKey:  s.pubKey,
		VAPIDPrivateKey: s.privKey,
		TTL:             notificationTTL,
		Urgency:         webpush.UrgencyHigh,
	})
	if err != nil {
		slog.Warn("push: send failed", "endpoint", endpointHost(sub.Endpoint), "err", err)
		return
	}
	defer resp.Body.Close()

	switch {
	case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
		// 404/410 means the subscription is permanently gone (the user cleared
		// site data, the browser rotated the endpoint, the PWA was deleted).
		// Pruning keeps the table from accreting dead rows we'd retry forever.
		if err := s.queries.DeletePushSubscription(ctx, sub.ID); err != nil {
			slog.Error("push: prune dead subscription failed", "id", sub.ID, "err", err)
		}
	case resp.StatusCode >= 200 && resp.StatusCode < 300:
		_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
			LastUsedAt: time.Now().Unix(),
			ID:         sub.ID,
		})
	default:
		slog.Warn("push: unexpected status",
			"status", resp.StatusCode, "endpoint", endpointHost(sub.Endpoint))
	}
}

// SubscriptionID is the primary key for a subscription: the hex SHA-256 of its
// endpoint. Hashing makes re-subscribe idempotent and keeps the (long, opaque)
// endpoint out of the PK while staying deterministic for upsert/delete.
func SubscriptionID(endpoint string) string {
	sum := sha256.Sum256([]byte(endpoint))
	return hex.EncodeToString(sum[:])
}

// endpointHost reduces an endpoint URL to its host for logging — the full path
// is an unguessable capability we should not write to logs.
func endpointHost(endpoint string) string {
	u, err := url.Parse(endpoint)
	if err != nil || u.Host == "" {
		return "?"
	}
	return u.Host
}

const defaultLocale = "zh-CN"

// notifyStrings holds the few server-rendered notification templates, keyed by
// locale then string id. The set mirrors the frontend i18n only for these push
// strings; message bodies are user text and are never templated here.
var notifyStrings = map[string]map[string]string{
	"zh-CN": {
		"incoming.title": "收到文件",
		"incoming.body":  "%s 发来 %s",
		"done.title":     "传输完成",
		"failed.title":   "传输失败",
	},
	"zh-TW": {
		"incoming.title": "收到檔案",
		"incoming.body":  "%s 傳來 %s",
		"done.title":     "傳輸完成",
		"failed.title":   "傳輸失敗",
	},
	"en-US": {
		"incoming.title": "Incoming file",
		"incoming.body":  "%s is sending %s",
		"done.title":     "Transfer complete",
		"failed.title":   "Transfer failed",
	},
}

// render turns an intent into the wire payload, localized to the subscription's
// stored locale. Unknown locales fall back to defaultLocale.
func render(n Notification, locale string) wirePayload {
	title, body := localize(n.Type, n.Params, locale)
	out := wirePayload{Type: n.Type, Title: title, Body: body, Tag: n.Tag, URL: n.URL}
	if out.URL == "" {
		out.URL = "/"
	}
	return out
}

func localize(typ string, params map[string]string, locale string) (title, body string) {
	// A message's title is the sender's device name and its body is the user's
	// own text — neither is a template, so locale is irrelevant.
	if typ == KindMessage {
		return params["sender"], params["text"]
	}

	t, ok := notifyStrings[locale]
	if !ok {
		t = notifyStrings[defaultLocale]
	}
	switch typ {
	case KindTransferIncoming:
		return t["incoming.title"], fmt.Sprintf(t["incoming.body"], params["sender"], params["filename"])
	case KindTransferDone:
		return t["done.title"], params["filename"]
	case KindTransferFailed:
		return t["failed.title"], params["filename"]
	default:
		return params["title"], params["body"]
	}
}
