package com.foxx.androidcast.dev;

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.LinkAddress;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.ProxyInfo;
import android.os.Build;
import android.os.PowerManager;
import android.telephony.TelephonyManager;

import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.crash.CrashSettingsStore;
import com.foxx.androidcast.discovery.CastLanPeerProbe;
import com.foxx.androidcast.network.BackendEndpoints;
import com.foxx.androidcast.network.BackendNtpSync;

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;

/** Focused network self-test for developer settings (compact structured report). */
public final class DevNetworkSelfTestProbe {
    private static final int HTTP_TIMEOUT_MS = 5_000;
    private static final String API10_NA = "requires Android API 10+";

    private DevNetworkSelfTestProbe() {}

    public static String run(Context context, AtomicBoolean cancel) {
        DevNetworkSelfTestReport report = new DevNetworkSelfTestReport();
        report.header();

        ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
        Network active = cm != null ? cm.getActiveNetwork() : null;
        NetworkCapabilities caps = (cm != null && active != null) ? cm.getNetworkCapabilities(active) : null;
        LinkProperties lp = (cm != null && active != null) ? cm.getLinkProperties(active) : null;

        probeOverall(context, caps, cancel, report);
        probeLink(context, caps, lp, report);
        probeCaptive(caps, cancel, report);
        probeIntraLan(context, lp, cancel, report);
        probeBackend(context, cancel, report);
        DevVpnStatusProbe.probe(context, report);
        probeNtp(context, cancel, report);
        probeNatExternal(caps, context, cancel, report);
        probeMtu(lp, report);
        probeBluetooth(caps, cancel, report);

        if (cancel != null && cancel.get()) {
            report.itemWarn("run", "cancelled before completion");
        }
        return report.build();
    }

    private static void probeOverall(Context context, NetworkCapabilities caps, AtomicBoolean cancel,
            DevNetworkSelfTestReport r) {
        r.section("AVAIL", "Availability");
        boolean online = caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
        boolean validated = caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
        if (!online) {
            r.itemFail("internet", "no active route");
        } else if (!validated) {
            r.itemWarn("internet", "online, not validated");
        } else {
            r.itemPass("internet", "online + validated");
        }
        r.itemBool(DevNetworkSelfTestReport.Level.PASS, "wifi",
                hasTransport(caps, NetworkCapabilities.TRANSPORT_WIFI), "active", "off");
        r.itemBool(DevNetworkSelfTestReport.Level.PASS, "mobile",
                hasTransport(caps, NetworkCapabilities.TRANSPORT_CELLULAR), "active", "off");
        r.itemBool(DevNetworkSelfTestReport.Level.PASS, "ethernet",
                hasTransport(caps, NetworkCapabilities.TRANSPORT_ETHERNET), "active", "off");
        r.itemBool(DevNetworkSelfTestReport.Level.PASS, "bt-transport",
                hasTransport(caps, NetworkCapabilities.TRANSPORT_BLUETOOTH), "active", "off");
        r.itemBool(DevNetworkSelfTestReport.Level.PASS, "bt-tether",
                hasBtPanInterface(), "bt-pan up", "none");

        PowerManager pm = context.getSystemService(PowerManager.class);
        if (pm != null) {
            r.itemBool(pm.isPowerSaveMode() ? DevNetworkSelfTestReport.Level.WARN : DevNetworkSelfTestReport.Level.PASS,
                    "power-save", true, pm.isPowerSaveMode() ? "on" : "off", "off");
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                r.itemBool(pm.isDeviceIdleMode() ? DevNetworkSelfTestReport.Level.WARN : DevNetworkSelfTestReport.Level.PASS,
                        "doze", true, pm.isDeviceIdleMode() ? "active" : "off", "off");
            } else {
                r.itemNa("doze", API10_NA);
            }
        }
        if (cancel != null && cancel.get()) {
            r.itemWarn("status", "stopped");
        }
    }

    private static void probeLink(Context context, NetworkCapabilities caps, LinkProperties lp,
            DevNetworkSelfTestReport r) {
        r.section("LINK", "Link parameters");
        List<String> modes = new ArrayList<>();
        if (hasTransport(caps, NetworkCapabilities.TRANSPORT_WIFI)) {
            modes.add("wifi");
        }
        if (hasTransport(caps, NetworkCapabilities.TRANSPORT_CELLULAR)) {
            modes.add("mobile");
        }
        if (hasTransport(caps, NetworkCapabilities.TRANSPORT_ETHERNET)) {
            modes.add("ethernet");
        }
        if (hasTransport(caps, NetworkCapabilities.TRANSPORT_BLUETOOTH)) {
            modes.add("bluetooth");
        }
        r.itemPass("mode", modes.isEmpty() ? "other/unknown" : String.join("+", modes));
        r.itemBool(hasTransport(caps, NetworkCapabilities.TRANSPORT_VPN)
                        ? DevNetworkSelfTestReport.Level.WARN : DevNetworkSelfTestReport.Level.PASS,
                "vpn/tunnel", hasTransport(caps, NetworkCapabilities.TRANSPORT_VPN), "in use", "none");

        if (lp == null) {
            r.itemWarn("iface", "no link properties");
            return;
        }
        r.itemPass("iface", lp.getInterfaceName() != null ? lp.getInterfaceName() : "?");
        String ipv4 = firstIpv4Cidr(lp);
        if (ipv4.isEmpty()) {
            r.itemWarn("ipv4", "none");
        } else {
            r.itemPass("ipv4", ipv4);
        }
        if (lp.getLinkAddresses().size() > 1) {
            r.itemPass("cidr-all", joinCidrs(lp));
        }
        if (lp.getDnsServers().isEmpty()) {
            r.itemFail("dns", "none configured");
        } else {
            List<String> dns = new ArrayList<>();
            for (InetAddress addr : lp.getDnsServers()) {
                dns.add(addr.getHostAddress());
            }
            r.itemPass("dns", String.join(", ", dns));
        }
        ProxyInfo proxy = lp.getHttpProxy();
        if (proxy != null && proxy.getHost() != null) {
            r.itemWarn("http-proxy", proxy.getHost() + ":" + proxy.getPort());
        } else {
            r.itemPass("http-proxy", "none");
        }

        TelephonyManager tm = context.getSystemService(TelephonyManager.class);
        if (tm != null) {
            String operator = tm.getNetworkOperatorName();
            r.itemPass("operator", operator == null || operator.isEmpty() ? "unknown" : operator);
            r.itemNa("apn", API10_NA);
        }
    }

    private static void probeCaptive(NetworkCapabilities caps, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
        r.section("PORTAL", "Captive portal");
        boolean declared = caps != null && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL);
        if (declared) {
            r.itemFail("os-flag", "captive portal declared");
        } else {
            r.itemPass("os-flag", "clear");
        }
        if (cancel != null && cancel.get()) {
            r.itemWarn("probe", "stopped");
            return;
        }
        HttpResult probe = httpHead("http://connectivitycheck.gstatic.com/generate_204");
        if (probe.code == 204) {
            r.itemPass("generate_204", probe.summary);
            r.itemPass("heuristic", "no portal signs");
        } else if (probe.code > 0) {
            r.itemWarn("generate_204", probe.summary);
            r.itemWarn("heuristic", "possible captive portal");
        } else {
            r.itemFail("generate_204", probe.summary);
        }
    }

    private static void probeIntraLan(Context context, LinkProperties lp, AtomicBoolean cancel,
            DevNetworkSelfTestReport r) {
        r.section("LAN", "Intra-network");
        String ip = firstIpv4Host(lp);
        if (ip == null) {
            r.itemNa("peer-scan", "no local IPv4");
            return;
        }
        if (cancel != null && cancel.get()) {
            r.itemWarn("peer-scan", "stopped");
            return;
        }
        String[] parts = ip.split("\\.");
        if (parts.length != 4) {
            r.itemNa("peer-scan", "unsupported IPv4");
            return;
        }
        String prefix = parts[0] + "." + parts[1] + "." + parts[2] + ".";
        r.itemPass("subnet", prefix + "x (self " + ip + ")");
        CastLanPeerProbe.Result scan = CastLanPeerProbe.scan(context, cancel);
        r.itemPass("scan-ms", Long.toString(scan.elapsedMs));
        if (scan.peers == 0) {
            r.itemWarn("cast-peers",
                    "0 on LAN (UDP :" + CastConfig.DISCOVERY_PORT + ", need peer listening/Receive)");
        } else {
            r.itemPass("cast-peers", scan.peers + " · " + scan.summary);
        }
    }

    private static void probeBackend(Context context, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
        r.section("BACKEND", "Backend reachability");
        String heartbeat = BackendEndpoints.HEARTBEAT_URL;
        r.itemPass("url", shortenUrl(heartbeat));
        String configured = BackendEndpoints.normalizeCrashesUrl(
                CrashSettingsStore.load(context).uploadUrl);
        if (!configured.isEmpty() && !configured.equals(BackendEndpoints.UPLOAD_URL)) {
            r.itemWarn("configured", shortenUrl(configured) + " (legacy host; probe uses apps.f0xx.org)");
        }
        if (cancel != null && cancel.get()) {
            r.itemWarn("probe", "stopped");
            return;
        }
        HttpResult head = httpHead(BackendEndpoints.CRASHES_BASE + "/");
        reportHttp(r, "head", head.code, head.summary);
        HttpReadResult read = httpRead(BackendEndpoints.CRASHES_BASE + "/", 32 * 1024);
        reportHttpRead(r, "downlink", read.code, read.summary);
        HttpResult uplink = httpPostJson(BackendEndpoints.HEARTBEAT_URL,
                "{\"heartbeat\":{\"type\":\"ping\"}}");
        reportHttp(r, "uplink", uplink.code, uplink.summary);
    }

    private static void probeNtp(Context context, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
        r.section("NTP", "Time sync");
        r.itemPass("utc-epoch", Long.toString(System.currentTimeMillis() / 1000L));
        int tzMin = java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60000;
        r.itemPass("local-tz-offset", tzMin + " min");
        boolean enabled = AppPreferences.isDevBackendNtpSyncEnabled(context);
        r.itemBool(enabled ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.NA,
                "be-ntp-sync", enabled, "enabled (±3 min threshold)", "disabled (dev setting)");
        if (!enabled) {
            return;
        }
        String backend = BackendEndpoints.HEARTBEAT_URL;
        if (cancel != null && cancel.get()) {
            r.itemWarn("be-ntp", "stopped");
            return;
        }
        long clientEpochSec = System.currentTimeMillis() / 1000L;
        String tz = new java.text.SimpleDateFormat("Z", Locale.US).format(new java.util.Date());
        String body = "{\"heartbeat\":{\"type\":\"ntp\",\"date\":" + clientEpochSec
                + ",\"tz\":\"" + tz + "\",\"time_source\":\"device\"}}";
        long t0 = System.currentTimeMillis();
        HttpTextResult text = httpPostJsonText(backend, body);
        long t1 = System.currentTimeMillis();
        if (!httpSuccess(text.code)) {
            if (text.code == 404) {
                r.itemFail("be-ntp", text.summary + " · heartbeat.php missing on host");
            } else if (text.code > 0) {
                r.itemFail("be-ntp", text.summary);
            } else {
                r.itemFail("be-ntp", text.summary);
            }
            return;
        }
        BackendNtpSync.Result sync = BackendNtpSync.evaluateResponse(
                context, t0, t1, clientEpochSec, text.body);
        switch (sync.action) {
            case SYNCED:
                r.itemPass("be-ntp", text.summary);
                r.itemPass("skew", sync.skewMs + " ms");
                r.itemPass("action", "synced (|skew| > 3 min) · offset=" + sync.appliedOffsetMs + " ms");
                break;
            case WITHIN_THRESHOLD:
                r.itemPass("be-ntp", text.summary);
                r.itemPass("skew", sync.skewMs + " ms");
                r.itemPass("action", "within ±3 min · offset=" + sync.appliedOffsetMs + " ms");
                break;
            case FAILED:
                r.itemFail("be-ntp", sync.detail);
                break;
            default:
                r.itemNa("be-ntp", sync.detail);
                break;
        }
        if (!sync.serverWallBeTz.isEmpty()) {
            r.itemPass("be-wall", sync.serverWallBeTz);
        }
        if (!sync.localWallBeTz.isEmpty()) {
            r.itemPass("local-as-be", sync.localWallBeTz);
        }
        if (sync.detail != null && !sync.detail.isEmpty()) {
            r.itemPass("detail", truncate(sync.detail, 160));
        }
        long corrected = BackendNtpSync.correctedNowMs(context);
        r.itemPass("corrected-utc", Long.toString(corrected / 1000L));
    }

    private static void probeNatExternal(NetworkCapabilities caps, Context context, AtomicBoolean cancel,
            DevNetworkSelfTestReport r) {
        r.section("WAN", "NAT / external IP");
        String nat;
        if (hasTransport(caps, NetworkCapabilities.TRANSPORT_VPN)) {
            nat = "vpn/tunnel";
        } else if (hasTransport(caps, NetworkCapabilities.TRANSPORT_CELLULAR)) {
            nat = "likely CGNAT (mobile)";
        } else if (hasTransport(caps, NetworkCapabilities.TRANSPORT_WIFI)) {
            nat = "likely router NAT";
        } else {
            nat = "unknown";
        }
        r.itemPass("nat", nat);
        if (cancel != null && cancel.get()) {
            r.itemWarn("external-ip", "stopped");
            return;
        }
        HttpTextResult viaBackend = httpPostJsonText(BackendEndpoints.HEARTBEAT_URL,
                "{\"heartbeat\":{\"type\":\"ip\"}}");
        if (viaBackend.code < 0) {
            r.itemFail("external-ip", viaBackend.summary);
        } else if (!httpSuccess(viaBackend.code)) {
            r.itemFail("external-ip", viaBackend.summary);
        } else {
            r.itemPass("external-ip", viaBackend.summary);
            if (viaBackend.body != null && !viaBackend.body.isEmpty()) {
                r.itemPass("be-ip-body", truncate(viaBackend.body, 80));
            }
        }
    }

    private static void probeMtu(LinkProperties lp, DevNetworkSelfTestReport r) {
        r.section("MTU", "MTU");
        if (lp != null) {
            r.itemPass("active-link", Integer.toString(lp.getMtu()));
        } else {
            r.itemNa("active-link", "n/a");
        }
        try {
            Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces();
            if (en == null) {
                r.itemNa("ifaces", "unavailable");
                return;
            }
            List<String> lines = new ArrayList<>();
            for (NetworkInterface nif : Collections.list(en)) {
                if (!nif.isUp()) {
                    continue;
                }
                lines.add(nif.getName() + "=" + nif.getMTU());
                if (lines.size() >= 6) {
                    break;
                }
            }
            if (lines.isEmpty()) {
                r.itemWarn("ifaces", "none up");
            } else {
                r.itemPass("ifaces", String.join(", ", lines));
            }
        } catch (Exception e) {
            r.itemFail("ifaces", e.getMessage());
        }
    }

    private static void probeBluetooth(NetworkCapabilities caps, AtomicBoolean cancel, DevNetworkSelfTestReport r) {
        r.section("BT", "Bluetooth");
        boolean btTransport = hasTransport(caps, NetworkCapabilities.TRANSPORT_BLUETOOTH);
        boolean btPan = hasBtPanInterface();
        if (!btTransport && !btPan) {
            r.itemNa("available", "no BT network iface");
            return;
        }
        r.itemPass("available", btTransport ? "transport+iface" : "bt-pan only");
        if (cancel != null && cancel.get()) {
            r.itemWarn("bw-sample", "stopped");
            return;
        }
        long t0 = System.currentTimeMillis();
        int bytes = 0;
        try {
            InetAddress addr = InetAddress.getByName("1.1.1.1");
            if (addr.isReachable(350)) {
                bytes = 512;
            }
        } catch (Exception ignored) {
        }
        long dt = Math.max(1L, System.currentTimeMillis() - t0);
        double kbps = (bytes * 8.0) / dt;
        r.itemPass("bw-sample", String.format(Locale.US, "~%.1f Kbit/s (rough)", kbps));
    }

    private static String firstIpv4Cidr(LinkProperties lp) {
        if (lp == null) {
            return "";
        }
        for (LinkAddress la : lp.getLinkAddresses()) {
            InetAddress addr = la.getAddress();
            if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
                return la.toString();
            }
        }
        return "";
    }

    private static String firstIpv4Host(LinkProperties lp) {
        if (lp == null) {
            return null;
        }
        for (LinkAddress la : lp.getLinkAddresses()) {
            InetAddress addr = la.getAddress();
            if (addr instanceof Inet4Address && !addr.isLoopbackAddress()) {
                return addr.getHostAddress();
            }
        }
        return null;
    }

    private static String joinCidrs(LinkProperties lp) {
        List<String> out = new ArrayList<>();
        for (LinkAddress la : lp.getLinkAddresses()) {
            out.add(la.toString());
        }
        return String.join("; ", out);
    }

    private static String shortenUrl(String url) {
        if (url.length() <= 72) {
            return url;
        }
        return url.substring(0, 69) + "...";
    }

    private static String truncate(String s, int max) {
        if (s == null) {
            return "";
        }
        String oneLine = s.replace('\n', ' ').trim();
        if (oneLine.length() <= max) {
            return oneLine;
        }
        return oneLine.substring(0, max - 3) + "...";
    }

    private static boolean hasTransport(NetworkCapabilities caps, int t) {
        return caps != null && caps.hasTransport(t);
    }

    private static boolean hasBtPanInterface() {
        try {
            Enumeration<NetworkInterface> en = NetworkInterface.getNetworkInterfaces();
            if (en == null) {
                return false;
            }
            for (NetworkInterface nif : Collections.list(en)) {
                String n = nif.getName();
                if (n != null && (n.startsWith("bt-pan") || n.startsWith("bnep"))) {
                    return nif.isUp();
                }
            }
        } catch (Exception ignored) {
        }
        return false;
    }

    private static boolean httpSuccess(int code) {
        return code >= 200 && code < 300;
    }

    /** 2xx pass; 404 fail; other 4xx warn (host answered, wrong path/method/body). */
    private static void reportHttp(DevNetworkSelfTestReport r, String key, int code, String summary) {
        if (httpSuccess(code)) {
            r.itemPass(key, summary);
        } else if (code == 404) {
            r.itemFail(key, summary + " · not found");
        } else if (code > 0 && code < 500) {
            r.itemWarn(key, summary + " · reachable, unexpected status");
        } else {
            r.itemFail(key, summary);
        }
    }

    private static void reportHttpRead(DevNetworkSelfTestReport r, String key, int code, String summary) {
        if (summary.startsWith("fail")) {
            r.itemFail(key, summary);
        } else {
            reportHttp(r, key, code, summary);
        }
    }

    private static HttpResult httpHead(String url) {
        long t0 = System.currentTimeMillis();
        try {
            HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
            c.setRequestMethod("HEAD");
            c.setConnectTimeout(HTTP_TIMEOUT_MS);
            c.setReadTimeout(HTTP_TIMEOUT_MS);
            c.setInstanceFollowRedirects(true);
            int code = c.getResponseCode();
            long ms = System.currentTimeMillis() - t0;
            c.disconnect();
            return new HttpResult(code, "HTTP " + code + " · " + ms + " ms");
        } catch (Exception e) {
            long ms = System.currentTimeMillis() - t0;
            return new HttpResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms");
        }
    }

    private static HttpResult httpPostJson(String url, String body) {
        long t0 = System.currentTimeMillis();
        try {
            HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
            c.setRequestMethod("POST");
            c.setConnectTimeout(HTTP_TIMEOUT_MS);
            c.setReadTimeout(HTTP_TIMEOUT_MS);
            c.setDoOutput(true);
            c.setRequestProperty("Content-Type", "application/json");
            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
            try (OutputStream os = c.getOutputStream()) {
                os.write(bytes);
            }
            int code = c.getResponseCode();
            long ms = System.currentTimeMillis() - t0;
            c.disconnect();
            return new HttpResult(code, "HTTP " + code + " · " + ms + " ms · " + bytes.length + " B");
        } catch (Exception e) {
            long ms = System.currentTimeMillis() - t0;
            return new HttpResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms");
        }
    }

    private static HttpReadResult httpRead(String url, int maxBytes) {
        long t0 = System.currentTimeMillis();
        try {
            HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
            c.setRequestMethod("GET");
            c.setConnectTimeout(HTTP_TIMEOUT_MS);
            c.setReadTimeout(HTTP_TIMEOUT_MS);
            int code = c.getResponseCode();
            int read = 0;
            try (InputStream in = c.getInputStream()) {
                byte[] buf = new byte[4096];
                while (read < maxBytes) {
                    int n = in.read(buf, 0, Math.min(buf.length, maxBytes - read));
                    if (n <= 0) {
                        break;
                    }
                    read += n;
                }
            }
            long ms = Math.max(1L, System.currentTimeMillis() - t0);
            double kbps = (read * 8.0) / ms;
            c.disconnect();
            return new HttpReadResult(code, "HTTP " + code + " · " + read + " B · "
                    + String.format(Locale.US, "%.1f Kbit/s", kbps));
        } catch (Exception e) {
            long ms = System.currentTimeMillis() - t0;
            return new HttpReadResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms");
        }
    }

    private static HttpTextResult httpPostJsonText(String url, String body) {
        long t0 = System.currentTimeMillis();
        try {
            HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
            c.setRequestMethod("POST");
            c.setConnectTimeout(HTTP_TIMEOUT_MS);
            c.setReadTimeout(HTTP_TIMEOUT_MS);
            c.setDoOutput(true);
            c.setRequestProperty("Content-Type", "application/json");
            try (OutputStream os = c.getOutputStream()) {
                os.write(body.getBytes(StandardCharsets.UTF_8));
            }
            int code = c.getResponseCode();
            String text = readBody(c);
            long ms = System.currentTimeMillis() - t0;
            c.disconnect();
            return new HttpTextResult(code, "HTTP " + code + " · " + ms + " ms", text);
        } catch (Exception e) {
            long ms = System.currentTimeMillis() - t0;
            return new HttpTextResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms", "");
        }
    }

    private static HttpTextResult httpGetText(String url) {
        long t0 = System.currentTimeMillis();
        try {
            HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
            c.setRequestMethod("GET");
            c.setConnectTimeout(HTTP_TIMEOUT_MS);
            c.setReadTimeout(HTTP_TIMEOUT_MS);
            int code = c.getResponseCode();
            String text = readBody(c);
            long ms = System.currentTimeMillis() - t0;
            c.disconnect();
            return new HttpTextResult(code, "HTTP " + code + " · " + ms + " ms", text);
        } catch (Exception e) {
            long ms = System.currentTimeMillis() - t0;
            return new HttpTextResult(-1, "fail · " + e.getClass().getSimpleName() + " · " + ms + " ms", "");
        }
    }

    private static String readBody(HttpURLConnection c) {
        try (BufferedReader br = new BufferedReader(new InputStreamReader(c.getInputStream(), StandardCharsets.UTF_8))) {
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = br.readLine()) != null) {
                sb.append(line);
                if (sb.length() > 2048) {
                    break;
                }
            }
            return sb.toString();
        } catch (Exception e) {
            try (BufferedReader br = new BufferedReader(new InputStreamReader(c.getErrorStream(), StandardCharsets.UTF_8))) {
                String line = br.readLine();
                return line != null ? line : "";
            } catch (Exception ignored) {
                return "";
            }
        }
    }

    private static final class HttpResult {
        final int code;
        final String summary;

        HttpResult(int code, String summary) {
            this.code = code;
            this.summary = summary;
        }
    }

    private static final class HttpReadResult {
        final int code;
        final String summary;

        HttpReadResult(int code, String summary) {
            this.code = code;
            this.summary = summary;
        }
    }

    private static final class HttpTextResult {
        final int code;
        final String summary;
        final String body;

        HttpTextResult(int code, String summary, String body) {
            this.code = code;
            this.summary = summary;
            this.body = body;
        }
    }
}
