package invalid.lena.scrcpy;

import android.content.Context;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;

// One mirroring session: owns the Adb connection, the spawned scrcpy
// server, the three streams, sinks, and the controller. start() and
// stop() are idempotent and may be called from any thread.
//
// Listener contract:
//   onConnected(w,h) - wire open, frames flowing. Fires on the first
//                  connect and again after each successful auto-reconnect.
//   onReconnecting() - a live link dropped; the bring-up ladder is rerun.
//                  Followed by onConnected (recovered) or onError (gave up).
//   onError(t)   - fatal: retries exhausted, or non-retriable failure.
//                  Always followed by onStopped().
//   onStopped()  - final state. Fires exactly once per session, whether
//                  user stop() or retries-exhausted.
//
// Auto-reconnect: after a successful connect the video socket is watched;
// if it ends mid-session (target sleep, Wi-Fi blip, server crash) the
// session reruns the bring-up ladder instead of freezing. Only when that
// ladder is exhausted does it give up via onError + onStopped.
//
// Surface-readiness race: Mirror.surfaceCreated builds the Session and
// calls start(), but Mirror.surfaceChanged (which carries the view
// dimensions) can fire before run() has finished constructing Controller.
// setViewSize() therefore stashes the value and applies it as soon as
// the Controller is available.
public final class Session {

    public interface Listener {
        // Fires the first time the server reports a session-meta packet
        // (wire open, frames about to flow), and again after each
        // successful auto-reconnect. Useful for the activity's status bar.
        default void onConnected(int w, int h) {}
        // A previously-live link dropped and we are bringing it back up.
        // Followed by onConnected (recovered), onError (gave up), or
        // onStopped alone (user stop() raced the reconnect).
        default void onReconnecting() {}
        void onError(Throwable t);
        void onStopped();
    }

    // Bring-up retry budget. Total worst-case wait ~ sum of these
    // delays + per-attempt bring-up time. Keep below e2e.sh's deadline.
    private static final long[] BACKOFF_MS = {0L, 1_500L, 5_000L};

    private final Context        ctx;
    private final Adb            adb;
    private final Devices.Device target;
    private volatile Surface     surface;
    private final Listener       listener;

    // Lightweight counters for the in-app status overlay; readers reach
    // into the sinks/controller directly.
    public long videoFrames()  { VideoSink v = videoSink;   return v == null ? 0 : v.frames; }
    public long audioFrames()  { AudioSink a = audioSink;   return a == null ? 0 : a.frames; }
    public String lastEvent()  { Controller c = controller; return c == null ? "(idle)" : c.lastEvent; }

    private Server        server;
    private VideoStream   videoStream;
    private VideoSink     videoSink;
    private AudioStream   audioStream;
    private AudioSink     audioSink;
    private ControlStream controlStream;
    private Controller    controller;
    private MuxRecorder   recorder;
    private Thread        runner;
    private volatile boolean stopped;
    private volatile int  pendingViewW, pendingViewH;

    // Counted down by the video reader when its loop exits; the session
    // thread parks on it for the live duration of a connection. Swapped
    // for a fresh latch on each reconnect cycle.
    private volatile CountDownLatch endSignal = new CountDownLatch(1);

    public Session(Context ctx, Adb adb, Devices.Device target, Surface surface, Listener listener) {
        this.ctx = ctx;
        this.adb = adb;
        this.target = target;
        this.surface = surface;
        this.listener = listener;
    }

    public synchronized void start() {
        if (runner != null) return;
        runner = new Thread(this::run, "session");
        runner.start();
    }

    public synchronized void stop() {
        if (stopped) return;
        stopped = true;
        Log.i("session: stop");
        tearDownInstalled();
        if (listener != null) listener.onStopped();
    }

    // ---- input forwarding (Controller stays internal) ----

    public void onTouch(MotionEvent ev) {
        Controller c = controller;
        if (c != null) c.onTouch(ev);
    }

    public void onKey(KeyEvent ev) {
        Controller c = controller;
        if (c != null) c.onKey(ev);
    }

    public void onBack() {
        Controller c = controller;
        if (c != null) c.onBack();
    }

    public void setViewSize(int w, int h) {
        pendingViewW = w; pendingViewH = h;
        Controller c = controller;
        if (c != null) c.setViewSize(w, h);
    }

    // ---- recording (delegates to MuxRecorder) ----

    public void armRecording(File out) {
        MuxRecorder r = recorder;
        if (r == null) {
            Log.w("session: armRecording before bring-up - ignored");
            return;
        }
        r.arm(out);
    }

    // Returns true if a file was actually written (see MuxRecorder.stop).
    public boolean stopRecording() {
        MuxRecorder r = recorder;
        return r != null && r.stop();
    }

    public boolean isRecording() {
        MuxRecorder r = recorder;
        return r != null && r.isActive();
    }

    // Swap the surface the video pipeline draws to. The audio and control
    // sides keep streaming, so audio + clipboard still work while the
    // activity is backgrounded. Pass null to detach; pass a new Surface
    // (from a recreated SurfaceView) to resume rendering.
    public void swapSurface(Surface s) {
        surface = s;
        VideoSink vk = videoSink;
        if (vk != null) vk.setOutputSurface(s);
    }

    // ---- internals ----

    // Supervisor loop: connect, park until the live pipeline dies, and
    // reconnect if the death wasn't a user stop(). Runs on one thread for
    // the whole session lifetime.
    private void run() {
        while (!stopped) {
            if (!connect()) return;        // gave up: onError + onStopped fired
            try {
                endSignal.await();         // park until the pipeline dies or stop()
            } catch (InterruptedException ie) {
                return;
            }
            // Decide teardown-and-retry under the monitor so a concurrent
            // stop() can't interleave: without this, onReconnecting()
            // could fire after stop()'s onStopped(), breaking the
            // listener contract.
            synchronized (this) {
                if (stopped) return;       // user stop()
                Log.i("session: video stream ended - link lost, reconnecting");
                tearDownInstalled();
                endSignal = new CountDownLatch(1);
                if (listener != null) listener.onReconnecting();
            }
        }
    }

    // Run the bring-up retry ladder once. Returns true when a session is
    // live (read threads started); false if the budget was exhausted, in
    // which case onError() + onStopped() have already fired.
    private boolean connect() {
        Throwable lastErr = null;
        for (int attempt = 0; attempt < BACKOFF_MS.length && !stopped; attempt++) {
            if (BACKOFF_MS[attempt] > 0) {
                Log.i("session: retry %d/%d after %d ms",
                        attempt + 1, BACKOFF_MS.length, BACKOFF_MS[attempt]);
                try { Thread.sleep(BACKOFF_MS[attempt]); }
                catch (InterruptedException ie) { return false; }
                if (stopped) return false;
            }
            try {
                bringUp();
                return true; // success; the read threads own the live session
            } catch (InterruptedException ie) {
                return false;
            } catch (Throwable t) {
                Log.w("session: bring-up attempt %d/%d failed: %s",
                        attempt + 1, BACKOFF_MS.length, t);
                lastErr = t;
                // Discard any partial state from this attempt before retrying.
                tearDownInstalled();
            }
        }
        if (stopped) return false;
        Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length);
        Throwable err = lastErr;
        synchronized (this) {
            if (stopped) return false;
            stopped = true;
            tearDownInstalled();
        }
        if (listener != null) {
            listener.onError(err);
            listener.onStopped();
        }
        return false;
    }

    // Build everything into locals first. Then install + start under the
    // monitor - but only if stop() hasn't already fired, in which case
    // we tear down the locals we just built so nothing leaks.
    private void bringUp() throws Exception {
        Log.i("session: connect %s:%d", target.host, target.port);
        adb.disconnect();
        if (!adb.connect(target.host, target.port)) {
            throw new IOException("adb connect returned false");
        }
        Log.i("adb connect ok");

        Server srv = null;
        ControlStream cs = null;
        Controller ctrl = null;
        AudioSink ak = null;
        AudioStream as = null;
        VideoSink vk = null;
        VideoStream vs = null;
        MuxRecorder rec = null;
        boolean installed = false;
        try {
            srv = new Server(ctx, adb);
            Server.Streams s = srv.bringUp();

            cs = new ControlStream(s.controlIn, s.controlOut, this::onVideoEnded);
            ctrl = new Controller(ctx, cs::send);
            cs.setInboundSink(ctrl);

            ak = new AudioSink(this::onVideoEnded);
            as = new AudioStream(s.audioIn, ak);

            vk = new VideoSink(surface, this::onVideoEnded);
            Controller ctrlRef = ctrl;
            AtomicBoolean reported = new AtomicBoolean();
            vs = new VideoStream(s.videoIn, vk,
                    (w, h) -> reportConnected(ctrlRef, reported, w, h));
            vs.setOnEnd(this::onVideoEnded);
            rec = new MuxRecorder();
            vs.setRecorder(rec);

            // Start locals before publishing them. stop() either tears down
            // a previously installed generation or marks this generation for
            // rollback; it can never release objects that bringUp then starts.
            cs.start();
            as.start();
            vs.start();

            synchronized (this) {
                if (stopped) throw new IOException("session: stopped during bring-up");
                server        = srv;
                controlStream = cs;
                controller    = ctrl;
                audioSink     = ak;
                audioStream   = as;
                videoSink     = vk;
                videoStream   = vs;
                recorder      = rec;
                if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH);
                installed = true;
            }
        } finally {
            if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec);
        }
    }

    private synchronized void reportConnected(Controller ctrl, AtomicBoolean reported,
                                               int w, int h) {
        if (stopped) return;
        ctrl.setTargetSize(w, h);
        if (reported.compareAndSet(false, true) && listener != null) {
            listener.onConnected(w, h);
        }
    }

    // Fired on the video-reader thread when its read loop exits (EOF,
    // error, or stop()). The video socket is the authoritative stream;
    // its end wakes the supervisor, which either unwinds (stop) or
    // reconnects. A no-op countdown after stop() is harmless.
    private void onVideoEnded() {
        endSignal.countDown();
    }

    private synchronized void tearDownInstalled() {
        tearDownLocals(server, controlStream, controller,
                       audioSink, audioStream, videoSink, videoStream, recorder);
        server = null;
        controlStream = null;
        controller = null;
        audioSink = null;
        audioStream = null;
        videoSink = null;
        videoStream = null;
        recorder = null;
        try { adb.disconnect(); } catch (Exception ignored) {}
    }

    private static void tearDownLocals(Server srv, ControlStream cs, Controller ctrl,
                                       AudioSink ak, AudioStream as,
                                       VideoSink vk, VideoStream vs, MuxRecorder rec) {
        if (rec != null) rec.close();
        // Closing the owning ADB streams first unblocks readers. Join them
        // before releasing their sinks so no callback can recreate resources
        // after teardown.
        if (srv != null) srv.close();
        if (vs != null) vs.stop();
        if (as != null) as.stop();
        if (cs != null) cs.stop();
        if (vk != null) vk.release();
        if (ak != null) ak.release();
        if (ctrl != null) ctrl.release();
    }
}
