package com.miraclegarden.smsmessage.xposed.hook;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;

import com.miraclegarden.smsmessage.xposed.HookBridge;
import com.miraclegarden.smsmessage.xposed.HookForwarder;

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

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;

/**
 * TNG eWallet Money Packet（群红包）领取统计。
 * 从 HTTP 响应 JSON / Gson 反序列化结果中提取 receiverList（昵称 + 金额），转发到 notiMessage。
 */
public final class TngMoneyPacketHook {

    private static final String TAG = "notiMessageHook/TngMmp";
    private static final String PACKAGE = TngRootBypassHook.PACKAGE;
    private static final int DEDUP_SIZE = 256;

    private static final ArrayDeque<String> RECENT_KEYS = new ArrayDeque<>();
    private static final HashSet<String> RECENT_SET = new HashSet<>();
    /** activityId → 上次转发时间 */
    private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
    /** activityId → 领取指纹 */
    private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
    /** 同一 activityId + 相同领取名单短时不重复推（可由领取台设置覆盖） */
    private static final long DEFAULT_PACKET_DEDUP_MS = 3_000L;
    private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();

    /** 历史列表触发的自动拉详情：已排队 / 上次成功时间 */
    private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
    private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
    private static final long DEFAULT_DETAIL_COOLDOWN_MS = 8_000L;
    /** activityId → 历史列表里的发放时间 createTime */
    private static final java.util.HashMap<String, String> ACTIVITY_ISSUE_TIME =
            new java.util.HashMap<>();
    /** 已成功拿到领取名单的 activityId */
    private static final java.util.HashSet<String> PACKETS_WITH_DATA = new java.util.HashSet<>();
    /** 已领完且已有数据 → 不再自动刷详情 */
    private static final java.util.HashSet<String> COMPLETED_PACKETS = new java.util.HashSet<>();
    /** 自动拉历史列表冷却（无需打开历史页；可由领取台设置覆盖） */
    private static final long DEFAULT_HISTORY_COOLDOWN_MS = 8_000L;
    private static final long DEFAULT_DETAIL_GAP_MS = 80L;
    private static final long SETTINGS_POLL_MS = 10_000L;
    /** 与 AppConfig.DEBUG_SERVER_URLS 对齐 */
    private static final String[] SETTINGS_BASE_URLS = {
            "http://127.0.0.1:8765",
            "http://10.151.104.25:8765",
    };
    private static volatile long sHistoryCooldownMs = DEFAULT_HISTORY_COOLDOWN_MS;
    private static volatile long sDetailCooldownMs = DEFAULT_DETAIL_COOLDOWN_MS;
    private static volatile long sPacketDedupMs = DEFAULT_PACKET_DEDUP_MS;
    private static volatile long sDetailGapMs = DEFAULT_DETAIL_GAP_MS;
    private static volatile boolean sOpenHistoryIfNoTemplate = true;
    /** 断线/拉历史失败时：自动进一下历史页再返回，重建模板与连接 */
    private static volatile boolean sAutoBounceHistoryOnDisconnect = true;
    private static volatile long sLastSettingsPollAt;
    private static volatile long sLastAutoHistoryAt;
    private static volatile boolean sAutoHistoryRunning;
    private static volatile long sLastHistoryBounceAt;
    private static volatile boolean sBounceHistoryPending;
    private static volatile long sLastHookStatusAt;
    private static final long HISTORY_BOUNCE_COOLDOWN_MS = 45_000L;
    private static final long HISTORY_BOUNCE_FINISH_DELAY_MS = 2_200L;
    private static final long HOOK_STATUS_COOLDOWN_MS = 20_000L;
    /** 曾拿到过 session，之后变空 / RPC 鉴权失败 → 视为断线，恢复后强制重拉 */
    private static volatile boolean sHadSession;
    private static volatile boolean sSessionDisconnected;
    /** 本线程正在主动拉历史，避免 Quake afterHook 再 schedule 一遍 */
    private static final ThreadLocal<Boolean> AUTO_HISTORY_SELF = new ThreadLocal<>();
    private static volatile Object sMoneyPacketRepository;
    private static volatile Object sMoneyPacketRpcTask;
    private static volatile ClassLoader sAppClassLoader;
    private static volatile String sCachedUserId;
    private static volatile String sCachedSessionId;
    private static volatile String sCachedLoginId;
    /** 真实详情/历史请求模板（有则克隆；无则用 ILoginStorage 自造） */
    private static volatile Object sTemplateDetailRequest;
    private static volatile Object sTemplateHistoryRequest;
    /** 最近一次带登录态的请求（历史/详情），用于给自造详情请求补 session */
    private static volatile Object sLoginDonorRequest;
    private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
    private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
            java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
                Thread t = new Thread(r, "TngMmp-auto-detail");
                t.setDaemon(true);
                return t;
            });

    private TngMoneyPacketHook() {
    }

    public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
        if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
            return;
        }
        sAppClassLoader = lpparam.classLoader;
        hookOkHttpUrl(lpparam);
        hookResponseBody(lpparam);
        hookGsonFromJson(lpparam);
        hookQuakeRpc(lpparam);
        hookLoginStorage(lpparam);
        hookActivityResumeForAutoHistory(lpparam);
        startSettingsPoller();
        startAutoHistoryPoller();
        reportHookAlive("install");
        XposedBridge.log(TAG + " installed for " + lpparam.packageName
                + " mod=" + HookBridge.MODULE_VERSION_NAME
                + "/" + HookBridge.MODULE_VERSION_CODE);
    }

    /** 按历史冷却定时拉列表 + 心跳，避免无新红包时状态条误报「久无数据」 */
    private static void startAutoHistoryPoller() {
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    reportHookAlive("poll");
                    scheduleAutoHistory("poll");
                    long sleepMs = Math.max(5_000L, sHistoryCooldownMs);
                    Thread.sleep(sleepMs);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    return;
                } catch (Throwable ignored) {
                    try {
                        Thread.sleep(Math.max(5_000L, sHistoryCooldownMs));
                    } catch (InterruptedException ie2) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
            }
        }, "TngMmp-auto-hist-poll");
        t.setDaemon(true);
        t.start();
    }

    /** 后台轮询领取台 /api/mmp/settings，约 10 秒内生效 */
    private static void startSettingsPoller() {
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    pollMmpSettings(true);
                    Thread.sleep(SETTINGS_POLL_MS);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    return;
                } catch (Throwable ignored) {
                    try {
                        Thread.sleep(SETTINGS_POLL_MS);
                    } catch (InterruptedException ie2) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
            }
        }, "TngMmp-settings");
        t.setDaemon(true);
        t.start();
    }

    private static void pollMmpSettings(boolean force) {
        long now = System.currentTimeMillis();
        if (!force && now - sLastSettingsPollAt < SETTINGS_POLL_MS) {
            return;
        }
        sLastSettingsPollAt = now;
        for (String base : SETTINGS_BASE_URLS) {
            if (tryApplySettingsFromUrl(base + "/api/mmp/settings")) {
                return;
            }
        }
    }

    private static boolean tryApplySettingsFromUrl(String urlStr) {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(urlStr);
            conn = (HttpURLConnection) url.openConnection();
            conn.setConnectTimeout(2500);
            conn.setReadTimeout(2500);
            conn.setRequestMethod("GET");
            conn.setUseCaches(false);
            int code = conn.getResponseCode();
            if (code != 200) {
                return false;
            }
            StringBuilder sb = new StringBuilder();
            try (BufferedReader br = new BufferedReader(
                    new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while ((line = br.readLine()) != null) {
                    sb.append(line);
                }
            }
            JSONObject json = new JSONObject(sb.toString());
            long historyMs = Math.max(1, json.optInt("historyCooldownSec", 8)) * 1000L;
            long detailMs = Math.max(1, json.optInt("detailCooldownSec", 8)) * 1000L;
            long dedupMs = Math.max(0, json.optInt("dedupSec", 3)) * 1000L;
            long gapMs = Math.max(0, json.optInt("detailGapMs", 80));
            boolean openHistory = json.optBoolean("openHistoryIfNoTemplate", true);
            boolean bounceHistory = json.optBoolean("autoBounceHistoryOnDisconnect", true);
            boolean changed = historyMs != sHistoryCooldownMs
                    || detailMs != sDetailCooldownMs
                    || dedupMs != sPacketDedupMs
                    || gapMs != sDetailGapMs
                    || openHistory != sOpenHistoryIfNoTemplate
                    || bounceHistory != sAutoBounceHistoryOnDisconnect;
            sHistoryCooldownMs = historyMs;
            sDetailCooldownMs = detailMs;
            sPacketDedupMs = dedupMs;
            sDetailGapMs = gapMs;
            sOpenHistoryIfNoTemplate = openHistory;
            sAutoBounceHistoryOnDisconnect = bounceHistory;
            if (changed) {
                XposedBridge.log(TAG + " settings applied hist=" + (historyMs / 1000)
                        + "s detail=" + (detailMs / 1000) + "s dedup=" + (dedupMs / 1000)
                        + "s gap=" + gapMs + "ms openHist=" + openHistory
                        + " bounceHist=" + bounceHistory
                        + " from " + urlStr);
            }
            return true;
        } catch (Throwable ignored) {
            return false;
        } finally {
            if (conn != null) {
                conn.disconnect();
            }
        }
    }

    private static void hookOkHttpUrl(XC_LoadPackage.LoadPackageParam lpparam) {
        try {
            XposedHelpers.findAndHookMethod(
                    "okhttp3.Request$Builder",
                    lpparam.classLoader,
                    "build",
                    new XC_MethodHook() {
                        @Override
                        protected void afterHookedMethod(MethodHookParam param) {
                            try {
                                Object url = XposedHelpers.callMethod(param.getResult(), "url");
                                if (url != null) {
                                    CURRENT_REQUEST_URL.set(String.valueOf(url));
                                }
                            } catch (Throwable ignored) {
                            }
                        }
                    });
        } catch (Throwable t) {
            XposedBridge.log(TAG + " Request.Builder.build hook failed: " + t.getMessage());
        }
    }

    private static void hookResponseBody(XC_LoadPackage.LoadPackageParam lpparam) {
        try {
            XposedHelpers.findAndHookMethod(
                    "okhttp3.ResponseBody",
                    lpparam.classLoader,
                    "string",
                    new XC_MethodHook() {
                        @Override
                        protected void afterHookedMethod(MethodHookParam param) {
                            String body = (String) param.getResult();
                            String url = CURRENT_REQUEST_URL.get();
                            CURRENT_REQUEST_URL.remove();
                            if (!isMmpPayload(url, body)) {
                                return;
                            }
                            forwardParsedJson(body, url, "http");
                        }
                    });
        } catch (Throwable t) {
            XposedBridge.log(TAG + " ResponseBody.string hook failed: " + t.getMessage());
        }
    }

    private static void hookGsonFromJson(XC_LoadPackage.LoadPackageParam lpparam) {
        XC_MethodHook hook = new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) {
                Object result = param.getResult();
                if (result == null) {
                    return;
                }
                String className = result.getClass().getName();
                if (!isMmpModelClass(className)) {
                    return;
                }
                try {
                    String json = buildJsonFromObject(result);
                    if (TextUtils.isEmpty(json)) {
                        return;
                    }
                    forwardParsedJson(json, className, "gson");
                } catch (Throwable t) {
                    XposedBridge.log(TAG + " gson capture failed: " + t.getMessage());
                }
            }
        };
        try {
            XposedHelpers.findAndHookMethod(
                    "com.google.gson.Gson",
                    lpparam.classLoader,
                    "fromJson",
                    String.class,
                    Class.class,
                    hook);
        } catch (Throwable t) {
            XposedBridge.log(TAG + " Gson.fromJson(Class) hook failed: " + t.getMessage());
        }
        try {
            XposedHelpers.findAndHookMethod(
                    "com.google.gson.Gson",
                    lpparam.classLoader,
                    "fromJson",
                    String.class,
                    java.lang.reflect.Type.class,
                    hook);
        } catch (Throwable ignored) {
        }
    }

    private static boolean isMmpPayload(String url, String body) {
        if (TextUtils.isEmpty(body) || body.length() < 24) {
            return false;
        }
        String lower = body.toLowerCase(Locale.US);
        boolean jsonHit = lower.contains("receiverlist")
                || lower.contains("\"claimedamount\"")
                || lower.contains("mmpreceiver")
                || (lower.contains("nick") && lower.contains("amount") && lower.contains("mmp"));
        if (jsonHit) {
            return true;
        }
        if (TextUtils.isEmpty(url)) {
            return false;
        }
        String urlLower = url.toLowerCase(Locale.US);
        return urlLower.contains("mmp") || urlLower.contains("moneypacket");
    }

    private static boolean isMmpModelClass(String className) {
        if (TextUtils.isEmpty(className)) {
            return false;
        }
        String n = className;
        return n.contains("MmpDetail")
                || n.contains("MmpClaim")
                || n.contains("MmpReceiver")
                || n.contains("MmpLeaderboard")
                || n.contains("MoneyPacketDetail")
                || n.contains("MoneyPacketClaim")
                || n.contains("MoneyPacketLeader")
                || n.contains("MoneyPacketDoClaim")
                || n.contains("ClaimResultDto")
                || n.contains("ClaimDto")
                || n.contains("ReceiverList")
                || n.contains("packetReceiver");
    }

    private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
        // TNG 封装 + Quake 底层都挂，避免历史/详情只走其中一条链路时漏抓
        String[] rpcClasses = {
                "my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
                "com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
        };
        XC_MethodHook hook = new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) {
                if (param.hasThrowable() || param.getResult() == null) {
                    return;
                }
                cacheMoneyPacketRpcTask(param.args);
                cacheLoginFromInvokeArgs(param.args);
                String op = extractMoneyPacketRpcOp(param.args);
                if (op == null) {
                    return;
                }
                XposedBridge.log(TAG + " rpc op=" + op
                        + " via=" + param.thisObject.getClass().getSimpleName());
                Object result = param.getResult();
                // 历史列表：无领取排行，但可用 activityId 自动拉详情
                if (isHistoryRpcOp(op)) {
                    Object historyReq = extractRpcRequestArg(param.args);
                    if (historyReq != null) {
                        sTemplateHistoryRequest = cloneRequestShallow(historyReq);
                        sLoginDonorRequest = historyReq;
                        cacheLoginFromRequestObject(historyReq);
                        XposedBridge.log(TAG + " cached history request template"
                                + " session=" + abbreviate(stringField(historyReq,
                                "sessionId", "getSessionId")));
                    }
                    // 无论是否自己触发的历史请求，都先缓存发放时间
                    cacheIssueTimesFromHistory(result);
                    if (Boolean.TRUE.equals(AUTO_HISTORY_SELF.get())) {
                        return;
                    }
                    XposedBridge.log(TAG + " history hit → schedule auto detail");
                    scheduleAutoDetailsFromHistory(result);
                    return;
                }
                if (!isDetailRpcOp(op)) {
                    return;
                }
                String className = result.getClass().getName();
                XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
                try {
                    Object detailReq = extractRpcRequestArg(param.args);
                    String json = buildJsonFromObject(result);
                    if (TextUtils.isEmpty(json)) {
                        json = tryFastjson(result);
                    }
                    if (TextUtils.isEmpty(json)) {
                        XposedBridge.log(TAG + " rpc serialize empty op=" + op);
                        return;
                    }
                    MmpSnapshot probe = parseSnapshot(json);
                    enrichSnapshotFromJavaResult(result, probe);
                    if (probe != null && (!probe.claims.isEmpty() || snapshotHasPacketMeta(probe))) {
                        if (detailReq != null) {
                            sTemplateDetailRequest = cloneRequestShallow(detailReq);
                            sLoginDonorRequest = detailReq;
                            cacheLoginFromRequestObject(detailReq);
                            XposedBridge.log(TAG + " cached detail request template"
                                    + " userId=" + stringField(detailReq, "userId", "getUserId")
                                    + " session=" + abbreviate(stringField(detailReq,
                                    "sessionId", "getSessionId"))
                                    + " page=" + stringField(detailReq, "page", "getPage")
                                    + " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
                        }
                        forwardSnapshot(probe, op, "rpc");
                    } else {
                        String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
                        XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
                    }
                } catch (Throwable t) {
                    XposedBridge.log(TAG + " rpc capture failed: " + t.getMessage());
                }
            }
        };
        for (String className : rpcClasses) {
            try {
                Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
                int hooked = 0;
                for (Method method : clazz.getDeclaredMethods()) {
                    if (!"invoke".equals(method.getName())) {
                        continue;
                    }
                    XposedBridge.hookMethod(method, hook);
                    hooked++;
                }
                if (hooked > 0) {
                    XposedBridge.log(TAG + " hooked RPC " + className + " invoke=" + hooked);
                }
            } catch (Throwable t) {
                XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
            }
        }
        hookMoneyPacketRepository(lpparam);
    }

    /** DI 创建 Repository 时立刻缓存 task + login，不必等用户进红包页 */
    private static void hookMoneyPacketRepository(XC_LoadPackage.LoadPackageParam lpparam) {
        try {
            Class<?> repoCls = XposedHelpers.findClass(
                    "my.com.tngdigital.funding.moneypacket.common.data.repo.MoneyPacketRepositoryImpl",
                    lpparam.classLoader);
            XposedBridge.hookAllConstructors(repoCls, new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) {
                    captureRepoInstance(param.thisObject, "ctor");
                }
            });
            // Dagger 可能先无参构造再注入字段；在业务方法入口再捕获一次
            for (String methodName : new String[]{
                    "getMoneyPacketList", "getMoneyPacketDetail", "doClaim", "preClaim"}) {
                try {
                    for (Method m : repoCls.getDeclaredMethods()) {
                        if (!methodName.equals(m.getName())) {
                            continue;
                        }
                        XposedBridge.hookMethod(m, new XC_MethodHook() {
                            @Override
                            protected void beforeHookedMethod(MethodHookParam param) {
                                captureRepoInstance(param.thisObject, "method:" + methodName);
                            }
                        });
                    }
                } catch (Throwable ignored) {
                }
            }
            XposedBridge.log(TAG + " hooked MoneyPacketRepositoryImpl");
        } catch (Throwable t) {
            XposedBridge.log(TAG + " repo hook skip: " + t.getMessage());
        }
    }

    private static void captureRepoInstance(Object repo, String reason) {
        if (repo == null) {
            return;
        }
        try {
            sMoneyPacketRepository = repo;
            Object task = null;
            try {
                task = XposedHelpers.getObjectField(repo, "task");
            } catch (Throwable ignored) {
            }
            if (task != null && sMoneyPacketRpcTask == null) {
                sMoneyPacketRpcTask = task;
                XposedBridge.log(TAG + " cached RPC task from repo (" + reason + ")");
            }
            Object loginStorage = null;
            try {
                loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
            } catch (Throwable ignored) {
            }
            applyLoginStorage(loginStorage);
            if (sMoneyPacketRpcTask != null && !TextUtils.isEmpty(sCachedSessionId)) {
                scheduleAutoHistory("repo-" + reason);
            }
        } catch (Throwable t) {
            XposedBridge.log(TAG + " captureRepo fail: " + t.getMessage());
        }
    }

    /** 与 App 一致：发 RPC 前走 Repository.addNetworkHandle，否则易 RpcApiException / 空结果 */
    private static void prepareRpcRequest(Object req) {
        if (req == null) {
            return;
        }
        Object repo = sMoneyPacketRepository;
        if (repo == null) {
            repo = obtainMoneyPacketRepository(sAppClassLoader);
            if (repo != null) {
                sMoneyPacketRepository = repo;
            }
        }
        if (repo == null) {
            XposedBridge.log(TAG + " prepareRpc skip: no repository");
            return;
        }
        try {
            // addNetworkHandle$default(repo, request, processor=null, onError=null, mask=3, marker=null)
            XposedHelpers.callStaticMethod(repo.getClass(), "addNetworkHandle$default",
                    repo, req, null, null, 3, null);
            XposedBridge.log(TAG + " prepareRpc addNetworkHandle$default ok");
            return;
        } catch (Throwable t) {
            XposedBridge.log(TAG + " addNetworkHandle$default fail: " + t.getMessage());
        }
        try {
            XposedHelpers.callMethod(repo, "addNetworkHandle", req, null, null);
            XposedBridge.log(TAG + " prepareRpc addNetworkHandle ok");
        } catch (Throwable t) {
            XposedBridge.log(TAG + " addNetworkHandle fail: " + t.getMessage());
        }
    }

    private static boolean isHistoryRpcOp(String op) {
        if (TextUtils.isEmpty(op)) {
            return false;
        }
        String lower = op.toLowerCase(Locale.US);
        return lower.contains("historylist")
                || lower.contains("history")
                || lower.contains("list.retrieve")
                || lower.equals("moneypackethistorylist");
    }

    /** 只要详情/领取查询，历史列表走 auto-detail */
    private static boolean isDetailRpcOp(String op) {
        if (TextUtils.isEmpty(op) || isHistoryRpcOp(op)) {
            return false;
        }
        String lower = op.toLowerCase(Locale.US);
        return lower.contains("detail")
                || lower.contains("retrieve")
                || lower.contains("claim.result")
                || lower.contains("leaderboard")
                || lower.contains("moneypacketdetail");
    }

    /** InvocationHandler.invoke(proxy, method, args) → 缓存 MoneyPacketRpcTask 代理 */
    private static void cacheMoneyPacketRpcTask(Object[] args) {
        if (args == null || args.length == 0) {
            return;
        }
        try {
            Object proxy = args[0];
            if (proxy == null || !java.lang.reflect.Proxy.isProxyClass(proxy.getClass())) {
                return;
            }
            if (sMoneyPacketRpcTask != null) {
                return;
            }
            for (Class<?> iface : proxy.getClass().getInterfaces()) {
                String name = iface.getName();
                if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
                    sMoneyPacketRpcTask = proxy;
                    XposedBridge.log(TAG + " cached RPC task " + name);
                    scheduleAutoHistory("rpc-task-cached");
                    return;
                }
            }
            try {
                proxy.getClass().getMethod("moneyPacketDetail",
                        XposedHelpers.findClass(
                                "my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
                                sAppClassLoader));
                sMoneyPacketRpcTask = proxy;
                XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
                scheduleAutoHistory("rpc-task-cached");
            } catch (Throwable ignored) {
            }
        } catch (Throwable ignored) {
        }
    }

    /** 从历史/详情请求里偷登录态，供自动拉详情复用 */
    private static void cacheLoginFromInvokeArgs(Object[] args) {
        cacheLoginFromRequestObject(extractRpcRequestArg(args));
    }

    private static void cacheLoginFromRequestObject(Object req) {
        if (req == null) {
            return;
        }
        String userId = stringField(req, "userId", "getUserId");
        String sessionId = stringField(req, "sessionId", "getSessionId");
        String loginId = stringField(req, "loginId", "getLoginId");
        if (!TextUtils.isEmpty(userId)) {
            sCachedUserId = userId;
        }
        if (!TextUtils.isEmpty(sessionId)) {
            sCachedSessionId = sessionId;
        }
        if (!TextUtils.isEmpty(loginId)) {
            sCachedLoginId = loginId;
        }
    }

    private static String abbreviate(String s) {
        if (TextUtils.isEmpty(s)) {
            return "-";
        }
        return s.length() <= 8 ? s : s.substring(0, 4) + "…" + s.substring(s.length() - 4);
    }

    private static Object cloneRequestShallow(Object src) {
        if (src == null) {
            return null;
        }
        try {
            Object copy = src.getClass().getDeclaredConstructor().newInstance();
            copyAllFields(src, copy);
            return copy;
        } catch (Throwable t) {
            // Kotlin data class 可能无无参构造，直接复用引用（自动拉取在单线程）
            return src;
        }
    }

    private static void copyAllFields(Object src, Object dst) {
        Class<?> c = src.getClass();
        while (c != null && c != Object.class) {
            for (java.lang.reflect.Field f : c.getDeclaredFields()) {
                if (f.getName().contains("$")) {
                    continue;
                }
                try {
                    f.setAccessible(true);
                    f.set(dst, f.get(src));
                } catch (Throwable ignored) {
                }
            }
            c = c.getSuperclass();
        }
    }

    private static void applyActivityToRequest(Object req, String activityId, String senderUserId) {
        try {
            XposedHelpers.setObjectField(req, "activityId", activityId);
        } catch (Throwable ignored) {
            trySet(req, "setActivityId", activityId);
        }
        try {
            XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
        } catch (Throwable ignored) {
            trySet(req, "setSenderUserId", senderUserId);
        }
        // 尽量一次拉全领取名单（避免默认 20 条截断）
        bumpDetailPageSize(req);
    }

    private static void bumpDetailPageSize(Object req) {
        if (req == null) {
            return;
        }
        try {
            XposedHelpers.setIntField(req, "page", 0);
        } catch (Throwable ignored) {
        }
        try {
            XposedHelpers.setIntField(req, "maxResult", 200);
        } catch (Throwable ignored) {
            try {
                XposedHelpers.setObjectField(req, "maxResult", 200);
            } catch (Throwable ignored2) {
            }
        }
    }


    private static Object extractRpcRequestArg(Object[] args) {
        if (args == null) {
            return null;
        }
        // InvocationHandler.invoke(proxy, method, Object[] methodArgs)
        if (args.length >= 3 && args[2] instanceof Object[]) {
            Object[] methodArgs = (Object[]) args[2];
            if (methodArgs.length > 0 && methodArgs[0] != null) {
                String n = methodArgs[0].getClass().getName();
                if (n.contains("Request") || n.contains("moneypacket") || n.contains("MoneyPacket")) {
                    return methodArgs[0];
                }
            }
        }
        for (Object arg : args) {
            if (arg == null || arg instanceof Method || arg instanceof String) {
                continue;
            }
            if (arg instanceof Object[]) {
                continue;
            }
            String n = arg.getClass().getName();
            if (n.contains("Request") && (n.contains("MoneyPacket") || n.contains("moneypacket")
                    || n.contains("Mmp"))) {
                return arg;
            }
        }
        return null;
    }

    private static void scheduleAutoDetailsFromHistory(Object historyResult) {
        if (historyResult == null) {
            return;
        }
        pollMmpSettings(false);
        // 解析放调用线程；真正 RPC 必须丢到主线程（Quake/登录态常绑主线程）
        final List<String[]> jobs;
        try {
            jobs = extractHistoryJobs(historyResult);
        } catch (Throwable t) {
            XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
            return;
        }
        XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
        if (jobs.isEmpty()) {
            return;
        }
            long delayMs = 0L;
            for (String[] job : jobs) {
            final String activityId = job[0];
            final String senderUserId = job[1];
            final String createTime = job.length > 2 ? job[2] : null;
            if (!TextUtils.isEmpty(createTime)) {
                synchronized (ACTIVITY_ISSUE_TIME) {
                    ACTIVITY_ISSUE_TIME.put(activityId, createTime);
                }
            }
            long now = System.currentTimeMillis();
            long detailCooldown = sDetailCooldownMs;
            if (COMPLETED_PACKETS.contains(activityId)) {
                continue;
            }
            // job[3]=remainingCount, job[4]=claimedCount/totalCount hint, job[5]=status
            String remainHint = job.length > 3 ? job[3] : null;
            String countHint = job.length > 4 ? job[4] : null;
            String statusHint = job.length > 5 ? job[5] : null;
            if (isFinishedFlags(remainHint, countHint, statusHint)
                    && PACKETS_WITH_DATA.contains(activityId)) {
                COMPLETED_PACKETS.add(activityId);
                XposedBridge.log(TAG + " skip completed(history) id=" + activityId);
                continue;
            }
            synchronized (AUTO_DETAIL_AT) {
                Long last = AUTO_DETAIL_AT.get(activityId);
                if (last != null && now - last < detailCooldown) {
                    continue;
                }
                if (!AUTO_DETAIL_PENDING.add(activityId)) {
                    continue;
                }
            }
            final long thisDelay = delayMs;
            delayMs += Math.max(0L, sDetailGapMs);
            AUTO_DETAIL_EXEC.execute(() -> {
                try {
                    if (thisDelay > 0) {
                        Thread.sleep(thisDelay);
                    }
                    boolean ok = fetchDetailByActivityId(activityId, senderUserId);
                    if (ok) {
                        synchronized (AUTO_DETAIL_AT) {
                            AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
                        }
                    }
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                } finally {
                    synchronized (AUTO_DETAIL_AT) {
                        AUTO_DETAIL_PENDING.remove(activityId);
                    }
                }
            });
        }
    }

    private static List<String[]> extractHistoryJobs(Object historyResult) {
        List<String[]> jobs = new ArrayList<>();
        try {
            Object infos = null;
            try {
                infos = XposedHelpers.callMethod(historyResult, "getParticipantInfos");
            } catch (Throwable ignored) {
            }
            if (infos == null) {
                try {
                    infos = XposedHelpers.getObjectField(historyResult, "participantInfos");
                } catch (Throwable ignored) {
                }
            }
            if (!(infos instanceof Iterable)) {
                return jobs;
            }
            for (Object info : (Iterable<?>) infos) {
                if (info == null) {
                    continue;
                }
                String activityId = stringField(info, "activityId", "getActivityId");
                String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
                String createTime = stringField(info, "createTime", "getCreateTime");
                if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
                    String remain = firstNonEmpty(
                            stringField(info, "remainingCount", "getRemainingCount"),
                            stringField(info, "remainCount", "getRemainCount"));
                    String claimed = stringField(info, "claimedCount", "getClaimedCount");
                    String totalCnt = stringField(info, "totalCount", "getTotalCount");
                    String countHint = "";
                    if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
                        countHint = nullToEmpty(claimed) + "/" + nullToEmpty(totalCnt);
                    }
                    String status = firstNonEmpty(
                            stringField(info, "activityStatus", "getActivityStatus"),
                            stringField(info, "participantStatus", "getParticipantStatus"),
                            stringField(info, "status", "getStatus"),
                            boolFieldText(info, "isFinished", "isFinished"));
                    jobs.add(new String[]{
                            activityId,
                            senderUserId,
                            TextUtils.isEmpty(createTime) ? "" : createTime,
                            nullToEmpty(remain),
                            countHint,
                            nullToEmpty(status)
                    });
                }
            }
        } catch (Throwable t) {
            XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
        }
        return jobs;
    }

    private static String stringField(Object obj, String field, String getter) {
        try {
            Object v = XposedHelpers.callMethod(obj, getter);
            if (v != null) {
                return String.valueOf(v);
            }
        } catch (Throwable ignored) {
        }
        try {
            Object v = XposedHelpers.getObjectField(obj, field);
            if (v != null) {
                return String.valueOf(v);
            }
        } catch (Throwable ignored) {
        }
        return null;
    }

    private static String boolFieldText(Object obj, String field, String getter) {
        try {
            Object v = XposedHelpers.callMethod(obj, getter);
            if (v instanceof Boolean) {
                return Boolean.TRUE.equals(v) ? "FINISHED" : null;
            }
        } catch (Throwable ignored) {
        }
        try {
            Object v = XposedHelpers.getObjectField(obj, field);
            if (v instanceof Boolean && Boolean.TRUE.equals(v)) {
                return "FINISHED";
            }
        } catch (Throwable ignored) {
        }
        return null;
    }

    private static String nullToEmpty(String s) {
        return s == null ? "" : s;
    }

    /** remaining / claimed/total / status 任一表明已领完或已结束 */
    private static boolean isFinishedFlags(String remain, String claimedSlashTotal, String status) {
        if (!TextUtils.isEmpty(remain)) {
            try {
                if (Integer.parseInt(remain.trim()) <= 0) {
                    return true;
                }
            } catch (NumberFormatException ignored) {
            }
        }
        if (!TextUtils.isEmpty(claimedSlashTotal) && claimedSlashTotal.contains("/")) {
            String[] parts = claimedSlashTotal.split("/", 2);
            try {
                int claimed = Integer.parseInt(parts[0].trim());
                int total = Integer.parseInt(parts[1].trim());
                if (total > 0 && claimed >= total) {
                    return true;
                }
            } catch (NumberFormatException ignored) {
            }
        }
        if (!TextUtils.isEmpty(status)) {
            String s = status.toUpperCase(Locale.US);
            // TNG ActivityStatus: ACTIVE/INIT/FINISHED/ENDED/CLOSED/CANCELLED
            if (s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
                    || s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
                    || s.contains("DONE") || "TRUE".equals(s)) {
                return true;
            }
        }
        return false;
    }

    private static boolean evaluateFinished(MmpSnapshot snapshot) {
        if (snapshot == null) {
            return false;
        }
        if (isFinishedFlags(snapshot.remainingCount,
                buildCountHint(snapshot.claimedCount, snapshot.totalCount),
                snapshot.activityStatus)) {
            return true;
        }
        if (isExpireTimePassed(snapshot.expireTime)) {
            return true;
        }
        // 已领金额 ≈ 总额
        Double claimedAmt = parseMoneyDouble(snapshot.claimedAmountText);
        Double totalAmt = parseMoneyDouble(snapshot.totalAmount);
        if (claimedAmt == null && snapshot.claims != null && !snapshot.claims.isEmpty()) {
            double sum = 0;
            for (ClaimLine line : snapshot.claims) {
                Double a = parseMoneyDouble(line.amount);
                if (a != null) {
                    sum += a;
                }
            }
            claimedAmt = sum;
        }
        if (claimedAmt != null && totalAmt != null && totalAmt > 0
                && Math.abs(claimedAmt - totalAmt) < 0.02) {
            return true;
        }
        return false;
    }

    /** expireTime 形如 05/08/2026 14:06:56 或 ISO；已过则视为结束 */
    private static boolean isExpireTimePassed(String expireTime) {
        long ms = parseIssueTimeMs(expireTime);
        return ms > 0 && System.currentTimeMillis() >= ms;
    }

    private static long parseIssueTimeMs(String text) {
        if (TextUtils.isEmpty(text)) {
            return 0L;
        }
        String s = text.trim();
        try {
            java.util.regex.Matcher m = java.util.regex.Pattern
                    .compile("^(\\d{2})/(\\d{2})/(\\d{4})(?:\\s+(\\d{2}):(\\d{2})(?::(\\d{2}))?)?")
                    .matcher(s);
            if (m.find()) {
                java.util.Calendar c = java.util.Calendar.getInstance();
                c.set(Integer.parseInt(m.group(3)),
                        Integer.parseInt(m.group(2)) - 1,
                        Integer.parseInt(m.group(1)),
                        m.group(4) != null ? Integer.parseInt(m.group(4)) : 23,
                        m.group(5) != null ? Integer.parseInt(m.group(5)) : 59,
                        m.group(6) != null ? Integer.parseInt(m.group(6)) : 59);
                c.set(java.util.Calendar.MILLISECOND, 999);
                return c.getTimeInMillis();
            }
            if (s.matches("^\\d{13}$")) {
                return Long.parseLong(s);
            }
            if (s.matches("^\\d{10}$")) {
                return Long.parseLong(s) * 1000L;
            }
            // 2026-08-05T14:06:56 / 2026-08-05 14:06:56
            java.util.regex.Matcher iso = java.util.regex.Pattern
                    .compile("^(\\d{4})-(\\d{2})-(\\d{2})[ T](\\d{2}):(\\d{2})(?::(\\d{2}))?")
                    .matcher(s);
            if (iso.find()) {
                java.util.Calendar c = java.util.Calendar.getInstance();
                c.set(Integer.parseInt(iso.group(1)),
                        Integer.parseInt(iso.group(2)) - 1,
                        Integer.parseInt(iso.group(3)),
                        Integer.parseInt(iso.group(4)),
                        Integer.parseInt(iso.group(5)),
                        iso.group(6) != null ? Integer.parseInt(iso.group(6)) : 0);
                c.set(java.util.Calendar.MILLISECOND, 0);
                return c.getTimeInMillis();
            }
        } catch (Throwable ignored) {
        }
        return 0L;
    }

    private static String buildCountHint(String claimed, String total) {
        if (TextUtils.isEmpty(claimed) && TextUtils.isEmpty(total)) {
            return null;
        }
        return nullToEmpty(claimed) + "/" + nullToEmpty(total);
    }

    private static Double parseMoneyDouble(String raw) {
        if (TextUtils.isEmpty(raw)) {
            return null;
        }
        try {
            String n = normalizeMoneyText(raw);
            if (TextUtils.isEmpty(n)) {
                return null;
            }
            return Double.parseDouble(n);
        } catch (Throwable ignored) {
            return null;
        }
    }

    private static void rememberPacketData(String packetId, MmpSnapshot snapshot, Object result) {
        if (snapshot == null) {
            return;
        }
        boolean hasClaims = snapshot.claims != null && !snapshot.claims.isEmpty();
        if (!hasClaims && !snapshotHasPacketMeta(snapshot)) {
            return;
        }
        String id = !TextUtils.isEmpty(packetId) ? packetId : snapshot.packetId;
        if (TextUtils.isEmpty(id) || !looksLikeActivityId(id)) {
            // 指纹 id 也记，避免反复刷
            if (!TextUtils.isEmpty(id) && hasClaims) {
                PACKETS_WITH_DATA.add(id);
            }
        } else if (hasClaims) {
            PACKETS_WITH_DATA.add(id);
        }
        if (result != null
                && TextUtils.isEmpty(snapshot.remainingCount)
                && TextUtils.isEmpty(snapshot.claimedCount)
                && TextUtils.isEmpty(snapshot.activityStatus)) {
            enrichSnapshotFromJavaResult(result, snapshot);
        }
        if (!snapshot.finished) {
            snapshot.finished = evaluateFinished(snapshot);
        }
        if (snapshot.finished && hasClaims && !TextUtils.isEmpty(id)) {
            if (COMPLETED_PACKETS.add(id)) {
                XposedBridge.log(TAG + " packet DONE stop-refresh id=" + id
                        + " claims=" + snapshot.claims.size()
                        + " remain=" + snapshot.remainingCount
                        + " status=" + snapshot.activityStatus);
            }
        }
    }

    private static void onSessionObserved(String sid) {
        boolean has = !TextUtils.isEmpty(sid) && !"null".equalsIgnoreCase(sid);
        if (has) {
            sCachedSessionId = sid;
            if (sSessionDisconnected) {
                sSessionDisconnected = false;
                XposedBridge.log(TAG + " session recovered → force re-fetch");
                scheduleAutoHistoryForced("session-recovered");
            }
            sHadSession = true;
        } else if (sHadSession) {
            if (!sSessionDisconnected) {
                sSessionDisconnected = true;
                XposedBridge.log(TAG + " session lost / disconnected");
            }
        }
    }

    private static void noteSessionAlive() {
        if (!TextUtils.isEmpty(sCachedSessionId)) {
            sHadSession = true;
            if (sSessionDisconnected) {
                sSessionDisconnected = false;
                XposedBridge.log(TAG + " connection alive again");
            }
        }
    }

    private static void tryRefreshLogin(ClassLoader cl) {
        try {
            Object ls = obtainLoginStorage(cl);
            if (ls != null) {
                applyLoginStorage(ls);
            }
        } catch (Throwable ignored) {
        }
    }

    private static void markMaybeDisconnected(Throwable t, String where) {
        String msg = t == null ? "" : String.valueOf(t.getMessage());
        if (TextUtils.isEmpty(msg) && t != null) {
            msg = t.getClass().getSimpleName();
        }
        String low = msg.toLowerCase(Locale.US);
        if (low.contains("session") || low.contains("login") || low.contains("auth")
                || low.contains("unauthorized") || low.contains("timeout")
                || low.contains("unable to resolve") || low.contains("failed to connect")
                || low.contains("network") || low.contains("disconnect")
                || low.contains("socket") || low.contains("ssl")
                || msg.contains("未登录") || msg.contains("非法") || msg.contains("登录")
                || msg.contains("过期")) {
            sSessionDisconnected = true;
            XposedBridge.log(TAG + " disconnect suspected @" + where + ": " + msg);
            tryRefreshLogin(sAppClassLoader);
        }
    }

    private static void notePossibleDisconnectFromEmpty(Object result, String activityId) {
        // 新发红包无人领取时 activityPoolInfos 为空、totalCount/claimedCount 也常为 0，不是断线
        if (looksLikeValidEmptyPacket(result, activityId)) {
            XposedBridge.log(TAG + " empty claims but valid summary id=" + activityId
                    + " → keep (0 claimants)");
            return;
        }
        // totalCount:0 + 无领取人 且无有效摘要时，才怀疑缺登录态
        String totalCnt = stringField(result, "totalCount", "getTotalCount");
        if ("0".equals(totalCnt) && sHadSession) {
            XposedBridge.log(TAG + " empty claims+totalCount0 id=" + activityId
                    + " → treat as possible disconnect");
            sSessionDisconnected = true;
            tryRefreshLogin(sAppClassLoader);
        }
    }

    /** 详情有 summary（ACTIVE / 有总额）但领取名单为空：刚发出去、还没人领 */
    private static boolean looksLikeValidEmptyPacket(Object result, String activityId) {
        if (result == null) {
            return false;
        }
        try {
            Object summary = null;
            try {
                summary = XposedHelpers.callMethod(result, "getSummaryInfo");
            } catch (Throwable ignored) {
            }
            if (summary == null) {
                try {
                    summary = XposedHelpers.getObjectField(result, "summaryInfo");
                } catch (Throwable ignored) {
                }
            }
            if (summary == null) {
                return false;
            }
            String id = firstNonEmpty(
                    stringField(summary, "activityId", "getActivityId"),
                    activityId);
            if (!looksLikeActivityId(id)) {
                return false;
            }
            String status = stringField(summary, "activityStatus", "getActivityStatus");
            if (!TextUtils.isEmpty(status)) {
                String up = status.toUpperCase(Locale.US);
                if (up.contains("ACTIVE") || up.contains("OPEN") || up.contains("PROGRESS")
                        || up.contains("FINISH") || up.contains("COMPLETE")
                        || up.contains("EXPIRE")) {
                    return true;
                }
            }
            String total = null;
            try {
                Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
                if (money != null) {
                    total = normalizeMoneyText(String.valueOf(
                            XposedHelpers.callMethod(money, "getAmount")));
                }
            } catch (Throwable ignored) {
            }
            return !TextUtils.isEmpty(total);
        } catch (Throwable ignored) {
            return false;
        }
    }

    private static boolean snapshotHasPacketMeta(MmpSnapshot snapshot) {
        return snapshot != null
                && looksLikeActivityId(snapshot.packetId)
                && (!TextUtils.isEmpty(snapshot.totalAmount)
                || !TextUtils.isEmpty(snapshot.activityStatus)
                || !TextUtils.isEmpty(snapshot.senderName));
    }

    private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
        ClassLoader cl = sAppClassLoader;
        ensureRpcTaskAndLogin(cl);
        Object task = sMoneyPacketRpcTask;
        if (cl == null || task == null) {
            XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
            return false;
        }
        try {
            Class<?> reqCls = XposedHelpers.findClass(
                    "my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
                    cl);
            Object req;
            if (sTemplateDetailRequest != null
                    && reqCls.isInstance(sTemplateDetailRequest)) {
                req = cloneRequestShallow(sTemplateDetailRequest);
                applyActivityToRequest(req, activityId, senderUserId);
                XposedBridge.log(TAG + " auto-detail using template request id=" + activityId);
            } else {
                req = newDetailRequest(reqCls, activityId, senderUserId);
                if (req == null) {
                    XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
                    return false;
                }
            }
            fillLoginOnRequest(req, cl);
            prepareRpcRequest(req);
            XposedBridge.log(TAG + " auto-detail req id=" + activityId
                    + " userId=" + stringField(req, "userId", "getUserId")
                    + " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
                    + " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
                    + " page=" + stringField(req, "page", "getPage")
                    + " max=" + stringField(req, "maxResult", "getMaxResult"));
            if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
                XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
                        + " (ILoginStorage 尚未就绪，等登录后 Activity resume 会重试)");
                return false;
            }
            Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
            if (result == null) {
                XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
                return false;
            }
            String json = buildJsonFromObject(result);
            if (TextUtils.isEmpty(json)) {
                json = tryFastjson(result);
            }
            if (TextUtils.isEmpty(json)) {
                XposedBridge.log(TAG + " auto-detail empty json id=" + activityId);
                return false;
            }
            MmpSnapshot probe = parseSnapshot(json);
            enrichSnapshotFromJavaResult(result, probe);
            if (probe == null) {
                XposedBridge.log(TAG + " auto-detail parse null id=" + activityId);
                return false;
            }
            if (TextUtils.isEmpty(probe.packetId)) {
                probe.packetId = activityId;
            }
            if (probe.claims.isEmpty()) {
                String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
                XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
                        + " body=" + snippet
                        + " userId=" + stringField(req, "userId", "getUserId"));
                if (!snapshotHasPacketMeta(probe) && !looksLikeValidEmptyPacket(result, activityId)) {
                    notePossibleDisconnectFromEmpty(result, activityId);
                    return false;
                }
                // 刚发出、还没人领：照样入库，方便列表看到「领取中 0人」
                rememberPacketData(probe.packetId, probe, result);
                forwardSnapshot(probe, "auto:" + activityId, "rpc");
                XposedBridge.log(TAG + " auto-detail ok(empty) activityId=" + activityId
                        + " status=" + probe.activityStatus
                        + " total=" + probe.totalAmount
                        + " issued=" + probe.issueTime);
                noteSessionAlive();
                return true;
            }
            rememberPacketData(probe.packetId != null ? probe.packetId : activityId, probe, result);
            forwardSnapshot(probe, "auto:" + activityId, "rpc");
            XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
                    + " claims=" + probe.claims.size()
                    + " issued=" + probe.issueTime
                    + (COMPLETED_PACKETS.contains(activityId) ? " DONE" : ""));
            noteSessionAlive();
            return true;
        } catch (Throwable t) {
            XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
            markMaybeDisconnected(t, "auto-detail");
            return false;
        }
    }

    private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
        // 优先： (activityId, senderUserId, loadTime, page, maxResult) — maxResult 拉大以尽量一次取全
        try {
            return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200);
        } catch (Throwable ignored) {
        }
        try {
            return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200,
                    31, null);
        } catch (Throwable ignored) {
        }
        try {
            Object req = reqCls.getDeclaredConstructor().newInstance();
            XposedHelpers.setObjectField(req, "activityId", activityId);
            XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
            bumpDetailPageSize(req);
            return req;
        } catch (Throwable t) {
            XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
            return null;
        }
    }

    private static void fillLoginOnRequest(Object req, ClassLoader cl) {
        ensureRpcTaskAndLogin(cl);
        // 0) 从最近真实请求拷贝登录相关字段（历史页打开后即可，无需点详情）
        Object donor = sLoginDonorRequest;
        if (donor == null) {
            donor = sTemplateHistoryRequest;
        }
        if (donor == null) {
            donor = sTemplateDetailRequest;
        }
        if (donor != null && donor != req) {
            copyLoginFields(donor, req);
            cacheLoginFromRequestObject(donor);
        }
        // 1) 再用缓存的登录态覆盖
        trySet(req, "setUserId", sCachedUserId);
        trySet(req, "setSessionId", sCachedSessionId);
        trySet(req, "setLoginId", sCachedLoginId);
        try {
            if (!TextUtils.isEmpty(sCachedUserId)) {
                XposedHelpers.setObjectField(req, "userId", sCachedUserId);
            }
            if (!TextUtils.isEmpty(sCachedSessionId)) {
                XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
            }
            if (!TextUtils.isEmpty(sCachedLoginId)) {
                XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
            }
        } catch (Throwable ignored) {
        }
        if (!TextUtils.isEmpty(stringField(req, "userId", "getUserId"))
                && !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
            return;
        }
        // 2) 从 Repository.loginStorage / ILoginStorage 再读一遍
        Object loginStorage = obtainLoginStorage(cl);
        if (loginStorage == null) {
            return;
        }
        applyLoginStorage(loginStorage);
        trySet(req, "setUserId", sCachedUserId);
        trySet(req, "setSessionId", sCachedSessionId);
        trySet(req, "setLoginId", sCachedLoginId);
        try {
            if (!TextUtils.isEmpty(sCachedUserId)) {
                XposedHelpers.setObjectField(req, "userId", sCachedUserId);
            }
            if (!TextUtils.isEmpty(sCachedSessionId)) {
                XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
            }
            if (!TextUtils.isEmpty(sCachedLoginId)) {
                XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
            }
        } catch (Throwable ignored) {
        }
    }

    private static void copyLoginFields(Object src, Object dst) {
        if (src == null || dst == null) {
            return;
        }
        String[] keys = {"userId", "sessionId", "loginId", "securityId", "apiName"};
        for (String key : keys) {
            try {
                Object v = null;
                try {
                    v = XposedHelpers.getObjectField(src, key);
                } catch (Throwable ignored) {
                }
                if (v == null) {
                    continue;
                }
                try {
                    XposedHelpers.setObjectField(dst, key, v);
                } catch (Throwable ignored) {
                }
                String setter = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
                trySet(dst, setter, v);
            } catch (Throwable ignored) {
            }
        }
        // extParams 等 Map 也尽量带上
        try {
            Object ext = XposedHelpers.getObjectField(src, "extParams");
            if (ext != null) {
                XposedHelpers.setObjectField(dst, "extParams", ext);
            }
        } catch (Throwable ignored) {
        }
    }

    private static void trySet(Object req, String setter, Object value) {
        if (value == null) {
            return;
        }
        try {
            XposedHelpers.callMethod(req, setter, value);
        } catch (Throwable ignored) {
        }
    }

    /** 被动缓存：App 任何地方读 session 时同步到本 Hook */
    private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
        // 不能 hook 接口抽象方法；只挂实现类
        try {
            Class<?> impl = XposedHelpers.findClass(
                    "my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
            XC_MethodHook cacheHook = new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) {
                    Object v = param.getResult();
                    String name = param.method.getName();
                    if ("getSessionId".equals(name)) {
                        onSessionObserved(v == null ? "" : String.valueOf(v));
                    } else if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
                        return;
                    } else if ("getAccountId".equals(name)) {
                        sCachedUserId = String.valueOf(v);
                    } else if ("getLoginId".equals(name)) {
                        sCachedLoginId = String.valueOf(v);
                    }
                }
            };
            for (String m : new String[]{"getSessionId", "getAccountId", "getLoginId"}) {
                XposedBridge.hookAllMethods(impl, m, cacheHook);
            }
            XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
        } catch (Throwable t) {
            XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
        }
    }

    /** 前台 Activity 恢复时自动拉历史列表（再级联详情），无需手动进红包历史页 */
    private static void hookActivityResumeForAutoHistory(XC_LoadPackage.LoadPackageParam lpparam) {
        try {
            XposedHelpers.findAndHookMethod(
                    "android.app.Activity",
                    lpparam.classLoader,
                    "onResume",
                    new XC_MethodHook() {
                        @Override
                        protected void afterHookedMethod(MethodHookParam param) {
                            try {
                                Object act = param.thisObject;
                                if (act == null) {
                                    return;
                                }
                                String pkg = String.valueOf(
                                        XposedHelpers.callMethod(act, "getPackageName"));
                                if (!PACKAGE.equals(pkg)) {
                                    return;
                                }
                                String actName = act.getClass().getName();
                                if (actName.contains("MoneyPacketHistoryActivity")
                                        && sBounceHistoryPending) {
                                    scheduleHistoryBounceFinish(act);
                                }
                                reportHookAlive("activity-resume");
                                if (sSessionDisconnected) {
                                    tryRefreshLogin(sAppClassLoader);
                                    scheduleAutoHistoryForced("reconnect-resume");
                                } else {
                                    scheduleAutoHistory("activity-resume");
                                }
                            } catch (Throwable ignored) {
                            }
                        }
                    });
            XposedBridge.log(TAG + " hooked Activity.onResume for auto history");
        } catch (Throwable t) {
            XposedBridge.log(TAG + " Activity.onResume hook skip: " + t.getMessage());
        }
    }

    private static void scheduleAutoHistory(String reason) {
        scheduleAutoHistoryInternal(reason, false);
    }

    /** 断线恢复后忽略冷却，立刻重拉历史/详情 */
    private static void scheduleAutoHistoryForced(String reason) {
        scheduleAutoHistoryInternal(reason, true);
    }

    private static void scheduleAutoHistoryInternal(String reason, boolean force) {
        pollMmpSettings(false);
        long now = System.currentTimeMillis();
        if (sAutoHistoryRunning) {
            return;
        }
        if (!force && now - sLastAutoHistoryAt < sHistoryCooldownMs) {
            return;
        }
        sAutoHistoryRunning = true;
        final String why = reason;
        AUTO_DETAIL_EXEC.execute(() -> {
            try {
                // 等登录/Hilt/Network 就绪；不要在主线程阻塞等 RPC
                Thread.sleep(why != null && why.startsWith("repo") ? 1500L : 800L);
                boolean ok = fetchHistoryListAuto(why);
                if (ok) {
                    sLastAutoHistoryAt = System.currentTimeMillis();
                    noteSessionAlive();
                    reportHookAlive("auto-history-ok");
                }
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
            } finally {
                sAutoHistoryRunning = false;
            }
        });
    }

    /** 主动调 moneyPacketHistoryList，结果走现有 scheduleAutoDetailsFromHistory */
    private static boolean fetchHistoryListAuto(String reason) {
        ClassLoader cl = sAppClassLoader;
        ensureRpcTaskAndLogin(cl);
        Object task = sMoneyPacketRpcTask;
        if (cl == null || task == null) {
            XposedBridge.log(TAG + " auto-history skip: no rpc task (" + reason + ")");
            maybeBounceHistoryPage("no-rpc-task");
            return false;
        }
        if (TextUtils.isEmpty(sCachedSessionId)) {
            Object ls = obtainLoginStorage(cl);
            if (ls != null) {
                applyLoginStorage(ls);
            }
        }
        if (TextUtils.isEmpty(sCachedSessionId)) {
            XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
            if (sHadSession) {
                sSessionDisconnected = true;
            }
            maybeBounceHistoryPage("no-session");
            return false;
        }
        try {
            Class<?> reqCls = XposedHelpers.findClass(
                    "my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketHistoryListRequest",
                    cl);
            // 1) 已有官方请求模板（首次打开历史页后会缓存）→ 静默复用
            if (sTemplateHistoryRequest != null
                    && reqCls.isInstance(sTemplateHistoryRequest)) {
                Object req = cloneRequestShallow(sTemplateHistoryRequest);
                XposedBridge.log(TAG + " auto-history using template (" + reason + ")");
                fillLoginOnRequest(req, cl);
                prepareRpcRequest(req);
                AUTO_HISTORY_SELF.set(Boolean.TRUE);
                Object result;
                try {
                    result = callHistoryListRpc(task, req);
                } finally {
                    AUTO_HISTORY_SELF.remove();
                }
                if (result != null) {
                    if (historyHasJobs(result)) {
                        XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
                        scheduleAutoDetailsFromHistory(result);
                    } else {
                        XposedBridge.log(TAG + " auto-history ok(template) but empty list");
                    }
                    return true;
                }
                // 有模板但 RPC 失败：不要误当成「无模板」反复乱跳；按断线处理并短进历史页
                XposedBridge.log(TAG + " auto-history template rpc failed (" + reason + ")");
                maybeBounceHistoryPage("template-rpc-fail");
                return false;
            }
            // 2) 无模板时：自造请求易「非法参数」，直接拉起官方历史页让 App 发正确 RPC
            if (!sOpenHistoryIfNoTemplate && !sAutoBounceHistoryOnDisconnect) {
                XposedBridge.log(TAG + " auto-history no template, openHistory disabled ("
                        + reason + ")");
                return false;
            }
            XposedBridge.log(TAG + " auto-history no template → open HistoryActivity (" + reason + ")");
            openMoneyPacketHistoryActivity(true);
            return false;
        } catch (Throwable t) {
            XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
            markMaybeDisconnected(t, "auto-history");
            maybeBounceHistoryPage("history-exception");
            return false;
        }
    }

    private static boolean historyHasJobs(Object historyResult) {
        return !extractHistoryJobs(historyResult).isEmpty();
    }

    /** 断线/失败时短进历史页再建连（有冷却，避免刷屏） */
    private static void maybeBounceHistoryPage(String reason) {
        if (!sAutoBounceHistoryOnDisconnect) {
            XposedBridge.log(TAG + " bounce history disabled (" + reason + ")");
            return;
        }
        openMoneyPacketHistoryActivity(true);
        XposedBridge.log(TAG + " bounce history for " + reason);
    }

    /** 拉起官方历史页；bounceBack=true 时约 2 秒后自动 finish 返回 */
    private static void openMoneyPacketHistoryActivity(boolean bounceBack) {
        try {
            long now = System.currentTimeMillis();
            if (bounceBack) {
                if (now - sLastHistoryBounceAt < HISTORY_BOUNCE_COOLDOWN_MS) {
                    XposedBridge.log(TAG + " bounce history cooldown skip ageMs="
                            + (now - sLastHistoryBounceAt));
                    return;
                }
                sLastHistoryBounceAt = now;
                sBounceHistoryPending = true;
            }
            Context ctx = getContext();
            if (ctx == null) {
                sBounceHistoryPending = false;
                return;
            }
            android.content.Intent intent = new android.content.Intent();
            intent.setClassName(PACKAGE,
                    "my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
            intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
                    | android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
            ctx.startActivity(intent);
            XposedBridge.log(TAG + " started MoneyPacketHistoryActivity bounce=" + bounceBack);
        } catch (Throwable t) {
            sBounceHistoryPending = false;
            XposedBridge.log(TAG + " start HistoryActivity fail: " + t.getMessage());
        }
    }

    private static void scheduleHistoryBounceFinish(final Object activity) {
        if (!(activity instanceof android.app.Activity)) {
            return;
        }
        final android.app.Activity act = (android.app.Activity) activity;
        MAIN_HANDLER.postDelayed(() -> {
            try {
                if (!sBounceHistoryPending) {
                    return;
                }
                if (act.isFinishing()) {
                    sBounceHistoryPending = false;
                    return;
                }
                act.finish();
                XposedBridge.log(TAG + " bounce history finished → back");
            } catch (Throwable t) {
                XposedBridge.log(TAG + " bounce history finish fail: " + t.getMessage());
            } finally {
                sBounceHistoryPending = false;
            }
        }, HISTORY_BOUNCE_FINISH_DELAY_MS);
    }

    private static Object callHistoryListRpc(Object task, Object req) {
        try {
            return XposedHelpers.callMethod(task, "moneyPacketHistoryList", req);
        } catch (Throwable t) {
            XposedBridge.log(TAG + " history rpc fail: " + t.getMessage());
            markMaybeDisconnected(t, "history-rpc");
            return null;
        }
    }

    private static void ensureRpcTaskAndLogin(ClassLoader cl) {
        if (cl == null) {
            return;
        }
        if (sMoneyPacketRpcTask == null || TextUtils.isEmpty(sCachedSessionId)) {
            Object repo = obtainMoneyPacketRepository(cl);
            if (repo != null) {
                if (sMoneyPacketRpcTask == null) {
                    try {
                        Object task = XposedHelpers.getObjectField(repo, "task");
                        if (task != null) {
                            sMoneyPacketRpcTask = task;
                            XposedBridge.log(TAG + " cached RPC task from repository");
                        }
                    } catch (Throwable ignored) {
                    }
                }
                try {
                    Object loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
                    applyLoginStorage(loginStorage);
                } catch (Throwable ignored) {
                }
            }
        }
        if (TextUtils.isEmpty(sCachedSessionId)) {
            applyLoginStorage(obtainLoginStorage(cl));
        }
    }

    private static void applyLoginStorage(Object loginStorage) {
        if (loginStorage == null) {
            return;
        }
        try {
            Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
            Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
            Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
            if (accountId != null && !TextUtils.isEmpty(String.valueOf(accountId))) {
                sCachedUserId = String.valueOf(accountId);
            }
            if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
                onSessionObserved(String.valueOf(sessionId));
            } else if (sHadSession) {
                onSessionObserved("");
            }
            if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
                sCachedLoginId = String.valueOf(loginId);
            }
            if (!TextUtils.isEmpty(sCachedSessionId)) {
                XposedBridge.log(TAG + " login from storage session="
                        + abbreviate(sCachedSessionId)
                        + " userId=" + sCachedUserId);
            }
        } catch (Throwable t) {
            XposedBridge.log(TAG + " applyLoginStorage skip: " + t.getMessage());
        }
    }

    private static Object obtainLoginStorage(ClassLoader cl) {
        try {
            Object repo = obtainMoneyPacketRepository(cl);
            if (repo != null) {
                try {
                    Object ls = XposedHelpers.getObjectField(repo, "loginStorage");
                    if (ls != null) {
                        return ls;
                    }
                } catch (Throwable ignored) {
                }
            }
        } catch (Throwable ignored) {
        }
        // Hilt：尝试常见 EntryPoint 上返回 ILoginStorage 的无参方法
        try {
            Context ctx = getContext();
            if (ctx == null || cl == null) {
                return null;
            }
            String[] entryPoints = {
                    "my.com.tngdigital.member.di.MemberEntryPoint",
                    "my.com.tngdigital.user.di.UserEntryPoint",
                    "my.com.tngdigital.common.di.CommonEntryPoint",
            };
            Class<?> entryPointsCls = Class.forName("dagger.hilt.EntryPoints", false, cl);
            for (String epName : entryPoints) {
                try {
                    Class<?> epCls = Class.forName(epName, false, cl);
                    Object ep = XposedHelpers.callStaticMethod(
                            entryPointsCls, "get", ctx.getApplicationContext(), epCls);
                    for (Method m : ep.getClass().getMethods()) {
                        if (m.getParameterTypes().length != 0) {
                            continue;
                        }
                        if (m.getReturnType().getName().contains("ILoginStorage")) {
                            Object ls = m.invoke(ep);
                            if (ls != null) {
                                return ls;
                            }
                        }
                    }
                } catch (Throwable ignored) {
                }
            }
        } catch (Throwable ignored) {
        }
        return null;
    }

    private static Object obtainMoneyPacketRepository(ClassLoader cl) {
        Context ctx = getContext();
        if (ctx == null || cl == null) {
            return null;
        }
        Context appCtx = ctx.getApplicationContext() != null ? ctx.getApplicationContext() : ctx;
        Class<?> entryPoint;
        try {
            entryPoint = XposedHelpers.findClass(
                    "my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", cl);
        } catch (Throwable t) {
            XposedBridge.log(TAG + " MoneyPacketEntryPoint missing: " + t.getMessage());
            return null;
        }
        // 1) EntryPointAccessors.fromApplication（Hilt Android 推荐）
        try {
            Class<?> accessors = XposedHelpers.findClass(
                    "dagger.hilt.android.EntryPointAccessors", cl);
            Object ep = XposedHelpers.callStaticMethod(
                    accessors, "fromApplication", appCtx, entryPoint);
            Object repo = invokeRepository(ep);
            if (repo != null) {
                return repo;
            }
        } catch (Throwable t) {
            XposedBridge.log(TAG + " EntryPointAccessors fail: " + t.getMessage());
        }
        // 2) EntryPoints.get
        try {
            Class<?> entryPoints = XposedHelpers.findClass("dagger.hilt.EntryPoints", cl);
            Object ep = XposedHelpers.callStaticMethod(
                    entryPoints, "get", appCtx, entryPoint);
            Object repo = invokeRepository(ep);
            if (repo != null) {
                return repo;
            }
        } catch (Throwable t) {
            XposedBridge.log(TAG + " EntryPoints.get fail: " + t.getMessage());
        }
        return null;
    }

    private static Object invokeRepository(Object ep) {
        if (ep == null) {
            return null;
        }
        try {
            return XposedHelpers.callMethod(ep, "repository");
        } catch (Throwable ignored) {
        }
        for (Method m : ep.getClass().getMethods()) {
            if (m.getParameterTypes().length == 0
                    && m.getReturnType().getName().contains("MoneyPacketRepository")) {
                try {
                    return m.invoke(ep);
                } catch (Throwable ignored) {
                }
            }
        }
        return null;
    }

    private static String extractMoneyPacketRpcOp(Object[] args) {
        if (args == null) {
            return null;
        }
        for (Object arg : args) {
            if (arg instanceof Method) {
                String name = ((Method) arg).getName().toLowerCase(Locale.US);
                if (name.contains("moneypacket") || name.contains("mmp")) {
                    return ((Method) arg).getName();
                }
            } else if (arg instanceof String) {
                String text = (String) arg;
                String lower = text.toLowerCase(Locale.US);
                if (lower.contains("moneypacket") || lower.contains(".mmp")
                        || lower.contains("tngdwallet.money")) {
                    return text;
                }
            }
        }
        return null;
    }

    private static String tryFastjson(Object obj) {
        try {
            Class<?> json = Class.forName("com.alibaba.fastjson.JSON", false,
                    obj.getClass().getClassLoader());
            Object out = XposedHelpers.callStaticMethod(json, "toJSONString", obj);
            return out != null ? String.valueOf(out) : null;
        } catch (Throwable ignored) {
            return null;
        }
    }

    private static void forwardParsedJson(String json, String sourceHint, String channel) {
        MmpSnapshot snapshot = parseSnapshot(json);
        if (snapshot == null || snapshot.claims.isEmpty()) {
            return;
        }
        forwardSnapshot(snapshot, sourceHint, channel);
    }

    private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
        if (snapshot == null) {
            return;
        }
        if (snapshot.claims.isEmpty() && !snapshotHasPacketMeta(snapshot)) {
            return;
        }
        dedupeClaims(snapshot);
        snapshot.applyCachedIssueTime();
        rememberPacketData(snapshot.packetId, snapshot, null);
        if (!shouldForwardPacket(snapshot)) {
            return;
        }
        Context context = getContext();
        if (context == null) {
            XposedBridge.log(TAG + " skip forward: no context, claims="
                    + snapshot.claims.size());
            return;
        }
        String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
        if (!TextUtils.isEmpty(snapshot.packetId) && snapshot.packetId.length() >= 8) {
            title = title + " · " + snapshot.packetId.substring(0, 8);
        }
        String content = snapshot.formatForForward(channel, sourceHint);
        HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
        XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
                + " claims=" + snapshot.claims.size()
                + " issued=" + snapshot.issueTime
                + (snapshot.finished ? " DONE" : "")
                + " via " + channel);
    }

    /** 直接从 Java 结果对象读 gmtModified / createTime，不依赖 JSON 字段是否序列化成功 */
    private static void enrichSnapshotFromJavaResult(Object result, MmpSnapshot snapshot) {
        if (result == null || snapshot == null) {
            return;
        }
        try {
            Object pools = null;
            try {
                pools = XposedHelpers.callMethod(result, "getActivityPoolInfos");
            } catch (Throwable ignored) {
            }
            if (pools == null) {
                try {
                    pools = XposedHelpers.getObjectField(result, "activityPoolInfos");
                } catch (Throwable ignored) {
                }
            }
            if (pools instanceof Iterable) {
                for (Object info : (Iterable<?>) pools) {
                    if (info == null) {
                        continue;
                    }
                    String name = stringField(info, "receiverName", "getReceiverName");
                    String receiverId = firstNonEmpty(
                            stringField(info, "receiverId", "getReceiverId"),
                            stringField(info, "userId", "getUserId"),
                            stringField(info, "receiverUserId", "getReceiverUserId"));
                    String poolId = stringField(info, "activityPoolId", "getActivityPoolId");
                    String claimTime = firstNonEmpty(
                            stringField(info, "gmtModified", "getGmtModified"),
                            stringField(info, "gmtCreate", "getGmtCreate"),
                            stringField(info, "createTime", "getCreateTime"));
                    String amount = null;
                    try {
                        Object money = XposedHelpers.callMethod(info, "getAmount");
                        if (money != null) {
                            amount = normalizeMoneyText(String.valueOf(
                                    XposedHelpers.callMethod(money, "getAmount")));
                        }
                    } catch (Throwable ignored) {
                    }
                    if (TextUtils.isEmpty(amount)) {
                        amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
                    }
                    if (TextUtils.isEmpty(name) && TextUtils.isEmpty(receiverId)) {
                        continue;
                    }
                    if (TextUtils.isEmpty(name)) {
                        name = "用户";
                    }
                    boolean matched = false;
                    for (ClaimLine line : snapshot.claims) {
                        boolean sameId = !TextUtils.isEmpty(receiverId)
                                && receiverId.equals(line.receiverId);
                        boolean sameName = TextUtils.isEmpty(receiverId)
                                && name.equals(line.nickname);
                        if (sameId || sameName) {
                            if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(claimTime)) {
                                line.claimTime = claimTime;
                            }
                            if (TextUtils.isEmpty(line.receiverId) && !TextUtils.isEmpty(receiverId)) {
                                line.receiverId = receiverId;
                            }
                            if (TextUtils.isEmpty(line.poolId) && !TextUtils.isEmpty(poolId)) {
                                line.poolId = poolId;
                            }
                            matched = true;
                        }
                    }
                    if (!matched && !TextUtils.isEmpty(amount)) {
                        ClaimLine line = new ClaimLine();
                        line.nickname = name;
                        line.receiverId = receiverId;
                        line.poolId = poolId;
                        line.amount = amount;
                        line.claimTime = claimTime;
                        snapshot.claims.add(line);
                    }
                }
            }
            Object summary = null;
            try {
                summary = XposedHelpers.callMethod(result, "getSummaryInfo");
            } catch (Throwable ignored) {
            }
            if (summary == null) {
                try {
                    summary = XposedHelpers.getObjectField(result, "summaryInfo");
                } catch (Throwable ignored) {
                }
            }
            if (summary != null) {
                if (TextUtils.isEmpty(snapshot.senderName)) {
                    snapshot.senderName = stringField(summary, "senderName", "getSenderName");
                }
                if (TextUtils.isEmpty(snapshot.packetId)) {
                    snapshot.packetId = stringField(summary, "activityId", "getActivityId");
                }
                if (TextUtils.isEmpty(snapshot.totalAmount)) {
                    try {
                        Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
                        if (money != null) {
                            snapshot.totalAmount = normalizeMoneyText(String.valueOf(
                                    XposedHelpers.callMethod(money, "getAmount")));
                        }
                    } catch (Throwable ignored) {
                    }
                }
                snapshot.remainingCount = firstNonEmpty(
                        stringField(summary, "remainingCount", "getRemainingCount"),
                        stringField(result, "remainingCount", "getRemainingCount"));
                snapshot.claimedCount = firstNonEmpty(
                        stringField(summary, "claimedCount", "getClaimedCount"),
                        stringField(result, "claimedCount", "getClaimedCount"));
                snapshot.totalCount = firstNonEmpty(
                        stringField(summary, "totalCount", "getTotalCount"),
                        stringField(result, "totalCount", "getTotalCount"));
                snapshot.activityStatus = firstNonEmpty(
                        stringField(summary, "activityStatus", "getActivityStatus"),
                        stringField(result, "activityStatus", "getActivityStatus"),
                        boolFieldText(summary, "isFinished", "isFinished"),
                        boolFieldText(result, "isFinished", "isFinished"));
                snapshot.expireTime = firstNonEmpty(
                        stringField(summary, "expireTime", "getExpireTime"),
                        stringField(result, "expireTime", "getExpireTime"),
                        stringField(summary, "expiryTime", "getExpiryTime"),
                        stringField(result, "expiryTime", "getExpiryTime"));
                if (TextUtils.isEmpty(snapshot.claimedAmountText)) {
                    try {
                        Object money = XposedHelpers.callMethod(summary, "getClaimedAmount");
                        if (money != null) {
                            snapshot.claimedAmountText = normalizeMoneyText(String.valueOf(
                                    XposedHelpers.callMethod(money, "getAmount")));
                        }
                    } catch (Throwable ignored) {
                    }
                }
            }
            snapshot.applyCachedIssueTime();
            snapshot.finished = evaluateFinished(snapshot);
        } catch (Throwable t) {
            XposedBridge.log(TAG + " enrich times failed: " + t.getMessage());
        }
    }

    private static void cacheIssueTimesFromHistory(Object historyResult) {
        if (historyResult == null) {
            return;
        }
        try {
            for (String[] job : extractHistoryJobs(historyResult)) {
                if (job.length > 2 && looksLikeActivityId(job[0]) && !TextUtils.isEmpty(job[2])) {
                    synchronized (ACTIVITY_ISSUE_TIME) {
                        ACTIVITY_ISSUE_TIME.put(job[0], job[2]);
                    }
                }
            }
            XposedBridge.log(TAG + " cached issue times size=" + ACTIVITY_ISSUE_TIME.size());
        } catch (Throwable t) {
            XposedBridge.log(TAG + " cache issue times failed: " + t.getMessage());
        }
    }

    private static void dedupeClaims(MmpSnapshot snapshot) {
        if (snapshot == null || snapshot.claims.size() <= 1) {
            return;
        }
        java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
        for (ClaimLine line : snapshot.claims) {
            if (line == null) {
                continue;
            }
            if (TextUtils.isEmpty(line.nickname) && TextUtils.isEmpty(line.receiverId)) {
                continue;
            }
            String key = !TextUtils.isEmpty(line.receiverId)
                    ? ("id:" + line.receiverId)
                    : ("n:" + line.nickname);
            ClaimLine old = map.get(key);
            if (old == null) {
                map.put(key, line);
                continue;
            }
            double oldAmt = parseMoneyDouble(old.amount) != null ? parseMoneyDouble(old.amount) : -1;
            double newAmt = parseMoneyDouble(line.amount) != null ? parseMoneyDouble(line.amount) : -1;
            if (newAmt >= oldAmt) {
                if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(old.claimTime)) {
                    line.claimTime = old.claimTime;
                }
                if (TextUtils.isEmpty(line.receiverId)) {
                    line.receiverId = old.receiverId;
                }
                if (TextUtils.isEmpty(line.poolId)) {
                    line.poolId = old.poolId;
                }
                map.put(key, line);
            } else {
                if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
                    old.claimTime = line.claimTime;
                }
                if (TextUtils.isEmpty(old.receiverId) && !TextUtils.isEmpty(line.receiverId)) {
                    old.receiverId = line.receiverId;
                }
                if (TextUtils.isEmpty(old.poolId) && !TextUtils.isEmpty(line.poolId)) {
                    old.poolId = line.poolId;
                }
            }
        }
        snapshot.claims.clear();
        snapshot.claims.addAll(map.values());
    }

    private static MmpSnapshot parseSnapshot(String json) {
        try {
            JSONObject root = new JSONObject(json.trim());
            return parseSnapshot(root, null);
        } catch (Throwable t) {
            return null;
        }
    }

    private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) {
        MmpSnapshot snapshot = base != null ? base : new MmpSnapshot();
        boolean rootPass = base == null;
        // 领取行不再回填 meta；其它嵌套节点可补 packetId/sender/total
        if (rootPass || parseClaimLine(root) == null) {
            fillMeta(root, snapshot);
        }

        JSONArray receiverList = findReceiverList(root);
        if (receiverList != null) {
            for (int i = 0; i < receiverList.length(); i++) {
                JSONObject item = receiverList.optJSONObject(i);
                if (item == null) {
                    continue;
                }
                ClaimLine line = parseClaimLine(item);
                if (line != null) {
                    snapshot.claims.add(line);
                }
            }
        }

        Iterator<String> keys = root.keys();
        while (keys.hasNext()) {
            String key = keys.next();
            Object val = root.opt(key);
            if (val instanceof JSONObject) {
                parseSnapshot((JSONObject) val, snapshot);
            } else if (val instanceof JSONArray) {
                JSONArray arr = (JSONArray) val;
                for (int i = 0; i < arr.length(); i++) {
                    Object elem = arr.opt(i);
                    if (elem instanceof JSONObject) {
                        ClaimLine line = parseClaimLine((JSONObject) elem);
                        if (line != null) {
                            snapshot.claims.add(line);
                        }
                        parseSnapshot((JSONObject) elem, snapshot);
                    }
                }
            }
        }
        if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) {
            snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
        }
        return snapshot;
    }

    private static JSONArray findReceiverList(JSONObject obj) {
        Iterator<String> keys = obj.keys();
        while (keys.hasNext()) {
            String key = keys.next();
            Object val = obj.opt(key);
            if (val instanceof JSONArray && isReceiverListKey(key)) {
                return (JSONArray) val;
            }
            if (val instanceof JSONObject) {
                JSONArray nested = findReceiverList((JSONObject) val);
                if (nested != null) {
                    return nested;
                }
            }
        }
        return null;
    }

    private static boolean isReceiverListKey(String key) {
        if (TextUtils.isEmpty(key)) {
            return false;
        }
        String k = key.toLowerCase(Locale.US);
        return "receiverlist".equals(k)
                || "leaderboardlist".equals(k)
                || "leaderboard".equals(k)
                || "rankinglist".equals(k)
                || "claimlist".equals(k)
                || "claimers".equals(k)
                || "packetreceiverlist".equals(k)
                || "receivers".equals(k)
                || "activitypoolinfos".equals(k)
                || k.contains("receiverlist")
                || k.contains("leaderboard")
                || k.contains("activitypool");
    }

    private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
        // activityId 是红包唯一标识（UUID），优先于其它 id 字段
        putIfPresent(obj, snapshot, "activityId", "packetId", "mmpId", "moneyPacketId",
                "packetCode", "fundOrderId", "orderNo", "bizNo", "bizOrderId");
        putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
        putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
        putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
        putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
                "totalPacketAmount", "originalAmount");
        putIfPresent(obj, snapshot, "createTime", "createdTime", "gmtCreate", "issueTime",
                "issuedTime", "sendTime", "packetCreateTime");
        if (TextUtils.isEmpty(snapshot.title)) {
            String merchant = firstNonEmpty(
                    obj.optString("merchantName", null),
                    obj.optString("shopName", null));
            if (!TextUtils.isEmpty(merchant)) {
                snapshot.title = merchant;
            }
        }
    }

    private static void putIfPresent(JSONObject obj, MmpSnapshot snapshot, String... keys) {
        for (String key : keys) {
            if (!obj.has(key)) {
                continue;
            }
            String val = readFieldText(obj, key);
            if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) {
                continue;
            }
            String keyLower = key.toLowerCase(Locale.US);
            if (keyLower.contains("createtime") || keyLower.contains("created")
                    || keyLower.contains("gmtcreate") || keyLower.contains("issue")
                    || keyLower.contains("sendtime")) {
                if (TextUtils.isEmpty(snapshot.issueTime) && !looksLikeMoneyJson(val)) {
                    snapshot.issueTime = val;
                }
            } else if (keyLower.equals("activityid") || keyLower.contains("packet")
                    || keyLower.contains("mmp") || keyLower.contains("order")
                    || keyLower.contains("biz") || keyLower.equals("packetcode")) {
                // 已有 UUID 形态的 activityId 时不覆盖
                if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
                    snapshot.packetId = val;
                } else if (!TextUtils.isEmpty(snapshot.packetId)
                        && keyLower.equals("activityid")
                        && looksLikeActivityId(val)) {
                    snapshot.packetId = val;
                }
            } else if (keyLower.contains("group") || keyLower.contains("chat")
                    || keyLower.contains("conversation")) {
                if (keyLower.contains("name")) {
                    if (TextUtils.isEmpty(snapshot.title)) {
                        snapshot.title = val;
                    }
                } else if (TextUtils.isEmpty(snapshot.groupId)) {
                    snapshot.groupId = val;
                }
            } else if (keyLower.contains("sender") || keyLower.contains("operator")
                    || keyLower.contains("creator")) {
                if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) {
                    snapshot.senderName = val;
                }
            } else if (keyLower.contains("total") || keyLower.contains("packetamount")
                    || keyLower.contains("original")) {
                String money = normalizeMoneyText(val);
                if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) {
                    snapshot.totalAmount = money;
                }
            }
        }
    }

    private static boolean looksLikeActivityId(String val) {
        if (TextUtils.isEmpty(val)) {
            return false;
        }
        // b8c37a58-bb2c-4aa2-83ef-f15466b9211e
        return val.length() >= 32 && val.indexOf('-') > 0;
    }

    private static boolean looksLikeClaimRow(JSONObject obj) {
        return parseClaimLine(obj) != null;
    }

    private static ClaimLine parseClaimLine(JSONObject obj) {
        String name = firstNonEmpty(
                readFieldText(obj, "nickName"),
                readFieldText(obj, "nickname"),
                readFieldText(obj, "displayName"),
                readFieldText(obj, "userName"),
                readFieldText(obj, "receiverName"),
                readFieldText(obj, "receiverAccountName"),
                readFieldText(obj, "accountName"),
                readFieldText(obj, "participantName"),
                readFieldText(obj, "name"));
        String amount = firstNonEmpty(
                normalizeMoneyText(readFieldText(obj, "claimedAmount")),
                normalizeMoneyText(readFieldText(obj, "receiveAmount")),
                normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")),
                normalizeMoneyText(readFieldText(obj, "claimAmount")),
                normalizeMoneyText(readFieldText(obj, "amount")));
        if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) {
            return null;
        }
        if (name.length() > 64 || looksLikeMoneyJson(name)) {
            return null;
        }
        ClaimLine line = new ClaimLine();
        line.nickname = name.trim();
        line.amount = amount;
        line.receiverId = firstNonEmpty(
                readFieldText(obj, "receiverId"),
                readFieldText(obj, "receiverUserId"),
                readFieldText(obj, "userId"),
                readFieldText(obj, "participantId"));
        // userName 字段误当 id 时：纯数字/长串才留
        if (!TextUtils.isEmpty(line.receiverId) && line.receiverId.equals(name)) {
            line.receiverId = null;
        }
        line.poolId = firstNonEmpty(
                readFieldText(obj, "activityPoolId"),
                readFieldText(obj, "poolId"));
        line.claimTime = firstNonEmpty(
                readFieldText(obj, "claimTime"),
                readFieldText(obj, "claimedTime"),
                readFieldText(obj, "receiveTime"),
                readFieldText(obj, "gmtClaim"),
                readFieldText(obj, "gmtModified"),
                readFieldText(obj, "gmtModify"),
                readFieldText(obj, "gmtCreate"),
                readFieldText(obj, "createTime"),
                readFieldText(obj, "time"));
        return line;
    }

    /** 读取字段：兼容 Money 对象 / 嵌套 JSON / 普通字符串。 */
    private static String readFieldText(JSONObject obj, String key) {
        if (obj == null || TextUtils.isEmpty(key) || !obj.has(key)) {
            return null;
        }
        Object raw = obj.opt(key);
        if (raw == null || raw == JSONObject.NULL) {
            return null;
        }
        if (raw instanceof JSONObject) {
            return normalizeMoneyText(((JSONObject) raw).toString());
        }
        String text = String.valueOf(raw).trim();
        if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
            return null;
        }
        if (looksLikeMoneyJson(text)) {
            return normalizeMoneyText(text);
        }
        return text;
    }

    private static boolean looksLikeMoneyJson(String text) {
        if (TextUtils.isEmpty(text)) {
            return false;
        }
        String t = text.trim();
        return t.startsWith("{") && t.contains("\"amount\"");
    }

    private static String normalizeMoneyText(String text) {
        if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
            return null;
        }
        String t = text.trim().replace("RM", "").trim();
        if (looksLikeMoneyJson(t)) {
            try {
                JSONObject money = new JSONObject(t);
                String amount = money.optString("amount", null);
                if (!TextUtils.isEmpty(amount) && !"null".equalsIgnoreCase(amount)) {
                    return amount.trim();
                }
                String cent = money.optString("cent", null);
                if (!TextUtils.isEmpty(cent) && !"null".equalsIgnoreCase(cent)) {
                    try {
                        return String.format(Locale.US, "%.2f", Integer.parseInt(cent) / 100.0);
                    } catch (Throwable ignored) {
                    }
                }
            } catch (Throwable ignored) {
            }
            return null;
        }
        // 纯数字或 1.00
        try {
            return String.format(Locale.US, "%.2f", Double.parseDouble(t.replace(",", "")));
        } catch (Throwable ignored) {
            return t;
        }
    }

    private static String buildJsonFromObject(Object root) {
        JSONObject json = objectToJson(root, new HashSet<Integer>(), 0);
        return json != null ? json.toString() : null;
    }

    private static JSONObject objectToJson(Object obj, Set<Integer> visited, int depth) {
        if (obj == null || depth > 6) {
            return null;
        }
        if (obj instanceof String || obj instanceof Number || obj instanceof Boolean) {
            JSONObject wrap = new JSONObject();
            try {
                wrap.put("value", obj);
            } catch (Throwable ignored) {
            }
            return wrap;
        }
        int identity = System.identityHashCode(obj);
        if (visited.contains(identity)) {
            return null;
        }
        visited.add(identity);

        JSONObject out = new JSONObject();
        Class<?> clazz = obj.getClass();
        if (clazz.isArray()) {
            JSONArray arr = new JSONArray();
            int len = Array.getLength(obj);
            for (int i = 0; i < len; i++) {
                Object elem = Array.get(obj, i);
                JSONObject child = objectToJson(elem, visited, depth + 1);
                if (child != null) {
                    arr.put(child);
                }
            }
            try {
                out.put("array", arr);
            } catch (Throwable ignored) {
            }
            return out;
        }
        if (obj instanceof Iterable) {
            JSONArray arr = new JSONArray();
            for (Object elem : (Iterable<?>) obj) {
                JSONObject child = objectToJson(elem, visited, depth + 1);
                if (child != null) {
                    arr.put(child);
                }
            }
            try {
                out.put("receiverList", arr);
            } catch (Throwable ignored) {
            }
            return out;
        }

        for (Field field : clazz.getDeclaredFields()) {
            String fname = field.getName();
            if (fname.contains("$") || fname.startsWith("CREATOR")) {
                continue;
            }
            try {
                field.setAccessible(true);
                Object val = field.get(obj);
                if (val == null) {
                    continue;
                }
                if (isPrimitiveLike(val)) {
                    out.put(fname, String.valueOf(val));
                } else if (val instanceof Iterable || val.getClass().isArray()) {
                    JSONArray arr = new JSONArray();
                    if (val instanceof Iterable) {
                        for (Object elem : (Iterable<?>) val) {
                            putJsonValue(arr, elem, visited, depth + 1);
                        }
                    } else {
                        int len = Array.getLength(val);
                        for (int i = 0; i < len; i++) {
                            putJsonValue(arr, Array.get(val, i), visited, depth + 1);
                        }
                    }
                    out.put(fname, arr);
                } else if (val.getClass().getName().startsWith("my.com.tngdigital")
                        || val.getClass().getName().contains("Mmp")) {
                    JSONObject child = objectToJson(val, visited, depth + 1);
                    if (child != null) {
                        out.put(fname, child);
                    }
                } else if (isPrimitiveLikeViaGetter(obj, fname)) {
                    out.put(fname, String.valueOf(val));
                }
            } catch (Throwable ignored) {
            }
        }
        return out.length() > 0 ? out : null;
    }

    private static void putJsonValue(JSONArray arr, Object elem, Set<Integer> visited, int depth) {
        if (elem == null) {
            return;
        }
        if (isPrimitiveLike(elem)) {
            arr.put(String.valueOf(elem));
            return;
        }
        JSONObject child = objectToJson(elem, visited, depth);
        if (child != null) {
            arr.put(child);
        }
    }

    private static boolean isPrimitiveLike(Object val) {
        return val instanceof String
                || val instanceof Number
                || val instanceof Boolean
                || val instanceof Character;
    }

    private static boolean isPrimitiveLikeViaGetter(Object obj, String fieldName) {
        try {
            String suffix = fieldName.substring(0, 1).toUpperCase(Locale.US) + fieldName.substring(1);
            for (String prefix : new String[]{"get", "is"}) {
                try {
                    Method m = obj.getClass().getMethod(prefix + suffix);
                    Class<?> rt = m.getReturnType();
                    return rt == String.class || Number.class.isAssignableFrom(rt)
                            || rt == boolean.class || rt == Boolean.class;
                } catch (Throwable ignored) {
                }
            }
        } catch (Throwable ignored) {
        }
        return false;
    }

    private static boolean shouldForwardPacket(MmpSnapshot snapshot) {
        String packetId = snapshot.packetId;
        if (TextUtils.isEmpty(packetId)) {
            packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
            snapshot.packetId = packetId;
        }
        String claimsFp = snapshot.claimsFingerprint();
        long now = System.currentTimeMillis();
        long dedupMs = sPacketDedupMs;
        synchronized (RECENT_PACKET_AT) {
            Long lastAt = RECENT_PACKET_AT.get(packetId);
            String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
            if (lastAt != null && lastClaims != null
                    && claimsFp.equals(lastClaims)
                    && now - lastAt < dedupMs) {
                XposedBridge.log(TAG + " skip dup activityId=" + packetId
                        + " ageMs=" + (now - lastAt));
                return false;
            }
            RECENT_PACKET_AT.put(packetId, now);
            RECENT_PACKET_CLAIMS.put(packetId, claimsFp);
            // 顺带清理过期项，防止无限涨
            if (RECENT_PACKET_AT.size() > DEDUP_SIZE) {
                java.util.Iterator<java.util.Map.Entry<String, Long>> it =
                        RECENT_PACKET_AT.entrySet().iterator();
                while (it.hasNext()) {
                    java.util.Map.Entry<String, Long> e = it.next();
                    if (now - e.getValue() > Math.max(dedupMs, 1L) * 3) {
                        String k = e.getKey();
                        it.remove();
                        RECENT_PACKET_CLAIMS.remove(k);
                    }
                }
            }
        }
        return true;
    }

    private static boolean remember(String key) {
        if (TextUtils.isEmpty(key)) {
            return false;
        }
        if (RECENT_SET.contains(key)) {
            return false;
        }
        RECENT_SET.add(key);
        RECENT_KEYS.addLast(key);
        while (RECENT_KEYS.size() > DEDUP_SIZE) {
            String old = RECENT_KEYS.removeFirst();
            RECENT_SET.remove(old);
        }
        return true;
    }

    private static String firstNonEmpty(String... values) {
        if (values == null) {
            return null;
        }
        for (String v : values) {
            if (!TextUtils.isEmpty(v) && !"null".equalsIgnoreCase(v)) {
                return v;
            }
        }
        return null;
    }

    private static void reportHookAlive(String reason) {
        long now = System.currentTimeMillis();
        if (!"install".equals(reason) && now - sLastHookStatusAt < HOOK_STATUS_COOLDOWN_MS) {
            return;
        }
        Context ctx = getContext();
        if (ctx == null) {
            return;
        }
        sLastHookStatusAt = now;
        HookForwarder.reportStatus(ctx, PACKAGE, HookBridge.SOURCE_XPOSED_TNG_MMP);
        XposedBridge.log(TAG + " hook status reported (" + reason + ") mod="
                + HookBridge.MODULE_VERSION_NAME);
    }

    private static Context getContext() {
        try {
            Class<?> activityThread = XposedHelpers.findClass("android.app.ActivityThread", null);
            Object app = XposedHelpers.callStaticMethod(activityThread, "currentApplication");
            if (app instanceof Context) {
                return (Context) app;
            }
        } catch (Throwable ignored) {
        }
        return null;
    }

    private static final class MmpSnapshot {
        String packetId;
        String groupId;
        String title;
        String senderName;
        String totalAmount;
        /** 发放时间（历史 createTime 或详情里的 createTime） */
        String issueTime;
        String remainingCount;
        String claimedCount;
        String totalCount;
        String activityStatus;
        String expireTime;
        String claimedAmountText;
        boolean finished;
        final List<ClaimLine> claims = new ArrayList<>();

        String dedupKey() {
            StringBuilder sb = new StringBuilder();
            sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|');
            sb.append(claimsFingerprint());
            return sb.toString();
        }

        String claimsFingerprint() {
            StringBuilder sb = new StringBuilder();
            for (ClaimLine line : claims) {
                sb.append(line.nickname).append('=').append(line.amount).append(';');
            }
            return sb.toString();
        }

        void applyCachedIssueTime() {
            if (!TextUtils.isEmpty(issueTime) || TextUtils.isEmpty(packetId)) {
                return;
            }
            synchronized (ACTIVITY_ISSUE_TIME) {
                String cached = ACTIVITY_ISSUE_TIME.get(packetId);
                if (!TextUtils.isEmpty(cached)) {
                    issueTime = cached;
                }
            }
        }

        String formatForForward(String channel, String sourceHint) {
            applyCachedIssueTime();
            if (!finished) {
                finished = evaluateFinished(this);
            }
            // 详情无发放时间时，用最早领取时间兜底（仍优于拉取时间）
            if (TextUtils.isEmpty(issueTime)) {
                for (ClaimLine line : claims) {
                    if (!TextUtils.isEmpty(line.claimTime)) {
                        if (TextUtils.isEmpty(issueTime)
                                || line.claimTime.compareTo(issueTime) < 0) {
                            issueTime = line.claimTime;
                        }
                    }
                }
            }
            StringBuilder sb = new StringBuilder();
            sb.append("[MMP统计]");
            if (!TextUtils.isEmpty(packetId)) {
                sb.append(" packet=").append(sanitizeMeta(packetId));
            }
            if (!TextUtils.isEmpty(groupId)) {
                sb.append(" | group=").append(sanitizeMeta(groupId));
            }
            if (!TextUtils.isEmpty(senderName)) {
                sb.append(" | sender=").append(sanitizeMeta(senderName));
            }
            if (!TextUtils.isEmpty(totalAmount)) {
                sb.append(" | total=").append(sanitizeMeta(totalAmount));
            }
            if (!TextUtils.isEmpty(issueTime)) {
                sb.append(" | issued=").append(sanitizeMeta(issueTime));
            }
            if (!TextUtils.isEmpty(expireTime)) {
                sb.append(" | expire=").append(sanitizeMeta(expireTime));
            }
            if (!TextUtils.isEmpty(activityStatus)) {
                sb.append(" | status=").append(sanitizeMeta(activityStatus));
            }
            String claimed = claimedCount;
            String totalCnt = totalCount;
            if (TextUtils.isEmpty(claimed) && claims != null) {
                claimed = String.valueOf(claims.size());
            }
            if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
                sb.append(" | counts=").append(sanitizeMeta(nullToEmpty(claimed)))
                        .append('/').append(sanitizeMeta(nullToEmpty(totalCnt)));
            }
            if (finished) {
                sb.append(" | done=1");
            }
            sb.append(" | via=").append(sanitizeMeta(channel));
            sb.append(" | mod=").append(HookBridge.MODULE_VERSION_NAME)
                    .append('/').append(HookBridge.MODULE_VERSION_CODE);
            if (!TextUtils.isEmpty(sourceHint)) {
                String src = sourceHint.length() > 120
                        ? sourceHint.substring(0, 120) + "..." : sourceHint;
                sb.append(" | src=").append(sanitizeMeta(src));
            }
            sb.append("\n");
            for (ClaimLine line : claims) {
                sb.append(line.nickname).append(" -> ").append(line.amount);
                if (!TextUtils.isEmpty(line.claimTime)) {
                    sb.append(" (").append(line.claimTime).append(')');
                }
                if (!TextUtils.isEmpty(line.receiverId)) {
                    sb.append(" #rid=").append(sanitizeMeta(line.receiverId));
                }
                if (!TextUtils.isEmpty(line.poolId)) {
                    sb.append(" #pool=").append(sanitizeMeta(line.poolId));
                }
                sb.append('\n');
            }
            return sb.toString().trim();
        }

        private static String sanitizeMeta(String value) {
            if (value == null) {
                return "";
            }
            // 避免空格拆坏 meta；竖线是分隔符
            return value.replace('|', '/').trim();
        }
    }

    private static final class ClaimLine {
        String nickname;
        String receiverId;
        String poolId;
        String amount;
        String claimTime;
    }
}
