package com.nadi.player.nativeplayer;

import android.app.Activity;
import android.app.PictureInPictureParams;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.format.DateFormat;
import android.util.Rational;
import android.view.GestureDetector;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;

import androidx.annotation.OptIn;
import androidx.media3.common.C;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.VideoSize;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaController;
import androidx.media3.session.SessionToken;
import androidx.media3.ui.PlayerView;
import androidx.core.content.ContextCompat;

import com.google.common.util.concurrent.ListenableFuture;
import com.nadi.player.R;
import com.nadi.player.nativeplayer.channels.Channel;
import com.nadi.player.nativeplayer.channels.ChannelOverlayController;
import com.nadi.player.nativeplayer.channels.ChannelStore;
import com.nadi.player.nativeplayer.gesture.PlayerGestureHandler;
import com.nadi.player.nativeplayer.lastplayed.LastPlayedStore;
import com.nadi.player.nativeplayer.tracks.TrackSelectorHelper;

import java.util.Date;

/**
 * Full-screen player UI. Owns NO ExoPlayer — that lives in
 * {@link PlaybackService} so playback continues when this activity goes
 * to PiP, the screen is locked, or the user switches to WhatsApp.
 *
 * Iteration 2 highlights:
 *   • Channel overlay is now LIVE-TV ONLY. Hidden for VOD (movies / series).
 *   • Real channel data comes from {@link ChannelStore}, populated by JS
 *     via the Capacitor bridge before this Activity launches.
 *   • VOD playback shows a bottom-of-screen pill with Audio and Subtitle
 *     pickers (backed by Media3 track selection).
 *   • Vertical drag gestures: left half = brightness, right half = volume,
 *     each with a small indicator pill.
 *   • Fast zapping: channel switches go through PlaybackService with
 *     EXTRA_ZAP_ONLY so the ExoPlayer is reused (no rebuild stutter).
 */
public class PlayerActivity extends Activity {

    public static final String EXTRA_URL = "url";
    public static final String EXTRA_USER_AGENT = "user_agent";
    public static final String EXTRA_TITLE = "title";
    public static final String EXTRA_IS_LIVE = "is_live";
    public static final String EXTRA_HEADER_KEYS = "header_keys";
    public static final String EXTRA_HEADER_VALS = "header_vals";

    private PlayerView playerView;
    private TextView errorView;
    private MediaController controller;
    private ListenableFuture<MediaController> controllerFuture;
    private Rational lastAspect = new Rational(16, 9);
    private boolean stoppedExplicitly = false;
    private boolean isLive = true;
    /** BUG FIX (2026-01) — PiP "X" dismissal audio leak.
     *
     *  When the user closes the PiP floating window via the [X] button,
     *  Android 11+ fires:
     *
     *      onPictureInPictureModeChanged(false, …)   ← leaving PiP
     *      onStop()                                  ← activity stopped
     *      onDestroy()                               ← NOT GUARANTEED to fire
     *
     *  onDestroy is skipped on many devices, so the foreground service +
     *  ExoPlayer keep running and audio plays in the background.
     *
     *  We track "PiP exit pending" instead of "currently in PiP":
     *    • Set to TRUE in onPictureInPictureModeChanged(false, …)
     *    • Cleared in onResume() — the user restored the window to
     *      fullscreen so we should NOT tear down.
     *    • Checked in onStop() — if still true, the user dismissed
     *      via [X] and we must tear down synchronously.
     */
    private boolean pipExitTeardownPending = false;

    // --- Premium overlay widgets ---
    private View topOverlay;
    private View bottomOverlayVod;
    private TextView overlayTitle;
    private TextView overlaySubtitle;
    private TextView overlayClock;
    private TextView overlayLiveBadge;
    private TextView overlayQuality;
    private ImageButton btnBack;
    private ImageButton btnPip;
    private ImageButton btnChannels;
    private ImageButton btnAudio;
    private ImageButton btnSubtitle;
    private ChannelOverlayController channelOverlay;
    private GestureDetector panelSwipeDetector;
    private PlayerGestureHandler gestureHandler;
    private final Handler ui = new Handler(Looper.getMainLooper());
    private final Runnable clockTick = new Runnable() {
        @Override public void run() {
            updateClock();
            ui.postDelayed(this, 30_000L);
        }
    };

    @OptIn(markerClass = UnstableApi.class)
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        requestWindowFeature(android.view.Window.FEATURE_NO_TITLE);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
        setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);

        setContentView(R.layout.activity_player);
        playerView   = findViewById(R.id.player_view);
        errorView    = findViewById(R.id.error_view);
        topOverlay   = findViewById(R.id.top_overlay);
        bottomOverlayVod = findViewById(R.id.bottom_overlay_vod);
        overlayTitle = findViewById(R.id.overlay_title);
        overlaySubtitle  = findViewById(R.id.overlay_subtitle);
        overlayClock     = findViewById(R.id.overlay_clock);
        overlayLiveBadge = findViewById(R.id.overlay_live_badge);
        overlayQuality   = findViewById(R.id.overlay_quality);
        btnBack       = findViewById(R.id.btn_back);
        btnPip        = findViewById(R.id.btn_pip);
        btnChannels   = findViewById(R.id.btn_channels);
        btnAudio      = findViewById(R.id.btn_audio);
        btnSubtitle   = findViewById(R.id.btn_subtitle);

        String url   = getIntent().getStringExtra(EXTRA_URL);
        String title = getIntent().getStringExtra(EXTRA_TITLE);
        isLive = getIntent().getBooleanExtra(EXTRA_IS_LIVE, true);
        String userAgent = getIntent().getStringExtra(EXTRA_USER_AGENT);

        if (url == null || url.isEmpty()) {
            showError("No URL provided");
            return;
        }

        // Persist the initial channel as "last played" so the JS layer's
        // resume-handler can offer one-tap restart on next cold start.
        // Live only — VOD progress isn't tracked yet.
        if (isLive) {
            LastPlayedStore.save(this, title, url, true, userAgent,
                    ChannelStore.get().currentChannelNumber(),
                    /* profileId = */ null);
        }

        // ── Top overlay text ───────────────────────────────────────
        overlayTitle.setText(title == null || title.isEmpty() ? "NadiPlayer" : title);
        overlaySubtitle.setText("");
        overlayLiveBadge.setVisibility(isLive ? View.VISIBLE : View.GONE);
        overlayQuality.setText("");
        overlayQuality.setVisibility(View.GONE);
        updateClock();
        ui.postDelayed(clockTick, 30_000L);

        configureChannelOverlay();
        configureVodControls();
        configureGestures();

        // ── Top-overlay button handlers ────────────────────────────
        btnBack.setOnClickListener(v -> onBackPressed());
        btnPip.setOnClickListener(v -> enterPipManually());
        // btn_channels click handler is bound in configureChannelOverlay
        // (it's null-checked depending on whether the panel exists at all).

        // ── Forward the play request to the service ────────────────
        Intent svc = new Intent(this, PlaybackService.class);
        svc.putExtra(PlaybackService.EXTRA_URL, url);
        svc.putExtra(PlaybackService.EXTRA_USER_AGENT,
                getIntent().getStringExtra(EXTRA_USER_AGENT));
        svc.putExtra(PlaybackService.EXTRA_TITLE, title);
        svc.putExtra(PlaybackService.EXTRA_IS_LIVE, isLive);
        svc.putExtra(PlaybackService.EXTRA_HEADER_KEYS,
                getIntent().getStringArrayExtra(EXTRA_HEADER_KEYS));
        svc.putExtra(PlaybackService.EXTRA_HEADER_VALS,
                getIntent().getStringArrayExtra(EXTRA_HEADER_VALS));
        ContextCompat.startForegroundService(this, svc);

        // Android 13+ requires runtime POST_NOTIFICATIONS for the
        // foreground "Now Playing" notification. Asking is best-effort:
        // playback works either way, the notification just shows silently
        // (or not at all) when the user denies.
        if (Build.VERSION.SDK_INT >= 33) {
            if (checkSelfPermission("android.permission.POST_NOTIFICATIONS")
                    != PackageManager.PERMISSION_GRANTED) {
                requestPermissions(
                        new String[]{"android.permission.POST_NOTIFICATIONS"},
                        /* requestCode = */ 4711);
            }
        }

        // ── Bind a MediaController to the running session ──────────
        SessionToken token = new SessionToken(this,
                new ComponentName(this, PlaybackService.class));
        controllerFuture = new MediaController.Builder(this, token).buildAsync();
        controllerFuture.addListener(() -> {
            try {
                controller = controllerFuture.get();
                playerView.setPlayer(controller);
                playerView.setKeepScreenOn(true);
                playerView.setUseController(true);
                // Show Media3's own built-in subtitle button too — gives
                // the user a redundant way to disable subs from the
                // standard controller while we keep our pill for picking
                // among multiple tracks/languages.
                playerView.setShowSubtitleButton(!isLive);

                // Sync our custom overlays (top bar + VOD bottom pill) with
                // Media3's built-in controller visibility so tapping the
                // video hides EVERYTHING together and shows it together.
                // This fixes the "VOD pill stays visible after tap" bug
                // and keeps the player UI feeling like one cohesive layer.
                // Use 200ms cross-fade to match Media3's own fade duration.
                playerView.setControllerVisibilityListener(
                        (PlayerView.ControllerVisibilityListener) visibility -> {
                            boolean show = visibility == View.VISIBLE;
                            fadeOverlay(topOverlay, show);
                            if (!isLive) fadeOverlay(bottomOverlayVod, show);
                        });
                controller.addListener(new Player.Listener() {
                    @Override
                    public void onPlayerError(PlaybackException error) {
                        showError("Playback error: " + error.getErrorCodeName() + "\n" +
                                (error.getMessage() == null ? "" : error.getMessage()));
                    }
                    @Override
                    public void onVideoSizeChanged(VideoSize size) {
                        if (size.width > 0 && size.height > 0) {
                            lastAspect = new Rational(size.width, size.height);
                            updateQualityBadge(size.height);
                        }
                    }
                    @Override
                    public void onPlaybackStateChanged(int state) {
                        // BUG FIX (2026-01) — auto-dismiss stale error
                        // overlay the moment the player recovers. When a
                        // new channel is zapped after the previous one
                        // failed, ExoPlayer transitions BUFFERING → READY
                        // as soon as the new MediaItem starts loading
                        // successfully. We use that signal to hide any
                        // leftover error text so the user never sees the
                        // "stuck error on top of working video" UI.
                        if (state == Player.STATE_BUFFERING
                                || state == Player.STATE_READY) {
                            clearError();
                        }
                    }
                    @Override
                    public void onMediaItemTransition(
                            @androidx.annotation.Nullable
                            androidx.media3.common.MediaItem mediaItem,
                            int reason) {
                        // Belt + braces: any switch to a new MediaItem
                        // (zap, lock-screen next, widget) implicitly
                        // resets the visible error state.
                        clearError();
                    }
                });
            } catch (Exception e) {
                showError("Service connect failed: " + e.getMessage());
            }
        }, ContextCompat.getMainExecutor(this));
    }

    /* ====================================================================
     *  Right-side channel overlay (Live TV only)
     * ==================================================================== */

    private void configureChannelOverlay() {
        View panelView = findViewById(R.id.channel_panel);
        // VOD or no playlist loaded → no channel overlay at all.
        boolean haveChannels = isLive && !ChannelStore.get().isEmpty();
        if (!haveChannels) {
            if (panelView != null) panelView.setVisibility(View.GONE);
            if (btnChannels != null) btnChannels.setVisibility(View.GONE);
            channelOverlay = null;
            return;
        }
        if (btnChannels != null) btnChannels.setVisibility(View.VISIBLE);

        channelOverlay = new ChannelOverlayController(panelView,
                new ChannelOverlayController.Listener() {
                    @Override public void onChannelSelected(Channel channel) {
                        playChannel(channel);
                    }
                    @Override public void onPanelVisibilityChanged(boolean visible) {
                        if (!visible) playerView.requestFocus();
                    }
                });
        if (btnChannels != null) {
            btnChannels.setOnClickListener(v -> channelOverlay.toggle());
        }
    }

    /** Swap to a new IPTV channel without rebuilding the player.
     *  Updates the top overlay immediately for instant feedback and tells
     *  the foreground service to set a new MediaItem on the live ExoPlayer.
     *
     *  BUG FIX (2026-01) — sticky error overlay on channel zap.
     *  Previously, if the previous channel hit a PlaybackException
     *  (manifest parse error, source error, etc.) the {@code errorView}
     *  text overlay was rendered on top of the player surface and
     *  STAYED rendered when the user picked a new channel from the
     *  sidebar — even when the new stream successfully started playing
     *  behind the overlay. The only escape was to back out to the Live
     *  TV menu and re-enter. Now we explicitly clear the overlay at
     *  the start of every channel change and prepare the new media
     *  item with a fresh state. */
    private void playChannel(Channel channel) {
        if (channel == null) return;
        // ── (1) Hide any stale error overlay from a previous failure
        //         so the new channel can render cleanly. ─────────────
        clearError();
        overlayTitle.setText(channel.name);
        overlaySubtitle.setText(channel.epgNow == null ? "" : channel.epgNow);
        overlayLiveBadge.setVisibility(View.VISIBLE);
        showOverlayBriefly();

        // ── (2) Force the player into a clean state BEFORE we send
        //         the zap intent. This drops the failed MediaItem queue
        //         from the previous channel so ExoPlayer doesn't try to
        //         resume the broken stream when the new one is queued.
        //         setMediaItem(empty) → stop() → clearMediaItems() is
        //         the canonical reset sequence per Media3 docs. ──────
        if (controller != null) {
            try {
                controller.stop();
                controller.clearMediaItems();
            } catch (Exception ignored) {
                /* controller might be in a transient state — fine */
            }
        }

        Intent zap = new Intent(this, PlaybackService.class);
        zap.putExtra(PlaybackService.EXTRA_URL, channel.streamUrl);
        zap.putExtra(PlaybackService.EXTRA_TITLE, channel.name);
        zap.putExtra(PlaybackService.EXTRA_IS_LIVE, true);
        zap.putExtra(PlaybackService.EXTRA_ZAP_ONLY, true);
        ContextCompat.startForegroundService(this, zap);

        // Remember this as the new "last played" for the JS resume-handler.
        LastPlayedStore.save(this, channel, true,
                getIntent().getStringExtra(EXTRA_USER_AGENT),
                /* profileId = */ null);
    }

    /** BUG FIX (2026-01): explicit error-overlay hide. Called at the
     *  start of every channel zap so a stale "Playback error: …"
     *  message from a previous broken stream cannot persist over a
     *  successfully-playing new one. */
    private void clearError() {
        if (errorView != null) {
            errorView.setVisibility(View.GONE);
            errorView.setText("");
        }
    }

    /* ====================================================================
     *  VOD bottom controls — audio + subtitle pickers
     * ==================================================================== */

    @OptIn(markerClass = UnstableApi.class)
    private void configureVodControls() {
        if (isLive) {
            if (bottomOverlayVod != null) bottomOverlayVod.setVisibility(View.GONE);
            return;
        }
        if (bottomOverlayVod == null) return;
        bottomOverlayVod.setVisibility(View.VISIBLE);

        if (btnAudio != null) {
            btnAudio.setOnClickListener(v -> {
                if (controller == null) return;
                TrackSelectorHelper.prompt(this, "Audio track",
                        TrackSelectorHelper.listTracks(controller, C.TRACK_TYPE_AUDIO),
                        opt -> TrackSelectorHelper.apply(controller,
                                C.TRACK_TYPE_AUDIO, opt));
            });
        }
        if (btnSubtitle != null) {
            btnSubtitle.setOnClickListener(v -> {
                if (controller == null) return;
                TrackSelectorHelper.prompt(this, "Subtitles",
                        TrackSelectorHelper.listTracks(controller, C.TRACK_TYPE_TEXT),
                        opt -> TrackSelectorHelper.apply(controller,
                                C.TRACK_TYPE_TEXT, opt));
            });
        }
    }

    /* ====================================================================
     *  Touch gestures — brightness (left half) + volume (right half),
     *  plus the existing horizontal swipe to open / close the panel.
     * ==================================================================== */

    private void configureGestures() {
        View indicator = findViewById(R.id.gesture_indicator);
        ImageView icon = findViewById(R.id.gesture_icon);
        ProgressBar bar = findViewById(R.id.gesture_bar);
        PlayerGestureHandler.IndicatorView indView = null;
        if (indicator != null && icon != null && bar != null) {
            indView = new PlayerGestureHandler.IconBarIndicator(
                    indicator, icon, bar,
                    R.drawable.ic_brightness, R.drawable.ic_volume);
        }
        gestureHandler = new PlayerGestureHandler(this, indView);

        panelSwipeDetector = new GestureDetector(this,
                new GestureDetector.SimpleOnGestureListener() {
                    @Override public boolean onFling(MotionEvent e1, MotionEvent e2,
                                                     float vX, float vY) {
                        if (e1 == null || channelOverlay == null) return false;
                        float dx = e2.getX() - e1.getX();
                        float dy = e2.getY() - e1.getY();
                        if (Math.abs(dx) > Math.abs(dy) * 1.5f
                                && Math.abs(dx) > 80f
                                && Math.abs(vX) > 250f) {
                            if (dx < 0 && !channelOverlay.isVisible()) {
                                channelOverlay.show();
                                return true;
                            } else if (dx > 0 && channelOverlay.isVisible()) {
                                channelOverlay.hide();
                                return true;
                            }
                        }
                        return false;
                    }
                });

        playerView.setOnTouchListener((v, ev) -> {
            // Vertical drags → brightness / volume (consumed when committed).
            boolean consumed = gestureHandler.onTouch(v, ev);
            // Horizontal flings → channel panel open/close (only if the panel
            // exists; for VOD it's null and the detector is skipped).
            if (channelOverlay != null) {
                panelSwipeDetector.onTouchEvent(ev);
            }
            // Let PlayerView still see the events so single-tap continues to
            // toggle its built-in controller. Only swallow when our vertical
            // drag actually consumed the gesture.
            return consumed;
        });
    }

    /* ====================================================================
     *  Overlay show / hide helpers
     * ==================================================================== */

    /** Show the top overlay for a few seconds — used after a successful zap
     *  so the user sees the new channel title without keeping the UI
     *  permanently on. We delegate to Media3's own controller show/hide
     *  via {@code playerView.showController()} so our overlay and Media3's
     *  controls stay in lockstep (the visibility listener wired in onCreate
     *  bridges them). Media3's auto-hide timer then fades everything
     *  together. */
    private void showOverlayBriefly() {
        if (playerView != null) {
            playerView.showController();
        }
    }

    /** Legacy hide path retained for safety on lifecycle teardown. */
    private final Runnable hideOverlay = () -> {
        if (topOverlay == null) return;
        topOverlay.animate().alpha(0f).setDuration(180L).withEndAction(() -> {
            if (topOverlay != null) topOverlay.setVisibility(View.GONE);
        }).start();
    };

    /** Cross-fade an overlay view in/out in lockstep with Media3's controller
     *  (~200 ms matches Media3's own controller fade). */
    private static void fadeOverlay(View v, boolean show) {
        if (v == null) return;
        v.animate().cancel();
        if (show) {
            v.setVisibility(View.VISIBLE);
            v.animate().alpha(1f).setDuration(200L).start();
        } else {
            v.animate().alpha(0f).setDuration(200L)
                    .withEndAction(() -> v.setVisibility(View.GONE)).start();
        }
    }

    private void updateClock() {
        if (overlayClock == null) return;
        java.text.SimpleDateFormat fmt =
                new java.text.SimpleDateFormat(
                        DateFormat.is24HourFormat(this) ? "HH:mm" : "h:mm a",
                        java.util.Locale.getDefault());
        overlayClock.setText(fmt.format(new Date()));
    }

    private void updateQualityBadge(int height) {
        String label;
        if (height >= 2160) label = "4K";
        else if (height >= 1080) label = "FHD";
        else if (height >= 720)  label = "HD";
        else                     label = "SD";
        overlayQuality.setText(label);
        overlayQuality.setVisibility(View.VISIBLE);
    }

    /* ====================================================================
     *  Picture-in-Picture
     * ==================================================================== */

    /** Manual PiP button → goes to PiP without waiting for Home press. */
    private void enterPipManually() {
        if (Build.VERSION.SDK_INT < 26 || !supportsPip()) {
            Toast.makeText(this, "Picture-in-Picture not supported on this device",
                    Toast.LENGTH_SHORT).show();
            return;
        }
        try {
            PictureInPictureParams params = new PictureInPictureParams.Builder()
                    .setAspectRatio(safeAspect(lastAspect))
                    .build();
            enterPictureInPictureMode(params);
        } catch (Exception e) {
            Toast.makeText(this, "Could not enter PiP: " + e.getMessage(),
                    Toast.LENGTH_SHORT).show();
        }
    }

    /* ====================================================================
     *  Key / D-pad handling
     * ==================================================================== */

    @Override
    public boolean dispatchKeyEvent(KeyEvent event) {
        if (event.getAction() != KeyEvent.ACTION_DOWN) {
            return super.dispatchKeyEvent(event);
        }
        int code = event.getKeyCode();

        // Channel-overlay specific keys only fire when the overlay exists
        // (i.e. Live TV + non-empty playlist).
        if (channelOverlay != null) {
            // RIGHT D-pad opens panel when it's closed and the user
            // isn't currently navigating the Media3 controller.
            if (code == KeyEvent.KEYCODE_DPAD_RIGHT
                    && !channelOverlay.isVisible()
                    && !playerView.isControllerFullyVisible()) {
                channelOverlay.show();
                return true;
            }

            // BACK closes the panel first if it's open.
            if (code == KeyEvent.KEYCODE_BACK && channelOverlay.isVisible()) {
                channelOverlay.hide();
                return true;
            }

            // UP / CH+ → previous channel, DOWN / CH- → next channel.
            if (!channelOverlay.isVisible()) {
                if (code == KeyEvent.KEYCODE_CHANNEL_UP
                        || (code == KeyEvent.KEYCODE_DPAD_UP
                            && !playerView.isControllerFullyVisible())) {
                    Channel ch = channelOverlay.zapPrevious();
                    if (ch != null) playChannel(ch);
                    return true;
                }
                if (code == KeyEvent.KEYCODE_CHANNEL_DOWN
                        || (code == KeyEvent.KEYCODE_DPAD_DOWN
                            && !playerView.isControllerFullyVisible())) {
                    Channel ch = channelOverlay.zapNext();
                    if (ch != null) playChannel(ch);
                    return true;
                }
            }
        }
        return super.dispatchKeyEvent(event);
    }

    /* ====================================================================
     *  Lifecycle
     * ==================================================================== */

    /** User pressed Home or swiped to Recents — slip into PiP if possible. */
    @Override
    protected void onUserLeaveHint() {
        super.onUserLeaveHint();
        if (Build.VERSION.SDK_INT >= 26 && supportsPip() && controller != null
                && controller.isPlaying()) {
            try {
                PictureInPictureParams params = new PictureInPictureParams.Builder()
                        .setAspectRatio(safeAspect(lastAspect))
                        .build();
                enterPictureInPictureMode(params);
            } catch (Exception ignored) {
                /* device doesn't support PiP — playback continues full-screen
                   and the foreground service keeps audio alive. */
            }
        }
    }

    /** Hide the controls + custom overlay while we're in the floating window. */
    @Override
    public void onPictureInPictureModeChanged(boolean inPip, Configuration newConfig) {
        super.onPictureInPictureModeChanged(inPip, newConfig);
        // BUG FIX (2026-01) — PiP [X] audio-leak detection.
        //
        // Android calls this with `inPip=false` for BOTH cases:
        //   (a) user tapped [X] on the floating window → activity will
        //       go straight to onStop()/onDestroy() and we MUST tear
        //       down playback,
        //   (b) user restored the window to fullscreen → activity goes
        //       through onResume() and we must NOT tear down.
        //
        // Disambiguating in this callback alone is impossible (the
        // next lifecycle event hasn't fired yet). The reliable trick:
        // arm a "teardown pending" flag here, then have onResume()
        // disarm it. If we reach onStop() with the flag still armed,
        // the user dismissed via [X].
        if (!inPip) {
            pipExitTeardownPending = true;
            android.util.Log.d("NadiPlayer",
                "PiP exit detected — teardown armed; waiting for onResume()/onStop() to disambiguate");
        }
        if (playerView != null) {
            playerView.setUseController(!inPip);
        }
        if (topOverlay != null) {
            topOverlay.setVisibility(inPip ? View.GONE : View.VISIBLE);
        }
        if (bottomOverlayVod != null && !isLive) {
            bottomOverlayVod.setVisibility(inPip ? View.GONE : View.VISIBLE);
        }
        if (inPip && channelOverlay != null && channelOverlay.isVisible()) {
            channelOverlay.hide();
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        // BUG FIX (2026-01) — disarm PiP-exit teardown. The user
        // restored the floating window to fullscreen, so onStop() is
        // NOT going to fire from here. Without this disarm, the next
        // legitimate stop (e.g. user backgrounding the app while
        // playing) would incorrectly tear down the player.
        if (pipExitTeardownPending) {
            android.util.Log.d("NadiPlayer",
                "onResume() — PiP teardown disarmed (user restored to fullscreen)");
            pipExitTeardownPending = false;
        }
    }

    /** Back press = explicit "stop playback". Dismisses notification.
     *
     *  BUG FIX (iter-5): previously we called {@code stopService()} but
     *  kept the bound MediaController alive — the implicit binding from
     *  {@code MediaController.Builder(...).buildAsync()} prevented the
     *  service from actually being destroyed, so playback (and audio)
     *  continued in the background. The correct shutdown sequence per
     *  Media3 docs is:
     *
     *     1. Stop the player (so MediaSession is idle)
     *     2. Release the controller future (drops the implicit binding)
     *     3. Then stopService() — service onDestroy now actually fires
     *
     *  The same teardown is run from {@link #onDestroy} for the
     *  "closed PiP window via [x]" path.
     */
    @Override
    public void onBackPressed() {
        if (channelOverlay != null && channelOverlay.isVisible()) {
            channelOverlay.hide();
            return;
        }
        stoppedExplicitly = true;
        teardownPlayback();
        super.onBackPressed();
    }

    /** One-shot, idempotent teardown of every native playback resource
     *  owned by this activity. Safe to call from any lifecycle hook. */
    private void teardownPlayback() {
        if (controller != null) {
            try {
                controller.stop();
                controller.clearMediaItems();
            } catch (Exception ignored) {
                /* controller might already be disconnected — fine */
            }
        }
        if (controllerFuture != null) {
            MediaController.releaseFuture(controllerFuture);
            controllerFuture = null;
        }
        controller = null;
        // BUG FIX (2026-01) — PiP [X] dismissal leaked audio.
        //
        // A bare stopService() is NOT reliable for a foreground
        // MediaSessionService. Android may defer destruction (or skip
        // it entirely if any controller is still bound via the
        // MediaSession token), so the ExoPlayer instance kept decoding
        // and we heard background audio.
        //
        // Send an explicit ACTION_STOP_PLAYBACK first — the service
        // releases the player synchronously, drops the foreground
        // notification, then calls stopSelf(). The follow-up
        // stopService() is belt + braces for older Android versions
        // where startService is the only way to deliver the action.
        Intent stopIntent = new Intent(this, PlaybackService.class);
        stopIntent.setAction(PlaybackService.ACTION_STOP_PLAYBACK);
        try {
            startService(stopIntent);
        } catch (Exception ignored) {
            /* foreground-start restriction (Android 12+) when the app
               is already backgrounded — that's fine, stopService below
               is sufficient because the service was started while we
               were foregrounded. */
        }
        stopService(new Intent(this, PlaybackService.class));
        // Iter-6: nudge the JS WebView so its useExitInterstitial hook
        // can count this exit and (every Nth time, for non-premium
        // users) show an interstitial when the user is back on the
        // main menu. Safe even if the plugin hasn't loaded yet —
        // notifyPlayerExited() guards against a null instance.
        NativePlayerPlugin.notifyPlayerExited();
    }

    @Override
    protected void onStop() {
        super.onStop();
        // BUG FIX (2026-01) — PiP "X" dismissal leaks audio.
        //
        // Lifecycle when user taps the PiP [X] button on Android 11+:
        //
        //   onPictureInPictureModeChanged(false, …)   ← teardown ARMED
        //   onStop()                                  ← we're HERE
        //   onDestroy()                               ← NOT GUARANTEED
        //
        // Android may move the activity to the stopped state and keep
        // it around indefinitely for task-switch performance, never
        // firing onDestroy. The teardown code in onDestroy therefore
        // never runs, the foreground MediaSession service keeps
        // alive, and ExoPlayer keeps decoding audio in the background.
        //
        // pipExitTeardownPending is armed in onPictureInPictureModeChanged(false)
        // and disarmed in onResume(). If we reach onStop() with it
        // still armed, the user DEFINITELY closed via [X] (no resume
        // happened). Tear down synchronously:
        //   1. mark stoppedExplicitly so onDestroy doesn't double-fire,
        //   2. teardownPlayback() — sends ACTION_STOP_PLAYBACK to the
        //      service which synchronously releases ExoPlayer + drops
        //      the foreground notification with STOP_FOREGROUND_REMOVE,
        //   3. finish() — moves the activity out of the back stack so
        //      a future cold-start can't resurrect the failed state.
        if (pipExitTeardownPending && !stoppedExplicitly) {
            android.util.Log.i("NadiPlayer",
                "onStop() — PiP [X] dismissed; tearing down ExoPlayer + service synchronously");
            stoppedExplicitly = true;
            pipExitTeardownPending = false;
            teardownPlayback();
            finish();
        }
        // If the activity is leaving but PiP isn't active, we still keep
        // the service running so audio survives → that's the "screen
        // locked / app minimised" behaviour. The notification is the
        // user's control surface from here.
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        ui.removeCallbacks(clockTick);
        if (topOverlay != null) topOverlay.removeCallbacks(hideOverlay);
        // If the user finished the activity *without* an explicit Back
        // (e.g. closed the PiP window via the [x]) we run the same full
        // teardown so the foreground service can actually be destroyed —
        // a bare stopService() doesn't drop the implicit MediaController
        // binding and the service (+ audio) would otherwise survive.
        if (isFinishing() && !stoppedExplicitly) {
            teardownPlayback();
        } else {
            // Normal config-change / lifecycle exit: just release our
            // controller future. The service stays alive (e.g. PiP path
            // or user backgrounded with audio playing).
            if (controllerFuture != null) {
                MediaController.releaseFuture(controllerFuture);
                controllerFuture = null;
            }
            controller = null;
        }
    }

    private boolean supportsPip() {
        if (Build.VERSION.SDK_INT < 26) return false;
        return getPackageManager().hasSystemFeature(
                PackageManager.FEATURE_PICTURE_IN_PICTURE);
    }

    /** PiP requires aspect within [0.4178, 2.39]. Clamp before passing. */
    private static Rational safeAspect(Rational r) {
        if (r == null || r.getNumerator() <= 0 || r.getDenominator() <= 0) {
            return new Rational(16, 9);
        }
        double v = r.doubleValue();
        if (v < 0.42) return new Rational(42, 100);
        if (v > 2.39) return new Rational(239, 100);
        return r;
    }

    private void showError(String msg) {
        if (errorView != null) {
            errorView.setVisibility(View.VISIBLE);
            errorView.setText(msg);
        }
    }
}
