import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.Set;
import java.util.UUID;

/**
 * MesharePoints - console tool (Java 8) that layers a simple, signed points
 * system on top of a Meshare-style mesh of servers (the servers listed in
 * "sync_servers.txt", e.g. the winners produced by MeshareSync).
 *
 * Rules:
 *   - Every server listed in "sync_servers.txt" earns 1 point per day it is
 *     a member (catch-up safe: if this tool is not run for a few days, the
 *     missed days are credited the next time it runs).
 *   - Every server has an RSA key pair. The public half lives in
 *     "public_key.txt" (served the same way rank.txt already is: a plain
 *     file at baseUrl + "public_key.txt"). The private half lives in
 *     "private_key.txt" and MUST stay local - never publish or broadcast it.
 *   - A server can send points to another server. Sending signs a message
 *     with the sender's private key and BROADCASTS it via HTTP GET to
 *     EVERY server in sync_servers.txt (not just the recipient), carrying
 *     from, to, amount, date and the resulting balances. Every server that
 *     receives the broadcast verifies the signature against the sender's
 *     public key before recording it.
 *
 * Trust model (please read):
 *   This is a lightweight, COOPERATIVE-TRUST ledger - not a blockchain and
 *   not Byzantine-fault-tolerant. The signature guarantees that nobody
 *   without a server's private key can forge a transfer "from" that
 *   server. It does NOT stop a dishonest member of the mesh from reporting
 *   a false balance for itself. Use this only among servers you already
 *   trust, exactly as you already trust them to publish an honest
 *   rank.txt. If you need real tamper-resistance you need a consensus
 *   protocol, which is out of scope here.
 *
 * Files (all in the working directory, run this from the same folder you
 * run MeshareSync in):
 *   sync_servers.txt    - list of member servers (one URL per line, same
 *                          format MeshareSync writes)
 *   public_key.txt       - this server's RSA public key (base64), auto
 *                          generated on first run
 *   private_key.txt      - this server's RSA private key (base64), auto
 *                          generated on first run - KEEP SECRET
 *   points_ledger.txt    - this server's local view of every member's
 *                          balance
 *   points_state.txt     - this server's own identity + last accrual date
 *   known_keys.txt        - cache of other servers' public keys
 *   transactions.txt      - append-only log of sent/received/rejected
 *                          transfers
 *
 * HTTP endpoints exposed when the built-in server (menu option 4) is
 * running:
 *   GET /public_key.txt                 -> this server's public key
 *   GET /meshare-points/ledger          -> text dump of this server's ledger
 *   GET /meshare-points/transfer?...    -> receives a broadcast transfer
 *
 * Usage:
 *   javac MesharePoints.java
 *   java MesharePoints                  interactive menu
 *   java MesharePoints --accrual-only   just run the daily accrual and
 *                                        exit (handy from cron), no menu
 */
public class MesharePoints {

    // ---- file names ----------------------------------------------------
    private static final String SERVERS_FILE     = "sync_servers.txt";
    private static final String PUBLIC_KEY_FILE  = "public_key.txt";
    private static final String PRIVATE_KEY_FILE = "private_key.txt";
    private static final String LEDGER_FILE      = "points_ledger.txt";
    private static final String STATE_FILE       = "points_state.txt";
    private static final String KNOWN_KEYS_FILE  = "known_keys.txt";
    private static final String TX_LOG_FILE      = "transactions.txt";

    // ---- HTTP paths (relative, no leading slash for URL building) ------
    private static final String TRANSFER_REL = "meshare-points/transfer";
    private static final String LEDGER_REL   = "meshare-points/ledger";
    private static final String PUBKEY_REL   = "public_key.txt";

    private static final int CONNECT_TIMEOUT_MS = 8000;
    private static final int READ_TIMEOUT_MS    = 15000;
    private static final int DEFAULT_PORT       = 8181;

    // ---- runtime state ---------------------------------------------------
    private static KeyPair myKeys;
    private static String  myServerUrl;
    private static LocalDate lastAccrualDate;
    private static final Map<String, Entry> ledger = new LinkedHashMap<String, Entry>();
    private static final Map<String, String> knownKeys = new LinkedHashMap<String, String>();
    private static final Set<String> seenNonces = new LinkedHashSet<String>();
    private static HttpServer httpServer;

    /** One member's local balance + the date it was first seen as a member. */
    private static final class Entry {
        long points;
        LocalDate firstSeen;
        Entry(long points, LocalDate firstSeen) { this.points = points; this.firstSeen = firstSeen; }
    }

    // ===================================================================
    //                          main
    // ===================================================================
    public static void main(String[] args) throws Exception {
        System.out.println("Meshare Points");
        System.out.println("==============");

        ensureKeys();
        loadKnownKeys();
        loadState();
        loadLedger();
        loadSeenNoncesFromLog();

        boolean accrualOnly = args.length > 0 && "--accrual-only".equals(args[0]);

        if (accrualOnly) {
            pickOwnIdentityNonInteractive();
            runDailyAccrual();
            return;
        }

        // A single Scanner for the whole session: creating more than one Scanner over
        // System.in leads to each buffering ahead independently and corrupting input order.
        Scanner in = new Scanner(System.in);
        pickOwnIdentity(in);
        boolean running = true;
        while (running) {
            printMenu();
            if (!in.hasNextLine()) {
                System.out.println();
                System.out.println("(input ended)");
                break;
            }
            String choice = in.nextLine().trim();
            try {
                if ("1".equals(choice)) {
                    showStatus();
                } else if ("2".equals(choice)) {
                    runDailyAccrual();
                } else if ("3".equals(choice)) {
                    sendPointsPrompt(in);
                } else if ("4".equals(choice)) {
                    startServerPrompt(in);
                } else if ("5".equals(choice)) {
                    stopServer();
                } else if ("6".equals(choice)) {
                    pullPeerLedgerPrompt(in);
                } else if ("0".equals(choice) || "7".equals(choice)) {
                    running = false;
                } else {
                    System.out.println("Unknown option.");
                }
            } catch (Exception e) {
                System.out.println("ERROR: " + e.getMessage());
            }
        }

        stopServer();
        saveLedger();
        saveState();
        saveKnownKeys();
        System.out.println("Bye.");
    }

    private static void printMenu() {
        System.out.println();
        System.out.println("----------------------------------------------");
        System.out.println("Identity : " + (myServerUrl == null ? "(not set)" : myServerUrl));
        System.out.println("Server   : " + (httpServer == null ? "stopped" : "running on port " + httpServer.getAddress().getPort()));
        System.out.println("1) Show status / balances");
        System.out.println("2) Run daily accrual now");
        System.out.println("3) Send points to another server");
        System.out.println("4) Start local server (receive broadcasts)");
        System.out.println("5) Stop local server");
        System.out.println("6) Pull & show a peer's ledger (diagnostic)");
        System.out.println("0) Exit");
        System.out.print("> ");
    }

    // ===================================================================
    //                          identity
    // ===================================================================
    private static void pickOwnIdentity(Scanner in) throws IOException {
        if (myServerUrl != null && !myServerUrl.isEmpty()) return;

        List<String> servers = readServers();
        if (!servers.isEmpty()) {
            System.out.println();
            System.out.println("Which of these servers is this one?");
            for (int i = 0; i < servers.size(); i++) {
                System.out.println("  " + (i + 1) + ") " + servers.get(i));
            }
            System.out.print("Enter a number, or type this server's URL: ");
            String choice = readLine(in).trim();
            int idx = parseIntOrMinus1(choice);
            if (idx >= 1 && idx <= servers.size()) {
                myServerUrl = normalizeServer(servers.get(idx - 1));
            } else if (!choice.isEmpty()) {
                myServerUrl = normalizeServer(choice);
            }
        }
        if (myServerUrl == null || myServerUrl.isEmpty()) {
            System.out.print("Enter this server's own URL (as it appears in " + SERVERS_FILE + "): ");
            myServerUrl = normalizeServer(readLine(in).trim());
        }
        saveState();
        System.out.println("Identity set to: " + myServerUrl);
    }

    /** Used by --accrual-only: never blocks on stdin. */
    private static void pickOwnIdentityNonInteractive() throws IOException {
        if (myServerUrl != null && !myServerUrl.isEmpty()) return;
        List<String> servers = readServers();
        if (!servers.isEmpty()) {
            myServerUrl = normalizeServer(servers.get(0));
            System.out.println("No identity saved yet; defaulting to first server in "
                    + SERVERS_FILE + ": " + myServerUrl
                    + " (run interactively once to set this properly).");
            saveState();
        } else {
            System.out.println("No identity saved and " + SERVERS_FILE + " is empty/missing; accrual will "
                    + "still run for whatever is in the ledger already.");
        }
    }

    // ===================================================================
    //                          status
    // ===================================================================
    private static void showStatus() {
        List<String> servers = readServers();
        System.out.println();
        System.out.println("Members of " + SERVERS_FILE + ": " + servers.size());
        System.out.println("Last accrual date: " + (lastAccrualDate == null ? "(never)" : lastAccrualDate));
        System.out.println();
        System.out.println("Balances:");
        List<String> keys = new ArrayList<String>(ledger.keySet());
        for (String server : keys) {
            Entry e = ledger.get(server);
            String marker = server.equals(myServerUrl) ? "  <- this server" : "";
            System.out.println("  " + pad(server, 46) + e.points + " pt(s)  (since " + e.firstSeen + ")" + marker);
        }
        for (String server : servers) {
            if (!ledger.containsKey(normalizeServer(server))) {
                System.out.println("  " + pad(server, 46) + "0 pt(s)  (not yet accrued - run option 2)");
            }
        }
    }

    private static String pad(String s, int width) {
        StringBuilder sb = new StringBuilder(s);
        while (sb.length() < width) sb.append(' ');
        return sb.toString();
    }

    // ===================================================================
    //                          daily accrual
    // ===================================================================
    private static void runDailyAccrual() throws IOException {
        List<String> servers = readServers();
        LocalDate today = LocalDate.now();

        // make sure every current member has a ledger entry
        for (String s : servers) {
            String key = normalizeServer(s);
            if (!ledger.containsKey(key)) {
                ledger.put(key, new Entry(0, today));
            }
        }

        if (lastAccrualDate == null) {
            // first ever run: don't retroactively credit days before we started tracking
            lastAccrualDate = today.minusDays(1);
        }

        int daysCredited = 0;
        for (LocalDate d = lastAccrualDate.plusDays(1); !d.isAfter(today); d = d.plusDays(1)) {
            for (String s : servers) {
                String key = normalizeServer(s);
                Entry e = ledger.get(key);
                if (e != null && !e.firstSeen.isAfter(d)) {
                    e.points += 1;
                }
            }
            daysCredited++;
        }
        lastAccrualDate = today;
        saveLedger();
        saveState();

        if (daysCredited == 0) {
            System.out.println("Already up to date for " + today + ". Nothing to credit.");
        } else {
            System.out.println("Credited " + daysCredited + " day(s) x 1 point to each of " + servers.size()
                    + " member(s), up to " + today + ".");
        }
    }

    // ===================================================================
    //                          sending points
    // ===================================================================
    private static void sendPointsPrompt(Scanner in) throws Exception {
        if (myServerUrl == null) {
            System.out.println("Set this server's identity first (restart the tool).");
            return;
        }
        Entry mine = ledger.get(myServerUrl);
        long balance = mine == null ? 0 : mine.points;
        System.out.println("Your current balance: " + balance + " pt(s)");

        System.out.print("Send to (server URL): ");
        String to = normalizeServer(readLine(in).trim());
        if (to.isEmpty()) { System.out.println("Cancelled."); return; }

        System.out.print("Amount: ");
        long amount;
        try {
            amount = Long.parseLong(readLine(in).trim());
        } catch (NumberFormatException e) {
            System.out.println("Not a valid whole number. Cancelled.");
            return;
        }
        if (amount <= 0) { System.out.println("Amount must be positive. Cancelled."); return; }
        if (mine == null || mine.points < amount) {
            System.out.println("Insufficient balance. Cancelled.");
            return;
        }

        System.out.print("Send " + amount + " pt(s) from " + myServerUrl + " to " + to + "? (y/n) ");
        if (!readLine(in).trim().toLowerCase().startsWith("y")) {
            System.out.println("Cancelled.");
            return;
        }

        sendPoints(myServerUrl, to, amount);
    }

    private static void sendPoints(String from, String to, long amount) throws Exception {
        Entry fromE = ledger.get(from);
        if (fromE == null) fromE = new Entry(0, LocalDate.now());
        Entry toE = ledger.get(to);
        if (toE == null) toE = new Entry(0, LocalDate.now());
        if (fromE.points < amount) throw new IllegalStateException("insufficient balance");

        fromE.points -= amount;
        toE.points += amount;
        ledger.put(from, fromE);
        ledger.put(to, toE);

        String date = LocalDate.now().toString();
        String nonce = UUID.randomUUID().toString();
        String payload = joinPayload(from, to, amount, date, nonce, fromE.points, toE.points);
        String sig = sign(payload, myKeys.getPrivate());

        Map<String, String> params = new LinkedHashMap<String, String>();
        params.put("from", from);
        params.put("to", to);
        params.put("amount", String.valueOf(amount));
        params.put("date", date);
        params.put("nonce", nonce);
        params.put("fromBalance", String.valueOf(fromE.points));
        params.put("toBalance", String.valueOf(toE.points));
        params.put("sig", sig);

        seenNonces.add(nonce);
        logTx("SENT", from, to, amount, nonce, "ok");
        saveLedger();

        System.out.println("Broadcasting transfer to every server in " + SERVERS_FILE + " ...");
        broadcast(TRANSFER_REL, params);
        System.out.println("Done. New balances -> " + from + ": " + fromE.points + ", " + to + ": " + toE.points);
    }

    private static String joinPayload(String from, String to, long amount, String date, String nonce,
                                       long fromBalance, long toBalance) {
        return from + "|" + to + "|" + amount + "|" + date + "|" + nonce + "|" + fromBalance + "|" + toBalance;
    }

    private static void broadcast(String relPath, Map<String, String> params) {
        List<String> servers = readServers();
        String query = buildQuery(params);
        for (String server : servers) {
            String url = normalizeServer(server) + relPath + "?" + query;
            try {
                String resp = httpGetText(url);
                System.out.println("  -> " + server + " : " + firstLine(resp));
            } catch (IOException e) {
                System.out.println("  -> " + server + " : FAILED (" + e.getMessage() + ")");
            }
        }
    }

    private static String firstLine(String s) {
        int nl = s.indexOf('\n');
        return (nl >= 0 ? s.substring(0, nl) : s).trim();
    }

    // ===================================================================
    //                          receiving (HTTP server)
    // ===================================================================
    private static void startServerPrompt(Scanner in) throws IOException {
        if (httpServer != null) {
            System.out.println("Already running on port " + httpServer.getAddress().getPort());
            return;
        }
        System.out.print("Port [" + DEFAULT_PORT + "]: ");
        String portStr = readLine(in).trim();
        int port = portStr.isEmpty() ? DEFAULT_PORT : Integer.parseInt(portStr);

        httpServer = HttpServer.create(new InetSocketAddress(port), 0);
        httpServer.createContext("/" + TRANSFER_REL, new TransferHandler());
        httpServer.createContext("/" + LEDGER_REL, new LedgerHandler());
        httpServer.createContext("/" + PUBKEY_REL, new PublicKeyHandler());
        httpServer.setExecutor(null);
        httpServer.start();
        System.out.println("Listening on port " + port + ":");
        System.out.println("  GET /" + PUBKEY_REL);
        System.out.println("  GET /" + LEDGER_REL);
        System.out.println("  GET /" + TRANSFER_REL + "?from=...&to=...&amount=...&date=...&nonce=...&fromBalance=...&toBalance=...&sig=...");
    }

    private static void stopServer() {
        if (httpServer != null) {
            httpServer.stop(0);
            httpServer = null;
            System.out.println("Local server stopped.");
        }
    }

    private static final class PublicKeyHandler implements com.sun.net.httpserver.HttpHandler {
        @Override public void handle(HttpExchange ex) throws IOException {
            try {
                String content = new String(Files.readAllBytes(new File(PUBLIC_KEY_FILE).toPath()), StandardCharsets.UTF_8);
                respond(ex, 200, content);
            } catch (IOException e) {
                respond(ex, 404, "no public key on file");
            }
        }
    }

    private static final class LedgerHandler implements com.sun.net.httpserver.HttpHandler {
        @Override public void handle(HttpExchange ex) throws IOException {
            StringBuilder sb = new StringBuilder();
            for (Map.Entry<String, Entry> e : ledger.entrySet()) {
                sb.append(e.getKey()).append('\t').append(e.getValue().points)
                        .append('\t').append(e.getValue().firstSeen).append('\n');
            }
            respond(ex, 200, sb.toString());
        }
    }

    private static final class TransferHandler implements com.sun.net.httpserver.HttpHandler {
        @Override public void handle(HttpExchange ex) throws IOException {
            try {
                Map<String, String> p = parseQuery(ex.getRequestURI().getRawQuery());
                String from = require(p, "from");
                String to = require(p, "to");
                long amount = Long.parseLong(require(p, "amount"));
                String date = require(p, "date");
                String nonce = require(p, "nonce");
                long fromBalance = Long.parseLong(require(p, "fromBalance"));
                long toBalance = Long.parseLong(require(p, "toBalance"));
                String sig = require(p, "sig");

                if (amount <= 0) throw new IllegalArgumentException("amount must be positive");

                if (seenNonces.contains(nonce)) {
                    respond(ex, 200, "DUPLICATE (already recorded)");
                    return;
                }

                PublicKey senderKey = getOrFetchPublicKey(from);
                String payload = joinPayload(from, to, amount, date, nonce, fromBalance, toBalance);
                boolean ok;
                try {
                    ok = verify(payload, sig, senderKey);
                } catch (Exception e) {
                    ok = false;
                }
                if (!ok) {
                    logTx("REJECTED", from, to, amount, nonce, "bad signature");
                    respond(ex, 400, "REJECTED: bad signature");
                    return;
                }

                String fromKey = normalizeServer(from);
                String toKey = normalizeServer(to);
                if (!ledger.containsKey(fromKey)) ledger.put(fromKey, new Entry(0, LocalDate.now()));
                if (!ledger.containsKey(toKey)) ledger.put(toKey, new Entry(0, LocalDate.now()));
                ledger.get(fromKey).points = fromBalance;
                ledger.get(toKey).points = toBalance;

                seenNonces.add(nonce);
                logTx("RECEIVED", from, to, amount, nonce, "ok");
                saveLedger();

                respond(ex, 200, "OK");
            } catch (Exception e) {
                respond(ex, 400, "ERROR: " + e.getMessage());
            }
        }

        private String require(Map<String, String> p, String key) {
            String v = p.get(key);
            if (v == null || v.isEmpty()) throw new IllegalArgumentException("missing " + key);
            return v;
        }
    }

    private static void respond(HttpExchange ex, int code, String body) throws IOException {
        byte[] b = body.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
        ex.sendResponseHeaders(code, b.length);
        OutputStream os = ex.getResponseBody();
        os.write(b);
        os.close();
    }

    // ===================================================================
    //                          diagnostics
    // ===================================================================
    private static void pullPeerLedgerPrompt(Scanner in) {
        System.out.print("Peer server URL: ");
        String peer = normalizeServer(readLine(in).trim());
        if (peer.isEmpty()) { System.out.println("Cancelled."); return; }
        try {
            String content = httpGetText(peer + LEDGER_REL);
            System.out.println();
            System.out.println("Ledger reported by " + peer + ":");
            System.out.println(content);
        } catch (IOException e) {
            System.out.println("Could not reach " + peer + ": " + e.getMessage());
        }
    }

    // ===================================================================
    //                          public key lookup
    // ===================================================================
    private static PublicKey getOrFetchPublicKey(String server) throws Exception {
        String key = normalizeServer(server);
        String b64 = knownKeys.get(key);
        if (b64 == null) {
            b64 = firstLine(httpGetText(key + PUBKEY_REL)).trim();
            knownKeys.put(key, b64);
            saveKnownKeys();
        }
        return decodePublicKey(b64);
    }

    // ===================================================================
    //                          crypto
    // ===================================================================
    private static void ensureKeys() throws Exception {
        File pub = new File(PUBLIC_KEY_FILE);
        File priv = new File(PRIVATE_KEY_FILE);
        if (pub.isFile() && priv.isFile()) {
            String pubB64 = firstNonBlankLine(pub);
            String privB64 = firstNonBlankLine(priv);
            PublicKey pk = decodePublicKey(pubB64);
            PrivateKey sk = decodePrivateKey(privB64);
            myKeys = new KeyPair(pk, sk);
            System.out.println("Loaded existing key pair from " + PUBLIC_KEY_FILE + " / " + PRIVATE_KEY_FILE);
        } else {
            System.out.println("No key pair found - generating a new RSA-2048 key pair...");
            KeyPairGenerator g = KeyPairGenerator.getInstance("RSA");
            g.initialize(2048);
            myKeys = g.generateKeyPair();
            String pubB64 = Base64.getEncoder().encodeToString(myKeys.getPublic().getEncoded());
            String privB64 = Base64.getEncoder().encodeToString(myKeys.getPrivate().getEncoded());
            atomicWrite(pub, singleLine(pubB64));
            atomicWrite(priv, singleLine(privB64));
            System.out.println("Saved new public key to " + PUBLIC_KEY_FILE + " (safe to publish).");
            System.out.println("Saved new PRIVATE key to " + PRIVATE_KEY_FILE + " - keep this file secret, never publish or broadcast it.");
        }
    }

    private static PublicKey decodePublicKey(String b64) throws Exception {
        byte[] bytes = Base64.getDecoder().decode(b64);
        return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(bytes));
    }

    private static PrivateKey decodePrivateKey(String b64) throws Exception {
        byte[] bytes = Base64.getDecoder().decode(b64);
        return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(bytes));
    }

    private static String sign(String data, PrivateKey key) throws Exception {
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initSign(key);
        sig.update(data.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(sig.sign());
    }

    private static boolean verify(String data, String sigB64, PublicKey key) throws Exception {
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initVerify(key);
        sig.update(data.getBytes(StandardCharsets.UTF_8));
        return sig.verify(Base64.getDecoder().decode(sigB64));
    }

    // ===================================================================
    //                          servers.txt handling (same rules as Meshare)
    // ===================================================================
    private static List<String> readServers() {
        List<String> servers = new ArrayList<String>();
        File f = new File(SERVERS_FILE);
        if (!f.isFile()) return servers;
        try {
            List<String> rawLines = Files.readAllLines(f.toPath(), StandardCharsets.UTF_8);
            Set<String> seen = new LinkedHashSet<String>();
            for (String line : rawLines) {
                String s = stripBom(line).trim();
                if (s.isEmpty() || s.startsWith("#")) continue;
                seen.add(normalizeServer(s));
            }
            servers.addAll(seen);
        } catch (IOException e) {
            System.out.println("WARNING: could not read " + SERVERS_FILE + ": " + e.getMessage());
        }
        return servers;
    }

    private static String stripBom(String s) {
        if (s != null && !s.isEmpty() && s.charAt(0) == '\uFEFF') return s.substring(1);
        return s;
    }

    private static String normalizeServer(String url) {
        url = url.trim();
        if (url.isEmpty()) return url;
        if (url.endsWith("/")) return url;
        int q = url.indexOf('?');
        int h = url.indexOf('#');
        int cut = -1;
        if (q >= 0) cut = q;
        if (h >= 0 && (cut < 0 || h < cut)) cut = h;
        if (cut >= 0) return url;
        return url + "/";
    }

    // ===================================================================
    //                          HTTP client (GET only)
    // ===================================================================
    private static String httpGetText(String urlStr) throws IOException {
        HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
        conn.setRequestMethod("GET");
        conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
        conn.setReadTimeout(READ_TIMEOUT_MS);
        conn.setRequestProperty("User-Agent", "MesharePoints/1.0");
        conn.setRequestProperty("Accept", "*/*");
        conn.setInstanceFollowRedirects(true);
        java.io.BufferedReader r = null;
        try {
            int code = conn.getResponseCode();
            if (code < 200 || code >= 300) throw new IOException("HTTP " + code);
            r = new java.io.BufferedReader(new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            char[] buf = new char[8192];
            int n;
            while ((n = r.read(buf)) >= 0) sb.append(buf, 0, n);
            return sb.toString();
        } finally {
            if (r != null) try { r.close(); } catch (IOException e) { /* ignore */ }
            conn.disconnect();
        }
    }

    private static Map<String, String> parseQuery(String query) {
        Map<String, String> params = new LinkedHashMap<String, String>();
        if (query == null || query.isEmpty()) return params;
        for (String pair : query.split("&")) {
            int eq = pair.indexOf('=');
            try {
                if (eq >= 0) {
                    String k = URLDecoder.decode(pair.substring(0, eq), "UTF-8");
                    String v = URLDecoder.decode(pair.substring(eq + 1), "UTF-8");
                    params.put(k, v);
                } else if (!pair.isEmpty()) {
                    params.put(URLDecoder.decode(pair, "UTF-8"), "");
                }
            } catch (Exception e) { /* skip malformed pair */ }
        }
        return params;
    }

    private static String buildQuery(Map<String, String> params) {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : params.entrySet()) {
            if (sb.length() > 0) sb.append('&');
            try {
                sb.append(URLEncoder.encode(e.getKey(), "UTF-8"))
                        .append('=')
                        .append(URLEncoder.encode(e.getValue(), "UTF-8"));
            } catch (Exception ex) { /* UTF-8 always supported */ }
        }
        return sb.toString();
    }

    // ===================================================================
    //                          persistence
    // ===================================================================
    private static void loadLedger() throws IOException {
        ledger.clear();
        File f = new File(LEDGER_FILE);
        if (!f.isFile()) return;
        for (String line : Files.readAllLines(f.toPath(), StandardCharsets.UTF_8)) {
            if (line.trim().isEmpty()) continue;
            String[] parts = line.split("\t");
            if (parts.length < 3) continue;
            String server = parts[0];
            long points = Long.parseLong(parts[1]);
            LocalDate firstSeen = "null".equals(parts[2]) ? LocalDate.now() : LocalDate.parse(parts[2]);
            ledger.put(server, new Entry(points, firstSeen));
        }
    }

    private static void saveLedger() throws IOException {
        List<String> lines = new ArrayList<String>();
        for (Map.Entry<String, Entry> e : ledger.entrySet()) {
            lines.add(e.getKey() + "\t" + e.getValue().points + "\t" + e.getValue().firstSeen);
        }
        atomicWrite(new File(LEDGER_FILE), lines);
    }

    private static void loadState() throws IOException {
        Map<String, String> kv = readKeyValueFile(new File(STATE_FILE));
        String self = kv.get("self");
        if (self != null && !self.isEmpty()) myServerUrl = self;
        String la = kv.get("lastAccrual");
        lastAccrualDate = (la != null && !la.isEmpty()) ? LocalDate.parse(la) : null;
    }

    private static void saveState() throws IOException {
        Map<String, String> kv = new LinkedHashMap<String, String>();
        if (myServerUrl != null) kv.put("self", myServerUrl);
        if (lastAccrualDate != null) kv.put("lastAccrual", lastAccrualDate.toString());
        writeKeyValueFile(new File(STATE_FILE), kv);
    }

    private static void loadKnownKeys() throws IOException {
        knownKeys.clear();
        File f = new File(KNOWN_KEYS_FILE);
        if (!f.isFile()) return;
        for (String line : Files.readAllLines(f.toPath(), StandardCharsets.UTF_8)) {
            if (line.trim().isEmpty()) continue;
            int tab = line.indexOf('\t');
            if (tab <= 0) continue;
            knownKeys.put(line.substring(0, tab), line.substring(tab + 1));
        }
    }

    private static void saveKnownKeys() throws IOException {
        List<String> lines = new ArrayList<String>();
        for (Map.Entry<String, String> e : knownKeys.entrySet()) {
            lines.add(e.getKey() + "\t" + e.getValue());
        }
        atomicWrite(new File(KNOWN_KEYS_FILE), lines);
    }

    private static void loadSeenNoncesFromLog() throws IOException {
        seenNonces.clear();
        File f = new File(TX_LOG_FILE);
        if (!f.isFile()) return;
        for (String line : Files.readAllLines(f.toPath(), StandardCharsets.UTF_8)) {
            String[] parts = line.split("\\|");
            if (parts.length >= 6) seenNonces.add(parts[5]);
        }
    }

    private static void logTx(String direction, String from, String to, long amount, String nonce, String note) {
        String line = java.time.LocalDateTime.now() + "|" + direction + "|" + from + "|" + to + "|" + amount + "|" + nonce + "|" + note;
        try {
            Files.write(new File(TX_LOG_FILE).toPath(),
                    (line + System.lineSeparator()).getBytes(StandardCharsets.UTF_8),
                    java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
        } catch (IOException e) {
            System.out.println("WARNING: could not write " + TX_LOG_FILE + ": " + e.getMessage());
        }
    }

    private static Map<String, String> readKeyValueFile(File f) throws IOException {
        Map<String, String> kv = new LinkedHashMap<String, String>();
        if (!f.isFile()) return kv;
        for (String line : Files.readAllLines(f.toPath(), StandardCharsets.UTF_8)) {
            int eq = line.indexOf('=');
            if (eq <= 0) continue;
            kv.put(line.substring(0, eq), line.substring(eq + 1));
        }
        return kv;
    }

    private static void writeKeyValueFile(File f, Map<String, String> kv) throws IOException {
        List<String> lines = new ArrayList<String>();
        for (Map.Entry<String, String> e : kv.entrySet()) {
            lines.add(e.getKey() + "=" + e.getValue());
        }
        atomicWrite(f, lines);
    }

    /** Writes one entry per line, via a temp file so a crash cannot leave a half-written file. */
    private static void atomicWrite(File file, List<String> lines) throws IOException {
        File tmp = new File(file.getAbsolutePath() + ".tmp");
        Files.write(tmp.toPath(), lines, StandardCharsets.UTF_8);
        Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
    }

    private static List<String> singleLine(String s) {
        List<String> l = new ArrayList<String>();
        l.add(s);
        return l;
    }

    private static String firstNonBlankLine(File f) throws IOException {
        for (String line : Files.readAllLines(f.toPath(), StandardCharsets.UTF_8)) {
            String t = line.trim();
            if (!t.isEmpty()) return t;
        }
        throw new IOException(f.getName() + " is empty");
    }

    /** Reads a line, returning "" instead of throwing if input has ended (e.g. piped stdin, Ctrl+D). */
    private static String readLine(Scanner in) {
        return in.hasNextLine() ? in.nextLine() : "";
    }

    private static int parseIntOrMinus1(String s) {
        try { return Integer.parseInt(s.trim()); } catch (NumberFormatException e) { return -1; }
    }
}
