package com.foxx.androidcast.receiver;

/*********************************************************************
 * ReceiverCastService.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 ReceiverCastService
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (18 commits, 1026 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	19d789a6606081796e55d6b81346ec4b3d8b98b288e80ab6e44eae8d98084804
**********************************************************************/
import android.app.NotificationManager;
import android.app.Service;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.PowerManager;
import android.os.RemoteCallbackList;
import android.os.RemoteException;
import android.util.Log;
import android.view.Surface;

import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter;
import com.foxx.androidcast.diagnostics.CastMtuSampler;
import com.foxx.androidcast.diagnostics.CpuLoadSampler;
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.VideoCodecBackend;
import com.foxx.androidcast.media.codec.VideoDecoderFactory;
import com.foxx.androidcast.media.codec.VideoDecoderSink;
import com.foxx.androidcast.discovery.DiscoveryManager;
import com.foxx.androidcast.live.LiveCastControlPlane;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.stats.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge;
import com.foxx.androidcast.util.CastBackgroundExecutor;

import java.util.Arrays;

/**
 * Foreground receiver: discovery, listen, decode.
 * Playback UI opens only after {@link CastProtocol#MSG_STREAM_CONFIG} (sender is actually streaming).
 */
public class ReceiverCastService extends Service {
    private static final String TAG = "ReceiverCastService";
    public static final String ACTION_START = "com.foxx.androidcast.RECEIVER_START";
    public static final String ACTION_STOP = "com.foxx.androidcast.STOP_RECV";
    public static final String ACTION_PLAYBACK_UI = "com.foxx.androidcast.RECV_PLAYBACK_UI";
    public static final String EXTRA_PLAYBACK_UI_VISIBLE = "playback_ui_visible";
    public static final String EXTRA_PIN = "pin";
    public static final String EXTRA_ALLOW_AUDIO = "allow_audio";

    private enum Phase {
        IDLE, LISTENING, CONNECTED, STREAMING
    }

    private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    private HandlerThread videoDecodeThread;
    private Handler videoDecodeHandler;
    private final Object audioDecoderLock = new Object();

    private DiscoveryManager discovery;
    private ReceiverSession session;
    private VideoDecoderSink videoDecoder;
    private AudioDecoder audioDecoder;
    private Surface attachedSurface;
    private String status = "";
    private CastSettings settings = new CastSettings();
    private Phase phase = Phase.IDLE;

    private int pendingWidth;
    private int pendingHeight;
    /** Stream config size used for letterboxing (not decoder padding). */
    private int displayWidth;
    private int displayHeight;
    private byte[] pendingCsd0;
    private byte[] pendingCsd1;
    private boolean hasPendingConfig;
    private volatile boolean reconfiguring;
    private volatile boolean awaitingKeyframe = true;
    private boolean playbackLaunched;
    /** True while {@link ReceiverPlaybackActivity} is in the foreground (started/stopped). */
    private volatile boolean playbackUiVisible;
    private int decoderWidth;
    private int decoderHeight;
    private boolean decoderActive;

    /** Set from receiver UI before each listen session. */
    private boolean allowIncomingAudio = true;
    /** null until first audio config this session; then mirrors user preference. */
    private Boolean audioAccepted;
    private int pendingAudioRate;
    private int pendingAudioChannels;
    private byte[] pendingAudioCsd;
    private PowerManager.WakeLock wakeLock;
    private String activePin = com.foxx.androidcast.CastConfig.DEFAULT_PIN;
    private final StreamMetrics streamMetrics = new StreamMetrics();
    private String negotiatedVideoMime;
    private CastSettings remoteCastSettings;
    private NetworkStatsSnapshot lastLocalStats;
    private boolean streamIdle;
    private volatile boolean castEnded;
    private long lastVideoFrameMs;
    private long lastStreamConfigMs;
    private final TransportLossStats transportLoss = new TransportLossStats();
    private SessionStatsRecorder sessionStatsRecorder;

    private final Runnable idleWatchdog = new Runnable() {
        @Override
        public void run() {
            checkStreamIdle();
            mainHandler.postDelayed(this, 500);
        }
    };

    private final ICastReceiverService.Stub binder = new ICastReceiverService.Stub() {
        @Override
        public void registerCallback(ICastStatusCallback callback) {
            if (callback != null) {
                callbacks.register(callback);
                broadcastStatus(status);
            }
        }

        @Override
        public void unregisterCallback(ICastStatusCallback callback) {
            if (callback != null) {
                callbacks.unregister(callback);
            }
        }

        @Override
        public String getStatus() {
            return status;
        }

        @Override
        public void attachVideoSurface(Surface surface) {
            mainHandler.post(() -> attachSurface(surface));
        }

        @Override
        public void detachVideoSurface() {
            mainHandler.post(ReceiverCastService.this::detachSurfaceOnly);
        }

        @Override
        public void stopReceiving() {
            stopSelfSafely();
        }

        @Override
        public String getDiagnosticsText() {
            return buildDiagnosticsText();
        }
    };

    @Override
    public void onCreate() {
        super.onCreate();
        NetworkSelfTestBridge.startCapture(this);
        CastNotifications.createChannels(this);
        if (AppPreferences.isGrabSessionStats(this)) {
            SessionStatsStore.pruneOnAppStart(this);
        }
        videoDecodeThread = new HandlerThread("VideoDecode");
        videoDecodeThread.start();
        videoDecodeHandler = new Handler(videoDecodeThread.getLooper());
        CpuLoadSampler.warmUp();
        mainHandler.post(idleWatchdog);
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null && ACTION_STOP.equals(intent.getAction())) {
            stopSelfSafely();
            return START_NOT_STICKY;
        }
        if (intent != null && ACTION_PLAYBACK_UI.equals(intent.getAction())) {
            playbackUiVisible = intent.getBooleanExtra(EXTRA_PLAYBACK_UI_VISIBLE, false);
            if (!playbackUiVisible) {
                playbackLaunched = false;
            }
            return START_STICKY;
        }
        if (intent != null && ACTION_START.equals(intent.getAction())) {
            String pin = intent.getStringExtra(EXTRA_PIN);
            if (pin == null) {
                pin = AppPreferences.getPin(this);
            }
            CastSettings fromIntent = IntentExtras.getCastSettings(intent);
            if (fromIntent != null) {
                settings = fromIntent;
            } else {
                settings = AppPreferences.loadReceiverDefaults(this);
            }
            allowIncomingAudio = intent.getBooleanExtra(EXTRA_ALLOW_AUDIO,
                    AppPreferences.isPlayIncomingAudio(this));
            activePin = pin;
            restartListening(pin);
        } else if (intent == null && phase == Phase.IDLE) {
            allowIncomingAudio = AppPreferences.isPlayIncomingAudio(this);
            settings = AppPreferences.loadReceiverDefaults(this);
            activePin = AppPreferences.getPin(this);
            restartListening(activePin);
        }
        return START_STICKY;
    }

    private final Object sessionLock = new Object();

    private void restartListening(String pin) {
        synchronized (sessionLock) {
            restartListeningLocked(pin);
        }
    }

    private void restartListeningLocked(String pin) {
        stopSessionOnly();
        castEnded = false;
        resetStreamState();
        pendingWidth = 0;
        pendingHeight = 0;
        playbackLaunched = false;
        phase = Phase.LISTENING;
        acquireWakeLock();
        startForeground(CastNotifications.ID_RECEIVER,
                CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
        CastActiveState.setReceiverListening(true);
        CastTrayNotifier.refresh(this);

        if (videoDecoder != null) {
            videoDecoder.release();
        }
        if (audioDecoder != null) {
            synchronized (audioDecoderLock) {
                audioDecoder.release();
            }
        }
        videoDecoder = VideoDecoderFactory.createDecoder();
        synchronized (audioDecoderLock) {
            audioDecoder = new AudioDecoder(this);
            audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
                    pcmBytes, levels.peak, levels.rms, levels.silent));
        }

        session = new ReceiverSession(this, pin, settings, new ReceiverSession.Listener() {
            @Override
            public void onListening() {
                mainHandler.post(ReceiverCastService.this::startDiscoveryAnnouncing);
            }

            @Override
            public void onCastSessionStarting() {
                if (AppPreferences.isGrabSessionStats(ReceiverCastService.this)) {
                    if (sessionStatsRecorder == null) {
                        sessionStatsRecorder = new SessionStatsRecorder("recv",
                                settings.getTransport());
                        SessionStatsContext.bind(sessionStatsRecorder);
                    }
                }
            }

            @Override
            public void onStatus(String s) {
                updateStatus(s);
            }

            @Override
            public void onAuthenticated(String senderName, CastSettings remoteSettings, String videoMime) {
                synchronized (ReceiverCastService.this) {
                    if (remoteSettings != null && remoteSettings.isAudioRequested() && allowIncomingAudio) {
                        audioAccepted = true;
                    }
                }
                mainHandler.post(() -> onSenderConnected(senderName, remoteSettings, videoMime));
            }

            @Override
            public void onStreamConfig(int width, int height, byte[] csd0, byte[] csd1) {
                mainHandler.post(() -> onStreamConfigReceived(width, height, csd0, csd1));
            }

            @Override
            public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
                final long nowMs = System.currentTimeMillis();
                mainHandler.post(() -> lastVideoFrameMs = nowMs);
                if (awaitingKeyframe && !keyFrame) {
                    synchronized (transportLoss) {
                        transportLoss.recvVideoSkippedAwaitingKey++;
                    }
                    return;
                }
                if (keyFrame) {
                    awaitingKeyframe = false;
                    reconfiguring = false;
                }
                final int frameBytes = data != null ? data.length : 0;
                final byte[] frameData = data;
                mainHandler.post(() -> {
                    if (streamIdle) {
                        onStreamResumedAfterIdle();
                    }
                    streamMetrics.onVideoPacket(keyFrame, frameBytes);
                    synchronized (transportLoss) {
                        transportLoss.recvVideoPackets++;
                    }
                });
                if (videoDecodeHandler != null) {
                    videoDecodeHandler.post(() -> {
                        if (videoDecoder != null) {
                            videoDecoder.queueFrame(ptsUs, keyFrame, frameData);
                        }
                    });
                }
            }

            @Override
            public void onAudioConfig(int sampleRate, int channels, byte[] csd0) {
                // Network I/O must stay on the session reader thread (StrictMode).
                final boolean accept;
                synchronized (ReceiverCastService.this) {
                    if (audioAccepted == null) {
                        accept = allowIncomingAudio;
                        audioAccepted = accept;
                        if (session != null) {
                            session.sendAudioConsent(accept);
                        }
                    } else {
                        accept = Boolean.TRUE.equals(audioAccepted);
                    }
                }
                final boolean accepted = accept;
                if (accepted) {
                    synchronized (audioDecoderLock) {
                        if (audioDecoder != null) {
                            try {
                                audioDecoder.configure(sampleRate, channels, csd0);
                            } catch (Exception e) {
                                Log.e(TAG, "Audio configure on config msg failed", e);
                            }
                        }
                    }
                }
                mainHandler.post(() -> onAudioConfigReceived(sampleRate, channels, csd0, accepted));
            }

            @Override
            public void onAudioFrame(long ptsUs, byte[] data) {
                if (castEnded || awaitingKeyframe || streamIdle) {
                    return;
                }
                final int frameBytes = data != null ? data.length : 0;
                final boolean accept;
                synchronized (ReceiverCastService.this) {
                    accept = Boolean.TRUE.equals(audioAccepted);
                }
                if (accept) {
                    synchronized (audioDecoderLock) {
                        if (audioDecoder != null) {
                            audioDecoder.queueFrame(ptsUs, data);
                        }
                    }
                }
                mainHandler.post(() -> {
                    streamMetrics.onAudioPacket(frameBytes);
                    synchronized (transportLoss) {
                        transportLoss.recvAudioPackets++;
                    }
                });
            }

            @Override
            public void onCastEnded() {
                mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate);
            }

            @Override
            public void onDisconnected() {
                mainHandler.post(ReceiverCastService.this::onSenderDisconnected);
            }

            @Override
            public void onNetworkAdviceApplied() {
            }

            @Override
            public void onPeerNetworkStats(NetworkStatsSnapshot stats) {
                mainHandler.post(() -> streamMetrics.onPeerStats(stats));
            }

            @Override
            public void onDiagPingSample(com.foxx.androidcast.network.diag.DiagPingMetrics.Snapshot snapshot) {
                mainHandler.post(() -> streamMetrics.onDiagPingSample(snapshot));
            }
        }, this::enrichReceiverStats);
        session.start();
        updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
        openPlaybackAwaiting(R.string.playback_listening);
        Log.i(TAG, "Listening PIN=" + pin + " audio=" + allowIncomingAudio);
    }

    private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
        phase = Phase.CONNECTED;
        castEnded = false;
        if (AppPreferences.isGrabSessionStats(this)) {
            if (sessionStatsRecorder == null) {
                sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
                SessionStatsContext.bind(sessionStatsRecorder);
                sessionStatsRecorder.setClientCount(1);
            }
            sessionStatsRecorder.mergeSettings(remoteSettings);
            sessionStatsRecorder.setCodecs(videoMime, "AAC");
        }
        negotiatedVideoMime = videoMime;
        String audioCodec = remoteSettings != null && remoteSettings.isAudioRequested() ? "AAC" : "none";
        LiveCastControlPlane.onCastStart(this, "android_receiver", videoMime, audioCodec,
                settings.getBitrateMode().name().toLowerCase());
        remoteCastSettings = remoteSettings;
        streamIdle = false;
        streamMetrics.reset();
        lastVideoFrameMs = 0;
        String codecLabel = videoMime != null
                ? com.foxx.androidcast.media.CodecNegotiator.displayName(videoMime) : "?";
        updateStatus(getString(R.string.casting_from_codec, senderName, codecLabel));
        broadcastAuthenticated(senderName);
        openPlaybackAwaiting(R.string.playback_awaiting_stream);
        if (remoteSettings != null && remoteSettings.isAudioRequested() && allowIncomingAudio) {
            audioAccepted = true;
            sendAudioConsentAsync(true);
            Log.i(TAG, "Audio accepted for sender (pre-config)");
        } else if (remoteSettings != null && remoteSettings.isAudioRequested()) {
            updateStatus(getString(R.string.audio_skipped));
        }
        Log.i(TAG, "Sender connected: " + senderName + " codec=" + videoMime);
    }

    private void sendAudioConsentAsync(final boolean accept) {
        CastBackgroundExecutor.execute(() -> {
            if (session != null) {
                session.sendAudioConsent(accept);
            }
        });
    }

    private void stopAudioPlaybackImmediate() {
        castEnded = true;
        synchronized (audioDecoderLock) {
            if (audioDecoder != null) {
                audioDecoder.flushAndStop();
                audioDecoder = new AudioDecoder(this);
                audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
                        pcmBytes, levels.peak, levels.rms, levels.silent));
            }
        }
        Log.i(TAG, "Audio playback stopped");
    }

    private void onSenderDisconnected() {
        stopAudioPlaybackImmediate();
        phase = Phase.LISTENING;
        audioAccepted = null;
        remoteCastSettings = null;
        streamIdle = false;
        lastVideoFrameMs = 0;
        negotiatedVideoMime = null;
        pendingWidth = 0;
        pendingHeight = 0;
        displayWidth = 0;
        displayHeight = 0;
        hasPendingConfig = false;
        resetDecoderState();
        enterAwaitingMode(R.string.playback_listening, true);
        updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase()));
        Log.i(TAG, "Sender disconnected — listening again");
    }

    private void checkStreamIdle() {
        if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) {
            return;
        }
        long nowMs = System.currentTimeMillis();
        if (reconfiguring || awaitingKeyframe) {
            if (lastStreamConfigMs > 0 && nowMs - lastStreamConfigMs < 20_000L) {
                return;
            }
        }
        long idleMs = nowMs - lastVideoFrameMs;
        if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) {
            Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting");
            if (sessionStatsRecorder != null) {
                sessionStatsRecorder.noteIdleTimeout();
            }
            enterAwaitingMode(R.string.playback_awaiting_stream, false);
        }
    }

    /** Called when video resumes after idle on the same TCP session. */
    private void onStreamResumedAfterIdle() {
        if (!streamIdle) {
            return;
        }
        streamIdle = false;
        enterAwaitingKeyframe();
        openPlaybackAwaiting(R.string.playback_awaiting_stream);
        scheduleDecoderConfigureRetries();
    }

    /** Stops decode, shows robot overlay; keeps TCP session if still connected. */
    private void enterAwaitingMode(int messageResId, boolean fullReset) {
        streamIdle = true;
        enterAwaitingKeyframe();
        if (fullReset) {
            hasPendingConfig = false;
            resetDecoderState();
            if (videoDecoder != null) {
                videoDecoder.release();
                videoDecoder = VideoDecoderFactory.createDecoder();
            }
            streamMetrics.reset();
        }
        broadcastStreamIdle();
        openPlaybackAwaiting(messageResId);
    }

    private void resetStreamState() {
        hasPendingConfig = false;
        enterAwaitingKeyframe();
        pendingWidth = 0;
        pendingHeight = 0;
        displayWidth = 0;
        displayHeight = 0;
        pendingCsd0 = null;
        pendingCsd1 = null;
        resetDecoderState();
    }

    private void resetDecoderState() {
        decoderActive = false;
        decoderWidth = 0;
        decoderHeight = 0;
    }

    private void enterAwaitingKeyframe() {
        awaitingKeyframe = true;
        flushAudioInput();
    }

    private void flushAudioInput() {
        synchronized (audioDecoderLock) {
            if (audioDecoder != null) {
                audioDecoder.clearInputQueue();
            }
        }
    }

    private void onAudioConfigReceived(int sampleRate, int channels, byte[] csd0, boolean accepted) {
        pendingAudioRate = sampleRate;
        pendingAudioChannels = channels;
        pendingAudioCsd = csd0;
        if (accepted) {
            updateStatus(getString(R.string.audio_playing));
        } else {
            synchronized (audioDecoderLock) {
                if (audioDecoder != null) {
                    audioDecoder.release();
                    audioDecoder = new AudioDecoder(this);
                    audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered(
                            pcmBytes, levels.peak, levels.rms, levels.silent));
                }
            }
            updateStatus(getString(R.string.audio_skipped));
        }
        Log.i(TAG, "Audio consent: " + accepted);
    }

    private void onStreamConfigReceived(int width, int height, byte[] csd0, byte[] csd1) {
        com.foxx.androidcast.media.StreamDimensionResolver.Size size =
                com.foxx.androidcast.media.StreamDimensionResolver.resolve(width, height, csd0);
        if (size.width <= 0 || size.height <= 0) {
            Log.w(TAG, "Ignoring invalid stream config " + width + "x" + height);
            return;
        }
        boolean same = hasPendingConfig
                && pendingWidth == size.width
                && pendingHeight == size.height
                && Arrays.equals(pendingCsd0, csd0)
                && Arrays.equals(pendingCsd1, csd1);
        if (same && decoderActive) {
            Log.d(TAG, "Stream config unchanged " + size.width + "x" + size.height);
            return;
        }
        boolean firstConfig = !hasPendingConfig;
        boolean wasIdle = streamIdle;
        boolean decoderWasActive = decoderActive;
        phase = Phase.STREAMING;
        streamIdle = false;
        if (firstConfig) {
            streamMetrics.reset();
        }
        reconfiguring = true;
        enterAwaitingKeyframe();
        lastVideoFrameMs = System.currentTimeMillis();
        lastStreamConfigMs = lastVideoFrameMs;
        if (firstConfig || wasIdle || !decoderWasActive) {
            openPlaybackAwaiting(R.string.playback_awaiting_stream);
        }
        pendingWidth = size.width;
        pendingHeight = size.height;
        displayWidth = size.width;
        displayHeight = size.height;
        pendingCsd0 = csd0;
        pendingCsd1 = csd1;
        hasPendingConfig = true;
        decoderActive = false;
        notifyPlaybackDimensions(displayWidth, displayHeight);
        scheduleDecoderConfigureRetries();
        if (sessionStatsRecorder != null) {
            sessionStatsRecorder.setResolution(pendingWidth, pendingHeight);
        }
        Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight);
    }

    private void scheduleDecoderConfigureRetries() {
        tryConfigureDecoder();
        mainHandler.postDelayed(this::tryConfigureDecoder, 250);
        mainHandler.postDelayed(this::tryConfigureDecoder, 750);
        mainHandler.postDelayed(this::tryConfigureDecoder, 2000);
    }

    /** Opens or refreshes fullscreen UI with robot overlay (listening / waiting for stream). */
    private void openPlaybackAwaiting(int messageResId) {
        if (!playbackUiVisible) {
            return;
        }
        playbackLaunched = true;
        Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
        intent.putExtra(ReceiverPlaybackActivity.EXTRA_SHOW_AWAITING, true);
        intent.putExtra(ReceiverPlaybackActivity.EXTRA_AWAITING_MESSAGE, messageResId);
        startActivity(intent);
    }

    private void notifyPlaybackDimensions(int width, int height) {
        broadcastStreamStarted(width, height);
        if (!playbackUiVisible) {
            return;
        }
        Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
        intent.putExtra(ReceiverPlaybackActivity.EXTRA_WIDTH, width);
        intent.putExtra(ReceiverPlaybackActivity.EXTRA_HEIGHT, height);
        startActivity(intent);
    }

    private void closePlaybackUi() {
        if (!playbackLaunched) {
            return;
        }
        Intent intent = new Intent(this, ReceiverPlaybackActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
        intent.putExtra(ReceiverPlaybackActivity.EXTRA_FINISH, true);
        startActivity(intent);
        playbackLaunched = false;
    }

    private void startDiscoveryAnnouncing() {
        if (discovery != null) {
            return;
        }
        discovery = new DiscoveryManager(this);
        com.foxx.androidcast.network.CastTransportFactory.init(this);
        int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
        discovery.startAnnouncing(this, AppPreferences.getUsername(this), announcePort,
                settings.getTransport(), true, activePin);
        Log.i(TAG, "Discovery announcing on port " + announcePort);
    }

    private void stopSessionOnly() {
        if (discovery != null) {
            discovery.stop();
            discovery = null;
        }
        if (session != null) {
            session.stop();
            session = null;
        }
        try {
            Thread.sleep(80);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private void acquireWakeLock() {
        if (wakeLock != null && wakeLock.isHeld()) {
            return;
        }
        PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
        wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AndroidCast:Receive");
        wakeLock.acquire(4 * 60 * 60 * 1000L);
    }

    private void releaseWakeLock() {
        if (wakeLock != null && wakeLock.isHeld()) {
            wakeLock.release();
        }
        wakeLock = null;
    }

    private void tryConfigureDecoder() {
        if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) {
            Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig
                    + " surface=" + (attachedSurface != null && attachedSurface.isValid()));
            return;
        }
        if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) {
            Log.d(TAG, "Decoder already active at " + decoderWidth + "x" + decoderHeight);
            return;
        }
        try {
            if (videoDecoder != null) {
                videoDecoder.release();
            }
            videoDecoder = VideoDecoderFactory.createDecoder();
            String mime = negotiatedVideoMime != null ? negotiatedVideoMime
                    : android.media.MediaFormat.MIMETYPE_VIDEO_AVC;
            CodecSessionRegistry.setVideoBackend(VideoCodecBackend.MEDIA_CODEC_HW, mime);
            if (remoteCastSettings != null) {
                CastSettings.AudioCodec effectiveAudio =
                        com.foxx.androidcast.media.AudioNegotiator.effective(remoteCastSettings);
                CodecSessionRegistry.setVideoPreference(remoteCastSettings.getVideoCodec());
                CodecSessionRegistry.setAudioPreference(effectiveAudio);
                CodecSessionRegistry.setAudioBackend(
                        PassthroughCodecPolicy.audioBackendFor(
                                effectiveAudio,
                                remoteCastSettings.isAudioRequested()));
                CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription(
                        remoteCastSettings.getStreamProtection(),
                        remoteCastSettings.getTransport(),
                        effectiveAudio));
            }
            videoDecoder.configure(mime, pendingWidth, pendingHeight, pendingCsd0, pendingCsd1, attachedSurface,
                    new VideoDecoderSink.Listener() {
                        @Override
                        public void onFirstFrameRendered() {
                            ReceiverCastService.this.onFirstFrameRendered();
                        }

                        @Override
                        public void onFrameRendered() {
                            ReceiverCastService.this.onFrameRendered();
                        }

                        @Override
                        public void onOutputSizeChanged(int width, int height) {
                            ReceiverCastService.this.onDecoderOutputSize(width, height);
                        }
                    });
            decoderActive = true;
            reconfiguring = false;
            decoderWidth = pendingWidth;
            decoderHeight = pendingHeight;
            com.foxx.androidcast.receiver.av.ReceiverAvRuntime.video()
                    .onConfigured(pendingWidth, pendingHeight);
            updateStatus(getString(R.string.playback_awaiting_stream));
            broadcastStreamStarted(pendingWidth, pendingHeight);
            Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);
        } catch (Exception e) {
            decoderActive = false;
            synchronized (transportLoss) {
                transportLoss.recvVideoDecodeErrors++;
            }
            Log.e(TAG, "Video configure failed", e);
            updateStatus("Video: " + e.getMessage());
        }
    }

    private void onFirstFrameRendered() {
        mainHandler.post(() -> {
            streamMetrics.onRenderedFrame();
            Log.i(TAG, "First frame on screen");
            streamIdle = false;
            if (pendingWidth > 0 && pendingHeight > 0) {
                updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight));
            }
            broadcastVideoRendering();
        });
    }

    private void onFrameRendered() {
        mainHandler.post(() -> streamMetrics.onRenderedFrame());
    }

    private void onDecoderOutputSize(int width, int height) {
        mainHandler.post(() -> {
            if (width <= 0 || height <= 0) {
                return;
            }
            decoderWidth = width;
            decoderHeight = height;
            Log.i(TAG, "Decoder output size " + width + "x" + height
                    + " (display " + displayWidth + "x" + displayHeight + ")");
        });
    }

    private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) {
        s.encodeFps = streamMetrics.getDecodeFps();
        TransportLossStats snap = buildReceiverLossSnapshot();
        TransportLossStatsBridge.applyTo(s, snap);
        if (sessionStatsRecorder != null) {
            sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer());
        }
        NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
        CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings;
        com.foxx.androidcast.CastTuningEngine.EffectiveParams eff =
                com.foxx.androidcast.CastTuningEngine.resolve(tune, peer);
        s.suggestedBitrateKbps = eff.videoBitrate / 1000;
        s.targetBitrateKbps = eff.videoBitrate / 1000;
        if (s.recvBitrateKbps > 0 && eff.videoBitrate / 1000 > s.recvBitrateKbps * 1.15f) {
            s.congestionLevel = Math.max(s.congestionLevel, 1);
        }
        float inFps = streamMetrics.getInFps();
        float renderFps = streamMetrics.getRenderFps();
        if (inFps > 12f && renderFps > 0 && renderFps < inFps * 0.7f) {
            s.congestionLevel = Math.max(s.congestionLevel, 2);
        }
        lastLocalStats = s.copy();
    }

    private String buildDiagnosticsText() {
        NetworkStatsSnapshot peer = streamMetrics.getLastPeer();
        return CastDiagnosticsFormatter.formatHtml(
                streamMetrics,
                settings.getTransport(),
                negotiatedVideoMime,
                pendingWidth,
                pendingHeight,
                decoderWidth,
                decoderHeight,
                streamIdle,
                settings,
                remoteCastSettings,
                peer,
                lastLocalStats,
                PlaybackViewState.getZoomPercent(),
                CastMtuSampler.sample(this));
    }

    private TransportLossStats buildReceiverLossSnapshot() {
        TransportLossStats snap = session != null
                ? session.getTransportLossStats() : new TransportLossStats();
        synchronized (transportLoss) {
            snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey;
            snap.recvVideoPackets = transportLoss.recvVideoPackets;
            snap.recvAudioPackets = transportLoss.recvAudioPackets;
            snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors;
        }
        if (audioDecoder != null) {
            AudioDecoder.Stats ast = audioDecoder.getStats();
            snap.recvAudioDecodeErrors = ast.decodeErrors;
            snap.recvAudioQueueDrops = ast.queueDrops;
        }
        return snap;
    }

    private void finishSessionStatsAsync() {
        String video = negotiatedVideoMime != null ? negotiatedVideoMime : "";
        String audio = Boolean.TRUE.equals(audioAccepted) ? "AAC" : "none";
        String bwMode = settings != null ? settings.getBitrateMode().name().toLowerCase() : "";
        LiveCastControlPlane.onCastStop(video, audio, bwMode);
        final SessionStatsRecorder recorder = sessionStatsRecorder;
        sessionStatsRecorder = null;
        if (recorder == null) {
            return;
        }
        recorder.setResolution(pendingWidth, pendingHeight);
        recorder.mergeLoss(buildReceiverLossSnapshot());
        recorder.finish(getApplicationContext());
    }

    private void attachSurface(Surface surface) {
        if (surface == null || !surface.isValid()) {
            Log.w(TAG, "attachSurface: invalid surface");
            return;
        }
        if (surface.equals(attachedSurface) && decoderActive) {
            Log.d(TAG, "Surface unchanged, decoder active");
            return;
        }
        attachedSurface = surface;
        Log.i(TAG, "Surface attached, configuring decoder");
        scheduleDecoderConfigureRetries();
    }

    private void detachSurfaceOnly() {
        attachedSurface = null;
        resetDecoderState();
        if (videoDecoder != null) {
            videoDecoder.release();
            videoDecoder = VideoDecoderFactory.createDecoder();
        }
        if (hasPendingConfig && phase == Phase.STREAMING) {
            enterAwaitingKeyframe();
            scheduleDecoderConfigureRetries();
        }
    }

    private void updateStatus(String s) {
        status = s;
        broadcastStatus(s);
        NotificationManager nm = getSystemService(NotificationManager.class);
        nm.notify(CastNotifications.ID_RECEIVER,
                CastNotifications.receiver(this, s, ReceiverCastService.class));
    }

    private void broadcastStatus(String s) {
        broadcastToCallbacks(cb -> {
            try {
                cb.onStatus(s);
            } catch (RemoteException ignored) {
            }
        });
    }

    private void broadcastAuthenticated(String name) {
        broadcastToCallbacks(cb -> {
            try {
                cb.onAuthenticated(name);
            } catch (RemoteException ignored) {
            }
        });
    }

    private void broadcastStreamStarted(int w, int h) {
        broadcastToCallbacks(cb -> {
            try {
                cb.onStreamStarted(w, h);
            } catch (RemoteException ignored) {
            }
        });
    }

    private void broadcastVideoRendering() {
        broadcastToCallbacks(cb -> {
            try {
                cb.onVideoRendering();
            } catch (RemoteException ignored) {
            }
        });
    }

    private void broadcastStreamIdle() {
        broadcastToCallbacks(cb -> {
            try {
                cb.onStreamIdle();
            } catch (RemoteException ignored) {
            }
        });
    }

    private void broadcastDisconnected() {
        broadcastToCallbacks(cb -> {
            try {
                cb.onDisconnected();
            } catch (RemoteException ignored) {
            }
        });
    }

    private interface CallbackAction {
        void dispatch(ICastStatusCallback callback) throws RemoteException;
    }

    private void broadcastToCallbacks(CallbackAction action) {
        synchronized (callbacks) {
            int n = callbacks.beginBroadcast();
            try {
                for (int i = 0; i < n; i++) {
                    try {
                        action.dispatch(callbacks.getBroadcastItem(i));
                    } catch (RemoteException ignored) {
                    }
                }
            } finally {
                callbacks.finishBroadcast();
            }
        }
    }

    private void releaseAll() {
        finishSessionStatsAsync();
        CastActiveState.setReceiverListening(false);
        CastTrayNotifier.refresh(this);
        phase = Phase.IDLE;
        resetStreamState();
        playbackLaunched = false;
        audioAccepted = null;
        stopSessionOnly();
        closePlaybackUi();
        releaseWakeLock();
        if (videoDecoder != null) {
            videoDecoder.release();
            videoDecoder = null;
        }
        synchronized (audioDecoderLock) {
            if (audioDecoder != null) {
                audioDecoder.release();
                audioDecoder = null;
            }
        }
        attachedSurface = null;
        CodecSessionRegistry.clear();
    }

    private void stopSelfSafely() {
        releaseAll();
        callbacks.kill();
        stopForeground(STOP_FOREGROUND_REMOVE);
        stopSelf();
    }

    @Override
    public IBinder onBind(Intent intent) {
        return binder;
    }

    @Override
    public void onDestroy() {
        mainHandler.removeCallbacks(idleWatchdog);
        releaseAll();
        if (videoDecodeThread != null) {
            videoDecodeThread.quitSafely();
            videoDecodeThread = null;
            videoDecodeHandler = null;
        }
        callbacks.kill();
        NetworkSelfTestBridge.stopCapture(this);
        super.onDestroy();
    }
}
