package com.foxx.androidcast.discovery;

/*********************************************************************
 * DiscoveryManager.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 DiscoveryManager
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (10 commits, 536 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	401ea19fe075a36c392b8e5d32e8f55e2428f4bc2ee1f48fec96a7aca0ffcc51
**********************************************************************/
import android.content.Context;
import android.net.wifi.WifiManager;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.PinAuth;
import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.CastPacketFramer;
import com.foxx.androidcast.network.CastProtoHeader;
import com.foxx.androidcast.network.CastProtocol;

import org.json.JSONObject;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;

/** UDP broadcast discovery of receivers on the same LAN. */
public class DiscoveryManager {
    private static final String TAG = "DiscoveryManager";

    public interface Listener {
        void onDevicesUpdated(List<DiscoveredDevice> devices);

        /** UDP browse socket is active. */
        default void onScanStarted() {
        }

        /** Browse pass finished (timeout or {@link #stop()}). */
        default void onScanEnded() {
        }
    }

    public static final class DiscoveredDevice {
        public final String name;
        public final String host;
        public final int port;
        public final String transport;
        public final long lastSeenMs;
        public final int videoCodecs;
        public final int audioCodecs;
        public final int placeholderId;
        public final long protoVersion;
        /** SHA-256 hex of receiver PIN, empty if anonymous. */
        public final String pinAuth;
        /** Shown from {@link PinnedReceiversStore} at top of sender list. */
        public boolean pinnedRecent;
        /** LAN discovery device UUID when advertised by peer. */
        public final String deviceUuid;

        public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
            this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "", "");
        }

        public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
                int videoCodecs, int audioCodecs, int placeholderId, long protoVersion) {
            this(name, host, port, transport, lastSeenMs, videoCodecs, audioCodecs, placeholderId, protoVersion, "");
        }

        public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
                int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth) {
            this(name, host, port, transport, lastSeenMs, videoCodecs, audioCodecs, placeholderId, protoVersion,
                    pinAuth, "");
        }

        public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs,
                int videoCodecs, int audioCodecs, int placeholderId, long protoVersion, String pinAuth,
                String deviceUuid) {
            this.name = name;
            this.host = host;
            this.port = port;
            this.transport = transport;
            this.lastSeenMs = lastSeenMs;
            this.videoCodecs = videoCodecs;
            this.audioCodecs = audioCodecs;
            this.placeholderId = placeholderId;
            this.protoVersion = protoVersion;
            this.pinAuth = pinAuth != null ? pinAuth : "";
            this.deviceUuid = deviceUuid != null ? deviceUuid : "";
        }

        public boolean pinMatches(String pin) {
            return PinAuth.matches(pin, pinAuth);
        }
    }

    private final Context appContext;
    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    private final ExecutorService executor = Executors.newCachedThreadPool();
    private final AtomicBoolean running = new AtomicBoolean(false);
    private final ConcurrentHashMap<String, DiscoveredDevice> devices = new ConcurrentHashMap<>();

    private Listener listener;
    private volatile DatagramSocket socket;
    private final Object lockGuard = new Object();
    private WifiManager.MulticastLock multicastLock;
    private int multicastHolds;
    private boolean burstScan;
    private NsdCastDiscovery nsdDiscovery;

    public DiscoveryManager(Context context) {
        this.appContext = context.getApplicationContext();
    }

    public void setListener(Listener listener) {
        this.listener = listener;
    }

    public boolean isBrowsing() {
        return running.get();
    }

    public void startBrowsing() {
        startBrowsing(false);
    }

    /** @param burst shorter UDP pass + NSD (user Refresh). */
    public void startBrowsing(boolean burst) {
        if (!running.compareAndSet(false, true)) {
            return;
        }
        burstScan = burst;
        purgeSelfFromDeviceMap();
        notifyScanStarted();
        startNsdBrowse();
        executor.execute(this::browseLoop);
    }

    /**
     * Stops discovery I/O. The background loop releases the multicast lock in its {@code finally}
     * block — do not release here (double-release crashed the sender when cast started).
     */
    public void stop() {
        running.set(false);
        stopNsdBrowse();
        stopNsdRegister();
        DatagramSocket toClose = socket;
        socket = null;
        if (toClose != null) {
            try {
                toClose.close();
            } catch (Exception ignored) {
            }
        }
        devices.clear();
    }

    public void clearDevices() {
        devices.clear();
        notifyListener();
    }

    public void startAnnouncing(String deviceName, int port, String transport) {
        startAnnouncing(deviceName, port, transport, true);
    }

    public void startAnnouncing(Context context, String deviceName, int port, String transport,
            boolean listening) {
        startAnnouncing(context, deviceName, port, transport, listening, "");
    }

    public void startAnnouncing(Context context, String deviceName, int port, String transport,
            boolean listening, String pin) {
        if (!running.compareAndSet(false, true)) {
            return;
        }
        String pinHash = com.foxx.androidcast.PinAuth.isProtected(pin)
                ? com.foxx.androidcast.PinAuth.hash(pin) : "";
        startNsdRegister(deviceName, port, transport, pinHash);
        executor.execute(() -> announceLoop(context, deviceName, port, transport, listening, pin));
    }

    /** Merge device from NSD or pinned list into the browse map. */
    public void upsertExternal(DiscoveredDevice device) {
        if (device == null || device.host == null || device.host.isEmpty()) {
            return;
        }
        if (DiscoverySelfFilter.isSelf(appContext, device.host, null)) {
            return;
        }
        String key = device.host + ":" + device.port;
        devices.put(key, device);
        notifyListener();
    }

    public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
        startAnnouncing(appContext, deviceName, port, transport, listening, "");
    }

    private void browseLoop() {
        acquireMulticastLock();
        try (DatagramSocket sock = new DatagramSocket(null)) {
            socket = sock;
            sock.setReuseAddress(true);
            sock.bind(new InetSocketAddress(CastConfig.DISCOVERY_PORT));
            sock.setBroadcast(true);
            sock.setSoTimeout(300);
            byte[] buf = new byte[1024];
            long passMs = burstScan ? CastConfig.DISCOVERY_BURST_MS : CastConfig.DISCOVERY_SCAN_PASS_MS;
            long probeInterval = burstScan ? CastConfig.DISCOVERY_BURST_PROBE_MS
                    : CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
            long deadline = System.currentTimeMillis() + passMs;
            long nextProbeMs = System.currentTimeMillis();
            while (running.get() && System.currentTimeMillis() < deadline) {
                long now = System.currentTimeMillis();
                if (now >= nextProbeMs) {
                    sendDiscoveryProbe(sock);
                    nextProbeMs = now + probeInterval;
                }
                DatagramPacket packet = new DatagramPacket(buf, buf.length);
                try {
                    sock.receive(packet);
                    handlePacket(packet);
                } catch (java.net.SocketTimeoutException ignored) {
                    // keep listening until pass ends
                }
            }
        } catch (Exception e) {
            if (running.get()) {
                Log.e(TAG, "Browse failed", e);
            }
        } finally {
            socket = null;
            releaseMulticastLock();
            running.set(false);
            notifyScanEnded();
        }
    }

    private void notifyScanStarted() {
        if (listener == null) {
            return;
        }
        mainHandler.post(() -> {
            if (listener != null) {
                listener.onScanStarted();
            }
        });
    }

    private void notifyScanEnded() {
        if (listener == null) {
            return;
        }
        mainHandler.post(() -> {
            if (listener != null) {
                listener.onScanEnded();
            }
        });
    }

    private void announceLoop(Context context, String deviceName, int port, String transport,
            boolean listening, String pin) {
        acquireMulticastLock();
        int placeholderId = DeviceInfo.getPlaceholderId(context);
        int videoMask = CastCodecFlags.localDecoderVideoMask();
        int audioMask = CastCodecFlags.localAudioMask();
        try (DatagramSocket sock = new DatagramSocket()) {
            socket = sock;
            sock.setBroadcast(true);
            sock.setSoTimeout(400);
            while (running.get()) {
                sendAnnounce(sock, context, deviceName, port, transport, listening, pin,
                        placeholderId, videoMask, audioMask);
                long listenUntil = System.currentTimeMillis() + 2_000;
                while (running.get() && System.currentTimeMillis() < listenUntil) {
                    try {
                        byte[] buf = new byte[1024];
                        DatagramPacket packet = new DatagramPacket(buf, buf.length);
                        sock.receive(packet);
                        if (isDiscoverProbe(packet)) {
                            sendAnnounce(sock, context, deviceName, port, transport, listening, pin,
                                    placeholderId, videoMask, audioMask);
                            sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, pin,
                                    placeholderId, videoMask, audioMask, packet.getAddress());
                        }
                    } catch (java.net.SocketTimeoutException ignored) {
                    }
                }
            }
        } catch (Exception e) {
            if (running.get()) {
                Log.e(TAG, "Announce failed", e);
            }
        } finally {
            socket = null;
            releaseMulticastLock();
            running.set(false);
        }
    }

    private static void sendDiscoveryProbe(DatagramSocket sock) {
        try {
            JSONObject json = new JSONObject();
            json.put("magic", CastConfig.DISCOVERY_MAGIC);
            json.put("discover", true);
            byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8);
            InetAddress broadcast = InetAddress.getByName("255.255.255.255");
            DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT);
            sock.send(packet);
        } catch (Exception e) {
            Log.w(TAG, "Discovery probe send failed: " + e.getMessage());
        }
    }

    private static boolean isDiscoverProbe(DatagramPacket packet) {
        try {
            String text = new String(packet.getData(), packet.getOffset(), packet.getLength(),
                    StandardCharsets.UTF_8);
            JSONObject json = new JSONObject(text);
            return CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))
                    && json.optBoolean("discover", false);
        } catch (Exception e) {
            return false;
        }
    }

    private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port,
            String transport, boolean listening, String pin, int placeholderId, int videoMask, int audioMask)
            throws Exception {
        byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin,
                placeholderId, videoMask, audioMask);
        InetAddress broadcast = InetAddress.getByName("255.255.255.255");
        DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT);
        sock.send(packet);
    }

    private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port,
            String transport, boolean listening, String pin, int placeholderId, int videoMask, int audioMask,
            InetAddress target) {
        try {
            byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, pin,
                    placeholderId, videoMask, audioMask);
            DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT);
            sock.send(packet);
        } catch (Exception e) {
            Log.w(TAG, "Unicast announce failed: " + e.getMessage());
        }
    }

    private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport,
            boolean listening, String pin, int placeholderId, int videoMask, int audioMask) throws Exception {
        JSONObject json = new JSONObject();
        json.put("magic", CastConfig.DISCOVERY_MAGIC);
        json.put("name", deviceName);
        json.put("port", port);
        json.put("transport", transport);
        json.put("listening", listening);
        json.put("device_uuid", DeviceInfo.getDeviceUuid(context));
        if (PinAuth.isProtected(pin)) {
            json.put("pin_auth", PinAuth.hash(pin));
        }
        byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8);
        CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask);
        try {
            return CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes);
        } catch (Exception e) {
            return jsonBytes;
        }
    }

    private void handlePacket(DatagramPacket packet) {
        try {
            byte[] raw = packet.getData();
            int off = packet.getOffset();
            int len = packet.getLength();
            String text;
            int videoCodecs = 0;
            int audioCodecs = 0;
            int placeholderId = 0;
            long protoVersion = 0L;
            int bodyOff = off;
            int bodyLen = len;
            if (len >= CastProtoHeader.HEADER_SIZE_V1 && readMagicLe(raw, off) == CastProtoHeader.MAGIC) {
                CastProtoHeader hdr = CastProtoHeader.decode(raw, off, len);
                if (hdr.verifyChecksum() && hdr.hsType == CastProtoHeader.HS_SDP) {
                    videoCodecs = hdr.videoCodecs;
                    audioCodecs = hdr.audioCodecs;
                    placeholderId = hdr.placeholderId;
                    protoVersion = hdr.protoVersion;
                }
                bodyOff = off + hdr.headerSize;
                bodyLen = len - hdr.headerSize;
                if (bodyLen > 5) {
                    byte type = raw[bodyOff];
                    int plen = readIntBe(raw, bodyOff + 1);
                    if (plen >= 0 && bodyOff + 5 + plen <= off + len) {
                        text = new String(raw, bodyOff + 5, plen, StandardCharsets.UTF_8);
                    } else {
                        text = new String(raw, bodyOff, bodyLen, StandardCharsets.UTF_8);
                    }
                } else {
                    text = new String(raw, bodyOff, bodyLen, StandardCharsets.UTF_8);
                }
            } else {
                text = new String(raw, off, len, StandardCharsets.UTF_8);
            }
            JSONObject json = new JSONObject(text);
            if (!CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic"))) {
                return;
            }
            String name = json.optString("name", "Unknown");
            int port = json.optInt("port", CastConfig.CAST_PORT);
            String transport = json.optString("transport", CastConfig.DEFAULT_TRANSPORT);
            if (!json.optBoolean("listening", true)) {
                return;
            }
            String host = packet.getAddress().getHostAddress();
            String remoteUuid = json.optString("device_uuid", "");
            if (DiscoverySelfFilter.isSelf(appContext, host, remoteUuid)) {
                String key = host + ":" + port;
                if (devices.remove(key) != null) {
                    notifyListener();
                }
                return;
            }
            String pinAuth = json.optString("pin_auth", "");
            String key = host + ":" + port;
            DiscoveredDevice device = new DiscoveredDevice(name, host, port, transport,
                    System.currentTimeMillis(), videoCodecs, audioCodecs, placeholderId, protoVersion, pinAuth,
                    remoteUuid);
            devices.put(key, device);
            notifyListener();
        } catch (Exception ignored) {
        }
    }

    private static int readMagicLe(byte[] b, int off) {
        return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8)
                | ((b[off + 2] & 0xff) << 16) | ((b[off + 3] & 0xff) << 24);
    }

    private static int readIntBe(byte[] b, int off) {
        return ((b[off] & 0xff) << 24) | ((b[off + 1] & 0xff) << 16)
                | ((b[off + 2] & 0xff) << 8) | (b[off + 3] & 0xff);
    }

    private void purgeSelfFromDeviceMap() {
        boolean changed = false;
        for (String key : new ArrayList<>(devices.keySet())) {
            DiscoveredDevice d = devices.get(key);
            if (d != null && DiscoverySelfFilter.isSelf(appContext, d.host, null)) {
                devices.remove(key);
                changed = true;
            }
        }
        if (changed) {
            notifyListener();
        }
    }

    private void notifyListener() {
        if (listener == null) {
            return;
        }
        long now = System.currentTimeMillis();
        List<DiscoveredDevice> fresh = new ArrayList<>();
        for (DiscoveredDevice d : devices.values()) {
            if (now - d.lastSeenMs < 10_000
                    && !DiscoverySelfFilter.isSelf(appContext, d.host, null)) {
                fresh.add(d);
            }
        }
        mainHandler.post(() -> listener.onDevicesUpdated(fresh));
    }

    private void acquireMulticastLock() {
        synchronized (lockGuard) {
            if (multicastLock == null) {
                WifiManager wifi = (WifiManager) appContext.getSystemService(Context.WIFI_SERVICE);
                if (wifi == null) {
                    return;
                }
                multicastLock = wifi.createMulticastLock("androidcast-discovery");
                multicastLock.setReferenceCounted(false);
            }
            if (multicastHolds == 0) {
                multicastLock.acquire();
            }
            multicastHolds++;
        }
    }

    private void startNsdBrowse() {
        if (nsdDiscovery == null) {
            nsdDiscovery = new NsdCastDiscovery(appContext, this::upsertExternal);
        }
        nsdDiscovery.startDiscovering();
    }

    private void stopNsdBrowse() {
        if (nsdDiscovery != null) {
            nsdDiscovery.stopDiscovering();
        }
    }

    private void startNsdRegister(String deviceName, int port, String transport, String pinAuth) {
        if (nsdDiscovery == null) {
            nsdDiscovery = new NsdCastDiscovery(appContext, this::upsertExternal);
        }
        nsdDiscovery.register(deviceName, port, transport, pinAuth);
    }

    public void stopNsdRegister() {
        if (nsdDiscovery != null) {
            nsdDiscovery.unregister();
        }
    }

    private void releaseMulticastLock() {
        synchronized (lockGuard) {
            if (multicastLock == null || multicastHolds <= 0) {
                return;
            }
            multicastHolds--;
            if (multicastHolds == 0) {
                try {
                    if (multicastLock.isHeld()) {
                        multicastLock.release();
                    }
                } catch (RuntimeException e) {
                    Log.w(TAG, "MulticastLock release: " + e.getMessage());
                }
            }
        }
    }
}
