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.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.ServerSocket;
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.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.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

/**
 * Meshare (web edition) - distributed file browser/searcher.
 *
 * Single-file, Java 8 compatible, no AWT/Swing. On launch it starts a local
 * HTTP server automatically and opens the default browser to it, so the
 * whole app is used from a normal web page instead of a desktop GUI.
 *
 * Functionally this mirrors the original desktop Meshare: it 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 into the page live over
 * Server-Sent Events as they are found. A search box filters by any field.
 * Files can be downloaded individually, in bulk for the current results, or
 * for every file on every server. Optionally this instance can also share
 * its own files/, info/, files.txt and links.txt over HTTP so it can be
 * added to another Meshare user's servers.txt.
 *
 * 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 {

    // ---------------- 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 String LOCAL_LINKS_LIST = "links.txt";
    private static final String PUBLIC_KEY_FILE  = "public_key.txt";
    private static final String TMP_LINKS_DIR    = "tmp_links";

    private static final int    DEFAULT_UI_PORT      = 8080;
    private static final int    DEFAULT_SHARE_PORT    = 8765;

    private static final int MAX_RESULTS         = 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;

    private static final int MAX_REMOTE_SERVERS_FILE_BYTES = 500 * 1024; // 500 KB cap for a peer's servers.txt/files.txt
    private static final int MAX_REMOTE_SERVERS_LINES      = 1000;       // ignore any lines beyond this in a peer's servers.txt
    private static final int[] VISITOR_DISCOVERY_PORTS     = {80, 8080, 8765};
    private static final int DISCOVERY_CONNECT_TIMEOUT_MS  = 4000;
    private static final int DISCOVERY_READ_TIMEOUT_MS     = 6000;

    private static final java.nio.charset.Charset UTF8 = StandardCharsets.UTF_8;

    // ===================================================================
    //                          instance state
    // ===================================================================
    private final List<FileEntry> currentResults = new CopyOnWriteArrayList<FileEntry>();
    private final Map<String, FileEntry> resultsByKey = new ConcurrentHashMap<String, FileEntry>();
    private final AtomicBoolean       searchActive    = new AtomicBoolean(false);
    private final AtomicBoolean       downloadActive  = new AtomicBoolean(false);
    private final AtomicInteger       resultsCount    = new AtomicInteger(0);
    private final AtomicInteger       downloadedCount = new AtomicInteger(0);
    private final ExecutorService     executor;

    private final List<String> logBuffer = Collections.synchronizedList(new ArrayList<String>());

    private final List<SseClient> sseClients = new CopyOnWriteArrayList<SseClient>();

    private volatile HttpServer shareServer;
    private ExecutorService      shareServerExecutor;
    private volatile int         shareServerPort = DEFAULT_SHARE_PORT;

    private HttpServer uiServer;
    private int         uiPort;

    private final Object serversFileLock = new Object();
    private final Set<String> checkedVisitorIps =
            Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());

    public Meshare() {
        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;
            }
        });
        ensureDirectories();
    }

    private void ensureDirectories() {
        new File(FILES_DIR).mkdirs();
        new File(INFO_DIR).mkdirs();
        new File(LOG_DIR).mkdirs();
        new File(TMP_LINKS_DIR).mkdirs();
    }

    // ===================================================================
    //                          FileEntry
    // ===================================================================
    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; }

        /** JSON object (not wrapped) describing this entry for the browser UI. */
        public String toJsonFragment(String displayName) {
            Map<String, Object> m = new LinkedHashMap<String, Object>();
            m.put("key", getKey());
            m.put("server", server);
            m.put("hash", hash);
            m.put("extension", extension);
            m.put("filename", filename);
            m.put("displayFilename", displayName);
            m.put("size", size);
            m.put("description", description);
            m.put("date", date);
            m.put("status", status);
            m.put("isLocal", Boolean.valueOf(isLocal));
            m.put("isLink", Boolean.valueOf(isLink));
            String remoteUrl = "";
            if (server != null && !server.isEmpty() && hash != null && extension != null && !extension.isEmpty()) {
                remoteUrl = normalizeServer(server) + "files/" + hash + "." + extension;
            }
            m.put("remoteUrl", remoteUrl);
            return toJsonObject(m);
        }
    }

    // ===================================================================
    //                          SSE plumbing
    // ===================================================================
    private static class SseClient {
        final BlockingQueue<String> queue = new LinkedBlockingQueue<String>();
    }

    private void broadcast(Map<String, Object> event) {
        String json = toJsonObject(event);
        for (SseClient c : sseClients) {
            c.queue.offer(json);
        }
    }

    private void log(String message) {
        String line = "[" + new SimpleDateFormat("HH:mm:ss").format(new Date()) + "] " + message;
        logBuffer.add(line);
        synchronized (logBuffer) {
            while (logBuffer.size() > MAX_LOG_LINES) logBuffer.remove(0);
        }
        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "log");
        ev.put("line", line);
        broadcast(ev);
    }

    private void setStatus(String text) {
        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "status");
        ev.put("text", text);
        broadcast(ev);
    }

    // ===================================================================
    //                          search
    // ===================================================================
    private void startSearch(final String query, final boolean randomOn, final int randomCount,
                              final boolean allowLinks) {
        if (searchActive.get()) {
            log("Search already in progress");
            return;
        }

        currentResults.clear();
        resultsByKey.clear();
        resultsCount.set(0);

        Map<String, Object> clearEv = new LinkedHashMap<String, Object>();
        clearEv.put("type", "clearResults");
        broadcast(clearEv);
        setStatus("Searching...");

        final List<String> servers = readServers();
        if (servers.isEmpty()) {
            log("No servers configured (add URLs to servers.txt)");
            finishSearch();
            return;
        }

        searchActive.set(true);
        broadcastSearchState();

        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() {
        searchActive.set(false);
        broadcastSearchState();
        setStatus("Search done: " + resultsCount.get() + " result(s)");
    }

    private void broadcastSearchState() {
        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "searchState");
        ev.put("active", Boolean.valueOf(searchActive.get()));
        ev.put("resultsCount", Integer.valueOf(resultsCount.get()));
        ev.put("maxResults", Integer.valueOf(MAX_RESULTS));
        broadcast(ev);
    }

    private void stopAll() {
        searchActive.set(false);
        downloadActive.set(false);
        setStatus("Stop requested...");
        broadcastSearchState();
    }

    private void searchServer(String server, String query, boolean randomOn,
                              int randomCount, boolean allowLinks) {
        final String baseUrl = normalizeServer(server);

        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)");
            }
        }

        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);
        }

        if (allowLinks) {
            String linksListContent = fetchLinksListWithCache(baseUrl, server);
            if (linksListContent == null) {
                log("[" + server + "] failed to fetch links.txt");
            } else {
                processLinksList(baseUrl, server, query, linksListContent, serverPublicKey);
            }
        }
    }

    private String fetchLinksListWithCache(String baseUrl, String server) {
        String urlHash = computeSha256OfBytes(baseUrl.getBytes(UTF8));
        File cacheFile = urlHash != null ? new File(TMP_LINKS_DIR, urlHash + ".txt") : null;

        if (cacheFile != null && cacheFile.exists()) {
            String cached = readFile(cacheFile);
            if (cached != null) {
                log("[" + server + "] links.txt loaded from local cache (" + cacheFile.getName() + ")");
                return cached;
            }
        }

        setStatus("[" + server + "] fetching links.txt");
        String linksListContent = httpGet(baseUrl + "links.txt");
        if (linksListContent != null && cacheFile != null) {
            saveTextFile(cacheFile, linksListContent);
            log("[" + server + "] links.txt cached to " + cacheFile.getPath());
        }
        return linksListContent;
    }

    private void saveTextFile(File f, String content) {
        FileWriter w = null;
        try {
            File parent = f.getParentFile();
            if (parent != null) parent.mkdirs();
            w = new FileWriter(f);
            w.write(content);
        } catch (IOException e) {
            log("Error saving " + f.getPath() + ": " + e.getMessage());
        } finally {
            if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    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;

            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;

            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, query);
            } else {
                processLinkRaw(baseUrl, server, line, serverPublicKey, query);
            }
        }

        log("[" + server + "] links.txt: " + matched + " matched (of " + total + ")");
    }

    private void processLinkUrl(String baseUrl, String server, String fullLine,
                                String urlStr, String ext, String serverPublicKey, String query) {
        if (resultsCount.get() >= MAX_RESULTS) return;

        String fileHash = computeSha256OfBytes(urlStr.getBytes(UTF8));
        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");

        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, query)) {
            addResult(entry);
        }
        log("[" + server + "] link URL (not auto-downloaded): " + fileHash + "." + ext);
    }

    private void processLinkRaw(String baseUrl, String server, String line, String serverPublicKey, String query) {
        if (resultsCount.get() >= MAX_RESULTS) return;

        byte[] lineBytes = line.getBytes(UTF8);
        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");

        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, query)) {
            addResult(entry);
        }
        log("[" + server + "] link raw (not auto-downloaded): " + fileHash + ".txt");
    }

    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;
        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);
        resultsByKey.put(entry.getKey(), entry);

        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "result");
        ev.put("entry", new RawJson(entry.toJsonFragment(getDisplayedFilename(entry))));
        broadcast(ev);
        setStatus("Results: " + resultsCount.get() + " / " + MAX_RESULTS);
    }

    /** Marker wrapper so toJsonObject() embeds an already-serialized JSON string verbatim. */
    private static class RawJson {
        final String json;
        RawJson(String json) { this.json = json; }
        @Override public String toString() { return json; }
    }

    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;
            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);
    }

    private void populateEntry(FileEntry entry, Map<String, String> fields) {
        entry.rawFields.putAll(fields);
        entry.filename        = get(fields, "filename", "");
        entry.size            = get(fields, "size", "");
        String jsonExt        = fields.get("extension");
        if (jsonExt != null && !jsonExt.isEmpty()) entry.extension = jsonExt;
        entry.publicKey       = get(fields, "public_key", "");
        entry.serverPublicKey = get(fields, "server_public_key", "");
        entry.nodeInfo        = get(fields, "node_info", "");
        entry.description     = get(fields, "description", "");
        entry.date            = get(fields, "date", "");
    }

    private static String get(Map<String, String> m, String k, String def) {
        String v = m.get(k);
        return v == null ? def : v;
    }

    // ===================================================================
    //                          download
    // ===================================================================
    private void downloadKeys(List<String> keys) {
        List<FileEntry> todo = new ArrayList<FileEntry>();
        for (String key : keys) {
            FileEntry e = resultsByKey.get(key);
            if (e != null && !e.isLocal) todo.add(e);
        }
        if (todo.isEmpty()) {
            log("Nothing to download (already downloaded or not found)");
            return;
        }
        log("Queuing " + todo.size() + " download(s)");
        for (final 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 (final 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)");
        broadcastDownloadState(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);
                broadcastDownloadState(false);
                log("Download all complete: " + downloadedCount.get() + " file(s) downloaded");
            }
        });
    }

    private void broadcastDownloadState(boolean active) {
        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "downloadState");
        ev.put("active", Boolean.valueOf(active));
        ev.put("downloadedCount", Integer.valueOf(downloadedCount.get()));
        broadcast(ev);
    }

    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];

            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);
            broadcastDownloadState(true);
        }
        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)) {
            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 {
            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();
            }
        }

        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;
        }

        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;
        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "entryUpdate");
        ev.put("key", entry.getKey());
        ev.put("status", status);
        ev.put("isLocal", Boolean.valueOf(entry.isLocal));
        broadcast(ev);
    }

    // ===================================================================
    //                          HTTP client
    // ===================================================================
    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(), UTF8);
    }

    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();
            return hex(digest);
        } 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);
            return hex(md.digest());
        } catch (Exception e) {
            return null;
        }
    }

    private static String hex(byte[] digest) {
        StringBuilder sb = new StringBuilder(digest.length * 2);
        for (byte b : digest) sb.append(String.format("%02x", b));
        return sb.toString();
    }

    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), UTF8));
                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 */ }
            }
        }
    }

    // ===================================================================
    //                          servers.txt / misc parsing
    // ===================================================================
    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), UTF8));
            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 + "/";
    }

    // ===================================================================
    //                          server discovery
    // ===================================================================

    /** Called once at startup: asks every known server for its own servers.txt and merges in any new, valid ones. */
    private void discoverServersAtStartup() {
        final List<String> servers = readServers();
        if (servers.isEmpty()) return;
        log("Checking " + servers.size() + " known server(s) for new peer URLs...");
        final List<Future<?>> futures = new ArrayList<Future<?>>();
        for (final String server : servers) {
            futures.add(executor.submit(new Runnable() {
                @Override public void run() {
                    try { discoverServersFrom(server); }
                    catch (Throwable t) { log("Peer discovery failed for " + server + ": " + t.getMessage()); }
                }
            }));
        }
        for (Future<?> f : futures) {
            try { f.get(); } catch (Exception ignore) { /* ignore */ }
        }
        log("Peer discovery finished.");
    }

    /** Fetches "<server>/servers.txt" and adds any new, verified server URLs found in it. */
    private void discoverServersFrom(String server) {
        String baseUrl = normalizeServer(server);
        String content = httpGetLimited(baseUrl + SERVERS_FILE, MAX_REMOTE_SERVERS_FILE_BYTES);
        if (content == null) return; // offline, or no servers.txt there - nothing to do

        String[] lines = content.split("\r?\n");
        int consideredLines = 0;
        int addedCount = 0;
        for (String rawLine : lines) {
            String candidate = rawLine.trim();
            if (candidate.isEmpty() || candidate.startsWith("#")) continue;
            if (consideredLines >= MAX_REMOTE_SERVERS_LINES) break; // ignore any lines beyond the limit
            consideredLines++;

            if (isServerAlreadyKnown(candidate)) continue;
            if (!verifyCandidateServer(candidate)) continue;
            if (addServerIfNew(candidate)) {
                addedCount++;
                log("Discovered new server from " + server + ": " + candidate);
            }
        }
        if (addedCount > 0) log("Added " + addedCount + " new server(s) found via " + server);
    }

    /** A candidate discovered in a peer's servers.txt must be http(s) and reachable, and must host a files.txt with >=1 valid line. */
    private boolean verifyCandidateServer(String candidateUrl) {
        if (candidateUrl == null) return false;
        String lower = candidateUrl.toLowerCase();
        if (!lower.startsWith("http://") && !lower.startsWith("https://")) return false;

        String baseUrl = normalizeServer(candidateUrl);
        String filesTxt = httpGetLimited(baseUrl + LOCAL_FILES_LIST, MAX_REMOTE_SERVERS_FILE_BYTES);
        if (filesTxt == null) return false; // not online, or no files.txt

        String[] lines = filesTxt.split("\r?\n");
        for (String line : lines) {
            if (parseFileLine(line) != null) return true; // at least one valid "<sha256>.<ext>" line; don't verify the file itself
        }
        return false;
    }

    /** Every time an external visitor connects to our share server, probe their IP on common ports for a Meshare peer. */
    private void discoverServerFromVisitor(String ip) {
        for (int port : VISITOR_DISCOVERY_PORTS) {
            String httpUrl  = "http://"  + ip + ":" + port + "/";
            String httpsUrl = "https://" + ip + ":" + port + "/";

            String online = null;
            if (isHttpServerOnline(httpUrl)) {
                online = httpUrl;
            } else if (isHttpServerOnline(httpsUrl)) {
                online = httpsUrl;
            }
            if (online == null) continue;
            if (isServerAlreadyKnown(online)) continue;
            if (addServerIfNew(online)) {
                log("Discovered new server from visitor " + ip + ": " + online);
            }
        }
    }

    /** Lightweight reachability probe used for visitor IP/port discovery - just needs an HTTP response, not a valid files.txt. */
    private boolean isHttpServerOnline(String urlStr) {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(urlStr);
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(DISCOVERY_CONNECT_TIMEOUT_MS);
            conn.setReadTimeout(DISCOVERY_READ_TIMEOUT_MS);
            conn.setRequestProperty("User-Agent", "Meshare/1.0");
            conn.setInstanceFollowRedirects(true);
            int code = conn.getResponseCode();
            return code > 0;
        } catch (Exception e) {
            return false;
        } finally {
            if (conn != null) conn.disconnect();
        }
    }

    /** GET with a hard cap on bytes read, so a huge or malicious response can't be used to exhaust memory/disk. */
    private String httpGetLimited(String urlStr, int maxBytes) {
        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) return null;

            InputStream is = conn.getInputStream();
            try {
                ByteArrayOutputStream bos = new ByteArrayOutputStream();
                byte[] buf = new byte[8192];
                int n;
                int total = 0;
                while ((n = is.read(buf)) >= 0) {
                    int room = maxBytes - total;
                    if (room <= 0) break; // hit the cap: stop reading, keep what we have (truncate)
                    int toWrite = Math.min(n, room);
                    bos.write(buf, 0, toWrite);
                    total += toWrite;
                    if (total >= maxBytes) break;
                }
                return bos.toString("UTF-8");
            } finally {
                try { is.close(); } catch (IOException e) { /* ignore */ }
            }
        } catch (Exception e) {
            return null;
        } finally {
            if (conn != null) conn.disconnect();
        }
    }

    /** True if this exact non-comment line is already present in the local servers.txt. */
    private boolean isServerAlreadyKnown(String candidate) {
        synchronized (serversFileLock) {
            for (String line : readServers()) {
                if (line.equals(candidate)) return true;
            }
        }
        return false;
    }

    /** Appends a server URL to servers.txt if (and only if) that exact line isn't already present. Returns true if it was added. */
    private boolean addServerIfNew(String candidate) {
        if (candidate == null || candidate.isEmpty()) return false;
        synchronized (serversFileLock) {
            for (String line : readServers()) {
                if (line.equals(candidate)) return false;
            }
            FileWriter w = null;
            try {
                File f = new File(SERVERS_FILE);
                String existing = readFile(f);
                boolean needsLeadingNewline = existing != null && existing.length() > 0
                        && !existing.endsWith("\n") && !existing.endsWith("\r\n");
                w = new FileWriter(f, true); // append
                if (needsLeadingNewline) w.write("\n");
                w.write(candidate);
                w.write("\n");
                return true;
            } catch (IOException e) {
                log("Failed to update servers.txt: " + e.getMessage());
                return false;
            } finally {
                if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
            }
        }
    }

    private static boolean isLocalAddress(String ip) {
        return ip == null || ip.startsWith("127.") || "0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip);
    }

    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;
    }

    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;
    }

    // ===================================================================
    //                          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) == '{') {
                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;
    }

    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 if (v instanceof RawJson) {
                sb.append(v.toString());
            } else if (v instanceof List) {
                sb.append(toJsonArray((List<?>) v));
            } else {
                sb.append('"').append(escapeJsonString(v.toString())).append('"');
            }
        }
        sb.append('}');
        return sb.toString();
    }

    public static String toJsonArray(List<?> list) {
        StringBuilder sb = new StringBuilder("[");
        boolean first = true;
        for (Object v : list) {
            if (!first) sb.append(',');
            first = false;
            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 if (v instanceof RawJson) {
                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();
    }

    /** Very small query-string / form decoder (application/x-www-form-urlencoded or ?a=b&c=d). */
    private static Map<String, String> parseQuery(String q) {
        Map<String, String> m = new LinkedHashMap<String, String>();
        if (q == null || q.isEmpty()) return m;
        String[] pairs = q.split("&");
        for (String p : pairs) {
            if (p.isEmpty()) continue;
            int eq = p.indexOf('=');
            try {
                if (eq < 0) {
                    m.put(URLDecoder.decode(p, "UTF-8"), "");
                } else {
                    String k = URLDecoder.decode(p.substring(0, eq), "UTF-8");
                    String v = URLDecoder.decode(p.substring(eq + 1), "UTF-8");
                    m.put(k, v);
                }
            } catch (Exception e) { /* ignore malformed pair */ }
        }
        return m;
    }

    // ===================================================================
    //                          share server (act as a node for peers)
    // ===================================================================
    private void startShareServer(final int port) {
        if (shareServer != null) {
            log("Share server already running");
            return;
        }
        try {
            HttpServer s = HttpServer.create(new InetSocketAddress(port), 0);
            s.createContext("/", new ShareHttpHandler());
            shareServerExecutor = Executors.newCachedThreadPool(new ThreadFactory() {
                private int id = 0;
                @Override public Thread newThread(Runnable r) {
                    Thread t = new Thread(r, "meshare-share-" + (++id));
                    t.setDaemon(true);
                    return t;
                }
            });
            s.setExecutor(shareServerExecutor);
            s.start();
            shareServer = s;
            shareServerPort = port;
            log("Share server started on port " + port
                    + " (serving files/, info/, " + LOCAL_FILES_LIST + ", " + LOCAL_LINKS_LIST + ")");
            broadcastShareState(true, port, null);
        } catch (final IOException e) {
            log("Failed to start share server on port " + port + ": " + e.getMessage());
            shareServer = null;
            broadcastShareState(false, port, e.getMessage());
        }
    }

    private void stopShareServer() {
        final HttpServer s = shareServer;
        if (s == null) return;
        shareServer = null;
        try {
            s.stop(0);
        } catch (Exception e) { /* ignore */ }
        if (shareServerExecutor != null) {
            shareServerExecutor.shutdownNow();
            shareServerExecutor = null;
        }
        log("Share server stopped");
        broadcastShareState(false, shareServerPort, null);
    }

    private void broadcastShareState(boolean running, int port, String error) {
        Map<String, Object> ev = new LinkedHashMap<String, Object>();
        ev.put("type", "shareState");
        ev.put("running", Boolean.valueOf(running));
        ev.put("port", Integer.valueOf(port));
        if (error != null) ev.put("error", error);
        broadcast(ev);
    }

    private class ShareHttpHandler implements HttpHandler {
        @Override
        public void handle(HttpExchange exchange) throws IOException {
            try {
                String method = exchange.getRequestMethod();
                if (!"GET".equalsIgnoreCase(method)) {
                    sendResponse(exchange, 405, "text/plain", "Method Not Allowed".getBytes(UTF8));
                    return;
                }

                String path = exchange.getRequestURI().getPath();
                if (path == null) path = "/";

                log("[share-server] " + exchange.getRemoteAddress() + " GET " + path);

                InetSocketAddress remote = exchange.getRemoteAddress();
                if (remote != null && remote.getAddress() != null) {
                    final String visitorIp = remote.getAddress().getHostAddress();
                    if (!isLocalAddress(visitorIp) && checkedVisitorIps.add(visitorIp)) {
                        executor.submit(new Runnable() {
                            @Override public void run() {
                                try { discoverServerFromVisitor(visitorIp); }
                                catch (Throwable t) { log("Visitor discovery failed for " + visitorIp + ": " + t.getMessage()); }
                            }
                        });
                    }
                }

                if (path.equals("/") || path.isEmpty()) {
                    String body = "Meshare node online.\n";
                    sendResponse(exchange, 200, "text/plain", body.getBytes(UTF8));
                } else if (path.equals("/files.txt")) {
                    serveFile(exchange, new File(LOCAL_FILES_LIST), "text/plain");
                } else if (path.equals("/links.txt")) {
                    serveFile(exchange, new File(LOCAL_LINKS_LIST), "text/plain");
                } else if (path.equals("/public_key.txt")) {
                    serveFile(exchange, new File(PUBLIC_KEY_FILE), "text/plain");
                } else if (path.startsWith("/info/")) {
                    String name = sanitizeRequestName(path.substring("/info/".length()));
                    if (name == null) {
                        sendResponse(exchange, 400, "text/plain", "Bad Request".getBytes(UTF8));
                    } else {
                        serveFile(exchange, new File(INFO_DIR, name), "application/json");
                    }
                } else if (path.startsWith("/files/")) {
                    String name = sanitizeRequestName(path.substring("/files/".length()));
                    if (name == null) {
                        sendResponse(exchange, 400, "text/plain", "Bad Request".getBytes(UTF8));
                    } else {
                        serveFile(exchange, new File(FILES_DIR, name), "application/octet-stream");
                    }
                } else {
                    sendResponse(exchange, 404, "text/plain", "Not Found".getBytes(UTF8));
                }
            } catch (Exception e) {
                try {
                    sendResponse(exchange, 500, "text/plain",
                            ("Internal error: " + e.getMessage()).getBytes(UTF8));
                } catch (IOException ignore) { /* ignore */ }
            } finally {
                exchange.close();
            }
        }
    }

    private static String sanitizeRequestName(String raw) {
        if (raw == null || raw.isEmpty()) return null;
        String name = raw;
        try {
            name = URLDecoder.decode(raw, "UTF-8");
        } catch (Exception e) { /* keep raw */ }
        if (name.isEmpty()) return null;
        if (name.contains("/") || name.contains("\\")) return null;
        if (name.contains("..")) return null;
        return name;
    }

    private void serveFile(HttpExchange exchange, File f, String contentType) throws IOException {
        if (f == null || !f.exists() || !f.isFile()) {
            byte[] body = "Not Found".getBytes(UTF8);
            sendResponse(exchange, 404, "text/plain", body);
            return;
        }
        exchange.getResponseHeaders().set("Content-Type", contentType);
        exchange.sendResponseHeaders(200, f.length());
        OutputStream os = exchange.getResponseBody();
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(f);
            byte[] buf = new byte[16 * 1024];
            int n;
            while ((n = fis.read(buf)) >= 0) os.write(buf, 0, n);
        } finally {
            if (fis != null) try { fis.close(); } catch (IOException e) { /* ignore */ }
            os.close();
        }
    }

    private static void sendResponse(HttpExchange exchange, int code, String contentType, byte[] body) throws IOException {
        exchange.getResponseHeaders().set("Content-Type", contentType);
        exchange.sendResponseHeaders(code, body.length);
        OutputStream os = exchange.getResponseBody();
        try {
            os.write(body);
        } finally {
            os.close();
        }
    }

    // ===================================================================
    //                          UI HTTP server
    // ===================================================================
    private void startUiServer(int preferredPort) throws IOException {
        int port = findFreePort(preferredPort);
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/", new RootHandler());
        server.createContext("/api/events", new EventsHandler());
        server.createContext("/api/state", new StateHandler());
        server.createContext("/api/search", new SearchHandler());
        server.createContext("/api/stop", new StopHandler());
        server.createContext("/api/download", new DownloadHandler());
        server.createContext("/api/servers/reload", new ServersReloadHandler());
        server.createContext("/api/entry/json", new EntryJsonHandler());
        server.createContext("/api/share/start", new ShareStartHandler());
        server.createContext("/api/share/stop", new ShareStopHandler());
        server.createContext("/api/openFolder", new OpenFolderHandler());
        server.createContext("/api/file", new FileConfigHandler());
        server.createContext("/files/", new LocalFilesHandler());
        server.createContext("/info/", new LocalInfoHandler());
        ExecutorService uiExecutor = Executors.newCachedThreadPool(new ThreadFactory() {
            private int id = 0;
            @Override public Thread newThread(Runnable r) {
                Thread t = new Thread(r, "meshare-ui-" + (++id));
                t.setDaemon(false);
                return t;
            }
        });
        server.setExecutor(uiExecutor);
        server.start();
        this.uiServer = server;
        this.uiPort = port;
    }

    private static int findFreePort(int preferred) {
        for (int p = preferred; p < preferred + 50; p++) {
            try {
                ServerSocket s = new ServerSocket(p);
                s.close();
                return p;
            } catch (IOException e) { /* try next */ }
        }
        return preferred;
    }

    private class RootHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            String path = exchange.getRequestURI().getPath();
            if (!"/".equals(path) && !"/index.html".equals(path)) {
                sendResponse(exchange, 404, "text/plain", "Not Found".getBytes(UTF8));
                return;
            }
            byte[] body = INDEX_HTML.getBytes(UTF8);
            sendResponse(exchange, 200, "text/html; charset=utf-8", body);
        }
    }

    private class EventsHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            exchange.getResponseHeaders().set("Content-Type", "text/event-stream; charset=utf-8");
            exchange.getResponseHeaders().set("Cache-Control", "no-cache");
            exchange.getResponseHeaders().set("Connection", "keep-alive");
            exchange.sendResponseHeaders(200, 0);
            OutputStream os = exchange.getResponseBody();
            SseClient client = new SseClient();
            sseClients.add(client);
            try {
                os.write(": connected\n\n".getBytes(UTF8));
                os.flush();
                while (true) {
                    String msg = client.queue.take();
                    os.write(("data: " + msg + "\n\n").getBytes(UTF8));
                    os.flush();
                }
            } catch (Exception e) {
                // client disconnected or thread interrupted; fall through to cleanup
            } finally {
                sseClients.remove(client);
                try { os.close(); } catch (Exception ignore) { /* ignore */ }
            }
        }
    }

    private class StateHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            Map<String, Object> state = new LinkedHashMap<String, Object>();
            state.put("serverCount", Integer.valueOf(readServers().size()));
            state.put("searchActive", Boolean.valueOf(searchActive.get()));
            state.put("downloadActive", Boolean.valueOf(downloadActive.get()));
            state.put("resultsCount", Integer.valueOf(resultsCount.get()));
            state.put("maxResults", Integer.valueOf(MAX_RESULTS));
            state.put("shareRunning", Boolean.valueOf(shareServer != null));
            state.put("sharePort", Integer.valueOf(shareServerPort));
            state.put("uiPort", Integer.valueOf(uiPort));

            List<Object> results = new ArrayList<Object>();
            for (FileEntry e : currentResults) {
                results.add(new RawJson(e.toJsonFragment(getDisplayedFilename(e))));
            }
            state.put("results", results);

            List<Object> logs = new ArrayList<Object>();
            synchronized (logBuffer) {
                int start = Math.max(0, logBuffer.size() - 400);
                for (int i = start; i < logBuffer.size(); i++) logs.add(logBuffer.get(i));
            }
            state.put("log", logs);

            byte[] body = toJsonObject(state).getBytes(UTF8);
            sendResponse(exchange, 200, "application/json; charset=utf-8", body);
        }
    }

    private class SearchHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendResponse(exchange, 405, "text/plain", "Method Not Allowed".getBytes(UTF8));
                return;
            }
            Map<String, String> params = parseQuery(readStream(exchange.getRequestBody()));
            final String query = get(params, "query", "");
            final boolean randomOn = "true".equals(get(params, "random", "false"));
            int randomCount = 10;
            try { randomCount = Integer.parseInt(get(params, "randomCount", "10")); } catch (Exception ignore) { /* ignore */ }
            final int rc = Math.max(1, randomCount);
            final boolean allowLinks = "true".equals(get(params, "allowLinks", "true"));

            executor.submit(new Runnable() {
                @Override public void run() { startSearch(query, randomOn, rc, allowLinks); }
            });
            sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
        }
    }

    private class StopHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            stopAll();
            sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
        }
    }

    private class DownloadHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
                sendResponse(exchange, 405, "text/plain", "Method Not Allowed".getBytes(UTF8));
                return;
            }
            Map<String, String> params = parseQuery(readStream(exchange.getRequestBody()));
            String mode = get(params, "mode", "selected");
            if ("all".equals(mode)) {
                executor.submit(new Runnable() {
                    @Override public void run() { downloadAll(); }
                });
            } else if ("matching".equals(mode)) {
                executor.submit(new Runnable() {
                    @Override public void run() { downloadMatching(); }
                });
            } else {
                String keysParam = get(params, "keys", "");
                final List<String> keys = new ArrayList<String>();
                for (String k : keysParam.split("\\|")) {
                    if (!k.trim().isEmpty()) keys.add(k.trim());
                }
                executor.submit(new Runnable() {
                    @Override public void run() { downloadKeys(keys); }
                });
            }
            sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
        }
    }

    private class ServersReloadHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            int count = readServers().size();
            Map<String, Object> ev = new LinkedHashMap<String, Object>();
            ev.put("type", "serverCount");
            ev.put("count", Integer.valueOf(count));
            broadcast(ev);
            sendResponse(exchange, 200, "application/json", ("{\"count\":" + count + "}").getBytes(UTF8));
        }
    }

    private class EntryJsonHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseQuery(exchange.getRequestURI().getRawQuery());
            String key = get(params, "key", "");
            String text = null;
            FileEntry e = resultsByKey.get(key);
            if (e != null && e.rawJson != null && !e.rawJson.isEmpty()) {
                text = e.rawJson;
            } else {
                int dot = key.lastIndexOf('.');
                if (dot > 0) {
                    String hash = key.substring(0, dot);
                    text = readFile(new File(INFO_DIR, hash + ".json"));
                }
            }
            if (text == null) text = "{}";
            sendResponse(exchange, 200, "application/json; charset=utf-8", text.getBytes(UTF8));
        }
    }

    private class ShareStartHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            Map<String, String> params = parseQuery(readStream(exchange.getRequestBody()));
            int port = DEFAULT_SHARE_PORT;
            try { port = Integer.parseInt(get(params, "port", String.valueOf(DEFAULT_SHARE_PORT))); } catch (Exception ignore) { /* ignore */ }
            startShareServer(port);
            sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
        }
    }

    private class ShareStopHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            stopShareServer();
            sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
        }
    }

    private class OpenFolderHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            try {
                File f = new File(FILES_DIR).getAbsoluteFile();
                if (!f.exists()) f.mkdirs();
                openPathInOs(f);
                log("Opened files folder: " + f.getAbsolutePath());
            } catch (Exception e) {
                log("Cannot open folder: " + e.getMessage());
            }
            sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
        }
    }

    /** GET /api/file?name=servers.txt|links.txt|public_key.txt to read/edit small config files from the browser. */
    private class FileConfigHandler implements HttpHandler {
        private final Set<String> allowed = new HashSet<String>(java.util.Arrays.asList(
                SERVERS_FILE, LOCAL_LINKS_LIST, PUBLIC_KEY_FILE));

        @Override public void handle(HttpExchange exchange) throws IOException {
            String method = exchange.getRequestMethod();
            if ("GET".equalsIgnoreCase(method)) {
                Map<String, String> params = parseQuery(exchange.getRequestURI().getRawQuery());
                String name = get(params, "name", "");
                if (!allowed.contains(name)) {
                    sendResponse(exchange, 400, "text/plain", "Unknown file".getBytes(UTF8));
                    return;
                }
                File f = new File(name);
                String content = f.exists() ? readFile(f) : "";
                if (content == null) content = "";
                Map<String, Object> resp = new LinkedHashMap<String, Object>();
                resp.put("name", name);
                resp.put("content", content);
                sendResponse(exchange, 200, "application/json; charset=utf-8", toJsonObject(resp).getBytes(UTF8));
            } else if ("POST".equalsIgnoreCase(method)) {
                Map<String, String> params = parseQuery(readStream(exchange.getRequestBody()));
                String name = get(params, "name", "");
                String content = get(params, "content", "");
                if (!allowed.contains(name)) {
                    sendResponse(exchange, 400, "text/plain", "Unknown file".getBytes(UTF8));
                    return;
                }
                saveTextFile(new File(name), content);
                log("Saved " + name);
                if (SERVERS_FILE.equals(name)) {
                    Map<String, Object> ev = new LinkedHashMap<String, Object>();
                    ev.put("type", "serverCount");
                    ev.put("count", Integer.valueOf(readServers().size()));
                    broadcast(ev);
                }
                sendResponse(exchange, 200, "application/json", "{\"ok\":true}".getBytes(UTF8));
            } else {
                sendResponse(exchange, 405, "text/plain", "Method Not Allowed".getBytes(UTF8));
            }
        }
    }

    private class LocalFilesHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            String path = exchange.getRequestURI().getPath();
            String name = sanitizeRequestName(path.substring("/files/".length()));
            if (name == null) {
                sendResponse(exchange, 400, "text/plain", "Bad Request".getBytes(UTF8));
                return;
            }
            File f = new File(FILES_DIR, name);
            String contentType = "application/octet-stream";
            exchange.getResponseHeaders().set("Content-Disposition", "inline; filename=\"" + name + "\"");
            serveFile(exchange, f, contentType);
        }
    }

    private class LocalInfoHandler implements HttpHandler {
        @Override public void handle(HttpExchange exchange) throws IOException {
            String path = exchange.getRequestURI().getPath();
            String name = sanitizeRequestName(path.substring("/info/".length()));
            if (name == null) {
                sendResponse(exchange, 400, "text/plain", "Bad Request".getBytes(UTF8));
                return;
            }
            serveFile(exchange, new File(INFO_DIR, name), "application/json");
        }
    }

    // ===================================================================
    //                          browser / OS launch helpers
    // ===================================================================
    private static boolean isWindows() {
        return System.getProperty("os.name", "").toLowerCase().contains("win");
    }

    private static boolean isMac() {
        String os = System.getProperty("os.name", "").toLowerCase();
        return os.contains("mac") || os.contains("darwin");
    }

    /** Opens a URL in the system's default browser, trying several strategies. */
    private static void openBrowser(String url) {
        try {
            if (java.awt.Desktop.isDesktopSupported()
                    && java.awt.Desktop.getDesktop().isSupported(java.awt.Desktop.Action.BROWSE)) {
                java.awt.Desktop.getDesktop().browse(URI.create(url));
                return;
            }
        } catch (Throwable t) { /* fall through to OS-specific commands */ }

        try {
            Process p;
            if (isWindows()) {
                p = Runtime.getRuntime().exec(new String[] { "cmd", "/c", "start", "", url });
            } else if (isMac()) {
                p = Runtime.getRuntime().exec(new String[] { "open", url });
            } else {
                p = Runtime.getRuntime().exec(new String[] { "xdg-open", url });
            }
            if (p != null) return;
        } catch (Throwable t) {
            System.out.println("Could not open a browser automatically. Please open " + url + " manually.");
        }
    }

    /** Opens a folder/file location in the OS file manager, used by "Open Files Folder". */
    private static void openPathInOs(File f) {
        try {
            if (java.awt.Desktop.isDesktopSupported()
                    && java.awt.Desktop.getDesktop().isSupported(java.awt.Desktop.Action.OPEN)) {
                java.awt.Desktop.getDesktop().open(f);
                return;
            }
        } catch (Throwable t) { /* fall through */ }
        try {
            if (isWindows()) {
                Runtime.getRuntime().exec(new String[] { "cmd", "/c", "start", "", f.getAbsolutePath() });
            } else if (isMac()) {
                Runtime.getRuntime().exec(new String[] { "open", f.getAbsolutePath() });
            } else {
                Runtime.getRuntime().exec(new String[] { "xdg-open", f.getAbsolutePath() });
            }
        } catch (Throwable t) { /* nothing more we can do */ }
    }

    // ===================================================================
    //                          embedded web UI
    // ===================================================================
    private static final String INDEX_HTML =
        "<!DOCTYPE html>\n" +
        "<html lang=\"en\">\n" +
        "<head>\n" +
        "<meta charset=\"utf-8\">\n" +
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n" +
        "<title>Meshare</title>\n" +
        "<style>\n" +
        "  :root{\n" +
        "    --bg:#0f1216; --panel:#171b21; --panel2:#1d2229; --border:#2a3038;\n" +
        "    --text:#e7ebf0; --muted:#8b93a1; --accent:#4f8cff; --accent2:#2f6fe0;\n" +
        "    --green:#3ec97a; --red:#ef5a5a; --yellow:#e0b34f;\n" +
        "  }\n" +
        "  *{box-sizing:border-box;}\n" +
        "  html,body{height:100%;}\n" +
        "  body{\n" +
        "    margin:0; background:var(--bg); color:var(--text);\n" +
        "    font:14px/1.45 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif;\n" +
        "  }\n" +
        "  header{\n" +
        "    padding:14px 20px; border-bottom:1px solid var(--border);\n" +
        "    display:flex; align-items:center; gap:14px; background:var(--panel);\n" +
        "  }\n" +
        "  header h1{font-size:17px; margin:0; font-weight:700; letter-spacing:.3px;}\n" +
        "  header h1 span{color:var(--accent);}\n" +
        "  #serverCount{color:var(--muted); font-size:12.5px;}\n" +
        "  main{padding:16px 20px 40px; max-width:1400px; margin:0 auto;}\n" +
        "  .card{\n" +
        "    background:var(--panel); border:1px solid var(--border); border-radius:10px;\n" +
        "    padding:14px 16px; margin-bottom:14px;\n" +
        "  }\n" +
        "  .card h2{font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 10px;}\n" +
        "  .row{display:flex; align-items:center; flex-wrap:wrap; gap:8px;}\n" +
        "  .row + .row{margin-top:10px;}\n" +
        "  input[type=text], input[type=number]{\n" +
        "    background:var(--panel2); border:1px solid var(--border); color:var(--text);\n" +
        "    border-radius:6px; padding:7px 10px; font-size:13.5px;\n" +
        "  }\n" +
        "  input[type=text]:focus, input[type=number]:focus{outline:1px solid var(--accent);}\n" +
        "  #searchField{flex:1 1 260px; min-width:200px;}\n" +
        "  label.inline{display:flex; align-items:center; gap:6px; color:var(--text); font-size:13px; user-select:none;}\n" +
        "  input[type=checkbox]{accent-color:var(--accent); width:15px; height:15px;}\n" +
        "  input[type=number]{width:76px;}\n" +
        "  button{\n" +
        "    background:var(--panel2); color:var(--text); border:1px solid var(--border);\n" +
        "    padding:7px 13px; border-radius:6px; font-size:13px; cursor:pointer;\n" +
        "  }\n" +
        "  button:hover:not(:disabled){border-color:var(--accent);}\n" +
        "  button:disabled{opacity:.45; cursor:default;}\n" +
        "  button.primary{background:var(--accent); border-color:var(--accent2); color:#fff; font-weight:600;}\n" +
        "  button.primary:hover:not(:disabled){background:var(--accent2);}\n" +
        "  button.danger{border-color:#5a2c2c;}\n" +
        "  button.danger:hover:not(:disabled){border-color:var(--red);}\n" +
        "  .spacer{flex:1;}\n" +
        "  #status{color:var(--muted); font-size:12.5px;}\n" +
        "  .search-top{\n" +
        "    position:sticky; top:0; z-index:20; background:var(--panel); border:1px solid var(--border);\n" +
        "    border-radius:10px; padding:14px 16px; margin-bottom:16px; box-shadow:0 6px 18px rgba(0,0,0,.25);\n" +
        "  }\n" +
        "  .search-top .row{gap:10px;}\n" +
        "  #searchField{font-size:15px; padding:10px 14px;}\n" +
        "  #searchBtn{font-size:14px; padding:9px 20px;}\n" +
        "  #resultsWrap{padding:2px;}\n" +
        "  .results-grid{\n" +
        "    display:grid; gap:14px;\n" +
        "    grid-template-columns:repeat(auto-fill, minmax(190px, 1fr));\n" +
        "  }\n" +
        "  .pagination{display:flex; align-items:center; justify-content:center; gap:14px; margin-top:16px;}\n" +
        "  .pagination button{padding:6px 14px;}\n" +
        "  .page-info{color:var(--muted); font-size:12px;}\n" +
        "  .result-card{\n" +
        "    position:relative; background:var(--panel2); border:1px solid var(--border); border-radius:10px;\n" +
        "    overflow:hidden; cursor:pointer; display:flex; flex-direction:column; transition:border-color .15s, transform .1s;\n" +
        "  }\n" +
        "  .result-card:hover{border-color:var(--accent); transform:translateY(-2px);}\n" +
        "  .result-card.local{border-color:#2b5a3c;}\n" +
        "  .thumb-wrap{\n" +
        "    height:110px; background:#0c0f13; display:flex; align-items:center; justify-content:center; overflow:hidden;\n" +
        "  }\n" +
        "  .thumb-wrap img, .thumb-wrap video{width:100%; height:100%; object-fit:cover;}\n" +
        "  .thumb-wrap svg{width:38px; height:38px; color:var(--muted);}\n" +
        "  .card-body{padding:9px 10px 10px; display:flex; flex-direction:column; gap:5px; flex:1;}\n" +
        "  .card-name{font-weight:600; font-size:12.5px; line-height:1.3; word-break:break-word;}\n" +
        "  .card-name a{color:var(--text); text-decoration:none;}\n" +
        "  .card-name a:hover{color:var(--accent); text-decoration:underline;}\n" +
        "  .card-meta{color:var(--muted); font-size:11px; display:flex; justify-content:space-between; gap:6px;}\n" +
        "  .card-desc{color:var(--muted); font-size:11px; max-height:2.6em; overflow:hidden; text-overflow:ellipsis;}\n" +
        "  .card-check{position:absolute; top:6px; left:6px; z-index:2; width:16px; height:16px;}\n" +
        "  .card-actions{display:flex; gap:5px; margin-top:auto;}\n" +
        "  .card-actions button{flex:1; padding:4px 6px; font-size:11px;}\n" +
        "  .status-pill{\n" +
        "    display:inline-block; padding:2px 8px; border-radius:999px; font-size:10.5px; font-weight:600;\n" +
        "    background:#2a3038; color:var(--muted); align-self:flex-start;\n" +
        "  }\n" +
        "  .status-pill.local{background:#173a25; color:var(--green);}\n" +
        "  .status-pill.dl{background:#1c2c46; color:var(--accent);}\n" +
        "  .status-pill.fail{background:#3a1c1c; color:var(--red);}\n" +
        "  #optionsCard summary{cursor:pointer; font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); outline:none; padding:2px 0;}\n" +
        "  #optionsCard[open] summary{margin-bottom:10px;}\n" +
        "  #optionsCard .card{margin-bottom:14px;}\n" +
        "  #details, #log{\n" +
        "    height:180px; overflow:auto; background:#0c0f13; border:1px solid var(--border); border-radius:8px;\n" +
        "    padding:10px 12px; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap;\n" +
        "    color:#c7cdd6;\n" +
        "  }\n" +
        "  .cols2{display:grid; grid-template-columns:1fr 1fr; gap:14px;}\n" +
        "  @media (max-width:900px){ .cols2{grid-template-columns:1fr;} }\n" +
        "  .badge{font-size:11px; padding:2px 7px; border-radius:5px; background:var(--panel2); color:var(--muted); border:1px solid var(--border);}\n" +
        "  .badge.on{color:var(--green); border-color:#2b5a3c;}\n" +
        "  .badge.off{color:var(--muted);}\n" +
        "  a.plainlink{color:var(--accent);}\n" +
        "  #modalBg{\n" +
        "    position:fixed; inset:0; background:rgba(0,0,0,.6); display:none;\n" +
        "    align-items:center; justify-content:center; z-index:50;\n" +
        "  }\n" +
        "  #modalBg.show{display:flex;}\n" +
        "  #modalBox{\n" +
        "    background:var(--panel); border:1px solid var(--border); border-radius:10px; width:min(720px,92vw);\n" +
        "    max-height:80vh; display:flex; flex-direction:column;\n" +
        "  }\n" +
        "  #modalBox header{border-bottom:1px solid var(--border); padding:10px 16px;}\n" +
        "  #modalBox .modalBody{padding:12px 16px; overflow:auto;}\n" +
        "  #modalBox pre{white-space:pre-wrap; word-break:break-word; font:12px/1.5 ui-monospace,Menlo,Consolas,monospace; margin:0;}\n" +
        "  #modalClose{margin-left:auto;}\n" +
        "  .foot-actions{display:flex; gap:8px; padding:10px 16px; border-top:1px solid var(--border);}\n" +
        "  small.hint{color:var(--muted);}\n" +
        "  .config-editor textarea{\n" +
        "    width:100%; min-height:100px; background:var(--panel2); border:1px solid var(--border); color:var(--text);\n" +
        "    border-radius:6px; padding:8px; font:12px/1.4 ui-monospace,Menlo,Consolas,monospace; resize:vertical;\n" +
        "  }\n" +
        "</style>\n" +
        "</head>\n" +
        "<body>\n" +
        "\n" +
        "<header>\n" +
        "  <h1>Me<span>share</span></h1>\n" +
        "  <span id=\"serverCount\">Servers: -</span>\n" +
        "  <span class=\"spacer\"></span>\n" +
        "  <span id=\"topStatus\" class=\"badge\">Ready</span>\n" +
        "</header>\n" +
        "\n" +
        "<main>\n" +
        "\n" +
        "  <div class=\"search-top\">\n" +
        "    <div class=\"row\">\n" +
        "      <input id=\"searchField\" type=\"text\" placeholder=\"Search filename, description, hash, server...\" autofocus>\n" +
        "      <button id=\"searchBtn\" class=\"primary\">Search</button>\n" +
        "      <button id=\"stopBtn\" disabled>Stop</button>\n" +
        "      <span class=\"spacer\"></span>\n" +
        "      <span id=\"status\">Ready</span>\n" +
        "    </div>\n" +
        "  </div>\n" +
        "\n" +
        "  <div class=\"card\">\n" +
        "    <h2>Results (<span id=\"resultsCount\">0</span> / <span id=\"maxResults\">200</span>)</h2>\n" +
        "    <div id=\"resultsWrap\">\n" +
        "      <div class=\"results-grid\" id=\"resultsBody\"></div>\n" +
        "    </div>\n" +
        "    <div class=\"pagination\" id=\"pagination\" style=\"display:none;\"></div>\n" +
        "  </div>\n" +
        "\n" +
        "  <details id=\"optionsCard\">\n" +
        "    <summary>Additional options</summary>\n" +
        "\n" +
        "    <div class=\"card\">\n" +
        "      <h2>Search options</h2>\n" +
        "      <div class=\"row\">\n" +
        "        <label class=\"inline\"><input type=\"checkbox\" id=\"randomCheck\"> Random tries per server:</label>\n" +
        "        <input type=\"number\" id=\"randomCount\" value=\"10\" min=\"1\" disabled>\n" +
        "        <label class=\"inline\"><input type=\"checkbox\" id=\"allowLinksCheck\" checked> Allow links</label>\n" +
        "        <span class=\"spacer\"></span>\n" +
        "        <button id=\"reloadServersBtn\">Reload servers.txt</button>\n" +
        "        <button id=\"editServersBtn\">Edit servers.txt</button>\n" +
        "      </div>\n" +
        "    </div>\n" +
        "\n" +
        "    <div class=\"card\">\n" +
        "      <h2>Downloads</h2>\n" +
        "      <div class=\"row\">\n" +
        "        <label class=\"inline\"><input type=\"checkbox\" id=\"selectAll\"> Select all results</label>\n" +
        "        <button id=\"downloadSelectedBtn\">Download Selected</button>\n" +
        "        <button id=\"downloadMatchingBtn\">Download Matching</button>\n" +
        "        <button id=\"downloadAllBtn\">Download All (every server)</button>\n" +
        "        <span class=\"spacer\"></span>\n" +
        "        <button id=\"openFolderBtn\">Open Files Folder</button>\n" +
        "      </div>\n" +
        "    </div>\n" +
        "\n" +
        "    <div class=\"card\">\n" +
        "      <h2>Share this node</h2>\n" +
        "      <div class=\"row\">\n" +
        "        <label class=\"inline\"><input type=\"checkbox\" id=\"shareCheck\"> Share this node (act as a server for peers)</label>\n" +
        "        <span>Port:</span>\n" +
        "        <input type=\"number\" id=\"sharePort\" value=\"8765\" min=\"1\" max=\"65535\">\n" +
        "        <span id=\"shareStatus\" class=\"badge off\">stopped</span>\n" +
        "        <span class=\"spacer\"></span>\n" +
        "        <button id=\"editLinksBtn\">Edit links.txt</button>\n" +
        "        <button id=\"editPubkeyBtn\">Edit public_key.txt</button>\n" +
        "      </div>\n" +
        "    </div>\n" +
        "\n" +
        "    <div class=\"cols2\">\n" +
        "      <div class=\"card\">\n" +
        "        <h2>Details</h2>\n" +
        "        <div id=\"details\">Select a result to see details.</div>\n" +
        "      </div>\n" +
        "      <div class=\"card\">\n" +
        "        <h2>Log</h2>\n" +
        "        <div id=\"log\"></div>\n" +
        "      </div>\n" +
        "    </div>\n" +
        "  </details>\n" +
        "\n" +
        "</main>\n" +
        "\n" +
        "<div id=\"modalBg\">\n" +
        "  <div id=\"modalBox\">\n" +
        "    <header class=\"row\">\n" +
        "      <strong id=\"modalTitle\">Details</strong>\n" +
        "      <button id=\"modalClose\">Close</button>\n" +
        "    </header>\n" +
        "    <div class=\"modalBody\" id=\"modalBody\"><pre id=\"modalPre\"></pre></div>\n" +
        "    <div class=\"foot-actions\" id=\"modalFoot\" style=\"display:none;\">\n" +
        "      <button id=\"modalSaveBtn\" class=\"primary\">Save</button>\n" +
        "    </div>\n" +
        "  </div>\n" +
        "</div>\n" +
        "\n" +
        "<script>\n" +
        "(function(){\n" +
        "  var PAGE_SIZE = 10;\n" +
        "  var state = {\n" +
        "    results: {},      // key -> entry object\n" +
        "    order: [],         // key insertion order\n" +
        "    selected: {},       // key -> true\n" +
        "    page: 1,            // current pagination page (1-based)\n" +
        "  };\n" +
        "\n" +
        "  var $ = function(id){ return document.getElementById(id); };\n" +
        "\n" +
        "  function escapeHtml(s){\n" +
        "    if (s === undefined || s === null) return \"\";\n" +
        "    return String(s).replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\");\n" +
        "  }\n" +
        "\n" +
        "  function statusClass(entry){\n" +
        "    if (entry.isLocal) return \"local\";\n" +
        "    var st = (entry.status || \"\").toLowerCase();\n" +
        "    if (st.indexOf(\"download\") >= 0) return \"dl\";\n" +
        "    if (st.indexOf(\"fail\") >= 0 || st.indexOf(\"mismatch\") >= 0) return \"fail\";\n" +
        "    return \"\";\n" +
        "  }\n" +
        "\n" +
        "  function entryOpenHref(entry){\n" +
        "    if (entry.isLink) {\n" +
        "      return entry.description && entry.description.length ? entry.description : null;\n" +
        "    }\n" +
        "    if (entry.isLocal) {\n" +
        "      return \"/files/\" + entry.key;\n" +
        "    }\n" +
        "    return null;\n" +
        "  }\n" +
        "\n" +
        "  var IMAGE_EXT = [\"jpg\",\"jpeg\",\"png\",\"gif\",\"webp\",\"bmp\",\"svg\",\"tiff\",\"ico\",\"heic\"];\n" +
        "  var VIDEO_EXT = [\"mp4\",\"webm\",\"mkv\",\"avi\",\"mov\",\"m4v\",\"flv\",\"wmv\",\"mpeg\"];\n" +
        "  var AUDIO_EXT = [\"mp3\",\"wav\",\"flac\",\"ogg\",\"m4a\",\"aac\",\"wma\"];\n" +
        "\n" +
        "  function extCategory(ext){\n" +
        "    ext = String(ext || \"\").toLowerCase().replace(/^\\./, \"\");\n" +
        "    if (IMAGE_EXT.indexOf(ext) >= 0) return \"image\";\n" +
        "    if (VIDEO_EXT.indexOf(ext) >= 0) return \"video\";\n" +
        "    if (AUDIO_EXT.indexOf(ext) >= 0) return \"audio\";\n" +
        "    return \"file\";\n" +
        "  }\n" +
        "\n" +
        "  var CATEGORY_ICONS = {\n" +
        "    image: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"2\"/><circle cx=\"8.5\" cy=\"9.5\" r=\"1.5\"/><path d=\"M21 16l-5.5-5.5L9 17\"/></svg>',\n" +
        "    video: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><rect x=\"3\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/><path d=\"M17 9.5l4-2.3v9.6l-4-2.3\"/></svg>',\n" +
        "    audio: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><path d=\"M9 18V6l10-2v12\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><circle cx=\"16\" cy=\"16\" r=\"3\"/></svg>',\n" +
        "    file: '<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\"><path d=\"M6 2h9l5 5v15H6z\"/><path d=\"M14 2v6h6\"/></svg>'\n" +
        "  };\n" +
        "\n" +
        "  function thumbHtml(entry, category){\n" +
        "    if (category === \"image\") {\n" +
        "      var src = entry.isLocal ? (\"/files/\" + encodeURIComponent(entry.key)) : entry.remoteUrl;\n" +
        "      if (src) return '<img src=\"' + escapeHtml(src) + '\" alt=\"\" loading=\"lazy\">';\n" +
        "    }\n" +
        "    if (category === \"video\") {\n" +
        "      var vsrc = entry.isLocal ? (\"/files/\" + encodeURIComponent(entry.key)) : entry.remoteUrl;\n" +
        "      if (vsrc) return '<video src=\"' + escapeHtml(vsrc) + '\" muted preload=\"metadata\"></video>';\n" +
        "    }\n" +
        "    return CATEGORY_ICONS[category] || CATEGORY_ICONS.file;\n" +
        "  }\n" +
        "\n" +
        "  function buildCard(entry){\n" +
        "    var card = document.createElement(\"div\");\n" +
        "    card.id = \"row-\" + cssEscape(entry.key);\n" +
        "    card.dataset.key = entry.key;\n" +
        "    var category = extCategory(entry.extension);\n" +
        "    card.className = \"result-card\" + (entry.isLocal ? \" local\" : \"\");\n" +
        "\n" +
        "    var openHref = entryOpenHref(entry);\n" +
        "    var displayName = entry.displayFilename || entry.filename || entry.key;\n" +
        "    var nameHtml = openHref\n" +
        "      ? '<a href=\"' + escapeHtml(openHref) + '\" target=\"_blank\" rel=\"noopener\">' + escapeHtml(displayName) + '</a>'\n" +
        "      : escapeHtml(displayName);\n" +
        "\n" +
        "    var pill = '<span class=\"status-pill ' + statusClass(entry) + '\">' + escapeHtml(entry.status || (entry.isLocal ? \"local\" : \"remote\")) + '</span>';\n" +
        "    var checked = state.selected[entry.key] ? \"checked\" : \"\";\n" +
        "    var dlDisabled = entry.isLocal ? \"disabled\" : \"\";\n" +
        "\n" +
        "    card.innerHTML =\n" +
        "      '<input type=\"checkbox\" class=\"rowcheck card-check\" ' + checked + '>' +\n" +
        "      '<div class=\"thumb-wrap\">' + thumbHtml(entry, category) + '</div>' +\n" +
        "      '<div class=\"card-body\">' +\n" +
        "        '<div class=\"card-name\" title=\"' + escapeHtml(displayName) + '\">' + nameHtml + '</div>' +\n" +
        "        '<div class=\"card-meta\"><span title=\"' + escapeHtml(entry.server) + '\">' + escapeHtml(entry.server) + '</span><span>' + escapeHtml(entry.size) + '</span></div>' +\n" +
        "        (entry.description ? '<div class=\"card-desc\" title=\"' + escapeHtml(entry.description) + '\">' + escapeHtml(entry.description) + '</div>' : '') +\n" +
        "        pill +\n" +
        "        '<div class=\"card-actions\">' +\n" +
        "          '<button class=\"dlOne\" ' + dlDisabled + '>Download</button>' +\n" +
        "          '<button class=\"jsonOne\">JSON</button>' +\n" +
        "        '</div>' +\n" +
        "      '</div>';\n" +
        "\n" +
        "    var thumbMedia = card.querySelector(\".thumb-wrap img, .thumb-wrap video\");\n" +
        "    if (thumbMedia) {\n" +
        "      thumbMedia.addEventListener(\"error\", function(){\n" +
        "        var wrap = card.querySelector(\".thumb-wrap\");\n" +
        "        if (wrap) wrap.innerHTML = CATEGORY_ICONS[category] || CATEGORY_ICONS.file;\n" +
        "      });\n" +
        "    }\n" +
        "\n" +
        "    card.querySelector(\".rowcheck\").addEventListener(\"click\", function(e){ e.stopPropagation(); });\n" +
        "    card.querySelector(\".rowcheck\").addEventListener(\"change\", function(e){\n" +
        "      if (e.target.checked) state.selected[entry.key] = true; else delete state.selected[entry.key];\n" +
        "      updateSelectAllState();\n" +
        "    });\n" +
        "    card.querySelector(\".dlOne\").addEventListener(\"click\", function(e){\n" +
        "      e.stopPropagation();\n" +
        "      downloadKeys([entry.key]);\n" +
        "    });\n" +
        "    card.querySelector(\".jsonOne\").addEventListener(\"click\", function(e){\n" +
        "      e.stopPropagation();\n" +
        "      showJson(entry.key, displayName);\n" +
        "    });\n" +
        "    card.addEventListener(\"click\", function(ev){\n" +
        "      if (ev.target.tagName === \"INPUT\" || ev.target.tagName === \"BUTTON\" || ev.target.tagName === \"A\") return;\n" +
        "      if (entry.isLocal) {\n" +
        "        // already downloaded: just view the local copy, never re-download\n" +
        "        window.open(\"/files/\" + encodeURIComponent(entry.key), \"_blank\", \"noopener\");\n" +
        "        return;\n" +
        "      }\n" +
        "      if (category === \"image\" || category === \"video\") {\n" +
        "        // friendly containers: clicking an image/video result downloads it right away\n" +
        "        downloadKeys([entry.key]);\n" +
        "        return;\n" +
        "      }\n" +
        "      showDetails(entry.key);\n" +
        "    });\n" +
        "\n" +
        "    return card;\n" +
        "  }\n" +
        "\n" +
        "  function cssEscape(s){ return String(s).replace(/[^a-zA-Z0-9_-]/g, \"_\"); }\n" +
        "\n" +
        "  function renderPage(){\n" +
        "    var totalPages = Math.max(1, Math.ceil(state.order.length / PAGE_SIZE));\n" +
        "    if (state.page > totalPages) state.page = totalPages;\n" +
        "    if (state.page < 1) state.page = 1;\n" +
        "    var startIdx = (state.page - 1) * PAGE_SIZE;\n" +
        "    var pageKeys = state.order.slice(startIdx, startIdx + PAGE_SIZE);\n" +
        "    var body = $(\"resultsBody\");\n" +
        "    body.innerHTML = \"\";\n" +
        "    pageKeys.forEach(function(k){\n" +
        "      var entry = state.results[k];\n" +
        "      if (entry) body.appendChild(buildCard(entry));\n" +
        "    });\n" +
        "    renderPagination(totalPages);\n" +
        "  }\n" +
        "\n" +
        "  function renderPagination(totalPages){\n" +
        "    var el = $(\"pagination\");\n" +
        "    if (!el) return;\n" +
        "    if (state.order.length === 0) { el.innerHTML = \"\"; el.style.display = \"none\"; return; }\n" +
        "    el.style.display = \"flex\";\n" +
        "    var prevDisabled = state.page <= 1 ? \"disabled\" : \"\";\n" +
        "    var nextDisabled = state.page >= totalPages ? \"disabled\" : \"\";\n" +
        "    el.innerHTML =\n" +
        "      '<button id=\"prevPageBtn\" ' + prevDisabled + '>\\u2039 Prev</button>' +\n" +
        "      '<span class=\"page-info\">Page ' + state.page + ' of ' + totalPages + ' \\u00b7 ' + state.order.length + ' results</span>' +\n" +
        "      '<button id=\"nextPageBtn\" ' + nextDisabled + '>Next \\u203a</button>';\n" +
        "    var prevBtn = $(\"prevPageBtn\");\n" +
        "    if (prevBtn) prevBtn.addEventListener(\"click\", function(){ state.page--; renderPage(); });\n" +
        "    var nextBtn = $(\"nextPageBtn\");\n" +
        "    if (nextBtn) nextBtn.addEventListener(\"click\", function(){ state.page++; renderPage(); });\n" +
        "  }\n" +
        "\n" +
        "  function addOrUpdateEntry(entry){\n" +
        "    if (!state.results[entry.key]) state.order.push(entry.key);\n" +
        "    state.results[entry.key] = entry;\n" +
        "    renderPage();\n" +
        "  }\n" +
        "\n" +
        "  function clearResults(){\n" +
        "    state.results = {}; state.order = []; state.selected = {}; state.page = 1;\n" +
        "    $(\"resultsBody\").innerHTML = \"\";\n" +
        "    var pel = $(\"pagination\"); if (pel) { pel.innerHTML = \"\"; pel.style.display = \"none\"; }\n" +
        "    $(\"details\").textContent = \"Select a result to see details.\";\n" +
        "  }\n" +
        "\n" +
        "  function updateSelectAllState(){\n" +
        "    var all = state.order.length > 0 && state.order.every(function(k){ return state.selected[k]; });\n" +
        "    $(\"selectAll\").checked = all;\n" +
        "  }\n" +
        "\n" +
        "  function showDetails(key){\n" +
        "    var e = state.results[key];\n" +
        "    if (!e) return;\n" +
        "    var lines = [\n" +
        "      \"Server:           \" + (e.server||\"\"),\n" +
        "      \"Hash:             \" + (e.hash||\"\"),\n" +
        "      \"Extension:        \" + (e.extension||\"\"),\n" +
        "      \"Filename:         \" + (e.filename||\"\"),\n" +
        "      \"Size:             \" + (e.size||\"\"),\n" +
        "      \"Description:      \" + (e.description||\"\"),\n" +
        "      \"Date:             \" + (e.date||\"\"),\n" +
        "      \"Status:           \" + (e.status||\"\"),\n" +
        "      \"Local file URL:   \" + (e.isLocal ? (\"/files/\" + e.key) : \"(not downloaded)\"),\n" +
        "      \"Local info URL:   \" + \"/info/\" + e.hash + \".json\"\n" +
        "    ];\n" +
        "    $(\"details\").textContent = lines.join(\"\\n\");\n" +
        "  }\n" +
        "\n" +
        "  function showJson(key, title){\n" +
        "    fetch(\"/api/entry/json?key=\" + encodeURIComponent(key)).then(function(r){ return r.text(); }).then(function(text){\n" +
        "      try { text = JSON.stringify(JSON.parse(text), null, 2); } catch(e) {}\n" +
        "      openModal(\"JSON: \" + title, text, false);\n" +
        "    });\n" +
        "  }\n" +
        "\n" +
        "  function openModal(title, text, showSave, onSave){\n" +
        "    $(\"modalTitle\").textContent = title;\n" +
        "    $(\"modalPre\").textContent = text;\n" +
        "    $(\"modalBg\").className = \"show\";\n" +
        "    var foot = $(\"modalFoot\");\n" +
        "    if (showSave) {\n" +
        "      foot.style.display = \"flex\";\n" +
        "      $(\"modalSaveBtn\").onclick = onSave;\n" +
        "    } else {\n" +
        "      foot.style.display = \"none\";\n" +
        "    }\n" +
        "  }\n" +
        "  $(\"modalClose\").addEventListener(\"click\", function(){ $(\"modalBg\").className = \"\"; });\n" +
        "  $(\"modalBg\").addEventListener(\"click\", function(ev){ if (ev.target === $(\"modalBg\")) $(\"modalBg\").className = \"\"; });\n" +
        "\n" +
        "  function appendLog(line){\n" +
        "    var el = $(\"log\");\n" +
        "    var atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;\n" +
        "    el.textContent += (el.textContent ? \"\\n\" : \"\") + line;\n" +
        "    var lines = el.textContent.split(\"\\n\");\n" +
        "    if (lines.length > 1500) el.textContent = lines.slice(lines.length - 1500).join(\"\\n\");\n" +
        "    if (atBottom) el.scrollTop = el.scrollHeight;\n" +
        "  }\n" +
        "\n" +
        "  function setStatus(text){ $(\"status\").textContent = text; $(\"topStatus\").textContent = text; }\n" +
        "\n" +
        "  function setSearchActive(active){\n" +
        "    $(\"searchBtn\").disabled = active;\n" +
        "    $(\"stopBtn\").disabled = !active;\n" +
        "  }\n" +
        "\n" +
        "  function setResultsCount(n, max){\n" +
        "    $(\"resultsCount\").textContent = n;\n" +
        "    if (max) $(\"maxResults\").textContent = max;\n" +
        "  }\n" +
        "\n" +
        "  function setShareState(running, port){\n" +
        "    var el = $(\"shareStatus\");\n" +
        "    el.textContent = running ? (\"running on \" + port) : \"stopped\";\n" +
        "    el.className = \"badge \" + (running ? \"on\" : \"off\");\n" +
        "    $(\"shareCheck\").checked = running;\n" +
        "  }\n" +
        "\n" +
        "  // -------- actions --------\n" +
        "  function postForm(url, params){\n" +
        "    var body = Object.keys(params).map(function(k){\n" +
        "      return encodeURIComponent(k) + \"=\" + encodeURIComponent(params[k]);\n" +
        "    }).join(\"&\");\n" +
        "    return fetch(url, { method: \"POST\", headers: {\"Content-Type\":\"application/x-www-form-urlencoded\"}, body: body });\n" +
        "  }\n" +
        "\n" +
        "  function startSearch(){\n" +
        "    var query = $(\"searchField\").value.trim();\n" +
        "    var randomOn = $(\"randomCheck\").checked;\n" +
        "    var randomCount = $(\"randomCount\").value || \"10\";\n" +
        "    var allowLinks = $(\"allowLinksCheck\").checked;\n" +
        "    clearResults();\n" +
        "    setSearchActive(true);\n" +
        "    postForm(\"/api/search\", { query: query, random: randomOn, randomCount: randomCount, allowLinks: allowLinks });\n" +
        "  }\n" +
        "\n" +
        "  function stopSearch(){ fetch(\"/api/stop\", { method: \"POST\" }); }\n" +
        "\n" +
        "  function downloadKeys(keys){\n" +
        "    if (!keys.length) return;\n" +
        "    postForm(\"/api/download\", { mode: \"selected\", keys: keys.join(\"|\") });\n" +
        "  }\n" +
        "\n" +
        "  function downloadSelected(){\n" +
        "    var keys = Object.keys(state.selected);\n" +
        "    if (!keys.length) { appendLog(\"No rows selected\"); return; }\n" +
        "    downloadKeys(keys);\n" +
        "  }\n" +
        "\n" +
        "  function downloadMatching(){ postForm(\"/api/download\", { mode: \"matching\" }); }\n" +
        "  function downloadAll(){ postForm(\"/api/download\", { mode: \"all\" }); }\n" +
        "\n" +
        "  $(\"searchBtn\").addEventListener(\"click\", startSearch);\n" +
        "  $(\"searchField\").addEventListener(\"keydown\", function(e){ if (e.key === \"Enter\") startSearch(); });\n" +
        "  $(\"stopBtn\").addEventListener(\"click\", stopSearch);\n" +
        "  $(\"randomCheck\").addEventListener(\"change\", function(){ $(\"randomCount\").disabled = !this.checked; });\n" +
        "  $(\"downloadSelectedBtn\").addEventListener(\"click\", downloadSelected);\n" +
        "  $(\"downloadMatchingBtn\").addEventListener(\"click\", downloadMatching);\n" +
        "  $(\"downloadAllBtn\").addEventListener(\"click\", downloadAll);\n" +
        "  $(\"openFolderBtn\").addEventListener(\"click\", function(){ fetch(\"/api/openFolder\", {method:\"POST\"}); });\n" +
        "  $(\"reloadServersBtn\").addEventListener(\"click\", function(){ fetch(\"/api/servers/reload\", {method:\"POST\"}); });\n" +
        "\n" +
        "  $(\"selectAll\").addEventListener(\"change\", function(){\n" +
        "    var checked = this.checked;\n" +
        "    state.order.forEach(function(k){\n" +
        "      if (checked) state.selected[k] = true; else delete state.selected[k];\n" +
        "    });\n" +
        "    renderPage();\n" +
        "  });\n" +
        "\n" +
        "  $(\"shareCheck\").addEventListener(\"change\", function(){\n" +
        "    if (this.checked) {\n" +
        "      postForm(\"/api/share/start\", { port: $(\"sharePort\").value || \"8765\" });\n" +
        "    } else {\n" +
        "      fetch(\"/api/share/stop\", { method: \"POST\" });\n" +
        "    }\n" +
        "  });\n" +
        "\n" +
        "  function editConfigFile(name){\n" +
        "    fetch(\"/api/file?name=\" + encodeURIComponent(name)).then(function(r){ return r.json(); }).then(function(data){\n" +
        "      $(\"modalTitle\").textContent = \"Edit \" + name;\n" +
        "      $(\"modalBody\").innerHTML = '<div class=\"config-editor\"><textarea id=\"cfgText\"></textarea><br><small class=\"hint\">One entry per line. Saved directly to disk.</small></div>';\n" +
        "      document.getElementById(\"cfgText\").value = data.content || \"\";\n" +
        "      $(\"modalBg\").className = \"show\";\n" +
        "      var foot = $(\"modalFoot\");\n" +
        "      foot.style.display = \"flex\";\n" +
        "      $(\"modalSaveBtn\").onclick = function(){\n" +
        "        var content = document.getElementById(\"cfgText\").value;\n" +
        "        postForm(\"/api/file\", { name: name, content: content }).then(function(){\n" +
        "          $(\"modalBg\").className = \"\";\n" +
        "        });\n" +
        "      };\n" +
        "    });\n" +
        "  }\n" +
        "  $(\"editServersBtn\").addEventListener(\"click\", function(){ editConfigFile(\"servers.txt\"); });\n" +
        "  $(\"editLinksBtn\").addEventListener(\"click\", function(){ editConfigFile(\"links.txt\"); });\n" +
        "  $(\"editPubkeyBtn\").addEventListener(\"click\", function(){ editConfigFile(\"public_key.txt\"); });\n" +
        "\n" +
        "  // close modal resets body back to plain <pre> view for JSON viewer next time\n" +
        "  $(\"modalClose\").addEventListener(\"click\", function(){\n" +
        "    $(\"modalBody\").innerHTML = '<pre id=\"modalPre\"></pre>';\n" +
        "  });\n" +
        "\n" +
        "  // -------- initial state + live events --------\n" +
        "  function applyEvent(ev){\n" +
        "    switch (ev.type) {\n" +
        "      case \"log\": appendLog(ev.line); break;\n" +
        "      case \"status\": setStatus(ev.text); break;\n" +
        "      case \"result\": addOrUpdateEntry(ev.entry); break;\n" +
        "      case \"entryUpdate\":\n" +
        "        var e = state.results[ev.key];\n" +
        "        if (e) { e.status = ev.status; e.isLocal = ev.isLocal; renderPage(); }\n" +
        "        break;\n" +
        "      case \"clearResults\": clearResults(); break;\n" +
        "      case \"searchState\":\n" +
        "        setSearchActive(ev.active);\n" +
        "        setResultsCount(ev.resultsCount, ev.maxResults);\n" +
        "        break;\n" +
        "      case \"downloadState\": break;\n" +
        "      case \"serverCount\": $(\"serverCount\").textContent = \"Servers: \" + ev.count; break;\n" +
        "      case \"shareState\": setShareState(ev.running, ev.port); break;\n" +
        "      default: break;\n" +
        "    }\n" +
        "  }\n" +
        "\n" +
        "  function loadState(){\n" +
        "    fetch(\"/api/state\").then(function(r){ return r.json(); }).then(function(s){\n" +
        "      $(\"serverCount\").textContent = \"Servers: \" + s.serverCount;\n" +
        "      setSearchActive(s.searchActive);\n" +
        "      setResultsCount(s.resultsCount, s.maxResults);\n" +
        "      setShareState(s.shareRunning, s.sharePort);\n" +
        "      $(\"sharePort\").value = s.sharePort;\n" +
        "      (s.results || []).forEach(function(e){ addOrUpdateEntry(e); });\n" +
        "      $(\"log\").textContent = (s.log || []).join(\"\\n\");\n" +
        "      $(\"log\").scrollTop = $(\"log\").scrollHeight;\n" +
        "    });\n" +
        "  }\n" +
        "\n" +
        "  function connectEvents(){\n" +
        "    var es = new EventSource(\"/api/events\");\n" +
        "    es.onmessage = function(msg){\n" +
        "      try { applyEvent(JSON.parse(msg.data)); } catch(e) { /* ignore parse errors */ }\n" +
        "    };\n" +
        "    es.onerror = function(){\n" +
        "      // browser auto-reconnects EventSource; nothing else to do\n" +
        "    };\n" +
        "  }\n" +
        "\n" +
        "  loadState();\n" +
        "  connectEvents();\n" +
        "})();\n" +
        "</script>\n" +
        "</body>\n" +
        "</html>\n" +
        "\n";

    // ===================================================================
    //                          main
    // ===================================================================
    public static void main(String[] args) {
        final Meshare app = new Meshare();

        int port = DEFAULT_UI_PORT;
        if (args.length > 0) {
            try { port = Integer.parseInt(args[0]); } catch (NumberFormatException ignore) { /* ignore */ }
        }

        try {
            app.startUiServer(port);
        } catch (IOException e) {
            System.err.println("Failed to start HTTP server: " + e.getMessage());
            return;
        }

        final String url = "http://localhost:" + app.uiPort + "/";
        System.out.println("Meshare is running at " + url);
        System.out.println("Press Ctrl+C to stop.");
        app.log("Meshare web server started at " + url);

        app.executor.submit(new Runnable() {
            @Override public void run() {
                try { app.discoverServersAtStartup(); }
                catch (Throwable t) { app.log("Startup peer discovery failed: " + t.getMessage()); }
            }
        });

        Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
            @Override public void run() {
                app.executor.shutdownNow();
                app.stopShareServer();
            }
        }));

        openBrowser(url);
    }
}
