package chat.vojo.app;

import android.content.Context;
import android.util.Log;

import androidx.webkit.ProxyConfig;
import androidx.webkit.ProxyController;
import androidx.webkit.WebViewFeature;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import chat.vojo.app.ProxyConfigStore.ProxyCreds;

/**
 * Process-global owner of the WebView proxy override + the loopback relay
 * (proxy_android_impl.md §3). Singleton because ProxyController is itself
 * process-global and catches ALL WebView traffic — so the reverse-bypass MUST
 * be scoped to exactly the homeserver host or external URL-preview / widget
 * origins would leak through the user's proxy.
 *
 * Lifecycle (single-session app, no account switch):
 *  - applyAtBoot() re-applies a stored+enabled override before login.
 *  - apply() / disable() are the runtime toggle.
 *  - On logout the plugin calls disable() + ProxyConfigStore.wipe().
 *
 * NEVER calls removeImplicitRules() (keeps localhost/link-local DIRECT — the
 * §15 SSRF-to-localhost guard) and never weakens TLS.
 */
final class ProxyManager {

    private static final String TAG = "ProxyManager";
    private static final long LISTENER_TIMEOUT_MS = 3000;

    interface StatusListener {
        void onStatus(String status, String detail);
    }

    private static volatile ProxyManager instance;

    static ProxyManager get(Context context) {
        ProxyManager local = instance;
        if (local == null) {
            synchronized (ProxyManager.class) {
                local = instance;
                if (local == null) {
                    local = new ProxyManager(context.getApplicationContext());
                    instance = local;
                }
            }
        }
        return local;
    }

    private final Context appContext;
    private final ProxyConfigStore store;
    private final ExecutorService callbackExec = Executors.newSingleThreadExecutor();

    private volatile boolean enabled = false;
    private volatile String status = "off";
    private volatile long appliedAtMs = 0; // when the override was applied (uptime base)
    private volatile String activeHost; // homeserver host the override is scoped to (null = route-all)
    private volatile LocalProxyRelay relay;
    private volatile StatusListener statusListener;

    private ProxyManager(Context context) {
        this.appContext = context;
        this.store = new ProxyConfigStore(context);
    }

    ProxyConfigStore store() {
        return store;
    }

    void setStatusListener(StatusListener listener) {
        this.statusListener = listener;
    }

    static boolean isOverrideSupported() {
        return WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE);
    }

    static boolean isReverseBypassSupported() {
        return WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE_REVERSE_BYPASS);
    }

    boolean isEnabled() {
        return enabled;
    }

    String getStatus() {
        return status;
    }

    /** Epoch-ms when the current override was applied (0 if disabled) — uptime base. */
    long getEnabledSince() {
        return enabled ? appliedAtMs : 0;
    }

    /** The homeserver host the override is scoped to, or null when routing all. */
    String getScopeHost() {
        return activeHost;
    }

    /** True when traffic goes via the loopback relay (authed proxy) vs direct. */
    boolean isRelayed() {
        LocalProxyRelay r = relay;
        return r != null && r.isRunning();
    }

    // ── Apply / disable ─────────────────────────────────────────────────────

    /** Apply the override for `creds`, scoped to `host` (null/routeAll = all
     *  traffic). Must run off the main thread. Returns true on success. */
    synchronized boolean apply(ProxyCreds creds, String host, boolean routeAll) {
        if (creds == null) return false;
        if (!isOverrideSupported()) {
            Log.w(TAG, "PROXY_OVERRIDE unsupported on this WebView");
            return false;
        }

        // Always (re)start a relay bound to fresh creds: the WebView uses it for
        // the SOCKS5-with-auth case, and the native HttpURLConnection paths
        // always route through it (proxy_support.md §15 #3). Restart so a config
        // edit takes a fresh token + upstream.
        teardownRelay();
        String relayTarget = startRelay(creds);

        // Route the WebView through the loopback relay whenever the upstream
        // needs auth — SOCKS5-auth (Chromium can't present it) AND HTTP-auth, so
        // the REAL upstream credential never transits the WebView's
        // onReceivedHttpAuthRequest callback; the WebView only ever
        // authenticates to the loopback relay with its per-session token, and
        // the relay holds the upstream creds. No-auth proxies go direct.
        String webViewTarget;
        boolean socks5 = "socks5".equalsIgnoreCase(creds.type);
        if (creds.hasAuth()) {
            if (relayTarget == null) {
                Log.w(TAG, "relay required for authed proxy but failed to start");
                // teardownRelay() above already killed any previous relay, so a
                // prior override now points at a dead port. Reset to a clean
                // disabled state (clear the override + flags) rather than leave a
                // half-dead proxy the UI would still report as ON (F12).
                disable();
                return false;
            }
            webViewTarget = relayTarget;
        } else if (socks5) {
            webViewTarget = "socks://" + creds.host + ":" + creds.port;
        } else {
            webViewTarget = "http://" + creds.host + ":" + creds.port;
        }

        ProxyConfig.Builder builder = new ProxyConfig.Builder().addProxyRule(webViewTarget);
        boolean scoped = !routeAll && host != null && !host.isEmpty() && isReverseBypassSupported();
        if (scoped) {
            // Invert the bypass list into an allow-list: ONLY the homeserver host
            // rides the proxy; everything else stays DIRECT. Exact host (a
            // wildcard may not match the bare apex — proxy_support.md §15).
            builder.setReverseBypassEnabled(true);
            builder.addBypassRule(host);
        }
        // NEVER removeImplicitRules(): keeps 127.0.0.1 / link-local DIRECT so the
        // loopback relay hop doesn't loop and XSS can't SSRF localhost.

        final CountDownLatch latch = new CountDownLatch(1);
        try {
            ProxyController.getInstance().setProxyOverride(builder.build(), callbackExec, latch::countDown);
            if (!latch.await(LISTENER_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
                // The request was accepted but Chromium's apply-callback didn't
                // confirm in time. Proceed (it usually lands shortly after) but
                // log it; the JS reachability probe is the authoritative
                // ok/error signal, so we don't claim a false 'ok' here.
                Log.w(TAG, "setProxyOverride listener timed out");
            }
        } catch (Throwable t) {
            Log.w(TAG, "setProxyOverride failed");
            // The previous relay is already torn down and the override may be
            // stale/partial — reset to a clean disabled state rather than leave
            // a dead-but-"enabled" proxy with in-memory enabled=true (F12).
            disable();
            return false;
        }

        enabled = true;
        appliedAtMs = System.currentTimeMillis();
        activeHost = scoped ? host : null;
        // The relay just (re)started, so any earlier "native send blocked"
        // condition is resolved — clear the flag (F17).
        clearNativeBlocked();
        setStatus("connecting", null);
        Log.i(TAG, "override applied (scoped=" + scoped + ")");
        return true;
    }

    /** Remove the override + tear down the relay. Must run off the main thread. */
    synchronized void disable() {
        if (isOverrideSupported()) {
            final CountDownLatch latch = new CountDownLatch(1);
            try {
                ProxyController.getInstance().clearProxyOverride(callbackExec, latch::countDown);
                latch.await(LISTENER_TIMEOUT_MS, TimeUnit.MILLISECONDS);
            } catch (Throwable t) {
                Log.w(TAG, "clearProxyOverride failed");
            }
        }
        teardownRelay();
        enabled = false;
        appliedAtMs = 0;
        activeHost = null;
        // Proxy is off → native paths go direct by design; no "blocked" meaning.
        clearNativeBlocked();
        setStatus("off", null);
    }

    /** Re-apply a stored+enabled override at boot. `host` = the resolved
     *  homeserver host (null pre-login → route-all). Must run off-main. */
    synchronized void applyAtBoot(String host) {
        if (!store.isEnabled() || !store.isConfigured()) return;
        ProxyCreds creds = store.load();
        if (creds == null) return;
        boolean routeAll = host == null || host.isEmpty();
        boolean ok = apply(creds, host, routeAll);
        // Persist the real outcome so the in-memory state, the persisted flag
        // the native paths read (isProxyActiveForNative), and the UI can never
        // disagree (F7). A boot-time apply failure persists disabled — the
        // logged-in narrow effect re-applies and re-enables if it then succeeds.
        store.setEnabled(ok);
    }

    // ── Status (dead-proxy detection bridge) ────────────────────────────────

    void onWebViewError(String failingHost) {
        if (!enabled) return;
        // Only flag the homeserver origin as an error; external origins failing
        // is not a proxy problem. Pre-login (route-all, activeHost==null) relies
        // on the JS reachability probe / discovery UI instead.
        if (activeHost != null && failingHost != null && activeHost.equalsIgnoreCase(failingHost)) {
            setStatus("error", failingHost);
        }
    }

    private void setStatus(String next, String detail) {
        status = next;
        StatusListener l = statusListener;
        if (l != null) {
            try {
                l.onStatus(next, detail);
            } catch (Throwable ignored) {
                // listener teardown race — drop
            }
        }
    }

    // ── WebView proxy auth (onReceivedHttpAuthRequest answer) ───────────────

    /** Returns {user, pass} for the relay's 407, else null. We match on the
     *  relay's Basic realm ("vojo") OR a loopback host — matching the realm is
     *  robust to whatever host Chromium reports for the relay's 407 (the F2
     *  linchpin uncertainty), and is safe: the token only works against our
     *  127.0.0.1 listener, so it's useless even if a site somehow saw it.
     *
     *  The real upstream credential is NEVER returned here — authed upstreams
     *  are reached through the relay, so the WebView only ever authenticates to
     *  the loopback relay with its per-session token. */
    String[] getProxyAuth(String host, String realm) {
        if (!enabled) return null;
        LocalProxyRelay r = relay;
        if (r == null || !r.isRunning()) return null;
        boolean realmMatch = realm != null && LocalProxyRelay.getAuthRealm().equals(realm);
        boolean loopback = host != null && isLoopback(normalizeHost(host));
        if (realmMatch || loopback) {
            return new String[] {LocalProxyRelay.getAuthUser(), r.getToken()};
        }
        return null;
    }

    // ── Native-path routing (Phase 2) ───────────────────────────────────────

    /** Reads the persisted flag (works in a cold Worker/receiver process where
     *  apply() never ran). */
    boolean isProxyActiveForNative() {
        return store.isEnabled() && store.isConfigured();
    }

    /** Ensure a relay is running for the native paths, starting it from the
     *  stored config if this process hasn't applied an override. Returns the
     *  port, or -1 on failure. */
    synchronized int ensureRelayForNative() {
        LocalProxyRelay r = relay;
        if (r != null && r.isRunning()) return r.getPort();
        ProxyCreds creds = store.load();
        if (creds == null) return -1;
        startRelay(creds);
        return relay != null && relay.isRunning() ? relay.getPort() : -1;
    }

    /** A background native send was refused because the relay couldn't start
     *  (fail-closed). Records the moment so the UI can warn (F17). */
    void onNativeBlocked() {
        store.setNativeBlockedAt(System.currentTimeMillis());
    }

    /** A native send went through the relay — clear any stale blocked flag. */
    void onNativeServed() {
        clearNativeBlocked();
    }

    /** Epoch-ms of the most recent fail-closed native block (0 = none). */
    long getNativeBlockedAt() {
        return store.getNativeBlockedAt();
    }

    private void clearNativeBlocked() {
        if (store.getNativeBlockedAt() != 0) store.setNativeBlockedAt(0);
    }

    String getRelayToken() {
        LocalProxyRelay r = relay;
        return r != null ? r.getToken() : null;
    }

    int getRelayPort() {
        LocalProxyRelay r = relay;
        return r != null && r.isRunning() ? r.getPort() : -1;
    }

    // ── Relay helpers ───────────────────────────────────────────────────────

    // Returns the WebView CONNECT-proxy target for the relay, or null on failure.
    private String startRelay(ProxyCreds creds) {
        try {
            LocalProxyRelay r = new LocalProxyRelay(creds, newToken());
            r.start();
            relay = r;
            return "http://127.0.0.1:" + r.getPort();
        } catch (Throwable t) {
            Log.w(TAG, "relay start failed");
            relay = null;
            return null;
        }
    }

    private void teardownRelay() {
        LocalProxyRelay r = relay;
        if (r != null) {
            r.stop();
            relay = null;
        }
        ProxyRouting.clearRelay();
    }

    private static String newToken() {
        byte[] b = new byte[24];
        new java.security.SecureRandom().nextBytes(b);
        return android.util.Base64.encodeToString(b, android.util.Base64.NO_WRAP | android.util.Base64.URL_SAFE);
    }

    private static String normalizeHost(String host) {
        String h = host;
        int slash = h.indexOf('/');
        if (slash >= 0) h = h.substring(0, slash);
        // strip :port
        int colon = h.lastIndexOf(':');
        if (colon > 0 && h.indexOf(':') == colon) h = h.substring(0, colon);
        if (h.startsWith("[") && h.endsWith("]")) h = h.substring(1, h.length() - 1);
        return h;
    }

    private static boolean isLoopback(String h) {
        return "127.0.0.1".equals(h) || "localhost".equalsIgnoreCase(h) || "::1".equals(h);
    }
}
