/*
 * TConnect Java SDK — TConnect payment gateway (docs.tconnect.vn)
 * A faithful port of the Python SDK. JDK-only (Java 11+): javax.crypto, java.net.http.HttpClient.
 *
 * NO external dependencies. To stay dependency-free, the API methods return the raw
 * JSON string (String) — parse it yourself with your preferred JSON library (Jackson/Gson...).
 *
 * Usage example (mirrors the Python version):
 *   TConnect tc = TConnect.builder()
 *       .partnerCode("PARTNER").secretKey("<AES256_HEX>")
 *       .username("user").password("pass")
 *       .clientId("cid").clientSecret("csecret")
 *       .serviceCode("SVC").va("VA123").bincode("970000").build();
 *   String services = tc.getServices(null, null, null, 10, 1);
 *   String qr = tc.createQr("ORDER-001", 100000, null, null, null, null);
 *   String ipn = tc.parseIpn(rawWebhookBody); // returns the decrypted JSON
 *
 * Full documentation: https://docs.tconnect.vn
 */

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Java SDK for the TConnect payment gateway.
 *
 * <p>The API methods return the raw (decrypted/plain) JSON {@code String}. This is a
 * deliberate choice so the SDK depends on no JSON library. In production you can parse
 * the result with Jackson, Gson, org.json... as you prefer.
 *
 * @see <a href="https://docs.tconnect.vn">https://docs.tconnect.vn</a>
 */
public class TConnect {

    // ------------------------------------------------------------------
    // Configuration
    // ------------------------------------------------------------------
    private final String partnerCode;
    private final byte[] key;          // AES-256 key bytes = hexToBytes(secretKey)
    private final String username;
    private final String password;
    private final String clientId;
    private final String clientSecret;
    private final String serviceCode;
    private final String va;
    private final String bincode;
    private final String baseUrl;
    private final int timeoutSeconds;

    private final HttpClient http;
    private final SecureRandom random = new SecureRandom();

    // ------------------------------------------------------------------
    // Token state
    // ------------------------------------------------------------------
    private String accessToken;
    private String refreshToken;
    private long accessExpiresAt;   // epoch seconds
    private long refreshExpiresAt;  // epoch seconds

    public static final String DEFAULT_BASE_URL = "https://sme-open-api-sandbox.tconnect.vn";

    // ------------------------------------------------------------------
    // Constructor + Builder
    // ------------------------------------------------------------------
    public TConnect(String partnerCode, String secretKey, String username, String password,
                    String clientId, String clientSecret, String serviceCode,
                    String va, String bincode, String baseUrl, int timeoutSeconds) {
        this.partnerCode = partnerCode;
        this.key = hexToBytes(secretKey);
        this.username = username;
        this.password = password;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.serviceCode = serviceCode;
        this.va = va;
        this.bincode = bincode;
        this.baseUrl = (baseUrl == null || baseUrl.isEmpty()) ? DEFAULT_BASE_URL : stripTrailingSlash(baseUrl);
        this.timeoutSeconds = timeoutSeconds <= 0 ? 30 : timeoutSeconds;
        this.http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(this.timeoutSeconds))
                .build();
    }

    public static Builder builder() {
        return new Builder();
    }

    /** Builder for {@link TConnect}. */
    public static class Builder {
        private String partnerCode;
        private String secretKey;
        private String username;
        private String password;
        private String clientId;
        private String clientSecret;
        private String serviceCode;
        private String va;
        private String bincode;
        private String baseUrl = DEFAULT_BASE_URL;
        private int timeoutSeconds = 30;

        public Builder partnerCode(String v) { this.partnerCode = v; return this; }
        public Builder secretKey(String v) { this.secretKey = v; return this; }
        public Builder username(String v) { this.username = v; return this; }
        public Builder password(String v) { this.password = v; return this; }
        public Builder clientId(String v) { this.clientId = v; return this; }
        public Builder clientSecret(String v) { this.clientSecret = v; return this; }
        public Builder serviceCode(String v) { this.serviceCode = v; return this; }
        public Builder va(String v) { this.va = v; return this; }
        public Builder bincode(String v) { this.bincode = v; return this; }
        public Builder baseUrl(String v) { this.baseUrl = v; return this; }
        public Builder timeoutSeconds(int v) { this.timeoutSeconds = v; return this; }

        public TConnect build() {
            return new TConnect(partnerCode, secretKey, username, password, clientId,
                    clientSecret, serviceCode, va, bincode, baseUrl, timeoutSeconds);
        }
    }

    // ==================================================================
    // AES-256-CBC (random 16-byte IV prepended, hex-encoded, PKCS5/7 padding)
    // ==================================================================

    /**
     * Encrypt {@code plain} with AES-256-CBC. A random 16-byte IV is prepended to the
     * ciphertext and the whole thing is returned as hex.
     */
    public String encrypt(String plain) {
        try {
            byte[] iv = new byte[16];
            random.nextBytes(iv);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
            byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
            byte[] out = new byte[iv.length + ct.length];
            System.arraycopy(iv, 0, out, 0, iv.length);
            System.arraycopy(ct, 0, out, iv.length, ct.length);
            return bytesToHex(out);
        } catch (Exception e) {
            throw new TConnectException("Encrypt failed: " + e.getMessage(), e);
        }
    }

    /** Decrypt the hex string produced by {@link #encrypt(String)}, returning UTF-8 plaintext. */
    public String decrypt(String hex) {
        try {
            byte[] raw = hexToBytes(hex);
            byte[] iv = new byte[16];
            System.arraycopy(raw, 0, iv, 0, 16);
            byte[] ct = new byte[raw.length - 16];
            System.arraycopy(raw, 16, ct, 0, ct.length);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new IvParameterSpec(iv));
            byte[] pt = cipher.doFinal(ct);
            return new String(pt, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new TConnectException("Decrypt failed: " + e.getMessage(), e);
        }
    }

    /** Convert a hex string -> byte[]. */
    public static byte[] hexToBytes(String hex) {
        if (hex == null) throw new IllegalArgumentException("hex is null");
        int len = hex.length();
        if (len % 2 != 0) throw new IllegalArgumentException("Invalid hex length");
        byte[] out = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            int hi = Character.digit(hex.charAt(i), 16);
            int lo = Character.digit(hex.charAt(i + 1), 16);
            if (hi < 0 || lo < 0) throw new IllegalArgumentException("Invalid hex char");
            out[i / 2] = (byte) ((hi << 4) | lo);
        }
        return out;
    }

    /** Convert byte[] -> hex string (lowercase). */
    public static String bytesToHex(byte[] bytes) {
        char[] hexChars = new char[bytes.length * 2];
        final char[] HEX = "0123456789abcdef".toCharArray();
        for (int i = 0; i < bytes.length; i++) {
            int v = bytes[i] & 0xFF;
            hexChars[i * 2] = HEX[v >>> 4];
            hexChars[i * 2 + 1] = HEX[v & 0x0F];
        }
        return new String(hexChars);
    }

    // ==================================================================
    // Authentication
    // ==================================================================

    /**
     * Log in. POST /openapi/v1/auth/login with an encrypted body, no token required.
     * Stores access_token / refresh_token / expires_in / refresh_expires_in.
     */
    public String login() {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("username", username);
        body.put("password", password);
        body.put("client_id", clientId);
        body.put("client_secret", clientSecret);
        String resp = post("/openapi/v1/auth/login", toJson(body), false, true, null);
        storeToken(resp);
        return resp;
    }

    /**
     * Refresh the token. If there is no refresh token or it has expired -> {@link #login()};
     * otherwise POST /openapi/v1/auth/refresh with an encrypted body.
     */
    public String refresh() {
        if (refreshToken == null || isExpired(refreshExpiresAt)) {
            return login();
        }
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("refresh_token", refreshToken);
        String resp = post("/openapi/v1/auth/refresh", toJson(body), false, true, null);
        storeToken(resp);
        return resp;
    }

    /**
     * Return a valid access token. If missing/expired: refresh() when the refresh token is
     * still valid, otherwise login().
     */
    public String token() {
        if (accessToken == null || isExpired(accessExpiresAt)) {
            if (refreshToken != null && !isExpired(refreshExpiresAt)) {
                refresh();
            } else {
                login();
            }
        }
        return accessToken;
    }

    private void storeToken(String json) {
        String access = extractString(json, "access_token");
        String refresh = extractString(json, "refresh_token");
        Long expiresIn = extractLong(json, "expires_in");
        Long refreshExpiresIn = extractLong(json, "refresh_expires_in");
        long now = System.currentTimeMillis() / 1000L;
        if (access != null) this.accessToken = access;
        if (refresh != null) this.refreshToken = refresh;
        if (expiresIn != null) this.accessExpiresAt = now + expiresIn;
        if (refreshExpiresIn != null) this.refreshExpiresAt = now + refreshExpiresIn;
    }

    /** Expired if there is no longer a 60s margin. */
    private boolean isExpired(long expiresAt) {
        long now = System.currentTimeMillis() / 1000L;
        return now >= (expiresAt - 60);
    }

    // ==================================================================
    // Business endpoints
    // ==================================================================

    /**
     * List services. GET /openapi/v1/services with query params (URL-encoded),
     * NOT encrypted, headers Partner-Code + Bearer.
     */
    public String getServices(String serviceType, String code, String paymentMethod, int limit, int page) {
        StringBuilder q = new StringBuilder();
        appendQuery(q, "service_type", serviceType);
        appendQuery(q, "code", code);
        appendQuery(q, "payment_method", paymentMethod);
        appendQuery(q, "limit", String.valueOf(limit));
        appendQuery(q, "page", String.valueOf(page));
        String path = "/openapi/v1/services" + (q.length() > 0 ? "?" + q : "");
        return get(path);
    }

    /**
     * Create a VA. POST /openapi/v1/va/va-account/create (encrypted, auth).
     * @param requestId may be null -> a UUID is generated
     */
    public String createVa(String fullName, String bankAccountNo, String internalCode, String requestId) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("request_id", requestId != null ? requestId : UUID.randomUUID().toString());
        body.put("full_name", fullName);
        body.put("bank_account_no", bankAccountNo);
        body.put("internal_code", internalCode);
        return post("/openapi/v1/va/va-account/create", toJson(body), true, true, null);
    }

    /**
     * Create a QR. POST /openapi/v1/transaction/qr/generate (encrypted, auth) + x-service-code header.
     * When va/bincode/serviceCode are null they fall back to the instance defaults;
     * if still missing -> {@link TConnectException}.
     * @param reqId may be null -> a UUID is generated
     */
    public String createQr(String orderId, long amount, String va, String bincode, String serviceCode, String reqId) {
        String vaVal = va != null ? va : this.va;
        String binVal = bincode != null ? bincode : this.bincode;
        String svcVal = serviceCode != null ? serviceCode : this.serviceCode;
        if (vaVal == null || binVal == null || svcVal == null) {
            throw new TConnectException("createQr requires va, bincode and serviceCode (from args or instance defaults)");
        }
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("req_id", reqId != null ? reqId : UUID.randomUUID().toString());
        body.put("order_id", orderId);
        body.put("va", vaVal);
        body.put("bincode", binVal);
        body.put("amount", amount);
        Map<String, String> headers = new LinkedHashMap<>();
        headers.put("x-service-code", svcVal);
        return post("/openapi/v1/transaction/qr/generate", toJson(body), true, true, headers);
    }

    /**
     * Push a payment command to a device. POST /openapi/v1/devices/payments/push (encrypted, auth).
     *
     * <p>type="pos": POS terminal — CARD payment. Pick the operation via {@code action}:
     * SALE (default) / MOTO / PREAUTH / PREAUTH_COMPLETE / VOID / SETTLEMENT. VOID and
     * PREAUTH_COMPLETE require {@code actionData} (retrievalRefNo); also send {@code traceNo}
     * (and {@code txnId} for PREAUTH_COMPLETE). SETTLEMENT needs no orderId/amount.
     * <p>type="soundbox": Soundbox speaker — requires {@code qrString} (from createQr).
     *
     * @param amount       may be null (e.g. SETTLEMENT)
     * @param extraData    extra data passed through to the device (POS only), may be null
     * @param customerCode -> X-Customer-Code header (required for a Service Provider token), may be null
     */
    public String pushToDevice(String serialNo, Long amount, String type, String orderId, String qrString,
                               String action, String actionData, String traceNo, String txnId,
                               String requestId, Map<String, Object> extraData, String customerCode) {
        String t = type != null ? type : "pos";
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("serial_no", serialNo);
        body.put("type", t);
        if (amount != null) body.put("amount", amount);
        if (orderId != null) body.put("order_id", orderId);
        if ("soundbox".equals(t)) {
            if (qrString != null) body.put("qr_string", qrString);
        } else { // pos
            if (action != null) body.put("action", action);
            if (actionData != null) body.put("action_data", actionData);
            if (traceNo != null) body.put("trace_no", traceNo);
            if (txnId != null) body.put("txn_id", txnId);
            body.put("request_id", requestId != null ? requestId : UUID.randomUUID().toString());
            if (extraData != null) body.put("extra_data", extraData);
        }
        Map<String, String> headers = null;
        if (customerCode != null) {
            headers = new LinkedHashMap<>();
            headers.put("X-Customer-Code", customerCode);
        }
        return post("/openapi/v1/devices/payments/push", toJson(body), true, true, headers);
    }

    /** Convenience overload for a simple SALE (POS) or QR (soundbox) push. */
    public String pushToDevice(String serialNo, long amount, String type, String orderId, String qrString) {
        return pushToDevice(serialNo, amount, type, orderId, qrString,
                null, null, null, null, null, null, null);
    }

    /**
     * Create an ECOM payment link. POST /openapi/v1/ecom/payments/link (encrypted, auth).
     * Returns JSON with payment_id, payment_url, status, expires_at, ...
     * @param customer optional map {"name","email","phone"}; may be null
     */
    public String createPaymentLink(String orderId, long amount, String currency, String description,
                                    String returnUrl, String ipnUrl, int expiresIn, Map<String, Object> customer) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("order_id", orderId);
        body.put("amount", amount);
        body.put("currency", currency != null ? currency : "VND");
        body.put("expires_in", expiresIn > 0 ? expiresIn : 900);
        if (description != null) body.put("description", description);
        if (returnUrl != null) body.put("return_url", returnUrl);
        if (ipnUrl != null) body.put("ipn_url", ipnUrl);
        if (customer != null) body.put("customer", customer);
        return post("/openapi/v1/ecom/payments/link", toJson(body), true, true, null);
    }

    /** Cancel an ECOM payment link by orderId. POST /openapi/v1/ecom/payments/cancel (encrypted, auth). */
    public String cancelPaymentLink(String orderId, String description) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("order_id", orderId);
        if (description != null) body.put("description", description);
        Map<String, String> extra = Collections.singletonMap("x-service-code", "ecom");
        return post("/openapi/v1/ecom/payments/cancel", toJson(body), true, true, extra);
    }

    /**
     * QR transaction history. POST /openapi/v1/transaction/qr (encrypted, auth).
     * from_date + to_date are only added when both are present; null optionals are skipped.
     */
    public String getQrTransactions(int limit, int page, String fromDate, String toDate, String orderId, String accNo) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("limit", limit);
        body.put("page", page);
        if (fromDate != null && toDate != null) {
            body.put("from_date", fromDate);
            body.put("to_date", toDate);
        }
        if (orderId != null) body.put("order_id", orderId);
        if (accNo != null) body.put("acc_no", accNo);
        return post("/openapi/v1/transaction/qr", toJson(body), true, true, null);
    }

    /** Convenience overload for {@link #getQrTransactions}. */
    public String getQrTransactions(int limit, int page) {
        return getQrTransactions(limit, page, null, null, null, null);
    }

    /**
     * Check a QR order status. POST /openapi/v1/transaction/qr/order/status (encrypted, auth).
     * @param accNo nullable
     */
    public String checkQrOrder(String orderId, String accNo) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("order_id", orderId);
        if (accNo != null) body.put("acc_no", accNo);
        return post("/openapi/v1/transaction/qr/order/status", toJson(body), true, true, null);
    }

    /**
     * SUCCESSFUL card transaction history (POS). POST /openapi/v1/transaction/card (encrypted, auth).
     * Filter by POS serial and/or a time range; if the order_id appears, the card payment succeeded.
     */
    public String getCardTransactions(int limit, int page, String fromDate, String toDate, String serialNo) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("limit", limit);
        body.put("page", page);
        if (fromDate != null && toDate != null) {
            body.put("from_date", fromDate);
            body.put("to_date", toDate);
        }
        if (serialNo != null) body.put("serial_no", serialNo);
        return post("/openapi/v1/transaction/card", toJson(body), true, true, null);
    }

    /** Convenience overload for {@link #getCardTransactions} without a serial filter. */
    public String getCardTransactions(int limit, int page, String fromDate, String toDate) {
        return getCardTransactions(limit, page, fromDate, toDate, null);
    }

    /** Cash transaction history. POST /openapi/v1/transaction/cash (encrypted, auth). */
    public String getCashTransactions(int limit, int page, String fromDate, String toDate) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("limit", limit);
        body.put("page", page);
        if (fromDate != null && toDate != null) {
            body.put("from_date", fromDate);
            body.put("to_date", toDate);
        }
        return post("/openapi/v1/transaction/cash", toJson(body), true, true, null);
    }

    /**
     * Parse an IPN/webhook. {@code body} is JSON with a "data" field holding the encrypted hex;
     * this extracts, decrypts, and returns the decrypted JSON string.
     */
    public String parseIpn(String body) {
        String data = extractString(body, "data");
        if (data == null) {
            throw new TConnectException("IPN body missing 'data' field");
        }
        return decrypt(data);
    }

    // ==================================================================
    // HTTP
    // ==================================================================

    /**
     * Send a POST. Headers always include Partner-Code + Content-Type application/json.
     * When {@code auth}, add Authorization Bearer. When {@code encrypted}, the body is
     * {"data":"<encrypt(payload)>"}; otherwise the payload is sent as-is.
     *
     * <p>These APIs return plain JSON (not wrapped in {"data":...}), so the body is returned
     * verbatim. A status >= 400 throws {@link TConnectException}.
     */
    public String post(String path, String jsonPayload, boolean auth, boolean encrypted, Map<String, String> extraHeaders) {
        String finalBody;
        if (encrypted) {
            Map<String, Object> env = new LinkedHashMap<>();
            env.put("data", encrypt(jsonPayload));
            finalBody = toJson(env);
        } else {
            finalBody = jsonPayload;
        }

        HttpRequest.Builder rb = HttpRequest.newBuilder()
                .uri(URI.create(baseUrl + path))
                .timeout(Duration.ofSeconds(timeoutSeconds))
                .header("Partner-Code", partnerCode)
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(finalBody, StandardCharsets.UTF_8));

        if (auth) {
            rb.header("Authorization", "Bearer " + token());
        }
        if (extraHeaders != null) {
            for (Map.Entry<String, String> e : extraHeaders.entrySet()) {
                rb.header(e.getKey(), e.getValue());
            }
        }

        return send(rb.build());
    }

    /** Send a GET with Partner-Code + Bearer headers (not encrypted). */
    private String get(String path) {
        HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create(baseUrl + path))
                .timeout(Duration.ofSeconds(timeoutSeconds))
                .header("Partner-Code", partnerCode)
                .header("Authorization", "Bearer " + token())
                .GET()
                .build();
        return send(req);
    }

    private String send(HttpRequest req) {
        try {
            HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            int status = resp.statusCode();
            String body = resp.body();
            if (status >= 400) {
                throw new TConnectException(status, body);
            }
            return body;
        } catch (TConnectException e) {
            throw e;
        } catch (Exception e) {
            throw new TConnectException("HTTP request failed: " + e.getMessage(), e);
        }
    }

    // ==================================================================
    // Minimal JSON helpers (no external dependency)
    // ==================================================================

    /**
     * Serialize a {@code Map<String,Object>} to compact JSON.
     * Supports String, Number, Boolean, nested Map, and null.
     */
    @SuppressWarnings("unchecked")
    public static String toJson(Map<String, Object> map) {
        StringBuilder sb = new StringBuilder("{");
        boolean first = true;
        for (Map.Entry<String, Object> e : map.entrySet()) {
            if (!first) sb.append(',');
            first = false;
            sb.append('"').append(escape(e.getKey())).append("\":");
            appendValue(sb, e.getValue());
        }
        sb.append('}');
        return sb.toString();
    }

    @SuppressWarnings("unchecked")
    private static void appendValue(StringBuilder sb, Object v) {
        if (v == null) {
            sb.append("null");
        } else if (v instanceof Number || v instanceof Boolean) {
            sb.append(v.toString());
        } else if (v instanceof Map) {
            sb.append(toJson((Map<String, Object>) v));
        } else {
            sb.append('"').append(escape(v.toString())).append('"');
        }
    }

    private static String escape(String s) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < s.length(); i++) {
            char c = s.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);
                    }
            }
        }
        return sb.toString();
    }

    /**
     * Extract a string value for a key from JSON (a minimal helper, not a full parser).
     * Use for flat fields such as access_token/refresh_token/data. Returns null if not found.
     * In production, use a real JSON library.
     */
    public static String extractString(String json, String key) {
        if (json == null) return null;
        Pattern p = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"");
        Matcher m = p.matcher(json);
        if (m.find()) {
            return unescape(m.group(1));
        }
        return null;
    }

    /** Extract a numeric (long) value for a key from JSON. Returns null if not found. */
    public static Long extractLong(String json, String key) {
        if (json == null) return null;
        Pattern p = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?\\d+)");
        Matcher m = p.matcher(json);
        if (m.find()) {
            try {
                return Long.parseLong(m.group(1));
            } catch (NumberFormatException ignored) {
                return null;
            }
        }
        return null;
    }

    private static String unescape(String s) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '\\' && i + 1 < s.length()) {
                char n = s.charAt(++i);
                switch (n) {
                    case '"':  sb.append('"'); break;
                    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;
                    case 'u':
                        if (i + 4 < s.length()) {
                            String hex = s.substring(i + 1, i + 5);
                            sb.append((char) Integer.parseInt(hex, 16));
                            i += 4;
                        }
                        break;
                    default: sb.append(n);
                }
            } else {
                sb.append(c);
            }
        }
        return sb.toString();
    }

    // ------------------------------------------------------------------
    // Internal utilities
    // ------------------------------------------------------------------
    private static void appendQuery(StringBuilder sb, String key, String value) {
        if (value == null) return;
        if (sb.length() > 0) sb.append('&');
        sb.append(URLEncoder.encode(key, StandardCharsets.UTF_8))
          .append('=')
          .append(URLEncoder.encode(value, StandardCharsets.UTF_8));
    }

    private static String stripTrailingSlash(String url) {
        return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
    }

    // ==================================================================
    // Exception
    // ==================================================================

    /** SDK exception, carrying the HTTP status and body (when available). */
    public static class TConnectException extends RuntimeException {
        private final int status;
        private final String body;

        public TConnectException(int status, String body) {
            super("TConnect API error " + status + ": " + body);
            this.status = status;
            this.body = body;
        }

        public TConnectException(String message) {
            super(message);
            this.status = 0;
            this.body = null;
        }

        public TConnectException(String message, Throwable cause) {
            super(message, cause);
            this.status = 0;
            this.body = null;
        }

        public int getStatus() { return status; }
        public String getBody() { return body; }
    }
}
