package com.foxx.androidcast.receiver;

/*********************************************************************
 * ReceiverSession.java
 * Created at: Fri 15 May 2026 16:49:02 +0200
 * Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
 * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
 * class ReceiverSession
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (12 commits, 326 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	a0df3c3e1589bdaaa4869da1b6208660f870637a135c06b0946263ca85d095c2
**********************************************************************/
import android.util.Log;

import android.content.Context;

import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastFramingContext;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastSessionGate;
import com.foxx.androidcast.network.CastTransport;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.transport.ProtectionNegotiationPrompts;
import com.foxx.androidcast.network.transport.StreamProtectionNegotiation;
import com.foxx.androidcast.network.UdpCastTransport;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.diag.DiagPingMetrics;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;

import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicBoolean;

/** Server: PIN auth, then forward encoded A/V to decoders. */
public class ReceiverSession {
    private static final String TAG = "ReceiverSession";

    public interface Listener {
        void onStatus(String status);

        void onAuthenticated(String senderName, CastSettings remoteSettings, String negotiatedVideoMime);

        void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1);

        void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data);

        void onAudioConfig(int sampleRate, int channels, byte[] csd0);

        void onAudioFrame(long ptsUs, byte[] data);

        void onDisconnected();

        /** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */
        void onCastEnded();

        /** TCP/UDP listen socket is bound; safe to advertise on the LAN. */
        void onListening();

        /** About to accept a sender and run handshake (bind session stats before I/O). */
        void onCastSessionStarting();

        void onNetworkAdviceApplied();

        void onPeerNetworkStats(NetworkStatsSnapshot stats);

        /** Diagnostic ping-pong sample (developer setting). */
        default void onDiagPingSample(DiagPingMetrics.Snapshot snapshot) {
        }
    }

    private final Context appContext;
    private final String pin;
    private final CastSettings localSettings;
    private final Listener listener;
    private final ExecutorService executor = Executors.newSingleThreadExecutor(new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "ReceiverIO");
            t.setDaemon(true);
            return t;
        }
    });
    private final AtomicBoolean running = new AtomicBoolean(false);

    private CastSession session;
    private CastTransport listenTransport;
    private final CastSessionGate inboundSessionGate = new CastSessionGate();
    private boolean loggedFirstVideoFrame;
    private NetworkFeedbackManager networkFeedback;
    private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;

    public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener) {
        this(context, pin, localSettings, listener, null);
    }

    public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
            PassThroughNetworkControlPlane.StatsEnricher statsEnricher) {
        this.appContext = context.getApplicationContext();
        this.pin = pin;
        this.localSettings = localSettings;
        this.listener = listener;
        this.statsEnricher = statsEnricher;
    }

    public void start() {
        if (running.get()) {
            stop();
        }
        if (!running.compareAndSet(false, true)) {
            return;
        }
        executor.execute(this::runServer);
    }

    public void stop() {
        running.set(false);
        if (session != null) {
            session.close();
            session = null;
        }
        if (listenTransport != null) {
            listenTransport.close();
            listenTransport = null;
        }
        inboundSessionGate.reset();
    }

    public TransportLossStats getTransportLossStats() {
        if (listenTransport != null) {
            return listenTransport.getLossStats();
        }
        return new TransportLossStats();
    }

    public void sendAudioConsent(boolean accept) {
        if (session == null) {
            return;
        }
        try {
            session.send(CastProtocol.MSG_AUDIO_CONSENT, CastProtocol.audioConsentPayload(accept));
        } catch (IOException e) {
            Log.w(TAG, "Failed to send audio consent", e);
        }
    }

    private void runServer() {
        while (running.get()) {
            try {
                if (listenTransport == null) {
                    com.foxx.androidcast.network.CastTransportFactory.init(appContext);
                    listenTransport = CastTransportFactory.create(localSettings);
                    inboundSessionGate.reset();
                    listenTransport.setInboundSessionGate(inboundSessionGate);
                    listenTransport.setFramingContext(new CastFramingContext(
                            0,
                            DeviceInfo.getPlaceholderId(appContext),
                            CastCodecFlags.localDecoderVideoMask(),
                            CastCodecFlags.localAudioMask()));
                    session = new CastSession(listenTransport);
                    int listenPort = CastConfig.portForTransport(localSettings.getTransport());
                    session.prepareListen(listenPort);
                    listener.onListening();
                    postStatus("Listening (" + listenTransport.getModeLabel() + ") port "
                            + listenPort);
                } else if (session == null) {
                    session = new CastSession(listenTransport);
                }
                inboundSessionGate.reset();
                listener.onCastSessionStarting();
                networkFeedback = new NetworkFeedbackManager(session, false);
                if (networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
                    PassThroughNetworkControlPlane plane =
                            (PassThroughNetworkControlPlane) networkFeedback.getPlane();
                    plane.setStatsEnricher(statsEnricher);
                    if (AppPreferences.isDevDiagPingPongEnabled(appContext)) {
                        networkFeedback.configureDiagPing(true,
                                AppPreferences.getDevDiagPingResponseMode(appContext),
                                listener::onDiagPingSample);
                    }
                }
                session.acceptClient();
                CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
                applyNegotiatedProtection(hs.settings);
                listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
                postStatus("Streaming from " + hs.senderName);
                readStream();
            } catch (java.io.EOFException e) {
                if (running.get()) {
                    Log.i(TAG, "Connection closed");
                    postStatus("Sender disconnected");
                }
            } catch (IOException e) {
                if (running.get()) {
                    if (isProtocolDesync(e)) {
                        Log.w(TAG, "Session desync (peer closed or corrupt stream): " + e.getMessage());
                        postStatus("Connection reset — waiting for sender…");
                    } else {
                        Log.e(TAG, "Session error", e);
                        postStatus(e.getMessage() != null ? e.getMessage() : "Session error");
                    }
                }
            } catch (Exception e) {
                if (running.get()) {
                    Log.e(TAG, "Session error", e);
                    postStatus(e.getMessage() != null ? e.getMessage() : "Session error");
                }
            } finally {
                if (session != null) {
                    session.releaseConnection();
                    session = null;
                }
                if (listenTransport != null) {
                    listenTransport.close();
                    listenTransport = null;
                }
                inboundSessionGate.reset();
                loggedFirstVideoFrame = false;
                networkFeedback = null;
                listener.onDisconnected();
            }
            if (running.get()) {
                postStatus("Waiting for sender…");
            }
        }
    }

    private void readStream() throws IOException {
        while (running.get()) {
            if (networkFeedback != null) {
                networkFeedback.tick();
            }
            CastProtocol.Message msg;
            try {
                msg = session.receive(500);
            } catch (java.io.EOFException e) {
                Log.i(TAG, "Sender closed connection");
                return;
            }
            if (msg == null) {
                continue;
            }
            if (networkFeedback != null && networkFeedback.handleMessage(msg)) {
                listener.onNetworkAdviceApplied();
                if (msg.type == CastProtocol.MSG_NETWORK_STATS) {
                    try {
                        listener.onPeerNetworkStats(CastProtocol.parseNetworkStats(msg.payload));
                    } catch (IOException ignored) {
                    }
                }
                continue;
            }
            switch (msg.type) {
                case CastProtocol.MSG_STREAM_CONFIG:
                    CastProtocol.StreamConfig vcfg = CastProtocol.parseStreamConfig(msg.payload);
                    listener.onStreamConfig(vcfg.width, vcfg.height, vcfg.csd0, vcfg.csd1);
                    break;
                case CastProtocol.MSG_VIDEO_FRAME:
                    CastProtocol.VideoFrame vf = CastProtocol.parseVideoFrame(msg.payload);
                    recordNetworkBytes(vf.data != null ? vf.data.length : 0);
                    if (!loggedFirstVideoFrame) {
                        loggedFirstVideoFrame = true;
                        Log.i(TAG, "First video over network "
                                + (vf.keyFrame ? "KEY" : "P") + " len="
                                + (vf.data != null ? vf.data.length : 0));
                    }
                    listener.onVideoFrame(vf.ptsUs, vf.keyFrame, vf.data);
                    break;
                case CastProtocol.MSG_AUDIO_CONFIG:
                    CastProtocol.AudioConfig acfg = CastProtocol.parseAudioConfig(msg.payload);
                    listener.onAudioConfig(acfg.sampleRate, acfg.channels, acfg.csd0);
                    break;
                case CastProtocol.MSG_AUDIO_FRAME:
                    CastProtocol.AudioFrame af = CastProtocol.parseAudioFrame(msg.payload);
                    recordNetworkBytes(af.data != null ? af.data.length : 0);
                    listener.onAudioFrame(af.ptsUs, af.data);
                    break;
                case CastProtocol.MSG_GOODBYE:
                    postStatus("Sender stopped casting");
                    listener.onCastEnded();
                    return;
                default:
                    break;
            }
        }
    }

    private void recordNetworkBytes(int bytes) {
        if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
            ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).recordBytes(bytes);
        }
    }

    private static boolean isProtocolDesync(IOException e) {
        String msg = e.getMessage();
        if (msg == null) {
            return false;
        }
        return msg.contains("Invalid payload length")
                || msg.contains("Invalid blob length")
                || msg.contains("Connection reset")
                || msg.contains("Broken pipe");
    }

    private void applyNegotiatedProtection(CastSettings remoteSettings) {
        if (listenTransport == null) {
            return;
        }
        UdpCastTransport udp = null;
        if (listenTransport instanceof UdpCastTransport) {
            udp = (UdpCastTransport) listenTransport;
        } else if (listenTransport instanceof QuicCronetCastTransport) {
            udp = ((QuicCronetCastTransport) listenTransport).getUdpTransport();
        }
        if (udp != null) {
            StreamProtectionNegotiation negotiated = CastTransportFactory.applyNegotiatedUdpProtection(
                    udp, localSettings, remoteSettings, true);
            String summary = ProtectionNegotiationPrompts.summarize(appContext, negotiated);
            Log.i(TAG, summary);
            postStatus(summary);
            if (negotiated.prompt != null) {
                String hint = ProtectionNegotiationPrompts.format(appContext, negotiated.prompt, true);
                Log.i(TAG, hint);
                postStatus(hint);
            }
        }
    }

    private void postStatus(String status) {
        listener.onStatus(status);
    }
}
