package com.foxx.androidcast.network;

/*********************************************************************
 * CastProtocol.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 CastProtocol
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (10 commits, 528 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	656a5843695c1e1219882549e895e14ba560d5f6b366de72827577752b833dc2
**********************************************************************/
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.network.control.NetworkControlAdvice;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.diag.CastDiagPing;

import android.media.MediaFormat;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/** Length-prefixed binary protocol (TCP) and payload format (shared with UDP). */
public final class CastProtocol {
    public static final byte MSG_HELLO = 1;
    public static final byte MSG_AUTH = 2;
    public static final byte MSG_AUTH_OK = 3;
    public static final byte MSG_AUTH_FAIL = 4;
    public static final byte MSG_STREAM_CONFIG = 5;
    public static final byte MSG_VIDEO_FRAME = 6;
    public static final byte MSG_GOODBYE = 7;
    public static final byte MSG_AUDIO_CONFIG = 8;
    public static final byte MSG_AUDIO_FRAME = 9;
    public static final byte MSG_CAST_SETTINGS = 10;
    public static final byte MSG_NETWORK_STATS = 11;
    public static final byte MSG_NETWORK_CONTROL = 12;
    public static final byte MSG_AUDIO_CONSENT = 13;
    public static final byte MSG_CODEC_CAPS = 14;
    public static final byte MSG_CODEC_SELECTED = 15;
    /** UDP NACK: payload is big-endian {@code messageId} (4 bytes). */
    public static final byte MSG_NACK = 16;
    /** Receiver capability/settings (same payload as {@link #MSG_CAST_SETTINGS}). */
    public static final byte MSG_RECEIVER_CAST_SETTINGS = 17;
    /**
     * Diagnostic ping-pong (developer). Payload: {@link com.foxx.androidcast.network.diag.CastDiagPing}.
     * Peers on older builds ignore unknown types.
     */
    public static final byte MSG_DIAG_PING = 18;
    /** UDP outer wrapper: payload is {@link CastPacketFramer} wire bytes. */
    public static final byte MSG_WIRE_PACKET = 127;

    private CastProtocol() {}

    public static void writeMessage(DataOutputStream out, byte type, byte[] payload) throws IOException {
        out.writeByte(type);
        int len = payload == null ? 0 : payload.length;
        out.writeInt(len);
        if (len > 0) {
            out.write(payload);
        }
        out.flush();
    }

    public static Message readMessage(DataInputStream in) throws IOException {
        byte type = in.readByte();
        int len = in.readInt();
        if (len < 0 || len > 4 * 1024 * 1024) {
            throw new IOException("Invalid payload length: " + len);
        }
        byte[] payload = len == 0 ? new byte[0] : new byte[len];
        if (len > 0) {
            in.readFully(payload);
        }
        return new Message(type, payload);
    }

    public static byte[] helloPayload(String deviceName) {
        return deviceName.getBytes(StandardCharsets.UTF_8);
    }

    public static byte[] authPayload(String pin) {
        return sha256(pin).getBytes(StandardCharsets.UTF_8);
    }

    public static byte[] castSettingsPayload(CastSettings settings) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeInt(CastSettings.SETTINGS_VERSION);
        dos.writeUTF(settings.getTransport());
        dos.writeInt(settings.getQuality().ordinal());
        dos.writeInt(settings.getResolution().ordinal());
        dos.writeByte(settings.isAudioEnabled() ? 1 : 0);
        dos.writeInt(settings.getVideoCodec().ordinal());
        dos.writeInt(settings.getAdjustmentPreset().ordinal());
        dos.writeInt(settings.getBitrateMode().ordinal());
        dos.writeInt(settings.getNetworkAdoption().ordinal());
        dos.writeInt(settings.getCaptureMode().ordinal());
        dos.writeInt(settings.getStreamProtection().ordinal());
        dos.writeInt(settings.getReceiverDisplayResolution().ordinal());
        dos.writeInt(settings.getAudioCodec().ordinal());
        dos.flush();
        return bos.toByteArray();
    }

    public static CastSettings parseCastSettings(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        CastSettings settings = new CastSettings();
        dis.mark(64);
        int first = dis.readInt();
        if (first == CastSettings.SETTINGS_VERSION) {
            settings.setTransport(dis.readUTF());
            settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM));
            settings.setResolution(readEnum(CastSettings.Resolution.values(), dis.readInt(),
                    CastSettings.Resolution.HD_720P));
            settings.setAudioEnabled(dis.readByte() != 0);
            if (dis.available() > 0) {
                settings.setVideoCodec(readEnum(CastSettings.VideoCodec.values(), dis.readInt(),
                        CastSettings.VideoCodec.AUTO));
            }
            if (dis.available() > 0) {
                settings.setAdjustmentPreset(readEnum(CastSettings.AdjustmentPreset.values(), dis.readInt(),
                        CastSettings.AdjustmentPreset.AUTO));
            }
            if (dis.available() > 0) {
                settings.setBitrateMode(readEnum(CastSettings.BitrateMode.values(), dis.readInt(),
                        CastSettings.BitrateMode.AUTO));
            }
            if (dis.available() > 0) {
                settings.setNetworkAdoption(readEnum(CastSettings.NetworkAdoption.values(), dis.readInt(),
                        CastSettings.NetworkAdoption.AUTO));
            }
            if (dis.available() > 0) {
                settings.setCaptureMode(readEnum(CastSettings.CaptureMode.values(), dis.readInt(),
                        CastSettings.CaptureMode.FULL_SCREEN));
            }
            if (dis.available() > 0) {
                settings.setStreamProtection(readEnum(CastSettings.StreamProtection.values(), dis.readInt(),
                        CastSettings.StreamProtection.NONE));
            }
            if (dis.available() > 0) {
                settings.setReceiverDisplayResolution(
                        readEnum(CastSettings.ReceiverDisplayResolution.values(), dis.readInt(),
                                CastSettings.ReceiverDisplayResolution.AUTO_FIT));
            }
            if (dis.available() > 0) {
                settings.setAudioCodec(readEnum(CastSettings.AudioCodec.values(), dis.readInt(),
                        CastSettings.AudioCodec.AUTO));
            }
        } else {
            dis.reset();
            settings.setTransport(dis.readUTF());
            settings.setQuality(readEnum(CastSettings.Quality.values(), dis.readInt(), CastSettings.Quality.MEDIUM));
            settings.setResolution(readEnum(CastSettings.Resolution.values(), dis.readInt(),
                    CastSettings.Resolution.HD_720P));
            settings.setAudioEnabled(dis.readByte() != 0);
        }
        return settings;
    }

    private static <T extends Enum<T>> T readEnum(T[] values, int ordinal, T fallback) {
        if (ordinal >= 0 && ordinal < values.length) {
            return values[ordinal];
        }
        return fallback;
    }

    public static byte[] codecCapsPayload(java.util.List<String> mimeTypes) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeInt(mimeTypes != null ? mimeTypes.size() : 0);
        if (mimeTypes != null) {
            for (String mime : mimeTypes) {
                dos.writeUTF(mime);
            }
        }
        dos.flush();
        return bos.toByteArray();
    }

    public static java.util.List<String> parseCodecCaps(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        int n = dis.readInt();
        java.util.List<String> list = new java.util.ArrayList<>(Math.max(0, n));
        for (int i = 0; i < n; i++) {
            list.add(dis.readUTF());
        }
        return list;
    }

    public static byte[] codecSelectedPayload(String mime) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeUTF(mime != null ? mime : MediaFormat.MIMETYPE_VIDEO_AVC);
        dos.flush();
        return bos.toByteArray();
    }

    public static String parseCodecSelected(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        return dis.readUTF();
    }

    public static byte[] streamConfigPayload(int width, int height, byte[] csd0, byte[] csd1) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeInt(width);
        dos.writeInt(height);
        writeBytes(dos, csd0);
        writeBytes(dos, csd1);
        dos.flush();
        return bos.toByteArray();
    }

    public static byte[] audioConfigPayload(int sampleRate, int channels, byte[] csd0) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeInt(sampleRate);
        dos.writeInt(channels);
        writeBytes(dos, csd0);
        dos.flush();
        return bos.toByteArray();
    }

    public static byte[] videoFramePayload(long ptsUs, boolean keyFrame, byte[] data) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeLong(ptsUs);
        dos.writeByte(keyFrame ? 1 : 0);
        writeBytes(dos, data);
        dos.flush();
        return bos.toByteArray();
    }

    public static byte[] audioFramePayload(long ptsUs, byte[] data) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeLong(ptsUs);
        writeBytes(dos, data);
        dos.flush();
        return bos.toByteArray();
    }

    public static StreamConfig parseStreamConfig(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        int width = dis.readInt();
        int height = dis.readInt();
        byte[] csd0 = readBytes(dis);
        byte[] csd1 = readBytes(dis);
        return new StreamConfig(width, height, csd0, csd1);
    }

    public static AudioConfig parseAudioConfig(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        int sampleRate = dis.readInt();
        int channels = dis.readInt();
        byte[] csd0 = readBytes(dis);
        return new AudioConfig(sampleRate, channels, csd0);
    }

    public static VideoFrame parseVideoFrame(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        long ptsUs = dis.readLong();
        boolean keyFrame = dis.readByte() != 0;
        byte[] data = readBytes(dis);
        return new VideoFrame(ptsUs, keyFrame, data);
    }

    public static AudioFrame parseAudioFrame(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        long ptsUs = dis.readLong();
        byte[] data = readBytes(dis);
        return new AudioFrame(ptsUs, data);
    }

    public static String payloadAsUtf8(byte[] payload) {
        return new String(payload, StandardCharsets.UTF_8);
    }

    public static boolean pinMatches(byte[] authHashUtf8, String pin) {
        String expected = sha256(pin);
        String received = new String(authHashUtf8, StandardCharsets.UTF_8);
        return expected.equals(received);
    }

    public static boolean transportMatches(CastSettings local, CastSettings remote) {
        return local.getTransport().equals(remote.getTransport());
    }

    private static final byte NETWORK_STATS_VERSION = 5;

    public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeLong(s.sequence);
        dos.writeLong(s.timestampMs);
        dos.writeInt(s.rttMs);
        dos.writeFloat(s.lossRatio);
        dos.writeInt(s.sendBitrateKbps);
        dos.writeInt(s.recvBitrateKbps);
        dos.writeInt(s.congestionLevel);
        dos.writeInt(s.jitterMs);
        dos.writeByte(NETWORK_STATS_VERSION);
        dos.writeFloat(s.encodeFps);
        dos.writeInt(s.encodeQueueDepth);
        dos.writeInt(s.droppedPFrames);
        dos.writeByte(s.audioActive ? 1 : 0);
        dos.writeLong(s.audioFramesSent);
        dos.writeInt(s.suggestedBitrateKbps);
        dos.writeInt(s.targetBitrateKbps);
        if (NETWORK_STATS_VERSION >= 3) {
            dos.writeByte(Math.max(0, Math.min(100, s.audioCaptureVuPercent)));
            dos.writeLong(s.audioCaptureSilentBlocks);
            dos.writeLong(s.audioCaptureAudibleBlocks);
            dos.writeFloat(s.audioCapturePeakDb);
            dos.writeFloat(s.audioCaptureRmsDb);
        }
        if (NETWORK_STATS_VERSION >= 4) {
            dos.writeLong(s.udpDatagramsRx);
            dos.writeLong(s.udpDatagramsTx);
            dos.writeLong(s.udpDatagramsInvalid);
            dos.writeLong(s.udpFragmentsRx);
            dos.writeLong(s.udpFragmentsDuplicate);
            dos.writeLong(s.udpReassemblyOk);
            dos.writeLong(s.udpReassemblyLost);
            dos.writeLong(s.udpWireDropped);
            dos.writeLong(s.udpMalformed);
            dos.writeInt(s.droppedAudioFrames);
            dos.writeLong(s.recvVideoPackets);
            dos.writeLong(s.recvAudioPackets);
            dos.writeLong(s.recvVideoSkippedKey);
            dos.writeLong(s.recvVideoDecodeErrors);
            dos.writeLong(s.recvAudioDecodeErrors);
            dos.writeLong(s.recvAudioQueueDrops);
            dos.writeLong(s.recvDamagedVideoFrames);
            dos.writeUTF(s.protectionMode != null ? s.protectionMode : "NONE");
            dos.writeLong(s.fecStubPackets);
            dos.writeLong(s.nackStubRequests);
        }
        if (NETWORK_STATS_VERSION >= 5) {
            dos.writeLong(s.fecPacketsDecoded);
            dos.writeLong(s.fecDecodeFailures);
        }
        dos.flush();
        return bos.toByteArray();
    }

    public static NetworkStatsSnapshot parseNetworkStats(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        NetworkStatsSnapshot s = new NetworkStatsSnapshot();
        s.sequence = dis.readLong();
        s.timestampMs = dis.readLong();
        s.rttMs = dis.readInt();
        s.lossRatio = dis.readFloat();
        s.sendBitrateKbps = dis.readInt();
        s.recvBitrateKbps = dis.readInt();
        s.congestionLevel = dis.readInt();
        s.jitterMs = dis.readInt();
        if (dis.available() > 0) {
            byte ver = dis.readByte();
            if (ver >= 2) {
                s.encodeFps = dis.readFloat();
                s.encodeQueueDepth = dis.readInt();
                s.droppedPFrames = dis.readInt();
                s.audioActive = dis.readByte() != 0;
                s.audioFramesSent = dis.readLong();
                s.suggestedBitrateKbps = dis.readInt();
                s.targetBitrateKbps = dis.readInt();
                if (ver >= 3 && dis.available() > 0) {
                    s.audioCaptureVuPercent = dis.readByte() & 0xff;
                    s.audioCaptureSilentBlocks = dis.readLong();
                    s.audioCaptureAudibleBlocks = dis.readLong();
                    s.audioCapturePeakDb = dis.readFloat();
                    s.audioCaptureRmsDb = dis.readFloat();
                }
                if (ver >= 4 && dis.available() > 0) {
                    s.udpDatagramsRx = dis.readLong();
                    s.udpDatagramsTx = dis.readLong();
                    s.udpDatagramsInvalid = dis.readLong();
                    s.udpFragmentsRx = dis.readLong();
                    s.udpFragmentsDuplicate = dis.readLong();
                    s.udpReassemblyOk = dis.readLong();
                    s.udpReassemblyLost = dis.readLong();
                    s.udpWireDropped = dis.readLong();
                    s.udpMalformed = dis.readLong();
                    s.droppedAudioFrames = dis.readInt();
                    s.recvVideoPackets = dis.readLong();
                    s.recvAudioPackets = dis.readLong();
                    s.recvVideoSkippedKey = dis.readLong();
                    s.recvVideoDecodeErrors = dis.readLong();
                    s.recvAudioDecodeErrors = dis.readLong();
                    s.recvAudioQueueDrops = dis.readLong();
                    s.recvDamagedVideoFrames = dis.readLong();
                    s.protectionMode = dis.readUTF();
                    s.fecStubPackets = dis.readLong();
                    s.nackStubRequests = dis.readLong();
                }
                if (ver >= 5 && dis.available() > 0) {
                    s.fecPacketsDecoded = dis.readLong();
                    s.fecDecodeFailures = dis.readLong();
                }
            }
        }
        return s;
    }

    public static byte[] networkControlPayload(NetworkControlAdvice advice) throws IOException {
        java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
        DataOutputStream dos = new DataOutputStream(bos);
        dos.writeInt(advice.suggestedBitrateKbps);
        dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.DEFAULT_TRANSPORT);
        dos.writeByte(advice.switchTransport ? 1 : 0);
        dos.writeInt(advice.congestionLevel);
        dos.flush();
        return bos.toByteArray();
    }

    public static NetworkControl parseNetworkControl(byte[] payload) throws IOException {
        DataInputStream dis = new DataInputStream(new java.io.ByteArrayInputStream(payload));
        NetworkControl c = new NetworkControl();
        c.suggestedBitrateKbps = dis.readInt();
        c.suggestedTransport = dis.readUTF();
        c.switchTransport = dis.readByte() != 0;
        c.congestionLevel = dis.readInt();
        return c;
    }

    public static byte[] diagPingPayload(CastDiagPing.Packet packet) throws IOException {
        return CastDiagPing.encode(packet);
    }

    public static CastDiagPing.Packet parseDiagPing(byte[] payload) throws IOException {
        return CastDiagPing.decode(payload);
    }

    public static byte[] audioConsentPayload(boolean accept) {
        return new byte[]{(byte) (accept ? 1 : 0)};
    }

    public static boolean parseAudioConsent(byte[] payload) {
        return payload != null && payload.length > 0 && payload[0] != 0;
    }

    private static void writeBytes(DataOutputStream dos, byte[] data) throws IOException {
        if (data == null) {
            dos.writeInt(0);
            return;
        }
        dos.writeInt(data.length);
        if (data.length > 0) {
            dos.write(data);
        }
    }

    private static byte[] readBytes(DataInputStream dis) throws IOException {
        int len = dis.readInt();
        if (len == 0) {
            return new byte[0];
        }
        if (len < 0 || len > 2 * 1024 * 1024) {
            throw new IOException("Invalid blob length: " + len);
        }
        byte[] buf = new byte[len];
        dis.readFully(buf);
        return buf;
    }

    private static String sha256(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : hash) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }

    public static final class Message {
        public final byte type;
        public final byte[] payload;

        public Message(byte type, byte[] payload) {
            this.type = type;
            this.payload = payload;
        }
    }

    public static final class StreamConfig {
        public final int width;
        public final int height;
        public final byte[] csd0;
        public final byte[] csd1;

        public StreamConfig(int width, int height, byte[] csd0, byte[] csd1) {
            this.width = width;
            this.height = height;
            this.csd0 = csd0;
            this.csd1 = csd1;
        }
    }

    public static final class AudioConfig {
        public final int sampleRate;
        public final int channels;
        public final byte[] csd0;

        public AudioConfig(int sampleRate, int channels, byte[] csd0) {
            this.sampleRate = sampleRate;
            this.channels = channels;
            this.csd0 = csd0;
        }
    }

    public static final class VideoFrame {
        public final long ptsUs;
        public final boolean keyFrame;
        public final byte[] data;

        public VideoFrame(long ptsUs, boolean keyFrame, byte[] data) {
            this.ptsUs = ptsUs;
            this.keyFrame = keyFrame;
            this.data = data;
        }
    }

    public static final class AudioFrame {
        public final long ptsUs;
        public final byte[] data;

        public AudioFrame(long ptsUs, byte[] data) {
            this.ptsUs = ptsUs;
            this.data = data;
        }
    }

    public static final class NetworkControl {
        public int suggestedBitrateKbps;
        public String suggestedTransport;
        public boolean switchTransport;
        public int congestionLevel;
    }
}
