package tech.wantu.pay;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
 * 顽兔支付 Java 商户 SDK（beta，单文件版）。
 *
 * <p>把本文件复制进你的项目即可使用，只依赖 JDK 标准库（Java 8+），不需要引入任何第三方包；
 * 包名可按你的项目习惯改。商户侧只有三件事：调接口、验 webhook、幂等入账。
 *
 * <p>命名口径：线上报文一律 snake_case，返回的 Map 的键就是报文字段名，与接口文档一字不差；
 * 方法名按 Java 惯例用 camelCase，与 Python / Node / Go 版一一对应。
 *
 * <p>用法：
 * <pre>
 * WantuPay.Client pay = new WantuPay.Client(
 *     new WantuPay.Config()
 *         .baseUrl(System.getenv("WANTU_PAY_BASE_URL"))
 *         .merchantId(System.getenv("WANTU_MERCHANT_ID"))
 *         .apiSecret(System.getenv("WANTU_API_SECRET")));
 *
 * Map&lt;String, Object&gt; order = pay.createOrder(new WantuPay.CreateOrderParams()
 *     .outTradeNo("R202608180001").amount("100.00").subject("积分充值100元").payMode("qrcode"));
 * String payUrl = (String) order.get("pay_url");
 * </pre>
 */
public final class WantuPay {

    /** 本 SDK 版本。beta：接口可能调整，生产接入前请与顽兔确认。 */
    public static final String VERSION = "0.2.0-beta";

    /** 单次请求默认超时（秒），与其他语言版本一致。 */
    private static final int DEFAULT_TIMEOUT_SEC = 10;
    /** webhook 时间戳默认容差（秒），与接口文档第 7 节一致。 */
    private static final int DEFAULT_TOLERANCE_SEC = 300;
    /** 出错时保留的响应正文长度（字符），排障够用又不刷屏。 */
    private static final int BODY_PREVIEW_LEN = 300;
    /** nonce 随机字节数，hex 后 24 位，落在网关认可的 8~64 位区间。 */
    private static final int NONCE_BYTES = 12;

    private static final Pattern OUT_NO_RE = Pattern.compile("^[A-Za-z0-9_]{1,64}$");
    private static final Pattern AMOUNT_RE = Pattern.compile("^(0|[1-9]\\d*)(\\.\\d{1,2})?$");
    private static final Pattern DIGITS_RE = Pattern.compile("^\\d+$");
    private static final SecureRandom RANDOM = new SecureRandom();

    private WantuPay() {
    }

    // ---------- 错误 ----------

    /** SDK 错误基类。均为非受检异常，按需捕获。 */
    public static class PayException extends RuntimeException {
        private static final long serialVersionUID = 1L;

        public PayException(String message) {
            super(message);
        }

        public PayException(String message, Throwable cause) {
            super(message, cause);
        }
    }

    /** 本地入参或配置错误，请求未发出。 */
    public static class ConfigException extends PayException {
        private static final long serialVersionUID = 1L;

        public ConfigException(String message) {
            super(message);
        }
    }

    /** 网络失败或响应异常。查询类接口可重试。 */
    public static class HttpException extends PayException {
        private static final long serialVersionUID = 1L;

        /** HTTP 状态码；请求没发出去（网络错误）时为 0。 */
        public final int status;
        /** 响应正文开头一截，排障用。 */
        public final String body;

        public HttpException(String message, int status, String body, Throwable cause) {
            super(message, cause);
            this.status = status;
            this.body = body;
        }
    }

    /** 网关返回业务失败（code != 0），错误码见接口文档第 8 节。 */
    public static class ApiException extends PayException {
        private static final long serialVersionUID = 1L;

        public final int code;
        public final int httpStatus;
        /** 渠道原因透传（仅排障展示用，业务逻辑不要依赖）。 */
        public final String channelCode;
        public final String channelMessage;

        public ApiException(int code, String message, int httpStatus, String channelCode, String channelMessage) {
            super("顽兔支付网关业务失败 " + code + ": " + message);
            this.code = code;
            this.httpStatus = httpStatus;
            this.channelCode = channelCode;
            this.channelMessage = channelMessage;
        }
    }

    /** webhook 验签失败：不要处理业务，返回非 2xx 让网关重试。 */
    public static class WebhookVerifyException extends PayException {
        private static final long serialVersionUID = 1L;

        public WebhookVerifyException(String message) {
            super(message);
        }
    }

    // ---------- 可注入的传输层（测试用） ----------

    /** 传输函数：收 URL、请求头、请求体，回 [状态码, 响应正文]。 */
    public interface Transport {
        Response send(String url, Map<String, String> headers, String body) throws IOException;
    }

    /** 传输层应答。 */
    public static final class Response {
        public final int status;
        public final String text;

        public Response(int status, String text) {
            this.status = status;
            this.text = text;
        }
    }

    // ---------- 客户端 ----------

    /** 客户端配置，链式设置。 */
    public static final class Config {
        String baseUrl;
        String merchantId;
        String apiSecret;
        String webhookSecret;
        int timeoutSec = DEFAULT_TIMEOUT_SEC;
        Transport transport;

        /** 网关地址，如 https://pay-api.wantutech.com（尾斜杠可有可无）。 */
        public Config baseUrl(String value) {
            this.baseUrl = value;
            return this;
        }

        /** 商户号，如 mch_7f2a9c。 */
        public Config merchantId(String value) {
            this.merchantId = value;
            return this;
        }

        /** 接口签名密钥。只从环境变量/密钥管理注入，绝不进代码库。 */
        public Config apiSecret(String value) {
            this.apiSecret = value;
            return this;
        }

        /** 可选独立回调密钥；未配置则回调验签共用 apiSecret。 */
        public Config webhookSecret(String value) {
            this.webhookSecret = value;
            return this;
        }

        /** 单次请求超时（秒），默认 10。 */
        public Config timeoutSec(int value) {
            this.timeoutSec = value;
            return this;
        }

        /** 自定义传输函数（测试注入或走代理时用）。 */
        public Config transport(Transport value) {
            this.transport = value;
            return this;
        }
    }

    /** 顽兔支付客户端，线程安全，建一个复用即可。 */
    public static final class Client {
        private final String baseUrl;
        private final String merchantId;
        private final String apiSecret;
        private final String webhookSecret;
        private final Transport transport;

        public Client(Config config) {
            if (config == null || isBlank(config.baseUrl)) {
                throw new ConfigException("base_url 不能为空");
            }
            if (isBlank(config.merchantId) || isBlank(config.apiSecret)) {
                throw new ConfigException("merchant_id 与 api_secret 不能为空");
            }
            this.baseUrl = stripTrailingSlash(config.baseUrl.trim());
            this.merchantId = config.merchantId.trim();
            this.apiSecret = config.apiSecret;
            this.webhookSecret = config.webhookSecret;
            this.transport = config.transport != null
                    ? config.transport
                    : new UrlConnectionTransport(config.timeoutSec > 0 ? config.timeoutSec : DEFAULT_TIMEOUT_SEC);
        }

        // ---------- 五个接口 ----------

        /**
         * 创建支付单。同单号同参数幂等。
         *
         * <p>channel 选支付渠道：不传或 "alipay"＝支付宝；"chinaums"＝银商，形态由 payMode 决定：
         * "qrcode"＝聚合码（pay_url 是二维码内容，前端渲染成码图、不能 iframe）；"redirect"＝公众号支付
         * （微信/支付宝/云闪付 App 内打开的 H5 页面把用户 302 到 pay_url，商户无需自有公众号）。
         */
        public Map<String, Object> createOrder(CreateOrderParams params) {
            if (params == null) {
                throw new ConfigException("下单参数不能为空");
            }
            if (params.outTradeNo == null || !OUT_NO_RE.matcher(params.outTradeNo).matches()) {
                throw new ConfigException("out_trade_no 需为 1-64 位字母/数字/下划线");
            }
            if (params.amount == null || !AMOUNT_RE.matcher(params.amount).matches()) {
                throw new ConfigException("amount 必须是字符串金额（元，最多两位小数），如 \"100.00\"");
            }
            if (!"qrcode".equals(params.payMode) && !"redirect".equals(params.payMode)) {
                throw new ConfigException("pay_mode 只能是 \"qrcode\" 或 \"redirect\"");
            }
            if (params.channel != null && !"alipay".equals(params.channel) && !"chinaums".equals(params.channel)) {
                throw new ConfigException("channel 只能是 \"alipay\"（支付宝）或 \"chinaums\"（银商）");
            }
            if (params.returnUrl != null && !"redirect".equals(params.payMode)) {
                throw new ConfigException("return_url 仅在 pay_mode=\"redirect\" 下有效（qrcode 场景请用轮询）");
            }
            return call("/v1/orders", params.toBody());
        }

        /** 查询支付单（out_trade_no 与 order_id 二选一）。回调之外的兜底手段。 */
        public Map<String, Object> queryOrder(OrderRef ref) {
            return call("/v1/orders/query", requireRef(ref).toBody());
        }

        /**
         * 关闭未支付的单（out_trade_no 与 order_id 二选一）。
         *
         * <p>用户取消、改金额换单时调用：网关同步到渠道关单，旧二维码/支付链接随即失效，不用等超时。
         * 已关闭的单幂等返回；已支付的单抛 ApiException（code=40902），按已支付处理（入账或退款）。
         */
        public Map<String, Object> closeOrder(OrderRef ref) {
            return call("/v1/orders/close", requireRef(ref).toBody());
        }

        /** 申请退款。out_refund_no 是幂等键，网络失败后用同一单号重试是安全的。 */
        public Map<String, Object> createRefund(CreateRefundParams params) {
            if (params == null) {
                throw new ConfigException("退款参数不能为空");
            }
            if (params.outRefundNo == null || !OUT_NO_RE.matcher(params.outRefundNo).matches()) {
                throw new ConfigException("out_refund_no 需为 1-64 位字母/数字/下划线");
            }
            if (params.amount == null || !AMOUNT_RE.matcher(params.amount).matches()) {
                throw new ConfigException("amount 必须是字符串金额（元，最多两位小数）");
            }
            return call("/v1/refunds", params.toBody());
        }

        /**
         * 查询退款。status 为 processing / succeeded / failed；failed 时多一个 fail_reason：
         * rejected（商户后台拒绝）/ expired（超 14 天未审批自动拒绝）/ channel（支付渠道明确失败）。
         */
        public Map<String, Object> queryRefund(String outTradeNo, String outRefundNo) {
            if (isBlank(outTradeNo) || isBlank(outRefundNo)) {
                throw new ConfigException("out_trade_no 与 out_refund_no 都必须传");
            }
            Map<String, Object> body = new LinkedHashMap<String, Object>();
            body.put("out_trade_no", outTradeNo);
            body.put("out_refund_no", outRefundNo);
            return call("/v1/refunds/query", body);
        }

        // ---------- webhook 验签 ----------

        /** 验签并解析 webhook 事件，容差取默认 300 秒。 */
        public Map<String, Object> verifyWebhook(Map<String, String> headers, byte[] rawBody) {
            return verifyWebhook(headers, rawBody, DEFAULT_TOLERANCE_SEC);
        }

        /** 验签并解析 webhook 事件（请求体传字符串原文）。 */
        public Map<String, Object> verifyWebhook(Map<String, String> headers, String rawBody) {
            return verifyWebhook(headers, rawBody, DEFAULT_TOLERANCE_SEC);
        }

        /** 验签并解析 webhook 事件（请求体传字符串原文），另指定时间戳容差（秒）。 */
        public Map<String, Object> verifyWebhook(Map<String, String> headers, String rawBody, int toleranceSec) {
            return verifyWebhook(headers, rawBody == null ? null : rawBody.getBytes(StandardCharsets.UTF_8),
                    toleranceSec);
        }

        /**
         * 验签并解析 webhook 事件。
         *
         * <p>rawBody 必须是请求体原文字节（Servlet 用 request.getInputStream() 读原始字节，
         * Spring 用 @RequestBody byte[]），任何 JSON 解析要放在验签之后。验签失败抛
         * WebhookVerifyException，此时不要处理业务，返回非 2xx 状态即可，网关会自动重试。
         *
         * <p>返回的就是网关报文：event_type 为 "order.paid"、"refund.succeeded"、"refund.failed"
         * 或测试事件 "webhook.ping"，业务数据在 event.get("data")。不认识的事件类型直接返回 2xx。
         * 入账前务必：核对 data 里的 amount 与本地订单一致 + 以 out_trade_no 幂等。
         */
        @SuppressWarnings("unchecked")
        public Map<String, Object> verifyWebhook(Map<String, String> headers, byte[] rawBody, int toleranceSec) {
            if (toleranceSec <= 0) {
                toleranceSec = DEFAULT_TOLERANCE_SEC;
            }
            String timestamp = header(headers, "X-Wantu-Timestamp");
            String nonce = header(headers, "X-Wantu-Nonce");
            String signature = header(headers, "X-Wantu-Signature");
            if (isBlank(timestamp) || isBlank(nonce) || isBlank(signature)) {
                throw new WebhookVerifyException("缺少 X-Wantu-Timestamp / Nonce / Signature 请求头");
            }
            if (!DIGITS_RE.matcher(timestamp).matches()) {
                throw new WebhookVerifyException("时间戳格式不正确");
            }
            long ts;
            try {
                ts = Long.parseLong(timestamp);
            } catch (NumberFormatException e) {
                throw new WebhookVerifyException("时间戳格式不正确");
            }
            long diff = System.currentTimeMillis() / 1000L - ts;
            if (diff > toleranceSec || diff < -toleranceSec) {
                throw new WebhookVerifyException("webhook 时间戳超窗");
            }
            String bodyStr = rawBody == null ? "" : new String(rawBody, StandardCharsets.UTF_8);
            // 配置了独立 webhook_secret 用它，否则与请求签名共用 api_secret
            String secret = isBlank(webhookSecret) ? apiSecret : webhookSecret;
            String expected = sign(secret, timestamp, nonce, bodyStr);
            if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                    signature.getBytes(StandardCharsets.UTF_8))) {
                throw new WebhookVerifyException("webhook 验签失败：报文不可信");
            }

            Object parsed;
            try {
                parsed = Json.parse(bodyStr);
            } catch (RuntimeException e) {
                throw new WebhookVerifyException("webhook 请求体不是合法 JSON");
            }
            if (!(parsed instanceof Map)) {
                throw new WebhookVerifyException("webhook 请求体不是 JSON 对象");
            }
            return (Map<String, Object>) parsed;
        }

        // ---------- 内部 ----------

        @SuppressWarnings("unchecked")
        private Map<String, Object> call(String path, Map<String, Object> payload) {
            String body = Json.stringify(payload);
            String timestamp = Long.toString(System.currentTimeMillis() / 1000L);
            String nonce = newNonce();
            Map<String, String> headers = new LinkedHashMap<String, String>();
            headers.put("Content-Type", "application/json; charset=utf-8");
            headers.put("X-Wantu-Merchant-Id", merchantId);
            headers.put("X-Wantu-Timestamp", timestamp);
            headers.put("X-Wantu-Nonce", nonce);
            headers.put("X-Wantu-Signature", sign(apiSecret, timestamp, nonce, body));

            Response response;
            try {
                response = transport.send(baseUrl + path, headers, body);
            } catch (IOException e) {
                throw new HttpException("请求顽兔支付网关失败: " + path, 0, null, e);
            }

            Object parsed;
            try {
                parsed = Json.parse(response.text);
            } catch (RuntimeException e) {
                throw new HttpException("网关响应不是 JSON（HTTP " + response.status + "）",
                        response.status, preview(response.text), e);
            }
            if (!(parsed instanceof Map)) {
                throw new HttpException("网关响应不是 JSON 对象（HTTP " + response.status + "）",
                        response.status, preview(response.text), null);
            }
            Map<String, Object> envelope = (Map<String, Object>) parsed;
            Object code = envelope.get("code");
            if (!(code instanceof Number)) {
                throw new HttpException("网关响应缺少 code 字段", response.status, preview(response.text), null);
            }
            int codeValue = ((Number) code).intValue();
            if (codeValue != 0) {
                Object message = envelope.get("message");
                throw new ApiException(codeValue,
                        message instanceof String ? (String) message : "未知错误",
                        response.status,
                        asString(envelope.get("channel_code")),
                        asString(envelope.get("channel_message")));
            }
            Object data = envelope.get("data");
            if (data instanceof Map) {
                return (Map<String, Object>) data;
            }
            return new LinkedHashMap<String, Object>();
        }

        private static OrderRef requireRef(OrderRef ref) {
            if (ref == null || (isBlank(ref.outTradeNo) && isBlank(ref.orderId))) {
                throw new ConfigException("out_trade_no 与 order_id 至少传一个");
            }
            return ref;
        }
    }

    // ---------- 入参 ----------

    /** 下单入参，链式设置；字段名与报文一一对应。 */
    public static final class CreateOrderParams {
        String outTradeNo;
        String amount;
        String subject;
        String payMode;
        String channel;
        Integer qrcodeWidth;
        String returnUrl;
        Integer expireMinutes;
        String attach;
        String notifyUrl;

        /** 商户订单号：1-64 位字母/数字/下划线，商户侧唯一，也是入账幂等键。 */
        public CreateOrderParams outTradeNo(String value) {
            this.outTradeNo = value;
            return this;
        }

        /** 金额（元）字符串，最多两位小数。务必以服务端价格为准，绝不信任前端传值。 */
        public CreateOrderParams amount(String value) {
            this.amount = value;
            return this;
        }

        /** 订单标题，不可含 / = & 特殊字符。 */
        public CreateOrderParams subject(String value) {
            this.subject = value;
            return this;
        }

        /** qrcode=二维码收款；redirect=整页跳转。 */
        public CreateOrderParams payMode(String value) {
            this.payMode = value;
            return this;
        }

        /** 支付渠道：不传或 alipay=支付宝；chinaums=银联商务聚合码。 */
        public CreateOrderParams channel(String value) {
            this.channel = value;
            return this;
        }

        /** qrcode 模式下二维码宽度（px），默认 200，仅支付宝生效。 */
        public CreateOrderParams qrcodeWidth(Integer value) {
            this.qrcodeWidth = value;
            return this;
        }

        /** redirect 模式支付完成后的回跳地址；仅作展示回跳，入账只认回调/查单。 */
        public CreateOrderParams returnUrl(String value) {
            this.returnUrl = value;
            return this;
        }

        /** 未支付自动关单时限（分钟），默认 15，范围 2 到 1440。 */
        public CreateOrderParams expireMinutes(Integer value) {
            this.expireMinutes = value;
            return this;
        }

        /** ≤128 字符，回调原样带回（如内部用户 ID）。 */
        public CreateOrderParams attach(String value) {
            this.attach = value;
            return this;
        }

        /**
         * 这张单的回调地址，http/https，最长 512 字符，三种 webhook 事件都发到它。只有商户后台「接入配置」把回调方式
         * 切成「按单指定」才能传，而且必传，值要和白名单里登记的地址一字不差；默认的「统一地址」模式下传了报 42202。
         */
        public CreateOrderParams notifyUrl(String value) {
            this.notifyUrl = value;
            return this;
        }

        Map<String, Object> toBody() {
            Map<String, Object> body = new LinkedHashMap<String, Object>();
            body.put("out_trade_no", outTradeNo);
            body.put("amount", amount);
            body.put("subject", subject);
            body.put("channel", channel);
            body.put("pay_mode", payMode);
            body.put("qrcode_width", qrcodeWidth);
            body.put("return_url", returnUrl);
            body.put("expire_minutes", expireMinutes);
            body.put("attach", attach);
            body.put("notify_url", notifyUrl);
            return body;
        }
    }

    /** 定位一张订单：outTradeNo 与 orderId 传一个即可，两个都传以 outTradeNo 为准。 */
    public static final class OrderRef {
        final String outTradeNo;
        final String orderId;

        private OrderRef(String outTradeNo, String orderId) {
            this.outTradeNo = outTradeNo;
            this.orderId = orderId;
        }

        /** 按商户订单号定位。 */
        public static OrderRef outTradeNo(String value) {
            return new OrderRef(value, null);
        }

        /** 按顽兔支付单号定位。 */
        public static OrderRef orderId(String value) {
            return new OrderRef(null, value);
        }

        Map<String, Object> toBody() {
            Map<String, Object> body = new LinkedHashMap<String, Object>();
            body.put("out_trade_no", outTradeNo);
            body.put("order_id", orderId);
            return body;
        }
    }

    /** 申请退款入参，链式设置。 */
    public static final class CreateRefundParams {
        String outTradeNo;
        String outRefundNo;
        String amount;
        String reason;

        public CreateRefundParams outTradeNo(String value) {
            this.outTradeNo = value;
            return this;
        }

        /** 商户退款单号：幂等键，重试请用同一单号。 */
        public CreateRefundParams outRefundNo(String value) {
            this.outRefundNo = value;
            return this;
        }

        public CreateRefundParams amount(String value) {
            this.amount = value;
            return this;
        }

        /** 退款原因，≤256 字符，透传给支付渠道。 */
        public CreateRefundParams reason(String value) {
            this.reason = value;
            return this;
        }

        Map<String, Object> toBody() {
            Map<String, Object> body = new LinkedHashMap<String, Object>();
            body.put("out_trade_no", outTradeNo);
            body.put("out_refund_no", outRefundNo);
            body.put("amount", amount);
            body.put("reason", reason);
            return body;
        }
    }

    // ---------- 签名 ----------

    /**
     * 按网关口径生成签名：三行拼接 timestamp\nnonce\n请求体原文，HMAC-SHA256 后 hex 小写。
     * 自行实现签名时可拿它对拍。
     */
    public static String sign(String secret, String timestamp, String nonce, String body) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return toHex(mac.doFinal((timestamp + "\n" + nonce + "\n" + body).getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new PayException("计算 HMAC-SHA256 失败", e);
        }
    }

    private static String newNonce() {
        byte[] buf = new byte[NONCE_BYTES];
        RANDOM.nextBytes(buf);
        return toHex(buf);
    }

    private static String toHex(byte[] bytes) {
        StringBuilder hex = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            hex.append(Character.forDigit((b >> 4) & 0xF, 16));
            hex.append(Character.forDigit(b & 0xF, 16));
        }
        return hex.toString();
    }

    // ---------- 工具 ----------

    private static boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }

    private static String stripTrailingSlash(String value) {
        int end = value.length();
        while (end > 0 && value.charAt(end - 1) == '/') {
            end--;
        }
        return value.substring(0, end);
    }

    private static String asString(Object value) {
        return value instanceof String ? (String) value : null;
    }

    private static String preview(String text) {
        if (text == null) {
            return null;
        }
        return text.length() > BODY_PREVIEW_LEN ? text.substring(0, BODY_PREVIEW_LEN) : text;
    }

    /** 大小写不敏感取请求头，兼容 Servlet / Spring 各自的键名习惯。 */
    private static String header(Map<String, String> headers, String name) {
        if (headers == null) {
            return null;
        }
        String direct = headers.get(name);
        if (direct != null) {
            return direct;
        }
        for (Map.Entry<String, String> entry : headers.entrySet()) {
            if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) {
                return entry.getValue();
            }
        }
        return null;
    }

    // ---------- 缺省传输：HttpURLConnection（Java 8 起可用，无第三方依赖） ----------

    private static final class UrlConnectionTransport implements Transport {
        private final int timeoutSec;

        UrlConnectionTransport(int timeoutSec) {
            this.timeoutSec = timeoutSec;
        }

        @Override
        public Response send(String url, Map<String, String> headers, String body) throws IOException {
            // 走 URI.create(...).toURL() 而不是 new URL(String)：后者在 JDK 20+ 已弃用，
            // 商户在新版 JDK 上开 -Xlint:deprecation -Werror 会被卡住。两者在 Java 8 上等价
            HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
            try {
                conn.setRequestMethod("POST");
                conn.setConnectTimeout(timeoutSec * 1000);
                conn.setReadTimeout(timeoutSec * 1000);
                conn.setDoOutput(true);
                for (Map.Entry<String, String> entry : headers.entrySet()) {
                    conn.setRequestProperty(entry.getKey(), entry.getValue());
                }
                byte[] payload = body.getBytes(StandardCharsets.UTF_8);
                // 不要用 setFixedLengthStreamingMode：固定长度流模式下 JDK 无法为「401 后重试认证」
                // 缓冲请求体，于是把 401 的响应正文连同 getErrorStream() 一起丢掉，网关的 40001/40002
                // 就读不到了（这两个恰是商户接入期最常撞的错）。请求体只有几百字节，交给 JDK 缓冲无妨。
                OutputStream out = conn.getOutputStream();
                try {
                    out.write(payload);
                } finally {
                    out.close();
                }
                int status = conn.getResponseCode();
                // 4xx/5xx 的响应体里是网关的错误外壳，照常读出来交给上层解析
                InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
                return new Response(status, readAll(stream));
            } finally {
                conn.disconnect();
            }
        }

        private static String readAll(InputStream stream) throws IOException {
            if (stream == null) {
                return "";
            }
            ByteArrayOutputStream buffer = new ByteArrayOutputStream();
            byte[] chunk = new byte[4096];
            int read;
            try {
                while ((read = stream.read(chunk)) != -1) {
                    buffer.write(chunk, 0, read);
                }
            } finally {
                stream.close();
            }
            return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
        }
    }

    // ---------- 极简 JSON（够用即可，避免引第三方包破坏「零依赖、复制即用」） ----------

    static final class Json {

        private Json() {
        }

        /**
         * 序列化为报文原文：值为 null 的键整条丢弃（与 Python 版剔除 None 同义）；
         * 非 ASCII 不转义、/ 与 &lt; &gt; &amp; 不转义，与其他语言版本输出一致。
         */
        static String stringify(Map<String, Object> map) {
            StringBuilder sb = new StringBuilder();
            sb.append('{');
            boolean first = true;
            for (Map.Entry<String, Object> entry : map.entrySet()) {
                if (entry.getValue() == null) {
                    continue;
                }
                if (!first) {
                    sb.append(',');
                }
                first = false;
                writeString(sb, entry.getKey());
                sb.append(':');
                writeValue(sb, entry.getValue());
            }
            sb.append('}');
            return sb.toString();
        }

        // 请求报文只有字符串与整数两种值（入参类都是 String / Integer），
        // 冒出别的类型说明调用方写错了，直接报错而不是悄悄 toString 出个歪报文
        private static void writeValue(StringBuilder sb, Object value) {
            if (value instanceof String) {
                writeString(sb, (String) value);
            } else if (value instanceof Number || value instanceof Boolean) {
                sb.append(value.toString());
            } else {
                throw new IllegalArgumentException("不支持的 JSON 值类型: " + value.getClass().getName());
            }
        }

        private static void writeString(StringBuilder sb, String value) {
            sb.append('"');
            for (int i = 0; i < value.length(); i++) {
                char c = value.charAt(i);
                switch (c) {
                    case '"':
                        sb.append("\\\"");
                        break;
                    case '\\':
                        sb.append("\\\\");
                        break;
                    case '\n':
                        sb.append("\\n");
                        break;
                    case '\r':
                        sb.append("\\r");
                        break;
                    case '\t':
                        sb.append("\\t");
                        break;
                    case '\b':
                        sb.append("\\b");
                        break;
                    case '\f':
                        sb.append("\\f");
                        break;
                    default:
                        if (c < 0x20) {
                            sb.append(String.format("\\u%04x", (int) c));
                        } else {
                            sb.append(c);
                        }
                }
            }
            sb.append('"');
        }

        /** 解析 JSON 文本；解析不了抛 IllegalArgumentException，由调用方转成对应的 SDK 异常。 */
        static Object parse(String text) {
            if (text == null) {
                throw new IllegalArgumentException("空文本");
            }
            Parser parser = new Parser(text);
            parser.skipWhitespace();
            Object value = parser.readValue();
            parser.skipWhitespace();
            if (!parser.atEnd()) {
                throw new IllegalArgumentException("JSON 尾部有多余内容");
            }
            return value;
        }

        private static final class Parser {
            private final String text;
            private int pos;

            Parser(String text) {
                this.text = text;
            }

            boolean atEnd() {
                return pos >= text.length();
            }

            void skipWhitespace() {
                while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) {
                    pos++;
                }
            }

            Object readValue() {
                if (atEnd()) {
                    throw new IllegalArgumentException("JSON 意外结束");
                }
                char c = text.charAt(pos);
                switch (c) {
                    case '{':
                        return readObject();
                    case '[':
                        return readArray();
                    case '"':
                        return readString();
                    case 't':
                        expect("true");
                        return Boolean.TRUE;
                    case 'f':
                        expect("false");
                        return Boolean.FALSE;
                    case 'n':
                        expect("null");
                        return null;
                    default:
                        return readNumber();
                }
            }

            private Map<String, Object> readObject() {
                Map<String, Object> map = new LinkedHashMap<String, Object>();
                pos++; // {
                skipWhitespace();
                if (!atEnd() && text.charAt(pos) == '}') {
                    pos++;
                    return map;
                }
                while (true) {
                    skipWhitespace();
                    String key = readString();
                    skipWhitespace();
                    if (atEnd() || text.charAt(pos) != ':') {
                        throw new IllegalArgumentException("对象缺少冒号");
                    }
                    pos++;
                    skipWhitespace();
                    map.put(key, readValue());
                    skipWhitespace();
                    if (atEnd()) {
                        throw new IllegalArgumentException("对象未闭合");
                    }
                    char c = text.charAt(pos++);
                    if (c == '}') {
                        return map;
                    }
                    if (c != ',') {
                        throw new IllegalArgumentException("对象分隔符不对");
                    }
                }
            }

            private List<Object> readArray() {
                List<Object> list = new ArrayList<Object>();
                pos++; // [
                skipWhitespace();
                if (!atEnd() && text.charAt(pos) == ']') {
                    pos++;
                    return list;
                }
                while (true) {
                    skipWhitespace();
                    list.add(readValue());
                    skipWhitespace();
                    if (atEnd()) {
                        throw new IllegalArgumentException("数组未闭合");
                    }
                    char c = text.charAt(pos++);
                    if (c == ']') {
                        return list;
                    }
                    if (c != ',') {
                        throw new IllegalArgumentException("数组分隔符不对");
                    }
                }
            }

            private String readString() {
                if (atEnd() || text.charAt(pos) != '"') {
                    throw new IllegalArgumentException("期望字符串");
                }
                pos++;
                StringBuilder sb = new StringBuilder();
                while (true) {
                    if (atEnd()) {
                        throw new IllegalArgumentException("字符串未闭合");
                    }
                    char c = text.charAt(pos++);
                    if (c == '"') {
                        return sb.toString();
                    }
                    if (c != '\\') {
                        sb.append(c);
                        continue;
                    }
                    if (atEnd()) {
                        throw new IllegalArgumentException("转义未完成");
                    }
                    char esc = text.charAt(pos++);
                    switch (esc) {
                        case '"':
                            sb.append('"');
                            break;
                        case '\\':
                            sb.append('\\');
                            break;
                        case '/':
                            sb.append('/');
                            break;
                        case 'b':
                            sb.append('\b');
                            break;
                        case 'f':
                            sb.append('\f');
                            break;
                        case 'n':
                            sb.append('\n');
                            break;
                        case 'r':
                            sb.append('\r');
                            break;
                        case 't':
                            sb.append('\t');
                            break;
                        case 'u':
                            if (pos + 4 > text.length()) {
                                throw new IllegalArgumentException("\\u 转义不完整");
                            }
                            sb.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16));
                            pos += 4;
                            break;
                        default:
                            throw new IllegalArgumentException("未知转义: \\" + esc);
                    }
                }
            }

            private Object readNumber() {
                int start = pos;
                while (pos < text.length() && "+-.eE0123456789".indexOf(text.charAt(pos)) >= 0) {
                    pos++;
                }
                String raw = text.substring(start, pos);
                if (raw.isEmpty()) {
                    throw new IllegalArgumentException("期望数字，实际: " + text.charAt(start));
                }
                if (raw.indexOf('.') >= 0 || raw.indexOf('e') >= 0 || raw.indexOf('E') >= 0) {
                    return Double.valueOf(raw);
                }
                return Long.valueOf(raw);
            }

            private void expect(String literal) {
                if (!text.startsWith(literal, pos)) {
                    throw new IllegalArgumentException("期望 " + literal);
                }
                pos += literal.length();
            }
        }
    }
}
