package invalid.lena.scrcpy;

import android.content.Context;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ConnectException;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.List;

import io.github.muntashirakon.adb.AdbStream;

// Bring the scrcpy server up on the target:
//   1. Push assets/scrcpy-server.jar to /data/local/tmp/scrcpy-server.jar
//      via the adb sync protocol.
//   2. Spawn `app_process / com.genymobile.scrcpy.Server <ver> key=value...`
//      via an adb shell stream and hold it open. cleanup=true means the
//      server exits when we close that stream.
//   3. Open three localabstract:scrcpy_<scid> streams in order
//      (video, audio, control). With tunnel_forward=true the server is
//      the listener, so we just dial.
//   4. Drain 1 probe byte + 64-byte device name from the FIRST stream
//      that the server accepts (video, if requested).
//
// Returns a Streams record with everything Session needs: typed
// InputStream/OutputStream wrappers (opened ONCE per AdbStream so the
// reader's offset is unambiguous), plus the underlying AdbStream
// handles so close() can release them.
public final class Server {

    private static final String REMOTE_PATH    = "/data/local/tmp/scrcpy-server.jar";
    private static final String ASSET_JAR      = "scrcpy-server.jar";
    private static final String ASSET_VERSION  = "scrcpy-server.version";
    private static final int    FILE_MODE      = 0100644;         // regular file, 0644
    private static final long   LISTENER_DEADLINE_MS = 20_000;
    private static final long   LISTENER_RETRY_MS = 100;
    public static final class Streams {
        public final AdbStream    videoAds, audioAds, controlAds;
        public final InputStream  videoIn, audioIn, controlIn;
        public final OutputStream controlOut;
        public final String       deviceName;
        public final String       scid;
        public final String       version;

        Streams(AdbStream va, AdbStream aa, AdbStream ca,
                InputStream vi, InputStream ai, InputStream ci, OutputStream co,
                String name, String scid, String version) {
            this.videoAds = va; this.audioAds = aa; this.controlAds = ca;
            this.videoIn = vi;  this.audioIn = ai;  this.controlIn = ci;
            this.controlOut = co;
            this.deviceName = name; this.scid = scid; this.version = version;
        }
    }

    private final Context ctx;
    private final Adb     adb;
    private AdbStream     shell;
    private Thread        shellPump;
    private Streams       streams;
    private volatile boolean serverEof;

    public Server(Context ctx, Adb adb) {
        this.ctx = ctx;
        this.adb = adb;
    }

    public Streams bringUp() throws Exception {
        serverEof = false;
        String version = readVersion();
        long pushed = push();
        Log.i("push %s bytes=%d", REMOTE_PATH, pushed);

        String scid = newScid();
        String cmd = buildCmdline(version, scid);
        Log.i("spawn server ver=%s scid=%s", version, scid);
        Log.i("cmdline: %s", cmd);

        AdbStream va = null, aa = null, ca = null;
        boolean committed = false;
        try {
            shell = adb.openShell(cmd);
            AdbStream shellRef = shell;
            shellPump = new Thread(() -> pump(shellRef.openInputStream()), "server-stdout");
            shellPump.setDaemon(true);
            shellPump.start();

            // These accepts are ordered. If one times out, the whole ADB
            // connection is discarded by Session; retrying an individual
            // open could shift video/audio/control onto the wrong sockets.
            va = openAbstract(scid);
            aa = openAbstract(scid);
            ca = openAbstract(scid);

            InputStream  vi = va.openInputStream();
            InputStream  ai = aa.openInputStream();
            InputStream  ci = ca.openInputStream();
            OutputStream co = ca.openOutputStream();

            String name = readDeviceMeta(vi);
            Log.i("device name=%s", name);
            streams = new Streams(va, aa, ca, vi, ai, ci, co, name, scid, version);
            committed = true;
            return streams;
        } finally {
            if (!committed) {
                closeQuietly(va);
                closeQuietly(aa);
                closeQuietly(ca);
                closeShell();
            }
        }
    }

    // Idempotent. Closes the three media/control streams first (lets
    // the wire drain), then the shell (which makes the scrcpy server
    // exit via cleanup=true), then gives the cleanup helper up to
    // CLOSE_GRACE_MS to restore device state - display power, screen
    // timeout, brightness, etc. - before we kill the adb connection
    // out from under it. Best-effort: cleanup is server-side and we
    // can't synchronously confirm it.
    public void close() {
        if (streams != null) {
            closeQuietly(streams.videoAds);
            closeQuietly(streams.audioAds);
            closeQuietly(streams.controlAds);
            streams = null;
        }
        closeShell();
    }

    private void closeShell() {
        Thread t = shellPump;
        AdbStream s = shell;
        shell = null;
        shellPump = null;
        closeQuietly(s);
        if (t != null) {
            try { t.join(CLOSE_GRACE_MS); }
            catch (InterruptedException ignored) {}
            if (t.isAlive()) t.interrupt();
        }
    }

    private static final long CLOSE_GRACE_MS = 500;

    private static void closeQuietly(AdbStream s) {
        if (s == null) return;
        try { s.close(); } catch (IOException ignored) {}
    }

    // ---- helpers ----

    private String readVersion() throws IOException {
        try (InputStream in = ctx.getAssets().open(ASSET_VERSION)) {
            byte[] buf = new byte[64];
            int n = 0, r;
            while ((r = in.read(buf, n, buf.length - n)) > 0) n += r;
            String v = new String(buf, 0, n, StandardCharsets.UTF_8).trim();
            if (v.isEmpty()) {
                throw new IOException("scrcpy-server.version is empty");
            }
            return v;
        }
    }

    private static String newScid() {
        // 31-bit random, 8 lowercase hex chars - matches scrcpy upstream client.
        int v = new SecureRandom().nextInt() & 0x7fffffff;
        return String.format("%08x", v);
    }

    private String buildCmdline(String version, String scid) {
        String videoCodec = Settings.videoCodec(ctx);
        String audioCodec = Settings.audioCodec(ctx);
        int maxSize     = Settings.maxSize(ctx);
        int videoBitR   = Settings.videoBitRate(ctx);
        int maxFps      = Settings.maxFps(ctx);
        List<String> args = new ArrayList<>();
        args.add("CLASSPATH=" + REMOTE_PATH);
        args.add("app_process");
        args.add("/");
        args.add("com.genymobile.scrcpy.Server");
        args.add(version);
        args.add("scid=" + scid);
        args.add("log_level=info");
        args.add("video=true");
        args.add("audio=true");
        args.add("control=true");
        args.add("video_codec=" + videoCodec);
        args.add("audio_codec=" + audioCodec);
        args.add("max_size=" + maxSize);
        args.add("video_bit_rate=" + videoBitR);
        if (maxFps > 0) args.add("max_fps=" + maxFps);
        args.add("clipboard_autosync=true");
        args.add("tunnel_forward=true");
        args.add("cleanup=true");
        args.add("power_on=true");
        return String.join(" ", args);
    }

    private AdbStream openAbstract(String scid) throws Exception {
        String name = "scrcpy_" + scid;
        long deadline = System.currentTimeMillis() + LISTENER_DEADLINE_MS;
        ConnectException last = null;
        while (System.currentTimeMillis() < deadline) {
            if (serverEof) {
                throw new IOException("server exited before opening " + name, last);
            }
            try {
                AdbStream stream = adb.openAbstract(name);
                Log.i("openAbstract %s ok", name);
                return stream;
            } catch (ConnectException e) {
                // A rejected OPEN consumed no server accept. Retry while the
                // server creates its listener; timeout failures remain fatal
                // because their acceptance state is ambiguous.
                last = e;
                Thread.sleep(LISTENER_RETRY_MS);
            }
        }
        throw new IOException("server did not open " + name + " within "
                + LISTENER_DEADLINE_MS + " ms", last);
    }

    private static String readDeviceMeta(InputStream in) throws IOException {
        byte[] probe = new byte[1];
        Wire.readFully(in, probe);
        byte[] name = new byte[64];
        Wire.readFully(in, name);
        int n = 0;
        while (n < name.length && name[n] != 0) n++;
        return new String(name, 0, n, StandardCharsets.UTF_8);
    }

    private void pump(InputStream in) {
        try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
            String line;
            while ((line = r.readLine()) != null) {
                Log.i("server: %s", line);
            }
        } catch (IOException e) {
            if (!Thread.currentThread().isInterrupted()) Log.w("server-stdout closed: %s", e);
        } finally {
            serverEof = true;
            Log.i("server: stream end");
        }
    }

    // ---- adb sync push ----

    private long push() throws Exception {
        AdbStream sync = adb.openSync();
        // sync.openInput/OutputStream() return wrapper streams whose close()
        // is a no-op; the AdbStream itself owns the channel. Close it once
        // in finally.
        try (InputStream src = ctx.getAssets().open(ASSET_JAR)) {
            int mtime = (int)(System.currentTimeMillis() / 1000L);
            return Sync.push(src, sync.openOutputStream(), sync.openInputStream(),
                    REMOTE_PATH, FILE_MODE, mtime);
        } finally {
            try { sync.close(); } catch (IOException ignored) {}
        }
    }
}
