// sellkeys Auth – Java SDK (Java 17+, no dependencies: java.net.http + built-in Ed25519)
//
//   SellkeysAuth auth = new SellkeysAuth("APP_ID", "PUBLIC_KEY", "1.0");
//   try {
//       auth.init();
//       Map<String, Object> lic = auth.license("XXXXX-XXXXX-XXXXX-XXXXX");
//       System.out.println("Welcome! Level " + lic.get("level") + ", expires " + lic.get("expires_at"));
//   } catch (SellkeysAuth.SellkeysAuthException e) {
//       System.out.println("Login failed: " + e.code + " " + e.getMessage());
//   }
//
// Answers are returned as Map<String, Object> (numbers are Long or Double, null = JSON null).
// Every answer is signed with your app's Ed25519 key; the SDK rejects wrong signatures and replayed nonces, so a fake
// server cannot unlock your program. (It does not protect against patching your program.)
import java.io.IOException;
import java.net.InetAddress;
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.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class SellkeysAuth {
    public static final String API_URL = "https://auth.sellkeys.cc/v1";

    public static class SellkeysAuthException extends Exception {
        private static final long serialVersionUID = 1L;
        public final String code;
        /** Full server answer, e.g. data.get("update_url") for code "update_required". */
        public final Map<String, Object> data;

        public SellkeysAuthException(String code, String message, Map<String, Object> data) {
            super(message);
            this.code = code;
            this.data = data == null ? Map.of() : data;
        }
    }

    public final String appId, version, apiUrl, hwid;
    public String session;
    public Map<String, Object> app, licenseInfo, user;
    private final PublicKey pub;
    private final HttpClient http;
    private final Duration timeout;
    private static final SecureRandom RNG = new SecureRandom();

    public SellkeysAuth(String appId, String publicKey, String version) throws SellkeysAuthException {
        this(appId, publicKey, version, API_URL, null, Duration.ofSeconds(15));
    }

    public SellkeysAuth(String appId, String publicKey, String version, String apiUrl, String hwid, Duration timeout) throws SellkeysAuthException {
        this.appId = appId;
        this.version = version;
        this.apiUrl = apiUrl;
        this.hwid = hwid != null ? hwid : defaultHwid();
        this.timeout = timeout;
        this.http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).connectTimeout(timeout).followRedirects(HttpClient.Redirect.NORMAL).build();
        try {
            byte[] raw = Base64.getDecoder().decode(publicKey);
            if (raw.length != 32) throw new IllegalArgumentException();
            // raw 32-byte Ed25519 key wrapped in its fixed X.509 SubjectPublicKeyInfo header
            byte[] spki = new byte[44];
            System.arraycopy(HexFormat.of().parseHex("302a300506032b6570032100"), 0, spki, 0, 12);
            System.arraycopy(raw, 0, spki, 12, 32);
            this.pub = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(spki));
        } catch (Exception e) {
            throw new SellkeysAuthException("bad_public_key", "The public key must be 32 bytes, base64.", null);
        }
    }

    static String sha256hex(byte[] b) {
        try {
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b));
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    private static String run(String... cmd) {
        try {
            Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
            String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
            p.waitFor();
            return out;
        } catch (Exception e) {
            return "";
        }
    }

    /** Stable per-machine id (hashed, the raw value never leaves the machine). */
    public static String defaultHwid() {
        String os = System.getProperty("os.name", "").toLowerCase(), raw = "";
        Matcher m;
        if (os.contains("win")) {
            m = Pattern.compile("MachineGuid\\s+REG_SZ\\s+(\\S+)").matcher(run("reg", "query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid", "/reg:64"));
            if (m.find()) raw = m.group(1);
        } else if (os.contains("mac")) {
            m = Pattern.compile("\"IOPlatformUUID\" = \"([^\"]+)\"").matcher(run("ioreg", "-rd1", "-c", "IOPlatformExpertDevice"));
            if (m.find()) raw = m.group(1);
        } else {
            for (String p : new String[]{"/etc/machine-id", "/var/lib/dbus/machine-id"}) {
                try {
                    raw = Files.readString(Path.of(p)).trim();
                    if (!raw.isEmpty()) break;
                } catch (IOException ignored) { }
            }
        }
        if (raw.isEmpty()) {
            try { raw = InetAddress.getLocalHost().getHostName(); } catch (IOException ignored) { }
        }
        return sha256hex(raw.getBytes(StandardCharsets.UTF_8));
    }

    /** SHA-256 of your program file (for the app's hash check). */
    public static String fileHash(Path path) throws IOException {
        return sha256hex(Files.readAllBytes(path));
    }

    private static String enc(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8); }

    private Map<String, Object> call(String type, String... kv) throws SellkeysAuthException {
        byte[] nb = new byte[16];
        RNG.nextBytes(nb);
        String nonce = HexFormat.of().formatHex(nb);
        StringBuilder form = new StringBuilder("type=" + enc(type) + "&app=" + enc(appId) + "&nonce=" + nonce);
        for (int i = 0; i + 1 < kv.length; i += 2) if (kv[i + 1] != null) form.append('&').append(enc(kv[i])).append('=').append(enc(kv[i + 1]));
        HttpResponse<byte[]> r;
        try {
            r = http.send(HttpRequest.newBuilder(URI.create(apiUrl)).timeout(timeout)
                    .header("Content-Type", "application/x-www-form-urlencoded")
                    .POST(HttpRequest.BodyPublishers.ofString(form.toString())).build(), HttpResponse.BodyHandlers.ofByteArray());
        } catch (IOException | InterruptedException e) {
            throw new SellkeysAuthException("network_error", "Could not reach the license server: " + e.getMessage(), null);
        }
        byte[] raw = r.body();
        String text = new String(raw, StandardCharsets.UTF_8);
        String sig = r.headers().firstValue("X-Sellkeys-Signature").orElse(null);
        if (sig == null) { // only errors before the app is known (rate limit, unknown app) are unsigned
            Map<String, Object> b = Map.of();
            try { b = Json.object(text); } catch (RuntimeException ignored) { }
            throw new SellkeysAuthException(b.get("code") instanceof String c ? c : "http_" + r.statusCode(),
                    b.get("message") instanceof String msg ? msg : "Unexpected server response.", null);
        }
        boolean ok;
        try {
            Signature v = Signature.getInstance("Ed25519");
            v.initVerify(pub);
            v.update(raw);
            ok = v.verify(Base64.getDecoder().decode(sig));
        } catch (Exception e) {
            ok = false;
        }
        if (!ok) throw new SellkeysAuthException("bad_signature", "The server response is not signed by sellkeys.", null);
        Map<String, Object> body = Json.object(text);
        if (!nonce.equals(body.get("nonce"))) throw new SellkeysAuthException("bad_nonce", "The server response does not belong to this request.", null);
        if (!Boolean.TRUE.equals(body.get("success"))) {
            throw new SellkeysAuthException(body.get("code") instanceof String c ? c : "error",
                    body.get("message") instanceof String msg ? msg : "Request failed.", body);
        }
        return body;
    }

    @SuppressWarnings("unchecked")
    private static Map<String, Object> obj(Object o) { return (Map<String, Object>) o; }

    private void ensureSession() throws SellkeysAuthException { if (session == null) init(null); }

    /** Starts a session. hash may be null. Throws code "update_required" (see e.data.get("update_url")) if your version is too old. */
    public Map<String, Object> init(String hash) throws SellkeysAuthException {
        Map<String, Object> b = call("init", "version", version, "hash", hash);
        session = (String) b.get("session");
        return app = obj(b.get("app"));
    }

    public Map<String, Object> init() throws SellkeysAuthException { return init(null); }

    public Map<String, Object> license(String key) throws SellkeysAuthException {
        ensureSession();
        return licenseInfo = obj(call("license", "session", session, "key", key.trim(), "hwid", hwid).get("license"));
    }

    /** Call periodically: throws if the license/account was banned, expired or the session ended. */
    public Map<String, Object> check() throws SellkeysAuthException {
        Map<String, Object> b = call("check", "session", session);
        if (b.containsKey("user")) return user = obj(b.get("user"));
        return licenseInfo = obj(b.get("license"));
    }

    // ---- accounts (username + password; the key is redeemed to the account) ----
    public Map<String, Object> register(String username, String password, String key) throws SellkeysAuthException {
        ensureSession();
        return user = obj(call("register", "session", session, "username", username, "password", password, "key", key.trim(), "hwid", hwid).get("user"));
    }

    public Map<String, Object> login(String username, String password) throws SellkeysAuthException {
        ensureSession();
        return user = obj(call("login", "session", session, "username", username, "password", password, "hwid", hwid).get("user"));
    }

    /** Adds another license (renewal, higher level) to an existing account. */
    public Map<String, Object> upgrade(String username, String key) throws SellkeysAuthException {
        ensureSession();
        return obj(call("upgrade", "session", session, "username", username, "key", key.trim()).get("user"));
    }

    // ---- content ----
    /** Global app variable (set in the dashboard). */
    public String var(String name) throws SellkeysAuthException { return (String) call("var", "session", session, "name", name).get("value"); }

    public String getUserVar(String name) throws SellkeysAuthException { return (String) call("getvar", "session", session, "name", name).get("value"); }

    public void setUserVar(String name, String value) throws SellkeysAuthException { call("setvar", "session", session, "name", name, "value", value); }

    /** Downloads an app file and checks it against the signed SHA-256. */
    public byte[] download(String fileId) throws SellkeysAuthException {
        Map<String, Object> info = call("file", "session", session, "file", fileId);
        HttpResponse<byte[]> r;
        try {
            r = http.send(HttpRequest.newBuilder(URI.create((String) info.get("url"))).timeout(timeout.compareTo(Duration.ofSeconds(120)) > 0 ? timeout : Duration.ofSeconds(120)).build(),
                    HttpResponse.BodyHandlers.ofByteArray());
        } catch (IOException | InterruptedException e) {
            throw new SellkeysAuthException("network_error", "Download failed: " + e.getMessage(), null);
        }
        if (r.statusCode() != 200) throw new SellkeysAuthException("network_error", "Download failed: HTTP " + r.statusCode(), null);
        if (!MessageDigest.isEqual(sha256hex(r.body()).getBytes(StandardCharsets.UTF_8), String.valueOf(info.get("sha256")).getBytes(StandardCharsets.UTF_8))) {
            throw new SellkeysAuthException("bad_file_hash", "The downloaded file does not match its signed checksum.", null);
        }
        return r.body();
    }

    /** Calls a webhook set in the dashboard through sellkeys (its URL and keys stay on the server). params, body, contentType may be null. Returns {status, response}. */
    public Map<String, Object> webhook(String name, String params, String body, String contentType) throws SellkeysAuthException {
        Map<String, Object> b = call("webhook", "session", session, "webid", name, "params", params == null || params.isEmpty() ? null : params, "body", body, "content_type", contentType);
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("status", b.get("status"));
        out.put("response", b.get("response"));
        return out;
    }

    public Map<String, Object> webhook(String name) throws SellkeysAuthException { return webhook(name, null, null, null); }

    /** Writes a line to the app's logs in the sellkeys dashboard. */
    public void log(String message) throws SellkeysAuthException {
        call("log", "session", session, "message", message.length() > 500 ? message.substring(0, 500) : message);
    }

    public void logout() throws SellkeysAuthException {
        if (session == null) return;
        call("logout", "session", session);
        session = null;
    }

    /** Minimal JSON reader for the server answers (objects, arrays, strings, numbers, booleans, null). */
    static final class Json {
        private final String s;
        private int i;

        private Json(String s) { this.s = s; }

        @SuppressWarnings("unchecked")
        static Map<String, Object> object(String text) {
            Json j = new Json(text);
            Object v = j.value();
            j.ws();
            if (j.i != j.s.length() || !(v instanceof Map)) throw new IllegalArgumentException("bad json");
            return (Map<String, Object>) v;
        }

        private void ws() { while (i < s.length() && " \t\r\n".indexOf(s.charAt(i)) >= 0) i++; }

        private void expect(char c) {
            ws();
            if (i >= s.length() || s.charAt(i) != c) throw new IllegalArgumentException("bad json at " + i);
            i++;
        }

        private Object value() {
            ws();
            if (i >= s.length()) throw new IllegalArgumentException("bad json");
            char c = s.charAt(i);
            if (c == '{') {
                Map<String, Object> m = new LinkedHashMap<>();
                i++;
                ws();
                if (s.charAt(i) == '}') { i++; return m; }
                do {
                    ws();
                    String k = string();
                    expect(':');
                    m.put(k, value());
                    ws();
                } while (s.charAt(i++) == ',');
                if (s.charAt(i - 1) != '}') throw new IllegalArgumentException("bad json at " + i);
                return m;
            }
            if (c == '[') {
                List<Object> l = new ArrayList<>();
                i++;
                ws();
                if (s.charAt(i) == ']') { i++; return l; }
                do { l.add(value()); ws(); } while (s.charAt(i++) == ',');
                if (s.charAt(i - 1) != ']') throw new IllegalArgumentException("bad json at " + i);
                return l;
            }
            if (c == '"') return string();
            for (String lit : new String[]{"true", "false", "null"}) {
                if (s.startsWith(lit, i)) { i += lit.length(); return lit.equals("null") ? null : lit.equals("true"); }
            }
            int st = i;
            while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) i++;
            String n = s.substring(st, i);
            if (n.isEmpty()) throw new IllegalArgumentException("bad json at " + i);
            return n.matches("-?\\d+") ? (Object) Long.parseLong(n) : (Object) Double.parseDouble(n);
        }

        private String string() {
            if (s.charAt(i) != '"') throw new IllegalArgumentException("bad json at " + i);
            StringBuilder b = new StringBuilder();
            for (i++; ; i++) {
                char c = s.charAt(i);
                if (c == '"') { i++; return b.toString(); }
                if (c != '\\') { b.append(c); continue; }
                char e = s.charAt(++i);
                switch (e) {
                    case 'n' -> b.append('\n');
                    case 't' -> b.append('\t');
                    case 'r' -> b.append('\r');
                    case 'b' -> b.append('\b');
                    case 'f' -> b.append('\f');
                    case 'u' -> { b.append((char) Integer.parseInt(s.substring(i + 1, i + 5), 16)); i += 4; }
                    default -> b.append(e); // " \ /
                }
            }
        }
    }
}
