Envelope.java

// SPDX-License-Identifier: Apache-2.0
package com.shengzhiai.yugu.stcompat.internal;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Map;

/**
 * JSON delivered to {@code onScore} and {@code OnRecordListener.onRecordEnd} (DESIGN 6.2).
 *
 * <pre>
 * {"tokenId","recordId","applicationId","userId","refText","eof":1,"dtLastResponse",
 *  "result":{platform compat result, byte for byte except the alignment below},"params":{app,audio,request},"audioUrl"}
 * {"tokenId","errId","error","eof":1,"applicationId"}
 * </pre>
 *
 * {@code params} only when needRequestParamsInResult is set, {@code audioUrl} only when the
 * platform response has one (top level or inside result).
 *
 * <p>Shengtong result-shape alignment (DESIGN 6): Shengtong parsers read paragraph word scores as
 * {@code result.sentences[].details[].overall}, the platform puts them at
 * {@code details[].scores.overall}. Every detail item without {@code overall} gets {@code overall}
 * copied from {@code scores.overall}, and {@code pronunciation} from {@code scores.pronunciation}
 * when present and missing on the item. Existing keys are never changed or removed; all other text
 * of {@code result} stays byte for byte as the platform returned it.
 */
public final class Envelope {
    private Envelope() {
    }

    /** {@code yyyy-MM-dd HH:mm:ss:SSS} in the device time zone. */
    public static String timestamp(long millis) {
        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS", Locale.US).format(new Date(millis));
    }

    /**
     * Success envelope around the platform body.
     *
     * @throws IllegalArgumentException when the platform body is not a JSON object with a result
     */
    public static String success(String tokenId, String appKey, String userId, String refText, String platformBody,
                                 String paramsJson, long nowMillis) {
        Map<String, String> top = RawJson.members(platformBody.trim());
        String result = top.get("result");
        if (result == null) {
            throw new IllegalArgumentException("platform response has no result");
        }
        String audioUrl = top.get("audioUrl");
        if (audioUrl == null && result.trim().startsWith("{")) {
            try {
                audioUrl = RawJson.members(result.trim()).get("audioUrl");
            } catch (IllegalArgumentException ignored) {
                audioUrl = null;
            }
        }
        StringBuilder sb = new StringBuilder(platformBody.length() + 512);
        sb.append('{');
        sb.append("\"tokenId\":").append(RawJson.quote(tokenId));
        String recordId = top.get("recordId");
        if (recordId != null) {
            sb.append(",\"recordId\":").append(recordId.trim());
        }
        sb.append(",\"applicationId\":").append(RawJson.quote(nz(appKey)));
        sb.append(",\"userId\":").append(RawJson.quote(nz(userId)));
        sb.append(",\"refText\":").append(RawJson.quote(nz(refText)));
        sb.append(",\"eof\":1");
        sb.append(",\"dtLastResponse\":").append(RawJson.quote(timestamp(nowMillis)));
        sb.append(",\"result\":").append(alignResult(result.trim()));
        if (paramsJson != null) {
            sb.append(",\"params\":").append(paramsJson);
        }
        if (audioUrl != null && RawJson.isString(audioUrl) && !RawJson.unquote(audioUrl).isEmpty()) {
            sb.append(",\"audioUrl\":").append(audioUrl.trim());
        }
        return sb.append('}').toString();
    }

    /**
     * Adds Shengtong-style {@code overall} and {@code pronunciation} to {@code sentences[].details[]}
     * items, inserting raw text before each item's closing brace. Returns the input unchanged when
     * there is nothing to add or the shape is unexpected.
     */
    public static String alignResult(String result) {
        try {
            if (!result.startsWith("{")) {
                return result;
            }
            RawJson.ObjectSpan root = RawJson.objectAt(result, 0);
            int[] sentences = root.members.get("sentences");
            if (sentences == null || result.charAt(sentences[0]) != '[') {
                return result;
            }
            // insertion position -> text, applied from the end so earlier positions stay valid
            java.util.TreeMap<Integer, String> inserts = new java.util.TreeMap<Integer, String>();
            for (int[] sentence : RawJson.arrayAt(result, sentences[0])) {
                if (result.charAt(sentence[0]) != '{') {
                    continue;
                }
                int[] details = RawJson.objectAt(result, sentence[0]).members.get("details");
                if (details == null || result.charAt(details[0]) != '[') {
                    continue;
                }
                for (int[] detail : RawJson.arrayAt(result, details[0])) {
                    if (result.charAt(detail[0]) != '{') {
                        continue;
                    }
                    RawJson.ObjectSpan item = RawJson.objectAt(result, detail[0]);
                    int[] scores = item.members.get("scores");
                    if (item.members.containsKey("overall") || scores == null || result.charAt(scores[0]) != '{') {
                        continue;
                    }
                    RawJson.ObjectSpan sc = RawJson.objectAt(result, scores[0]);
                    StringBuilder add = new StringBuilder();
                    int[] overall = sc.members.get("overall");
                    if (overall != null) {
                        add.append(",\"overall\":").append(result, overall[0], overall[1]);
                    }
                    int[] pron = sc.members.get("pronunciation");
                    if (pron != null && !item.members.containsKey("pronunciation")) {
                        add.append(",\"pronunciation\":").append(result, pron[0], pron[1]);
                    }
                    if (add.length() > 0) {
                        inserts.put(item.close, item.members.isEmpty() ? add.substring(1) : add.toString());
                    }
                }
            }
            if (inserts.isEmpty()) {
                return result;
            }
            StringBuilder out = new StringBuilder(result);
            for (java.util.Map.Entry<Integer, String> e : inserts.descendingMap().entrySet()) {
                out.insert((int) e.getKey(), e.getValue());
            }
            return out.toString();
        } catch (IllegalArgumentException | IndexOutOfBoundsException e) {
            YLog.w("result shape alignment skipped: " + e.getMessage());
            return result;
        }
    }

    /**
     * Intermediate result of realtime_feedback (DESIGN 14.3, BRIEF change 2026-10-12), delivered to
     * {@code onScore} before the final result:
     * {@code {"tokenId","recordId":"","applicationId","userId","refText","eof":0,"dtLastResponse","result":{...}}}
     * with {@code result} the progress frame's result as sent ({@code words} with the Shengtong-shaped
     * items of the newly read units, {@code progress}, {@code readUnits}, {@code matchedUnits},
     * {@code totalUnits}, {@code bytes}, {@code kernel_version}, {@code resource_version}).
     *
     * @param recordId the frame's recordId, "" during the round (the record exists with the final only)
     */
    public static String progress(String tokenId, String recordId, String appKey, String userId, String refText,
                                  String resultJson, long nowMillis) {
        return "{\"tokenId\":" + RawJson.quote(nz(tokenId))
                + ",\"recordId\":" + RawJson.quote(nz(recordId))
                + ",\"applicationId\":" + RawJson.quote(nz(appKey))
                + ",\"userId\":" + RawJson.quote(nz(userId))
                + ",\"refText\":" + RawJson.quote(nz(refText))
                + ",\"eof\":0"
                + ",\"dtLastResponse\":" + RawJson.quote(timestamp(nowMillis))
                + ",\"result\":" + resultJson.trim()
                + "}";
    }

    public static String error(String tokenId, int errId, String message, String appKey) {
        return "{\"tokenId\":" + RawJson.quote(nz(tokenId))
                + ",\"errId\":" + errId
                + ",\"error\":" + RawJson.quote(nz(message))
                + ",\"eof\":1"
                + ",\"applicationId\":" + RawJson.quote(nz(appKey))
                + "}";
    }

    /**
     * {@code params} member: {@code app} (applicationId, userId, timestamp), {@code audio}
     * (format of the uploaded audio) and {@code request} (coreType, tokenId and the form fields
     * sent). Never contains secretKey or signature.
     */
    public static String params(String appKey, String userId, long nowMillis, String audioType, int sampleRate,
                                int channel, String coreType, String tokenId, Map<String, String> fields) {
        StringBuilder sb = new StringBuilder(256);
        sb.append("{\"app\":{\"applicationId\":").append(RawJson.quote(nz(appKey)))
                .append(",\"userId\":").append(RawJson.quote(nz(userId)))
                .append(",\"timestamp\":").append(RawJson.quote(String.valueOf(nowMillis / 1000L))).append('}');
        sb.append(",\"audio\":{\"audioType\":").append(RawJson.quote(nz(audioType)))
                .append(",\"sampleRate\":").append(sampleRate)
                .append(",\"channel\":").append(channel)
                .append(",\"sampleBytes\":2}");
        sb.append(",\"request\":{\"coreType\":").append(RawJson.quote(nz(coreType)))
                .append(",\"tokenId\":").append(RawJson.quote(nz(tokenId)));
        if (fields != null) {
            for (Map.Entry<String, String> e : fields.entrySet()) {
                if ("coreType".equals(e.getKey()) || "tokenId".equals(e.getKey())) {
                    continue;
                }
                sb.append(',').append(RawJson.quote(e.getKey())).append(':').append(RawJson.quote(e.getValue()));
            }
        }
        sb.append("}}");
        return sb.toString();
    }

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