package com.foxx.androidcast.network;

/*********************************************************************
 * TcpCastTransport.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 TcpCastTransport
 * Contributors:
 *    - Anton Afanasyeu <a.afanasieff@gmail.com> (7 commits, 288 lines)
 *    - Cursor Agent (project assistant)
 * Digest: SHA256	770c47dffaaba48aaf909248ab441601a93ae94870e55d577486fd388cd07966
**********************************************************************/
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;

/** Reliable message transport over TCP (serialized send/receive). */
public class TcpCastTransport implements CastTransport {
    private static final int CONNECT_TIMEOUT_MS = 10_000;
    private static final int LEGACY_HEADER_BYTES = 5;

    private final Object sendLock = new Object();
    private CastFramingContext framingContext;
    private CastSessionGate inboundSessionGate;
    private final Object receiveLock = new Object();

    private ServerSocket serverSocket;
    private Socket socket;
    private BufferedInputStream bufferedIn;
    private DataInputStream in;
    private DataOutputStream out;

    /** True once {@link #listen} has bound the server socket (port is accepting connections). */
    public boolean isListening() {
        return serverSocket != null && !serverSocket.isClosed();
    }

    @Override
    public void prepareListen(int port) throws IOException {
        ensureServerSocket(port);
    }

    @Override
    public void acceptClient() throws IOException {
        acceptClientConnection();
    }

    @Override
    public void listen(int port) throws IOException {
        prepareListen(port);
        acceptClient();
    }

    private void ensureServerSocket(int port) throws IOException {
        if (serverSocket != null && !serverSocket.isClosed()) {
            return;
        }
        serverSocket = new ServerSocket();
        serverSocket.setReuseAddress(true);
        serverSocket.bind(new InetSocketAddress(InetAddress.getByName("0.0.0.0"), port));
    }

    private void acceptClientConnection() throws IOException {
        releaseConnection();
        socket = serverSocket.accept();
        socket.setTcpNoDelay(true);
        openStreams();
    }

    @Override
    public void connect(String host, int port) throws IOException {
        InetAddress address = InetAddress.getByName(host);
        socket = new Socket();
        socket.setTcpNoDelay(true);
        socket.connect(new InetSocketAddress(address, port), CONNECT_TIMEOUT_MS);
        openStreams();
    }

    private void openStreams() throws IOException {
        bufferedIn = new BufferedInputStream(socket.getInputStream(), 256 * 1024);
        in = new DataInputStream(bufferedIn);
        out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
    }

    @Override
    public void setFramingContext(CastFramingContext context) {
        this.framingContext = context;
    }

    @Override
    public void setInboundSessionGate(CastSessionGate gate) {
        this.inboundSessionGate = gate;
    }

    @Override
    public void send(byte type, byte[] payload) throws IOException {
        synchronized (sendLock) {
            if (socket == null || socket.isClosed() || out == null) {
                throw new IOException("Socket closed");
            }
            if (framingContext != null) {
                CastProtoHeader hdr = framingContext.newHeader(CastProtoHeader.HS_DEFAULT);
                byte[] wire = CastPacketFramer.frame(hdr, type, payload);
                out.write(wire);
                out.flush();
            } else {
                CastProtocol.writeMessage(out, type, payload);
            }
        }
    }

    /**
     * Waits up to {@code timeoutMs} for data, then reads one full message without a socket read timeout.
     * Avoids partial reads that desync the stream (root cause of "Invalid payload length" garbage).
     */
    @Override
    public CastProtocol.Message receive(int timeoutMs) throws IOException {
        synchronized (receiveLock) {
            if (socket == null || socket.isClosed() || in == null) {
                return null;
            }
            if (timeoutMs > 0 && !waitForHeader(timeoutMs)) {
                return null;
            }
            try {
                socket.setSoTimeout(0);
                if (framingContext != null) {
                    return readFramedMessage();
                }
                return CastProtocol.readMessage(in);
            } catch (SocketTimeoutException e) {
                return null;
            } catch (java.io.EOFException e) {
                return null;
            } catch (java.net.SocketException e) {
                if ("Connection reset".equals(e.getMessage()) || "Socket closed".equals(e.getMessage())) {
                    return null;
                }
                throw e;
            }
        }
    }

    private CastProtocol.Message readFramedMessage() throws IOException {
        if (inboundSessionGate == null) {
            return CastPacketFramer.readFramedMessage(bufferedIn);
        }
        byte[] magicBuf = readFully(bufferedIn, 4);
        if (magicBuf == null) {
            return null;
        }
        int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8)
                | ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24);
        if (magic != CastProtoHeader.MAGIC) {
            throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic));
        }
        byte[] restHdr = readFully(bufferedIn, CastProtoHeader.HEADER_SIZE_V1 - 4);
        if (restHdr == null) {
            return null;
        }
        byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1];
        System.arraycopy(magicBuf, 0, headerBytes, 0, 4);
        System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length);
        CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length);
        if (!hdr.verifyChecksum()) {
            throw new IOException("Header checksum mismatch");
        }
        if (!inboundSessionGate.accept(hdr)) {
            skipMessageBody(in);
            return null;
        }
        byte type = (byte) in.read();
        if (type < 0) {
            return null;
        }
        byte[] lenBuf = readFully(bufferedIn, 4);
        if (lenBuf == null) {
            return null;
        }
        int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
                | ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
        if (len < 0 || len > 4 * 1024 * 1024) {
            throw new IOException("Invalid payload length: " + len);
        }
        byte[] payload = len == 0 ? new byte[0] : readFully(bufferedIn, len);
        if (payload == null && len > 0) {
            return null;
        }
        return new CastProtocol.Message(type, payload);
    }

    private static void skipMessageBody(DataInputStream in) throws IOException {
        byte type = (byte) in.read();
        if (type < 0) {
            return;
        }
        byte[] lenBuf = readFully(in, 4);
        if (lenBuf == null) {
            return;
        }
        int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16)
                | ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff);
        if (len > 0 && len <= 4 * 1024 * 1024) {
            in.skipBytes(len);
        }
    }

    private static byte[] readFully(InputStream in, int n) throws IOException {
        byte[] buf = new byte[n];
        int off = 0;
        while (off < n) {
            int r = in.read(buf, off, n - off);
            if (r < 0) {
                if (off == 0) {
                    return null;
                }
                throw new java.io.EOFException("Short read");
            }
            off += r;
        }
        return buf;
    }

    private boolean waitForHeader(int timeoutMs) throws IOException {
        int need = framingContext != null ? CastProtoHeader.HEADER_SIZE_V1 : LEGACY_HEADER_BYTES;
        long deadline = System.currentTimeMillis() + timeoutMs;
        while (System.currentTimeMillis() < deadline) {
            if (bufferedIn.available() >= need) {
                return true;
            }
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
        return bufferedIn.available() >= need;
    }

    @Override
    public String getModeLabel() {
        return "TCP";
    }

    @Override
    public void releaseConnection() {
        synchronized (sendLock) {
            synchronized (receiveLock) {
                closeClientOnly();
            }
        }
    }

    @Override
    public void close() {
        synchronized (sendLock) {
            synchronized (receiveLock) {
                closeClientOnly();
                if (serverSocket != null) {
                    try {
                        serverSocket.close();
                    } catch (Exception ignored) {
                    }
                    serverSocket = null;
                }
            }
        }
    }

    private void closeClientOnly() {
        try {
            if (socket != null) {
                socket.close();
            }
        } catch (Exception ignored) {
        }
        socket = null;
        bufferedIn = null;
        in = null;
        out = null;
    }
}
