import java.awt.Component;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.GridLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JProgressBar;
import javax.swing.JScrollPane;
import javax.swing.JSpinner;
import javax.swing.JSplitPane;
import javax.swing.JTabbedPane;
import javax.swing.JTable;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingUtilities;
import javax.swing.UIManager;
import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.DefaultTableModel;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsParameters;
import com.sun.net.httpserver.HttpsServer;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.NetworkInterface;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.util.Enumeration;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.swing.AbstractButton;
import javax.swing.JComboBox;
import java.io.FilenameFilter;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.ECGenParameterSpec;
import java.util.Base64;
import java.util.LinkedHashSet;
import java.util.concurrent.Callable;

/**
 * Meshare - distributed file browser/searcher
 *
 * Single-file Java 8 GUI app. Reads a list of servers from "servers.txt",
 * pulls each server's "files.txt", then for every valid "<sha256>.<ext>" line
 * pulls the matching "info/<sha256>.json" and shows the metadata. Results
 * stream in as they are found. A search box lets the user filter by any
 * field. The user can download individual files, all currently matching
 * results, or every file from every server.
 *
 * When the "Allow links" option is enabled, the search also fetches each
 * server's "links.txt". Every line that matches the query is treated as
 * either a URL-with-extension (downloaded into files/, info JSON created)
 * or as raw text (saved into files/ as .txt, info JSON created). The
 * info JSON always carries the server's public_key.txt content (may be
 * empty) in "server_public_key" and the full line content in "description".
 *
 * JSON files referenced from files.txt or generated for links.txt are
 * cached locally in the "info" folder. If a matching JSON already exists
 * locally, we read from disk instead of asking the remote server again.
 *
 * Layout on disk:
 *   files/    binary downloads          (named <hash>.<ext from json>)
 *   info/     json metadata             (named <hash>.json)
 *   log/      per-download log files    (named <originalName>_<hash8>.log.json)
 *   servers.txt  one server URL per line
 *   files.txt    local list of files in files/ (one per line, maintained by us)
 */
public class Meshare extends JFrame {

    private static final long serialVersionUID = 1L;

    // ---------------- configuration ----------------
    private static final String SERVERS_FILE     = "servers.txt";
    private static final String FILES_DIR        = "files";
    private static final String INFO_DIR         = "info";
    private static final String LOG_DIR          = "log";
    private static final String LOCAL_FILES_LIST = "files.txt";

    private static final int MAX_RESULTS         = 200;
    private static final int MAX_STATS           = 200;
    private static final int MAX_FIELD_LENGTH    = 512;
    private static final int CONNECT_TIMEOUT_MS  = 10000;
    private static final int READ_TIMEOUT_MS     = 30000;
    private static final int MAX_LOG_LINES       = 1500;
    private static final int MAX_LINK_LINE_LENGTH = 5000;

    // ----- local peer server / discovery -----
    private static final String KEYSTORE_FILE            = "meshare_server.jks";
    private static final String KEYSTORE_PASSWORD         = "meshare123";
    private static final int    DEFAULT_HTTP_PORT         = 8080;
    private static final int    PEER_CHECK_FALLBACK_PORT  = 8080;
    private static final int    PEER_CHECK_TIMEOUT_MS     = 6000;

    // ----- identity keys -----
    private static final String PUBLIC_KEY_FILE   = "public_key.txt";
    private static final String PRIVATE_KEY_PREFIX = "key_";

    // ----- points / transfers / network consensus -----
    private static final String TRANSFERS_FILE     = "transfers.txt";
    private static final long   CONSENSUS_CACHE_MS  = 30000L;

    private static final String[] COLUMNS = {
        "Server", "Filename", "Ext", "Size", "Description", "Date", "Status"
    };

    // ---------------- UI ----------------
    private JTextField      searchField;
    private JCheckBox       randomTriesCheck;
    private JSpinner        randomTriesSpinner;
    private JCheckBox       allowLinksCheck;
    private JButton         searchButton;
    private JButton         stopButton;
    private JButton         downloadSelectedButton;
    private JButton         downloadMatchingButton;
    private JButton         downloadAllButton;
    private JButton         viewJsonButton;
    private JButton         openInBrowserButton;
    private JButton         openFolderButton;
    private JLabel          statusLabel;
    private JLabel          serverCountLabel;
    private JTextArea       logArea;
    private JTextArea       detailsArea;
    private DefaultTableModel tableModel;
    private JTable          resultTable;
    private JProgressBar    progressBar;

    // ----- statistics tab -----
    private JTabbedPane        mainTabbedPane;
    private JButton            refreshStatsButton;
    private JTable             serverFilesTable;
    private DefaultTableModel  serverFilesModel;
    private JTable             majorityFilesTable;
    private DefaultTableModel  majorityFilesModel;
    private JTable             publicKeyTable;
    private DefaultTableModel  publicKeyModel;
    private JTable             serverPublicKeyTable;
    private DefaultTableModel  serverPublicKeyModel;

    // ----- "Server" tab (local HTTP/HTTPS peer server) -----
    private JComboBox<String>  serverProtocolCombo;
    private JSpinner            serverPortSpinner;
    private JButton              serverStartButton;
    private JButton              serverStopButton;
    private JCheckBox            serverAutoRegisterCheck;
    private JLabel                serverStatusLabel;
    private JLabel                serverAddressLabel;
    private JTextArea             serverLogArea;
    private JTable                discoveredPeersTable;
    private DefaultTableModel     discoveredPeersModel;

    // ----- "Keys" tab (public/private key pair generation) -----
    private JComboBox<String>  keyCurveCombo;
    private JButton              generateKeysButton;
    private JLabel                keysStatusLabel;
    private JLabel                privateKeyPathLabel;
    private JTextArea             publicKeyDisplayArea;

    // ----- "Transfer" tab (points ledger + consensus-gated transfers) -----
    private JLabel               balanceLabel;
    private JButton                recalcPointsButton;
    private JButton                checkConsensusButton;
    private JLabel                  consensusStatusLabel;
    private JTextArea               consensusDetailsArea;
    private JTable                   pointsTable;
    private DefaultTableModel        pointsModel;
    private JTable                   transferLogTable;
    private DefaultTableModel        transferLogModel;
    private JTextField               recipientField;
    private JSpinner                  transferAmountSpinner;
    private JButton                    sendTransferButton;

    // ---------------- state ----------------
    private final List<FileEntry>     currentResults = new CopyOnWriteArrayList<FileEntry>();
    private final AtomicBoolean       searchActive   = new AtomicBoolean(false);
    private final AtomicBoolean       downloadActive = new AtomicBoolean(false);
    private final AtomicBoolean       statsScanning  = new AtomicBoolean(false);
    private final AtomicInteger       resultsCount   = new AtomicInteger(0);
    private final AtomicInteger       downloadedCount = new AtomicInteger(0);
    private ExecutorService           executor;

    // ----- local peer server state -----
    private volatile HttpServer       localHttpServer;
    private volatile int              localServerPort  = -1;
    private final Set<String>         recentlyCheckedIps =
            Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
    private final Object              serversFileLock  = new Object();

    // ----- consensus / points state -----
    private ExecutorService           consensusExecutor;
    private final Object              consensusCacheLock  = new Object();
    private volatile String           cachedConsensusText = null;
    private volatile long             cachedConsensusAt   = 0L;
    private final Map<String, String> fingerprintSampleCache = new ConcurrentHashMap<String, String>();

    // ===================================================================
    //                          inner class
    // ===================================================================
    public static class FileEntry {
        public String server;
        public String hash;
        public String extension;
        public String filename         = "";
        public String size             = "";
        public String publicKey        = "";
        public String serverPublicKey  = "";
        public String nodeInfo         = "";
        public String description      = "";
        public String date             = "";
        public volatile String status  = "";
        public volatile boolean isLocal = false;
        public volatile boolean isLink  = false;
        public final Map<String, String> rawFields = new LinkedHashMap<String, String>();
        public String rawJson = "";

        public String getKey()              { return hash + "." + extension; }
        public String getDisplayFilename()  { return filename.isEmpty() ? getKey() : filename; }
        public String getLocalPath()        {
            return new File(FILES_DIR, hash + "." + extension).getAbsolutePath();
        }
    }

    // ===================================================================
    //                          constructor
    // ===================================================================
    public Meshare() {
        super("Meshare");
        setSize(1280, 850);
        setMinimumSize(new Dimension(980, 620));
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setLocationRelativeTo(null);

        ensureDirectories();

        executor = Executors.newFixedThreadPool(16, new ThreadFactory() {
            private int id = 0;
            @Override public Thread newThread(Runnable r) {
                Thread t = new Thread(r, "meshare-" + (++id));
                t.setDaemon(true);
                return t;
            }
        });
        // Dedicated pool for network-consensus checks, kept separate from
        // `executor` (which also backs the embedded peer HTTP server) so a
        // consensus check triggered by an incoming request can never
        // deadlock waiting on itself.
        consensusExecutor = Executors.newFixedThreadPool(8, new ThreadFactory() {
            private int id = 0;
            @Override public Thread newThread(Runnable r) {
                Thread t = new Thread(r, "meshare-consensus-" + (++id));
                t.setDaemon(true);
                return t;
            }
        });

        initComponents();
        loadServerCount();

        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override public void run() {
                stopLocalServer();
                executor.shutdownNow();
                consensusExecutor.shutdownNow();
            }
        }));
    }

    private void ensureDirectories() {
        new File(FILES_DIR).mkdirs();
        new File(INFO_DIR).mkdirs();
        new File(LOG_DIR).mkdirs();
        new File(POINTS_DIR, POINTS_CATEGORY_FILE).mkdirs();
        new File(POINTS_DIR, POINTS_CATEGORY_SERVER).mkdirs();
    }

    // ===================================================================
    //                          UI
    // ===================================================================
    private void initComponents() {
        JPanel root = new JPanel(new BorderLayout(6, 6));
        root.setBorder(new EmptyBorder(8, 8, 8, 8));

        // The "Search" tab contains the existing layout (top search bar,
        // results table, bottom button / details / log panel).
        JPanel searchTab = new JPanel(new BorderLayout(6, 6));

        // ----- top -----
        JPanel topPanel = new JPanel();
        topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS));
        topPanel.setBorder(new TitledBorder("Search"));

        JPanel row1 = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        serverCountLabel = new JLabel("Servers: -");
        row1.add(serverCountLabel);
        JButton reloadBtn = new JButton("Reload servers.txt");
        reloadBtn.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { loadServerCount(); }
        });
        row1.add(reloadBtn);
        topPanel.add(row1);

        JPanel row2 = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        row2.add(new JLabel("Search:"));
        searchField = new JTextField(30);
        row2.add(searchField);

        randomTriesCheck = new JCheckBox("Random tries per server:");
        row2.add(randomTriesCheck);
        randomTriesSpinner = new JSpinner(new SpinnerNumberModel(10, 1, 1000000, 1));
        randomTriesSpinner.setPreferredSize(new Dimension(80, 26));
        randomTriesSpinner.setEnabled(false);
        row2.add(randomTriesSpinner);
        randomTriesCheck.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) {
                randomTriesSpinner.setEnabled(randomTriesCheck.isSelected());
            }
        });

        allowLinksCheck = new JCheckBox("Allow links", true);
        row2.add(allowLinksCheck);

        searchButton = new JButton("\u2315 Search");
        stopButton   = new JButton("\u25A0 Stop");
        stopButton.setEnabled(false);
        stylePrimaryButton(searchButton);
        styleSecondaryButton(stopButton);
        row2.add(searchButton);
        row2.add(stopButton);
        topPanel.add(row2);

        searchTab.add(topPanel, BorderLayout.NORTH);

        // ----- center: table -----
        tableModel = new DefaultTableModel(COLUMNS, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        resultTable = new JTable(tableModel);
        resultTable.setAutoCreateRowSorter(true);
        resultTable.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
        resultTable.setRowHeight(22);
        resultTable.setIntercellSpacing(new Dimension(4, 2));
        resultTable.getTableHeader().setReorderingAllowed(false);
        resultTable.setDefaultRenderer(Object.class, new DefaultTableCellRenderer() {
            private static final long serialVersionUID = 1L;
            @Override
            public Component getTableCellRendererComponent(JTable table, Object value,
                                                           boolean isSelected, boolean hasFocus,
                                                           int row, int column) {
                Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
                if (!isSelected) {
                    int modelRow = table.convertRowIndexToModel(row);
                    if (modelRow >= 0 && modelRow < currentResults.size()) {
                        FileEntry entry = currentResults.get(modelRow);
                        if (entry != null) {
                            if (column == 6) {
                                if (entry.isLocal)                          c.setBackground(new Color(210, 245, 210));
                                else if (entry.status != null
                                        && entry.status.toLowerCase().contains("download")) c.setBackground(new Color(220, 230, 255));
                                else if (entry.status != null
                                        && (entry.status.toLowerCase().contains("fail")
                                         || entry.status.toLowerCase().contains("mismatch"))) c.setBackground(new Color(255, 220, 220));
                                else                                        c.setBackground(Color.WHITE);
                            } else {
                                c.setBackground(entry.isLocal ? new Color(245, 252, 245) : Color.WHITE);
                            }
                        }
                    } else {
                        c.setBackground(Color.WHITE);
                    }
                }
                return c;
            }
        });

        int[] widths = {180, 200, 50, 80, 280, 150, 110};
        for (int i = 0; i < widths.length; i++) {
            resultTable.getColumnModel().getColumn(i).setPreferredWidth(widths[i]);
        }

        JScrollPane tableScroll = new JScrollPane(resultTable);
        tableScroll.setBorder(new TitledBorder("Results (max " + MAX_RESULTS + ")"));
        searchTab.add(tableScroll, BorderLayout.CENTER);

        // ----- bottom: buttons + details + log -----
        JPanel bottomPanel = new JPanel(new BorderLayout(5, 5));

        JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        downloadSelectedButton = new JButton("Download Selected");
        downloadMatchingButton = new JButton("Download Matching");
        downloadAllButton      = new JButton("Download All");
        viewJsonButton         = new JButton("View JSON");
        openInBrowserButton     = new JButton("Open in Browser");
        openFolderButton       = new JButton("Open Files Folder");
        for (JButton b : new JButton[] { downloadSelectedButton, downloadMatchingButton,
                downloadAllButton, viewJsonButton, openInBrowserButton, openFolderButton }) {
            styleSecondaryButton(b);
        }
        buttonPanel.add(downloadSelectedButton);
        buttonPanel.add(downloadMatchingButton);
        buttonPanel.add(downloadAllButton);
        buttonPanel.add(viewJsonButton);
        buttonPanel.add(openInBrowserButton);
        buttonPanel.add(openFolderButton);
        buttonPanel.add(Box.createHorizontalStrut(20));
        statusLabel = new JLabel("Ready");
        buttonPanel.add(statusLabel);
        progressBar = new JProgressBar();
        progressBar.setPreferredSize(new Dimension(160, 16));
        progressBar.setVisible(false);
        buttonPanel.add(progressBar);
        bottomPanel.add(buttonPanel, BorderLayout.NORTH);

        JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
        splitPane.setResizeWeight(0.5);

        detailsArea = new JTextArea(7, 50);
        detailsArea.setEditable(false);
        detailsArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
        detailsArea.setText("Select a row to see details.");
        JScrollPane detailsScroll = new JScrollPane(detailsArea);
        detailsScroll.setBorder(new TitledBorder("Details"));

        logArea = new JTextArea(7, 50);
        logArea.setEditable(false);
        logArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
        JScrollPane logScroll = new JScrollPane(logArea);
        logScroll.setBorder(new TitledBorder("Log"));

        splitPane.setTopComponent(detailsScroll);
        splitPane.setBottomComponent(logScroll);
        bottomPanel.add(splitPane, BorderLayout.CENTER);

        searchTab.add(bottomPanel, BorderLayout.SOUTH);

        // ----- statistics tab -----
        JPanel statisticsTab = buildStatisticsTab();

        // ----- server tab (host files for other peers) -----
        JPanel serverTab = buildServerTab();

        // ----- keys tab (generate identity key pair) -----
        JPanel keysTab = buildKeysTab();

        // ----- transfer tab (points ledger + consensus-gated transfers) -----
        JPanel transferTab = buildTransferTab();

        mainTabbedPane = new JTabbedPane();
        mainTabbedPane.addTab("Search", searchTab);
        mainTabbedPane.addTab("Statistics", statisticsTab);
        mainTabbedPane.addTab("Server", serverTab);
        mainTabbedPane.addTab("Keys", keysTab);
        mainTabbedPane.addTab("Transfer", transferTab);
        mainTabbedPane.addChangeListener(new ChangeListener() {
            @Override public void stateChanged(ChangeEvent e) {
                if (mainTabbedPane != null && mainTabbedPane.getSelectedIndex() == 1) {
                    // Only auto-refresh when we already have search results
                    // to show. An empty currentResults means the user hasn't
                    // searched yet — don't surprise them with a network scan
                    // just for opening the tab. They can hit Refresh to scan.
                    if (!currentResults.isEmpty()) {
                        refreshStatistics();
                    }
                }
            }
        });
        root.add(mainTabbedPane, BorderLayout.CENTER);

        setContentPane(root);

        // ----- listeners -----
        searchField.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { startSearch(); }
        });
        searchButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { startSearch(); }
        });
        stopButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { stopAll(); }
        });
        downloadSelectedButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { downloadSelected(); }
        });
        downloadMatchingButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { downloadMatching(); }
        });
        downloadAllButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { downloadAll(); }
        });
        viewJsonButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { viewSelectedJson(); }
        });
        openInBrowserButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { openInBrowser(); }
        });
        openFolderButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { openFilesFolder(); }
        });

        resultTable.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
            @Override public void valueChanged(ListSelectionEvent e) { updateDetails(); }
        });

        JPopupMenu popup = new JPopupMenu();
        JMenuItem dlItem     = new JMenuItem("Download");
        JMenuItem vjItem     = new JMenuItem("View JSON");
        JMenuItem browseItem = new JMenuItem("Open in Browser");
        JMenuItem openItem   = new JMenuItem("Reveal in folder");
        popup.add(dlItem);
        popup.add(vjItem);
        popup.add(browseItem);
        popup.addSeparator();
        popup.add(openItem);
        resultTable.setComponentPopupMenu(popup);
        dlItem.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { downloadSelected(); }
        });
        vjItem.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { viewSelectedJson(); }
        });
        browseItem.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { openInBrowser(); }
        });
        openItem.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { revealInFolder(); }
        });

        resultTable.addMouseListener(new MouseAdapter() {
            @Override public void mouseClicked(MouseEvent e) {
                if (e.getClickCount() == 2 && resultTable.getSelectedRow() >= 0) {
                    int view = resultTable.getSelectedRow();
                    int modelRow = resultTable.convertRowIndexToModel(view);
                    if (modelRow >= 0 && modelRow < currentResults.size()) {
                        FileEntry entry = currentResults.get(modelRow);
                        if (entry != null && (entry.isLink || entry.isLocal)) {
                            openInBrowser();
                            return;
                        }
                    }
                    downloadSelected();
                }
            }
        });
    }

    // ===================================================================
    //                          server handling
    // ===================================================================
    private void loadServerCount() {
        List<String> servers = readServers();
        String text = "Servers: " + servers.size();
        if (servers.isEmpty()) text += "  (edit servers.txt to add some)";
        serverCountLabel.setText(text);
    }

    private List<String> readServers() {
        List<String> list = new ArrayList<String>();
        File f = new File(SERVERS_FILE);
        if (!f.exists()) {
            log("servers.txt not found in " + new File(".").getAbsolutePath());
            return list;
        }
        BufferedReader r = null;
        try {
            r = new BufferedReader(new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8));
            String line;
            while ((line = r.readLine()) != null) {
                line = line.trim();
                if (line.isEmpty() || line.startsWith("#")) continue;
                list.add(line);
            }
        } catch (IOException e) {
            log("Error reading servers.txt: " + e.getMessage());
        } finally {
            if (r != null) try { r.close(); } catch (IOException e) { /* ignore */ }
        }
        return list;
    }

    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 + "/";
    }

    // ===================================================================
    //                          search
    // ===================================================================
    private void startSearch() {
        if (searchActive.get()) {
            log("Search already in progress");
            return;
        }

        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                tableModel.setRowCount(0);
                currentResults.clear();
                resultsCount.set(0);
                searchButton.setEnabled(false);
                stopButton.setEnabled(true);
                downloadAllButton.setEnabled(false);
                statusLabel.setText("Searching...");
            }
        });

        final String query         = searchField.getText().trim();
        final boolean randomOn     = randomTriesCheck.isSelected();
        final int randomCount      = ((Number) randomTriesSpinner.getValue()).intValue();
        final boolean allowLinks   = allowLinksCheck.isSelected();
        final List<String> servers = readServers();

        if (servers.isEmpty()) {
            log("No servers configured (add URLs to servers.txt)");
            finishSearch();
            return;
        }

        searchActive.set(true);

        log("Search started: query='" + query + "'"
                + (randomOn ? ", random=" + randomCount : "")
                + ", allowLinks=" + allowLinks
                + ", " + servers.size() + " server(s)");

        final List<Future<?>> futures = new ArrayList<Future<?>>();
        for (final String server : servers) {
            futures.add(executor.submit(new Runnable() {
                @Override public void run() {
                    try { searchServer(server, query, randomOn, randomCount, allowLinks); }
                    catch (Throwable t) { log("Error searching " + server + ": " + t.getMessage()); }
                }
            }));
        }

        executor.submit(new Runnable() {
            @Override public void run() {
                for (Future<?> f : futures) {
                    try { f.get(); } catch (Exception e) { /* ignore */ }
                }
                finishSearch();
            }
        });
    }

    private void finishSearch() {
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                searchActive.set(false);
                searchButton.setEnabled(true);
                stopButton.setEnabled(false);
                downloadAllButton.setEnabled(true);
                statusLabel.setText("Search done: " + resultsCount.get() + " result(s)");
                // Keep the statistics tab in sync with the latest results.
                refreshStatistics();
            }
        });
    }

    private void stopAll() {
        searchActive.set(false);
        downloadActive.set(false);
        statsScanning.set(false);
        setStatus("Stop requested...");
    }

    private void searchServer(String server, String query, boolean randomOn,
                              int randomCount, boolean allowLinks) {
        final String baseUrl = normalizeServer(server);

        // Fetch the server's public_key.txt once per server, only if we are
        // going to inspect links.txt. If absent, we treat it as empty.
        String serverPublicKey = "";
        if (allowLinks) {
            setStatus("[" + server + "] fetching public_key.txt");
            String pk = httpGet(baseUrl + "public_key.txt");
            if (pk != null) {
                serverPublicKey = pk;
                if (serverPublicKey.length() > MAX_FIELD_LENGTH) {
                    serverPublicKey = serverPublicKey.substring(0, MAX_FIELD_LENGTH);
                }
            } else {
                log("[" + server + "] public_key.txt not available (using empty)");
            }
        }

        // ----- files.txt -----
        setStatus("[" + server + "] fetching files.txt");
        String filesListContent = httpGet(baseUrl + "files.txt");
        if (filesListContent == null) {
            log("[" + server + "] failed to fetch files.txt");
        } else {
            processFilesList(baseUrl, server, query, randomOn, randomCount, filesListContent);
        }

        // ----- links.txt -----
        if (allowLinks) {
            setStatus("[" + server + "] fetching links.txt");
            String linksListContent = httpGet(baseUrl + "links.txt");
            if (linksListContent == null) {
                log("[" + server + "] failed to fetch links.txt");
            } else {
                processLinksList(baseUrl, server, query, linksListContent, serverPublicKey);
            }
        }
    }

    private void processFilesList(String baseUrl, String server, String query,
                                  boolean randomOn, int randomCount,
                                  String filesListContent) {
        String[] lines = filesListContent.split("\\r?\\n");
        List<Integer> validIdx = new ArrayList<Integer>();
        for (int i = 0; i < lines.length; i++) {
            if (parseFileLine(lines[i]) != null) validIdx.add(i);
        }

        log("[" + server + "] " + validIdx.size() + " valid entries (of " + lines.length + ")");

        if (validIdx.isEmpty()) return;

        if (randomOn) Collections.shuffle(validIdx, new Random());

        int processed = 0;

        for (int idx : validIdx) {
            if (!searchActive.get()) break;
            if (resultsCount.get() >= MAX_RESULTS) break;

            String line = lines[idx];
            String[] parsed = parseFileLine(line);
            if (parsed == null) continue;
            String hash = parsed[0];
            String ext  = parsed[1];

            FileEntry entry = new FileEntry();
            entry.server   = server;
            entry.hash     = hash;
            entry.extension = ext;

            // Auto-load info: read locally if present, otherwise download.
            File localInfo = new File(INFO_DIR, hash + ".json");
            String infoContent = null;
            Map<String, String> fields = null;

            if (localInfo.exists()) {
                infoContent = readFile(localInfo);
                if (infoContent != null) {
                    fields = parseJson(infoContent);
                }
            }
            if (fields == null || fields.isEmpty()) {
                setStatus("[" + server + "] fetching " + hash + " info");
                infoContent = httpGet(baseUrl + "info/" + hash + ".json");
                if (infoContent != null) {
                    FileWriter w = null;
                    try {
                        w = new FileWriter(localInfo);
                        w.write(infoContent);
                    } catch (IOException e) {
                        log("Error saving info: " + e.getMessage());
                    } finally {
                        if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
                    }
                    fields = parseJson(infoContent);
                }
            }
            if (fields == null || fields.isEmpty()) continue;

            entry.rawJson = infoContent;
            populateEntry(entry, fields);

            File localBinary = new File(FILES_DIR, hash + "." + entry.extension);
            if (localBinary.exists()) {
                entry.isLocal = true;
                entry.status  = "Downloaded";
            } else {
                entry.status  = "Available";
            }

            if (matchesQuery(entry, query)) {
                addResult(entry);
            }

            processed++;
            if (randomOn && processed >= randomCount) break;
        }
    }

    private void processLinksList(String baseUrl, String server, String query,
                                  String linksContent, String serverPublicKey) {
        String[] lines = linksContent.split("\\r?\\n");
        int total = 0, matched = 0;

        for (String rawLine : lines) {
            if (!searchActive.get()) break;
            if (resultsCount.get() >= MAX_RESULTS) break;
            total++;

            if (rawLine == null) continue;
            String line = rawLine.trim();
            if (line.isEmpty()) continue;

            // Hard cap per-line length to 5000 chars
            if (line.length() > MAX_LINK_LINE_LENGTH) {
                line = line.substring(0, MAX_LINK_LINE_LENGTH);
            }

            if (!matchesLinkQuery(line, query)) continue;
            matched++;

            String[] urlInfo = parseLinkAsUrl(line);
            if (urlInfo != null) {
                processLinkUrl(baseUrl, server, line, urlInfo[0], urlInfo[1], serverPublicKey);
            } else {
                processLinkRaw(baseUrl, server, line, serverPublicKey);
            }
        }

        log("[" + server + "] links.txt: " + matched + " matched (of " + total + ")");
    }

    private void processLinkUrl(String baseUrl, String server, String fullLine,
                                String urlStr, String ext, String serverPublicKey) {
        if (resultsCount.get() >= MAX_RESULTS) return;

        // Hash the URL string itself so we have a deterministic identifier
        // without having to fetch the content. The user can trigger the
        // actual binary download later via Download Selected/Matching.
        String fileHash = computeSha256OfBytes(urlStr.getBytes(StandardCharsets.UTF_8));
        if (fileHash == null) {
            log("[" + server + "] failed to hash link URL");
            return;
        }

        File finalFile = new File(FILES_DIR, fileHash + "." + ext);
        File infoFile  = new File(INFO_DIR,  fileHash + ".json");

        // Cache the info JSON locally so the link survives across restarts
        // and can be downloaded manually later. We do NOT fetch the binary.
        if (!infoFile.exists()) {
            Map<String, Object> data = new LinkedHashMap<String, Object>();
            data.put("hash",              fileHash);
            data.put("extension",         ext);
            data.put("filename",          fileHash + "." + ext);
            data.put("size",              finalFile.exists() ? String.valueOf(finalFile.length()) : "");
            data.put("server",            server);
            data.put("server_public_key", serverPublicKey);
            data.put("description",       fullLine);
            data.put("url",               urlStr);
            data.put("date",              new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX").format(new Date()));
            writeInfoJson(infoFile, data);
        }

        String content = readFile(infoFile);
        if (content == null) {
            log("[" + server + "] failed to read info JSON for link: " + urlStr);
            return;
        }

        FileEntry entry = new FileEntry();
        entry.server    = server;
        entry.hash      = fileHash;
        entry.extension = ext;
        entry.rawJson   = content;
        populateEntry(entry, parseJson(content));
        entry.isLink    = true;
        entry.isLocal   = finalFile.exists();
        entry.status    = finalFile.exists() ? "Downloaded" : "Link";

        if (matchesQuery(entry, searchField.getText().trim())) {
            addResult(entry);
        }
        log("[" + server + "] link URL (not auto-downloaded): " + fileHash + "." + ext);
    }

    private void processLinkRaw(String baseUrl, String server, String line, String serverPublicKey) {
        if (resultsCount.get() >= MAX_RESULTS) return;

        byte[] lineBytes = line.getBytes(StandardCharsets.UTF_8);
        String fileHash = computeSha256OfBytes(lineBytes);
        if (fileHash == null) {
            log("[" + server + "] failed to hash raw link content");
            return;
        }

        File finalFile = new File(FILES_DIR, fileHash + ".txt");
        File infoFile  = new File(INFO_DIR,  fileHash + ".json");

        // Cache the info JSON locally so the link survives across restarts.
        // The .txt itself is NOT written automatically; the user triggers
        // it manually via Download Selected/Matching.
        if (!infoFile.exists()) {
            Map<String, Object> data = new LinkedHashMap<String, Object>();
            data.put("hash",              fileHash);
            data.put("extension",         "txt");
            data.put("filename",          fileHash + ".txt");
            data.put("size",              finalFile.exists() ? String.valueOf(finalFile.length()) : String.valueOf(lineBytes.length));
            data.put("server",            server);
            data.put("server_public_key", serverPublicKey);
            data.put("description",       line);
            data.put("date",              new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX").format(new Date()));
            writeInfoJson(infoFile, data);
        }

        String content = readFile(infoFile);
        if (content == null) {
            log("[" + server + "] failed to read info JSON for raw link");
            return;
        }

        FileEntry entry = new FileEntry();
        entry.server    = server;
        entry.hash      = fileHash;
        entry.extension = "txt";
        entry.rawJson   = content;
        populateEntry(entry, parseJson(content));
        entry.isLink    = true;
        entry.isLocal   = finalFile.exists();
        entry.status    = finalFile.exists() ? "Downloaded" : "Link";

        if (matchesQuery(entry, searchField.getText().trim())) {
            addResult(entry);
        }
        log("[" + server + "] link raw (not auto-downloaded): " + fileHash + ".txt");
    }

    /**
     * Try to parse the trimmed line as a URL with a valid file extension.
     * Returns {url, ext} on success, null otherwise.
     */
    private static String[] parseLinkAsUrl(String line) {
        if (line == null) return null;
        String trimmed = line.trim();
        if (trimmed.isEmpty()) return null;

        URL url;
        try {
            url = new URL(trimmed);
        } catch (MalformedURLException e) {
            return null;
        }

        String protocol = url.getProtocol();
        if (protocol == null || protocol.isEmpty()) return null;
        // Only HTTP(S) and FTP(S) make sense for a binary download with our HTTP client
        String p = protocol.toLowerCase();
        if (!(p.equals("http") || p.equals("https") || p.equals("ftp") || p.equals("ftps"))) {
            return null;
        }

        String host = url.getHost();
        if (host == null || host.isEmpty()) return null;

        String path = url.getPath();
        if (path == null || path.isEmpty()) return null;

        String filename = path.substring(path.lastIndexOf('/') + 1);
        if (filename.isEmpty()) return null;

        int dot = filename.lastIndexOf('.');
        if (dot <= 0 || dot >= filename.length() - 1) return null;

        String ext = filename.substring(dot + 1);
        if (ext.isEmpty() || ext.length() > 16) return null;
        for (int i = 0; i < ext.length(); i++) {
            char c = ext.charAt(i);
            if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) {
                return null;
            }
        }

        return new String[] { trimmed, ext };
    }

    private static boolean matchesLinkQuery(String line, String query) {
        if (query == null || query.isEmpty()) return true;
        if (line == null) return false;
        return line.toLowerCase().contains(query.toLowerCase());
    }

    private void addResult(final FileEntry entry) {
        if (resultsCount.get() >= MAX_RESULTS) return;
        if (resultsCount.incrementAndGet() > MAX_RESULTS) {
            resultsCount.decrementAndGet();
            return;
        }
        currentResults.add(entry);

        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                Object[] row = new Object[] {
                    truncate(entry.server, 40),
                    truncate(getDisplayedFilename(entry), 60),
                    entry.extension,
                    entry.size,
                    truncate(entry.description, 100),
                    entry.date,
                    entry.status
                };
                tableModel.addRow(row);
                statusLabel.setText("Results: " + resultsCount.get() + " / " + MAX_RESULTS);
            }
        });
    }

    /**
     * Compute the value shown in the "Filename" column of the results table.
     *
     * For magnet links (i.e. link entries whose description starts with
     * "magnet:"), the JSON file's "filename" field is a hash-only name like
     * "abcd…1234.mp3", which is meaningless for the user. The actual name of
     * the torrent is encoded at a fixed offset (position 64, right after the
     * 64-char SHA-256 hash) inside the magnet "description" string, and is
     * usually percent-encoded (URL form). We slice that portion off and
     * decode it so the table shows the human-readable name.
     *
     * For every other entry we just return the regular filename / key.
     */
    private String getDisplayedFilename(FileEntry entry) {
        if (entry == null) return "";
        if (entry.isLink && entry.description != null && entry.description.startsWith("magnet:")) {
            String raw = entry.description;
            String tail = raw.length() > 64 ? raw.substring(64) : raw;
            // Strip anything after the first whitespace inside the dn=... value,
            // so trailing garbage from the magnet link (e.g. trackers) doesn't
            // pollute the displayed name.
            int ws = indexOfAnyWhitespace(tail);
            if (ws >= 0) tail = tail.substring(0, ws);
            try {
                return URLDecoder.decode(tail, "UTF-8");
            } catch (Exception e) {
                return tail;
            }
        }
        return entry.filename == null || entry.filename.isEmpty() ? entry.getKey() : entry.filename;
    }

    private static int indexOfAnyWhitespace(String s) {
        if (s == null) return -1;
        for (int i = 0; i < s.length(); i++) {
            if (Character.isWhitespace(s.charAt(i))) return i;
        }
        return -1;
    }

    private boolean matchesQuery(FileEntry entry, String query) {
        if (query == null || query.isEmpty()) return true;
        String q = query.toLowerCase();
        if (containsIc(entry.server,          q)) return true;
        if (containsIc(entry.filename,        q)) return true;
        if (containsIc(entry.description,     q)) return true;
        if (containsIc(entry.extension,       q)) return true;
        if (containsIc(entry.hash,            q)) return true;
        if (containsIc(entry.size,            q)) return true;
        if (containsIc(entry.date,            q)) return true;
        if (containsIc(entry.publicKey,       q)) return true;
        if (containsIc(entry.serverPublicKey, q)) return true;
        if (containsIc(entry.nodeInfo,        q)) return true;
        for (Map.Entry<String, String> f : entry.rawFields.entrySet()) {
            if (containsIc(f.getKey(),   q)) return true;
            if (containsIc(f.getValue(), q)) return true;
        }
        return false;
    }

    private static boolean containsIc(String s, String q) {
        return s != null && q != null && s.toLowerCase().contains(q);
    }

    // ===================================================================
    //                          download
    // ===================================================================
    private void downloadSelected() {
        int[] rows = resultTable.getSelectedRows();
        if (rows.length == 0) {
            log("No row selected");
            return;
        }
        List<FileEntry> todo = new ArrayList<FileEntry>();
        for (int row : rows) {
            int modelRow = resultTable.convertRowIndexToModel(row);
            if (modelRow >= 0 && modelRow < currentResults.size()) {
                FileEntry e = currentResults.get(modelRow);
                if (e != null && !e.isLocal) todo.add(e);
            }
        }
        if (todo.isEmpty()) {
            log("All selected entries are already downloaded");
            return;
        }
        log("Queuing " + todo.size() + " download(s)");
        for (FileEntry e : todo) {
            executor.submit(new Runnable() {
                @Override public void run() { downloadOne(e); }
            });
        }
    }

    private void downloadMatching() {
        List<FileEntry> todo = new ArrayList<FileEntry>();
        for (FileEntry e : currentResults) {
            if (!e.isLocal) todo.add(e);
        }
        if (todo.isEmpty()) {
            log("All matching entries are already downloaded");
            return;
        }
        log("Queuing " + todo.size() + " download(s) (matching)");
        for (FileEntry e : todo) {
            executor.submit(new Runnable() {
                @Override public void run() { downloadOne(e); }
            });
        }
    }

    private void downloadAll() {
        final List<String> servers = readServers();
        if (servers.isEmpty()) {
            log("No servers configured");
            return;
        }
        downloadActive.set(true);
        log("Download all started: " + servers.size() + " server(s)");
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                downloadAllButton.setEnabled(false);
                stopButton.setEnabled(true);
                progressBar.setIndeterminate(true);
                progressBar.setVisible(true);
            }
        });

        final List<Future<?>> futures = new ArrayList<Future<?>>();
        for (final String server : servers) {
            futures.add(executor.submit(new Runnable() {
                @Override public void run() {
                    try { downloadAllFromServer(server); }
                    catch (Throwable t) { log("Error downloading from " + server + ": " + t.getMessage()); }
                }
            }));
        }

        executor.submit(new Runnable() {
            @Override public void run() {
                for (Future<?> f : futures) {
                    try { f.get(); } catch (Exception e) { /* ignore */ }
                }
                downloadActive.set(false);
                SwingUtilities.invokeLater(new Runnable() {
                    @Override public void run() {
                        downloadAllButton.setEnabled(true);
                        stopButton.setEnabled(false);
                        progressBar.setVisible(false);
                        progressBar.setIndeterminate(false);
                        log("Download all complete: " + downloadedCount.get() + " file(s) downloaded");
                    }
                });
            }
        });
    }

    private void downloadAllFromServer(String server) {
        final String baseUrl = normalizeServer(server);
        String filesListContent = httpGet(baseUrl + "files.txt");
        if (filesListContent == null) {
            log("[" + server + "] failed to fetch files.txt");
            return;
        }
        String[] lines = filesListContent.split("\\r?\\n");
        int downloaded = 0, skipped = 0, failed = 0;

        for (String line : lines) {
            if (!downloadActive.get()) break;
            String[] parsed = parseFileLine(line);
            if (parsed == null) continue;
            String hash = parsed[0];
            String ext  = parsed[1];

            // Fetch info first so we know the canonical extension.
            File localInfo = new File(INFO_DIR, hash + ".json");
            String infoContent = null;
            Map<String, String> fields = null;

            if (localInfo.exists()) {
                infoContent = readFile(localInfo);
                if (infoContent != null) fields = parseJson(infoContent);
            }
            if (fields == null || fields.isEmpty()) {
                infoContent = httpGet(baseUrl + "info/" + hash + ".json");
                if (infoContent != null) {
                    FileWriter w = null;
                    try {
                        w = new FileWriter(localInfo);
                        w.write(infoContent);
                    } catch (IOException e) {
                        log("Error saving info: " + e.getMessage());
                    } finally {
                        if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
                    }
                    fields = parseJson(infoContent);
                }
            }
            if (fields == null || fields.isEmpty()) { failed++; continue; }

            String jsonExt = fields.get("extension");
            if (jsonExt == null || jsonExt.isEmpty()) jsonExt = ext;

            File localBinary = new File(FILES_DIR, hash + "." + jsonExt);
            if (localBinary.exists() && localInfo.exists()) { skipped++; continue; }

            if (!httpDownload(baseUrl + "files/" + hash + "." + jsonExt, localBinary)) {
                failed++;
                log("[" + server + "] failed to download binary: " + hash + "." + jsonExt);
                continue;
            }
            if (!verifyHash(localBinary, hash)) {
                log("[" + server + "] hash mismatch: " + hash + "." + jsonExt);
                localBinary.delete();
                failed++;
                continue;
            }
            writeLogFile(server, hash, jsonExt, fields);
            addToLocalFilesList(hash + "." + jsonExt);
            downloaded++;
            downloadedCount.incrementAndGet();
            log("[" + server + "] downloaded: " + hash + "." + jsonExt);
        }
        log("[" + server + "] summary: " + downloaded + " downloaded, "
                + skipped + " skipped, " + failed + " failed");
    }

    private void downloadLinkEntry(FileEntry entry) {
        if (entry.isLocal) {
            log("Already downloaded: " + entry.getKey());
            return;
        }

        final String hash = entry.hash;
        final String ext  = entry.extension;

        updateEntryStatus(entry, "Downloading...");

        File localInfo = new File(INFO_DIR, hash + ".json");
        String content = readFile(localInfo);
        if (content == null) {
            updateEntryStatus(entry, "Failed (info)");
            log("Failed to read info JSON for link: " + hash);
            return;
        }

        Map<String, String> fields = parseJson(content);
        populateEntry(entry, fields);

        File finalFile = new File(FILES_DIR, hash + "." + ext);
        if (finalFile.exists()) {
            entry.isLocal = true;
            updateEntryStatus(entry, "Downloaded");
            log("Already on disk: " + entry.getKey());
            return;
        }

        File parent = finalFile.getParentFile();
        if (parent != null) parent.mkdirs();

        if ("txt".equalsIgnoreCase(ext)) {
            // Raw text link: materialise the description into files/<hash>.txt
            String line = fields.get("description");
            if (line == null) line = "";
            FileWriter w = null;
            try {
                w = new FileWriter(finalFile, false);
                w.write(line);
            } catch (IOException e) {
                updateEntryStatus(entry, "Failed (write)");
                log("Failed to save raw link: " + e.getMessage());
                return;
            } finally {
                if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
            }
        } else {
            // URL link: fetch the body from the recorded URL
            String urlStr = fields.get("url");
            if (urlStr == null || urlStr.isEmpty()) {
                urlStr = fields.get("description");
            }
            if (urlStr == null || urlStr.isEmpty()) {
                updateEntryStatus(entry, "Failed (no URL)");
                log("No URL recorded for link: " + hash);
                return;
            }

            String tmpName = ".tmp_link_dl_" + System.nanoTime() + "_" + new Random().nextInt(1000000);
            File tempFile = new File(FILES_DIR, tmpName);
            try {
                if (!httpDownload(urlStr, tempFile)) {
                    updateEntryStatus(entry, "Failed (download)");
                    log("Failed to download link URL: " + urlStr);
                    return;
                }
                if (!tempFile.renameTo(finalFile)) {
                    try {
                        copyFile(tempFile, finalFile);
                    } catch (IOException e) {
                        updateEntryStatus(entry, "Failed (save)");
                        log("Failed to save link file: " + e.getMessage());
                        return;
                    }
                }
            } finally {
                if (tempFile.exists()) tempFile.delete();
            }
        }

        // Refresh info JSON with actual on-disk size now that we have the binary.
        if (finalFile.exists() && content.indexOf("\"size\"") >= 0) {
            Map<String, Object> data = new LinkedHashMap<String, Object>();
            for (Map.Entry<String, String> e : fields.entrySet()) {
                data.put(e.getKey(), e.getValue());
            }
            data.put("size", String.valueOf(finalFile.length()));
            writeInfoJson(localInfo, data);
        }

        entry.isLocal = true;
        updateEntryStatus(entry, "Downloaded");
        addToLocalFilesList(hash + "." + ext);
        downloadedCount.incrementAndGet();
        log("Link downloaded: " + hash + "." + ext);
    }

    private void downloadOne(FileEntry entry) {
        if (entry.isLocal) {
            log("Already downloaded: " + entry.getKey());
            return;
        }

        // Link entries (from links.txt) don't live on the meshare server;
        // they need a different download path.
        if (entry.isLink) {
            downloadLinkEntry(entry);
            return;
        }

        final String baseUrl = normalizeServer(entry.server);
        final String hash    = entry.hash;

        updateEntryStatus(entry, "Downloading...");

        File localInfo = new File(INFO_DIR, hash + ".json");
        Map<String, String> fields = null;
        String infoContent = null;

        if (localInfo.exists()) {
            infoContent = readFile(localInfo);
            if (infoContent != null) fields = parseJson(infoContent);
        }
        if (fields == null || fields.isEmpty()) {
            infoContent = httpGet(baseUrl + "info/" + hash + ".json");
            if (infoContent != null) {
                FileWriter w = null;
                try {
                    w = new FileWriter(localInfo);
                    w.write(infoContent);
                } catch (IOException e) {
                    log("Error saving info: " + e.getMessage());
                } finally {
                    if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
                }
                fields = parseJson(infoContent);
            }
        }
        if (fields == null || fields.isEmpty()) {
            updateEntryStatus(entry, "Failed (info)");
            log("Failed to get info for " + hash);
            return;
        }
        populateEntry(entry, fields);

        String jsonExt = fields.get("extension");
        if (jsonExt == null || jsonExt.isEmpty()) jsonExt = entry.extension;

        File localBinary = new File(FILES_DIR, hash + "." + jsonExt);
        if (!localBinary.exists()) {
            if (!httpDownload(baseUrl + "files/" + hash + "." + jsonExt, localBinary)) {
                updateEntryStatus(entry, "Failed (binary)");
                log("Failed to download binary: " + hash + "." + jsonExt);
                return;
            }
            if (!verifyHash(localBinary, hash)) {
                log("Hash mismatch: " + hash + "." + jsonExt);
                localBinary.delete();
                updateEntryStatus(entry, "Hash mismatch");
                return;
            }
        }

        entry.isLocal = true;
        updateEntryStatus(entry, "Downloaded");
        writeLogFile(entry.server, hash, jsonExt, fields);
        addToLocalFilesList(hash + "." + jsonExt);
        downloadedCount.incrementAndGet();
        log("Downloaded: " + hash + "." + jsonExt);
    }

    private void updateEntryStatus(final FileEntry entry, final String status) {
        entry.status = status;
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                int modelRow = currentResults.indexOf(entry);
                if (modelRow >= 0 && modelRow < tableModel.getRowCount()) {
                    tableModel.setValueAt(status, modelRow, 6);
                    resultTable.repaint();
                }
            }
        });
    }

    // ===================================================================
    //                          HTTP
    // ===================================================================
    private String httpGet(String urlStr) {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(urlStr);
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
            conn.setReadTimeout(READ_TIMEOUT_MS);
            conn.setRequestProperty("User-Agent", "Meshare/1.0");
            conn.setRequestProperty("Accept", "*/*");
            conn.setInstanceFollowRedirects(true);

            int code = conn.getResponseCode();
            if (code >= 200 && code < 300) {
                InputStream is = conn.getInputStream();
                try {
                    return readStream(is);
                } finally {
                    try { is.close(); } catch (IOException e) { /* ignore */ }
                }
            } else {
                log("HTTP " + code + " for " + urlStr);
                return null;
            }
        } catch (Exception e) {
            log("GET failed: " + urlStr + " - " + e.getMessage());
            return null;
        } finally {
            if (conn != null) conn.disconnect();
        }
    }

    private boolean httpDownload(String urlStr, File dest) {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(urlStr);
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
            conn.setReadTimeout(READ_TIMEOUT_MS);
            conn.setRequestProperty("User-Agent", "Meshare/1.0");
            conn.setInstanceFollowRedirects(true);

            int code = conn.getResponseCode();
            if (code < 200 || code >= 300) {
                log("HTTP " + code + " for " + urlStr);
                return false;
            }
            File parent = dest.getParentFile();
            if (parent != null) parent.mkdirs();
            InputStream is = conn.getInputStream();
            FileOutputStream fos = new FileOutputStream(dest);
            try {
                byte[] buf = new byte[16 * 1024];
                int n;
                while ((n = is.read(buf)) >= 0) fos.write(buf, 0, n);
            } finally {
                try { is.close(); } catch (IOException e) { /* ignore */ }
                try { fos.close(); } catch (IOException e) { /* ignore */ }
            }
            return true;
        } catch (Exception e) {
            log("Download failed: " + urlStr + " - " + e.getMessage());
            return false;
        } finally {
            if (conn != null) conn.disconnect();
        }
    }

    private static String readStream(InputStream is) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        byte[] buf = new byte[16 * 1024];
        int n;
        while ((n = is.read(buf)) >= 0) baos.write(buf, 0, n);
        return new String(baos.toByteArray(), StandardCharsets.UTF_8);
    }

    private static String readFile(File f) {
        if (!f.exists() || !f.isFile()) return null;
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(f);
            return readStream(fis);
        } catch (IOException e) {
            return null;
        } finally {
            if (fis != null) try { fis.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    private static void copyFile(File src, File dst) throws IOException {
        FileInputStream fis = null;
        FileOutputStream fos = null;
        try {
            fis = new FileInputStream(src);
            fos = new FileOutputStream(dst);
            byte[] buf = new byte[16 * 1024];
            int n;
            while ((n = fis.read(buf)) >= 0) fos.write(buf, 0, n);
        } finally {
            if (fis != null) try { fis.close(); } catch (IOException e) { /* ignore */ }
            if (fos != null) try { fos.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    // ===================================================================
    //                          hash & log
    // ===================================================================
    private boolean verifyHash(File file, String expectedHash) {
        String actual = computeSha256OfFile(file);
        if (actual == null) {
            log("Hash verify error for " + file.getName());
            return false;
        }
        return actual.equalsIgnoreCase(expectedHash);
    }

    private static String computeSha256OfFile(File file) {
        FileInputStream fis = null;
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            fis = new FileInputStream(file);
            byte[] buf = new byte[16 * 1024];
            int n;
            while ((n = fis.read(buf)) >= 0) md.update(buf, 0, n);
            byte[] digest = md.digest();
            StringBuilder hex = new StringBuilder(64);
            for (byte b : digest) hex.append(String.format("%02x", b));
            return hex.toString();
        } catch (Exception e) {
            return null;
        } finally {
            if (fis != null) try { fis.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    private static String computeSha256OfBytes(byte[] data) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(data);
            byte[] digest = md.digest();
            StringBuilder hex = new StringBuilder(64);
            for (byte b : digest) hex.append(String.format("%02x", b));
            return hex.toString();
        } catch (Exception e) {
            return null;
        }
    }

    private void writeLogFile(String server, String hash, String ext, Map<String, String> fields) {
        try {
            String filename = fields != null ? fields.get("filename") : null;
            String size     = fields != null ? fields.get("size")     : null;
            if (filename == null) filename = "";
            if (size     == null) size     = "";

            Map<String, Object> data = new LinkedHashMap<String, Object>();
            data.put("filename",  filename);
            data.put("hash",      hash);
            data.put("extension", ext);
            data.put("size",      size);
            data.put("server",    server);
            data.put("date",      new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX").format(new Date()));

            String logName = sanitizeFilename(filename);
            if (logName.isEmpty()) {
                logName = hash;
            } else {
                logName = logName + "_" + hash.substring(0, Math.min(8, hash.length()));
            }
            File logFile = new File(LOG_DIR, logName + ".log.json");
            FileWriter w = null;
            try {
                w = new FileWriter(logFile);
                w.write(toJsonObject(data));
            } finally {
                if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
            }
        } catch (IOException e) {
            log("Error writing log: " + e.getMessage());
        }
    }

    private void writeInfoJson(File infoFile, Map<String, Object> data) {
        FileWriter w = null;
        try {
            File parent = infoFile.getParentFile();
            if (parent != null) parent.mkdirs();
            w = new FileWriter(infoFile);
            w.write(toJsonObject(data));
        } catch (IOException e) {
            log("Error writing info JSON: " + e.getMessage());
        } finally {
            if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    private void addToLocalFilesList(String filename) {
        File f = new File(LOCAL_FILES_LIST);
        Set<String> existing = new HashSet<String>();
        if (f.exists()) {
            BufferedReader r = null;
            try {
                r = new BufferedReader(new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8));
                String line;
                while ((line = r.readLine()) != null) {
                    String t = line.trim();
                    if (!t.isEmpty()) existing.add(t);
                }
            } catch (IOException e) { /* ignore */ }
            finally {
                if (r != null) try { r.close(); } catch (IOException e) { /* ignore */ }
            }
        }
        if (existing.add(filename)) {
            FileWriter w = null;
            try {
                w = new FileWriter(f, true);
                w.write(filename + "\n");
            } catch (IOException e) {
                log("Error updating files.txt: " + e.getMessage());
            } finally {
                if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
            }
        }
    }

    // ===================================================================
    //                          JSON
    // ===================================================================
    public static Map<String, String> parseJson(String text) {
        Map<String, String> result = new LinkedHashMap<String, String>();
        if (text == null) return result;
        int i = skipWs(text, 0);
        if (i >= text.length() || text.charAt(i) != '{') return result;
        i++;
        i = skipWs(text, i);
        if (i < text.length() && text.charAt(i) == '}') return result;
        while (i < text.length()) {
            i = skipWs(text, i);
            if (i >= text.length() || text.charAt(i) != '"') break;
            Object[] keyRes = parseString(text, i);
            if (keyRes == null) break;
            String key = (String) keyRes[0];
            i = ((Integer) keyRes[1]).intValue();
            i = skipWs(text, i);
            if (i >= text.length() || text.charAt(i) != ':') break;
            i++;
            i = skipWs(text, i);
            String value;
            if (i < text.length() && text.charAt(i) == '"') {
                Object[] strRes = parseString(text, i);
                if (strRes == null) break;
                value = (String) strRes[0];
                i = ((Integer) strRes[1]).intValue();
            } else if (i < text.length() && text.charAt(i) == '{') {
                // skip nested object
                int start = i;
                int depth = 0;
                while (i < text.length()) {
                    char c = text.charAt(i);
                    if (c == '"') {
                        Object[] sres = parseString(text, i);
                        if (sres == null) break;
                        i = ((Integer) sres[1]).intValue();
                        continue;
                    }
                    if (c == '{') depth++;
                    else if (c == '}') { depth--; if (depth == 0) { i++; break; } }
                    i++;
                }
                value = text.substring(start, i);
            } else if (i < text.length() && text.charAt(i) == '[') {
                int start = i;
                int depth = 0;
                while (i < text.length()) {
                    char c = text.charAt(i);
                    if (c == '"') {
                        Object[] sres = parseString(text, i);
                        if (sres == null) break;
                        i = ((Integer) sres[1]).intValue();
                        continue;
                    }
                    if (c == '[') depth++;
                    else if (c == ']') { depth--; if (depth == 0) { i++; break; } }
                    i++;
                }
                value = text.substring(start, i);
            } else {
                int start = i;
                while (i < text.length()) {
                    char c = text.charAt(i);
                    if (c == ',' || c == '}' || Character.isWhitespace(c)) break;
                    i++;
                }
                value = text.substring(start, i);
            }
            if (value.length() > MAX_FIELD_LENGTH) {
                value = value.substring(0, MAX_FIELD_LENGTH) + "...";
            }
            result.put(key, value);
            i = skipWs(text, i);
            if (i < text.length() && text.charAt(i) == ',') {
                i++;
            } else if (i < text.length() && text.charAt(i) == '}') {
                break;
            } else {
                break;
            }
        }
        return result;
    }

    private static int skipWs(String text, int i) {
        while (i < text.length() && Character.isWhitespace(text.charAt(i))) i++;
        return i;
    }

    /** Returns Object[]{value, Integer(nextIndex)} on success, or null on failure. */
    private static Object[] parseString(String text, int i) {
        if (i >= text.length() || text.charAt(i) != '"') return null;
        i++;
        StringBuilder sb = new StringBuilder();
        while (i < text.length()) {
            char c = text.charAt(i);
            if (c == '"') {
                return new Object[] { sb.toString(), Integer.valueOf(i + 1) };
            } else if (c == '\\') {
                if (i + 1 >= text.length()) break;
                char n = text.charAt(i + 1);
                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 + 5 < text.length()) {
                            try {
                                int code = Integer.parseInt(text.substring(i + 2, i + 6), 16);
                                sb.append((char) code);
                                i += 4;
                            } catch (Exception e) { sb.append(n); }
                        } else {
                            sb.append(n);
                        }
                        break;
                    default: sb.append(n);
                }
                i += 2;
            } else {
                sb.append(c);
                i++;
            }
        }
        return null;
    }

    public static String toJsonObject(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(escapeJsonString(e.getKey())).append('"').append(':');
            Object v = e.getValue();
            if (v == null) {
                sb.append("null");
            } else if (v instanceof Boolean) {
                sb.append(((Boolean) v).booleanValue() ? "true" : "false");
            } else if (v instanceof Number) {
                sb.append(v.toString());
            } else {
                sb.append('"').append(escapeJsonString(v.toString())).append('"');
            }
        }
        sb.append('}');
        return sb.toString();
    }

    public static String escapeJsonString(String s) {
        if (s == null) return "";
        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();
    }

    // ===================================================================
    //                          details / view
    // ===================================================================
    private void updateDetails() {
        int row = resultTable.getSelectedRow();
        if (row < 0) {
            detailsArea.setText("Select a row to see details.");
            return;
        }
        int modelRow = resultTable.convertRowIndexToModel(row);
        if (modelRow < 0 || modelRow >= currentResults.size()) {
            detailsArea.setText("");
            return;
        }
        FileEntry entry = currentResults.get(modelRow);
        StringBuilder sb = new StringBuilder();
        sb.append("Server:           ").append(safe(entry.server)).append('\n');
        sb.append("Hash:             ").append(safe(entry.hash)).append('\n');
        sb.append("Extension:        ").append(safe(entry.extension)).append('\n');
        sb.append("Filename:         ").append(safe(entry.filename)).append('\n');
        sb.append("Size:             ").append(safe(entry.size)).append('\n');
        sb.append("Description:      ").append(safe(entry.description)).append('\n');
        sb.append("Date:             ").append(safe(entry.date)).append('\n');
        sb.append("Public key:       ").append(safe(entry.publicKey)).append('\n');
        sb.append("Server pub key:   ").append(safe(entry.serverPublicKey)).append('\n');
        sb.append("Node info:        ").append(safe(entry.nodeInfo)).append('\n');
        sb.append("Status:           ").append(safe(entry.status)).append('\n');
        sb.append("Local binary:     ").append(new File(FILES_DIR, entry.hash + "." + entry.extension).getAbsolutePath()).append('\n');
        sb.append("Local info:       ").append(new File(INFO_DIR,  entry.hash + ".json").getAbsolutePath()).append('\n');
        if (entry.rawJson != null && !entry.rawJson.isEmpty()) {
            sb.append("--- Raw JSON ---\n");
            sb.append(entry.rawJson);
        }
        detailsArea.setText(sb.toString());
        detailsArea.setCaretPosition(0);
    }

    private void viewSelectedJson() {
        int row = resultTable.getSelectedRow();
        if (row < 0) {
            JOptionPane.showMessageDialog(this, "Select a row first");
            return;
        }
        int modelRow = resultTable.convertRowIndexToModel(row);
        if (modelRow < 0 || modelRow >= currentResults.size()) return;
        FileEntry entry = currentResults.get(modelRow);
        String text = entry.rawJson;
        if (text == null || text.isEmpty()) {
            File localInfo = new File(INFO_DIR, entry.hash + ".json");
            text = readFile(localInfo);
        }
        if (text == null) text = "(no JSON available)";

        JTextArea ta = new JTextArea(20, 70);
        ta.setText(text);
        ta.setEditable(false);
        ta.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
        JScrollPane sp = new JScrollPane(ta);
        JOptionPane.showMessageDialog(this, sp,
                "JSON: " + entry.getDisplayFilename(),
                JOptionPane.INFORMATION_MESSAGE);
    }

    private void openFilesFolder() {
        try {
            File f = new File(FILES_DIR).getAbsoluteFile();
            if (!f.exists()) f.mkdirs();
            Desktop.getDesktop().open(f);
        } catch (Exception e) {
            log("Cannot open folder: " + e.getMessage());
        }
    }

    /**
     * Open the currently selected entry in the default browser.
     *
     *   - Link entry (URL or magnet): open the contents of the
     *     "description" field directly. For URL links the description IS
     *     the URL. For magnet links `Desktop.browse()` will hand the
     *     magnet URI off to the OS-registered torrent handler (browser or
     *     otherwise).
     *   - Regular file entry that is already downloaded: open the local
     *     file via a `file://` URI in the browser.
     *   - Otherwise: nothing to open yet, log a hint.
     */
    private void openInBrowser() {
        int row = resultTable.getSelectedRow();
        if (row < 0) {
            log("No row selected");
            return;
        }
        int modelRow = resultTable.convertRowIndexToModel(row);
        if (modelRow < 0 || modelRow >= currentResults.size()) return;
        FileEntry entry = currentResults.get(modelRow);
        if (entry == null) return;

        // ----- link entry: open the description verbatim in the browser -----
        if (entry.isLink) {
            String urlOrMagnet = entry.description;
            if (urlOrMagnet == null || urlOrMagnet.isEmpty()) {
                log("No description/URL to open for: " + entry.getKey());
                return;
            }
            // Magnet URIs aren't http/https, so Desktop.browse() usually
            // refuses to launch anything for them. Try a real browser
            // executable first, then fall back to the OS default web
            // navigation (xdg-open / open / start).
            if (urlOrMagnet.toLowerCase().startsWith("magnet:")) {
                openMagnetLink(urlOrMagnet);
                return;
            }
            try {
                Desktop.getDesktop().browse(new URI(urlOrMagnet));
                log("Opened link in browser: " + truncate(urlOrMagnet, 80));
            } catch (Exception e) {
                log("Cannot open link in browser: " + e.getMessage());
            }
            return;
        }

        // ----- regular file: open the on-disk copy in the browser -----
        if (entry.isLocal) {
            File f = new File(FILES_DIR, entry.hash + "." + entry.extension);
            if (!f.exists()) {
                log("File not present locally: " + f.getName());
                return;
            }
            try {
                Desktop.getDesktop().browse(f.toURI());
                log("Opened file in browser: " + f.getName());
            } catch (Exception e) {
                log("Cannot open file in browser: " + e.getMessage());
            }
            return;
        }

        log("Entry not downloaded yet \u2014 use Download first: " + entry.getKey());
    }

    /**
     * Open a magnet: URI by spawning a real browser executable. Most
     * platforms (and Java's Desktop.browse()) refuse to navigate to
     * non-http(s) URIs, so we invoke the browser binary ourselves with
     * the magnet URI as an argument. The browser is then expected to
     * hand the URI off to its registered magnet handler (e.g. a torrent
     * client extension).
     *
     * Tries Firefox first, then Chrome/Chromium, then the OS default
     * web navigation (xdg-open on Linux, `open` on macOS, `start` on
     * Windows). Stops at the first candidate that exec()s successfully.
     */
    private void openMagnetLink(String magnetUrl) {
        String os = System.getProperty("os.name", "").toLowerCase();
        boolean isWindows = os.contains("win");
        boolean isMac     = os.contains("mac") || os.contains("darwin");

        String[] browserNames;
        String[] browserCmds;
        if (isWindows) {
            String pf   = System.getenv("ProgramFiles");
            if (pf == null)   pf   = "C:\\Program Files";
            String pf86 = System.getenv("ProgramFiles(x86)");
            if (pf86 == null) pf86 = "C:\\Program Files (x86)";
            String la   = System.getenv("LocalAppData");
            if (la == null)   la   = "";
            browserNames = new String[] {
                "Firefox", "Firefox",
                "Chrome",  "Chrome",  "Chrome",
                "default"
            };
            browserCmds  = new String[] {
                pf   + "\\Mozilla Firefox\\firefox.exe",
                pf86 + "\\Mozilla Firefox\\firefox.exe",
                pf   + "\\Google\\Chrome\\Application\\chrome.exe",
                pf86 + "\\Google\\Chrome\\Application\\chrome.exe",
                la   + "\\Google\\Chrome\\Application\\chrome.exe",
                "" // placeholder; the default branch is handled separately below
            };
        } else if (isMac) {
            browserNames = new String[] {
                "Firefox", "Chrome",
                "default"
            };
            browserCmds  = new String[] {
                "/Applications/Firefox.app/Contents/MacOS/firefox",
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                "" // placeholder
            };
        } else {
            // Linux and other Unix-likes
            browserNames = new String[] {
                "Firefox", "Firefox",
                "Chrome",  "Chrome",  "Chrome",  "Chrome",
                "default"
            };
            browserCmds  = new String[] {
                "firefox",
                "firefox-esr",
                "google-chrome",
                "google-chrome-stable",
                "chromium",
                "chromium-browser",
                "" // placeholder
            };
        }

        for (int i = 0; i < browserNames.length; i++) {
            String label = browserNames[i];
            if ("default".equals(label)) continue;
            String cmd = browserCmds[i];
            try {
                Process p = Runtime.getRuntime().exec(new String[] { cmd, magnetUrl });
                if (p != null) {
                    log("Opened magnet link via " + label + ": " + truncate(magnetUrl, 80));
                    return;
                }
            } catch (Exception e) {
                // browser not installed at this path / not in PATH; try the next one
            }
        }

        // No browser above was launchable; fall back to the OS default
        // web navigation, which on every modern desktop is the system
        // handler for magnet:// URIs (usually a torrent client).
        try {
            Process p;
            if (isWindows) {
                p = Runtime.getRuntime().exec(new String[] { "cmd", "/c", "start", "", magnetUrl });
            } else if (isMac) {
                p = Runtime.getRuntime().exec(new String[] { "open", magnetUrl });
            } else {
                p = Runtime.getRuntime().exec(new String[] { "xdg-open", magnetUrl });
            }
            if (p != null) {
                log("Opened magnet link via OS default: " + truncate(magnetUrl, 80));
                return;
            }
        } catch (Exception e) {
            log("Cannot open magnet link (tried Firefox/Chrome/default): " + e.getMessage());
        }
    }

    private void revealInFolder() {
        int row = resultTable.getSelectedRow();
        if (row < 0) return;
        int modelRow = resultTable.convertRowIndexToModel(row);
        if (modelRow < 0 || modelRow >= currentResults.size()) return;
        FileEntry entry = currentResults.get(modelRow);
        File f = new File(FILES_DIR, entry.hash + "." + entry.extension);
        if (!f.exists()) {
            log("File not present locally: " + f.getName());
            return;
        }
        try {
            Desktop.getDesktop().open(f.getParentFile());
        } catch (Exception e) {
            log("Cannot open: " + e.getMessage());
        }
    }

    // ===================================================================
    //                          logging / utils
    // ===================================================================
    private void log(String message) {
        final String line = "[" +
                new SimpleDateFormat("HH:mm:ss").format(new Date()) + "] " + message;
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                logArea.append(line + "\n");
                // Cap the log area
                int len = logArea.getDocument().getLength();
                if (len > 200000) {
                    String t = logArea.getText();
                    int cut = t.length() / 2;
                    int nl = t.indexOf('\n', cut);
                    if (nl > 0) logArea.setText(t.substring(nl + 1));
                }
                String full = logArea.getText();
                int lines = 0;
                for (int i = 0; i < full.length(); i++) if (full.charAt(i) == '\n') lines++;
                if (lines > MAX_LOG_LINES) {
                    int cut = full.length() / 2;
                    int nl = full.indexOf('\n', cut);
                    if (nl > 0) logArea.setText(full.substring(nl + 1));
                }
                logArea.setCaretPosition(logArea.getDocument().getLength());
            }
        });
    }

    private void setStatus(String text) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() { statusLabel.setText(text); }
        });
    }

    private static String truncate(String s, int max) {
        if (s == null) return "";
        if (s.length() <= max) return s;
        return s.substring(0, Math.max(0, max - 3)) + "...";
    }

    private static String safe(String s) { return s == null ? "" : s; }

    private static String sanitizeFilename(String name) {
        if (name == null) return "";
        String n = name.replaceAll("[\\\\/:*?\"<>|]", "_").trim();
        if (n.length() > 80) n = n.substring(0, 80);
        return n;
    }

    /** Parse "<hash>.<ext>" where hash must be a 64-char hex SHA-256. */
    private static String[] parseFileLine(String line) {
        if (line == null) return null;
        line = line.trim();
        if (line.isEmpty()) return null;
        int dot = line.lastIndexOf('.');
        if (dot <= 0 || dot >= line.length() - 1) return null;
        String hash = line.substring(0, dot);
        String ext  = line.substring(dot + 1);
        if (!isValidSha256(hash)) return null;
        if (ext.isEmpty()) return null;
        if (ext.indexOf('/') >= 0 || ext.indexOf('\\') >= 0) return null;
        if (ext.length() > 16) return null;
        return new String[] { hash, ext };
    }

    private static boolean isValidSha256(String s) {
        if (s == null || s.length() != 64) return false;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            boolean ok = (c >= '0' && c <= '9')
                      || (c >= 'a' && c <= 'f')
                      || (c >= 'A' && c <= 'F');
            if (!ok) return false;
        }
        return true;
    }

    private void populateEntry(FileEntry entry, Map<String, String> fields) {
        entry.rawFields.putAll(fields);
        entry.filename        = fields.getOrDefault("filename", "");
        entry.size            = fields.getOrDefault("size", "");
        String jsonExt        = fields.get("extension");
        if (jsonExt != null && !jsonExt.isEmpty()) entry.extension = jsonExt;
        entry.publicKey       = fields.getOrDefault("public_key", "");
        entry.serverPublicKey = fields.getOrDefault("server_public_key", "");
        entry.nodeInfo        = fields.getOrDefault("node_info", "");
        entry.description     = fields.getOrDefault("description", "");
        entry.date            = fields.getOrDefault("date", "");
    }

    // ===================================================================
    //                          statistics
    // ===================================================================

    /**
     * Build the "Statistics" tab. It contains four sub-tables (2 x 2 grid):
     *   1. Servers with more files  (server, file count)        — desc
     *   2. File in majority of servers (hash.ext, filename, distinct-server count) — desc
     *   3. public_key values in majority of servers (value, distinct-server count) — desc
     *   4. server_public_key values in majority of servers (value, distinct-server count) — desc
     *
     * Every sub-table is capped at MAX_STATS rows. The statistics are
     * derived from the current `currentResults` list, i.e. the rows
     * currently visible in the Search tab (subject to MAX_RESULTS).
     */
    private JPanel buildStatisticsTab() {
        JPanel panel = new JPanel(new BorderLayout(6, 6));
        panel.setBorder(new EmptyBorder(8, 8, 8, 8));

        // ----- header -----
        JPanel headerPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2));
        JLabel titleLabel = new JLabel("Statistics");
        titleLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 13));
        headerPanel.add(titleLabel);
        JLabel hintLabel = new JLabel("(uses current search results; click Refresh to "
                + "scan all servers if none; max " + MAX_STATS + " rows per table)");
        hintLabel.setForeground(Color.GRAY);
        headerPanel.add(hintLabel);
        refreshStatsButton = new JButton("Refresh");
        headerPanel.add(refreshStatsButton);
        panel.add(headerPanel, BorderLayout.NORTH);

        // ----- 2x2 grid of sub-tables -----
        JPanel grid = new JPanel(new GridLayout(2, 2, 6, 6));

        // 1) Servers with more files
        serverFilesModel = new DefaultTableModel(
                new String[] { "Server", "File Count" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        serverFilesTable = new JTable(serverFilesModel);
        serverFilesTable.setAutoCreateRowSorter(true);
        serverFilesTable.setRowHeight(22);
        serverFilesTable.getTableHeader().setReorderingAllowed(false);
        serverFilesTable.getColumnModel().getColumn(0).setPreferredWidth(260);
        serverFilesTable.getColumnModel().getColumn(1).setPreferredWidth(90);
        grid.add(buildStatsSection("Servers with more files", serverFilesTable));

        // 2) File in majority of servers
        majorityFilesModel = new DefaultTableModel(
                new String[] { "File (hash.ext)", "Filename", "Server Count" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        majorityFilesTable = new JTable(majorityFilesModel);
        majorityFilesTable.setAutoCreateRowSorter(true);
        majorityFilesTable.setRowHeight(22);
        majorityFilesTable.getTableHeader().setReorderingAllowed(false);
        majorityFilesTable.getColumnModel().getColumn(0).setPreferredWidth(280);
        majorityFilesTable.getColumnModel().getColumn(1).setPreferredWidth(180);
        majorityFilesTable.getColumnModel().getColumn(2).setPreferredWidth(100);
        grid.add(buildStatsSection("File in majority of servers", majorityFilesTable));

        // 3) public_key in majority of servers
        publicKeyModel = new DefaultTableModel(
                new String[] { "public_key", "Server Count" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        publicKeyTable = new JTable(publicKeyModel);
        publicKeyTable.setAutoCreateRowSorter(true);
        publicKeyTable.setRowHeight(22);
        publicKeyTable.getTableHeader().setReorderingAllowed(false);
        publicKeyTable.getColumnModel().getColumn(0).setPreferredWidth(420);
        publicKeyTable.getColumnModel().getColumn(1).setPreferredWidth(100);
        publicKeyTable.setDefaultRenderer(Object.class, new DefaultTableCellRenderer() {
            private static final long serialVersionUID = 1L;
            @Override
            public Component getTableCellRendererComponent(JTable table, Object value,
                                                           boolean isSelected, boolean hasFocus,
                                                           int row, int column) {
                Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
                if (value != null) {
                    String s = value.toString();
                    if (s.length() > 64) {
                        setToolTipText(s);
                    } else {
                        setToolTipText(null);
                    }
                }
                return c;
            }
        });
        grid.add(buildStatsSection("public_key in majority of servers", publicKeyTable));

        // 4) server_public_key in majority of servers
        serverPublicKeyModel = new DefaultTableModel(
                new String[] { "server_public_key", "Server Count" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        serverPublicKeyTable = new JTable(serverPublicKeyModel);
        serverPublicKeyTable.setAutoCreateRowSorter(true);
        serverPublicKeyTable.setRowHeight(22);
        serverPublicKeyTable.getTableHeader().setReorderingAllowed(false);
        serverPublicKeyTable.getColumnModel().getColumn(0).setPreferredWidth(420);
        serverPublicKeyTable.getColumnModel().getColumn(1).setPreferredWidth(100);
        serverPublicKeyTable.setDefaultRenderer(Object.class, new DefaultTableCellRenderer() {
            private static final long serialVersionUID = 1L;
            @Override
            public Component getTableCellRendererComponent(JTable table, Object value,
                                                           boolean isSelected, boolean hasFocus,
                                                           int row, int column) {
                Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
                if (value != null) {
                    String s = value.toString();
                    if (s.length() > 64) {
                        setToolTipText(s);
                    } else {
                        setToolTipText(null);
                    }
                }
                return c;
            }
        });
        grid.add(buildStatsSection("server_public_key in majority of servers", serverPublicKeyTable));

        panel.add(grid, BorderLayout.CENTER);

        // ----- refresh button -----
        refreshStatsButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { refreshStatistics(); }
        });

        return panel;
    }

    /** Wrap a JTable in a TitledBorder scroll pane for the statistics grid. */
    private static JPanel buildStatsSection(String title, JTable table) {
        JPanel section = new JPanel(new BorderLayout());
        section.setBorder(new TitledBorder(title));
        JScrollPane sp = new JScrollPane(table);
        section.add(sp, BorderLayout.CENTER);
        return section;
    }

    /**
     * Recompute all four statistics tables. Safe to call from the EDT
     * only; called from the tab change listener, the Refresh button,
     * and finishSearch().
     *
     * If `currentResults` is non-empty we use it (fast path). Otherwise
     * we kick off a lightweight scan that fetches `files.txt` and
     * `public_key.txt` from every server in `servers.txt` and populates
     * the three tables that don't need the per-file info JSON. The
     * "public_key in majority of servers" table is left empty in scan
     * mode because per-file keys live in the info JSON, which we
     * deliberately avoid fetching here.
     */
    private void refreshStatistics() {
        if (serverFilesModel == null) return; // tab not built yet

        if (currentResults.isEmpty()) {
            scanServersForStats();
        } else {
            computeStatsFromSnapshot(new ArrayList<FileEntry>(currentResults));
        }
    }

    /**
     * Compute every statistics table from a snapshot of FileEntry rows
     * (typically the current search results). Must be called on the EDT.
     */
    private void computeStatsFromSnapshot(List<FileEntry> snapshot) {
        // 1) Servers with more files — count entries per server, desc.
        Map<String, Integer> serverFileCount = new LinkedHashMap<String, Integer>();
        for (FileEntry e : snapshot) {
            if (e == null || e.server == null || e.server.isEmpty()) continue;
            Integer c = serverFileCount.get(e.server);
            serverFileCount.put(e.server, c == null ? Integer.valueOf(1) : Integer.valueOf(c.intValue() + 1));
        }
        List<Map.Entry<String, Integer>> serverEntries =
                new ArrayList<Map.Entry<String, Integer>>(serverFileCount.entrySet());
        Collections.sort(serverEntries, new Comparator<Map.Entry<String, Integer>>() {
            @Override public int compare(Map.Entry<String, Integer> a, Map.Entry<String, Integer> b) {
                int diff = b.getValue().intValue() - a.getValue().intValue(); // desc
                if (diff != 0) return diff;
                return a.getKey().compareTo(b.getKey());
            }
        });
        serverFilesModel.setRowCount(0);
        int n1 = Math.min(serverEntries.size(), MAX_STATS);
        for (int i = 0; i < n1; i++) {
            Map.Entry<String, Integer> en = serverEntries.get(i);
            serverFilesModel.addRow(new Object[] { en.getKey(), en.getValue() });
        }

        // 2) File in majority of servers — group by hash.ext, count distinct
        //    servers per file, desc. We also remember the first non-empty
        //    filename we see for each file so the user can recognise it.
        Map<String, Set<String>> fileServers   = new LinkedHashMap<String, Set<String>>();
        Map<String, String>      fileFilenames = new LinkedHashMap<String, String>();
        for (FileEntry e : snapshot) {
            if (e == null || e.hash == null || e.hash.isEmpty()) continue;
            String key = e.hash + "." + (e.extension == null ? "" : e.extension);
            Set<String> servers = fileServers.get(key);
            if (servers == null) {
                servers = new HashSet<String>();
                fileServers.put(key, servers);
            }
            if (e.server != null) servers.add(e.server);
            if (!fileFilenames.containsKey(key) && e.filename != null && !e.filename.isEmpty()) {
                fileFilenames.put(key, e.filename);
            }
        }
        List<Map.Entry<String, Set<String>>> fileEntries =
                new ArrayList<Map.Entry<String, Set<String>>>(fileServers.entrySet());
        Collections.sort(fileEntries, new Comparator<Map.Entry<String, Set<String>>>() {
            @Override public int compare(Map.Entry<String, Set<String>> a, Map.Entry<String, Set<String>> b) {
                int diff = b.getValue().size() - a.getValue().size(); // desc
                if (diff != 0) return diff;
                return a.getKey().compareTo(b.getKey());
            }
        });
        majorityFilesModel.setRowCount(0);
        int n2 = Math.min(fileEntries.size(), MAX_STATS);
        for (int i = 0; i < n2; i++) {
            Map.Entry<String, Set<String>> en = fileEntries.get(i);
            String filename = fileFilenames.get(en.getKey());
            majorityFilesModel.addRow(new Object[] {
                    en.getKey(),
                    filename == null ? "" : filename,
                    Integer.valueOf(en.getValue().size())
            });
        }

        // 3) public_key in majority of servers
        fillKeyMajority(publicKeyModel, snapshot, true);
        // 4) server_public_key in majority of servers
        fillKeyMajority(serverPublicKeyModel, snapshot, false);
    }

    /**
     * Lightweight server scan used when the user clicks Refresh before
     * any search has run. For every server in `servers.txt` we fetch
     * `files.txt` (to count files and group by hash) and
     * `public_key.txt` (to populate the server_public_key table). No
     * info JSON, no binary downloads — this is the cheap path.
     *
     * The per-file `public_key` table is left empty because its values
     * live in the info JSON; once the user runs a real search it will
     * be populated.
     *
     * Guarded by `statsScanning` so a double-click on Refresh or an
     * overlapping scan can't start a second batch of HTTP requests.
     */
    private void scanServersForStats() {
        if (!statsScanning.compareAndSet(false, true)) {
            log("Stats scan already in progress");
            return;
        }

        final List<String> servers = readServers();
        if (servers.isEmpty()) {
            log("No servers configured (add URLs to servers.txt)");
            statsScanning.set(false);
            return;
        }

        setStatus("Scanning " + servers.size() + " server(s) for stats...");
        if (refreshStatsButton != null) refreshStatsButton.setEnabled(false);
        log("Stats scan started: " + servers.size() + " server(s)");

        // Thread-safe accumulators. Each value-set is itself a
        // concurrent set, so per-server workers can update without a
        // shared lock.
        final ConcurrentHashMap<String, AtomicInteger> serverFileCount =
                new ConcurrentHashMap<String, AtomicInteger>();
        final ConcurrentHashMap<String, Set<String>> fileServers =
                new ConcurrentHashMap<String, Set<String>>();
        final ConcurrentHashMap<String, Set<String>> spkServers =
                new ConcurrentHashMap<String, Set<String>>();

        final List<Future<?>> futures = new ArrayList<Future<?>>();
        for (final String server : servers) {
            futures.add(executor.submit(new Runnable() {
                @Override public void run() {
                    if (!statsScanning.get()) return; // user hit Stop
                    final String baseUrl = normalizeServer(server);

                    // Count files for this server and group by hash.ext.
                    int count = 0;
                    String content = httpGet(baseUrl + "files.txt");
                    if (content != null) {
                        String[] lines = content.split("\\r?\\n");
                        for (String line : lines) {
                            String[] parsed = parseFileLine(line);
                            if (parsed == null) continue;
                            count++;
                            String key = parsed[0] + "." + parsed[1];
                            Set<String> s = fileServers.get(key);
                            if (s == null) {
                                s = Collections.newSetFromMap(
                                        new ConcurrentHashMap<String, Boolean>());
                                Set<String> existing = fileServers.putIfAbsent(key, s);
                                if (existing != null) s = existing;
                            }
                            s.add(server);
                        }
                    }
                    serverFileCount.put(server, new AtomicInteger(count));

                    // server_public_key for this server.
                    if (!statsScanning.get()) return; // user hit Stop
                    String pk = httpGet(baseUrl + "public_key.txt");
                    if (pk != null) {
                        String trimmed = pk.trim();
                        if (!trimmed.isEmpty()) {
                            Set<String> s = spkServers.get(trimmed);
                            if (s == null) {
                                s = Collections.newSetFromMap(
                                        new ConcurrentHashMap<String, Boolean>());
                                Set<String> existing = spkServers.putIfAbsent(trimmed, s);
                                if (existing != null) s = existing;
                            }
                            s.add(server);
                        }
                    }
                }
            }));
        }

        executor.submit(new Runnable() {
            @Override public void run() {
                for (Future<?> f : futures) {
                    try { f.get(); } catch (Exception e) { /* ignore */ }
                }

                SwingUtilities.invokeLater(new Runnable() {
                    @Override public void run() {
                        // 1) Servers with more files
                        serverFilesModel.setRowCount(0);
                        List<Map.Entry<String, AtomicInteger>> serverEntries =
                                new ArrayList<Map.Entry<String, AtomicInteger>>(serverFileCount.entrySet());
                        Collections.sort(serverEntries, new Comparator<Map.Entry<String, AtomicInteger>>() {
                            @Override public int compare(Map.Entry<String, AtomicInteger> a,
                                                         Map.Entry<String, AtomicInteger> b) {
                                int diff = b.getValue().intValue() - a.getValue().intValue(); // desc
                                if (diff != 0) return diff;
                                return a.getKey().compareTo(b.getKey());
                            }
                        });
                        int n1 = Math.min(serverEntries.size(), MAX_STATS);
                        for (int i = 0; i < n1; i++) {
                            Map.Entry<String, AtomicInteger> en = serverEntries.get(i);
                            serverFilesModel.addRow(new Object[] {
                                    en.getKey(),
                                    Integer.valueOf(en.getValue().intValue())
                            });
                        }

                        // 2) File in majority of servers
                        majorityFilesModel.setRowCount(0);
                        List<Map.Entry<String, Set<String>>> fileEntries =
                                new ArrayList<Map.Entry<String, Set<String>>>(fileServers.entrySet());
                        Collections.sort(fileEntries, new Comparator<Map.Entry<String, Set<String>>>() {
                            @Override public int compare(Map.Entry<String, Set<String>> a,
                                                         Map.Entry<String, Set<String>> b) {
                                int diff = b.getValue().size() - a.getValue().size(); // desc
                                if (diff != 0) return diff;
                                return a.getKey().compareTo(b.getKey());
                            }
                        });
                        int n2 = Math.min(fileEntries.size(), MAX_STATS);
                        for (int i = 0; i < n2; i++) {
                            Map.Entry<String, Set<String>> en = fileEntries.get(i);
                            majorityFilesModel.addRow(new Object[] {
                                    en.getKey(),
                                    "",
                                    Integer.valueOf(en.getValue().size())
                            });
                        }

                        // 3) public_key per-file — not populated by a scan
                        //    (would need to fetch info JSON for every file).
                        //    Leave the table empty; a real search will fill it.
                        publicKeyModel.setRowCount(0);

                        // 4) server_public_key in majority of servers
                        serverPublicKeyModel.setRowCount(0);
                        List<Map.Entry<String, Set<String>>> spkEntries =
                                new ArrayList<Map.Entry<String, Set<String>>>(spkServers.entrySet());
                        Collections.sort(spkEntries, new Comparator<Map.Entry<String, Set<String>>>() {
                            @Override public int compare(Map.Entry<String, Set<String>> a,
                                                         Map.Entry<String, Set<String>> b) {
                                int diff = b.getValue().size() - a.getValue().size(); // desc
                                if (diff != 0) return diff;
                                return a.getKey().compareTo(b.getKey());
                            }
                        });
                        int n4 = Math.min(spkEntries.size(), MAX_STATS);
                        for (int i = 0; i < n4; i++) {
                            Map.Entry<String, Set<String>> en = spkEntries.get(i);
                            serverPublicKeyModel.addRow(new Object[] {
                                    en.getKey(),
                                    Integer.valueOf(en.getValue().size())
                            });
                        }

                        if (refreshStatsButton != null) refreshStatsButton.setEnabled(true);
                        statsScanning.set(false);
                        setStatus("Stats scan done: " + servers.size() + " server(s), "
                                + fileServers.size() + " unique file(s); "
                                + "(run a Search to populate public_key)");
                    }
                });
            }
        });
    }

    /**
     * Populate one of the "majority of servers" tables. For every
     * non-empty value of the chosen field across `snapshot`, count the
     * distinct servers carrying that value, then write the top
     * MAX_STATS rows sorted by count descending.
     *
     * @param model     the table model to refresh
     * @param snapshot  current results
     * @param usePubKey true → use entry.publicKey, false → entry.serverPublicKey
     */
    private void fillKeyMajority(DefaultTableModel model,
                                 List<FileEntry> snapshot,
                                 boolean usePubKey) {
        Map<String, Set<String>> keyServers = new LinkedHashMap<String, Set<String>>();
        for (FileEntry e : snapshot) {
            if (e == null) continue;
            String val = usePubKey ? e.publicKey : e.serverPublicKey;
            if (val == null) continue;
            String trimmed = val.trim();
            if (trimmed.isEmpty()) continue; // skip empty values
            Set<String> servers = keyServers.get(trimmed);
            if (servers == null) {
                servers = new HashSet<String>();
                keyServers.put(trimmed, servers);
            }
            if (e.server != null) servers.add(e.server);
        }
        List<Map.Entry<String, Set<String>>> entries =
                new ArrayList<Map.Entry<String, Set<String>>>(keyServers.entrySet());
        Collections.sort(entries, new Comparator<Map.Entry<String, Set<String>>>() {
            @Override public int compare(Map.Entry<String, Set<String>> a, Map.Entry<String, Set<String>> b) {
                int diff = b.getValue().size() - a.getValue().size(); // desc
                if (diff != 0) return diff;
                return a.getKey().compareTo(b.getKey());
            }
        });
        model.setRowCount(0);
        int n = Math.min(entries.size(), MAX_STATS);
        for (int i = 0; i < n; i++) {
            Map.Entry<String, Set<String>> en = entries.get(i);
            // Show the value as-is; the table renderer exposes a tooltip
            // with the full string for long keys.
            model.addRow(new Object[] {
                    en.getKey(),
                    Integer.valueOf(en.getValue().size())
            });
        }
    }

    // ===================================================================
    //                    "Server" tab UI
    // ===================================================================
    /**
     * Builds the tab that lets the user host this machine as a Meshare
     * peer: an embedded HTTP/HTTPS server exposing files.txt, links.txt,
     * public_key.txt, files/ and info/ from this instance's own working
     * directory — the same layout every Meshare server is expected to
     * serve. Whenever someone connects to it, we check whether *they* are
     * also running a valid Meshare server (see {@link #checkAndRegisterPeer}).
     */
    private JPanel buildServerTab() {
        JPanel panel = new JPanel(new BorderLayout(10, 10));
        panel.setBorder(new EmptyBorder(12, 12, 12, 12));

        JPanel card = new JPanel(new GridBagLayout());
        card.setBackground(Color.WHITE);
        card.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(0xDD, 0xE2, 0xE8)),
                new EmptyBorder(16, 16, 16, 16)));

        GridBagConstraints gc = new GridBagConstraints();
        gc.insets = new Insets(6, 6, 6, 6);
        gc.anchor = GridBagConstraints.WEST;

        JLabel title = new JLabel("Local Peer Server");
        title.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
        gc.gridx = 0; gc.gridy = 0; gc.gridwidth = 4;
        card.add(title, gc);

        JLabel subtitle = new JLabel("<html><body style='width:520px'>"
                + "Host this machine's <b>files.txt</b> / <b>files/</b> so other Meshare peers can "
                + "find you. Whenever someone connects here, this app checks whether that visitor "
                + "also runs a valid, online Meshare server of their own \u2014 on this same port and "
                + "on 8080 \u2014 by requesting <b>files.txt</b> from their root. If they do, their "
                + "address is added to servers.txt automatically (no duplicates)."
                + "</body></html>");
        subtitle.setForeground(new Color(0x5B, 0x64, 0x72));
        gc.gridy = 1; gc.gridwidth = 4;
        card.add(subtitle, gc);
        gc.gridwidth = 1;

        gc.gridy = 2; gc.gridx = 0;
        card.add(new JLabel("Protocol:"), gc);
        serverProtocolCombo = new JComboBox<String>(new String[] { "HTTP", "HTTPS" });
        gc.gridx = 1;
        card.add(serverProtocolCombo, gc);

        gc.gridx = 2;
        card.add(new JLabel("Port:"), gc);
        serverPortSpinner = new JSpinner(new SpinnerNumberModel(DEFAULT_HTTP_PORT, 1, 65535, 1));
        serverPortSpinner.setPreferredSize(new Dimension(90, 26));
        gc.gridx = 3;
        card.add(serverPortSpinner, gc);

        serverAutoRegisterCheck = new JCheckBox("Auto-add discovered peers to servers.txt", true);
        gc.gridx = 0; gc.gridy = 3; gc.gridwidth = 4;
        card.add(serverAutoRegisterCheck, gc);
        gc.gridwidth = 1;

        JPanel btnRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
        serverStartButton = new JButton("\u25B6 Start Server");
        serverStopButton  = new JButton("\u25A0 Stop Server");
        serverStopButton.setEnabled(false);
        stylePrimaryButton(serverStartButton);
        styleSecondaryButton(serverStopButton);
        btnRow.add(serverStartButton);
        btnRow.add(serverStopButton);
        gc.gridx = 0; gc.gridy = 4; gc.gridwidth = 4;
        card.add(btnRow, gc);
        gc.gridwidth = 1;

        serverStatusLabel = new JLabel("\u25CF Stopped");
        serverStatusLabel.setForeground(new Color(0xB3, 0x26, 0x1E));
        serverStatusLabel.setFont(serverStatusLabel.getFont().deriveFont(Font.BOLD));
        gc.gridx = 0; gc.gridy = 5;
        card.add(serverStatusLabel, gc);

        serverAddressLabel = new JLabel(" ");
        serverAddressLabel.setForeground(new Color(0x2D, 0x6C, 0xDF));
        gc.gridx = 1; gc.gridwidth = 3;
        card.add(serverAddressLabel, gc);
        gc.gridwidth = 1;

        panel.add(card, BorderLayout.NORTH);

        // ----- discovered peers + server log -----
        discoveredPeersModel = new DefaultTableModel(
                new String[] { "IP", "Port", "Server URL", "Added at" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        discoveredPeersTable = new JTable(discoveredPeersModel);
        discoveredPeersTable.setAutoCreateRowSorter(true);
        discoveredPeersTable.setRowHeight(22);
        discoveredPeersTable.getTableHeader().setReorderingAllowed(false);
        JScrollPane peersScroll = new JScrollPane(discoveredPeersTable);
        peersScroll.setBorder(new TitledBorder("Discovered peers (auto-added to servers.txt)"));

        serverLogArea = new JTextArea(8, 50);
        serverLogArea.setEditable(false);
        serverLogArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
        JScrollPane serverLogScroll = new JScrollPane(serverLogArea);
        serverLogScroll.setBorder(new TitledBorder("Server log"));

        JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, peersScroll, serverLogScroll);
        split.setResizeWeight(0.5);
        panel.add(split, BorderLayout.CENTER);

        serverStartButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { onStartServerClicked(); }
        });
        serverStopButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { onStopServerClicked(); }
        });

        return panel;
    }

    // ===================================================================
    //                    local peer server (host + discovery)
    // ===================================================================
    private void onStartServerClicked() {
        if (localHttpServer != null) {
            serverLog("Server already running");
            return;
        }
        final int port = ((Number) serverPortSpinner.getValue()).intValue();
        final boolean https = "HTTPS".equals(serverProtocolCombo.getSelectedItem());
        serverStartButton.setEnabled(false);

        executor.submit(new Runnable() {
            @Override public void run() {
                try {
                    startLocalServer(port, https);
                    final String proto = https ? "https" : "http";
                    final String ip = getLocalIpAddress();
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override public void run() {
                            serverStopButton.setEnabled(true);
                            serverProtocolCombo.setEnabled(false);
                            serverPortSpinner.setEnabled(false);
                            serverStatusLabel.setText("\u25CF Running");
                            serverStatusLabel.setForeground(new Color(0x1E, 0x8E, 0x3E));
                            serverAddressLabel.setText(proto + "://" + ip + ":" + port + "/");
                        }
                    });
                    serverLog("Server started on port " + port + " (" + (https ? "HTTPS" : "HTTP") + ")");
                } catch (final Exception ex) {
                    serverLog("Failed to start server: " + ex.getMessage());
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override public void run() { serverStartButton.setEnabled(true); }
                    });
                }
            }
        });
    }

    private void onStopServerClicked() {
        stopLocalServer();
        serverStopButton.setEnabled(false);
        serverStartButton.setEnabled(true);
        serverProtocolCombo.setEnabled(true);
        serverPortSpinner.setEnabled(true);
        serverStatusLabel.setText("\u25CF Stopped");
        serverStatusLabel.setForeground(new Color(0xB3, 0x26, 0x1E));
        serverAddressLabel.setText(" ");
        serverLog("Server stopped");
    }

    private void startLocalServer(int port, boolean https) throws Exception {
        HttpServer server;
        if (https) {
            HttpsServer httpsServer = HttpsServer.create(new InetSocketAddress(port), 0);
            final SSLContext sslContext = buildSslContext();
            httpsServer.setHttpsConfigurator(new HttpsConfigurator(sslContext) {
                @Override public void configure(HttpsParameters params) {
                    try {
                        SSLContext c = getSSLContext();
                        SSLEngine engine = c.createSSLEngine();
                        params.setNeedClientAuth(false);
                        params.setCipherSuites(engine.getEnabledCipherSuites());
                        params.setProtocols(engine.getEnabledProtocols());
                        params.setSSLParameters(c.getDefaultSSLParameters());
                    } catch (Exception ex) {
                        serverLog("HTTPS parameter setup failed: " + ex.getMessage());
                    }
                }
            });
            server = httpsServer;
        } else {
            server = HttpServer.create(new InetSocketAddress(port), 0);
        }
        server.createContext("/", new PeerRequestHandler());
        server.setExecutor(executor);
        server.start();
        localHttpServer = server;
        localServerPort = port;
    }

    private void stopLocalServer() {
        final HttpServer s = localHttpServer;
        localHttpServer = null;
        localServerPort = -1;
        if (s != null) {
            try { s.stop(0); } catch (Exception ex) { /* ignore */ }
        }
    }

    /** Loads (or, on first run, generates via the JDK's own keytool) a
     *  self-signed keystore used to serve HTTPS. Using keytool avoids
     *  depending on internal sun.security certificate-generation APIs,
     *  which differ across JDK 8 vendors/builds. */
    private SSLContext buildSslContext() throws Exception {
        File ks = new File(KEYSTORE_FILE);
        if (!ks.exists()) {
            ensureKeystore(ks);
        }
        KeyStore keyStore = KeyStore.getInstance("JKS");
        FileInputStream fis = new FileInputStream(ks);
        try {
            keyStore.load(fis, KEYSTORE_PASSWORD.toCharArray());
        } finally {
            try { fis.close(); } catch (IOException e) { /* ignore */ }
        }
        KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray());
        SSLContext ctx = SSLContext.getInstance("TLS");
        ctx.init(kmf.getKeyManagers(), null, new SecureRandom());
        return ctx;
    }

    private void ensureKeystore(File ks) throws Exception {
        serverLog("Generating self-signed certificate for HTTPS (first run)...");
        String javaHome = System.getProperty("java.home");
        String keytool = javaHome + File.separator + "bin" + File.separator + "keytool";
        ProcessBuilder pb = new ProcessBuilder(
                keytool, "-genkeypair",
                "-alias", "meshare",
                "-keyalg", "RSA",
                "-keysize", "2048",
                "-validity", "3650",
                "-keystore", ks.getAbsolutePath(),
                "-storepass", KEYSTORE_PASSWORD,
                "-keypass", KEYSTORE_PASSWORD,
                "-dname", "CN=Meshare, OU=Meshare, O=Meshare, L=Local, ST=Local, C=US");
        pb.redirectErrorStream(true);
        Process proc = pb.start();
        String output = readStream(proc.getInputStream());
        int code = proc.waitFor();
        if (code != 0) {
            throw new IOException("keytool exited with code " + code + ": " + output);
        }
        serverLog("Self-signed certificate generated: " + ks.getAbsolutePath());
    }

    /** Handles every incoming HTTP(S) request to the local peer server:
     *  serves this instance's own Meshare root, and independently kicks
     *  off a discovery check on the connecting IP. */
    private class PeerRequestHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            try {
                InetSocketAddress remote = exchange.getRemoteAddress();
                if (remote != null && remote.getAddress() != null) {
                    checkAndRegisterPeer(remote.getAddress().getHostAddress());
                }
                String path = exchange.getRequestURI().getPath();
                serveLocalPath(exchange, path == null ? "/" : path);
            } catch (Exception ex) {
                try { exchange.sendResponseHeaders(500, -1); } catch (IOException ignore) { /* ignore */ }
                exchange.close();
            }
        }
    }

    private void serveLocalPath(HttpExchange exchange, String path) throws IOException {
        String clean = path.startsWith("/") ? path.substring(1) : path;
        if (clean.equals("consensus.txt")) {
            byte[] body = getCachedConsensusText().getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
            exchange.sendResponseHeaders(200, body.length);
            OutputStream os = exchange.getResponseBody();
            os.write(body);
            os.close();
            exchange.close();
            return;
        }

        File file = resolveLocalFile(path);
        if (file == null || !file.exists() || !file.isFile()) {
            byte[] body = "Not found".getBytes(StandardCharsets.UTF_8);
            exchange.sendResponseHeaders(404, body.length);
            OutputStream os = exchange.getResponseBody();
            os.write(body);
            os.close();
            exchange.close();
            return;
        }
        exchange.getResponseHeaders().set("Content-Type", guessContentType(file.getName()));
        exchange.sendResponseHeaders(200, file.length());
        OutputStream os = exchange.getResponseBody();
        FileInputStream fis = new FileInputStream(file);
        try {
            byte[] buf = new byte[16 * 1024];
            int n;
            while ((n = fis.read(buf)) >= 0) os.write(buf, 0, n);
        } finally {
            try { fis.close(); } catch (IOException e) { /* ignore */ }
            os.close();
            exchange.close();
        }
    }

    /** Maps request paths onto this instance's own Meshare root, mirroring
     *  the layout every Meshare server is expected to expose: files.txt,
     *  links.txt and public_key.txt at the root, files/&lt;hash&gt;.&lt;ext&gt;
     *  and info/&lt;hash&gt;.json underneath. */
    private File resolveLocalFile(String path) {
        if (path == null || path.isEmpty() || path.equals("/")) {
            return new File(LOCAL_FILES_LIST);
        }
        String clean = path.startsWith("/") ? path.substring(1) : path;
        if (clean.contains("..")) return null; // traversal guard
        if (clean.equals("files.txt") || clean.equals("links.txt") || clean.equals("public_key.txt")
                || clean.equals("servers.txt")) {
            return new File(clean);
        }
        if (clean.startsWith("files/")) {
            return new File(FILES_DIR, clean.substring("files/".length()));
        }
        if (clean.startsWith("info/")) {
            return new File(INFO_DIR, clean.substring("info/".length()));
        }
        return null;
    }

    private static String guessContentType(String name) {
        String lower = name.toLowerCase();
        if (lower.endsWith(".json")) return "application/json; charset=utf-8";
        if (lower.endsWith(".txt"))  return "text/plain; charset=utf-8";
        return "application/octet-stream";
    }

    /**
     * Called whenever a client connects to our local server. Independently
     * of what they requested, we check whether that client also runs a
     * valid Meshare HTTP server of its own: first on the "default" port
     * for this exchange (the port our own server is listening on), then on
     * 8080 as a common fallback \u2014 by requesting "files.txt" from its root.
     * If either responds successfully, we record "http://ip:port/" into
     * servers.txt, skipping it if that address is already present.
     */
    private void checkAndRegisterPeer(final String ip) {
        if (ip == null || ip.isEmpty()) return;
        if (!serverAutoRegisterCheck.isSelected()) return;
        if (isLocalAddress(ip)) return;
        if (!recentlyCheckedIps.add(ip)) return; // already being checked

        executor.submit(new Runnable() {
            @Override public void run() {
                try {
                    List<Integer> ports = new ArrayList<Integer>();
                    int defaultPort = localServerPort > 0 ? localServerPort : DEFAULT_HTTP_PORT;
                    ports.add(defaultPort);
                    if (defaultPort != PEER_CHECK_FALLBACK_PORT) ports.add(PEER_CHECK_FALLBACK_PORT);

                    for (int port : ports) {
                        String base = "http://" + ip + ":" + port + "/";
                        if (probePeerFilesTxt(base)) {
                            registerDiscoveredServer(ip, port, base);
                            return;
                        }
                    }
                    serverLog("Peer " + ip + " has no reachable files.txt on " + ports);
                } finally {
                    recentlyCheckedIps.remove(ip);
                }
            }
        });
    }

    private boolean probePeerFilesTxt(String baseUrl) {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(baseUrl + "files.txt");
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(PEER_CHECK_TIMEOUT_MS);
            conn.setReadTimeout(PEER_CHECK_TIMEOUT_MS);
            conn.setRequestProperty("User-Agent", "Meshare/1.0");
            conn.setInstanceFollowRedirects(true);
            int code = conn.getResponseCode();
            return code >= 200 && code < 300;
        } catch (Exception e) {
            return false;
        } finally {
            if (conn != null) conn.disconnect();
        }
    }

    private void registerDiscoveredServer(final String ip, final int port, final String url) {
        synchronized (serversFileLock) {
            String normalized = normalizeServer(url);
            for (String existing : readServers()) {
                if (normalizeServer(existing).equalsIgnoreCase(normalized)) {
                    serverLog("Peer already known, not re-added: " + normalized);
                    return;
                }
            }
            FileWriter w = null;
            try {
                w = new FileWriter(SERVERS_FILE, true);
                w.write(normalized);
                w.write(System.getProperty("line.separator"));
            } catch (IOException e) {
                serverLog("Failed to write servers.txt: " + e.getMessage());
                return;
            } finally {
                if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
            }
        }
        final String addedAt = new SimpleDateFormat("HH:mm:ss").format(new Date());
        serverLog("Registered new peer server: " + url);
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                discoveredPeersModel.addRow(new Object[] { ip, Integer.valueOf(port), url, addedAt });
                loadServerCount();
            }
        });
    }

    private static boolean isLocalAddress(String ip) {
        try {
            InetAddress addr = InetAddress.getByName(ip);
            if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) return true;
            return NetworkInterface.getByInetAddress(addr) != null;
        } catch (Exception e) {
            return false;
        }
    }

    private static String getLocalIpAddress() {
        try {
            Enumeration<NetworkInterface> ifaces = NetworkInterface.getNetworkInterfaces();
            while (ifaces.hasMoreElements()) {
                NetworkInterface iface = ifaces.nextElement();
                if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
                Enumeration<InetAddress> addrs = iface.getInetAddresses();
                while (addrs.hasMoreElements()) {
                    InetAddress addr = addrs.nextElement();
                    if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
                        return addr.getHostAddress();
                    }
                }
            }
        } catch (Exception e) { /* ignore */ }
        return "127.0.0.1";
    }

    private void serverLog(final String message) {
        final String line = "[" + new SimpleDateFormat("HH:mm:ss").format(new Date()) + "] " + message;
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                if (serverLogArea == null) return;
                serverLogArea.append(line + "\n");
                int len = serverLogArea.getDocument().getLength();
                if (len > 200000) {
                    String t = serverLogArea.getText();
                    int cut = t.length() / 2;
                    int nl = t.indexOf('\n', cut);
                    if (nl > 0) serverLogArea.setText(t.substring(nl + 1));
                }
                serverLogArea.setCaretPosition(serverLogArea.getDocument().getLength());
            }
        });
        log(message);
    }

    // ===================================================================
    //                    "Keys" tab UI
    // ===================================================================
    /**
     * Builds the tab that lets the user generate an RSA identity key pair.
     * The public key is written to {@link #PUBLIC_KEY_FILE} (the same file
     * other servers already read via public_key.txt, and that this app's
     * own peer server hosts). The private key is written to a file named
     * "key_" + sha256(private key bytes) + ".txt", so the filename itself
     * is a fingerprint of the key it contains.
     */
    private JPanel buildKeysTab() {
        JPanel panel = new JPanel(new BorderLayout(10, 10));
        panel.setBorder(new EmptyBorder(12, 12, 12, 12));

        JPanel card = new JPanel(new GridBagLayout());
        card.setBackground(Color.WHITE);
        card.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(0xDD, 0xE2, 0xE8)),
                new EmptyBorder(16, 16, 16, 16)));

        GridBagConstraints gc = new GridBagConstraints();
        gc.insets = new Insets(6, 6, 6, 6);
        gc.anchor = GridBagConstraints.WEST;

        JLabel title = new JLabel("Identity Keys");
        title.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
        gc.gridx = 0; gc.gridy = 0; gc.gridwidth = 4;
        card.add(title, gc);

        JLabel subtitle = new JLabel("<html><body style='width:520px'>"
                + "Generates a short elliptic-curve (EC) key pair for this instance. The "
                + "<b>public</b> key is saved as a single short line of raw Base64 text "
                + "(no <tt>-----BEGIN...-----</tt> header) to <b>" + PUBLIC_KEY_FILE + "</b> "
                + "(the file other peers already read, and that this app's own Server tab hosts). "
                + "The <b>private</b> key is saved the same way, locally, as "
                + "<b>" + PRIVATE_KEY_PREFIX + "&lt;sha256 of the private key&gt;.txt</b> "
                + "\u2014 keep that file secret, never share it or host it."
                + "</body></html>");
        subtitle.setForeground(new Color(0x5B, 0x64, 0x72));
        gc.gridy = 1; gc.gridwidth = 4;
        card.add(subtitle, gc);
        gc.gridwidth = 1;

        gc.gridy = 2; gc.gridx = 0;
        card.add(new JLabel("Curve:"), gc);
        keyCurveCombo = new JComboBox<String>(new String[] {
                "secp256r1 (P-256, shortest)", "secp384r1 (P-384)", "secp521r1 (P-521)" });
        gc.gridx = 1;
        card.add(keyCurveCombo, gc);

        generateKeysButton = new JButton("\u2699 Generate Key Pair");
        stylePrimaryButton(generateKeysButton);
        gc.gridx = 2; gc.gridwidth = 2;
        card.add(generateKeysButton, gc);
        gc.gridwidth = 1;

        boolean pubExists = new File(PUBLIC_KEY_FILE).exists();
        keysStatusLabel = new JLabel(pubExists ? "\u25CF public_key.txt loaded from disk" : "\u25CF No keys generated yet");
        keysStatusLabel.setForeground(pubExists ? new Color(0x1E, 0x8E, 0x3E) : new Color(0x5B, 0x64, 0x72));
        keysStatusLabel.setFont(keysStatusLabel.getFont().deriveFont(Font.BOLD));
        gc.gridx = 0; gc.gridy = 3;
        card.add(keysStatusLabel, gc);

        File existingPriv = findMostRecentPrivateKeyFile();
        privateKeyPathLabel = new JLabel(existingPriv != null
                ? ("Private key file: " + existingPriv.getName())
                : "No private key file found in this folder yet");
        privateKeyPathLabel.setForeground(new Color(0x2D, 0x6C, 0xDF));
        gc.gridx = 1; gc.gridwidth = 3;
        card.add(privateKeyPathLabel, gc);
        gc.gridwidth = 1;

        panel.add(card, BorderLayout.NORTH);

        publicKeyDisplayArea = new JTextArea(14, 60);
        publicKeyDisplayArea.setEditable(false);
        publicKeyDisplayArea.setLineWrap(true);
        publicKeyDisplayArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
        String existingPub = readFile(new File(PUBLIC_KEY_FILE));
        if (existingPub != null) publicKeyDisplayArea.setText(existingPub);
        JScrollPane pubScroll = new JScrollPane(publicKeyDisplayArea);
        pubScroll.setBorder(new TitledBorder(PUBLIC_KEY_FILE + " content"));
        panel.add(pubScroll, BorderLayout.CENTER);

        generateKeysButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { onGenerateKeysClicked(); }
        });

        return panel;
    }

    private void onGenerateKeysClicked() {
        final String curveSelection = (String) keyCurveCombo.getSelectedItem();
        final String curveName = curveSelection.substring(0, curveSelection.indexOf(' '));
        File pubFile = new File(PUBLIC_KEY_FILE);
        if (pubFile.exists()) {
            int choice = JOptionPane.showConfirmDialog(this,
                    "\"" + PUBLIC_KEY_FILE + "\" already exists and will be overwritten.\n"
                    + "Any peers who already trust your current public key will need the new one.\n\n"
                    + "Generate a new key pair anyway?",
                    "Overwrite existing key?",
                    JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE);
            if (choice != JOptionPane.YES_OPTION) return;
        }

        generateKeysButton.setEnabled(false);
        keysStatusLabel.setText("\u25CF Generating EC key pair (" + curveName + ")...");
        keysStatusLabel.setForeground(new Color(0x5B, 0x64, 0x72));

        executor.submit(new Runnable() {
            @Override public void run() { generateKeyPair(curveName); }
        });
    }

    private void generateKeyPair(String curveName) {
        try {
            KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
            kpg.initialize(new ECGenParameterSpec(curveName), new SecureRandom());
            KeyPair pair = kpg.generateKeyPair();

            PublicKey  publicKey  = pair.getPublic();
            PrivateKey privateKey = pair.getPrivate();

            // Short-form, plain-text encoding: a single line of raw Base64
            // (no "-----BEGIN ...-----" armor) so the files stay small and
            // simple to paste/compare.
            final String publicText  = Base64.getEncoder().encodeToString(publicKey.getEncoded());
            final String privateText = Base64.getEncoder().encodeToString(privateKey.getEncoded());

            // The filename itself is a fingerprint: key_<sha256 of the raw
            // private key bytes>.txt. Hashing the raw encoded key (rather
            // than the text form) keeps the fingerprint independent of
            // formatting, matching how the rest of the app hashes raw bytes.
            final String privHash = computeSha256OfBytes(privateKey.getEncoded());
            if (privHash == null) throw new IOException("Failed to compute SHA-256 of the private key");

            final File pubFile  = new File(PUBLIC_KEY_FILE);
            final File privFile = new File(PRIVATE_KEY_PREFIX + privHash + ".txt");

            writeTextFile(pubFile, publicText);
            writeTextFile(privFile, privateText);

            log("Generated EC key pair (curve " + curveName + ")");
            log("Public key saved to: " + pubFile.getAbsolutePath());
            log("Private key saved to: " + privFile.getAbsolutePath() + " (keep this secret)");

            SwingUtilities.invokeLater(new Runnable() {
                @Override public void run() {
                    publicKeyDisplayArea.setText(publicText);
                    publicKeyDisplayArea.setCaretPosition(0);
                    privateKeyPathLabel.setText("Private key file: " + privFile.getName());
                    keysStatusLabel.setText("\u25CF Key pair generated (" + publicText.length() + " chars)");
                    keysStatusLabel.setForeground(new Color(0x1E, 0x8E, 0x3E));
                    generateKeysButton.setEnabled(true);
                }
            });
        } catch (final Exception ex) {
            log("Failed to generate key pair: " + ex.getMessage());
            SwingUtilities.invokeLater(new Runnable() {
                @Override public void run() {
                    keysStatusLabel.setText("\u25CF Generation failed: " + ex.getMessage());
                    keysStatusLabel.setForeground(new Color(0xB3, 0x26, 0x1E));
                    generateKeysButton.setEnabled(true);
                }
            });
        }
    }

    private static void writeTextFile(File f, String content) throws IOException {
        FileWriter w = new FileWriter(f, false);
        try {
            w.write(content);
        } finally {
            w.close();
        }
    }

    /** Looks for existing "key_&lt;sha256&gt;.txt" files in the working
     *  directory and returns the most recently modified one, if any. Used
     *  only to show a helpful hint in the Keys tab on startup. */
    private static File findMostRecentPrivateKeyFile() {
        File dir = new File(".");
        File[] matches = dir.listFiles(new FilenameFilter() {
            @Override public boolean accept(File d, String name) {
                if (!name.startsWith(PRIVATE_KEY_PREFIX) || !name.endsWith(".txt")) return false;
                String middle = name.substring(PRIVATE_KEY_PREFIX.length(), name.length() - ".txt".length());
                return isValidSha256(middle);
            }
        });
        if (matches == null || matches.length == 0) return null;
        File newest = matches[0];
        for (File f : matches) {
            if (f.lastModified() > newest.lastModified()) newest = f;
        }
        return newest;
    }

    // ===================================================================
    //                    "Transfer" tab UI
    // ===================================================================
    /**
     * Builds the tab that lets the user send points to another public key.
     * Points are earned locally by scanning info/*.json for "public_key"
     * and "server_public_key" fields (one point per occurrence, credited to
     * the SHA-256 fingerprint of that key's text). A transfer is only
     * accepted once this node reaches consensus with its peers on: the
     * server list (servers.txt), the file count of every server (files.txt
     * per server), and the total file count of the network. No hash
     * integrity or existence check of individual files is performed, as
     * requested \u2014 only the counts and the server list need to agree.
     */
    private JPanel buildTransferTab() {
        JPanel panel = new JPanel(new BorderLayout(10, 10));
        panel.setBorder(new EmptyBorder(12, 12, 12, 12));

        JPanel card = new JPanel(new GridBagLayout());
        card.setBackground(Color.WHITE);
        card.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(0xDD, 0xE2, 0xE8)),
                new EmptyBorder(16, 16, 16, 16)));
        GridBagConstraints gc = new GridBagConstraints();
        gc.insets = new Insets(6, 6, 6, 6);
        gc.anchor = GridBagConstraints.WEST;

        JLabel title = new JLabel("Points & Transfers");
        title.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 16));
        gc.gridx = 0; gc.gridy = 0; gc.gridwidth = 5;
        card.add(title, gc);

        JLabel subtitle = new JLabel("<html><body style='width:580px'>"
                + "Every cached record in <b>info/</b> that carries a <b>public_key</b> or "
                + "<b>server_public_key</b> field earns that key one point, recorded as an empty "
                + "marker file under <b>points/public_key/&lt;fingerprint&gt;/&lt;filehash&gt;.txt</b> "
                + "(or <b>points/server_public_key/...</b>) \u2014 a file can never award the same key "
                + "a point twice. A key's points are also capped at the network's total file count, "
                + "so nobody can hold more points than files actually exist. To stop spam, a transfer "
                + "only goes through once this node agrees with its peers on the server list, the file "
                + "count of every server, and the network's total file count."
                + "</body></html>");
        subtitle.setForeground(new Color(0x5B, 0x64, 0x72));
        gc.gridy = 1; gc.gridwidth = 5;
        card.add(subtitle, gc);
        gc.gridwidth = 1;

        balanceLabel = new JLabel("Your balance: \u2014");
        balanceLabel.setFont(balanceLabel.getFont().deriveFont(Font.BOLD));
        gc.gridx = 0; gc.gridy = 2; gc.gridwidth = 5;
        card.add(balanceLabel, gc);
        gc.gridwidth = 1;

        JPanel btnRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
        recalcPointsButton   = new JButton("\u21BB Recalculate Points");
        checkConsensusButton = new JButton("\u2696 Check Network Consensus");
        styleSecondaryButton(recalcPointsButton);
        styleSecondaryButton(checkConsensusButton);
        btnRow.add(recalcPointsButton);
        btnRow.add(checkConsensusButton);
        gc.gridx = 0; gc.gridy = 3; gc.gridwidth = 5;
        card.add(btnRow, gc);
        gc.gridwidth = 1;

        consensusStatusLabel = new JLabel("\u25CF Consensus not checked yet");
        consensusStatusLabel.setForeground(new Color(0x5B, 0x64, 0x72));
        consensusStatusLabel.setFont(consensusStatusLabel.getFont().deriveFont(Font.BOLD));
        gc.gridx = 0; gc.gridy = 4; gc.gridwidth = 5;
        card.add(consensusStatusLabel, gc);
        gc.gridwidth = 1;

        gc.gridx = 0; gc.gridy = 5;
        card.add(new JLabel("Recipient (public key text or 64-char fingerprint):"), gc);
        recipientField = new JTextField(34);
        gc.gridx = 1; gc.gridwidth = 2;
        card.add(recipientField, gc);
        gc.gridwidth = 1;

        gc.gridx = 3;
        card.add(new JLabel("Amount:"), gc);
        transferAmountSpinner = new JSpinner(new SpinnerNumberModel(1, 1, 1000000, 1));
        transferAmountSpinner.setPreferredSize(new Dimension(90, 26));
        gc.gridx = 4;
        card.add(transferAmountSpinner, gc);

        sendTransferButton = new JButton("\u2192 Send Transfer");
        stylePrimaryButton(sendTransferButton);
        gc.gridx = 0; gc.gridy = 6; gc.gridwidth = 5;
        card.add(sendTransferButton, gc);
        gc.gridwidth = 1;

        panel.add(card, BorderLayout.NORTH);

        // ----- points ledger + transfer log -----
        pointsModel = new DefaultTableModel(
                new String[] { "Fingerprint", "Sample key", "Earned", "Sent", "Received", "Balance" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        pointsTable = new JTable(pointsModel);
        pointsTable.setAutoCreateRowSorter(true);
        pointsTable.setRowHeight(22);
        pointsTable.getTableHeader().setReorderingAllowed(false);
        pointsTable.getColumnModel().getColumn(0).setPreferredWidth(260);
        pointsTable.getColumnModel().getColumn(1).setPreferredWidth(160);
        pointsTable.setDefaultRenderer(Object.class, new DefaultTableCellRenderer() {
            private static final long serialVersionUID = 1L;
            @Override
            public Component getTableCellRendererComponent(JTable table, Object value,
                                                           boolean isSelected, boolean hasFocus,
                                                           int row, int column) {
                Component c = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
                setToolTipText(column == 0 && value != null ? value.toString() : null);
                return c;
            }
        });
        JScrollPane pointsScroll = new JScrollPane(pointsTable);
        pointsScroll.setBorder(new TitledBorder("Points ledger (earned via info/*.json)"));

        transferLogModel = new DefaultTableModel(new String[] { "Time", "From", "To", "Amount" }, 0) {
            private static final long serialVersionUID = 1L;
            @Override public boolean isCellEditable(int row, int column) { return false; }
        };
        transferLogTable = new JTable(transferLogModel);
        transferLogTable.setAutoCreateRowSorter(true);
        transferLogTable.setRowHeight(22);
        transferLogTable.getTableHeader().setReorderingAllowed(false);
        JScrollPane transferScroll = new JScrollPane(transferLogTable);
        transferScroll.setBorder(new TitledBorder("Transfer log (" + TRANSFERS_FILE + ")"));

        JSplitPane tablesSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, pointsScroll, transferScroll);
        tablesSplit.setResizeWeight(0.55);

        consensusDetailsArea = new JTextArea(6, 60);
        consensusDetailsArea.setEditable(false);
        consensusDetailsArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 11));
        JScrollPane detailsScroll = new JScrollPane(consensusDetailsArea);
        detailsScroll.setBorder(new TitledBorder("Last consensus check details"));

        JSplitPane mainSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, tablesSplit, detailsScroll);
        mainSplit.setResizeWeight(0.7);
        panel.add(mainSplit, BorderLayout.CENTER);

        recalcPointsButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { refreshPointsLedger(); }
        });
        checkConsensusButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { onCheckConsensusClicked(); }
        });
        sendTransferButton.addActionListener(new ActionListener() {
            @Override public void actionPerformed(ActionEvent e) { onSendTransferClicked(); }
        });

        refreshPointsLedger();
        return panel;
    }

    // ===================================================================
    //                    points ledger
    // ===================================================================
    private static class TransferRecord {
        String time;
        String from;
        String to;
        int    amount;
    }

    /**
     * Points are recorded on disk as empty marker files, one per
     * (earning key, file) pair, so a given file can never award the same
     * key more than one point \u2014 the marker either already exists (no-op)
     * or gets created (first time only):
     * <pre>
     *   points/public_key/&lt;fingerprint&gt;/&lt;filehash&gt;.txt
     *   points/server_public_key/&lt;fingerprint&gt;/&lt;filehash&gt;.txt
     * </pre>
     * This makes the marker files themselves the ledger \u2014 counting a
     * fingerprint's points is just counting files in its folder(s).
     */
    private static final String POINTS_DIR = "points";
    private static final String POINTS_CATEGORY_FILE   = "public_key";
    private static final String POINTS_CATEGORY_SERVER = "server_public_key";

    /** Walks info/*.json (filename minus ".json" is the file's hash) and,
     *  for every non-empty "public_key" / "server_public_key" field found,
     *  ensures the corresponding marker file exists. Already-existing
     *  markers are left untouched \u2014 "if this block already exists it
     *  cannot be added again". No hash/existence verification of the file
     *  itself is performed, only that its name looks like a sha256 hash. */
    private void syncPointMarkersFromInfoCache() {
        File dir = new File(INFO_DIR);
        File[] files = dir.listFiles(new FilenameFilter() {
            @Override public boolean accept(File d, String name) { return name.toLowerCase().endsWith(".json"); }
        });
        if (files == null) return;
        for (File f : files) {
            String name = f.getName();
            String filehash = name.substring(0, name.length() - ".json".length());
            if (!isValidSha256(filehash)) continue;
            String content = readFile(f);
            if (content == null) continue;
            Map<String, String> fields = parseJson(content);
            markPointIfPresent(POINTS_CATEGORY_FILE,   fields.get("public_key"),        filehash);
            markPointIfPresent(POINTS_CATEGORY_SERVER, fields.get("server_public_key"), filehash);
        }
    }

    private void markPointIfPresent(String category, String rawKey, String filehash) {
        if (rawKey == null) return;
        String trimmed = rawKey.trim();
        if (trimmed.isEmpty()) return;
        String fingerprint = computeSha256OfBytes(trimmed.getBytes(StandardCharsets.UTF_8));
        if (fingerprint == null) return;
        fingerprintSampleCache.put(fingerprint, trimmed);

        File keyDir = new File(new File(POINTS_DIR, category), fingerprint);
        File marker = new File(keyDir, filehash + ".txt");
        if (marker.exists()) return; // already recorded — cannot be added again
        keyDir.mkdirs();
        try {
            marker.createNewFile();
        } catch (IOException e) {
            log("Failed to record point marker " + marker.getPath() + ": " + e.getMessage());
        }
    }

    /** Counts marker files per fingerprint across both categories. This is
     *  the raw (uncapped) points a key has recorded locally. */
    private Map<String, Integer> countPointMarkers() {
        Map<String, Integer> earned = new LinkedHashMap<String, Integer>();
        accumulateMarkerCounts(new File(POINTS_DIR, POINTS_CATEGORY_FILE),   earned);
        accumulateMarkerCounts(new File(POINTS_DIR, POINTS_CATEGORY_SERVER), earned);
        return earned;
    }

    private void accumulateMarkerCounts(File categoryDir, Map<String, Integer> earned) {
        File[] keyDirs = categoryDir.listFiles();
        if (keyDirs == null) return;
        for (File keyDir : keyDirs) {
            if (!keyDir.isDirectory() || !isValidSha256(keyDir.getName())) continue;
            File[] markers = keyDir.listFiles(new FilenameFilter() {
                @Override public boolean accept(File d, String name) { return name.endsWith(".txt"); }
            });
            int count = markers == null ? 0 : markers.length;
            if (count == 0) continue;
            String fp = keyDir.getName();
            Integer cur = earned.get(fp);
            earned.put(fp, cur == null ? Integer.valueOf(count) : Integer.valueOf(cur.intValue() + count));
        }
    }

    /** Total files currently known across the network, from our (cached)
     *  consensus view. Used only as an upper bound on any single key's
     *  points \u2014 nobody can legitimately have earned points for more files
     *  than exist. Returns 0 if the network size can't currently be
     *  determined (e.g. no servers configured / none reachable yet). */
    private int getCachedTotalNetworkFiles() {
        try {
            return parseConsensusText(getCachedConsensusText()).totalFiles;
        } catch (Exception e) {
            return 0;
        }
    }

    /** Rebuilds point markers from the local info/ cache, then returns each
     *  key's point count \u2014 capped at the total number of files currently
     *  known on the network, so "the user can not have more points than the
     *  total files in network". The cap is skipped only when the network
     *  size itself is unknown (0), so a fresh/offline node doesn't get its
     *  legitimately-recorded markers zeroed out for lack of data. */
    private Map<String, Integer> computeEarnedPoints() {
        syncPointMarkersFromInfoCache();
        Map<String, Integer> raw = countPointMarkers();
        int networkTotal = getCachedTotalNetworkFiles();
        if (networkTotal <= 0) return raw;

        Map<String, Integer> capped = new LinkedHashMap<String, Integer>();
        for (Map.Entry<String, Integer> e : raw.entrySet()) {
            int v = e.getValue().intValue();
            if (v > networkTotal) {
                log("Points for " + truncate(e.getKey(), 12) + "\u2026 capped to network total ("
                        + v + " \u2192 " + networkTotal + ")");
            }
            capped.put(e.getKey(), Integer.valueOf(Math.min(v, networkTotal)));
        }
        return capped;
    }

    /** Reads the local transfer ledger: "<ISO time>|<from fp>|<to fp>|<amount>" per line. */
    private List<TransferRecord> readTransfers() {
        List<TransferRecord> list = new ArrayList<TransferRecord>();
        String content = readFile(new File(TRANSFERS_FILE));
        if (content == null) return list;
        for (String line : content.split("\\r?\\n")) {
            line = line.trim();
            if (line.isEmpty()) continue;
            String[] parts = line.split("\\|");
            if (parts.length != 4) continue;
            TransferRecord r = new TransferRecord();
            r.time = parts[0];
            r.from = parts[1];
            r.to   = parts[2];
            try {
                r.amount = Integer.parseInt(parts[3].trim());
            } catch (NumberFormatException e) {
                continue;
            }
            list.add(r);
        }
        return list;
    }

    private void recordTransfer(String fromFp, String toFp, int amount) {
        String line = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX").format(new Date())
                + "|" + fromFp + "|" + toFp + "|" + amount;
        FileWriter w = null;
        try {
            w = new FileWriter(TRANSFERS_FILE, true);
            w.write(line);
            w.write(System.getProperty("line.separator"));
        } catch (IOException e) {
            log("Failed to write " + TRANSFERS_FILE + ": " + e.getMessage());
        } finally {
            if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    /** SHA-256 fingerprint of the content of public_key.txt, i.e. "who we are"
     *  for the points ledger. Returns null if no key has been generated yet. */
    private String getOwnPublicKeyFingerprint() {
        String pub = readFile(new File(PUBLIC_KEY_FILE));
        if (pub == null) return null;
        String trimmed = pub.trim();
        if (trimmed.isEmpty()) return null;
        return computeSha256OfBytes(trimmed.getBytes(StandardCharsets.UTF_8));
    }

    private void refreshPointsLedger() {
        if (recalcPointsButton != null) recalcPointsButton.setEnabled(false);
        executor.submit(new Runnable() {
            @Override public void run() {
                final Map<String, Integer> earned    = computeEarnedPoints();
                final List<TransferRecord> transfers = readTransfers();
                final Map<String, Integer> sent     = new LinkedHashMap<String, Integer>();
                final Map<String, Integer> received = new LinkedHashMap<String, Integer>();
                for (TransferRecord r : transfers) {
                    Integer s = sent.get(r.from);
                    sent.put(r.from, Integer.valueOf((s == null ? 0 : s.intValue()) + r.amount));
                    Integer rec = received.get(r.to);
                    received.put(r.to, Integer.valueOf((rec == null ? 0 : rec.intValue()) + r.amount));
                }

                Set<String> allKeys = new LinkedHashSet<String>();
                allKeys.addAll(earned.keySet());
                allKeys.addAll(sent.keySet());
                allKeys.addAll(received.keySet());

                List<Object[]> rows = new ArrayList<Object[]>();
                for (String fp : allKeys) {
                    int e = earned.containsKey(fp)   ? earned.get(fp).intValue()   : 0;
                    int s = sent.containsKey(fp)     ? sent.get(fp).intValue()     : 0;
                    int r = received.containsKey(fp) ? received.get(fp).intValue() : 0;
                    int balance = e - s + r;
                    String sample = fingerprintSampleCache.get(fp);
                    String display = sample != null ? truncate(sample, 28) : "(not cached locally)";
                    rows.add(new Object[] { fp, display,
                            Integer.valueOf(e), Integer.valueOf(s), Integer.valueOf(r), Integer.valueOf(balance) });
                }
                Collections.sort(rows, new Comparator<Object[]>() {
                    @Override public int compare(Object[] a, Object[] b) {
                        return ((Integer) b[5]).intValue() - ((Integer) a[5]).intValue();
                    }
                });

                final String ownFp = getOwnPublicKeyFingerprint();
                final int ownBalance = ownFp == null ? 0
                        : (earned.containsKey(ownFp)   ? earned.get(ownFp).intValue()   : 0)
                        - (sent.containsKey(ownFp)     ? sent.get(ownFp).intValue()     : 0)
                        + (received.containsKey(ownFp) ? received.get(ownFp).intValue() : 0);

                final List<Object[]> finalRows = rows;
                SwingUtilities.invokeLater(new Runnable() {
                    @Override public void run() {
                        pointsModel.setRowCount(0);
                        for (Object[] row : finalRows) pointsModel.addRow(row);

                        transferLogModel.setRowCount(0);
                        for (TransferRecord r : transfers) {
                            transferLogModel.addRow(new Object[] {
                                    r.time, truncate(r.from, 16) + "\u2026",
                                    truncate(r.to, 16) + "\u2026", Integer.valueOf(r.amount)
                            });
                        }

                        if (ownFp != null) {
                            balanceLabel.setText("Your balance: " + ownBalance
                                    + " point(s)  (fingerprint " + truncate(ownFp, 16) + "\u2026)");
                        } else {
                            balanceLabel.setText("Your balance: \u2014 (generate a key pair in the Keys tab first)");
                        }
                        if (recalcPointsButton != null) recalcPointsButton.setEnabled(true);
                    }
                });
            }
        });
    }

    // ===================================================================
    //                    network consensus (anti-spam gate)
    // ===================================================================
    /** This node's (or a peer's) view of the network: the server list
     *  fingerprint, each server's file count (per files.txt), and the sum
     *  of those counts. No hash/existence check of individual files is
     *  performed \u2014 only counts and the server list need to agree. */
    private static class ConsensusView {
        String serversHash = "";
        int totalFiles = 0;
        Map<String, Integer> fileCounts = new LinkedHashMap<String, Integer>();
    }

    private static class ConsensusResult {
        boolean passed;
        int peersChecked;
        int peersAgreed;
        final List<String> details = new ArrayList<String>();
    }

    /** Computes our own current view of the network by fetching files.txt
     *  from every server in servers.txt (in parallel) and counting the
     *  valid "&lt;hash&gt;.&lt;ext&gt;" entries in each \u2014 the same validity
     *  rule the rest of the app already uses (parseFileLine). */
    private ConsensusView computeConsensusView() {
        ConsensusView view = new ConsensusView();
        List<String> servers = readServers();

        List<String> normalizedSorted = new ArrayList<String>();
        for (String s : servers) normalizedSorted.add(normalizeServer(s));
        Collections.sort(normalizedSorted);
        StringBuilder joined = new StringBuilder();
        for (String s : normalizedSorted) joined.append(s).append('\n');
        String hash = computeSha256OfBytes(joined.toString().getBytes(StandardCharsets.UTF_8));
        view.serversHash = hash != null ? hash : "";

        final Map<String, Integer> counts = new ConcurrentHashMap<String, Integer>();
        List<Future<?>> futures = new ArrayList<Future<?>>();
        for (final String server : servers) {
            futures.add(consensusExecutor.submit(new Runnable() {
                @Override public void run() {
                    String baseUrl = normalizeServer(server);
                    int count = 0;
                    String content = httpGet(baseUrl + "files.txt");
                    if (content != null) {
                        for (String line : content.split("\\r?\\n")) {
                            if (parseFileLine(line) != null) count++;
                        }
                    }
                    counts.put(baseUrl, Integer.valueOf(count));
                }
            }));
        }
        for (Future<?> f : futures) { try { f.get(); } catch (Exception e) { /* ignore */ } }

        int total = 0;
        for (Map.Entry<String, Integer> e : counts.entrySet()) {
            view.fileCounts.put(e.getKey(), e.getValue());
            total += e.getValue().intValue();
        }
        view.totalFiles = total;
        return view;
    }

    private static String formatConsensusText(ConsensusView view) {
        StringBuilder sb = new StringBuilder();
        sb.append("SERVERS_HASH:").append(view.serversHash).append('\n');
        sb.append("TOTAL_FILES:").append(view.totalFiles).append('\n');
        List<String> keys = new ArrayList<String>(view.fileCounts.keySet());
        Collections.sort(keys);
        for (String k : keys) {
            sb.append("FILES:").append(k).append('=').append(view.fileCounts.get(k)).append('\n');
        }
        return sb.toString();
    }

    private static ConsensusView parseConsensusText(String text) {
        ConsensusView view = new ConsensusView();
        if (text == null) return view;
        for (String line : text.split("\\r?\\n")) {
            if (line.startsWith("SERVERS_HASH:")) {
                view.serversHash = line.substring("SERVERS_HASH:".length()).trim();
            } else if (line.startsWith("TOTAL_FILES:")) {
                try { view.totalFiles = Integer.parseInt(line.substring("TOTAL_FILES:".length()).trim()); }
                catch (NumberFormatException e) { /* ignore */ }
            } else if (line.startsWith("FILES:")) {
                String rest = line.substring("FILES:".length());
                int eq = rest.lastIndexOf('=');
                if (eq < 0) continue;
                String server = rest.substring(0, eq);
                try {
                    view.fileCounts.put(server, Integer.valueOf(Integer.parseInt(rest.substring(eq + 1).trim())));
                } catch (NumberFormatException e) { /* ignore */ }
            }
        }
        return view;
    }

    /** Cached version of our own consensus view, served to peers at
     *  "/consensus.txt" by the embedded peer server. Recomputing this
     *  requires fetching files.txt from every configured server, so it is
     *  cached for {@link #CONSENSUS_CACHE_MS} to keep the server responsive
     *  under repeated hits. */
    private String getCachedConsensusText() {
        synchronized (consensusCacheLock) {
            long now = System.currentTimeMillis();
            if (cachedConsensusText != null && (now - cachedConsensusAt) < CONSENSUS_CACHE_MS) {
                return cachedConsensusText;
            }
        }
        String text = formatConsensusText(computeConsensusView());
        synchronized (consensusCacheLock) {
            cachedConsensusText = text;
            cachedConsensusAt = System.currentTimeMillis();
        }
        return text;
    }

    /**
     * Anti-spam gate: computes our own view of the network, fetches every
     * peer's "/consensus.txt", and checks whether each one agrees on the
     * server list, every server's file count, and the network total. A
     * transfer is only allowed once every peer that responded agrees; peers
     * that are unreachable are skipped (logged, not counted as a mismatch).
     */
    private ConsensusResult checkNetworkConsensus() {
        ConsensusResult result = new ConsensusResult();
        final List<String> servers = readServers();
        if (servers.isEmpty()) {
            result.details.add("No servers configured in servers.txt \u2014 cannot reach network consensus.");
            result.passed = false;
            return result;
        }

        ConsensusView ourView = computeConsensusView();

        List<Future<String>> futures = new ArrayList<Future<String>>();
        for (final String server : servers) {
            futures.add(consensusExecutor.submit(new Callable<String>() {
                @Override public String call() {
                    return httpGet(normalizeServer(server) + "consensus.txt");
                }
            }));
        }

        for (int i = 0; i < servers.size(); i++) {
            String server = servers.get(i);
            String content;
            try { content = futures.get(i).get(); } catch (Exception e) { content = null; }
            if (content == null) {
                result.details.add("[" + server + "] unreachable / no consensus.txt \u2014 skipped");
                continue;
            }
            result.peersChecked++;
            ConsensusView peerView = parseConsensusText(content);
            boolean agree = ourView.serversHash.equals(peerView.serversHash)
                    && ourView.totalFiles == peerView.totalFiles
                    && ourView.fileCounts.equals(peerView.fileCounts);
            if (agree) {
                result.peersAgreed++;
                result.details.add("[" + server + "] agrees (server list, per-server counts, total files all match)");
            } else {
                StringBuilder why = new StringBuilder();
                if (!ourView.serversHash.equals(peerView.serversHash)) why.append("server list differs; ");
                if (ourView.totalFiles != peerView.totalFiles) {
                    why.append("total files differs (us=").append(ourView.totalFiles)
                       .append(" them=").append(peerView.totalFiles).append("); ");
                }
                if (!ourView.fileCounts.equals(peerView.fileCounts)) why.append("per-server file counts differ; ");
                result.details.add("[" + server + "] MISMATCH: " + why.toString());
            }
        }

        result.passed = result.peersChecked > 0 && result.peersAgreed == result.peersChecked;
        return result;
    }

    private void updateConsensusUI(final ConsensusResult result) {
        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                if (result.passed) {
                    consensusStatusLabel.setText("\u25CF In consensus: "
                            + result.peersAgreed + "/" + result.peersChecked + " peer(s) agree");
                    consensusStatusLabel.setForeground(new Color(0x1E, 0x8E, 0x3E));
                } else {
                    consensusStatusLabel.setText("\u25CF Not in consensus: "
                            + result.peersAgreed + "/" + result.peersChecked + " peer(s) agree");
                    consensusStatusLabel.setForeground(new Color(0xB3, 0x26, 0x1E));
                }
                StringBuilder sb = new StringBuilder();
                for (String d : result.details) sb.append(d).append('\n');
                consensusDetailsArea.setText(sb.toString());
            }
        });
    }

    private void onCheckConsensusClicked() {
        checkConsensusButton.setEnabled(false);
        consensusStatusLabel.setText("\u25CF Checking network consensus...");
        consensusStatusLabel.setForeground(new Color(0x5B, 0x64, 0x72));
        executor.submit(new Runnable() {
            @Override public void run() {
                ConsensusResult result = checkNetworkConsensus();
                updateConsensusUI(result);
                SwingUtilities.invokeLater(new Runnable() {
                    @Override public void run() { checkConsensusButton.setEnabled(true); }
                });
            }
        });
    }

    // ===================================================================
    //                    send transfer
    // ===================================================================
    private void onSendTransferClicked() {
        final String ownFp = getOwnPublicKeyFingerprint();
        if (ownFp == null) {
            JOptionPane.showMessageDialog(this, "Generate a key pair in the Keys tab first.",
                    "No identity", JOptionPane.WARNING_MESSAGE);
            return;
        }

        String recipientRaw = recipientField.getText().trim();
        if (recipientRaw.isEmpty()) {
            JOptionPane.showMessageDialog(this, "Enter a recipient public key or fingerprint.",
                    "Missing recipient", JOptionPane.WARNING_MESSAGE);
            return;
        }
        final String recipientFp = isValidSha256(recipientRaw)
                ? recipientRaw.toLowerCase()
                : computeSha256OfBytes(recipientRaw.getBytes(StandardCharsets.UTF_8));
        if (recipientFp == null) {
            JOptionPane.showMessageDialog(this, "Could not compute a fingerprint for that recipient.",
                    "Invalid recipient", JOptionPane.WARNING_MESSAGE);
            return;
        }
        if (recipientFp.equalsIgnoreCase(ownFp)) {
            int c = JOptionPane.showConfirmDialog(this, "Recipient is your own public key. Continue anyway?",
                    "Send to self?", JOptionPane.YES_NO_OPTION);
            if (c != JOptionPane.YES_OPTION) return;
        }

        final int amount = ((Number) transferAmountSpinner.getValue()).intValue();
        if (amount <= 0) {
            JOptionPane.showMessageDialog(this, "Amount must be greater than zero.",
                    "Invalid amount", JOptionPane.WARNING_MESSAGE);
            return;
        }

        sendTransferButton.setEnabled(false);
        consensusStatusLabel.setText("\u25CF Checking network consensus...");
        consensusStatusLabel.setForeground(new Color(0x5B, 0x64, 0x72));

        executor.submit(new Runnable() {
            @Override public void run() {
                // Re-check the balance right before sending, from a fresh scan.
                Map<String, Integer> earned = computeEarnedPoints();
                List<TransferRecord> transfers = readTransfers();
                int sentTotal = 0, receivedTotal = 0;
                for (TransferRecord r : transfers) {
                    if (r.from.equals(ownFp)) sentTotal += r.amount;
                    if (r.to.equals(ownFp))   receivedTotal += r.amount;
                }
                int earnedOwn = earned.containsKey(ownFp) ? earned.get(ownFp).intValue() : 0;
                final int balance = earnedOwn - sentTotal + receivedTotal;

                if (amount > balance) {
                    log("Transfer blocked: amount " + amount + " exceeds balance " + balance);
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override public void run() {
                            JOptionPane.showMessageDialog(Meshare.this,
                                    "Insufficient balance (" + balance + " point(s) available).\n"
                                    + "Recalculate points to refresh your balance.",
                                    "Insufficient balance", JOptionPane.ERROR_MESSAGE);
                            sendTransferButton.setEnabled(true);
                            consensusStatusLabel.setText("\u25CF Consensus not checked yet");
                            consensusStatusLabel.setForeground(new Color(0x5B, 0x64, 0x72));
                        }
                    });
                    return;
                }

                ConsensusResult result = checkNetworkConsensus();
                updateConsensusUI(result);

                if (!result.passed) {
                    log("Transfer blocked: network consensus not reached ("
                            + result.peersAgreed + "/" + result.peersChecked + " peers agree)");
                    SwingUtilities.invokeLater(new Runnable() {
                        @Override public void run() {
                            JOptionPane.showMessageDialog(Meshare.this,
                                    "Transfer blocked: this node is not in consensus with the network\n"
                                    + "(server list / per-server file counts / total files don't match "
                                    + "enough peers).\nSee the consensus details panel for specifics.",
                                    "Consensus not reached", JOptionPane.ERROR_MESSAGE);
                            sendTransferButton.setEnabled(true);
                        }
                    });
                    return;
                }

                recordTransfer(ownFp, recipientFp, amount);
                log("Transfer sent: " + amount + " point(s) from " + truncate(ownFp, 12)
                        + "\u2026 to " + truncate(recipientFp, 12) + "\u2026");

                SwingUtilities.invokeLater(new Runnable() {
                    @Override public void run() {
                        sendTransferButton.setEnabled(true);
                        recipientField.setText("");
                    }
                });
                refreshPointsLedger();
            }
        });
    }

    // ===================================================================
    //                    modern theme helpers
    // ===================================================================
    private static void applyModernTheme() {
        try {
            for (UIManager.LookAndFeelInfo info : UIManager.getInstalledLookAndFeels()) {
                if ("Nimbus".equals(info.getName())) {
                    UIManager.setLookAndFeel(info.getClassName());
                    break;
                }
            }
        } catch (Exception ex) {
            try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); }
            catch (Exception ex2) { /* fall back to default */ }
        }

        Color accent  = new Color(0x2D, 0x6C, 0xDF);
        Color bgLight = new Color(0xF7, 0xF8, 0xFA);
        Color panelBg = Color.WHITE;
        Color text    = new Color(0x22, 0x26, 0x2B);

        UIManager.put("control", bgLight);
        UIManager.put("info", panelBg);
        UIManager.put("nimbusBase", new Color(0x2B, 0x2F, 0x38));
        UIManager.put("nimbusBlueGrey", new Color(0xCD, 0xD3, 0xDB));
        UIManager.put("nimbusLightBackground", panelBg);
        UIManager.put("nimbusFocus", accent);
        UIManager.put("nimbusSelectionBackground", accent);
        UIManager.put("nimbusSelectedText", Color.WHITE);
        UIManager.put("nimbusOrange", accent);
        UIManager.put("text", text);
        UIManager.put("controlText", text);
        UIManager.put("menuText", text);
        UIManager.put("nimbusDisabledText", new Color(0xA5, 0xAC, 0xB5));

        Font baseFont = new Font(Font.SANS_SERIF, Font.PLAIN, 13);
        UIManager.put("defaultFont", baseFont);

        UIManager.put("Table.rowHeight", 24);
        UIManager.put("Table.gridColor", new Color(0xE3, 0xE6, 0xEA));
        UIManager.put("Table.selectionBackground", accent);
        UIManager.put("Table.selectionForeground", Color.WHITE);
        UIManager.put("TableHeader.background", new Color(0xEE, 0xF1, 0xF5));
        UIManager.put("TableHeader.foreground", new Color(0x33, 0x38, 0x3F));
    }

    /** Solid accent-colored style for the single "main action" button of a
     *  panel (Search, Start Server). Uses a plain BasicButtonUI so the
     *  custom background paints correctly under Nimbus. */
    private static void stylePrimaryButton(AbstractButton b) {
        b.setUI(new javax.swing.plaf.basic.BasicButtonUI());
        b.setBackground(new Color(0x2D, 0x6C, 0xDF));
        b.setForeground(Color.WHITE);
        b.setFont(b.getFont().deriveFont(Font.BOLD));
        b.setFocusPainted(false);
        b.setOpaque(true);
        b.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(0x1F, 0x4F, 0xB0)),
                new EmptyBorder(6, 14, 6, 14)));
    }

    /** Flat outline style for secondary/utility buttons. */
    private static void styleSecondaryButton(AbstractButton b) {
        b.setFocusPainted(false);
        b.setBorder(BorderFactory.createCompoundBorder(
                BorderFactory.createLineBorder(new Color(0xCD, 0xD3, 0xDB)),
                new EmptyBorder(5, 12, 5, 12)));
    }

    // ===================================================================
    //                          main
    // ===================================================================
    public static void main(String[] args) {
        applyModernTheme();

        SwingUtilities.invokeLater(new Runnable() {
            @Override public void run() {
                Meshare app = new Meshare();
                app.setVisible(true);
            }
        });
    }
}