package quant.rich.emoney.service;

import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Value;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import io.micrometer.core.instrument.util.IOUtils;
import lombok.extern.slf4j.Slf4j;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import quant.rich.emoney.client.OkHttpClientProvider;
import quant.rich.emoney.component.RequireAuthAndProxyAspect.RequireAuthAndProxy;
import quant.rich.emoney.entity.config.IndexInfoConfig;
import quant.rich.emoney.entity.config.SmartViewWriter;
import quant.rich.emoney.entity.sqlite.RequestInfo;
import quant.rich.emoney.pojo.dto.IndexDetail;
import quant.rich.emoney.pojo.dto.NonParamsIndexDetail;
import quant.rich.emoney.pojo.dto.NonParamsIndexDetail.NonParamsIndexDetailData;
import quant.rich.emoney.util.EncryptUtils;
import quant.rich.emoney.util.SmartResourceResolver;
import quant.rich.emoney.pojo.dto.ParamsIndexDetail;
import quant.rich.emoney.service.sqlite.RequestInfoService;

/**
 * 获取指标详情的服务
 * <p>
 * <ul>
 * <li>
 * 无参指标通过模拟 Webview 获取，其介绍都在欲加载网页的 &lt;script> 脚本中，以 js object
 * 的形式存在，需要循环载入网页的所有 script 并进行正则匹配
 * <li>
 * 有参指标通过接口获取
 */
@Slf4j
@Service
public class IndexDetailService {

    @Autowired
    IndexInfoConfig indexInfoConfig;
    
    @Autowired
    RequestInfoService requestInfoService;
    
    @Lazy
    @Autowired
    IndexDetailService self;
    
    static final String filePath = "./conf/extra/indexDetail/";
    static final ObjectMapper mapper = new ObjectMapper();
    static final Context jsContext = Context.create("js");
    static final Pattern nonParamsIndexDetailPattern = Pattern.compile("\\[(\\{(id:\\d+,?|data:\\[\\{(title:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*?\",?|items:\\[(\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*?\",?)+],?|image:\".*?\",?)+\\}+\\],?|name:\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*?\",?|nameCode:\"\\d+\",?)+\\},?)+\\]", Pattern.DOTALL);
    
    static {
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }
    
    /**
     * 强制刷新并重新获取
     * @param indexCode
     * @return
     */
    @CacheEvict(cacheNames="@indexDetailService.getIndexDetail(Serializable)", key="#indexCode.toString()")
    public IndexDetail forceRefreshAndGetIndexDetail(Serializable indexCode) {
        
        // 刷新的本质就是从网络获取，因为此处已经清理了缓存，所以直接从网络获取后再
        // 走一次 getIndexDetail，获取到的就是从网络保存到了本地的，此时缓存也更新了
        
        if (!hasParams(indexCode)) {
            self.getNonParamsIndexDetailOnline(indexCode);
        }
        else {
            self.getParamsIndexDetailOnline(indexCode);
        }
        
        // 此处用 self 是为了在本次调用时就设置缓存
        return self.getIndexDetail(indexCode);
    }
    

    @Cacheable(cacheNames="@indexDetailService.getIndexDetail(Serializable)", key="#indexCode.toString()")
    public IndexDetail getIndexDetail(Serializable indexCode) {
        
        if (indexCode == null) {
            throw new NullPointerException("indexCode 不能为空");
        }
        
        if (!hasParams(indexCode)) {
            return getNonParamsIndexDetail(indexCode);
        }
        else {
            return getParamsIndexDetail(indexCode);
        }
    }
    
    
    private ParamsIndexDetail getParamsIndexDetail(Serializable indexCode) {
        // 先判断本地有没有
        InputStream stream = getIndexDetailStream(indexCode);
        if (stream != null) {
            ParamsIndexDetail detail = null;
            try {
                String str = IOUtils.toString(stream, StandardCharsets.UTF_8);
                detail = mapper.readValue(str, ParamsIndexDetail.class);
            } catch (IOException e) {
                log.warn("无法获取本地无参数指标说明，将尝试重新从网络获取 indexCode: {}", indexCode, e);
            }
            if (detail != null) {
                return detail;
            }
        }
        // 从网络获取
        return self.getParamsIndexDetailOnline(indexCode);
    }
    
    /**
     * 从网络获取有参指标详情
     * <p>本例用到的 requestInfo 涉及鉴权
     * @param indexCode
     * @see RequestInfoService#getDefaultRequestInfo()
     * @return
     */
    @RequireAuthAndProxy(autoLogin = true)
    public ParamsIndexDetail getParamsIndexDetailOnline(Serializable indexCode) {
        RequestInfo requestInfo = requestInfoService.getDefaultRequestInfo();
        if (requestInfo == null || StringUtils.isBlank(requestInfo.getAuthorization())) {
            throw new RuntimeException("无法获取已鉴权的 RequestInfo");
        }
        try {
            OkHttpClient client = OkHttpClientProvider.getInstance();
            String url = "https://emapp.emoney.cn/Config/AppIndicator/Get";
            byte[] content = new StringBuilder().append("{\"code\":\"").append(indexCode).append("\"}").toString().getBytes();
            MediaType type = MediaType.parse("application/json");
            RequestBody body = RequestBody.create(content, type);
            Request request = new Request.Builder()
                    .url(url)
                    .post(body)
                    .header("X-Protocol-Id", "Config%2FAppIndicator%2FGet")
                    .header("X-Request-Id", "null")
                    .header("EM-Sign", EncryptUtils.getEMSign(content, "POST", "Config%2FAppIndicator%2FGet"))
                    .header("Authorization", requestInfoService.getDefaultRequestInfo().getAuthorization())
                    .header("X-Android-Agent", requestInfoService.getDefaultRequestInfo().getXAndroidAgent())
                    .header("Emapp-ViewMode", requestInfoService.getDefaultRequestInfo().getEmappViewMode())
                    .build();
            final Response response = client.newCall(request).execute();
            String responseText = response.body().string();
            ObjectNode result = (ObjectNode)mapper.readTree(responseText);
            Integer code = result.get("result").get("code").asInt();
            if (code == 0) {
                ParamsIndexDetail detail = mapper.treeToValue(result.get("detail"), ParamsIndexDetail.class);
                if (detail == null) {
                /** 网络访问成功但为 null, 新建一空 detail **/
                    detail = new ParamsIndexDetail();
                    detail.setCode(indexCode.toString());
                    detail.getDescriptions().add("该指标说明接口返回为空");
                }
                else {
                    detail.setOriginal(result.get("detail").toString());
                }
                /** 清洗内容：凡是文本类型的内容的，都要清洗一遍，判断是否有脚本、
                    视频和图片等，有的要清除并记录，以免有泄露网址、客户端 IP 风险*/
                detail.sanitize();
                saveIndexDetail(detail);
                return detail;
            }
            else {
                log.error("网络获取 ParamsIndexDetail 失败，code: {}, msg: {}, indexCode: {}",
                        code, result.get("result").get("msg").asText(),
                        indexCode);
                return null;
            }
        }
        catch (Exception e) {
            String message = MessageFormat.format("网络获取 ParamsIndexDetail 失败，indexCode: {0}，message: {1}", 
                    indexCode, e.getLocalizedMessage());
            log.error(message, e);
            throw new RuntimeException(message, e);
        }
    }
    
    /**
     * 获取不带参数的指标信息，通过模拟 Webview 获取
     * @param indexCode
     * @return
     */
    private NonParamsIndexDetail getNonParamsIndexDetail(Serializable indexCode) {
        // 先判断本地有没有
        InputStream stream = getIndexDetailStream(indexCode);
        if (stream != null) {
            NonParamsIndexDetail detail = null;
            try {
                String str = IOUtils.toString(stream, StandardCharsets.UTF_8);
                detail = mapper.readValue(str, NonParamsIndexDetail.class);
            }
            catch (IOException e) {
                log.warn("无法获取本地无参数指标说明，将尝试重新从网络获取 indexCode: {}", indexCode, e);
            }
            if (detail != null) {
                self.loadImages(detail);
                saveIndexDetail(detail);
                return detail;
            }
        }
        // 从网络获取
        return self.getNonParamsIndexDetailOnline(indexCode);
    }
    
    /**
     * 从网络获取指定 indexCode 的无参指标详情
     * <p>本例用到的 requestInfo 不需要 PatchOkHttp 覆写，但要求鉴权参数拼接到 url 中，故要求鉴权
     * <p>会一并尝试获取其他在本地未有的无参指标</p>
     * <p>由于本例需要鉴权, 故在方法上开启了 {@code @RequireAuthAndProxy}, 而本方法被类内其他方法调用, 需要 proxy 才能使 AOP 生效,
     * 为了使 self 调用成功, 需要在任意配置类上添加 {@code @EnableAspectJAutoProxy(exposeProxy = true)}
     * @param indexCode
     * @see RequestInfo#getWebviewUserAgent()
     * @see RequestInfoService#getDefaultRequestInfo()
     * @return
     */
    @RequireAuthAndProxy(autoLogin = true)
    public NonParamsIndexDetail getNonParamsIndexDetailOnline(Serializable indexCode) {
        String url = self.buildNonParamsIndexUrl(indexCode);
        Request request = new Request.Builder()
                .url(url)
                .header("Host", "appstatic.emoney.cn")
                .header("Connection", "keep-alive")
                .header("Upgrade-Insecure-Requests", "1")
                .header("User-Agent", requestInfoService.getDefaultRequestInfo().getWebviewUserAgent())
                .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
                .header("X-Request-With", "cn.emoney.emstock")
                .header("Sec-Fetch-Site", "none")
                .header("Sec-Fetch-Mode", "navigate")
                .header("Sec-Fetch-User", "?1")
                .header("Sec-Fetch-Dest", "document")
                .header("Accept-Encoding", "gzip, deflate")
                .header("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7")
                .build();
        OkHttpClient client = OkHttpClientProvider.getInstance();
        List<String> scripts = new ArrayList<>();
        
        // 1. 获取页面内 scripts 列表，需要的信息都包含在里面
        try (Response response = client.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                log.error("NonParamsIndexDetail 获取失败，status code: {}, indexCode: {}", response.code(), indexCode);
                return null;
            }

            String responseBody = response.body().string();
            
            Document doc = Jsoup.parseBodyFragment(responseBody);
            doc.select("script[src]").forEach(el -> {
                String absoluteURI = resolveUrl(url, el.attr("src"));
                log.debug("script uri: {}", absoluteURI);
                if (absoluteURI != null) {
                    scripts.add(absoluteURI);
                }
            });
            
        } catch (IOException e) {
            String msg = MessageFormat.format("NonParamsIndexDetail 获取失败：指标页访问失败，indexCode: {0}", indexCode);
            log.error(msg, e);
            throw new RuntimeException(msg, e);
        }
        
        if (scripts.size() == 0) {
            String msg = MessageFormat.format("NonParamsIndexDetail 获取失败：未能获取基本页面内脚本链接，indexCode: {0}", indexCode);
            log.error(msg);
            throw new RuntimeException(msg);
        }
        
        // 2. 加载 scripts，试图匹配包含在 js 中的指标说明
        Request.Builder scriptBuilder = new Request.Builder()
                .header("Host", "appstatic.emoney.cn")
                .header("Connection", "keep-alive")
                .header("User-Agent", requestInfoService.getDefaultRequestInfo().getWebviewUserAgent())
                .header("Accept", "*/*")
                .header("X-Request-With", "cn.emoney.emstock")
                .header("Sec-Fetch-Site", "same-origin")
                .header("Sec-Fetch-Mode", "no-cors")
                .header("Sec-Fetch-Dest", "script")
                .header("Accept-Encoding", "gzip, deflate")
                .header("Referer", url)
                .header("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7");
        List<NonParamsIndexDetail> valids = new ArrayList<>();
        
        // 循环获取脚本，一旦获取的脚本内正则匹配到包含无参
        // 指标的文本，立即转换为 json、转换指标并结束循环
        scriptLoop:
        for (String scriptUrl : scripts) {
            Request scriptRequest = scriptBuilder.url(scriptUrl).build();
            try (Response response = client.newCall(scriptRequest).execute()) {
                if (!response.isSuccessful()) {
                    log.warn("NonParamsIndexDetail 获取失败：读取页面内 script 请求失败, response.isSuccessful() is false, indexCode: {}, scriptUrl: {}", indexCode, scriptUrl);
                    continue;
                }

                String responseBody = response.body().string();
                // 尝试匹配
                Matcher m = nonParamsIndexDetailPattern.matcher(responseBody);
                while (m.find()) {
                    String find = m.group();
                    Optional<String> jsonStringify = stringifyJsArray(find);
                    if (jsonStringify.isPresent()) {
                        String jsonString = jsonStringify.get();
                        JsonNode root = mapper.readTree(jsonString);
                        boolean foundAny = false;
                        if (root.isArray()) {
                            ArrayNode array = (ArrayNode) root;
                            for (JsonNode obj : array) {
                                if (obj.isObject() &&
                                        obj.has("name") &&
                                        obj.get("name").isTextual() &&
                                        obj.has("nameCode") && obj.get("nameCode").isTextual() &&
                                        obj.has("data") && obj.get("data").isArray()) {
                                    NonParamsIndexDetail detail = mapper.treeToValue(obj, NonParamsIndexDetail.class);
                                    detail.setOriginal(obj.toString());
                                    valids.add(detail);
                                    foundAny = true;
                                }
                            }
                        }
                        if (foundAny) {
                            break scriptLoop;
                        }
                    }
                }
            } catch (IOException e) {
                log.warn("NonParamsIndexDetail 获取失败：读取页面内 script 请求失败, response.isSuccessful() is true, indexCode: {}, scriptUrl: {}", indexCode, scriptUrl, e);
                continue;
            }
        }
        
        if (valids.isEmpty()) {
            String msg = MessageFormat.format(
                "NonParamsIndexDetail 获取失败：未从页面 js 脚本中获取到任何可能匹配的参数说明，indexCode: {}",
                indexCode);
            log.warn(msg);
            throw new RuntimeException(msg);
        }
        
        // 直接保存在本地
        Pattern numericPattern = Pattern.compile("^\\d+$");
        NonParamsIndexDetail targetDetail = null;
        for (NonParamsIndexDetail detail : valids) {
            // 判断 nameCode 是否是合法的代码
            if (!numericPattern.matcher(detail.getNameCode()).matches()) {
                continue;
            }
            String path = getIndexDetailPath(detail);
            // 判断是否是需求的 detail
            if (indexCode.toString().equals(detail.getIndexCode())) {
                self.loadImages(detail);
                targetDetail = detail;
            }
            // 清洗内容：凡是文本类型的内容的，都要清洗一遍，判断是否有脚本、
            // 视频和图片等，有的要清除并记录，以免有泄露网址、客户端 IP 风险
            detail.sanitize();

            InputStream inputStream = null;
            try {
                inputStream = SmartResourceResolver.loadResource(path);
            }
            catch (IOException e) {
                log.warn("读取资源文件时发生错误: {}, {}", path, e);
            }
            if (inputStream == null) {
                // 不存在则保存
                saveIndexDetail(detail);
            }
            else {
                // 判断 original 是否一致，不一致则更新
                NonParamsIndexDetail existed; 
                try {
                    existed = mapper.readValue(inputStream, NonParamsIndexDetail.class);
                    if (existed.getOriginal() == null || 
                        !existed.getOriginal().equals(detail.getOriginal())) {
                        log.debug("本地 NonParamsIndexDetail {} 原始数据与最新数据不一致，更新", indexCode);
                        saveIndexDetail(detail);
                    }
                }
                catch (IOException e) {
                    log.debug("读取本地存在的 NonParamsIndexDetail 文件成功，但转换失败。格式错误？路径：{}", path, e);
                }
            }
        }
        
        if (targetDetail == null) {
            // 创建一空 target
            targetDetail = new NonParamsIndexDetail();
            targetDetail.setNameCode(indexCode.toString());
            NonParamsIndexDetailData data = new NonParamsIndexDetailData();
            List<String> items = List.of("该指标说明接口返回为空");
            data.setItems(items);
            targetDetail.getData().add(data);
            String path = getIndexDetailPath(targetDetail);

            try {
                SmartResourceResolver.loadResource(path);
            }
            catch (IOException e) {
                // 不存在则保存
                saveIndexDetail(targetDetail);
            }
        }
        
        return targetDetail;
    }
    
    /**
     * 为指定 NonParamsIndexDetail 加载在线图片并转换成 base64
     * <p>该方法涉及 authorization(token)/webviewUserAgent，需确保 RequestInfo 已正确配置并登录</p>
     * @param detail
     * @return
     * @see RequestInfo
     */
    @RequireAuthAndProxy(autoLogin = true)
    public NonParamsIndexDetail loadImages(NonParamsIndexDetail detail) {
        OkHttpClient client = OkHttpClientProvider.getInstance();
        for (NonParamsIndexDetailData data : detail.getData()) {
            String imageUrl = data.getImage();
            if (StringUtils.isNotBlank(imageUrl)) {
                if (imageUrl.startsWith("data:image/") && imageUrl.contains("base64")) {
                    continue;
                }
                else if (imageUrl.toLowerCase().startsWith("http")) {
                    // 需要转换
                    URI uri;
                    try {
                        uri = new URI(imageUrl);
                    }
                    catch (URISyntaxException e) {
                        log.warn("未经转换的 NonParamsIndexDetailData.image {} 非合法 http(s) 地址，请检查", imageUrl, e);
                        continue;
                    }
                    String host = uri.getHost();
                    // 从网络获取
                    Request request = new Request.Builder()
                        .url(imageUrl)
                        .header("Host", host)
                        .header("Connection", "keep-alive")
                        .header("User-Agent", requestInfoService.getDefaultRequestInfo().getWebviewUserAgent())
                        .header("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
                        .header("X-Request-With", "cn.emoney.emstock")
                        .header("Sec-Fetch-Site", "same-origin")
                        .header("Sec-Fetch-Mode", "no-cors")
                        .header("Sec-Fetch-Dest", "image")
                        .header("Accept-Encoding", "gzip, deflate")
                        .header("Referer", self.buildNonParamsIndexUrl(detail.getIndexCode()))
                        .header("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7")
                        .build();
                    
                    try (Response response = client.newCall(request).execute()) {
                        if (!response.isSuccessful()) {
                            log.warn("未经转换的 NonParamsIndexDetailData.image {} 获取图片详情失败，请检查或重试", imageUrl);
                            continue;
                        }
                        byte[] bytes = response.body().bytes();
                        String contentType = response.body().contentType().toString();
                        String base64 = Base64.getEncoder().encodeToString(bytes);
                        data.setImage("data:" + contentType + ";base64," + base64);
                    }
                    catch (IOException e) {
                        log.warn("未经转换的 NonParamsIndexDetailData.image {} 获取图片详情失败，请检查或重试", imageUrl, e);
                        continue;
                    }
                }
                else {
                    log.warn("未知的 NonParamsIndexDetailData.image 格式 {}", imageUrl);
                }
            }
        }
        return detail;
    }
    
    /**
     * 保存指标详情到本地文件，无论其原本是否存在
     * @param <Description>
     * @param des
     */
    private <Description extends IndexDetail> void saveIndexDetail(Description des) {
        SmartViewWriter writer = new SmartViewWriter();
        String joString = writer.writeWithSmartView(des, IndexDetail.class);
        String path = getIndexDetailPath(des);
        try {
            SmartResourceResolver.saveText(path, joString); 
        }
        catch (IOException e) {
            log.error("写入指标详情到 {} 失败", path.toString(), e);
        }
    }
    
    /**
     * 获取指标详情本地路径 Path
     * @param <Description>
     * @param description
     * @return
     */
    private <Description extends IndexDetail> String getIndexDetailPath(Description description) {
        Path path = Path.of(new StringBuilder(filePath)
                .append((description instanceof NonParamsIndexDetail) ? "nonParams/": "params/")
                .append(description.getIndexCode())
                .append(".json").toString());
        return path.normalize().toString();
    }
    
    /**
     * 根据指标代码 indexCode 获取本地文件路径
     * @param indexCode
     * @return
     */
    private InputStream getIndexDetailStream(Serializable indexCode) {
        boolean hasParams = hasParams(indexCode);
        String path = new StringBuilder(filePath)
                .append(!hasParams ? "nonParams/": "params/")
                .append(indexCode)
                .append(".json").toString();
        InputStream inputStream = null;
        try {
            inputStream = SmartResourceResolver.loadResource(path);
        }
        catch (IOException e) {
            log.warn("读取资源文件时发生错误: {}, {}", path, e);
        }

        return inputStream;
    }
    
    /**
     * 根据指标代码 indexCode 判断是否有参数
     * @param indexCode
     * @return
     */
    private boolean hasParams(Serializable indexCode) {
        JsonNode configIndOnline = indexInfoConfig.getConfigIndOnline();
        JsonNode jsonNode = configIndOnline.get("indMap").get(indexCode.toString());
        if (jsonNode == null) {
            throw new RuntimeException("无法根据指定 indexCode: " + indexCode + " 找到对应 indMap 内容");
        }
        ObjectNode node = (ObjectNode)jsonNode;
        return node.has("indParam");
    }
    
    /**
     * 获取 NonParamsIndexDetail URL
     * <p>该 Url 涉及 authorization(token)，需确保 RequestInfo 已正确配置并登录</p>
     * @param indexCode
     * @return
     * @see RequestInfo
     */
    @RequireAuthAndProxy(autoLogin = true)
    public String buildNonParamsIndexUrl(Serializable indexCode) {

        RequestInfo requestInfo = requestInfoService.getDefaultRequestInfo();
        if (requestInfo == null || StringUtils.isBlank(requestInfo.getAuthorization())) {
            throw new RuntimeException("无法获取已鉴权的 RequestInfo");
        }
        
        StringBuilder urlBuilder = new StringBuilder();
        urlBuilder.append("https://appstatic.emoney.cn/html/emapp/stock/note/?name=");
        urlBuilder.append(indexCode.toString());
        urlBuilder.append("&emoneyScaleType=0&emoneyLandMode=0&token=");
        urlBuilder.append(requestInfoService.getDefaultRequestInfo().getAuthorization());
        return urlBuilder.toString();
    }
    
    /**
     * 根据页面解析绝对路径
     */
    private static String resolveUrl(String pageUrl, String src) {
        try {
            URI base = URI.create(pageUrl);
            return base.resolve(src).toString();
        } catch (Exception e) {
            log.warn("转换 URI 错误，pageUrl: {}, scriptSrc: {} {}", pageUrl, src, e);
            return null;
        }
    }
    
    /**
     *  使用 GraalVM 的 JS 引擎尝试 JSON.stringify
     * @param jsArrayText
     * @return
     */
    private static Optional<String> stringifyJsArray(String jsArrayText) {
        try {
            Value result = jsContext.eval("js", "JSON.stringify(" + jsArrayText + ")");
            return result.isString() ? Optional.of(result.asString()) : Optional.empty();
        } catch (Exception e) {
            log.warn("Failed to call JSON.stringify on {}", jsArrayText, e);
            return Optional.empty();
        }
    }
}
