package com.foxx.androidcast.sender;

/*********************************************************************
 * ScreenCastService.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 ScreenCastService
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (18 commits, 1131 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	239fafac379dab7a0d380509d4d3a46e1742de7f807dd40a3b231ccb5c48d860
**********************************************************************/
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.hardware.display.DisplayManager;
import android.hardware.display.VirtualDisplay;
import android.view.Display;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
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.DisplayMetrics;
import android.util.Log;
import android.view.Surface;
import android.view.WindowManager;

import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.ICastSenderService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
import com.foxx.androidcast.R;
import com.foxx.androidcast.network.CastProtocol;
import com.foxx.androidcast.network.CastSession;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.network.control.NetworkFeedbackManager;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane;
import com.foxx.androidcast.network.transport.TransportLossStats;
import com.foxx.androidcast.network.transport.TransportLossStatsBridge;
import com.foxx.androidcast.network.selftest.NetworkSelfTestBridge;
import com.foxx.androidcast.media.AudioNegotiator;
import com.foxx.androidcast.media.codec.AudioCodecFactory;
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.VideoCodecFactory;
import com.foxx.androidcast.media.codec.VideoEncoderSink;
import com.foxx.androidcast.media.codec.AudioEncoderSink;
import com.foxx.androidcast.stats.SessionStatsContext;
import com.foxx.androidcast.stats.SessionStatsRecorder;
import com.foxx.androidcast.stats.SessionStatsStore;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

/** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */
public class ScreenCastService extends Service implements
        com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost {
    private static final String TAG = "ScreenCastService";
    private static final int CONNECT_RETRIES = 20;
    private static final long CONNECT_RETRY_MS = 1000;
    private static final long CAPTURE_SIZE_UPDATE_DEBOUNCE_MS = 2_000L;

    public static final String EXTRA_RESULT_CODE = "result_code";
    public static final String EXTRA_RESULT_DATA = "result_data";
    public static final String EXTRA_HOST = "host";
    public static final String EXTRA_PORT = "port";
    public static final String EXTRA_PIN = "pin";
    public static final String EXTRA_DEVICE_NAME = "device_name";
    public static final String EXTRA_SENDER_NAME = "sender_name";
    public static final String EXTRA_TARGETS = "cast_targets";
    public static final String EXTRA_CALIBRATION = "calibration_mode";
    public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND";

    private final RemoteCallbackList<ICastStatusCallback> callbacks = new RemoteCallbackList<>();
    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    private HandlerThread projectionThread;
    private Handler projectionHandler;
    private final AtomicReference<IOException> captureSetupError = new AtomicReference<>();
    private final AtomicBoolean stopping = new AtomicBoolean(false);
    private final AtomicBoolean casting = new AtomicBoolean(false);
    private final AtomicBoolean resourcesReleased = new AtomicBoolean(false);

    private MediaProjection projection;
    private VirtualDisplay virtualDisplay;
    private VideoEncoderSink videoEncoder;
    private EncoderKeepalive encoderKeepalive;
    private CastTuningEngine.EffectiveParams effectiveParams;
    private long lastSignificantFrameMs;
    private final SenderStreamMetrics senderMetrics = new SenderStreamMetrics();
    private final SenderAudioMetrics senderAudioMetrics = new SenderAudioMetrics();
    private AudioEncoderSink audioEncoder;
    private AudioCapture audioCapture;
    private long audioFramesSent;
    private volatile String audioCaptureMode = "playback";
    private CastSession session;
    private CastSendPump sendPump;
    private CastFanoutPump fanoutPump;
    private MultiCastCoordinator multiCoordinator;
    private NetworkFeedbackManager networkFeedback;
    private PowerManager.WakeLock wakeLock;
    private Thread ioThread;
    private String status = "";

    private CastSettings activeSettings;
    private int captureWidth;
    private int captureHeight;
    private int captureDensity;
    private DisplayManager displayManager;
    private DisplayManager.DisplayListener displayListener;
    private Runnable pendingCaptureSizeUpdate;
    private CastResolution.Size pendingCaptureSize;
    private final AtomicBoolean captureReconfigureInFlight = new AtomicBoolean(false);
    private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver;
    private volatile boolean calibrationMode;
    private volatile boolean cameraMode;
    private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture;
    private SessionStatsRecorder sessionStatsRecorder;

    private final ICastSenderService.Stub binder = new ICastSenderService.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 boolean isCasting() {
            return casting.get();
        }

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

    @Override
    public void onCreate() {
        super.onCreate();
        NetworkSelfTestBridge.startCapture(this);
        if (AppPreferences.isGrabSessionStats(this)) {
            SessionStatsStore.pruneOnAppStart(this);
        }
        CastNotifications.createChannels(this);
        com.foxx.androidcast.network.CastTransportFactory.init(this);
        com.foxx.androidcast.diagnostics.CpuLoadSampler.warmUp();
        projectionThread = new HandlerThread("CastProjection");
        projectionThread.start();
        projectionHandler = new Handler(projectionThread.getLooper());
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null && ACTION_STOP.equals(intent.getAction())) {
            stopSelfSafely();
            return START_NOT_STICKY;
        }
        if (casting.get()) {
            return START_STICKY;
        }
        CastSettings startSettings = IntentExtras.getCastSettings(intent);
        CastActiveState.setSenderCasting(true);
        startSenderForeground(getString(R.string.connecting),
                startSettings != null && startSettings.isAudioEnabled());
        ioThread = new Thread(() -> runCast(intent), "ScreenCastIO");
        ioThread.start();
        return START_STICKY;
    }

    private void runCast(Intent intent) {
        resourcesReleased.set(false);
        stopping.set(false);
        if (intent == null) {
            failAndStop(getString(R.string.error_no_intent));
            return;
        }
        int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0);
        Intent data = IntentExtras.getParcelableIntent(intent, EXTRA_RESULT_DATA);
        String host = intent.getStringExtra(EXTRA_HOST);
        int port = intent.getIntExtra(EXTRA_PORT, com.foxx.androidcast.CastConfig.CAST_PORT);
        String pin = intent.getStringExtra(EXTRA_PIN);
        if (pin == null) {
            pin = "";
        }
        String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME);
        String senderName = intent.getStringExtra(EXTRA_SENDER_NAME);
        CastSettings settings = IntentExtras.getCastSettings(intent);

        boolean calibration = settings != null
                && settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
        boolean camera = settings != null && settings.isCameraCapture();
        if (!calibration && !camera && data == null) {
            failAndStop(getString(R.string.error_projection_data));
            return;
        }
        if (host == null || settings == null) {
            failAndStop(getString(R.string.error_missing_params));
            return;
        }
        if (senderName == null || senderName.isEmpty()) {
            senderName = AppPreferences.getUsername(this);
        }
        cameraMode = settings.isCameraCapture();
        calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
        if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
            failAndStop(getString(R.string.capture_camera_requires_api29));
            return;
        }
        int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
        if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) {
            port = castPort;
        }

        acquireWakeLock();
        try {
            ArrayList<CastReceiverTarget> targets = IntentExtras.getReceiverTargets(intent, EXTRA_TARGETS);
            if (targets == null || targets.isEmpty()) {
                targets = new ArrayList<>();
                targets.add(new CastReceiverTarget(
                        deviceName != null ? deviceName : host, host, port, settings.getTransport(), 0, 0, 0));
            }
            multiCoordinator = new MultiCastCoordinator(this);
            multiCoordinator.setKeyframeRequester(() -> runOnProjectionThread(() -> {
                if (videoEncoder != null) {
                    videoEncoder.requestKeyframe();
                }
            }));
            updateStatus(getString(R.string.connecting_to, host));
            if (AppPreferences.isGrabSessionStats(this)) {
                sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
                SessionStatsContext.bind(sessionStatsRecorder);
            }
            multiCoordinator.connectAndHandshake(targets, settings, senderName, pin);
            List<MultiCastCoordinator.ActivePeer> peers = multiCoordinator.getPeers();
            if (peers.isEmpty()) {
                throw new IOException("No receivers connected");
            }
            MultiCastCoordinator.ActivePeer primary = peers.get(0);
            session = primary.session;
            settings = primary.settings;
            settings.setNegotiatedVideoMime(primary.negotiatedMime);
            AudioNegotiator.resolveForSender(settings, primary.target.audioCodecs);
            final CastSettings captureSettings = settings;
            updateStatus(getString(R.string.authenticating));
            networkFeedback = new NetworkFeedbackManager(session, true);
            PassThroughNetworkControlPlane plane =
                    (PassThroughNetworkControlPlane) networkFeedback.getPlane();
            plane.setStatsEnricher(this::enrichSenderStats);
            if (AppPreferences.isDevDiagPingPongEnabled(this)) {
                networkFeedback.configureDiagPing(true,
                        AppPreferences.getDevDiagPingResponseMode(this),
                        snapshot -> senderMetrics.onDiagPingSample(snapshot));
            }
            if (peers.size() > 1) {
                fanoutPump = new CastFanoutPump();
                fanoutPump.start(multiCoordinator.getSessions(), () -> stopping.set(true), plane);
            } else {
                sendPump = new CastSendPump();
                sendPump.start(session, () -> stopping.set(true), plane);
            }
            com.foxx.androidcast.dev.StreamDumpWriter.startIfEnabled(this);
            casting.set(true);
            CastActiveState.setSenderCasting(true);
            CastTrayNotifier.refresh(this);
            activeSettings = settings;
            if (AppPreferences.isGrabSessionStats(this)) {
                if (sessionStatsRecorder == null) {
                    sessionStatsRecorder = new SessionStatsRecorder("send", settings.getTransport());
                    SessionStatsContext.bind(sessionStatsRecorder);
                }
                sessionStatsRecorder.mergeSettings(settings);
                sessionStatsRecorder.setClientCount(peers.size());
                sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(),
                        PassthroughCodecPolicy.displayAudioPreference(
                                AudioNegotiator.effective(settings)));
            }
            final int projResultCode = resultCode;
            final Intent projData = data != null ? new Intent(data) : null;
            if (calibration) {
                postCaptureSetup(() -> startCalibrationCaptureInternal(captureSettings));
            } else if (camera) {
                postCaptureSetup(() -> startCameraCaptureInternal(captureSettings));
            } else {
                postCaptureSetup(() -> startCaptureInternal(projResultCode, projData, captureSettings));
            }
            scheduleTuningTick();
            while (!stopping.get()) {
                IOException setupErr = captureSetupError.get();
                if (setupErr != null) {
                    throw setupErr;
                }
                try {
                    if (networkFeedback != null) {
                        networkFeedback.tick();
                        CastProtocol.Message msg = multiCoordinator != null
                                ? multiCoordinator.pollReceive(100)
                                : session.receive(100);
                        if (msg != null) {
                            if (networkFeedback.handleMessage(msg)) {
                                networkFeedback.applyAdvice(networkFeedback.getPlane().getOutboundAdvice());
                            } else if (msg.type == CastProtocol.MSG_AUDIO_CONSENT) {
                                handleAudioConsent(CastProtocol.parseAudioConsent(msg.payload));
                            }
                        }
                    }
                } catch (java.net.SocketException e) {
                    if (!stopping.get()) {
                        Log.i(TAG, "Connection lost: " + e.getMessage());
                        stopping.set(true);
                    }
                    break;
                } catch (IOException e) {
                    if (!stopping.get()) {
                        Log.i(TAG, "Connection lost: " + e.getMessage());
                        stopping.set(true);
                    }
                    break;
                }
            }
        } catch (Exception e) {
            if (!stopping.get()) {
                Log.e(TAG, "Cast failed", e);
                failAndStop(e.getMessage() != null ? e.getMessage() : e.toString());
            }
        } finally {
            releaseAll();
            stopForeground(STOP_FOREGROUND_REMOVE);
            stopSelf();
        }
    }

    private void connectWithRetry(String host, int port, CastSettings settings) throws IOException {
        IOException last = null;
        for (int i = 0; i < CONNECT_RETRIES; i++) {
            try {
                if (session != null) {
                    session.close();
                }
                session = new CastSession(CastTransportFactory.create(settings));
                session.connect(host, port);
                return;
            } catch (IOException e) {
                last = e;
                Log.w(TAG, "Connect attempt " + (i + 1) + " failed", e);
                if (session != null) {
                    session.close();
                    session = null;
                }
                try {
                    Thread.sleep(CONNECT_RETRY_MS);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new IOException("Interrupted", ie);
                }
            }
        }
        String detail = last != null && last.getMessage() != null ? last.getMessage() : "";
        if (detail.contains("ECONNREFUSED")) {
            throw new IOException(getString(R.string.error_receiver_refused, host, port), last);
        }
        throw new IOException(getString(R.string.error_connect_failed, host, port), last);
    }

    private void postCaptureSetup(CaptureSetupTask task) {
        captureSetupError.set(null);
        Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
        handler.post(() -> {
            try {
                task.run();
            } catch (IOException e) {
                Log.e(TAG, "Capture setup failed", e);
                captureSetupError.set(e);
                stopping.set(true);
            } catch (Exception e) {
                Log.e(TAG, "Capture setup failed", e);
                captureSetupError.set(new IOException(
                        e.getMessage() != null ? e.getMessage() : e.toString(), e));
                stopping.set(true);
            }
        });
    }

    @FunctionalInterface
    private interface CaptureSetupTask {
        void run() throws IOException;
    }

    private void startCalibrationCaptureInternal(CastSettings settings) throws IOException {
        CastResolution.Size size = CastResolution.calibrationTestSize(this);
        setupVideoPipeline(size, settings, true);
        startAudioPipeline(settings, true);
        updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight));
    }

    private void startCameraCaptureInternal(CastSettings settings) throws IOException {
        DisplayMetrics metrics = new DisplayMetrics();
        WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
        wm.getDefaultDisplay().getRealMetrics(metrics);
        CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
        setupVideoPipeline(size, settings, false);
        Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null;
        if (surface == null) {
            throw new IOException("Encoder surface unavailable for camera");
        }
        cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this);
        try {
            cameraCapture.start(surface, captureWidth, captureHeight, activeSettings != null
                    && activeSettings.isFrontCamera());
        } catch (android.hardware.camera2.CameraAccessException e) {
            throw new IOException("Camera open failed: " + e.getMessage(), e);
        }
        startAudioPipeline(settings, true);
        updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight));
    }

    private void startCaptureInternal(int resultCode, Intent data, CastSettings settings) throws IOException {
        SenderPreviewService.stop(this);
        MediaProjectionManager mgr =
                (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE);
        projection = mgr.getMediaProjection(resultCode, data);
        if (projection == null) {
            throw new IOException("MediaProjection unavailable");
        }

        projection.registerCallback(new MediaProjection.Callback() {
            @Override
            public void onStop() {
                Log.w(TAG, "MediaProjection stopped");
                stopping.set(true);
            }
        }, projectionHandler != null ? projectionHandler : mainHandler);

        DisplayMetrics metrics = new DisplayMetrics();
        WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
        wm.getDefaultDisplay().getRealMetrics(metrics);
        CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats());
        setupVideoPipeline(size, settings, false);
        registerDisplayListener();

        startAudioPipeline(settings, false);

        String mode = session.getTransport().getModeLabel();
        updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight));
        if (videoEncoder != null) {
            videoEncoder.requestKeyframe();
        }
    }

    private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
            throws IOException {
        Log.d(TAG, "Video capture target displayId=" + resolveCaptureDisplayId()
                + " policy=" + AppPreferences.getExternalDisplayCapturePolicy(this).name());
        if (settings.isPassthrough()) {
            throw new IOException("Passthrough video is not implemented yet (debug only)");
        }
        if (virtualDisplay != null) {
            virtualDisplay.release();
            virtualDisplay = null;
        }
        if (encoderKeepalive != null) {
            encoderKeepalive.stop();
            encoderKeepalive = null;
        }
        if (videoEncoder != null) {
            videoEncoder.stop();
            videoEncoder = null;
        }
        if (fanoutPump != null) {
            fanoutPump.flushVideoQueue();
        } else if (sendPump != null) {
            sendPump.flushVideoQueue();
        }

        captureWidth = size.width;
        captureHeight = size.height;
        captureDensity = size.densityDpi;

        NetworkStatsSnapshot peer = null;
        if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
            peer = ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats();
        }
        effectiveParams = CastTuningEngine.resolve(settings, peer);
        String mime = settings.getNegotiatedVideoMime();
        if (mime == null || mime.isEmpty()) {
            throw new IOException("No negotiated video codec");
        }

        videoEncoder = VideoCodecFactory.createEncoder(settings.getVideoCodec());
        CodecSessionRegistry.setProtectionSummary(PassthroughCodecPolicy.protectionDescription(
                settings.getStreamProtection(), settings.getTransport(), settings.getAudioCodec()));
        VideoEncoderSink.Callback videoCb = buildVideoEncoderCallback(mime);

        if (calibration) {
            startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true);
            return;
        }

        Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb);
        Handler keepaliveHandler = projectionHandler != null ? projectionHandler : mainHandler;
        encoderKeepalive = new EncoderKeepalive(keepaliveHandler);
        encoderKeepalive.start(videoEncoder);
        if (projection != null) {
            virtualDisplay = projection.createVirtualDisplay(
                    "AndroidCast",
                    captureWidth,
                    captureHeight,
                    captureDensity,
                    DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
                    surface,
                    null,
                    null);
        } else {
            throw new IOException("MediaProjection required for screen capture");
        }
        videoEncoder.requestKeyframe();
        Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight);
    }

    private void registerDisplayListener() {
        if (displayManager != null) {
            return;
        }
        displayManager = (DisplayManager) getSystemService(DISPLAY_SERVICE);
        displayListener = new DisplayManager.DisplayListener() {
            @Override
            public void onDisplayAdded(int displayId) {
                if (displayId != Display.DEFAULT_DISPLAY) {
                    onExternalDisplayTopologyChanged();
                }
            }

            @Override
            public void onDisplayRemoved(int displayId) {
                if (displayId != Display.DEFAULT_DISPLAY) {
                    onExternalDisplayTopologyChanged();
                }
            }

            @Override
            public void onDisplayChanged(int displayId) {
                if (displayId == Display.DEFAULT_DISPLAY) {
                    runOnProjectionThread(ScreenCastService.this::maybeReconfigureForRotation);
                } else {
                    onExternalDisplayTopologyChanged();
                }
            }
        };
        Handler listenerHandler = projectionHandler != null ? projectionHandler : mainHandler;
        displayManager.registerDisplayListener(displayListener, listenerHandler);
    }

    private void unregisterDisplayListener() {
        if (displayManager != null && displayListener != null) {
            displayManager.unregisterDisplayListener(displayListener);
        }
        displayManager = null;
        displayListener = null;
    }

    private void onExternalDisplayTopologyChanged() {
        WiredDisplayMonitor monitor = WiredDisplayMonitor.getInstance(this);
        if (monitor.hasExternalDisplay()) {
            Log.i(TAG, "External display: " + monitor.getSummaryLine());
            if (AppPreferences.isDevWiredDisplayHintsEnabled(this)) {
                updateStatus(getString(R.string.wired_display_detected_hint));
            }
        }
    }

    /**
     * Display id for MediaProjection capture (roadmap D). Non-default capture not implemented yet.
     */
    private int resolveCaptureDisplayId() {
        ExternalDisplayCapturePolicy policy = AppPreferences.getExternalDisplayCapturePolicy(this);
        if (policy == ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY) {
            return Display.DEFAULT_DISPLAY;
        }
        if (WiredDisplayMonitor.getInstance(this).hasExternalDisplay()) {
            Log.i(TAG, "Capture policy " + policy.name() + " — using default display until wired capture ships");
        }
        return Display.DEFAULT_DISPLAY;
    }

    private void maybeReconfigureForRotation() {
        if (calibrationMode || cameraMode || !casting.get() || projection == null
                || activeSettings == null || stopping.get()) {
            return;
        }
        DisplayMetrics metrics = new DisplayMetrics();
        WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
        wm.getDefaultDisplay().getRealMetrics(metrics);
        CastResolution.Size size = CastResolution.compute(metrics, activeSettings, getPeerStats());
        scheduleCaptureSizeUpdate(size, "rotation");
    }

    private boolean captureSizeChangeSignificant(CastResolution.Size size) {
        if (size.width == captureWidth && size.height == captureHeight) {
            return false;
        }
        int dw = Math.abs(size.width - captureWidth);
        int dh = Math.abs(size.height - captureHeight);
        return dw >= Math.max(32, captureWidth / 10) || dh >= Math.max(32, captureHeight / 10);
    }

    private void scheduleCaptureSizeUpdate(CastResolution.Size size, String reason) {
        if (calibrationMode || cameraMode || !casting.get() || projection == null
                || activeSettings == null || stopping.get()) {
            return;
        }
        if (!captureSizeChangeSignificant(size)) {
            return;
        }
        pendingCaptureSize = size;
        Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
        if (pendingCaptureSizeUpdate != null) {
            handler.removeCallbacks(pendingCaptureSizeUpdate);
        }
        pendingCaptureSizeUpdate = () -> applyCaptureSizeUpdate(reason);
        handler.postDelayed(pendingCaptureSizeUpdate, CAPTURE_SIZE_UPDATE_DEBOUNCE_MS);
    }

    private void applyCaptureSizeUpdate(String reason) {
        pendingCaptureSizeUpdate = null;
        if (calibrationMode || cameraMode || !casting.get() || projection == null
                || activeSettings == null || stopping.get() || pendingCaptureSize == null) {
            return;
        }
        CastResolution.Size size = pendingCaptureSize;
        pendingCaptureSize = null;
        if (!captureSizeChangeSignificant(size)) {
            return;
        }
        if (!captureReconfigureInFlight.compareAndSet(false, true)) {
            Log.d(TAG, "Capture size update skipped (" + reason + "): reconfigure in flight");
            scheduleCaptureSizeUpdate(size, reason);
            return;
        }
        try {
            setupVideoPipeline(size, activeSettings, false);
            if ("rotation".equals(reason)) {
                updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight));
            }
            Log.i(TAG, "Capture reconfigured (" + reason + ") " + captureWidth + "x" + captureHeight);
        } catch (IOException e) {
            Log.e(TAG, "Capture reconfigure failed (" + reason + ")", e);
        } finally {
            captureReconfigureInFlight.set(false);
        }
    }

    private void cancelPendingCaptureSizeUpdate() {
        pendingCaptureSize = null;
        if (pendingCaptureSizeUpdate == null) {
            return;
        }
        Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
        handler.removeCallbacks(pendingCaptureSizeUpdate);
        pendingCaptureSizeUpdate = null;
    }

    private NetworkStatsSnapshot getPeerStats() {
        if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) {
            return ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats();
        }
        return null;
    }

    private void enrichSenderStats(NetworkStatsSnapshot s, long nowMs) {
        s.encodeFps = senderMetrics.getLastSecondFps();
        if (fanoutPump != null) {
            s.encodeQueueDepth = fanoutPump.getQueueDepth();
            s.droppedPFrames = fanoutPump.getDroppedPFrames();
            s.droppedAudioFrames = fanoutPump.getDroppedAudioFrames();
        } else if (sendPump != null) {
            s.encodeQueueDepth = sendPump.getQueueDepth();
            s.droppedPFrames = sendPump.getDroppedPFrames();
        }
        TransportLossStats snap = buildSenderLossSnapshot();
        TransportLossStatsBridge.applyTo(s, snap);
        if (sessionStatsRecorder != null) {
            sessionStatsRecorder.sampleNetwork(s, getPeerStats());
        }
        s.audioActive = audioEncoder != null && audioFramesSent > 0;
        s.audioFramesSent = audioFramesSent;
        s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent();
        s.audioCaptureSilentBlocks = senderAudioMetrics.getSilentBlocks();
        s.audioCaptureAudibleBlocks = senderAudioMetrics.getAudibleBlocks();
        s.audioCapturePeakDb = senderAudioMetrics.getLastPeakDb();
        s.audioCaptureRmsDb = senderAudioMetrics.getLastRmsDb();
        if (effectiveParams != null) {
            s.targetBitrateKbps = effectiveParams.videoBitrate / 1000;
        }
        if (activeSettings != null) {
            CastTuningEngine.EffectiveParams eff = CastTuningEngine.resolve(activeSettings, getPeerStats());
            s.suggestedBitrateKbps = eff.videoBitrate / 1000;
        }
        NetworkStatsSnapshot peer = getPeerStats();
        if (peer != null && peer.recvBitrateKbps > 0 && s.sendBitrateKbps > peer.recvBitrateKbps * 1.2f) {
            s.congestionLevel = Math.max(s.congestionLevel, 1);
        }
        int qDepth = fanoutPump != null ? fanoutPump.getQueueDepth()
                : (sendPump != null ? sendPump.getQueueDepth() : 0);
        if (qDepth > 20) {
            s.congestionLevel = Math.max(s.congestionLevel, 2);
        }
    }

    private void scheduleTuningTick() {
        mainHandler.postDelayed(() -> {
            if (!casting.get() || stopping.get() || activeSettings == null) {
                return;
            }
            NetworkStatsSnapshot peer = getPeerStats();
            PassThroughNetworkControlPlane plane = networkFeedback != null
                    && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane
                    ? (PassThroughNetworkControlPlane) networkFeedback.getPlane() : null;
            if (plane != null) {
                NetworkStatsSnapshot local = plane.buildLocalStats(System.currentTimeMillis());
                senderMetrics.onNetworkSample(local.sendBitrateKbps,
                        peer != null ? peer.rttMs : 0);
            }
            CastTuningEngine.EffectiveParams next = CastTuningEngine.resolve(activeSettings, peer);
            long idleMs = lastSignificantFrameMs > 0
                    ? System.currentTimeMillis() - lastSignificantFrameMs : 0;
            if (idleMs > CastConfig.KEYFRAME_KEEPALIVE_MS && videoEncoder != null) {
                runOnProjectionThread(() -> videoEncoder.requestKeyframe());
            }
            if (effectiveParams != null && next.videoBitrate != effectiveParams.videoBitrate) {
                Log.d(TAG, "Tuning bitrate " + (next.videoBitrate / 1000) + " kbps (next pipeline)");
            }
            effectiveParams = next;
            maybeAdaptCaptureSize(peer);
            String mime = activeSettings != null ? activeSettings.getNegotiatedVideoMime() : null;
            CastSendPump pumpForStats = sendPump;
            updateStatus(senderMetrics.format(mime, captureWidth, captureHeight, pumpForStats, senderAudioMetrics,
                    activeSettings != null && activeSettings.isAudioEnabled()));
            scheduleTuningTick();
        }, 2_000);
    }

    private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) {
        if (calibrationMode || cameraMode || activeSettings == null
                || activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) {
            return;
        }
        DisplayMetrics metrics = new DisplayMetrics();
        WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
        wm.getDefaultDisplay().getRealMetrics(metrics);
        CastResolution.Size size = CastResolution.compute(metrics, activeSettings, peer);
        scheduleCaptureSizeUpdate(size, "adaptive");
    }

    private void runOnProjectionThread(Runnable action) {
        Handler handler = projectionHandler != null ? projectionHandler : mainHandler;
        if (Looper.myLooper() == handler.getLooper()) {
            action.run();
        } else {
            handler.post(action);
        }
    }

    private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) {
        if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            Log.i(TAG, "Audio capture disabled for this session");
            return;
        }
        if (projection == null && !calibrationOnlyMic) {
            Log.w(TAG, "Audio skipped — no MediaProjection");
            return;
        }
        try {
            if (audioEncoder != null) {
                audioEncoder.stop();
            }
            if (audioCapture != null) {
                audioCapture.stop();
            }
            CastSettings.AudioCodec effectiveAudio = AudioNegotiator.effective(settings);
            audioEncoder = AudioCodecFactory.createEncoder(settings);
            CodecSessionRegistry.setAudioBackend(
                    PassthroughCodecPolicy.audioBackendFor(effectiveAudio, true));
            audioEncoder.prepare(new AudioEncoderSink.Callback() {
                private boolean configSent;

                @Override
                public void onConfig(int sampleRate, int channels, byte[] csd0) {
                    if (configSent || stopping.get()) {
                        return;
                    }
                    configSent = true;
                    Log.i(TAG, "Sending audio config " + sampleRate + "Hz");
                    sendSafe(CastProtocol.MSG_AUDIO_CONFIG,
                            () -> CastProtocol.audioConfigPayload(sampleRate, channels, csd0));
                }

                @Override
                public void onEncodedFrame(long ptsUs, byte[] frameData) {
                    if (frameData == null || frameData.length == 0) {
                        return;
                    }
                    audioFramesSent++;
                    sendSafe(CastProtocol.MSG_AUDIO_FRAME,
                            () -> CastProtocol.audioFramePayload(ptsUs, frameData));
                }
            });
            audioFramesSent = 0;
            senderAudioMetrics.reset();
            audioCaptureMode = "playback";
            audioCapture = new AudioCapture(this);
            MediaProjection audioProjection = calibrationOnlyMic ? null : projection;
            audioCapture.start(audioProjection, new AudioCapture.Callback() {
                @Override
                public void onPcm(byte[] pcm, int length, long ptsUs,
                        com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) {
                    senderAudioMetrics.onPcm(levels);
                    if (calibrationDriver != null) {
                        calibrationDriver.setVuFromPcm(pcm, length);
                    }
                    if (audioEncoder != null) {
                        audioEncoder.queuePcm(pcm, length, ptsUs);
                    }
                }

                @Override
                public void onCaptureModeChanged(String mode) {
                    audioCaptureMode = mode;
                    Log.i(TAG, "Audio capture mode: " + mode);
                }
            });
            Log.i(TAG, "Audio pipeline started");
        } catch (IOException e) {
            Log.e(TAG, "Audio pipeline failed", e);
        }
    }

    private void handleAudioConsent(boolean accept) {
        if (accept) {
            if (audioEncoder == null && activeSettings != null) {
                startAudioPipeline(activeSettings, activeSettings != null
                        && activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST);
            }
            Log.i(TAG, "Receiver accepted audio");
        } else {
            if (audioCapture != null) {
                audioCapture.stop();
                audioCapture = null;
            }
            if (audioEncoder != null) {
                audioEncoder.stop();
                audioEncoder = null;
            }
            Log.i(TAG, "Receiver declined audio");
        }
    }

    private void enqueueVideoFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
        if (fanoutPump != null) {
            fanoutPump.enqueueVideo(ptsUs, keyFrame, frameData);
        } else if (sendPump != null) {
            sendPump.enqueueVideo(ptsUs, keyFrame, frameData);
        }
    }

    private void sendSafe(byte type, PayloadBuilder builder) {
        if (stopping.get() || (sendPump == null && fanoutPump == null)) {
            return;
        }
        try {
            byte[] payload = builder.build();
            if (fanoutPump != null) {
                fanoutPump.enqueue(type, payload);
            } else {
                sendPump.enqueue(type, payload);
            }
        } catch (IOException e) {
            if (!stopping.get()) {
                Log.w(TAG, "Payload build failed: " + e.getMessage());
                stopping.set(true);
            }
        }
    }

    private interface PayloadBuilder {
        byte[] build() throws IOException;
    }

    private void acquireWakeLock() {
        PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
        wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AndroidCast:Send");
        wakeLock.acquire(4 * 60 * 60 * 1000L);
    }

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

    private void releaseAll() {
        if (!resourcesReleased.compareAndSet(false, true)) {
            return;
        }
        finishSessionStatsAsync();
        stopping.set(true);
        casting.set(false);
        CastActiveState.setSenderCasting(false);
        CastTrayNotifier.refresh(this);
        networkFeedback = null;
        calibrationMode = false;
        cameraMode = false;
        if (audioCapture != null) {
            audioCapture.stop();
            audioCapture = null;
        }
        if (audioEncoder != null) {
            audioEncoder.stop();
            audioEncoder = null;
        }
        Handler ph = projectionHandler;
        if (ph != null) {
            CountDownLatch latch = new CountDownLatch(1);
            ph.post(() -> {
                releaseProjectionCapture();
                latch.countDown();
            });
            try {
                latch.await(3, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        } else {
            releaseProjectionCapture();
        }
        if (projectionThread != null) {
            projectionThread.quitSafely();
            try {
                projectionThread.join(1500);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            projectionThread = null;
            projectionHandler = null;
        }
        if (multiCoordinator != null) {
            multiCoordinator.sendGoodbyeAll();
        } else if (session != null) {
            try {
                session.send(CastProtocol.MSG_GOODBYE, new byte[0]);
            } catch (Exception ignored) {
            }
        }
        if (sendPump != null) {
            sendPump.stop();
            sendPump = null;
        }
        com.foxx.androidcast.dev.StreamDumpWriter.finalizeIfActive(this);
        if (fanoutPump != null) {
            fanoutPump.stop();
            fanoutPump = null;
        }
        if (multiCoordinator != null) {
            multiCoordinator.closeAll();
            multiCoordinator = null;
        } else if (session != null) {
            session.close();
            session = null;
        }
        releaseWakeLock();
    }

    private void failAndStop(String message) {
        updateStatus(message);
        stopping.set(true);
    }

    private void stopSelfSafely() {
        if (!stopping.compareAndSet(false, true)) {
            return;
        }
        Thread stopper = new Thread(() -> {
            releaseAll();
            mainHandler.post(() -> {
                stopForeground(STOP_FOREGROUND_REMOVE);
                stopSelf();
            });
        }, "CastStop");
        stopper.start();
    }

    private void startSenderForeground(String text, boolean withAudio) {
        android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
            if (withAudio) {
                fgsType |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
            }
            startForeground(CastNotifications.ID_SENDER, n, fgsType);
        } else {
            startForeground(CastNotifications.ID_SENDER, n);
        }
    }

    private TransportLossStats buildSenderLossSnapshot() {
        TransportLossStats snap = session != null
                ? session.getTransport().getLossStats() : new TransportLossStats();
        if (sendPump != null) {
            snap.sendDroppedVideoP = sendPump.getDroppedPFrames();
            snap.sendDroppedAudio = sendPump.getDroppedAudioFrames();
            snap.sendQueueDepth = sendPump.getQueueDepth();
        } else if (fanoutPump != null) {
            snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames();
            snap.sendDroppedAudio = fanoutPump.getDroppedAudioFrames();
            snap.sendQueueDepth = fanoutPump.getQueueDepth();
        }
        return snap;
    }

    private void finishSessionStatsAsync() {
        final SessionStatsRecorder recorder = sessionStatsRecorder;
        sessionStatsRecorder = null;
        if (recorder == null) {
            return;
        }
        if (activeSettings != null) {
            recorder.setResolution(captureWidth, captureHeight);
            recorder.setCaptureMode(activeSettings.getCaptureMode() != null
                    ? activeSettings.getCaptureMode().name() : "");
        }
        recorder.mergeLoss(buildSenderLossSnapshot());
        recorder.finish(getApplicationContext());
    }

    private void logEncodedCodecSample(String mime, byte[] frameData) {
        String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData);
        Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime)
                + " — " + probe);
    }

    private void updateStatus(String text) {
        status = text;
        broadcastStatus(text);
        android.app.NotificationManager nm = getSystemService(android.app.NotificationManager.class);
        nm.notify(CastNotifications.ID_SENDER,
                CastNotifications.sender(this, text, ScreenCastService.class));
    }

    private void broadcastStatus(String s) {
        synchronized (callbacks) {
            int n = callbacks.beginBroadcast();
            try {
                for (int i = 0; i < n; i++) {
                    try {
                        callbacks.getBroadcastItem(i).onStatus(s);
                    } catch (RemoteException ignored) {
                    }
                }
            } finally {
                callbacks.finishBroadcast();
            }
        }
    }

    private VideoEncoderSink.Callback buildVideoEncoderCallback(String mime) {
        return new VideoEncoderSink.Callback() {
            @Override
            public void onConfig(int w, int h, byte[] csd0, byte[] csd1) {
                if (stopping.get()) {
                    return;
                }
                try {
                    byte[] payload = CastProtocol.streamConfigPayload(w, h, csd0, csd1);
                    if (fanoutPump != null && multiCoordinator != null) {
                        multiCoordinator.broadcast(CastProtocol.MSG_STREAM_CONFIG, payload);
                    } else {
                        sendSafe(CastProtocol.MSG_STREAM_CONFIG, () -> payload);
                    }
                    Log.i(TAG, "Stream config " + w + "x" + h
                            + " peers=" + (multiCoordinator != null ? multiCoordinator.getPeers().size() : 1));
                    runOnProjectionThread(() -> {
                        if (videoEncoder != null) {
                            videoEncoder.requestKeyframe();
                        }
                    });
                } catch (IOException e) {
                    Log.w(TAG, "Stream config failed: " + e.getMessage());
                }
            }

            @Override
            public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) {
                if ((sendPump == null && fanoutPump == null) || frameData == null) {
                    return;
                }
                boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames
                        && !keyFrame
                        && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES;
                if (tiny) {
                    return;
                }
                lastSignificantFrameMs = System.currentTimeMillis();
                senderMetrics.onEncodedFrame(keyFrame);
                if (keyFrame) {
                    logEncodedCodecSample(mime, frameData);
                }
                enqueueVideoFrame(ptsUs, keyFrame, frameData);
            }
        };
    }

    private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params,
            VideoEncoderSink.Callback videoCb, boolean tryGles) throws IOException {
        if (encoderKeepalive != null) {
            encoderKeepalive.stop();
            encoderKeepalive = null;
        }
        if (calibrationDriver != null) {
            calibrationDriver.release();
            calibrationDriver = null;
        }
        if (videoEncoder != null) {
            videoEncoder.stop();
        }
        CastSettings.VideoCodec vPref = activeSettings != null
                ? activeSettings.getVideoCodec() : CastSettings.VideoCodec.AUTO;
        videoEncoder = VideoCodecFactory.createEncoder(vPref);
        float density = getResources().getDisplayMetrics().density;
        calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this);
        if (tryGles) {
            Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb);
            calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density);
            videoEncoder.requestKeyframe();
            Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime);
        } else {
            videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb);
            calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density);
            videoEncoder.requestKeyframe();
            Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime);
        }
    }

    @Override
    public void onCalibrationNeedBufferEncoder() {
        if (!calibrationMode || stopping.get() || activeSettings == null) {
            return;
        }
        String mime = activeSettings.getNegotiatedVideoMime();
        if (mime == null || mime.isEmpty()) {
            return;
        }
        runOnProjectionThread(() -> {
            try {
                startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false);
            } catch (IOException e) {
                Log.e(TAG, "Calibration buffer fallback failed", e);
                failAndStop("Calibration video failed");
            }
        });
    }

    private void releaseProjectionCapture() {
        cancelPendingCaptureSizeUpdate();
        captureReconfigureInFlight.set(false);
        unregisterDisplayListener();
        if (virtualDisplay != null) {
            virtualDisplay.release();
            virtualDisplay = null;
        }
        if (encoderKeepalive != null) {
            encoderKeepalive.stop();
            encoderKeepalive = null;
        }
        if (cameraCapture != null) {
            cameraCapture.stop();
            cameraCapture = null;
        }
        if (calibrationDriver != null) {
            calibrationDriver.release();
            calibrationDriver = null;
        }
        if (videoEncoder != null) {
            videoEncoder.stop();
            videoEncoder = null;
        }
        if (projection != null) {
            projection.stop();
            projection = null;
        }
    }

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

    @Override
    public void onDestroy() {
        stopping.set(true);
        releaseAll();
        callbacks.kill();
        NetworkSelfTestBridge.stopCapture(this);
        super.onDestroy();
    }
}
