package invalid.lena.scrcpy;

import android.Manifest;
import android.app.Activity;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.SurfaceTexture;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

// Full-screen mirror activity. Pulls target host/port from intent
// extras, starts a Sessions foreground service to keep the process
// alive during brief backgrounding, and owns the Session itself.
//
// Two layouts ship: src/main/res/layout/mirror.xml (release: SurfaceView
// + status bar) and src/debug/res/layout/mirror.xml (debug: TextureView
// + status bar + bottom stats overlay used by the e2e screen capture
// because emulators don't composite SurfaceView into screencap).
//
// Surface lifetime is decoupled from session lifetime: when the surface
// goes away (rotation, background) we swap the session's video surface
// to null and let the wire keep draining. When the surface comes back
// we swap the new one in. Audio and control streams are unaffected.
//
// Session state vs activity lifetime: a fatal session error does NOT
// finish() the activity any more - instead the status bar transitions
// to DISCONNECTED and exposes a Reconnect button.
public final class Mirror extends Activity {

    public static final String EXTRA_HOST = "host";
    public static final String EXTRA_PORT = "port";

    // Arbitrary request code for POST_NOTIFICATIONS - we don't react to
    // the result; the system caches the choice for next launch.
    private static final int RQ_POST_NOTIFICATIONS = 1001;

    private enum State { CONNECTING, CONNECTED, DISCONNECTED }

    private volatile Adb   adb;
    private Devices.Device target;
    private Session        session;
    private Surface        currentSurface;
    private boolean        ownsCurrentSurface;
    private volatile boolean destroyed;
    private long           sessionGeneration;
    private boolean        stoppingSession;
    private State          state = State.CONNECTING;
    private int            connectedW, connectedH;

    // Only one of these is non-null per build variant.
    private TextureView textureView;
    private SurfaceView surfaceView;

    // Always present (declared in both layouts).
    private View     statusBar;
    private TextView statusText;
    private Button   reconnectBtn;
    private Button   recordBtn;

    // Overlay TextViews - only present in the debug layout. null in release.
    private TextView overlayTarget, overlayStats, overlayEvent;
    private final Handler ui = new Handler(Looper.getMainLooper());
    private int textureUpdates;

    @Override
    protected void onCreate(Bundle saved) {
        super.onCreate(saved);
        setContentView(R.layout.mirror);
        immersive();
        // Hold the source screen awake for as long as Mirror is in
        // front. Cleared automatically when the activity is destroyed.
        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

        String host = getIntent().getStringExtra(EXTRA_HOST);
        int port = getIntent().getIntExtra(EXTRA_PORT, -1);
        if (host == null || port <= 0) {
            Log.e("mirror: bad extras host=%s port=%d", host, port);
            finish();
            return;
        }
        target = new Devices.Device(host, port);

        View v = findViewById(R.id.surface);
        if (v instanceof TextureView) {
            textureView = (TextureView) v;
            textureView.setSurfaceTextureListener(textureListener);
        } else if (v instanceof SurfaceView) {
            surfaceView = (SurfaceView) v;
            surfaceView.getHolder().addCallback(holderCallback);
        } else {
            Log.e("mirror: layout has no SurfaceView or TextureView at R.id.surface");
            finish();
            return;
        }

        statusBar    = findViewById(R.id.status_bar);
        statusText   = findViewById(R.id.status_text);
        reconnectBtn = findViewById(R.id.reconnect);
        recordBtn    = findViewById(R.id.record);
        reconnectBtn.setOnClickListener(view -> reconnect());
        recordBtn.setOnClickListener(view -> toggleRecord());
        updateStatusBar();

        overlayTarget = findViewById(R.id.overlay_target);
        overlayStats  = findViewById(R.id.overlay_stats);
        overlayEvent  = findViewById(R.id.overlay_event);
        if (overlayTarget != null) {
            overlayTarget.setText("target: " + host + ":" + port);
            ui.postDelayed(this::pollOverlay, 200);
        }

        requestNotificationsIfNeeded();
        startForegroundService(new Intent(this, Sessions.class));

        if (!Settings.hintBackShown(this)) {
            Toast.makeText(this, R.string.hint_long_press_back,
                    Toast.LENGTH_LONG).show();
            Settings.setHintBackShown(this, true);
        }

        new Thread(() -> {
            try {
                Adb a = Adb.getInstance(this);
                runOnUiThread(() -> {
                    if (destroyed) return;
                    adb = a;
                    if (session == null && currentSurface != null) {
                        startSession(currentSurface);
                    }
                });
            } catch (Exception e) {
                Log.e(e, "mirror: adb init");
                runOnUiThread(() -> {
                    if (destroyed) return;
                    Toast.makeText(this, "adb init: " + e.getMessage(),
                            Toast.LENGTH_LONG).show();
                    finish();
                });
            }
        }, "adb-init").start();
    }

    // ---- surface lifecycle: TextureView path ----

    private final TextureView.SurfaceTextureListener textureListener =
            new TextureView.SurfaceTextureListener() {
        @Override
        public void onSurfaceTextureAvailable(SurfaceTexture st, int w, int h) {
            Log.i("mirror: texture available %dx%d", w, h);
            attachSurface(new Surface(st), true, w, h);
        }
        @Override
        public void onSurfaceTextureSizeChanged(SurfaceTexture st, int w, int h) {
            Log.i("mirror: texture resized %dx%d", w, h);
            if (session != null) session.setViewSize(w, h);
        }
        @Override
        public boolean onSurfaceTextureDestroyed(SurfaceTexture st) {
            Log.i("mirror: texture destroyed");
            detachSurface();
            return true;
        }
        @Override
        public void onSurfaceTextureUpdated(SurfaceTexture st) {
            if (++textureUpdates == 1 || textureUpdates % 30 == 0) {
                Log.i("mirror: texture updates=%d", textureUpdates);
            }
        }
    };

    // ---- surface lifecycle: SurfaceView path ----

    private final SurfaceHolder.Callback holderCallback = new SurfaceHolder.Callback() {
        @Override
        public void surfaceCreated(SurfaceHolder holder) {
            Log.i("mirror: surface created");
            attachSurface(holder.getSurface(), false, 0, 0);
        }
        @Override
        public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
            Log.i("mirror: surface changed %dx%d", w, h);
            if (session != null) session.setViewSize(w, h);
        }
        @Override
        public void surfaceDestroyed(SurfaceHolder holder) {
            Log.i("mirror: surface destroyed");
            detachSurface();
        }
    };

    // ---- session driver ----

    private void attachSurface(Surface s, boolean owned, int w, int h) {
        if (session != null && currentSurface != null) session.swapSurface(null);
        releaseOwnedSurface();
        currentSurface = s;
        ownsCurrentSurface = owned;
        if (session != null) {
            session.swapSurface(s);
            if (w > 0) session.setViewSize(w, h);
            return;
        }
        if (adb == null) return; // adb-init thread will start the session
        if (stoppingSession) return; // stop worker starts the latest target
        if (state == State.DISCONNECTED) return; // wait for user to tap reconnect
        startSession(s);
        if (w > 0) session.setViewSize(w, h);
    }

    private void detachSurface() {
        if (session != null) session.swapSurface(null);
        releaseOwnedSurface();
        currentSurface = null;
    }

    private void releaseOwnedSurface() {
        if (ownsCurrentSurface && currentSurface != null) currentSurface.release();
        ownsCurrentSurface = false;
    }

    private void startSession(Surface s) {
        if (destroyed) return;
        state = State.CONNECTING;
        updateStatusBar();
        long generation = ++sessionGeneration;
        session = new Session(this, adb, target, s, new Session.Listener() {
            @Override public void onConnected(int w, int h) {
                runOnUiThread(() -> {
                    if (destroyed || generation != sessionGeneration) return;
                    state = State.CONNECTED;
                    connectedW = w; connectedH = h;
                    updateStatusBar();
                });
            }
            @Override public void onReconnecting() {
                Log.i("mirror: link lost, reconnecting");
                runOnUiThread(() -> {
                    if (destroyed || generation != sessionGeneration) return;
                    state = State.CONNECTING;
                    updateStatusBar();
                });
            }
            @Override public void onError(Throwable t) {
                runOnUiThread(() -> {
                    if (destroyed || generation != sessionGeneration) return;
                    Toast.makeText(Mirror.this,
                            "session error: " + t.getMessage(), Toast.LENGTH_LONG).show();
                });
            }
            @Override public void onStopped() {
                Log.i("mirror: session stopped");
                runOnUiThread(() -> {
                    if (destroyed || generation != sessionGeneration) return;
                    state = State.DISCONNECTED;
                    updateStatusBar();
                });
            }
        });
        session.start();
    }

    private void toggleRecord() {
        if (session == null) return;
        if (session.isRecording()) {
            // stopRecording is false when no keyframe ever landed (still
            // ARMED) - no file was written, so don't claim one was saved.
            boolean saved = session.stopRecording();
            Toast.makeText(this, saved ? "recording saved" : "recording discarded (no video)",
                    Toast.LENGTH_SHORT).show();
        } else {
            File dir = getExternalFilesDir(null);
            if (dir == null) {
                Toast.makeText(this, "no external storage", Toast.LENGTH_LONG).show();
                return;
            }
            String ts = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(new Date());
            File out = new File(dir, "scrcpy-" + ts + ".mp4");
            session.armRecording(out);
            Toast.makeText(this, "recording -> " + out.getName(), Toast.LENGTH_SHORT).show();
        }
        updateRecordButton();
    }

    private void updateRecordButton() {
        boolean on = session != null && session.isRecording();
        recordBtn.setText(on ? R.string.record_on : R.string.record);
    }

    private void reconnect() {
        Log.i("mirror: reconnect tapped");
        state = State.CONNECTING;
        updateStatusBar();
        if (stoppingSession) return;
        stoppingSession = true;
        Session old = session;
        session = null;
        sessionGeneration++;
        // Stop the old session off the UI thread (teardown closes
        // sockets and joins the server's log pump), THEN start the new
        // one. Sequencing matters: both sessions share the singleton
        // Adb, so the old teardown's disconnect must finish before the
        // new bring-up connects.
        new Thread(() -> {
            if (old != null) old.stop();
            runOnUiThread(() -> {
                if (destroyed) return;
                stoppingSession = false;
                if (session != null) return;  // another path already started one
                if (adb == null || currentSurface == null) return;
                startSession(currentSurface);
            });
        }, "session-stop").start();
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        String host = intent.getStringExtra(EXTRA_HOST);
        int port = intent.getIntExtra(EXTRA_PORT, -1);
        if (host == null || port <= 0 || port > 65535) {
            Log.w("mirror: ignoring bad replacement target host=%s port=%d", host, port);
            return;
        }
        if (target.host.equals(host) && target.port == port) return;

        setIntent(intent);
        target = new Devices.Device(host, port);
        connectedW = connectedH = 0;
        if (overlayTarget != null) overlayTarget.setText("target: " + target);
        reconnect();
    }

    private void updateStatusBar() {
        if (statusText == null) return;
        String s;
        switch (state) {
            case CONNECTED:
                s = String.format(Locale.ROOT, "%s:%d  %dx%d  %s",
                        target.host, target.port, connectedW, connectedH,
                        Settings.videoCodec(this));
                break;
            case DISCONNECTED:
                s = String.format(Locale.ROOT, "%s:%d  %s",
                        target.host, target.port, getString(R.string.disconnected));
                break;
            default:
                s = String.format(Locale.ROOT, "%s:%d  %s",
                        target.host, target.port, getString(R.string.connecting));
        }
        statusText.setText(s);
        reconnectBtn.setVisibility(state == State.DISCONNECTED ? View.VISIBLE : View.GONE);
        recordBtn.setEnabled(state == State.CONNECTED);
        updateRecordButton();

        // Hide the whole bar while actively mirroring if the user opted
        // out; keep it up whenever not CONNECTED so the status and the
        // Reconnect button stay reachable.
        if (statusBar != null) {
            boolean show = Settings.showStatusBar(this) || state != State.CONNECTED;
            statusBar.setVisibility(show ? View.VISIBLE : View.GONE);
        }
    }

    // ---- overlay (debug only - fields are null in release) ----

    private void pollOverlay() {
        if (overlayStats == null) return;
        if (session != null) {
            overlayStats.setText(String.format(Locale.ROOT,
                    "v=%-4d  a=%-4d  tex=%-4d",
                    session.videoFrames(), session.audioFrames(), textureUpdates));
            overlayEvent.setText("event: " + session.lastEvent());
        }
        ui.postDelayed(this::pollOverlay, 200);
    }

    // ---- input ----

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (session != null) {
            session.onTouch(ev);
            return true;
        }
        return super.onTouchEvent(ev);
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK) {
            event.startTracking();
            return true;
        }
        return super.onKeyDown(keyCode, event);
    }

    @Override
    public boolean onKeyLongPress(int keyCode, KeyEvent event) {
        if (keyCode == KeyEvent.KEYCODE_BACK && session != null) {
            session.onBack();
            return true;
        }
        return super.onKeyLongPress(keyCode, event);
    }

    @Override
    public boolean dispatchKeyEvent(KeyEvent ev) {
        if (ev.getKeyCode() == KeyEvent.KEYCODE_BACK) {
            return super.dispatchKeyEvent(ev);
        }
        if (session != null && shouldForward(ev)) {
            session.onKey(ev);
            return true;
        }
        return super.dispatchKeyEvent(ev);
    }

    // ---- lifecycle ----

    @Override
    protected void onDestroy() {
        destroyed = true;
        sessionGeneration++;
        ui.removeCallbacksAndMessages(null);
        releaseOwnedSurface();
        currentSurface = null;
        Session s = session;
        session = null;
        if (s != null) {
            // Teardown blocks on socket closes and a thread join; keep
            // it off the UI thread.
            new Thread(s::stop, "session-stop").start();
        }
        stopService(new Intent(this, Sessions.class));
        super.onDestroy();
    }

    // Android 13+ requires runtime grant for POST_NOTIFICATIONS. The
    // foreground service notification is silently suppressed if the
    // user never sees the dialog, and on Android 14+ a notification-
    // less FGS can be killed at any time.
    private void requestNotificationsIfNeeded() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
        if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
                == PackageManager.PERMISSION_GRANTED) return;
        requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS},
                RQ_POST_NOTIFICATIONS);
    }

    private void immersive() {
        WindowInsetsController c = getWindow().getInsetsController();
        if (c != null) {
            c.hide(WindowInsets.Type.systemBars());
            c.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
        }
        getWindow().setDecorFitsSystemWindows(false);
    }

    private static boolean shouldForward(KeyEvent ev) {
        int code = ev.getKeyCode();
        if (code >= KeyEvent.KEYCODE_DPAD_UP && code <= KeyEvent.KEYCODE_DPAD_CENTER) return true;
        if (code >= KeyEvent.KEYCODE_0 && code <= KeyEvent.KEYCODE_9) return true;
        if (code >= KeyEvent.KEYCODE_A && code <= KeyEvent.KEYCODE_Z) return true;
        switch (code) {
            case KeyEvent.KEYCODE_SPACE:
            case KeyEvent.KEYCODE_ENTER:
            case KeyEvent.KEYCODE_DEL:
            case KeyEvent.KEYCODE_FORWARD_DEL:
            case KeyEvent.KEYCODE_TAB:
            case KeyEvent.KEYCODE_ESCAPE:
            case KeyEvent.KEYCODE_PAGE_UP:
            case KeyEvent.KEYCODE_PAGE_DOWN:
            case KeyEvent.KEYCODE_MOVE_HOME:
            case KeyEvent.KEYCODE_MOVE_END:
            case KeyEvent.KEYCODE_INSERT:
            case KeyEvent.KEYCODE_SHIFT_LEFT:
            case KeyEvent.KEYCODE_SHIFT_RIGHT:
            case KeyEvent.KEYCODE_CTRL_LEFT:
            case KeyEvent.KEYCODE_CTRL_RIGHT:
            case KeyEvent.KEYCODE_ALT_LEFT:
            case KeyEvent.KEYCODE_ALT_RIGHT:
            case KeyEvent.KEYCODE_META_LEFT:
            case KeyEvent.KEYCODE_META_RIGHT:
            case KeyEvent.KEYCODE_CAPS_LOCK:
            case KeyEvent.KEYCODE_NUM_LOCK:
            case KeyEvent.KEYCODE_SCROLL_LOCK:
            case KeyEvent.KEYCODE_COMMA:
            case KeyEvent.KEYCODE_PERIOD:
            case KeyEvent.KEYCODE_SLASH:
            case KeyEvent.KEYCODE_BACKSLASH:
            case KeyEvent.KEYCODE_SEMICOLON:
            case KeyEvent.KEYCODE_APOSTROPHE:
            case KeyEvent.KEYCODE_GRAVE:
            case KeyEvent.KEYCODE_LEFT_BRACKET:
            case KeyEvent.KEYCODE_RIGHT_BRACKET:
            case KeyEvent.KEYCODE_MINUS:
            case KeyEvent.KEYCODE_EQUALS:
                return true;
            default:
                return false;
        }
    }
}
