package com.miraclegarden.smsmessage.mmp;

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

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

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 本地红包领取台数据：解析 Hook 的 [MMP统计] 文本，按 activityId 持久化。
 */
public final class MmpPacketStore {

    private static final String TAG = "MmpPacketStore";
    private static final String PREF = "mmp_packets";
    private static final String KEY_JSON = "packets_json";
    private static final String KEY_LAST_INGEST = "last_ingest_at";
    private static final int MAX_PACKETS = 200;

    private static final Pattern AMOUNT_JSON = Pattern.compile(
            "\"amount\"\\s*:\\s*\"?([0-9]+(?:\\.[0-9]+)?)\"?");
    private static final Pattern AMOUNT_PLAIN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)");

    public interface Listener {
        void onMmpPacketsChanged();
    }

    private static final CopyOnWriteArrayList<Listener> LISTENERS = new CopyOnWriteArrayList<>();
    private static volatile long sLastIngestAt;

    private MmpPacketStore() {
    }

    public static void addListener(Listener listener) {
        if (listener != null) {
            LISTENERS.add(listener);
        }
    }

    public static void removeListener(Listener listener) {
        LISTENERS.remove(listener);
    }

    public static boolean isMmpContent(String content, String source) {
        if (source != null && source.toLowerCase(Locale.US).contains("tng_mmp")) {
            return true;
        }
        return content != null && content.contains("[MMP统计]");
    }

    /** 解析并写入；非 MMP 或无领取人则忽略。 */
    public static boolean ingest(Context context, String title, String content, String source) {
        if (context == null || TextUtils.isEmpty(content) || !isMmpContent(content, source)) {
            return false;
        }
        ParseResult parsed = parse(content);
        noteModuleFromMeta(context, parsed.meta);
        String packetId = parsed.meta.get("packet");
        if (TextUtils.isEmpty(packetId)) {
            packetId = parsed.meta.get("packetId");
        }
        boolean hasRealPacketId = !TextUtils.isEmpty(packetId)
                && !"unknown".equals(packetId)
                && !"TNG 红包".equals(packetId)
                && !"TNG Money Packet".equals(packetId);
        // 允许无人领取的新红包入库（只有 packet/meta，claims 为空）
        if (parsed.claims.isEmpty() && !hasRealPacketId) {
            return false;
        }
        if (!hasRealPacketId) {
            packetId = "红包-" + claimsFingerprint(parsed.claims);
            if (packetId.length() > 56) {
                packetId = packetId.substring(0, 56);
            }
        }

        MmpPacket incoming = new MmpPacket();
        incoming.packetId = packetId;
        incoming.title = TextUtils.isEmpty(title) ? "TNG 红包" : title;
        incoming.sender = nullToEmpty(parsed.meta.get("sender"));
        incoming.group = nullToEmpty(parsed.meta.get("group"));
        incoming.total = nullToEmpty(parsed.meta.get("total"));
        incoming.via = nullToEmpty(parsed.meta.get("via"));
        incoming.issuedAt = firstNonEmpty(parsed.meta, "issued", "createTime", "issuedAt", "create");
        incoming.expiresAt = firstNonEmpty(parsed.meta, "expire", "expireTime", "expiry", "expiresAt");
        incoming.status = firstNonEmpty(parsed.meta, "status", "activityStatus");
        parseCountsMeta(incoming, parsed.meta.get("counts"));
        if (incoming.claimedCount <= 0) {
            tryParseIntField(incoming, parsed.meta, "claimed", "claimedCount");
        }
        if (incoming.totalCount <= 0) {
            tryParseIntField(incoming, parsed.meta, "slots", "totalCount", "quantity");
        }
        incoming.finished = "1".equals(parsed.meta.get("done"))
                || "true".equalsIgnoreCase(parsed.meta.get("done"))
                || isTerminalStatusMeta(incoming.status);
        incoming.refreshClosedState();
        incoming.updatedAt = nowText();
        incoming.snapshots = 1;
        List<MmpClaim> ranked = aggregateClaims(parsed.claims);
        incoming.leaderboard = ranked;
        incoming.claimantCount = ranked.size();
        if (incoming.claimedCount <= 0) {
            incoming.claimedCount = ranked.size();
        }
        if (incoming.totalCount > 0 && incoming.claimedCount >= incoming.totalCount) {
            incoming.finished = true;
        }
        double sum = 0;
        for (MmpClaim c : ranked) {
            sum += c.amount;
        }
        incoming.sumClaimed = Math.round(sum * 10000.0) / 10000.0;
        Double totalNum = normalizeMoney(incoming.total);
        if (totalNum == null || (incoming.sumClaimed > 0
                && Math.abs(totalNum - incoming.sumClaimed) > 0.001
                && totalNum <= maxClaimAmount(ranked) + 1e-9)) {
            incoming.total = String.format(Locale.US, "%.2f", incoming.sumClaimed);
        } else {
            incoming.total = String.format(Locale.US, "%.2f", totalNum);
        }

        synchronized (MmpPacketStore.class) {
            Map<String, MmpPacket> map = loadMap(context);
            MmpPacket existing = map.get(packetId);
                if (existing != null) {
                if (score(incoming) >= score(existing)) {
                    incoming.snapshots = existing.snapshots + 1;
                    if (TextUtils.isEmpty(incoming.issuedAt) && !TextUtils.isEmpty(existing.issuedAt)) {
                        incoming.issuedAt = existing.issuedAt;
                    }
                    if (TextUtils.isEmpty(incoming.expiresAt) && !TextUtils.isEmpty(existing.expiresAt)) {
                        incoming.expiresAt = existing.expiresAt;
                    }
                    if (TextUtils.isEmpty(incoming.status) && !TextUtils.isEmpty(existing.status)) {
                        incoming.status = existing.status;
                    }
                    if (incoming.totalCount <= 0 && existing.totalCount > 0) {
                        incoming.totalCount = existing.totalCount;
                    }
                    if (incoming.claimedCount < existing.claimedCount) {
                        // 保留更大的已领人数
                        incoming.claimedCount = existing.claimedCount;
                    }
                    if (!incoming.finished && existing.finished) {
                        incoming.finished = true;
                    }
                    incoming.refreshClosedState();
                    map.put(packetId, incoming);
                } else {
                    existing.snapshots = existing.snapshots + 1;
                    if (TextUtils.isEmpty(existing.issuedAt) && !TextUtils.isEmpty(incoming.issuedAt)) {
                        existing.issuedAt = incoming.issuedAt;
                    }
                    existing.updatedAt = nowText();
                }
            } else {
                map.put(packetId, incoming);
            }
            trimMap(map);
            saveMap(context, map);
        }
        notifyListeners();
        sLastIngestAt = System.currentTimeMillis();
        try {
            prefs(context).edit().putLong(KEY_LAST_INGEST, sLastIngestAt).apply();
        } catch (Exception ignored) {
        }
        Log.i(TAG, "ingest packet=" + packetId + " claims=" + ranked.size());
        try {
            MmpSyncClient.syncIfStale(context.getApplicationContext(), 3000L);
        } catch (Exception e) {
            Log.w(TAG, "auto sync skip", e);
        }
        return true;
    }

    /** Hook 最近一次成功写入本地的时间戳；0 表示尚无 */
    public static long lastIngestAt(Context context) {
        if (sLastIngestAt > 0) {
            return sLastIngestAt;
        }
        if (context == null) {
            return 0L;
        }
        try {
            long v = prefs(context).getLong(KEY_LAST_INGEST, 0L);
            if (v > 0) {
                sLastIngestAt = v;
            }
            return v;
        } catch (Exception e) {
            return 0L;
        }
    }

    public static List<MmpPacket> getPackets(Context context) {
        synchronized (MmpPacketStore.class) {
            List<MmpPacket> list = new ArrayList<>(loadMap(context).values());
            Collections.sort(list, new Comparator<MmpPacket>() {
                @Override
                public int compare(MmpPacket a, MmpPacket b) {
                    // DD/MM/YYYY 不能按字符串比；按真实时间降序，最新在前
                    long ta = sortTimeMs(a);
                    long tb = sortTimeMs(b);
                    if (tb != ta) {
                        return Long.compare(tb, ta);
                    }
                    return nullToEmpty(b.packetId).compareTo(nullToEmpty(a.packetId));
                }
            });
            for (MmpPacket p : list) {
                if (p != null) {
                    p.refreshClosedState();
                }
            }
            return list;
        }
    }

    private static long sortTimeMs(MmpPacket p) {
        if (p == null) {
            return 0L;
        }
        long ms = MmpPacket.parseIssueMs(p.issuedAt);
        if (ms <= 0) {
            ms = MmpPacket.parseIssueMs(p.updatedAt);
        }
        return ms;
    }

    public static MmpPacket getPacket(Context context, String packetId) {
        if (TextUtils.isEmpty(packetId)) {
            return null;
        }
        synchronized (MmpPacketStore.class) {
            MmpPacket p = loadMap(context).get(packetId);
            if (p != null) {
                p.refreshClosedState();
            }
            return p;
        }
    }

    public static void clear(Context context) {
        synchronized (MmpPacketStore.class) {
            prefs(context).edit().remove(KEY_JSON).apply();
        }
        notifyListeners();
    }

    /** 导出给 PC /api/mmp/sync 的全量 JSON。 */
    public static JSONArray exportJsonArray(Context context) {
        JSONArray arr = new JSONArray();
        synchronized (MmpPacketStore.class) {
            for (MmpPacket p : loadMap(context).values()) {
                try {
                    arr.put(toJson(p));
                } catch (Exception e) {
                    Log.e(TAG, "export packet failed", e);
                }
            }
        }
        return arr;
    }

    // --- parse ---

    /** 从 header 的 mod=1.2.0/3 更新 Hook 运行版本（兜底） */
    private static void noteModuleFromMeta(Context context, Map<String, String> meta) {
        if (context == null || meta == null) {
            return;
        }
        String mod = meta.get("mod");
        if (TextUtils.isEmpty(mod)) {
            return;
        }
        int code = 0;
        String name = mod.trim();
        int slash = name.lastIndexOf('/');
        if (slash >= 0 && slash + 1 < name.length()) {
            String codePart = name.substring(slash + 1).trim();
            name = name.substring(0, slash).trim();
            try {
                code = Integer.parseInt(codePart);
            } catch (NumberFormatException ignored) {
                code = 0;
            }
        }
        MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, "my.com.tngdigital.ewallet");
    }

    private static final class ParseResult {
        final Map<String, String> meta = new LinkedHashMap<>();
        final List<RawClaim> claims = new ArrayList<>();
    }

    private static final class RawClaim {
        String nickname;
        String userId;
        String poolId;
        double amount;
        String claimTime;
    }

    private static ParseResult parse(String content) {
        ParseResult out = new ParseResult();
        String text = content.trim();
        String[] lines = text.split("\\r?\\n");
        if (lines.length == 0) {
            return out;
        }
        String head = lines[0].trim();
        if (head.startsWith("[MMP统计]")) {
            head = head.substring("[MMP统计]".length()).trim();
        }
        List<String> parts;
        if (head.contains(" | ")) {
            parts = splitPipe(head);
        } else {
            parts = splitLegacy(head);
        }
        for (String part : parts) {
            int eq = part.indexOf('=');
            if (eq <= 0) {
                continue;
            }
            out.meta.put(part.substring(0, eq).trim(), part.substring(eq + 1).trim());
        }
        // sender 被空格截断时尽量还原
        String sender = out.meta.get("sender");
        if (sender != null && head.contains("sender=")) {
            String raw = head.substring(head.indexOf("sender=") + "sender=".length());
            for (String stop : new String[]{" | ", " total=", " via=", " group=", " packet=", " src=", " issued="}) {
                int i = raw.indexOf(stop);
                if (i >= 0) {
                    raw = raw.substring(0, i);
                    break;
                }
            }
            raw = raw.trim();
            if (raw.length() > sender.length()) {
                out.meta.put("sender", raw);
            }
        }
        Double totalNum = normalizeMoney(out.meta.get("total"));
        if (totalNum != null) {
            out.meta.put("total", String.format(Locale.US, "%.2f", totalNum));
        }
        for (int i = 1; i < lines.length; i++) {
            String line = lines[i].trim();
            if (line.isEmpty() || !line.contains("->")) {
                continue;
            }
            int arrow = line.indexOf("->");
            String nick = line.substring(0, arrow).trim();
            String right = line.substring(arrow + 2).trim();
            String userId = "";
            String poolId = "";
            int ridAt = right.indexOf(" #rid=");
            if (ridAt >= 0) {
                String rest = right.substring(ridAt + 6).trim();
                right = right.substring(0, ridAt).trim();
                int sp = rest.indexOf(' ');
                if (sp > 0) {
                    userId = rest.substring(0, sp).trim();
                    String more = rest.substring(sp + 1).trim();
                    if (more.startsWith("#pool=")) {
                        poolId = more.substring(6).trim();
                    }
                } else {
                    userId = rest;
                }
            }
            int poolAt = right.indexOf(" #pool=");
            if (poolAt >= 0 && TextUtils.isEmpty(poolId)) {
                poolId = right.substring(poolAt + 7).trim();
                right = right.substring(0, poolAt).trim();
            }
            // 兼容 #rid= 后仍有 #pool=
            int poolInId = userId.indexOf("#pool=");
            if (poolInId >= 0) {
                poolId = userId.substring(poolInId + 6).trim();
                userId = userId.substring(0, poolInId).trim();
            }
            String claimTime = "";
            String amountRaw = right;
            if (right.endsWith(")") && right.contains("(") && !right.startsWith("{")) {
                int lp = right.lastIndexOf('(');
                amountRaw = right.substring(0, lp).trim();
                claimTime = right.substring(lp + 1, right.length() - 1).trim();
            }
            Double amt = normalizeMoney(amountRaw);
            if (!TextUtils.isEmpty(nick) || !TextUtils.isEmpty(userId)) {
                RawClaim c = new RawClaim();
                c.nickname = TextUtils.isEmpty(nick) ? "?" : nick;
                c.userId = userId;
                c.poolId = poolId;
                c.amount = amt != null ? amt : 0;
                c.claimTime = claimTime;
                out.claims.add(c);
            }
        }
        return out;
    }

    private static List<String> splitPipe(String head) {
        String[] arr = head.split(" \\| ");
        List<String> parts = new ArrayList<>();
        for (String p : arr) {
            if (!TextUtils.isEmpty(p.trim())) {
                parts.add(p.trim());
            }
        }
        return parts;
    }

    private static List<String> splitLegacy(String head) {
        List<String> parts = new ArrayList<>();
        String buf = "";
        for (String part : head.split(" ")) {
            if (!buf.isEmpty()) {
                buf += " " + part;
                if (countChar(buf, '{') <= countChar(buf, '}')) {
                    parts.add(buf);
                    buf = "";
                }
                continue;
            }
            if (part.contains("=")) {
                String v = part.substring(part.indexOf('=') + 1);
                if (v.startsWith("{") && countChar(part, '{') > countChar(part, '}')) {
                    buf = part;
                    continue;
                }
            }
            parts.add(part);
        }
        if (!buf.isEmpty()) {
            parts.add(buf);
        }
        return parts;
    }

    private static int countChar(String s, char c) {
        int n = 0;
        for (int i = 0; i < s.length(); i++) {
            if (s.charAt(i) == c) {
                n++;
            }
        }
        return n;
    }

    private static List<MmpClaim> aggregateClaims(List<RawClaim> claims) {
        LinkedHashMap<String, MmpClaim> buckets = new LinkedHashMap<>();
        for (RawClaim c : claims) {
            String nick = c.nickname != null ? c.nickname : "?";
            String key = !TextUtils.isEmpty(c.userId) ? ("id:" + c.userId) : ("n:" + nick);
            MmpClaim b = buckets.get(key);
            if (b == null) {
                MmpClaim created = new MmpClaim(nick, c.amount, c.claimTime);
                created.userId = c.userId;
                created.poolId = c.poolId;
                buckets.put(key, created);
                continue;
            }
            if (c.amount >= b.amount) {
                b.amount = c.amount;
                b.amountText = String.format(Locale.US, "%.2f", c.amount);
                if (!TextUtils.isEmpty(c.claimTime)) {
                    b.claimTime = c.claimTime;
                }
                if (!TextUtils.isEmpty(c.userId)) {
                    b.userId = c.userId;
                }
                if (!TextUtils.isEmpty(c.poolId)) {
                    b.poolId = c.poolId;
                }
                if (!TextUtils.isEmpty(nick) && !"?".equals(nick)) {
                    b.nickname = nick;
                }
            } else {
                if (TextUtils.isEmpty(b.claimTime) && !TextUtils.isEmpty(c.claimTime)) {
                    b.claimTime = c.claimTime;
                }
                if (TextUtils.isEmpty(b.userId) && !TextUtils.isEmpty(c.userId)) {
                    b.userId = c.userId;
                }
                if (TextUtils.isEmpty(b.poolId) && !TextUtils.isEmpty(c.poolId)) {
                    b.poolId = c.poolId;
                }
            }
        }
        List<MmpClaim> result = new ArrayList<>(buckets.values());
        Collections.sort(result, new Comparator<MmpClaim>() {
            @Override
            public int compare(MmpClaim a, MmpClaim b) {
                return Double.compare(b.amount, a.amount);
            }
        });
        for (int i = 0; i < result.size(); i++) {
            result.get(i).rank = i + 1;
        }
        return result;
    }

    private static String claimsFingerprint(List<RawClaim> claims) {
        List<String> rows = new ArrayList<>();
        for (RawClaim c : claims) {
            String id = !TextUtils.isEmpty(c.userId) ? c.userId : (c.nickname != null ? c.nickname : "?");
            rows.add(id + "=" + String.format(Locale.US, "%.2f", c.amount));
        }
        Collections.sort(rows);
        StringBuilder sb = new StringBuilder();
        for (String r : rows) {
            if (sb.length() > 0) {
                sb.append('|');
            }
            sb.append(r);
        }
        return sb.toString();
    }

    static Double normalizeMoney(String raw) {
        if (TextUtils.isEmpty(raw)) {
            return null;
        }
        String s = raw.trim();
        if (s.startsWith("{")) {
            Matcher m = AMOUNT_JSON.matcher(s);
            if (m.find()) {
                try {
                    return Double.parseDouble(m.group(1));
                } catch (NumberFormatException ignored) {
                }
            }
        }
        Matcher m = AMOUNT_PLAIN.matcher(s.replace(",", ""));
        if (m.find()) {
            try {
                return Double.parseDouble(m.group(1));
            } catch (NumberFormatException ignored) {
            }
        }
        return null;
    }

    private static double maxClaimAmount(List<MmpClaim> ranked) {
        double max = 0;
        for (MmpClaim c : ranked) {
            if (c.amount > max) {
                max = c.amount;
            }
        }
        return max;
    }

    private static int score(MmpPacket p) {
        int issued = TextUtils.isEmpty(p.issuedAt) ? 0 : 1;
        int claims = p.leaderboard != null ? p.leaderboard.size() : 0;
        return issued * 1000 + claims;
    }

    private static void trimMap(Map<String, MmpPacket> map) {
        if (map.size() <= MAX_PACKETS) {
            return;
        }
        List<MmpPacket> list = new ArrayList<>(map.values());
        Collections.sort(list, new Comparator<MmpPacket>() {
            @Override
            public int compare(MmpPacket a, MmpPacket b) {
                return nullToEmpty(a.updatedAt).compareTo(nullToEmpty(b.updatedAt));
            }
        });
        int remove = map.size() - MAX_PACKETS;
        for (int i = 0; i < remove; i++) {
            map.remove(list.get(i).packetId);
        }
    }

    // --- persistence ---

    private static SharedPreferences prefs(Context context) {
        return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
    }

    private static Map<String, MmpPacket> loadMap(Context context) {
        Map<String, MmpPacket> map = new HashMap<>();
        String raw = prefs(context).getString(KEY_JSON, null);
        if (TextUtils.isEmpty(raw)) {
            return map;
        }
        try {
            JSONArray arr = new JSONArray(raw);
            for (int i = 0; i < arr.length(); i++) {
                MmpPacket p = fromJson(arr.getJSONObject(i));
                if (p != null && !TextUtils.isEmpty(p.packetId)) {
                    map.put(p.packetId, p);
                }
            }
        } catch (Exception e) {
            Log.e(TAG, "load failed", e);
        }
        return map;
    }

    private static void saveMap(Context context, Map<String, MmpPacket> map) {
        try {
            JSONArray arr = new JSONArray();
            for (MmpPacket p : map.values()) {
                arr.put(toJson(p));
            }
            prefs(context).edit().putString(KEY_JSON, arr.toString()).apply();
        } catch (Exception e) {
            Log.e(TAG, "save failed", e);
        }
    }

    private static JSONObject toJson(MmpPacket p) throws Exception {
        JSONObject o = new JSONObject();
        o.put("packetId", nullToEmpty(p.packetId));
        o.put("title", nullToEmpty(p.title));
        o.put("sender", nullToEmpty(p.sender));
        o.put("group", nullToEmpty(p.group));
        o.put("total", nullToEmpty(p.total));
        o.put("via", nullToEmpty(p.via));
        o.put("issuedAt", nullToEmpty(p.issuedAt));
        o.put("expiresAt", nullToEmpty(p.expiresAt));
        o.put("status", nullToEmpty(p.status));
        o.put("updatedAt", nullToEmpty(p.updatedAt));
        o.put("finished", p.finished);
        o.put("snapshots", p.snapshots);
        o.put("claimedCount", p.claimedCount);
        o.put("totalCount", p.totalCount);
        o.put("claimantCount", p.claimantCount);
        o.put("sumClaimed", p.sumClaimed);
        JSONArray board = new JSONArray();
        if (p.leaderboard != null) {
            for (MmpClaim c : p.leaderboard) {
                JSONObject row = new JSONObject();
                row.put("nickname", nullToEmpty(c.nickname));
                row.put("userId", nullToEmpty(c.userId));
                row.put("poolId", nullToEmpty(c.poolId));
                row.put("amountText", nullToEmpty(c.amountText));
                row.put("amount", c.amount);
                row.put("claimTime", nullToEmpty(c.claimTime));
                row.put("rank", c.rank);
                board.put(row);
            }
        }
        o.put("leaderboard", board);
        return o;
    }

    private static MmpPacket fromJson(JSONObject o) throws Exception {
        MmpPacket p = new MmpPacket();
        p.packetId = o.optString("packetId", "");
        p.title = o.optString("title", "");
        p.sender = o.optString("sender", "");
        p.group = o.optString("group", "");
        p.total = o.optString("total", "");
        p.via = o.optString("via", "");
        p.issuedAt = o.optString("issuedAt", "");
        p.expiresAt = o.optString("expiresAt", "");
        p.status = o.optString("status", "");
        p.updatedAt = o.optString("updatedAt", "");
        p.finished = o.optBoolean("finished", false);
        p.snapshots = o.optInt("snapshots", 1);
        p.claimedCount = o.optInt("claimedCount", 0);
        p.totalCount = o.optInt("totalCount", 0);
        p.claimantCount = o.optInt("claimantCount", 0);
        p.refreshClosedState();
        p.snapshots = o.optInt("snapshots", 1);
        p.claimantCount = o.optInt("claimantCount", 0);
        p.sumClaimed = o.optDouble("sumClaimed", 0);
        JSONArray board = o.optJSONArray("leaderboard");
        if (board != null) {
            for (int i = 0; i < board.length(); i++) {
                JSONObject row = board.getJSONObject(i);
                MmpClaim c = new MmpClaim();
                c.nickname = row.optString("nickname", "");
                c.userId = row.optString("userId", "");
                c.poolId = row.optString("poolId", "");
                c.amount = row.optDouble("amount", 0);
                c.amountText = row.optString("amountText",
                        String.format(Locale.US, "%.2f", c.amount));
                c.claimTime = row.optString("claimTime", "");
                c.rank = row.optInt("rank", i + 1);
                p.leaderboard.add(c);
            }
        }
        if (p.claimantCount <= 0) {
            p.claimantCount = p.leaderboard.size();
        }
        if (p.claimedCount <= 0) {
            p.claimedCount = p.claimantCount;
        }
        p.refreshClosedState();
        return p;
    }

    private static void notifyListeners() {
        for (Listener l : LISTENERS) {
            try {
                l.onMmpPacketsChanged();
            } catch (Exception e) {
                Log.e(TAG, "listener error", e);
            }
        }
    }

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

    private static String firstNonEmpty(Map<String, String> meta, String... keys) {
        for (String k : keys) {
            String v = meta.get(k);
            if (!TextUtils.isEmpty(v)) {
                return v;
            }
        }
        return "";
    }

    /** counts=1/5 */
    private static void parseCountsMeta(MmpPacket packet, String counts) {
        if (packet == null || TextUtils.isEmpty(counts)) {
            return;
        }
        String raw = counts.trim();
        int slash = raw.indexOf('/');
        try {
            if (slash >= 0) {
                String left = raw.substring(0, slash).trim();
                String right = raw.substring(slash + 1).trim();
                if (!left.isEmpty()) {
                    packet.claimedCount = Integer.parseInt(left);
                }
                if (!right.isEmpty()) {
                    packet.totalCount = Integer.parseInt(right);
                }
            } else {
                packet.claimedCount = Integer.parseInt(raw);
            }
        } catch (NumberFormatException ignored) {
        }
    }

    private static void tryParseIntField(MmpPacket packet, Map<String, String> meta,
                                         String... keys) {
        if (packet == null || meta == null) {
            return;
        }
        for (String k : keys) {
            String v = meta.get(k);
            if (TextUtils.isEmpty(v)) {
                continue;
            }
            try {
                int n = Integer.parseInt(v.trim());
                if (n < 0) {
                    continue;
                }
                if ("claimed".equals(k) || "claimedCount".equals(k)) {
                    packet.claimedCount = n;
                } else {
                    packet.totalCount = n;
                }
                return;
            } catch (NumberFormatException ignored) {
            }
        }
    }

    private static boolean isTerminalStatusMeta(String status) {
        if (TextUtils.isEmpty(status)) {
            return false;
        }
        String s = status.toUpperCase(Locale.US);
        return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
                || s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
                || s.contains("DONE");
    }

    private static String nowText() {
        return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
                .format(new java.util.Date());
    }
}
