import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.security.MessageDigest;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
 * MeshareVerify - console tool (Java 8) that audits every server listed in
 * "servers.txt".
 *
 * For each server it:
 *   1. downloads "files.txt" (one "<sha256>.<ext>" per line),
 *   2. downloads every listed file from "files/<name>" (streamed, nothing is
 *      written to disk) and computes its SHA-256,
 *   3. compares that hash with the hash part of the file name.
 *
 * A server that serves at least one file whose content hash does NOT match its
 * file name is removed from "servers.txt" (comments and blank lines in that
 * file are preserved).
 *
 * At the end a report is APPENDED to "servers_log.txt" containing the date,
 * the servers whose files are all OK, the removed servers together with the
 * files that did not match, and the servers that could not be fully verified
 * (network errors, HTTP errors). Servers that only had errors are NOT removed,
 * because an unreachable server is not the same thing as a corrupted one.
 *
 * Usage:
 *   javac MeshareVerify.java
 *   java MeshareVerify
 *
 * Run it from the folder that contains servers.txt.
 */
public class MeshareVerify {

    private static final String SERVERS_FILE = "servers.txt";
    private static final String LOG_FILE     = "servers_log.txt";

    private static final int CONNECT_TIMEOUT_MS = 10000;
    private static final int READ_TIMEOUT_MS    = 30000;

    private static final String NL = System.getProperty("line.separator");

    // ===================================================================
    //                          result model
    // ===================================================================
    private static class Mismatch {
        final String fileName;
        final String expected;
        final String actual;

        Mismatch(String fileName, String expected, String actual) {
            this.fileName = fileName;
            this.expected = expected;
            this.actual   = actual;
        }
    }

    private static class ServerResult {
        final String server;
        int filesOk = 0;                 // downloaded and hash matches
        int ignoredLines = 0;            // non-empty lines that are not "<sha256>.<ext>"
        String fatalError = null;        // files.txt could not be fetched
        final List<Mismatch> mismatches = new ArrayList<Mismatch>();
        final List<String>   errors     = new ArrayList<String>();

        ServerResult(String server) { this.server = server; }

        boolean isRemoved()      { return !mismatches.isEmpty(); }
        boolean isFullyOk()      { return mismatches.isEmpty() && errors.isEmpty() && fatalError == null; }
    }

    // ===================================================================
    //                          main
    // ===================================================================
    public static void main(String[] args) {
        File serversFile = new File(SERVERS_FILE);
        if (!serversFile.isFile()) {
            System.err.println("ERROR: " + SERVERS_FILE + " not found in " + new File(".").getAbsolutePath());
            System.exit(1);
            return;
        }

        List<String> rawLines;
        try {
            rawLines = Files.readAllLines(serversFile.toPath(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            System.err.println("ERROR: cannot read " + SERVERS_FILE + ": " + e.getMessage());
            System.exit(1);
            return;
        }

        // Unique, trimmed server entries (blank lines and "#" comments ignored).
        Set<String> servers = new LinkedHashSet<String>();
        for (String line : rawLines) {
            String s = stripBom(line).trim();
            if (s.isEmpty() || s.startsWith("#")) continue;
            servers.add(s);
        }

        if (servers.isEmpty()) {
            System.out.println("No servers found in " + SERVERS_FILE + ". Nothing to do.");
            return;
        }

        System.out.println("Meshare verify - " + servers.size() + " server(s) to check");
        System.out.println("==============================================");

        List<ServerResult> results = new ArrayList<ServerResult>();
        int n = 0;
        for (String server : servers) {
            n++;
            System.out.println();
            System.out.println("[" + n + "/" + servers.size() + "] " + server);
            results.add(checkServer(server));
        }

        // ----- remove bad servers from servers.txt -----
        Set<String> toRemove = new LinkedHashSet<String>();
        for (ServerResult r : results) if (r.isRemoved()) toRemove.add(r.server);

        boolean serversFileUpdated = false;
        if (!toRemove.isEmpty()) {
            try {
                removeServersFromFile(serversFile, rawLines, toRemove);
                serversFileUpdated = true;
            } catch (IOException e) {
                System.err.println("ERROR: could not update " + SERVERS_FILE + ": " + e.getMessage());
            }
        }

        // ----- write report -----
        String date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        try {
            appendLog(new File(LOG_FILE), date, results, serversFileUpdated || toRemove.isEmpty());
        } catch (IOException e) {
            System.err.println("ERROR: could not write " + LOG_FILE + ": " + e.getMessage());
        }

        printSummary(results, serversFileUpdated);
    }

    // ===================================================================
    //                          server check
    // ===================================================================
    private static ServerResult checkServer(String server) {
        ServerResult result = new ServerResult(server);
        String baseUrl = normalizeServer(server);

        String listContent;
        try {
            listContent = httpGetText(baseUrl + "files.txt");
        } catch (IOException e) {
            result.fatalError = "could not fetch files.txt: " + e.getMessage();
            System.out.println("  ! " + result.fatalError);
            return result;
        }

        // Valid, unique "<sha256>.<ext>" entries.
        Set<String> names = new LinkedHashSet<String>();
        for (String line : listContent.split("\\r?\\n")) {
            String t = stripBom(line).trim();
            if (t.isEmpty()) continue;
            if (parseFileLine(t) == null) { result.ignoredLines++; continue; }
            names.add(t);
        }

        System.out.println("  " + names.size() + " file(s) to verify"
                + (result.ignoredLines > 0 ? " (" + result.ignoredLines + " invalid line(s) ignored)" : ""));

        int i = 0;
        for (String name : names) {
            i++;
            String expected = parseFileLine(name)[0].toLowerCase();
            String prefix = "  (" + i + "/" + names.size() + ") " + name + " ... ";
            try {
                String actual = sha256OfUrl(baseUrl + "files/" + encodeName(name));
                if (actual.equals(expected)) {
                    result.filesOk++;
                    System.out.println(prefix + "OK");
                } else {
                    result.mismatches.add(new Mismatch(name, expected, actual));
                    System.out.println(prefix + "MISMATCH (actual " + actual + ")");
                }
            } catch (IOException e) {
                result.errors.add(name + " - " + e.getMessage());
                System.out.println(prefix + "ERROR (" + e.getMessage() + ")");
            }
        }
        return result;
    }

    // ===================================================================
    //                          servers.txt update
    // ===================================================================
    /** Rewrites servers.txt without the given servers, keeping every other line untouched. */
    private static void removeServersFromFile(File file, List<String> rawLines, Set<String> toRemove)
            throws IOException {
        List<String> kept = new ArrayList<String>();
        for (String line : rawLines) {
            String s = stripBom(line).trim();
            if (toRemove.contains(s)) continue;
            kept.add(line);
        }
        // Write to a temp file first, then replace, so a failure cannot leave a half-written list.
        File tmp = new File(file.getAbsolutePath() + ".tmp");
        Files.write(tmp.toPath(), kept, StandardCharsets.UTF_8);
        Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
    }

    // ===================================================================
    //                          report
    // ===================================================================
    private static void appendLog(File logFile, String date, List<ServerResult> results,
                                  boolean serversFileUpdated) throws IOException {
        List<ServerResult> ok = new ArrayList<ServerResult>();
        List<ServerResult> removed = new ArrayList<ServerResult>();
        List<ServerResult> unverified = new ArrayList<ServerResult>();
        for (ServerResult r : results) {
            if (r.isRemoved()) removed.add(r);
            else if (r.isFullyOk()) ok.add(r);
            else unverified.add(r);
        }

        StringBuilder sb = new StringBuilder();
        String bar = "==================================================";
        sb.append(bar).append(NL);
        sb.append("Meshare server verification").append(NL);
        sb.append("Date: ").append(date).append(NL);
        sb.append(bar).append(NL).append(NL);

        sb.append("Servers with ALL files OK (").append(ok.size()).append("):").append(NL);
        if (ok.isEmpty()) sb.append("  (none)").append(NL);
        for (ServerResult r : ok) {
            sb.append("  - ").append(r.server).append("  [").append(r.filesOk)
              .append(r.filesOk == 1 ? " file" : " files").append(" checked]").append(NL);
        }
        sb.append(NL);

        sb.append("Servers REMOVED from ").append(SERVERS_FILE)
          .append(" (").append(removed.size()).append("):").append(NL);
        if (removed.isEmpty()) sb.append("  (none)").append(NL);
        for (ServerResult r : removed) {
            sb.append("  - ").append(r.server).append(NL);
            sb.append("      Files that do not match (").append(r.mismatches.size()).append("):").append(NL);
            for (Mismatch m : r.mismatches) {
                sb.append("        * ").append(m.fileName).append(NL);
                sb.append("            expected hash: ").append(m.expected).append(NL);
                sb.append("            actual hash:   ").append(m.actual).append(NL);
            }
            if (!r.errors.isEmpty()) {
                sb.append("      Files that could not be checked (").append(r.errors.size()).append("):").append(NL);
                for (String e : r.errors) sb.append("        * ").append(e).append(NL);
            }
        }
        if (!removed.isEmpty() && !serversFileUpdated) {
            sb.append("  !! WARNING: ").append(SERVERS_FILE)
              .append(" could NOT be updated, the servers above are still listed in it.").append(NL);
        }
        sb.append(NL);

        sb.append("Servers NOT fully verified - kept in ").append(SERVERS_FILE)
          .append(" (").append(unverified.size()).append("):").append(NL);
        if (unverified.isEmpty()) sb.append("  (none)").append(NL);
        for (ServerResult r : unverified) {
            sb.append("  - ").append(r.server).append(NL);
            if (r.fatalError != null) {
                sb.append("      ").append(r.fatalError).append(NL);
            } else {
                sb.append("      ").append(r.filesOk).append(" file(s) OK, ")
                  .append(r.errors.size()).append(" could not be checked:").append(NL);
                for (String e : r.errors) sb.append("        * ").append(e).append(NL);
            }
        }
        sb.append(NL);

        Writer w = null;
        try {
            w = new OutputStreamWriter(new FileOutputStream(logFile, true), StandardCharsets.UTF_8);
            w.write(sb.toString());
        } finally {
            if (w != null) try { w.close(); } catch (IOException e) { /* ignore */ }
        }
    }

    private static void printSummary(List<ServerResult> results, boolean serversFileUpdated) {
        int ok = 0, removed = 0, unverified = 0;
        for (ServerResult r : results) {
            if (r.isRemoved()) removed++;
            else if (r.isFullyOk()) ok++;
            else unverified++;
        }
        System.out.println();
        System.out.println("==============================================");
        System.out.println("Done. " + ok + " server(s) OK, " + removed + " removed, "
                + unverified + " not fully verified.");
        if (removed > 0 && serversFileUpdated) {
            System.out.println("Removed servers were deleted from " + SERVERS_FILE + ".");
        }
        System.out.println("Report appended to " + LOG_FILE);
    }

    // ===================================================================
    //                          HTTP / hashing
    // ===================================================================
    private static HttpURLConnection open(String urlStr) throws IOException {
        HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection();
        conn.setRequestMethod("GET");
        conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
        conn.setReadTimeout(READ_TIMEOUT_MS);
        conn.setRequestProperty("User-Agent", "Meshare/1.0");
        conn.setRequestProperty("Accept", "*/*");
        conn.setInstanceFollowRedirects(true);
        int code = conn.getResponseCode();
        if (code < 200 || code >= 300) {
            conn.disconnect();
            throw new IOException("HTTP " + code);
        }
        return conn;
    }

    private static String httpGetText(String urlStr) throws IOException {
        HttpURLConnection conn = open(urlStr);
        BufferedReader r = null;
        try {
            r = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            char[] buf = new char[8192];
            int n;
            while ((n = r.read(buf)) >= 0) sb.append(buf, 0, n);
            return sb.toString();
        } finally {
            if (r != null) try { r.close(); } catch (IOException e) { /* ignore */ }
            conn.disconnect();
        }
    }

    /** Streams the resource and returns the lowercase hex SHA-256 of its bytes. */
    private static String sha256OfUrl(String urlStr) throws IOException {
        HttpURLConnection conn = open(urlStr);
        InputStream is = null;
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            is = conn.getInputStream();
            byte[] buf = new byte[64 * 1024];
            int n;
            while ((n = is.read(buf)) >= 0) md.update(buf, 0, n);
            return toHex(md.digest());
        } catch (java.security.NoSuchAlgorithmException e) {
            throw new IOException("SHA-256 not available: " + e.getMessage());
        } finally {
            if (is != null) try { is.close(); } catch (IOException e) { /* ignore */ }
            conn.disconnect();
        }
    }

    private static String toHex(byte[] bytes) {
        StringBuilder hex = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) hex.append(String.format("%02x", b));
        return hex.toString();
    }

    /** Percent-encodes a bare file name for use in a URL path. */
    private static String encodeName(String name) {
        try {
            return URLEncoder.encode(name, "UTF-8").replace("+", "%20");
        } catch (java.io.UnsupportedEncodingException e) {
            return name; // UTF-8 is always supported
        }
    }

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

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

    /** Parse "<hash>.<ext>" where hash must be a 64-char hex SHA-256. */
    private static String[] parseFileLine(String line) {
        if (line == null) return null;
        line = line.trim();
        if (line.isEmpty()) return null;
        int dot = line.lastIndexOf('.');
        if (dot <= 0 || dot >= line.length() - 1) return null;
        String hash = line.substring(0, dot);
        String ext  = line.substring(dot + 1);
        if (!isValidSha256(hash)) return null;
        if (ext.isEmpty()) return null;
        if (ext.indexOf('/') >= 0 || ext.indexOf('\\') >= 0) return null;
        if (ext.length() > 16) return null;
        return new String[] { hash, ext };
    }

    private static boolean isValidSha256(String s) {
        if (s == null || s.length() != 64) return false;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            boolean ok = (c >= '0' && c <= '9')
                      || (c >= 'a' && c <= 'f')
                      || (c >= 'A' && c <= 'F');
            if (!ok) return false;
        }
        return true;
    }
}
