package chat.vojo.app;

import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.core.app.NotificationManagerCompat;
import androidx.work.Worker;
import androidx.work.WorkerParameters;

import org.json.JSONArray;
import org.json.JSONObject;

import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

/**
 * Periodic poll of `/_matrix/client/v3/notifications` as a fallback delivery
 * channel for users whose network blocks FCM (mtalk.google.com:5228) — the
 * ~5% slice on whitelist intranets (corporate / school / government) that
 * otherwise receive zero pushes.
 *
 * Scheduling: enqueued from PollingPlugin.schedule() with a 15-minute period
 * (Android's minimum for PeriodicWorkRequest) and CONNECTED network constraint.
 * Cancelled via PollingPlugin.cancel() on logout / push disable.
 *
 * Credentials: read from SharedPreferences (saved by the JS side through
 * PollingPlugin.saveSession). Vanilla Synapse (no MAS/OIDC) issues
 * non-expiring access tokens; we do not implement refresh-token flow here.
 * If a 401 ever occurs, doWork returns Result.success() — the next foreground
 * launch re-saves the credentials and polling resumes. Retrying with a stale
 * token would just waste battery and amplify rate limits.
 *
 * Output: messages and invites route through VojoFirebaseMessagingService
 * .renderMessageNotification (shared with FCM, same notif-id slots →
 * Android dedupes by replace). RTC ring events route through
 * .renderMissedCallNotification (always stale by the time we poll — 15-min
 * cadence vs 30-second ring lifetime), so the user sees "Missed call" instead
 * of a phantom incoming-call CallStyle for a long-dead ring.
 *
 * E2EE caveat: Synapse cannot decrypt event content, so for end-to-end
 * encrypted rooms the response carries `content.algorithm`+`ciphertext`
 * with no `body`. The renderer falls through to PushStrings.messageFallback
 * (i18n "New message") with the room name as title — same UX as the web
 * Service Worker on encrypted pushes. By design — no key access from the
 * Worker.
 *
 * Dedup is two complementary mechanisms:
 *   1) A per-poll high-watermark on the latest event ts we've notified.
 *      Stored as KEY_LAST_SEEN_TS; advances only after a successful render
 *      (or a foreground-skipped event the user already saw in-app). Worker
 *      stops walking within a run as soon as it hits ts strictly less than
 *      watermark — newest-first ordering guarantees the rest are also
 *      older. Same-ts events fall through to the secondary filters because
 *      multiple events can share a millisecond.
 *   2) NotificationDedup — a shared cross-source bounded LRU written by
 *      every renderer (FCM service after successful nm.notify, this Worker
 *      after successful render, and the ring-upsert paths at seed time).
 *      Lets the Worker skip events FCM already delivered even after the
 *      user dismissed the FCM notification.
 *
 * Each fire starts from the HEAD of /notifications (no persistent
 * pagination cursor — the spec's `next_token` walks BACKWARDS into
 * history, so a persisted cursor silently drifts off the new events the
 * next poll should see; see matrix-js-sdk client.ts:5040 for the
 * reference traversal pattern). When a single fire's backlog exceeds
 * MAX_PAGES_PER_RUN pages the leftover next_token is saved as
 * KEY_DRAIN_CURSOR (with the head ts snapshotted in KEY_DRAIN_TARGET_TS)
 * and resumed on the next run, so big backlogs (>250 events) drain over
 * consecutive polls without being clipped.
 */
public class VojoPollWorker extends Worker {

    private static final String TAG = "VojoPoll";

    static final String PREFS = "vojo_poll_state";
    static final String KEY_ACCESS_TOKEN = "access_token";
    static final String KEY_HOMESERVER_URL = "homeserver_url";
    static final String KEY_USER_ID = "user_id";
    // High-watermark on the latest event ts we've already notified about.
    // Stored as a long-millis string. Replaces an earlier `last_from` cursor
    // experiment that misunderstood /notifications pagination direction.
    static final String KEY_LAST_SEEN_TS = "last_seen_ts";
    // Continuation cursor used when a single run hits MAX_PAGES_PER_RUN before
    // reaching the watermark. Persists the next_token across runs so a >250
    // event backlog drains over consecutive polls instead of being clipped
    // forever by the page cap. Cleared once we either reach the watermark or
    // exhaust pagination on a single run.
    static final String KEY_DRAIN_CURSOR = "drain_cursor";
    // The "head ts" we recorded when entering drain mode. After drain
    // completes the watermark is jumped to THIS value rather than the
    // (older) max ts seen during drain — otherwise the bounded LRU could
    // evict events from the original head and let the next normal run
    // re-render them. Set once on entering drain mode, untouched while
    // draining, cleared when drain completes.
    static final String KEY_DRAIN_TARGET_TS = "drain_target_ts";
    static final String KEY_NOTIFIED_IDS = "notified_ids";
    static final String KEY_ROOM_NAMES = "room_names";
    // user_id → MXC avatar URL, JSON-encoded, bridged from JS via
    // PollingPlugin.saveUserAvatars. Consumed by
    // VojoFirebaseMessagingService.lookupUserAvatarMxc for per-sender
    // Person.setIcon in MessagingStyle conversations. Bounded at 500
    // entries on the JS side; read tolerantly here.
    static final String KEY_USER_AVATARS = "user_avatars";

    private static final int HTTP_TIMEOUT_MS = 30_000;
    // Cap pages-per-fire so an unexpectedly large backlog (server-side bug,
    // first run after a long offline window) cannot loop until Android's
    // 10-minute Worker kill timer fires. 5 pages × 50 events = up to 250
    // events per cycle — well above realistic 15-minute backlog for a single
    // user. We also break as soon as we hit ts ≤ watermark, so most polls
    // touch only a single page.
    private static final int MAX_PAGES_PER_RUN = 5;
    private static final int PAGE_LIMIT = 50;

    private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification";
    private static final String RTC_NOTIFICATION_TYPE_STABLE = "m.rtc.notification";

    public VojoPollWorker(@NonNull Context context, @NonNull WorkerParameters params) {
        super(context, params);
    }

    @NonNull
    @Override
    public Result doWork() {
        Context ctx = getApplicationContext();
        SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);

        String token = prefs.getString(KEY_ACCESS_TOKEN, null);
        String homeserver = prefs.getString(KEY_HOMESERVER_URL, null);
        if (token == null || homeserver == null) {
            // Not logged in (or JS hasn't bridged credentials yet). Return
            // success so WorkManager keeps the periodic schedule alive —
            // we'll pick up the credentials on the next fire.
            Log.i(TAG, "poll: no credentials, bail");
            return Result.success();
        }

        // If POST_NOTIFICATIONS was revoked we'd fetch + parse + try to
        // render and then watch every nm.notify fail with SecurityException
        // — which leaves the LRU/watermark unadvanced (correctly so for a
        // transient failure) and re-runs the same loop every 15 minutes
        // forever. Bail early to avoid burning battery on a permanent
        // user choice. The next visibility re-bridge inside the JS app
        // will pick up a re-granted permission.
        if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
            Log.i(TAG, "poll: notifications disabled, bail");
            return Result.success();
        }

        long watermark = prefs.getLong(KEY_LAST_SEEN_TS, 0L);
        String drainCursor = prefs.getString(KEY_DRAIN_CURSOR, null);
        long drainTargetTs = prefs.getLong(KEY_DRAIN_TARGET_TS, 0L);
        boolean wasDraining = drainCursor != null;
        Map<String, String> roomNames = loadRoomNamesMap(prefs);
        // Mirror the FCM service's foreground gate: if the user is actively in
        // the app, the live timeline owns the UX and a system notification for
        // a backlog event would be both stale and visually noisy. We still
        // consume state (LRU, watermark) so the same event doesn't surface
        // when the user later backgrounds the app.
        boolean inForeground = MainActivity.isInForeground;

        Log.i(TAG, "poll: start fg=" + inForeground
            + " watermark=" + watermark
            + " draining=" + wasDraining);

        int pagesFetched = 0;
        int renderedCount = 0;
        int skippedDedupCount = 0;
        long highestTsSeen = watermark;
        boolean reachedWatermark = false;
        // The continuation cursor we'd save if this run is capped. Starts as
        // the resumed drain cursor; advances with each successful page fetch
        // so a transient mid-pagination error still preserves drain progress.
        String pendingCursor = drainCursor;
        boolean paginationExhausted = false;

        try {
            // Cursor strategy: drain cursor resumes from where a previous capped
            // run stopped; otherwise we start from the HEAD. next_token from
            // /notifications paginates BACKWARDS into history, so a stored
            // cursor must be used as a drain-only continuation, NOT as an
            // ongoing "since" mark (the latter would silently drift off new
            // events). Within a single fire we stop as soon as ts < watermark
            // (newest-first ordering means everything past that is covered).
            String nextFrom = drainCursor;
            for (int page = 0; page < MAX_PAGES_PER_RUN && !reachedWatermark; page += 1) {
                // Cooperative cancellation. WorkManager.cancelUniqueWork (called
                // from PollingPlugin.cancel during logout / push disable) only
                // marks future scheduling — it does NOT interrupt this thread.
                // Without these checks the Worker keeps fetching pages, posting
                // notifications, and (worst of all) running the final
                // editor.apply() with stale state written AFTER clearSession
                // wiped prefs — leaking watermark / drain cursor from the
                // logged-out account into the next login.
                if (isStopped()) return Result.success();

                JSONObject body = fetchNotifications(homeserver, token, nextFrom);
                // fetchNotifications throws on every failure path; a null
                // return is unreachable in current code. The early-break here
                // is a defensive belt-and-suspenders — keep paginationExhausted
                // consistent so the drain-bookkeeping below clears the cursor
                // instead of replaying the same empty page forever.
                if (body == null) {
                    paginationExhausted = true;
                    pendingCursor = null;
                    break;
                }

                JSONArray notifications = body.optJSONArray("notifications");
                if (notifications == null || notifications.length() == 0) {
                    // Server returned no entries for this page. Treat as
                    // end-of-pagination so a drain in progress can complete
                    // (otherwise pendingCursor would keep its old value and
                    // we'd re-fetch the same empty page next cycle forever).
                    paginationExhausted = true;
                    pendingCursor = null;
                    break;
                }

                for (int i = 0; i < notifications.length(); i += 1) {
                    if (isStopped()) return Result.success();
                    JSONObject entry = notifications.optJSONObject(i);
                    if (entry == null) continue;
                    String eventId = extractEventId(entry);
                    if (eventId == null) continue;

                    // ts gate: server returns newest-first, so once we hit
                    // ts STRICTLY less than the watermark we know the rest of
                    // the page (and every subsequent page) is already covered.
                    // Same-ts events fall through to the LRU/read filters
                    // below — multiple events can share a millisecond, and
                    // collapsing them at the ts boundary would silently drop
                    // a fresh sibling of a previously-rendered one.
                    long ts = entry.optLong("ts", 0L);
                    if (ts > 0 && ts < watermark) {
                        reachedWatermark = true;
                        break;
                    }

                    // Skip notifications the user already read on another
                    // client (web tab, Element, second device). Spec marks
                    // `read` as a required boolean on each entry.
                    if (entry.optBoolean("read", false)) {
                        if (ts > highestTsSeen) highestTsSeen = ts;
                        continue;
                    }

                    // Skip events the push rules said don't notify (muted
                    // rooms, dont_notify overrides). Without this gate
                    // polling would re-surface events Sygnal already
                    // suppressed for the FCM path — the mute toggle
                    // wouldn't actually mute on whitelist networks.
                    if (!notifyAllowed(entry)) {
                        if (ts > highestTsSeen) highestTsSeen = ts;
                        continue;
                    }

                    // Cross-source dedup via NotificationDedup: FCM writes
                    // into this set after every successful render, so the
                    // Worker correctly skips events the FCM service already
                    // delivered — even if the user dismissed the FCM
                    // notification before this cycle fired.
                    if (NotificationDedup.wasNotified(ctx, eventId)) {
                        skippedDedupCount += 1;
                        if (ts > highestTsSeen) highestTsSeen = ts;
                        continue;
                    }

                    // Three outcomes for marking + watermark advance:
                    //   foreground            → mark + advance (skip render
                    //                           but consume state, otherwise
                    //                           next bg poll would replay)
                    //   background + posted   → mark + advance
                    //   background + !posted  → DON'T mark, DON'T advance
                    //                           (transient render failure
                    //                           should be retried next poll)
                    boolean posted = false;
                    boolean treatAsNotRenderable = false;
                    if (!inForeground) {
                        Map<String, String> flattened = flattenNotification(entry, roomNames);
                        String type = flattened.get("type");
                        boolean isRtcType = RTC_NOTIFICATION_TYPE.equals(type)
                            || RTC_NOTIFICATION_TYPE_STABLE.equals(type);
                        boolean isRing = "ring".equals(flattened.get("content_notification_type"));

                        if (isRtcType && isRing) {
                            // Composite session dedup: if FCM already alerted
                            // for this call session (different ring event,
                            // same parent), skip posting a duplicate
                            // missed-call. Without this, a session with one
                            // FCM live-alert ring + one re-ring through
                            // polling would surface as both a CallStyle and
                            // a missed-call card. Helpers live in
                            // VojoFirebaseMessagingService so the key shape
                            // stays in lock-step across FCM and polling.
                            String roomIdField = flattened.get("room_id");
                            String sessionId = VojoFirebaseMessagingService
                                .extractCallSessionId(flattened);
                            String composite = null;
                            if (roomIdField != null && sessionId != null) {
                                composite = VojoFirebaseMessagingService
                                    .compositeCallDedupKey(roomIdField, sessionId);
                                if (NotificationDedup.wasNotified(ctx, composite)) {
                                    if (ts > highestTsSeen) highestTsSeen = ts;
                                    treatAsNotRenderable = true;
                                }
                            }
                            if (!treatAsNotRenderable) {
                                // Stale ring (call lifetime is 30 seconds; we
                                // poll every 15 minutes). Show "Missed call"
                                // so the user knows somebody tried, without
                                // phantom-ringing a long-dead call via
                                // CallStyle.
                                posted = VojoFirebaseMessagingService
                                    .renderMissedCallNotification(ctx, flattened);
                                if (posted && composite != null) {
                                    // Mark the composite so the next polling
                                    // cycle observing a re-ring for the same
                                    // session doesn't double-post.
                                    NotificationDedup.markNotified(ctx, composite);
                                }
                            }
                        } else if (isRtcType) {
                            // Non-ring RTC sub-type. MSC4075 defines at least
                            // "ring" and "notification" — the latter is the
                            // chat-style alert variant which doesn't make
                            // sense to surface as a stale "missed" entry from
                            // a 15-minute poll. Falling through to
                            // renderMessageNotification would post a generic
                            // "New message" with no body (no content.body on
                            // RTC events). Skip rendering but still mark seen
                            // so we don't re-walk it next poll.
                            treatAsNotRenderable = true;
                        } else {
                            posted = VojoFirebaseMessagingService
                                .renderMessageNotification(ctx, flattened, null);
                        }
                    }
                    // Mark + advance ts whenever we've consumed the event
                    // (foreground-skipped, non-ring-RTC skipped, or
                    // successfully rendered). Render-failure (bg branch where
                    // posted==false) is intentionally excluded so the next
                    // poll retries it.
                    if (inForeground || posted || treatAsNotRenderable) {
                        NotificationDedup.markNotified(ctx, eventId);
                        if (ts > highestTsSeen) highestTsSeen = ts;
                        if (posted) renderedCount += 1;
                    }
                }

                pagesFetched += 1;
                // optString returns the fallback only when the key is absent;
                // a literal JSON `null` becomes the string "null" — guard
                // against the rare server quirk so we don't loop on it.
                String rawNext = body.optString("next_token", null);
                if (rawNext == null || rawNext.isEmpty() || "null".equals(rawNext)) {
                    nextFrom = null;
                } else {
                    nextFrom = rawNext;
                }
                pendingCursor = nextFrom;
                if (nextFrom == null) {
                    paginationExhausted = true;
                    break;
                }
            }
        } catch (UnauthorizedException e) {
            Log.w(TAG, "poll: 401 — clearing credentials, awaiting next foreground re-bridge");
            prefs.edit()
                .remove(KEY_ACCESS_TOKEN)
                .apply();
            return Result.success();
        } catch (ProxyRouting.RelayUnavailableException e) {
            // Proxy enabled but the relay couldn't start (e.g. Keystore not yet
            // usable pre-unlock after a reboot). Fail-closed: skip this cycle
            // rather than poll direct and leak IP/SNI. Result.success() (not
            // retry) so we wait for the next scheduled fire instead of an
            // accelerated retry-storm while the device is still locked.
            Log.w(TAG, "poll: proxy relay unavailable — skipping cycle (fail-closed)");
            return Result.success();
        } catch (ForbiddenException e) {
            // 403 from Synapse is usually rate-limit or a transient server
            // policy reject, not a dead token. Don't clear credentials —
            // just let the next periodic fire retry. Avoid Result.retry()
            // because we don't want an immediate accelerated retry that
            // amplifies the rate-limit cause.
            Log.w(TAG, "poll: 403/429 — skipping this cycle, will retry on next scheduled fire");
            return Result.success();
        } catch (Throwable t) {
            Log.w(TAG, "poll: failed at page " + pagesFetched, t);
            return Result.retry();
        }

        // Final stopped-check before persisting state. If cancellation landed
        // between the last in-loop check and here, do NOT apply: the
        // accumulated editor writes would otherwise overwrite KEY_LAST_SEEN_TS
        // and KEY_DRAIN_CURSOR AFTER JS clearSession wiped them, leaking
        // stale state from the just-logged-out account into the next login.
        if (isStopped()) return Result.success();

        SharedPreferences.Editor editor = prefs.edit();
        // Drain-mode bookkeeping. Three transitions:
        //  - normal → normal (cap not hit): advance watermark to highestTsSeen.
        //  - normal → drain (cap hit, no prior drain): save continuation
        //    cursor AND snapshot drainTargetTs = highestTsSeen. The current
        //    run's highest ts becomes the "fast-forward" target for when
        //    drain eventually completes — without this, the bounded LRU
        //    could evict the original head events and let the post-drain
        //    normal run re-render them.
        //  - drain → drain (still capped): keep cursor + target unchanged.
        //    Don't overwrite drainTargetTs with this run's highestTsSeen,
        //    because drain pages are always OLDER than the original head.
        //  - drain → normal (drain complete): clear cursor + target. Advance
        //    watermark to drainTargetTs — drain pages always walk backwards
        //    (older than the snapshotted head), so highestTsSeen accumulated
        //    during drain is by construction ≤ drainTargetTs.
        boolean cappedWithMore = !reachedWatermark && !paginationExhausted && pendingCursor != null;
        long newWatermark = watermark;
        String drainState;
        if (cappedWithMore) {
            editor.putString(KEY_DRAIN_CURSOR, pendingCursor);
            if (!wasDraining) {
                // First run entering drain mode — snapshot the head ts.
                editor.putLong(KEY_DRAIN_TARGET_TS, highestTsSeen);
                drainState = "drain-entered";
            } else {
                drainState = "drain-continued";
            }
        } else {
            editor.remove(KEY_DRAIN_CURSOR);
            editor.remove(KEY_DRAIN_TARGET_TS);
            long advanceTo = wasDraining ? drainTargetTs : highestTsSeen;
            if (advanceTo > watermark) {
                editor.putLong(KEY_LAST_SEEN_TS, advanceTo);
                newWatermark = advanceTo;
            }
            drainState = wasDraining ? "drain-exited" : "normal";
        }
        editor.apply();

        Log.i(TAG, "poll: done pages=" + pagesFetched
            + " rendered=" + renderedCount
            + " dedupSkipped=" + skippedDedupCount
            + " watermark=" + newWatermark
            + " state=" + drainState);
        return Result.success();
    }

    // Returns true iff at least one element of entry.actions is the literal
    // string "notify". Per Matrix spec §13.13.1, tweak objects
    // (`{set_tweak: ...}`) only MODIFY a notification produced by a separate
    // `"notify"` action — they do not by themselves imply notify. "dont_notify"
    // or an empty actions array means the push rule explicitly suppressed
    // this event (most commonly: a muted room).
    private static boolean notifyAllowed(JSONObject entry) {
        JSONArray actions = entry.optJSONArray("actions");
        if (actions == null || actions.length() == 0) return false;
        for (int i = 0; i < actions.length(); i += 1) {
            Object a = actions.opt(i);
            if ((a instanceof String) && "notify".equals(a)) return true;
        }
        return false;
    }

    // ────────────────────────────────────────────────────────────────────
    // HTTP
    // ────────────────────────────────────────────────────────────────────

    private static final class UnauthorizedException extends IOException {
        UnauthorizedException() {
            super("401 Unauthorized");
        }
    }

    // 403 from Synapse is most commonly a rate-limit or a transient policy
    // reject (M_LIMIT_EXCEEDED, M_FORBIDDEN). It is NOT "token died" — we
    // surface it as a distinct exception so doWork can skip this cycle
    // without clearing credentials and without an accelerated Result.retry()
    // that would amplify the rate-limit cause.
    private static final class ForbiddenException extends IOException {
        ForbiddenException() {
            super("403 Forbidden");
        }
    }

    private JSONObject fetchNotifications(String homeserverUrl, String token, String fromCursor)
        throws IOException {
        StringBuilder url = new StringBuilder(homeserverUrl);
        if (!homeserverUrl.endsWith("/")) url.append('/');
        url.append("_matrix/client/v3/notifications?limit=").append(PAGE_LIMIT);
        if (fromCursor != null && !fromCursor.isEmpty()) {
            url.append("&from=").append(java.net.URLEncoder.encode(fromCursor, "UTF-8"));
        }

        // Route through the loopback relay when a proxy is enabled — the
        // /notifications poll is exactly the path a censored intranet user
        // needs proxied (§15 #3). Identical to before when the proxy is off.
        HttpURLConnection conn = ProxyRouting.openConnection(getApplicationContext(), new URL(url.toString()));
        try {
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Authorization", "Bearer " + token);
            conn.setRequestProperty("Accept", "application/json");
            // Identifiable UA so server logs can attribute polling traffic
            // (some WAFs also flag bare "Java/<version>" as suspicious).
            conn.setRequestProperty("User-Agent", "Vojo-Android-Poll/" + BuildConfig.VERSION_NAME);
            conn.setConnectTimeout(HTTP_TIMEOUT_MS);
            conn.setReadTimeout(HTTP_TIMEOUT_MS);
            int code = conn.getResponseCode();
            if (code == 401) throw new UnauthorizedException();
            // Treat 429 (rate limited) and 403 (Synapse policy reject) the
            // same: skip this cycle, don't retry-storm. Result.retry()'s 30s
            // backoff would amplify the rate-limit cause; the next periodic
            // fire in 15 minutes is well past any realistic Retry-After
            // window from a Matrix homeserver.
            if (code == 403 || code == 429) throw new ForbiddenException();
            if (code < 200 || code >= 300) {
                throw new IOException("HTTP " + code);
            }
            try (InputStream in = conn.getInputStream()) {
                return new JSONObject(readAll(in));
            } catch (org.json.JSONException je) {
                throw new IOException("malformed JSON", je);
            }
        } finally {
            conn.disconnect();
        }
    }

    private static String readAll(InputStream in) throws IOException {
        // Accumulate raw bytes, then decode the whole buffer as a single UTF-8
        // string. Decoding each 8 KB chunk separately would corrupt multi-byte
        // sequences that straddle a chunk boundary — for a Russian-content
        // notification body that crosses ~8 KB, the result is U+FFFD in place
        // of a Cyrillic character. Also use != -1 rather than > 0 for the
        // read loop: InputStream.read(byte[]) is contractually allowed to
        // return 0 without indicating EOF.
        java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
        byte[] buf = new byte[8 * 1024];
        int n;
        while ((n = in.read(buf)) != -1) {
            if (n > 0) out.write(buf, 0, n);
        }
        return out.toString("UTF-8");
    }

    // ────────────────────────────────────────────────────────────────────
    // Payload shaping
    //
    // The /notifications response shape is structured (event{type,sender,
    // content{}}, room_id, ts, read, actions) — different from Sygnal's
    // flattened FCM payload. We flatten into the Sygnal-shape Map<String,
    // String> so the shared renderer in VojoFirebaseMessagingService can
    // stay source-agnostic. Keys we set: event_id, room_id, sender, type,
    // content_membership, content_body, content_notification_type,
    // content_sender_ts, content_lifetime, room_name (from local cache).
    //
    // NOTE: sender_display_name is NOT set here — /notifications returns the
    // raw event without the Sygnal-side profile resolution that gives FCM
    // its `sender_display_name`. The renderer's title-fallback chain
    // (room_name → sender_display_name → sender → "Vojo") therefore lands
    // on `sender` (a raw MXID) when the room name isn't cached. The renderer
    // strips the MXID to its local-part as a final cosmetic guard so users
    // see "alice" instead of "@alice:hs.tld".
    // ────────────────────────────────────────────────────────────────────

    private static Map<String, String> flattenNotification(
        JSONObject entry, Map<String, String> roomNames
    ) {
        Map<String, String> out = new HashMap<>();
        String roomId = entry.optString("room_id", null);
        if (roomId != null) out.put("room_id", roomId);

        JSONObject event = entry.optJSONObject("event");
        if (event != null) {
            putIfPresent(out, event, "event_id", "event_id");
            putIfPresent(out, event, "sender", "sender");
            putIfPresent(out, event, "type", "type");
            JSONObject content = event.optJSONObject("content");
            if (content != null) {
                putIfPresent(out, content, "membership", "content_membership");
                putIfPresent(out, content, "body", "content_body");
                putIfPresent(out, content, "notification_type", "content_notification_type");
                if (content.has("sender_ts")) {
                    out.put("content_sender_ts", String.valueOf(content.optLong("sender_ts")));
                }
                if (content.has("lifetime")) {
                    out.put("content_lifetime", String.valueOf(content.optLong("lifetime")));
                }
                // Parent call event_id for session-level dedup. The shared
                // FCM renderer reads this from the flattened key
                // `content_m.relates_to_event_id` (mirroring one of Sygnal's
                // flatten shapes); writing the literal-dot variant here keeps
                // FCM and polling on the same key.
                JSONObject relates = content.optJSONObject("m.relates_to");
                if (relates != null) {
                    String parentEventId = relates.optString("event_id", null);
                    if (parentEventId != null && !parentEventId.isEmpty()) {
                        out.put("content_m.relates_to_event_id", parentEventId);
                    }
                }
                // Legacy MSC2746 call_id fallback. Modern MSC4075 sessions
                // surface via m.relates_to above; this branch is a no-op for
                // them but keeps the shape symmetric for older deployments.
                if (content.has("call_id")) {
                    String callId = content.optString("call_id", null);
                    if (callId != null && !callId.isEmpty()) {
                        out.put("content_call_id", callId);
                    }
                }
            }
        }

        // Room name from the snapshot the JS side pushes through
        // PollingPlugin.saveRoomNames, parsed once at the start of doWork().
        // Brand-new rooms (not yet observed by JS at last bridge time) miss
        // the cache — the renderer falls back to sender / "Vojo".
        if (roomId != null) {
            String roomName = roomNames.get(roomId);
            if (roomName != null && !roomName.isEmpty()) out.put("room_name", roomName);
        }

        return out;
    }

    // Parse the SharedPreferences-stored room-name JSON snapshot once per
    // doWork() so we don't redo the parse for every event in the page (up to
    // PAGE_LIMIT × MAX_PAGES_PER_RUN = 250 events).
    //
    // The snapshot shape evolved: legacy was {roomId: "Display name"}, current
    // is {roomId: {name, isDirect, isEncrypted, avatarMxc?}}. We parse both
    // tolerantly — for the structured shape we extract `name`, for the legacy
    // shape we use the string verbatim. A naive optString on the structured
    // entry serialises the whole object as JSON ("{name:Alice,...}") and that
    // string leaked into the missed-call / message title on the polling
    // path — visible bug.
    private static Map<String, String> loadRoomNamesMap(SharedPreferences prefs) {
        Map<String, String> out = new HashMap<>();
        String raw = prefs.getString(KEY_ROOM_NAMES, null);
        if (raw == null || raw.isEmpty()) return out;
        try {
            JSONObject map = new JSONObject(raw);
            for (Iterator<String> it = map.keys(); it.hasNext(); ) {
                String roomId = it.next();
                if (map.isNull(roomId)) continue;
                JSONObject obj = map.optJSONObject(roomId);
                String name = obj != null
                    ? obj.optString("name", null)
                    : map.optString(roomId, null);
                if (name != null && !name.isEmpty()) out.put(roomId, name);
            }
        } catch (org.json.JSONException je) {
            // Corrupt blob — return empty map. Renderer falls back to sender.
        }
        return out;
    }

    private static void putIfPresent(
        Map<String, String> out, JSONObject src, String srcKey, String dstKey
    ) {
        // Guard against a literal JSON null at the key: JSONObject.optString
        // returns the *fallback* only when the key is absent, but on a
        // present-but-null key it coerces JSONObject.NULL to the four-char
        // string "null", which would leak as "null" into a notification body.
        if (!src.has(srcKey) || src.isNull(srcKey)) return;
        String v = src.optString(srcKey, null);
        if (v != null && !v.isEmpty()) out.put(dstKey, v);
    }

    private static String extractEventId(JSONObject entry) {
        JSONObject event = entry.optJSONObject("event");
        if (event == null) return null;
        if (!event.has("event_id") || event.isNull("event_id")) return null;
        String eventId = event.optString("event_id", null);
        if (eventId == null || eventId.isEmpty()) return null;
        return eventId;
    }


}
