// Dashing Crypto Prepaid Transaction Fee API: a complete client in one file.
//
//   org.web3j:crypto:5.0.0                      keys, keccak-256, secp256k1 signatures
//   com.fasterxml.jackson.core:jackson-databind  JSON
//
// Every request is signed by the account's key. A read may carry a read token instead, which a
// signed POST /{address}/tokens returns: PrepaidClient.Reader reads with one and holds no key.
// GET /terms needs neither. The signed message is five lines:
//
//   Dashing Crypto prepaid v1
//   METHOD
//   /nile/prepaid/T…/transfers            the path as sent, network prefix included
//   issued=1790467200&limit=25             the query without signature, encoded and sorted
//   e3b0c442…                              SHA-256 of the body as sent, lower-case hex
//
// signed as a TRON message (TIP-191): keccak-256 over "\x19TRON Signed Message:\n", the message's
// length in bytes and the message; secp256k1; r || s || v with v 27 or 28.

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
import org.web3j.crypto.ECKeyPair;
import org.web3j.crypto.Hash;
import org.web3j.crypto.Keys;
import org.web3j.crypto.Sign;
import org.web3j.utils.Numeric;

public class PrepaidClient {

    public static final String MAINNET_API = "https://api.crypto.dashing.ws/prepaid";
    public static final String NILE_API = "https://api.crypto.dashing.ws/nile/prepaid";
    public static final String MAINNET_NODE = "https://api.trongrid.io";
    public static final String NILE_NODE = "https://nile.trongrid.io";
    public static final String MAINNET_USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
    public static final String NILE_USDT = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf";

    private static final ObjectMapper JSON = new ObjectMapper();
    private static final HttpClient HTTP = HttpClient.newHttpClient();

    private final URI api;
    private final ECKeyPair key;
    public final String address;

    /** @param api MAINNET_API or NILE_API; @param privateKeyHex the account's key, which signs every request */
    public PrepaidClient(String api, String privateKeyHex) {
        this.api = URI.create(api.replaceAll("/$", ""));
        this.key = ECKeyPair.create(new BigInteger(privateKeyHex, 16));
        this.address = addressOf(key);
    }

    // ---- the request signature ------------------------------------------------------------

    /** One signed request. {@code path} is relative to the account: "", "/quotes", "/transactions". */
    public JsonNode request(String method, String path, Map<String, String> query, Object body) throws Exception {
        String fullPath = api.getPath() + "/" + address + path;

        Map<String, String> params = new TreeMap<>(query);
        params.put("issued", Long.toString(System.currentTimeMillis() / 1000));
        String canonical = canonicalQuery(params);

        byte[] bodyBytes = body == null ? new byte[0] : JSON.writeValueAsBytes(body);
        String message = String.join("\n",
                "Dashing Crypto prepaid v1",
                method,
                fullPath,
                canonical,
                Numeric.toHexStringNoPrefix(Hash.sha256(bodyBytes)));
        String signature = signMessage(key, message);

        URI uri = URI.create(api.getScheme() + "://" + api.getAuthority() + fullPath
                + "?" + canonical + "&signature=" + signature);
        HttpRequest.Builder request = HttpRequest.newBuilder(uri);
        if (body == null) {
            request.method(method, HttpRequest.BodyPublishers.noBody());
        } else {
            // The exact bytes that were hashed.
            request.header("Content-Type", "application/json")
                    .method(method, HttpRequest.BodyPublishers.ofByteArray(bodyBytes));
        }
        return answer(HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString()));
    }

    /** The body, or a PrepaidException carrying the status and the body's code; null for 204. */
    static JsonNode answer(HttpResponse<String> response) throws Exception {
        if (response.statusCode() == 204) {
            return null;
        }
        JsonNode json = JSON.readTree(response.body());
        if (response.statusCode() >= 400) {
            throw new PrepaidException(response.statusCode(), json);
        }
        return json;
    }

    /** TIP-191, as TronWeb's signMessageV2 does it: 65 bytes r || s || v as hex, v 27 or 28. */
    static String signMessage(ECKeyPair key, String message) {
        byte[] body = message.getBytes(StandardCharsets.UTF_8);
        byte[] prefix = ("\u0019TRON Signed Message:\n" + body.length).getBytes(StandardCharsets.UTF_8);
        byte[] digest = Hash.sha3(concat(prefix, body)); // keccak-256
        Sign.SignatureData signature = Sign.signMessage(digest, key, false);
        return Numeric.toHexStringNoPrefix(concat(signature.getR(), signature.getS(), signature.getV()));
    }

    /** The query without signature: each name and value encoded, sorted, joined by &. */
    static String canonicalQuery(Map<String, String> params) {
        return params.entrySet().stream()
                .map(e -> new String[] {encode(e.getKey()), encode(e.getValue())})
                .sorted(java.util.Comparator.<String[], String>comparing(p -> p[0]).thenComparing(p -> p[1]))
                .map(p -> p[0] + "=" + p[1])
                .collect(Collectors.joining("&"));
    }

    /** RFC 3986: A–Z a–z 0–9 - . _ ~ stay; every other byte is %XX in upper-case hex. */
    static String encode(String value) {
        StringBuilder out = new StringBuilder();
        for (byte b : value.getBytes(StandardCharsets.UTF_8)) {
            int c = b & 0xFF;
            if (Character.isLetterOrDigit(c) && c < 128 || c == '-' || c == '.' || c == '_' || c == '~') {
                out.append((char) c);
            } else {
                out.append(String.format("%%%02X", c));
            }
        }
        return out.toString();
    }

    // ---- the operations -------------------------------------------------------------------

    public JsonNode account() throws Exception {
        return request("GET", "", Map.of(), null);
    }

    /** A read token for this account, prt_…; shown once. Keep it for {@link Reader}. */
    public String mintToken() throws Exception {
        return request("POST", "/tokens", Map.of(), null).path("token").asText();
    }

    /** Ends every read token this account holds, for a phone that was lost. */
    public void revokeTokens() throws Exception {
        request("DELETE", "/tokens", Map.of(), null);
    }

    /** The deposit terms for an address with none of its own. No key and no token. */
    public static JsonNode terms(String api) throws Exception {
        return answer(HTTP.send(
                HttpRequest.newBuilder(URI.create(api.replaceAll("/$", "") + "/terms")).GET().build(),
                HttpResponse.BodyHandlers.ofString()));
    }

    public JsonNode quote(String from, String to, String amount) throws Exception {
        return request("POST", "/quotes", Map.of(), Map.of("from", from, "to", to, "amount", amount));
    }

    public JsonNode transfer(String from, String to, String amount, String signedTransaction, String maxPrice)
            throws Exception {
        Map<String, String> body = new TreeMap<>(Map.of(
                "from", from, "to", to, "amount", amount, "signedTransaction", signedTransaction));
        if (maxPrice != null) body.put("maxPrice", maxPrice);
        return request("POST", "/transfers", Map.of(), body);
    }

    public JsonNode depositByTxId(String txId) throws Exception {
        return request("POST", "/deposits", Map.of(), Map.of("txId", txId));
    }

    public JsonNode depositSponsored(String signedTransaction, String maxSponsoredFee) throws Exception {
        Map<String, String> body = new TreeMap<>(Map.of("signedTransaction", signedTransaction));
        if (maxSponsoredFee != null) body.put("maxSponsoredFee", maxSponsoredFee);
        return request("POST", "/deposits", Map.of(), body);
    }

    public JsonNode transactions(int limit, String cursor) throws Exception {
        Map<String, String> query = new TreeMap<>(Map.of("limit", Integer.toString(limit)));
        if (cursor != null) query.put("cursor", cursor);
        return request("GET", "/transactions", query, null);
    }

    public JsonNode transaction(String id) throws Exception {
        return request("GET", "/transactions/" + id, Map.of(), null);
    }

    /**
     * Reads one account with a read token, and holds no key: for a wallet that keeps the key behind
     * a fingerprint. A token unused for 180 days, or revoked, fails with TOKEN_EXPIRED; sign
     * {@link #mintToken()} for a new one.
     */
    public static class Reader {
        private final String base;
        private final String token;

        /** @param api MAINNET_API or NILE_API; @param address the account; @param token prt_… */
        public Reader(String api, String address, String token) {
            this.base = api.replaceAll("/$", "") + "/" + address;
            this.token = token;
        }

        public JsonNode request(String method, String path, Map<String, String> query) throws Exception {
            String search = query.entrySet().stream()
                    .map(e -> encode(e.getKey()) + "=" + encode(e.getValue()))
                    .collect(Collectors.joining("&"));
            HttpRequest request = HttpRequest.newBuilder(URI.create(base + path + (search.isEmpty() ? "" : "?" + search)))
                    .header("Authorization", "Bearer " + token)
                    .method(method, HttpRequest.BodyPublishers.noBody())
                    .build();
            return answer(HTTP.send(request, HttpResponse.BodyHandlers.ofString()));
        }

        public JsonNode account() throws Exception {
            return request("GET", "", Map.of());
        }

        public JsonNode transactions(int limit, String cursor) throws Exception {
            Map<String, String> query = new TreeMap<>(Map.of("limit", Integer.toString(limit)));
            if (cursor != null) query.put("cursor", cursor);
            return request("GET", "/transactions", query);
        }

        public JsonNode transaction(String id) throws Exception {
            return request("GET", "/transactions/" + id, Map.of());
        }

        /** Ends this token, and only this one. */
        public void revoke() throws Exception {
            request("DELETE", "/tokens", Map.of());
        }
    }

    public static class PrepaidException extends Exception {
        public final int status;
        public final JsonNode body;

        PrepaidException(int status, JsonNode body) {
            super(status + " " + body.path("code").asText() + ": " + body.path("message").asText());
            this.status = status;
            this.body = body;
        }
    }

    // ---- the USDT transfer a sender signs --------------------------------------------------

    /**
     * A USDT transfer built by the node, given a five-minute expiry, and signed by the sender.
     *
     * @return {txID, hex}: hex is the whole signed Transaction, as the API takes it
     */
    public static String[] signedUsdtTransfer(String node, String usdt, String senderKeyHex, String to, String amount)
            throws Exception {
        ECKeyPair sender = ECKeyPair.create(new BigInteger(senderKeyHex, 16));
        long micros = new java.math.BigDecimal(amount).movePointRight(6).longValueExact();
        byte[] recipient = Arrays.copyOfRange(base58CheckDecode(to), 1, 21);
        String parameter = Numeric.toHexStringNoPrefixZeroPadded(new BigInteger(1, recipient), 64)
                + Numeric.toHexStringNoPrefixZeroPadded(BigInteger.valueOf(micros), 64);

        String build = JSON.writeValueAsString(Map.of(
                "owner_address", addressOf(sender),
                "contract_address", usdt,
                "function_selector", "transfer(address,uint256)",
                "parameter", parameter,
                "fee_limit", 20_000_000,
                "call_value", 0,
                "visible", true));
        JsonNode built = JSON.readTree(HTTP.send(
                HttpRequest.newBuilder(URI.create(node + "/wallet/triggersmartcontract"))
                        .header("Content-Type", "application/json")
                        .POST(HttpRequest.BodyPublishers.ofString(build)).build(),
                HttpResponse.BodyHandlers.ofString()).body()).path("transaction");

        // A node stamps a one-minute expiry; the API wants two to ten minutes left. Field 8 of
        // raw_data is the expiry in milliseconds, and the transaction id is SHA-256 of raw_data.
        byte[] raw = withExpiration(
                Numeric.hexStringToByteArray(built.path("raw_data_hex").asText()),
                System.currentTimeMillis() + 5 * 60 * 1000);
        byte[] txId = Hash.sha256(raw);
        Sign.SignatureData sig = Sign.signMessage(txId, sender, false);
        byte[] signature = concat(sig.getR(), sig.getS(), sig.getV());

        // Transaction { raw_data = 1; signature = 2 }
        byte[] signed = concat(new byte[] {0x0a}, varint(raw.length), raw, new byte[] {0x12}, varint(65), signature);
        return new String[] {Numeric.toHexStringNoPrefix(txId), Numeric.toHexStringNoPrefix(signed)};
    }

    /** raw_data with field 8 (expiration) replaced and every other field copied as it is. */
    static byte[] withExpiration(byte[] raw, long expirationMillis) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int[] at = {0};
        while (at[0] < raw.length) {
            int start = at[0];
            long tag = readVarint(raw, at);
            int wire = (int) (tag & 7);
            switch (wire) {
                case 0 -> readVarint(raw, at);
                case 1 -> at[0] += 8;
                case 2 -> { int length = (int) readVarint(raw, at); at[0] += length; }
                case 5 -> at[0] += 4;
                default -> throw new IllegalArgumentException("unknown wire type " + wire);
            }
            if (tag >>> 3 == 8 && wire == 0) {
                out.writeBytes(varint(tag));
                out.writeBytes(varint(expirationMillis));
            } else {
                out.write(raw, start, at[0] - start);
            }
        }
        return out.toByteArray();
    }

    /** Broadcasts a signed transaction through a node, for a deposit you send yourself. */
    public static JsonNode broadcast(String node, String signedHex) throws Exception {
        return JSON.readTree(HTTP.send(
                HttpRequest.newBuilder(URI.create(node + "/wallet/broadcasthex"))
                        .header("Content-Type", "application/json")
                        .POST(HttpRequest.BodyPublishers.ofString("{\"transaction\":\"" + signedHex + "\"}")).build(),
                HttpResponse.BodyHandlers.ofString()).body());
    }

    // ---- addresses --------------------------------------------------------------------------

    /** The base58 TRON address of a key: 0x41 + the last 20 bytes of keccak(public key). */
    public static String addressOf(ECKeyPair key) {
        byte[] body = concat(new byte[] {0x41}, Numeric.hexStringToByteArray(Keys.getAddress(key.getPublicKey())));
        return base58(concat(body, Arrays.copyOf(Hash.sha256(Hash.sha256(body)), 4)));
    }

    private static final String ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";

    static String base58(byte[] bytes) {
        StringBuilder out = new StringBuilder();
        BigInteger value = new BigInteger(1, bytes);
        while (value.signum() > 0) {
            BigInteger[] qr = value.divideAndRemainder(BigInteger.valueOf(58));
            out.append(ALPHABET.charAt(qr[1].intValue()));
            value = qr[0];
        }
        for (int i = 0; i < bytes.length && bytes[i] == 0; i++) out.append('1');
        return out.reverse().toString();
    }

    static byte[] base58CheckDecode(String text) {
        BigInteger value = BigInteger.ZERO;
        for (char c : text.toCharArray()) value = value.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(ALPHABET.indexOf(c)));
        byte[] full = Numeric.toBytesPadded(value, 25);
        return Arrays.copyOf(full, 21);
    }

    // ---- bytes --------------------------------------------------------------------------------

    static byte[] varint(long value) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        while ((value & ~0x7FL) != 0) {
            out.write((int) ((value & 0x7F) | 0x80));
            value >>>= 7;
        }
        out.write((int) value);
        return out.toByteArray();
    }

    static long readVarint(byte[] bytes, int[] at) {
        long value = 0;
        for (int shift = 0; ; shift += 7) {
            byte b = bytes[at[0]++];
            value |= (long) (b & 0x7F) << shift;
            if ((b & 0x80) == 0) return value;
        }
    }

    static byte[] concat(byte[]... parts) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        for (byte[] part : parts) out.writeBytes(part);
        return out.toByteArray();
    }
}
