// Package apns delivers Apple Push Notification service (APNs) notifications to
// iOS devices whose SSE connection is closed. It mirrors the shape of the push
// (Web Push) package: an inert Sender when unconfigured, per-subscription
// localization via the shared push.Localize machinery, and automatic pruning of
// dead device tokens on HTTP 410 (Unregistered) responses.
//
// APNs uses provider-token authentication: a cached ES256 JWT signed with the
// team's .p8 key, refreshed every ~50 minutes (Apple requires 20-60 minutes).
// HTTP/2 is negotiated automatically by Go's net/http Transport over TLS.
package apns

import (
	"bytes"
	"context"
	"crypto/ecdsa"
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/golang-jwt/jwt/v5"

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

// apnsTimeout bounds a single Notify call (including all subscriptions for one
// device), so a hung APNs endpoint cannot leak goroutines.
const apnsTimeout = 15 * time.Second

// jwtRefreshAge is how long we cache a provider JWT before minting a fresh one.
// Apple requires a new token between 20 and 60 minutes; 50 minutes gives a safe margin.
const jwtRefreshAge = 50 * time.Minute

// prodBase and sandboxBase are the APNs HTTP/2 endpoints.
const (
	prodBase    = "https://api.push.apple.com"
	sandboxBase = "https://api.sandbox.push.apple.com"
)

// apsPayload is the JSON body for an APNs alert notification.
type apsPayload struct {
	APS  apsEnvelope `json:"aps"`
	Type string      `json:"type"`
	URL  string      `json:"url"`
}

type apsEnvelope struct {
	Alert apsAlert `json:"alert"`
	Sound string   `json:"sound"`
}

type apsAlert struct {
	Title string `json:"title"`
	Body  string `json:"body,omitempty"`
}

// Sender delivers APNs notifications and prunes dead device tokens. A Sender
// built without all required config (key/keyID/teamID/topic) is inert: Enabled
// reports false and Notify is a no-op. Safe for concurrent use.
type Sender struct {
	queries *db.Queries
	key     *ecdsa.PrivateKey
	keyID   string
	teamID  string
	topic   string
	base    string // APNs base URL; settable for tests
	client  *http.Client

	mu          sync.Mutex
	cachedJWT   string
	jwtMintedAt time.Time
}

// NewSender returns a configured APNs Sender. keyPEM is the .p8 PKCS8 EC private
// key Apple issues. env is "prod" (default) or "sandbox". When any of keyPEM,
// keyID, teamID, or topic is empty/nil the returned Sender is inert (Enabled
// returns false, Notify is a no-op) so callers can invoke it unconditionally.
// A PEM parse failure is logged and also yields an inert Sender rather than a
// hard error, so startup can proceed with APNs disabled.
func NewSender(queries *db.Queries, keyPEM []byte, keyID, teamID, topic, env string) *Sender {
	if len(keyPEM) == 0 || keyID == "" || teamID == "" || topic == "" {
		return &Sender{}
	}

	block, _ := pem.Decode(keyPEM)
	if block == nil {
		slog.Error("apns: cannot decode PEM block from .p8 key; APNs disabled")
		return &Sender{}
	}
	raw, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		slog.Error("apns: cannot parse PKCS8 private key; APNs disabled", "err", err)
		return &Sender{}
	}
	ecKey, ok := raw.(*ecdsa.PrivateKey)
	if !ok {
		slog.Error("apns: .p8 key is not an ECDSA private key; APNs disabled")
		return &Sender{}
	}

	base := prodBase
	switch strings.ToLower(strings.TrimSpace(env)) {
	case "sandbox":
		base = sandboxBase
	case "", "prod", "production":
		base = prodBase
	default:
		// Unknown env is an operator typo; default to prod but log loudly so the
		// sandbox/prod mismatch footgun (dev tokens → prod endpoint → BadDeviceToken)
		// surfaces instead of silently selecting the wrong endpoint.
		slog.Warn("apns: unknown CDROP_APNS_ENV, defaulting to production", "env", env)
	}

	return &Sender{
		queries: queries,
		key:     ecKey,
		keyID:   keyID,
		teamID:  teamID,
		topic:   topic,
		base:    base,
		client:  &http.Client{Timeout: apnsTimeout},
	}
}

// Enabled reports whether APNs is configured.
func (s *Sender) Enabled() bool { return s != nil && s.key != nil }

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

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

	subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(ctx, db.ListAPNsSubscriptionsByUserDeviceParams{
		UserID:     userID,
		DeviceName: deviceName,
	})
	if err != nil {
		slog.Error("apns: list subscriptions failed",
			"user", userID, "device", deviceName, "err", err)
		return
	}
	if len(subs) == 0 {
		return
	}

	jwtStr, err := s.providerJWT()
	if err != nil {
		slog.Error("apns: jwt signing failed", "err", err)
		return
	}

	for _, sub := range subs {
		title, body := push.Localize(n.Type, n.Params, sub.Locale)
		url := n.URL
		if url == "" {
			url = "/"
		}
		payload := apsPayload{
			APS: apsEnvelope{
				Alert: apsAlert{Title: title, Body: body},
				Sound: "default",
			},
			Type: n.Type,
			URL:  url,
		}
		s.send(ctx, sub.ID, sub.Endpoint, jwtStr, payload)
	}
}

func (s *Sender) send(
	ctx context.Context,
	subID, deviceToken, jwtStr string,
	payload apsPayload,
) {
	body, err := json.Marshal(payload)
	if err != nil {
		slog.Error("apns: marshal payload failed", "err", err)
		return
	}

	reqURL := fmt.Sprintf("%s/3/device/%s", s.base, deviceToken)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
	if err != nil {
		slog.Error("apns: build request failed", "err", err)
		return
	}
	req.Header.Set("Authorization", "bearer "+jwtStr)
	req.Header.Set("apns-topic", s.topic)
	req.Header.Set("apns-push-type", "alert")
	req.Header.Set("apns-priority", "10")
	req.Header.Set("Content-Type", "application/json")

	resp, err := s.client.Do(req)
	if err != nil {
		slog.Warn("apns: send failed", "token_prefix", tokenPrefix(deviceToken), "err", err)
		return
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
		_ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{
			LastUsedAt: time.Now().Unix(),
			ID:         subID,
		})
		return
	}

	// Non-2xx: APNs returns a small JSON body whose `reason` names the exact failure
	// (BadDeviceToken, InvalidProviderToken, BadTopic, ExpiredProviderToken, …). It is
	// the single most useful diagnostic, so always surface it rather than logging a
	// bare numeric status.
	reason := apnsReason(resp.Body)
	switch {
	case resp.StatusCode == http.StatusGone, reason == "BadDeviceToken", reason == "DeviceTokenNotForTopic":
		// 410 Unregistered, or a permanently-invalid token (the app was uninstalled,
		// the token rotated, or it's malformed / for the wrong topic). Prune the row
		// so we stop re-firing a guaranteed-fail request on every future Notify.
		if err := s.queries.DeletePushSubscription(ctx, subID); err != nil {
			slog.Error("apns: prune dead token failed", "id", subID, "reason", reason, "err", err)
		}
	case reason == "ExpiredProviderToken":
		// Clock skew pushed the cached provider JWT past Apple's 60-min limit before
		// our 50-min refresh. Bust the cache so the next call re-mints immediately;
		// otherwise every push fails for the rest of the refresh window.
		s.mu.Lock()
		s.cachedJWT = ""
		s.mu.Unlock()
		slog.Warn("apns: provider token expired, busting cache", "token_prefix", tokenPrefix(deviceToken))
	default:
		slog.Warn("apns: rejected",
			"status", resp.StatusCode, "reason", reason, "token_prefix", tokenPrefix(deviceToken))
	}
}

// apnsReason extracts the `reason` field from an APNs error response body (small
// JSON like {"reason":"BadDeviceToken"}). Returns "" if absent or unparseable.
func apnsReason(body io.Reader) string {
	var e struct {
		Reason string `json:"reason"`
	}
	_ = json.NewDecoder(io.LimitReader(body, 1024)).Decode(&e)
	return e.Reason
}

// providerJWT returns a cached ES256 provider JWT, re-minting when the cached
// token is older than jwtRefreshAge. Safe for concurrent callers (mutex-protected).
func (s *Sender) providerJWT() (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.cachedJWT != "" && time.Since(s.jwtMintedAt) < jwtRefreshAge {
		return s.cachedJWT, nil
	}

	now := time.Now()
	tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
		"iss": s.teamID,
		"iat": now.Unix(),
	})
	tok.Header["kid"] = s.keyID

	signed, err := tok.SignedString(s.key)
	if err != nil {
		return "", fmt.Errorf("sign apns jwt: %w", err)
	}

	s.cachedJWT = signed
	s.jwtMintedAt = now
	return signed, nil
}

// tokenPrefix returns the first 8 hex chars of a device token for log
// messages — enough to correlate without exposing the full opaque token.
func tokenPrefix(token string) string {
	if len(token) <= 8 {
		return token
	}
	return token[:8] + "..."
}
