package chat.vojo.app;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log;

import androidx.core.app.NotificationManagerCompat;

import org.json.JSONObject;

import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Sends {@code m.call.decline} directly from the notification action tap,
 * bypassing the WebView boot entirely. Fires via {@code PendingIntent.getBroadcast}
 * from the CallStyle Decline button — MainActivity never starts, so the
 * lockscreen-unlock-and-app-flash UX from Phase 2.5.3 is gone.
 *
 * Session data ({@code accessToken}, {@code baseUrl}, {@code userId}) is
 * mirrored from JS into {@code shared_prefs/CapacitorStorage.xml} by
 * {@code writeSessionBridge()} on client mount.
 *
 * Recovery contract:
 *   - Before HTTP: write {@code vojo.pendingDeclines.{notifEventId}} tombstone
 *     so {@code usePendingDeclinesFlusher} can retry on app resume if our
 *     HTTP fails (network drop, token invalidated, process killed mid-PUT).
 *   - On 2xx: remove the tombstone — receiver path succeeded, no flusher work.
 *   - On non-2xx or exception: leave tombstone; flusher drains on next resume.
 *
 * Null-session edge case (fresh reinstall + first push before first login):
 * we cannot send the decline at all — there's no access token, and the
 * JS-path can't cover us either (a logged-out client has no Matrix session
 * to call sendRtcDecline against). Cancelling the notification is the only
 * feedback we can give; leaving the ring would trap the user on a call they
 * can't accept or decline until the A-side times out.
 *
 * Note on idempotency: the flusher's retry generates a new txnId, so on a
 * split-success-fail sequence (receiver HTTP timed out, flusher succeeds)
 * we may land two {@code m.call.decline} events in the timeline with the
 * same {@code rel.event_id}. This is cosmetic — the caller's auto-hangup
 * hook is idempotent and fires on the first decline.
 */
public class CallDeclineReceiver extends BroadcastReceiver {

    public static final String ACTION_DECLINE_CALL = "chat.vojo.app.DECLINE_CALL";
    public static final String EXTRA_ROOM_ID = "room_id";
    public static final String EXTRA_NOTIF_EVENT_ID = "notif_event_id";
    public static final String EXTRA_NOTIF_TAG = "notif_tag";
    public static final String EXTRA_NOTIF_ID = "notif_id";

    private static final String PREFS_FILE = "CapacitorStorage";
    private static final String SESSION_KEY = "vojo.matrixSession";
    private static final String PENDING_DECLINES_PREFIX = "vojo.pendingDeclines.";

    private static final int CONNECT_TIMEOUT_MS = 8_000;
    private static final int READ_TIMEOUT_MS = 8_000;

    private static final String TAG = "CallDeclineReceiver";

    // Single reusable executor keeps us off the main thread without spawning
    // a fresh one per broadcast — declines come rarely enough that a pool of 1
    // is fine; a short-lived Thread would also work but is noisier on tracing.
    private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();

    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent == null) return;
        final String roomId = intent.getStringExtra(EXTRA_ROOM_ID);
        final String notifEventId = intent.getStringExtra(EXTRA_NOTIF_EVENT_ID);
        final String notifTag = intent.getStringExtra(EXTRA_NOTIF_TAG);
        final int notifId = intent.getIntExtra(EXTRA_NOTIF_ID, -1);
        if (roomId == null || notifEventId == null) {
            Log.w(TAG, "onReceive: missing extras, abort");
            return;
        }

        final Context appContext = context.getApplicationContext();
        final SharedPreferences prefs =
            appContext.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE);

        final String sessionJson = prefs.getString(SESSION_KEY, null);
        if (sessionJson == null) {
            // Fresh reinstall / logged-out: no access token, no Matrix client.
            // We can't send m.call.decline and neither can the JS-path (no
            // session to decline from). Cancel the notif so the user isn't
            // stuck on a ring they can't action. Skip the tombstone — a
            // retry without a session would be equally impotent.
            Log.w(TAG, "onReceive: no session in prefs, cancelling notif without HTTP");
            if (notifTag != null && notifId != -1) {
                NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
            }
            VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
            return;
        }

        final String accessToken;
        final String baseUrl;
        try {
            JSONObject session = new JSONObject(sessionJson);
            accessToken = session.optString("accessToken", null);
            baseUrl = session.optString("baseUrl", null);
        } catch (Throwable t) {
            // Do NOT pass the Throwable — JSONException.getMessage() embeds
            // the malformed input, which here contains the access token.
            Log.e(TAG, "onReceive: prefs JSON parse failed: " + t.getClass().getSimpleName());
            // Still drop the native and the registry entry — user tapped Decline,
            // the ring should not re-surface on next backgrounding just because
            // we can't send the decline over HTTP.
            if (notifTag != null && notifId != -1) {
                NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
            }
            VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
            return;
        }
        if (accessToken == null || accessToken.isEmpty() || baseUrl == null || baseUrl.isEmpty()) {
            Log.w(TAG, "onReceive: empty accessToken/baseUrl in session, cancelling ring locally");
            if (notifTag != null && notifId != -1) {
                NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
            }
            VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);
            return;
        }

        // 1. Cancel first for instant user feedback. Ringtone stops within
        //    NotificationManagerCompat's Binder latency (~tens of ms) — HTTP
        //    completion is irrelevant to the perceived UX.
        if (notifTag != null && notifId != -1) {
            NotificationManagerCompat.from(appContext).cancel(notifTag, notifId);
        }
        // Drop the registry entry and tombstone the eventId so the next
        // onPause renderRegistry can't resurrect the declined ring.
        VojoFirebaseMessagingService.removeIncomingRing(appContext, notifEventId);

        // 2. Write tombstone BEFORE HTTP so the flusher sees work to do
        //    if we fail/die. Remove only on confirmed 2xx.
        try {
            JSONObject tombstone = new JSONObject();
            tombstone.put("roomId", roomId);
            tombstone.put("ts", System.currentTimeMillis());
            prefs.edit()
                .putString(PENDING_DECLINES_PREFIX + notifEventId, tombstone.toString())
                .apply();
        } catch (Throwable t) {
            Log.w(TAG, "onReceive: tombstone write failed (non-fatal)", t);
        }

        // 3. HTTP PUT off-main on goAsync. Receiver stays alive ~10s for us
        //    to finish; after that Android reclaims the process and the
        //    pending request dies — flusher covers recovery on next resume.
        final PendingResult pendingResult = goAsync();
        final String txnId = UUID.randomUUID().toString();
        EXECUTOR.execute(() -> {
            try {
                int status = sendDecline(context, baseUrl, accessToken, roomId, notifEventId, txnId);
                if (status >= 200 && status < 300) {
                    prefs.edit().remove(PENDING_DECLINES_PREFIX + notifEventId).apply();
                    Log.d(TAG, "decline PUT ok status=" + status + " room=" + roomId);
                } else {
                    Log.w(TAG, "decline PUT non-2xx status=" + status + " room=" + roomId);
                }
            } catch (Throwable t) {
                Log.e(TAG, "decline PUT threw", t);
            } finally {
                pendingResult.finish();
            }
        });
    }

    private int sendDecline(
        Context context,
        String baseUrl,
        String accessToken,
        String roomId,
        String notifEventId,
        String txnId
    ) throws Exception {
        String url = trimTrailingSlash(baseUrl)
            + "/_matrix/client/v3/rooms/"
            + URLEncoder.encode(roomId, "UTF-8")
            + "/send/org.matrix.msc4310.rtc.decline/"
            + URLEncoder.encode(txnId, "UTF-8");

        JSONObject relates = new JSONObject();
        relates.put("rel_type", "m.reference");
        relates.put("event_id", notifEventId);
        JSONObject body = new JSONObject();
        body.put("m.relates_to", relates);
        byte[] payload = body.toString().getBytes("UTF-8");

        HttpURLConnection conn = null;
        try {
            // Route through the loopback relay when a proxy is enabled (closes
            // the §15 #3 native-path leak); identical to before when off.
            conn = ProxyRouting.openConnection(context, new URL(url));
            conn.setRequestMethod("PUT");
            conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
            conn.setReadTimeout(READ_TIMEOUT_MS);
            conn.setDoOutput(true);
            conn.setRequestProperty("Authorization", "Bearer " + accessToken);
            conn.setRequestProperty("Content-Type", "application/json");
            conn.setFixedLengthStreamingMode(payload.length);
            try (OutputStream os = conn.getOutputStream()) {
                os.write(payload);
            }
            return conn.getResponseCode();
        } finally {
            if (conn != null) conn.disconnect();
        }
    }

    private static String trimTrailingSlash(String s) {
        return (s != null && s.endsWith("/")) ? s.substring(0, s.length() - 1) : s;
    }
}
