package webhook

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/ClareAI/astra-insight-mcp/internal/agent"
	"github.com/ClareAI/astra-insight-mcp/internal/gateway"
	"github.com/ClareAI/astra-insight-mcp/internal/llm"
	"github.com/ClareAI/astra-insight-mcp/internal/oauth"
	"github.com/ClareAI/astra-insight-mcp/internal/pkg/gcs"
	redispkg "github.com/ClareAI/astra-insight-mcp/internal/pkg/redis"
)

// WatiMessage represents the payload of a Wati "Message Received" webhook event.
// Reference: https://docs.wati.io/reference/message-received
type WatiMessage struct {
	ID                     string      `json:"id"`
	Created                string      `json:"created"`
	WhatsappMessageID      string      `json:"whatsappMessageId"`
	ConversationID         string      `json:"conversationId"`
	TicketID               string      `json:"ticketId"`
	Text                   string      `json:"text"`
	Type                   string      `json:"type"`
	Data                   interface{} `json:"data"`
	SourceID               *string     `json:"sourceId"`
	SourceURL              *string     `json:"sourceUrl"`
	Timestamp              string      `json:"timestamp"`
	Owner                  bool        `json:"owner"`
	EventType              string      `json:"eventType"`
	StatusString           string      `json:"statusString"`
	AvatarURL              *string     `json:"avatarUrl"`
	AssignedID             string      `json:"assignedId"`
	OperatorName           string      `json:"operatorName"`
	OperatorEmail          string      `json:"operatorEmail"`
	WaID                   string      `json:"waId"`
	MessageContact         interface{} `json:"messageContact"`
	SenderName             string      `json:"senderName"`
	ListReply              interface{} `json:"listReply"`
	InteractiveButtonReply interface{} `json:"interactiveButtonReply"`
	ButtonReply            interface{} `json:"buttonReply"`
	ReplyContextID         string      `json:"replyContextId"`
	SourceType             int         `json:"sourceType"`
	FrequentlyForwarded    bool        `json:"frequentlyForwarded"`
	Forwarded              bool        `json:"forwarded"`
	ChannelID              *string     `json:"channelId"`
	ChannelPhoneNumber     string      `json:"channelPhoneNumber"`
}

// Handler processes Wati webhook callbacks through the Builder Agent loop.
// Fully stateless: each request loads conversation history from Redis,
// creates a temporary agent, runs it, and saves new messages back to Redis.
// No in-memory sessions, no Pub/Sub routing — horizontally scalable.
//
// The webhook returns 200 immediately and processes the message asynchronously.
// Once the agent finishes, the reply is sent back via Wati's sendSessionMessage API.
type Handler struct {
	llmClient  *llm.Client
	fastClient *llm.Client
	gwClient   *gateway.Client
	httpClient *http.Client

	watiBaseURL  string // e.g. "https://live-mt-server.wati.io"
	watiTenantID string
	watiToken    string
	asrTenantID  string

	// Per-user OAuth
	tokenStore       *oauth.TokenStore
	pendingAuth      *oauth.PendingAuthStore
	oauthEnv         string // "prod", "dev", etc.
	oauthCallbackURL string // public base URL for /oauth/callback
	requireAuth      bool   // when true, unauthenticated users get an OAuth prompt

	redisClient   *redispkg.Client
	gcsClient     *gcs.GCSClient
	gcsSessionDir string // GCS object prefix for session summaries (e.g. "wa_sessions/")
	recipeStore   agent.RecipeStore
}

const (
	defaultWatiBaseURL = "https://live-mt-server.wati.io"

	redisKeyPrefix   = "astra:insight_mcp:"
	pendingLogoutTTL = 5 * time.Minute
	staleMessageAge  = 2 * time.Minute  // drop webhook messages older than this
	oauthLinkCooldown = 60 * time.Second // min interval between OAuth links per waId

	// Sliding window: 30 rounds × ~4 messages/round = 120 active messages.
	// Double buffer: archive overflow when total exceeds 240.
	msgWindowSize = 120
	msgBufferCap  = 240

	// Distributed lock for per-waId serialization across pods.
	// TTL must exceed the agent.Run timeout (3min) to avoid premature expiry.
	waLockTTL          = 5 * time.Minute
	waLockRetryDelay   = 500 * time.Millisecond
	waLockMaxRetries   = 360 // 3 minutes max wait

	// Stale message cleanup: archive to GCS then delete from Redis.
	messagesActiveTTL   = 7 * 24 * time.Hour // heartbeat key TTL
	cleanupInterval     = 1 * time.Hour       // how often the cleanup loop runs
	cleanupLockKey      = redisKeyPrefix + "cleanup_lock"
	cleanupLockTTL      = 10 * time.Minute    // max time one pod holds the cleanup lock
	cleanupScanPattern  = redisKeyPrefix + "messages:*"

)

// HandlerOpts configures optional webhook behavior.
type HandlerOpts struct {
	TokenStore       *oauth.TokenStore
	PendingAuth      *oauth.PendingAuthStore
	OAuthEnv         string
	OAuthCallbackURL string // e.g. "https://myserver.example.com"
	RedisClient      *redispkg.Client
	GCSClient        *gcs.GCSClient
	GCSSessionDir    string // GCS object prefix, e.g. "wa_sessions/"
	RecipeStore      agent.RecipeStore
}

func NewHandler(llmClient *llm.Client, fastClient *llm.Client, gwClient *gateway.Client, opts ...HandlerOpts) *Handler {
	watiBase := os.Getenv("WATI_API_BASE_URL")
	if watiBase == "" {
		watiBase = defaultWatiBaseURL
	}

	h := &Handler{
		llmClient:    llmClient,
		fastClient:   fastClient,
		gwClient:     gwClient,
		httpClient:   &http.Client{Timeout: 30 * time.Second},
		watiBaseURL:  watiBase,
		watiTenantID: os.Getenv("WATI_TENANT_ID"),
		watiToken:    os.Getenv("WATI_TOKEN"),
		asrTenantID:  os.Getenv("ASTRA_ASR_TENANT_ID"),
	}
	if len(opts) > 0 {
		o := opts[0]
		h.tokenStore = o.TokenStore
		h.pendingAuth = o.PendingAuth
		h.oauthEnv = o.OAuthEnv
		h.oauthCallbackURL = o.OAuthCallbackURL
		h.requireAuth = h.tokenStore != nil && h.oauthCallbackURL != ""
		h.redisClient = o.RedisClient
		h.gcsClient = o.GCSClient
		h.gcsSessionDir = o.GCSSessionDir
		h.recipeStore = o.RecipeStore
	}
	if h.pendingAuth != nil {
		go h.cleanupPendingAuth()
	}
	if h.redisClient != nil {
		go h.staleMessagesCleanupLoop()
	}
	slog.Info("webhook: handler created (stateless mode)", "redis", h.redisClient != nil, "gcs", h.gcsClient != nil)
	return h
}

// Shutdown is a no-op in stateless mode. All state lives in Redis / GCS.
func (h *Handler) Shutdown() {
	slog.Info("webhook: shutdown (stateless — nothing to persist)")
}

// ---- Redis distributed lock (per waId) ----

func waLockKey(waID string) string {
	return redisKeyPrefix + "wa_lock:" + sanitizeWaID(waID)
}

// acquireWaLock blocks until the distributed lock for waID is obtained.
// Returns a unique value needed for releaseWaLock, or "" on failure.
func (h *Handler) acquireWaLock(ctx context.Context, waID string) string {
	if h.redisClient == nil {
		return "local"
	}
	key := waLockKey(waID)
	hostname, _ := os.Hostname()
	val := fmt.Sprintf("%s:%d:%d", hostname, os.Getpid(), time.Now().UnixNano())

	for i := 0; i < waLockMaxRetries; i++ {
		ok, err := h.redisClient.SetNX(ctx, key, val, waLockTTL)
		if err != nil {
			slog.Error("webhook: wa_lock setnx failed", "wa_id", waID, "error", err)
			return val // proceed optimistically
		}
		if ok {
			return val
		}
		select {
		case <-ctx.Done():
			return ""
		case <-time.After(waLockRetryDelay):
		}
	}
	slog.Warn("webhook: wa_lock timeout, proceeding anyway", "wa_id", waID)
	return val
}

func (h *Handler) releaseWaLock(ctx context.Context, waID, lockVal string) {
	if h.redisClient == nil || lockVal == "" || lockVal == "local" {
		return
	}
	// Atomic: only deletes if the value still matches (Lua script).
	// Prevents accidentally releasing a lock that was already expired and re-acquired by another pod.
	if _, err := h.redisClient.DelIfEqual(ctx, waLockKey(waID), lockVal); err != nil {
		slog.Warn("webhook: release wa_lock failed", "wa_id", waID, "error", err)
	}
}

// ---- OAuth link cooldown (Redis-backed) ----

func oauthCooldownKey(waID string) string {
	return redisKeyPrefix + "oauth_cooldown:" + sanitizeWaID(waID)
}

func (h *Handler) isOAuthCooldown(ctx context.Context, waID string) bool {
	if h.redisClient == nil {
		return false
	}
	val, _ := h.redisClient.Get(ctx, oauthCooldownKey(waID))
	return val != ""
}

func (h *Handler) setOAuthCooldown(ctx context.Context, waID string) {
	if h.redisClient == nil {
		return
	}
	_ = h.redisClient.Set(ctx, oauthCooldownKey(waID), "1", oauthLinkCooldown)
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		slog.Error("webhook: read body failed", "error", err)
		http.Error(w, "read body failed", http.StatusBadRequest)
		return
	}

	var msg WatiMessage
	if err := json.Unmarshal(rawBody, &msg); err != nil {
		slog.Error("webhook: decode failed", "error", err, "raw_body", string(rawBody))
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}

	slog.Info("webhook: received",
		"id", msg.ID,
		"type", msg.Type,
		"wa_id", msg.WaID,
		"conversation_id", msg.ConversationID,
		"sender", msg.SenderName,
		"owner", msg.Owner,
	)
	slog.Info("webhook: payload (full)", "body", string(rawBody))

	// Always return 200 immediately.
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]string{"status": "accepted"})

	if msg.Owner {
		slog.Info("webhook: skipped owner message", "wa_id", msg.WaID)
		return
	}

	// Drop stale messages (Wati may batch-deliver queued webhooks on reconnect).
	if ts, err := strconv.ParseInt(msg.Timestamp, 10, 64); err == nil {
		age := time.Since(time.Unix(ts, 0))
		if age > staleMessageAge {
			slog.Info("webhook: skipped stale message",
				"wa_id", msg.WaID,
				"msg_id", msg.ID,
				"age", age.Round(time.Second).String(),
			)
			return
		}
	}

	// Process asynchronously — agent run + reply via Wati API.
	go h.processAsync(msg)
}

func (h *Handler) processAsync(msg WatiMessage) {
	ctx := context.Background()

	// Handle /logout flow (two-step: request → confirm) — no lock needed.
	if h.requireAuth && msg.Type == "text" {
		trimmed := strings.TrimSpace(strings.ToLower(msg.Text))
		if trimmed == "/logout" || trimmed == "logout" {
			if h.tokenStore.Get(msg.WaID) == nil {
				_ = h.sendWatiReply(ctx, &msg, "You are not logged in.")
				return
			}
			h.setPendingLogout(ctx, msg.WaID)
			_ = h.sendWatiReply(ctx, &msg,
				"Are you sure you want to log out? This will clear your Astra credentials.\n\n"+
					"Reply *YES* to confirm, or anything else to cancel.")
			return
		}
		if h.popPendingLogout(ctx, msg.WaID) {
			if trimmed == "yes" {
				tenantID := h.resolveTenantID("")
				if tok := h.tokenStore.Get(msg.WaID); tok != nil {
					tenantID = h.resolveTenantID(tok.AccessToken)
				}
				h.archiveAllAndCleanup(ctx, msg.WaID, tenantID)
				h.tokenStore.Delete(msg.WaID)
				slog.Info("webhook: user logged out", "wa_id", msg.WaID, "tenant_id", tenantID)
				_ = h.sendWatiReply(ctx, &msg, "You have been logged out. Send any message to receive a new login link.")
			} else {
				_ = h.sendWatiReply(ctx, &msg, "Logout cancelled.")
			}
			return
		}
	}

	// Resolve per-user token early so extractMessage (ASR) can use it too.
	var userToken string
	if h.requireAuth {
		tok := h.tokenStore.RefreshIfNeeded(msg.WaID)
		if tok == nil {
			if h.isOAuthCooldown(ctx, msg.WaID) {
				slog.Info("webhook: OAuth link cooldown, skipping duplicate",
					"wa_id", msg.WaID, "msg_id", msg.ID)
				return
			}
			slog.Info("webhook: user not authenticated, sending OAuth link", "wa_id", msg.WaID)
			h.sendOAuthLink(ctx, &msg)
			h.setOAuthCooldown(ctx, msg.WaID)
			return
		}
		userToken = tok.AccessToken
	}

	userText, inputType, err := h.extractMessage(ctx, &msg, userToken)
	if err != nil {
		payloadJSON, _ := json.Marshal(msg)
		slog.Error("webhook: extract failed",
			"error", err,
			"type", msg.Type,
			"wa_id", msg.WaID,
			"conversation_id", msg.ConversationID,
			"full_payload", string(payloadJSON),
		)
		return
	}

	if userText == "" {
		slog.Info("webhook: skipped empty message", "wa_id", msg.WaID)
		return
	}

	tenantID := ""
	if userToken != "" {
		tenantID = h.resolveTenantID(userToken)
	}

	slog.Info("webhook: agent run start",
		"wa_id", msg.WaID,
		"conversation_id", msg.ConversationID,
		"ticket_id", msg.TicketID,
		"input_type", inputType,
		"text_len", len(userText),
		"user_text_preview", truncatePreview(userText, 120),
		"has_user_token", userToken != "",
		"tenant_id", tenantID,
	)

	// ── Distributed lock: serialize processing per waId across all pods ──
	lockVal := h.acquireWaLock(ctx, msg.WaID)
	if lockVal == "" {
		slog.Error("webhook: failed to acquire lock", "wa_id", msg.WaID)
		return
	}
	defer h.releaseWaLock(ctx, msg.WaID, lockVal)

	// ── Load conversation from Redis ──
	storedMsgs := h.loadMessagesFromRedis(ctx, msg.WaID, tenantID)

	// ── Create temporary agent ──
	buf := &strings.Builder{}
	cb := agent.Callbacks{
		OnContent: func(s string) { buf.WriteString(s) },
		OnToolCall: func(name string, args map[string]interface{}) {
			slog.Debug("webhook: tool_call", "tool", name, "wa_id", msg.WaID)
		},
		OnToolDone: func(name string, result string) {
			slog.Debug("webhook: tool_done", "tool", name, "result_len", len(result), "wa_id", msg.WaID)
		},
		OnIterationContent: func(content string) {
			if content == "" {
				return
			}
			slog.Info("webhook: sending intermediate content",
				"wa_id", msg.WaID,
				"content_len", len(content),
				"preview", truncatePreview(content, 80),
			)
			tmpMsg := &WatiMessage{WaID: msg.WaID}
			if err := h.sendWatiReply(context.Background(), tmpMsg, content); err != nil {
				slog.Error("webhook: intermediate send failed", "error", err, "wa_id", msg.WaID)
			}
			buf.Reset()
		},
	}
	opts := agent.Options{}
	if h.fastClient != nil {
		opts.FastClient = h.fastClient
	}
	if userToken != "" {
		opts.APIKeyOverride = userToken
	}
	if h.recipeStore != nil {
		opts.RecipeStore = h.recipeStore
	}

	ag := agent.New(h.llmClient, h.gwClient, cb, opts)

	// Restore conversation: prefer Redis, fallback to GCS cold storage.
	if len(storedMsgs) > 0 {
		memory := h.loadMemory(ctx, msg.WaID, tenantID)
		ag.RestoreMessagesWithMemory(storedMsgs, memory)
	} else if memory, transcript := h.loadSessionContext(msg.WaID, tenantID); memory != "" || len(transcript) > 0 {
		ag.InjectRestoredContext(memory, transcript)
	}

	enrichedText := fmt.Sprintf("[Webhook context | wa_id: %s | sender_name: %s | conversation_id: %s]\n%s",
		msg.WaID, msg.SenderName, msg.ConversationID, userText)

	runCtx, runCancel := context.WithTimeout(ctx, 3*time.Minute)
	defer runCancel()

	if err := ag.Run(runCtx, enrichedText); err != nil {
		slog.Error("webhook: agent run failed",
			"error", err,
			"wa_id", msg.WaID,
			"conversation_id", msg.ConversationID,
			"input_type", inputType,
			"text_len", len(userText),
		)
		return
	}

	// ── Save new messages to Redis ──
	newMsgs := ag.ExportNewMessages()
	if len(newMsgs) > 0 {
		h.saveMessagesToRedis(ctx, msg.WaID, tenantID, newMsgs)
	}

	// ── Archive overflow if buffer exceeded ──
	h.maybeArchiveOverflow(ctx, msg.WaID, tenantID, ag)

	reply := buf.String()
	slog.Info("webhook: agent run done",
		"wa_id", msg.WaID,
		"conversation_id", msg.ConversationID,
		"reply_len", len(reply),
		"reply_preview", truncatePreview(reply, 120),
		"new_messages", len(newMsgs),
	)

	if reply == "" {
		slog.Warn("webhook: agent returned empty reply, skipping send", "wa_id", msg.WaID)
		return
	}

	if err := h.sendWatiReply(ctx, &msg, reply); err != nil {
		slog.Error("webhook: send reply failed",
			"error", err,
			"wa_id", msg.WaID,
			"conversation_id", msg.ConversationID,
		)
	}
}

// sendOAuthLink generates an OAuth PKCE authorize URL for the user and sends
// it as a WhatsApp message. The state is stored so the callback can map back
// to this wa_id.
func (h *Handler) sendOAuthLink(ctx context.Context, msg *WatiMessage) {
	if h.pendingAuth == nil || h.oauthCallbackURL == "" {
		slog.Error("webhook: OAuth not configured, cannot send auth link", "wa_id", msg.WaID)
		return
	}
	cfg := oauth.Config{Env: h.oauthEnv}
	authCtx, err := oauth.BuildServerAuthorizeContext(cfg, h.oauthCallbackURL)
	if err != nil {
		slog.Error("webhook: build authorize URL failed", "error", err, "wa_id", msg.WaID)
		return
	}

	h.pendingAuth.Put(&oauth.PendingAuth{
		WaID:         msg.WaID,
		State:        authCtx.State,
		CodeVerifier: authCtx.CodeVerifier,
		RedirectURI:  authCtx.RedirectURI,
		Env:          h.oauthEnv,
		ClientID:     oauth.DefaultClientID,
		CreatedAt:    time.Now().Unix(),
	})

	reply := fmt.Sprintf(
		"Please log in to the Astra platform to continue.\n\n"+
			"Click the link below to log in:\n%s\n\n"+
			"After logging in, send any message to start chatting.",
		authCtx.URL,
	)

	if err := h.sendWatiReply(ctx, msg, reply); err != nil {
		slog.Error("webhook: send OAuth link failed", "error", err, "wa_id", msg.WaID)
	}
}

// cleanupPendingAuth removes stale pending OAuth sessions every 5 minutes.
func (h *Handler) cleanupPendingAuth() {
	ticker := time.NewTicker(5 * time.Minute)
	defer ticker.Stop()
	for range ticker.C {
		h.pendingAuth.Cleanup(300) // 5-minute max age
	}
}

// sendWatiReply sends the agent's reply back to the user via Wati sendSessionMessage API.
// Session messages support full WhatsApp formatting (bold, italic, lists, newlines).
func (h *Handler) sendWatiReply(ctx context.Context, msg *WatiMessage, reply string) error {
	if h.watiTenantID == "" || h.watiToken == "" {
		return fmt.Errorf("WATI_TENANT_ID or WATI_TOKEN not set")
	}

	formatted := convertMarkdownToWhatsApp(reply)

	reqURL := fmt.Sprintf("%s/%s/api/v1/sendSessionMessage/%s?messageText=%s",
		h.watiBaseURL, h.watiTenantID, msg.WaID, urlEncode(formatted))

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
	if err != nil {
		return fmt.Errorf("create request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+h.watiToken)
	req.Header.Set("Accept", "*/*")

	slog.Info("webhook: sending reply via wati",
		"url", reqURL,
		"wa_id", msg.WaID,
		"sender_name", msg.SenderName,
		"reply_len", len(formatted),
	)

	resp, err := h.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("wati request failed: %w", err)
	}
	defer resp.Body.Close()

	respBody, _ := io.ReadAll(resp.Body)

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		slog.Error("webhook: wati api non-2xx",
			"status", resp.StatusCode,
			"body", truncatePreview(string(respBody), 300),
			"wa_id", msg.WaID,
		)
		return fmt.Errorf("wati returned %d: %s", resp.StatusCode, string(respBody))
	}

	slog.Info("webhook: reply sent",
		"wa_id", msg.WaID,
		"wati_status", resp.StatusCode,
		"wati_response", truncatePreview(string(respBody), 200),
	)
	return nil
}

// extractASRText parses ASR API response and returns the transcription text.
// /v1/voice/asr/transcribe/upload returns: {"result": "...", "status_code": 200, "error": ""}
func extractASRText(raw json.RawMessage) string {
	var m map[string]interface{}
	if err := json.Unmarshal(raw, &m); err != nil {
		return ""
	}
	for _, key := range []string{"result", "text", "transcription"} {
		if v, ok := m[key]; ok {
			if s, ok := v.(string); ok && s != "" {
				return s
			}
		}
	}
	if data, ok := m["data"].(map[string]interface{}); ok {
		if v, ok := data["text"]; ok {
			if s, ok := v.(string); ok && s != "" {
				return s
			}
		}
		if v, ok := data["transcription"]; ok {
			if s, ok := v.(string); ok && s != "" {
				return s
			}
		}
	}
	if result, ok := m["result"]; ok {
		if s, ok := result.(string); ok && s != "" {
			return s
		}
		if obj, ok := result.(map[string]interface{}); ok {
			if v, ok := obj["text"]; ok {
				if s, ok := v.(string); ok && s != "" {
					return s
				}
			}
		}
	}
	return ""
}

// downloadAudio fetches the audio file from url. For Wati URLs (wati.io)
// it adds Authorization: Bearer token so the download succeeds.
func (h *Handler) downloadAudio(ctx context.Context, audioURL string) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, audioURL, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}
	if strings.Contains(audioURL, "wati.io") && h.watiToken != "" {
		req.Header.Set("Authorization", "Bearer "+h.watiToken)
	}

	resp, err := h.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("download returned %d: %s", resp.StatusCode, string(body))
	}
	return io.ReadAll(resp.Body)
}

// extractMessage resolves the user's text from the webhook payload.
// For voice/audio messages, ASR transcription is performed first.
// userToken is the per-user OAuth token (may be empty, falls back to env var).
func (h *Handler) extractMessage(ctx context.Context, msg *WatiMessage, userToken string) (text, inputType string, err error) {
	switch msg.Type {
	case "text":
		return msg.Text, "text", nil

	case "audio":
		audioURL := h.extractAudioURL(msg)
		if audioURL == "" {
			return "", "", fmt.Errorf("no audio URL available for %s message (sourceUrl and data are empty)", msg.Type)
		}

		audioBody, err := h.downloadAudio(ctx, audioURL)
		if err != nil {
			return "", "", fmt.Errorf("download audio: %w", err)
		}
		filename := "audio.opus"
		if strings.Contains(audioURL, ".mp3") {
			filename = "audio.mp3"
		} else if strings.Contains(audioURL, ".wav") {
			filename = "audio.wav"
		}

		apiKey := userToken
		if apiKey == "" {
			apiKey = os.Getenv("ASTRA_API_KEY")
		}
		result, err := h.gwClient.ASRTranscribeUpload(ctx, apiKey, h.asrTenantID, audioBody, filename)
		if err != nil {
			return "", "", fmt.Errorf("ASR transcription failed: %w", err)
		}

		transcript := extractASRText(result)
		if transcript == "" {
			slog.Error("webhook: ASR returned no text", "raw_response", string(result), "audio_url", audioURL)
			return "", "", fmt.Errorf("ASR returned empty transcription")
		}

		slog.Info("webhook: ASR transcribed", "text_len", len(transcript), "audio_url", audioURL)

		composed := fmt.Sprintf("[Voice message | audio_url: %s | format: %s]\n%s", audioURL, filename, transcript)
		return composed, "voice_transcribed", nil

	case "interactive":
		if msg.ListReply != nil {
			b, _ := json.Marshal(msg.ListReply)
			return string(b), "interactive_list", nil
		}
		if msg.InteractiveButtonReply != nil {
			b, _ := json.Marshal(msg.InteractiveButtonReply)
			return string(b), "interactive_button", nil
		}
		if msg.ButtonReply != nil {
			b, _ := json.Marshal(msg.ButtonReply)
			return string(b), "button_reply", nil
		}
		return msg.Text, "interactive", nil

	case "button":
		if msg.ButtonReply != nil {
			b, _ := json.Marshal(msg.ButtonReply)
			return string(b), "button_reply", nil
		}
		return msg.Text, "button", nil

	case "image", "video", "document", "sticker", "location", "contacts", "order":
		if msg.Text != "" {
			return msg.Text, msg.Type, nil
		}
		return "", "", fmt.Errorf("unsupported message type for agent processing: %s (no text content)", msg.Type)

	default:
		if msg.Text != "" {
			return msg.Text, msg.Type, nil
		}
		return "", "", fmt.Errorf("unsupported message type: %s", msg.Type)
	}
}

func (h *Handler) extractAudioURL(msg *WatiMessage) string {
	if msg.SourceURL != nil && *msg.SourceURL != "" {
		return *msg.SourceURL
	}
	// Wati sends audio URL as data string: "data": "https://live-mt-server.wati.io/.../showFile?fileName=..."
	if s, ok := msg.Data.(string); ok && s != "" {
		return s
	}
	if m, ok := msg.Data.(map[string]interface{}); ok {
		for _, key := range []string{"url", "link", "mediaUrl", "media_url"} {
			if u, ok := m[key].(string); ok && u != "" {
				return u
			}
		}
	}
	return ""
}

// ---- Markdown → WhatsApp formatting ----

var (
	reCodeBlock  = regexp.MustCompile("(?s)```.*?```")
	reInlineCode = regexp.MustCompile("`[^`]+`")
	reHeader     = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
	reBold       = regexp.MustCompile(`\*\*(.*?)\*\*`)
	reItalicStar = regexp.MustCompile(`(?m)(^|[^*])\*([^*\n]+)\*`)
	reStrike     = regexp.MustCompile(`~~(.*?)~~`)
	reLink       = regexp.MustCompile(`\[(.*?)\]\((.*?)\)`)
	reImage      = regexp.MustCompile(`!\[(.*?)\]\((.*?)\)`)
	reBullet     = regexp.MustCompile(`(?m)^[\t ]*[-*+]\s+`)
	reNumbered   = regexp.MustCompile(`(?m)^[\t ]*(\d+\.)\s+`)
	reHR         = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
	reTableSep   = regexp.MustCompile(`(?m)^\|[\s\-:|]+\|[\t ]*$`)
	reBlockquote = regexp.MustCompile(`(?m)^>\s?(.*)$`)
	reMultiBlank = regexp.MustCompile(`\n{3,}`)
)

// convertMarkdownTableToWhatsApp converts markdown tables into readable key-value lines.
func convertMarkdownTableToWhatsApp(s string) string {
	lines := strings.Split(s, "\n")
	var out []string
	i := 0
	for i < len(lines) {
		line := lines[i]
		if !strings.HasPrefix(strings.TrimLeft(line, " \t"), "|") {
			out = append(out, line)
			i++
			continue
		}
		var rows [][]string
		for i < len(lines) {
			trimmed := strings.TrimSpace(lines[i])
			if trimmed == "" || !strings.HasPrefix(trimmed, "|") {
				break
			}
			if reTableSep.MatchString(trimmed) {
				i++
				continue
			}
			parts := strings.Split(trimmed, "|")
			var cells []string
			for _, p := range parts {
				c := strings.TrimSpace(p)
				if c != "" {
					cells = append(cells, c)
				}
			}
			if len(cells) >= 1 {
				rows = append(rows, cells)
			}
			i++
		}
		if len(rows) == 0 {
			continue
		}
		start := 0
		if len(rows) > 1 {
			start = 1
		}
		for r := start; r < len(rows); r++ {
			row := rows[r]
			if len(row) >= 2 {
				out = append(out, "▸ "+row[0]+": "+row[1])
			} else if len(row) == 1 {
				out = append(out, "▸ "+row[0])
			}
		}
	}
	return strings.Join(out, "\n")
}

// convertMarkdownToWhatsApp converts Markdown to WhatsApp-compatible formatting.
// Preserves newlines for session messages. WhatsApp supports: *bold* _italic_ ~strike~ ```mono```
func convertMarkdownToWhatsApp(input string) string {
	s := input

	// 0. Tables first
	s = convertMarkdownTableToWhatsApp(s)

	// 1. Protect code blocks and inline code
	codeBlocks := reCodeBlock.FindAllString(s, -1)
	cbMap := make(map[string]string, len(codeBlocks))
	for i, b := range codeBlocks {
		ph := fmt.Sprintf("\x00CB%d\x00", i)
		cbMap[ph] = b
		s = strings.Replace(s, b, ph, 1)
	}
	inlineCodes := reInlineCode.FindAllString(s, -1)
	icMap := make(map[string]string, len(inlineCodes))
	for i, c := range inlineCodes {
		ph := fmt.Sprintf("\x00IC%d\x00", i)
		icMap[ph] = c
		s = strings.Replace(s, c, ph, 1)
	}

	// 2. Images ![alt](url) → url
	s = reImage.ReplaceAllString(s, "$2")

	// 3. Links [text](url) → text (url)
	s = reLink.ReplaceAllStringFunc(s, func(m string) string {
		parts := reLink.FindStringSubmatch(m)
		if len(parts) < 3 || parts[1] == parts[2] || parts[1] == "" {
			return parts[2]
		}
		return parts[1] + " (" + parts[2] + ")"
	})

	// 4. Horizontal rules → single blank line
	s = reHR.ReplaceAllString(s, "")

	// 5. Blockquotes > text → ┃ text
	s = reBlockquote.ReplaceAllString(s, "┃ $1")

	// 6. Headers # → *bold* with blank line above
	s = reHeader.ReplaceAllString(s, "\n*$1*")

	// 7. Bold **text** → *text*
	s = reBold.ReplaceAllString(s, "*$1*")

	// 8. Single-star italic *text* → _text_
	s = reItalicStar.ReplaceAllString(s, "${1}_${2}_")

	// 9. Strikethrough ~~text~~ → ~text~
	s = reStrike.ReplaceAllString(s, "~$1~")

	// 10. Bullet lists → "• " (WhatsApp renders these nicely)
	s = reBullet.ReplaceAllString(s, "• ")

	// 11. Numbered lists → keep number with "." (strip indent)
	s = reNumbered.ReplaceAllString(s, "$1 ")

	// 12. Restore inline code and code blocks
	for ph, c := range icMap {
		s = strings.Replace(s, ph, c, -1)
	}
	for ph, b := range cbMap {
		s = strings.Replace(s, ph, b, -1)
	}

	// 13. Clean up excessive blank lines (max 2 consecutive)
	s = reMultiBlank.ReplaceAllString(s, "\n\n")
	s = strings.TrimSpace(s)

	return s
}

func truncatePreview(s string, maxLen int) string {
	s = strings.TrimSpace(s)
	if len(s) <= maxLen {
		return s
	}
	return s[:maxLen] + "..."
}

func urlEncode(s string) string {
	return url.QueryEscape(s)
}

// ---- Stateless Redis message storage ----

// messagesRedisKey returns the Redis key for a user's conversation message list.
func messagesRedisKey(waID, tenantID string) string {
	return redisKeyPrefix + "messages:" + sanitizeWaID(waID) + ":" + sanitizeWaID(tenantID)
}

// messagesActiveKey returns the heartbeat key that tracks last-write time.
// When this key expires (7d TTL), the cleanup loop archives and deletes the messages list.
func messagesActiveKey(waID, tenantID string) string {
	return redisKeyPrefix + "messages_ts:" + sanitizeWaID(waID) + ":" + sanitizeWaID(tenantID)
}

// loadMessagesFromRedis loads the latest msgWindowSize llm.Message objects
// from the Redis list. The list may contain up to msgBufferCap messages
// (older ones await archiving), but only the active window is sent to the LLM.
func (h *Handler) loadMessagesFromRedis(ctx context.Context, waID, tenantID string) []llm.Message {
	if h.redisClient == nil || tenantID == "" {
		return nil
	}
	key := messagesRedisKey(waID, tenantID)
	rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	// LRANGE -120 -1: only fetch the newest msgWindowSize messages.
	vals, err := h.redisClient.LRange(rctx, key, -int64(msgWindowSize), -1)
	if err != nil || len(vals) == 0 {
		return nil
	}
	msgs := make([]llm.Message, 0, len(vals))
	for _, v := range vals {
		var m llm.Message
		if err := json.Unmarshal([]byte(v), &m); err != nil {
			slog.Warn("webhook: skip corrupt redis message", "error", err, "wa_id", waID)
			continue
		}
		msgs = append(msgs, m)
	}
	slog.Info("webhook: loaded messages from redis",
		"wa_id", waID, "tenant_id", tenantID, "count", len(msgs))
	return msgs
}

// saveMessagesToRedis appends new llm.Message objects to the Redis sliding window.
func (h *Handler) saveMessagesToRedis(ctx context.Context, waID, tenantID string, msgs []llm.Message) {
	if h.redisClient == nil || tenantID == "" || len(msgs) == 0 {
		return
	}
	key := messagesRedisKey(waID, tenantID)
	values := make([]string, 0, len(msgs))
	for _, m := range msgs {
		b, err := json.Marshal(m)
		if err != nil {
			continue
		}
		values = append(values, string(b))
	}
	if len(values) == 0 {
		return
	}
	rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	if err := h.redisClient.RPush(rctx, key, values...); err != nil {
		slog.Error("webhook: redis rpush messages failed", "error", err, "wa_id", waID)
		return
	}
	// Refresh heartbeat — cleanup loop uses this to detect stale message lists.
	_ = h.redisClient.Set(rctx, messagesActiveKey(waID, tenantID), "1", messagesActiveTTL)
}

// maybeArchiveOverflow checks if the Redis list exceeds the buffer cap.
// If so: extract memory from ALL messages → append overflow to GCS transcript → LTRIM.
func (h *Handler) maybeArchiveOverflow(ctx context.Context, waID, tenantID string, ag *agent.Agent) {
	if h.redisClient == nil || tenantID == "" {
		return
	}
	key := messagesRedisKey(waID, tenantID)
	rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	listLen, err := h.redisClient.LLen(rctx, key)
	if err != nil || listLen <= msgBufferCap {
		return
	}

	slog.Info("webhook: archive overflow triggered",
		"wa_id", waID, "tenant_id", tenantID,
		"list_len", listLen, "buffer_cap", msgBufferCap)

	overflowCount := listLen - int64(msgWindowSize)

	// 1. Extract long-term memory from all current messages (uses the agent that
	//    just ran, which has the full context window loaded).
	memCtx, memCancel := context.WithTimeout(ctx, 20*time.Second)
	defer memCancel()
	memory := ag.ExtractMemory(memCtx)
	if memory != "" {
		h.saveMemory(ctx, waID, tenantID, memory)
	}

	// 2. Read the overflow messages and append to GCS transcript.
	overflow, err := h.redisClient.LRange(rctx, key, 0, overflowCount-1)
	if err != nil {
		slog.Error("webhook: read overflow messages failed", "error", err, "wa_id", waID)
		return
	}
	if len(overflow) > 0 {
		h.appendTranscriptToGCS(ctx, waID, tenantID, overflow)
	}

	// 3. Trim to keep only the active window.
	if err := h.redisClient.LTrim(rctx, key, overflowCount, -1); err != nil {
		slog.Error("webhook: ltrim failed", "error", err, "wa_id", waID)
	} else {
		slog.Info("webhook: archive overflow done",
			"wa_id", waID, "tenant_id", tenantID,
			"archived", len(overflow), "remaining", msgWindowSize)
	}
}

// appendTranscriptToGCS downloads existing transcript.jsonl, appends new lines,
// and re-uploads. Each line is a raw JSON llm.Message from Redis.
func (h *Handler) appendTranscriptToGCS(ctx context.Context, waID, tenantID string, jsonLines []string) {
	if h.gcsClient == nil {
		return
	}
	objPath := h.gcsObjectPath(waID, tenantID, "transcript.jsonl")

	existing, _ := h.gcsClient.Download(ctx, objPath)
	var sb strings.Builder
	if len(existing) > 0 {
		sb.Write(existing)
		if !strings.HasSuffix(string(existing), "\n") {
			sb.WriteByte('\n')
		}
	}
	for _, line := range jsonLines {
		sb.WriteString(line)
		sb.WriteByte('\n')
	}

	if _, err := h.gcsClient.Upload(ctx, objPath, strings.NewReader(sb.String())); err != nil {
		slog.Error("webhook: gcs append transcript failed", "error", err, "wa_id", waID, "tenant_id", tenantID)
	} else {
		slog.Info("webhook: transcript appended to gcs",
			"wa_id", waID, "tenant_id", tenantID, "new_lines", len(jsonLines))
	}
}

// archiveAllAndCleanup persists all Redis messages to GCS and deletes the Redis keys.
// Used during logout and stale-message cleanup to ensure no data loss.
func (h *Handler) archiveAllAndCleanup(ctx context.Context, waID, tenantID string) {
	if h.redisClient == nil || tenantID == "" {
		return
	}
	key := messagesRedisKey(waID, tenantID)
	rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	vals, err := h.redisClient.LRange(rctx, key, 0, -1)
	if err != nil || len(vals) == 0 {
		_ = h.redisClient.Del(rctx, key)
		_ = h.redisClient.Del(rctx, messagesActiveKey(waID, tenantID))
		return
	}

	// Extract memory before cleanup.
	if h.gcsClient != nil && h.fastClient != nil {
		tmpBuf := &strings.Builder{}
		tmpCb := agent.Callbacks{OnContent: func(s string) { tmpBuf.WriteString(s) }}
		tmpOpts := agent.Options{FastClient: h.fastClient}
		tmpAgent := agent.New(h.llmClient, h.gwClient, tmpCb, tmpOpts)

		var msgs []llm.Message
		for _, v := range vals {
			var m llm.Message
			if json.Unmarshal([]byte(v), &m) == nil {
				msgs = append(msgs, m)
			}
		}
		tmpAgent.RestoreMessages(msgs)

		memCtx, memCancel := context.WithTimeout(ctx, 20*time.Second)
		memory := tmpAgent.ExtractMemory(memCtx)
		memCancel()
		if memory != "" {
			h.saveMemory(ctx, waID, tenantID, memory)
		}
	}

	h.appendTranscriptToGCS(ctx, waID, tenantID, vals)
	_ = h.redisClient.Del(rctx, key)
	_ = h.redisClient.Del(rctx, messagesActiveKey(waID, tenantID))
	slog.Info("webhook: archived all messages and cleaned up redis",
		"wa_id", waID, "tenant_id", tenantID, "count", len(vals))
}

// ---- Stale messages cleanup ----

// staleMessagesCleanupLoop runs periodically to archive and delete messages
// whose heartbeat key (messages_ts) has expired (user inactive > 7 days).
// Only one pod acquires the cleanup lock at a time.
func (h *Handler) staleMessagesCleanupLoop() {
	// Stagger first run so all pods don't scan simultaneously at startup.
	time.Sleep(30 * time.Second)

	ticker := time.NewTicker(cleanupInterval)
	defer ticker.Stop()

	for range ticker.C {
		h.runStaleMessagesCleanup()
	}
}

func (h *Handler) runStaleMessagesCleanup() {
	ctx := context.Background()

	// Only one pod runs cleanup at a time.
	ok, err := h.redisClient.SetNX(ctx, cleanupLockKey, "1", cleanupLockTTL)
	if err != nil || !ok {
		return
	}
	defer h.redisClient.Del(ctx, cleanupLockKey)

	keys, err := h.redisClient.Scan(ctx, cleanupScanPattern, 100)
	if err != nil {
		slog.Error("webhook: cleanup scan failed", "error", err)
		return
	}
	if len(keys) == 0 {
		return
	}

	archived := 0
	for _, msgKey := range keys {
		waID, tenantID := parseMessageKey(msgKey)
		if waID == "" || tenantID == "" {
			continue
		}

		// Check heartbeat — if still alive, skip.
		tsKey := messagesActiveKey(waID, tenantID)
		val, _ := h.redisClient.Get(ctx, tsKey)
		if val != "" {
			continue
		}

		// Heartbeat expired → archive this user's messages.
		slog.Info("webhook: cleanup — archiving stale messages",
			"wa_id", waID, "tenant_id", tenantID)

		lockVal := h.acquireWaLock(ctx, waID)
		if lockVal == "" {
			continue
		}

		h.archiveAllAndCleanup(ctx, waID, tenantID)
		h.releaseWaLock(ctx, waID, lockVal)
		archived++
	}

	if archived > 0 {
		slog.Info("webhook: cleanup done", "scanned", len(keys), "archived", archived)
	}
}

// parseMessageKey extracts waID and tenantID from a Redis messages key.
// Expected format: astra:insight_mcp:messages:{waId}:{tenantId}
func parseMessageKey(key string) (waID, tenantID string) {
	prefix := redisKeyPrefix + "messages:"
	if !strings.HasPrefix(key, prefix) {
		return "", ""
	}
	suffix := key[len(prefix):]
	parts := strings.SplitN(suffix, ":", 2)
	if len(parts) != 2 {
		return "", ""
	}
	return parts[0], parts[1]
}

// resolveTenantID calls the gateway to get the tenant associated with the token.
// Falls back to WATI_TENANT_ID env var for non-OAuth flows.
func (h *Handler) resolveTenantID(userToken string) string {
	apiKey := userToken
	if apiKey == "" {
		apiKey = os.Getenv("ASTRA_API_KEY")
	}
	if apiKey == "" {
		return os.Getenv("WATI_TENANT_ID")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	tid, err := h.gwClient.ResolveTenantID(ctx, apiKey)
	if err != nil {
		slog.Warn("webhook: resolve tenant_id failed, falling back to env", "error", err)
		return os.Getenv("WATI_TENANT_ID")
	}
	return tid
}


// sanitizeWaID strips anything that isn't alphanumeric, underscore, or hyphen
// to prevent path traversal when used in file names.
var safeWaIDRe = regexp.MustCompile(`[^a-zA-Z0-9_\-]`)

func sanitizeWaID(id string) string {
	return safeWaIDRe.ReplaceAllString(id, "")
}


// pendingLogoutRedisKey returns the Redis key for a user's pending logout state.
func pendingLogoutRedisKey(waID string) string {
	return redisKeyPrefix + "pending_logout:" + sanitizeWaID(waID)
}


// setPendingLogout marks a user as awaiting logout confirmation (Redis, TTL=5min).
func (h *Handler) setPendingLogout(ctx context.Context, waID string) {
	if h.redisClient == nil {
		return
	}
	rctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	_, _ = h.redisClient.SetNX(rctx, pendingLogoutRedisKey(waID), "1", pendingLogoutTTL)
}

// popPendingLogout atomically checks and clears the pending logout flag.
func (h *Handler) popPendingLogout(ctx context.Context, waID string) bool {
	if h.redisClient == nil {
		return false
	}
	rctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()
	val, err := h.redisClient.GetDel(rctx, pendingLogoutRedisKey(waID))
	return err == nil && val != ""
}

// gcsObjectPath builds a GCS path: {gcsSessionDir}/{waId}/{tenantId}/{file}.
// tenantId isolates data when the same waId logs into different Astra accounts.
func (h *Handler) gcsObjectPath(waID, tenantID, file string) string {
	return h.gcsSessionDir + sanitizeWaID(waID) + "/" + sanitizeWaID(tenantID) + "/" + file
}


// saveMemory writes a memory document to GCS, preserving the previous version.
func (h *Handler) saveMemory(ctx context.Context, waID, tenantID, memory string) {
	existing := h.loadMemory(ctx, waID, tenantID)
	memDoc := map[string]interface{}{
		"wa_id":      waID,
		"tenant_id":  tenantID,
		"updated_at": time.Now().UTC().Format(time.RFC3339),
		"memory":     memory,
	}
	if existing != "" && existing != memory {
		memDoc["previous_memory"] = existing
	}

	b, err := json.Marshal(memDoc)
	if err != nil {
		slog.Error("webhook: marshal memory failed", "error", err, "wa_id", waID)
		return
	}
	objPath := h.gcsObjectPath(waID, tenantID, "memory.json")
	if _, err := h.gcsClient.Upload(ctx, objPath, strings.NewReader(string(b))); err != nil {
		slog.Error("webhook: gcs upload memory failed", "error", err, "wa_id", waID, "tenant_id", tenantID)
	} else {
		slog.Info("webhook: memory persisted", "wa_id", waID, "tenant_id", tenantID, "memory_len", len(memory))
	}
}

// loadMemory reads the long-term memory file from GCS.
func (h *Handler) loadMemory(ctx context.Context, waID, tenantID string) string {
	b, err := h.gcsClient.Download(ctx, h.gcsObjectPath(waID, tenantID, "memory.json"))
	if err != nil || b == nil {
		return ""
	}
	var doc map[string]interface{}
	if err := json.Unmarshal(b, &doc); err != nil {
		return ""
	}
	if m, ok := doc["memory"].(string); ok {
		return m
	}
	return ""
}

// loadTranscript reads the transcript JSONL file from GCS.
func (h *Handler) loadTranscript(ctx context.Context, waID, tenantID string) []agent.TranscriptEntry {
	b, err := h.gcsClient.Download(ctx, h.gcsObjectPath(waID, tenantID, "transcript.jsonl"))
	if err != nil || b == nil {
		return nil
	}
	var entries []agent.TranscriptEntry
	for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
		if line == "" {
			continue
		}
		var e agent.TranscriptEntry
		if err := json.Unmarshal([]byte(line), &e); err != nil {
			continue
		}
		entries = append(entries, e)
	}
	return entries
}

// loadSessionContext loads both memory and transcript from GCS for session restoration.
func (h *Handler) loadSessionContext(waID, tenantID string) (memory string, transcript []agent.TranscriptEntry) {
	if h.gcsClient == nil || tenantID == "" {
		return "", nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	memory = h.loadMemory(ctx, waID, tenantID)
	transcript = h.loadTranscript(ctx, waID, tenantID)
	return
}

// ---- OAuth callback handler ----

// OAuthCallbackHandler returns an http.HandlerFunc that handles the OAuth PKCE
// callback redirect. It exchanges the code for a token and stores it per wa_id.
func (h *Handler) OAuthCallbackHandler() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		state := q.Get("state")
		code := q.Get("code")
		errParam := q.Get("error")

		if errParam != "" {
			desc := q.Get("error_description")
			slog.Error("oauth callback: error from provider", "error", errParam, "description", desc)
			writeCallbackHTML(w, false, fmt.Sprintf("Authorization failed: %s — %s", errParam, desc))
			return
		}
		if state == "" || code == "" {
			writeCallbackHTML(w, false, "Missing state or code parameter.")
			return
		}

		pending := h.pendingAuth.Pop(state)
		if pending == nil {
			slog.Warn("oauth callback: no pending auth for state", "state", state)
			writeCallbackHTML(w, false, "Login session expired or invalid. Please request a new login link.")
			return
		}

		tok, err := oauth.ExchangeCodeForToken(
			pending.Env, code, pending.ClientID, pending.RedirectURI, pending.CodeVerifier,
		)
		if err != nil {
			slog.Error("oauth callback: token exchange failed", "error", err, "wa_id", pending.WaID)
			writeCallbackHTML(w, false, "Token exchange failed. Please try again.")
			return
		}

		tok.ClientID = pending.ClientID
		tok.Env = pending.Env
		tok.ObtainedAt = time.Now().Unix()

		if err := h.tokenStore.Set(pending.WaID, tok); err != nil {
			slog.Error("oauth callback: save token failed", "error", err, "wa_id", pending.WaID)
			writeCallbackHTML(w, false, "Failed to save token. Please try again.")
			return
		}

		slog.Info("oauth callback: success",
			"wa_id", pending.WaID,
			"env", pending.Env,
			"token_prefix", truncatePreview(tok.AccessToken, 8),
		)

		writeCallbackHTML(w, true, "")

		// Send a WhatsApp message notifying the user that login succeeded.
		go func() {
			msg := &WatiMessage{WaID: pending.WaID}
			notice := "Login successful! You can now send any message to start chatting."
			if err := h.sendWatiReply(context.Background(), msg, notice); err != nil {
				slog.Error("oauth callback: notify user failed", "error", err, "wa_id", pending.WaID)
			}
		}()
	}
}

func writeCallbackHTML(w http.ResponseWriter, ok bool, errMsg string) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if ok {
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `<!DOCTYPE html><html><body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:90vh;margin:0;background:#f0fdf4"><div style="text-align:center"><h1 style="color:#16a34a">&#10003; Login Successful</h1><p>You can close this tab and return to WhatsApp.</p></div></body></html>`)
	} else {
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(w, `<!DOCTYPE html><html><body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:90vh;margin:0;background:#fef2f2"><div style="text-align:center"><h1 style="color:#dc2626">&#10007; Login Failed</h1><p>%s</p></div></body></html>`, errMsg)
	}
}
