package com.dragonssh.xhttpdemo.core.tunnel;

import android.content.Context;

import com.dragonssh.xhttpdemo.core.BuildConfig;
import com.dragonssh.xhttpdemo.core.logger.SkStatus;
import com.dragonssh.xhttpdemo.core.tunnel.vpn.SocketProtector;
import com.trilead.ssh2.ProxyData;

import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

import javax.net.SocketFactory;
import javax.net.ssl.X509TrustManager;

import okhttp3.Call;
import okhttp3.Dispatcher;
import okhttp3.Dns;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

/**
 * XHTTP (SplitHTTP) transport for the SSH tunnel, over <b>HTTP/2</b>.
 *
 * <p>Implements {@link ProxyData} and returns a
 * {@link Socket} whose duplex byte stream is consumed by the Trilead SSH client.
 * The SSH stream is carried over the server's native XHTTP inbound as a real
 * Xray-style XHTTP client would:</p>
 *
 * <ul>
 *   <li><b>Downlink</b> — {@code GET {path}/{session}} : one long-lived streamed
 *       HTTP/2 response body = server&rarr;client SSH stream (stream-down).</li>
 *   <li><b>Uplink</b> — {@code POST {path}/{session}/{seq}} : one discrete POST
 *       per SSH packet, sequence-numbered (packet-up). The server reassembles by
 *       {@code seq}.</li>
 * </ul>
 *
 * <p><b>Why this exact shape:</b> CDNs (e.g. Azion) buffer HTTP/1.1 and buffer
 * streaming request bodies, but pass HTTP/2 responses and discrete POSTs through
 * in real time. So the download must be HTTP/2 stream-down and the upload must be
 * packet-up — anything else stalls the SSH handshake behind the CDN.</p>
 *
 * <p><b>Domain fronting:</b> OkHttp derives the TLS SNI from the URL host and the
 * HTTP routing host from the request {@code Host} header, so the URL host
 * is the SNI (e.g. a carrier "bug" domain) while {@code Host} is the CDN edge,
 * and a custom {@link Dns} pins the connection to the configured server IP.</p>
 */
public class XHttpProxy implements ProxyData {

	private static final String USER_AGENT =
			"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
	private static final MediaType OCTET = MediaType.parse("application/octet-stream");
	private static final long UPLINK_READ_TIMEOUT_SECONDS = 20;
	private static final long UPLINK_CALL_TIMEOUT_SECONDS = 30;

	private final String server;   // IP/host to actually connect to (the "proxy")
	private final int port;
	private final boolean useTls;
	private final String sni;      // TLS SNI (URL host)
	private final String path;
	private final String host;     // XHTTP Host used by the CDN/reverse proxy
	private final Context mContext;

	private volatile XHttpBridgeSocket bridge;

	public XHttpProxy(String server, int port, boolean useTls, String sni, String path, String host, Context context) {
		this.server = server;
		this.port = port;
		this.useTls = useTls;
		this.sni = trimOrNull(sni);
		this.path = path;
		this.host = trimOrNull(host);
		this.mContext = context;
	}

	@Override
	public Socket openConnection(String hostname, int port, int connectTimeout, int readTimeout) throws IOException {
		final String sessionId = UUID.randomUUID().toString().replace("-", "");
		final String urlHost = (useTls && sni != null) ? sni : stripBrackets(server);
		final String hostHeader = hostHeader();
		final String reqPath = buildPath(sessionId);
		final String scheme = useTls ? "https" : "http";

		final HttpUrl sessionUrl = new HttpUrl.Builder()
				.scheme(scheme)
				.host(urlHost)
				.port(this.port)
				.encodedPath(reqPath)
				.build();

		if (BuildConfig.DEBUG) {
			SkStatus.logInfo("XHTTP(h2): connect=" + server + ":" + this.port
					+ " sni=" + (sni != null ? sni : "(none)") + " host=" + hostHeader
					+ " url=" + sessionUrl);
		} else {
			SkStatus.logInfo(useTls
					? "Iniciando XHTTP (TLS)..."
					: "Iniciando XHTTP (sem TLS)...");
		}

		final OkHttpClient client = buildClient(connectTimeout);

		try {
			// --- Downlink: GET, streamed HTTP/2 response body (server -> client) ---
			Request getReq = new Request.Builder()
					.url(sessionUrl)
					.header("Host", hostHeader)
					.header("User-Agent", USER_AGENT)
					.header("Accept", "*/*")
					.get()
					.build();

			Response dlResp = client.newCall(getReq).execute();
			if (!dlResp.isSuccessful() || dlResp.body() == null) {
				int code = dlResp.code();
				dlResp.close();
				throw new IOException("XHTTP download rejected: HTTP " + code);
			}
			final Response downResp = dlResp;
			final InputStream rawDownIn = dlResp.body().byteStream();
			final InputStream downIn = new FilterInputStream(rawDownIn) {
				private boolean terminationLogged;

				private void logTermination(String detail) {
					if (!terminationLogged) {
						terminationLogged = true;
						SkStatus.logError("XHTTP downlink stopped: " + detail);
					}
				}

				@Override public int read() throws IOException {
					try {
						int value = super.read();
						if (value < 0) logTermination("EOF");
						return value;
					} catch (IOException e) {
						logTermination(e.getMessage() != null ? e.getMessage() : e.toString());
						throw e;
					}
				}

				@Override public int read(byte[] b, int off, int len) throws IOException {
					try {
						int count = super.read(b, off, len);
						if (count < 0) logTermination("EOF");
						return count;
					} catch (IOException e) {
						logTermination(e.getMessage() != null ? e.getMessage() : e.toString());
						throw e;
					}
				}
			};
			// h2 is required to traverse CDNs; surface the negotiated protocol so a
			// silent fallback to HTTP/1.1 (which CDNs buffer) is visible in the log.
			SkStatus.logInfo("XHTTP proto=" + dlResp.protocol());

			// --- Uplink: packet-up (one POST per SSH packet) ---
			final PacketUpOutputStream upOut =
					new PacketUpOutputStream(client, sessionUrl, hostHeader);

			Runnable onClose = new Runnable() {
				@Override public void run() {
					upOut.markClosed();
					try { downResp.close(); } catch (Exception ignored) {}
				}
			};

			XHttpBridgeSocket b = new XHttpBridgeSocket(downIn, upOut, onClose);
			upOut.setBridge(b);
			this.bridge = b;
			return b;
		} catch (IOException e) {
			throw e;
		} catch (Exception e) {
			throw new IOException("XHTTP connect failed: " + e);
		}
	}

	private OkHttpClient buildClient(int connectTimeout) throws IOException {
		// Pin every lookup to the configured server IP (the "proxy"); the URL host
		// is used only for SNI / routing, never for DNS.
		final InetAddress[] pinned;
		try {
			pinned = InetAddress.getAllByName(stripBrackets(server));
		} catch (UnknownHostException e) {
			throw new IOException("XHTTP: cannot resolve server " + server + ": " + e);
		}
		Dns dns = new Dns() {
			@Override public List<InetAddress> lookup(String hostname) {
				return Arrays.asList(pinned);
			}
		};

		// This client only needs one stream-down GET plus one packet-up POST at a
		// time. Keep a small bounded dispatcher so a transport bug cannot create an
		// unbounded HTTP queue inside the VPN process.
		Dispatcher dispatcher = new Dispatcher();
		dispatcher.setMaxRequests(8);
		dispatcher.setMaxRequestsPerHost(8);

		OkHttpClient.Builder b = new OkHttpClient.Builder()
				.dns(dns)
				.dispatcher(dispatcher)
				.socketFactory(new ProtectedSocketFactory())
				.connectTimeout(connectTimeout > 0 ? connectTimeout : 15000, TimeUnit.MILLISECONDS)
				.readTimeout(0, TimeUnit.MILLISECONDS)   // streaming download: no read timeout
				.writeTimeout(30, TimeUnit.SECONDS)
				.callTimeout(0, TimeUnit.MILLISECONDS)
				.pingInterval(25, TimeUnit.SECONDS)      // keep the h2 connection alive
				.retryOnConnectionFailure(false);

		if (useTls) {
			X509TrustManager trustAll = new X509TrustManager() {
				@Override public void checkClientTrusted(java.security.cert.X509Certificate[] c, String a) {}
				@Override public void checkServerTrusted(java.security.cert.X509Certificate[] c, String a) {}
				@Override public java.security.cert.X509Certificate[] getAcceptedIssuers() {
					return new java.security.cert.X509Certificate[0];
				}
			};
			try {
				b.sslSocketFactory(new TLSSocketFactory(mContext), trustAll);
			} catch (Exception e) {
				throw new IOException("XHTTP: TLS setup failed: " + e);
			}
			b.hostnameVerifier((h, s) -> true);
			b.protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1));
		} else {
			// Plaintext is only for direct (no-CDN) testing, where HTTP/1.1 streams
			// fine; h2 is only required to defeat CDN buffering over TLS.
			b.protocols(Arrays.asList(Protocol.HTTP_1_1));
		}

		return b.build();
	}

	/** SocketFactory that excludes control sockets from the VPN (avoids routing loops). */
	private static final class ProtectedSocketFactory extends SocketFactory {
		@Override public Socket createSocket() throws IOException {
			Socket s = new Socket();
			boolean vpnReady = SocketProtector.isVpnServiceReady();
			boolean protectedOk = SocketProtector.protect(s);

			/*
			 * Some Samsung/vendor builds return false here for an unconnected
			 * socket even though the VPN is valid. Do not destroy the reconnect:
			 * the active VPN was established with this package UID excluded, so
			 * this socket is already outside the TUN at the routing-policy layer.
			 */
			if (vpnReady && !protectedOk && !SocketProtector.isProcessBypassConfigured()) {
				try { s.close(); } catch (IOException ignored) {}
				throw new IOException("XHTTP control socket has no VPN bypass");
			}
			if (vpnReady && !protectedOk) {
				SkStatus.logDebug("XHTTP protect(Socket) returned false; using package UID VPN bypass");
			}
			return s;
		}
		@Override public Socket createSocket(String host, int port) { throw new UnsupportedOperationException(); }
		@Override public Socket createSocket(String host, int port, InetAddress lh, int lp) { throw new UnsupportedOperationException(); }
		@Override public Socket createSocket(InetAddress host, int port) { throw new UnsupportedOperationException(); }
		@Override public Socket createSocket(InetAddress host, int port, InetAddress lh, int lp) { throw new UnsupportedOperationException(); }
	}

	/**
	 * Uplink as sequence-numbered POSTs (packet-up): {@code POST {session}/{seq}}.
	 *
	 * <p>Sent <b>strictly in order, single-flight</b> — one POST fully completes
	 * before the next starts. The server sends its {@code 200} only after pushing
	 * each packet to the SSH reader, so waiting for each response guarantees the
	 * server receives seq 0,1,2,… in order and its reorder buffer never grows
	 * (concurrent/out-of-order POSTs used to overflow it and kill the tunnel).</p>
	 *
	 * <p>SSH writes just append to a buffer; a single worker thread drains it,
	 * coalescing whatever accumulated during the previous round-trip into one POST
	 * (up to {@link #MAX_POST}) so throughput isn't limited to one packet per RTT.
	 * Writers block once the buffer hits {@link #MAX_BUFFER} (backpressure).</p>
	 */
	private static final class PacketUpOutputStream extends OutputStream {
		private static final int MAX_POST = 900 * 1024;       // stay under server per-POST cap (1MB)
		private static final int MAX_BUFFER = 8 * 1024 * 1024; // backpressure ceiling
		private static final long MAX_BACKPRESSURE_WAIT_MS = 35_000;

		private final OkHttpClient uplinkClient;
		private final HttpUrl base;      // .../{session}
		private final String hostHeader;

		private final Object lock = new Object();
		private final ByteArrayOutputStream buf = new ByteArrayOutputStream();
		private long seq = 0;            // touched only by the worker thread
		private volatile IOException error;
		private volatile boolean closed;
		private volatile XHttpBridgeSocket bridge;
		private volatile Call activeCall;
		private final Thread worker;

		PacketUpOutputStream(OkHttpClient client, HttpUrl base, String hostHeader) {
			// The stream-down call must have no read/call timeout, but packet-up POSTs
			// absolutely must. A CDN can keep a POST open forever without returning an
			// error; with the old shared no-timeout client the SSH writer blocked, the
			// ping thread blocked behind it, and automatic reconnect never ran.
			this.uplinkClient = client.newBuilder()
					.readTimeout(UPLINK_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
					.callTimeout(UPLINK_CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
					.build();
			this.base = base;
			this.hostHeader = hostHeader;
			this.worker = new Thread(this::runUplink, "xhttp-uplink");
			this.worker.setDaemon(true);
			this.worker.start();
		}

		void setBridge(XHttpBridgeSocket b) { this.bridge = b; }

		void markClosed() {
			Call call;
			synchronized (lock) {
				closed = true;
				buf.reset();
				call = activeCall;
				lock.notifyAll();
			}
			if (call != null) {
				call.cancel();
			}
		}

		@Override public void write(int b) throws IOException {
			write(new byte[]{(byte) b}, 0, 1);
		}

		@Override public void write(byte[] b, int off, int len) throws IOException {
			if (len <= 0) {
				return;
			}
			IOException timeoutFailure = null;
			synchronized (lock) {
				checkState();
				// Backpressure: don't let the buffer grow unbounded if SSH out-runs the link.
				long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(MAX_BACKPRESSURE_WAIT_MS);
				while (buf.size() >= MAX_BUFFER && error == null && !closed) {
					long remainingNanos = deadlineNanos - System.nanoTime();
					if (remainingNanos <= 0) {
						timeoutFailure = new IOException("XHTTP uplink backpressure timed out");
						break;
					}
					waitOn(remainingNanos);
				}
				if (timeoutFailure == null) {
					checkState();
					buf.write(b, off, len);
					lock.notifyAll();
				}
			}
			if (timeoutFailure != null) {
				fail(timeoutFailure);
				throw timeoutFailure;
			}
		}

		@Override public void flush() throws IOException {
			synchronized (lock) {
				checkState();
				lock.notifyAll(); // the worker already sends promptly; nudge it
			}
		}

		@Override public void close() {
			markClosed();
		}

		private void runUplink() {
			while (true) {
				byte[] data;
				synchronized (lock) {
					while (buf.size() == 0 && !closed && error == null) {
						try {
							lock.wait();
						} catch (InterruptedException e) {
							Thread.currentThread().interrupt();
							fail(new IOException("XHTTP uplink worker interrupted", e));
							return;
						}
					}
					if (error != null || closed) {
						return;
					}
					byte[] all = buf.toByteArray();
					buf.reset();
					if (all.length > MAX_POST) {
						data = Arrays.copyOfRange(all, 0, MAX_POST);
						buf.write(all, MAX_POST, all.length - MAX_POST); // keep remainder for next round
					} else {
						data = all;
					}
					lock.notifyAll(); // release any writer blocked on backpressure
				}
				if (!sendPacket(data)) {
					return; // error already recorded and bridge closed
				}
			}
		}

		/** Sends one packet POST synchronously (single-flight) so ordering is guaranteed. */
		private boolean sendPacket(byte[] data) {
			if (closed) {
				return false;
			}
			long s = seq++;
			HttpUrl u = base.newBuilder().addPathSegment(Long.toString(s)).build();
			Request req = new Request.Builder()
					.url(u)
					.header("Host", hostHeader)
					.header("User-Agent", USER_AGENT)
					.post(RequestBody.create(OCTET, data))
					.build();
			Call call = uplinkClient.newCall(req);
			activeCall = call;
			if (closed) {
				call.cancel();
				activeCall = null;
				return false;
			}
			try (Response r = call.execute()) {
				if (!r.isSuccessful()) {
					fail(new IOException("XHTTP uplink seq=" + s + " HTTP " + r.code()));
					return false;
				}
				return true;
			} catch (IOException e) {
				if (closed) {
					return false;
				}
				fail(new IOException("XHTTP uplink seq=" + s + " failed: " + e.getMessage(), e));
				return false;
			} finally {
				if (activeCall == call) {
					activeCall = null;
				}
			}
		}

		private void fail(IOException e) {
			boolean first;
			synchronized (lock) {
				first = error == null;
				if (first) {
					error = e;
				}
				lock.notifyAll();
			}
			if (first) {
				SkStatus.logError("XHTTP uplink stopped: " + e.getMessage());
			}
			XHttpBridgeSocket b = bridge;
			if (b != null) {
				try { b.close(); } catch (Exception ignored) {}
			}
		}

		private void waitOn(long remainingNanos) throws IOException {
			try {
				long waitMillis = remainingNanos / 1_000_000L;
				int waitNanos = (int) (remainingNanos % 1_000_000L);
				lock.wait(waitMillis, waitNanos);
			} catch (InterruptedException e) {
				Thread.currentThread().interrupt();
				throw new IOException("XHTTP uplink interrupted");
			}
		}

		private void checkState() throws IOException {
			if (error != null) {
				throw error;
			}
			if (closed) {
				throw new IOException("XHTTP uplink closed");
			}
		}
	}

	private String buildPath(String sessionId) {
		String p = (path == null || path.trim().isEmpty()) ? "/" : path.trim();
		if (!p.startsWith("/")) {
			p = "/" + p;
		}
		while (p.endsWith("/")) {
			p = p.substring(0, p.length() - 1);
		}
		return p + "/" + sessionId;
	}

	private String hostHeader() {
		if (host != null) {
			return host;
		}
		if (sni != null) {
			return sni;
		}
		return stripBrackets(server);
	}

	@Override
	public void close() {
		XHttpBridgeSocket b = this.bridge;
		if (b != null) {
			try { b.close(); } catch (IOException ignored) {}
		}
	}

	private static String trimOrNull(String s) {
		if (s == null) return null;
		s = s.trim();
		return s.isEmpty() ? null : s;
	}

	private static String stripBrackets(String h) {
		if (h == null) return null;
		if (h.startsWith("[") && h.endsWith("]")) {
			return h.substring(1, h.length() - 1);
		}
		return h;
	}
}
