/*
 * Copyright (c) 2010-2026 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.openhab.binding.dahuadoor.internal.media;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketException;
import java.net.SocketTimeoutException;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Relays local go2rtc backchannel RTP packets to the currently active SIP audio target.
 *
 * <p>
 * go2rtc currently uses ffmpeg to transcode browser backchannel audio to RTP PCM/16 kHz on a local
 * loopback port. This relay forwards those RTP packets to the negotiated SIP target, preferring
 * PCM/16000 passthrough and falling back to G.711 PCMA/PCMU transcoding when needed.
 * </p>
 *
 * @author Sven Schad - Initial contribution
 */
@NonNullByDefault
public class SipBackchannelRtpRelay {

    private static final Logger LOGGER = LoggerFactory.getLogger(SipBackchannelRtpRelay.class);

    private static final int MAX_PACKET_SIZE = 2048;
    private static final int RTP_HEADER_MIN_LEN = 12;
    private static final int RTP_VERSION_MASK = 0xC0;
    private static final int RTP_VERSION_2 = 0x80;
    private static final int RTP_TIMESTAMP_OFFSET = 4;
    private static final String CODEC_PCM = "PCM";
    private static final String CODEC_PCMA = "PCMA";
    private static final String CODEC_PCMU = "PCMU";
    private static final int PCM_CLOCK_RATE = 16000;
    private static final int G711_CLOCK_RATE = 8000;
    private static final int ULAW_CLIP = 32635;
    private static final int[] SEGMENT_ENDS = { 0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF };

    static final class RelayTarget {
        final InetAddress remoteAddress;
        final int remotePort;
        final int payloadType;
        final String codecName;
        final int clockRate;

        RelayTarget(InetAddress remoteAddress, int remotePort, int payloadType, String codecName, int clockRate) {
            this.remoteAddress = remoteAddress;
            this.remotePort = remotePort;
            this.payloadType = payloadType;
            this.codecName = codecName;
            this.clockRate = clockRate;
        }
    }

    private final String label;
    private final int listenPort;
    private final int sourcePort;
    private final Object lifecycleLock = new Object();

    private volatile @Nullable RelayTarget relayTarget;

    private @Nullable DatagramSocket receiveSocket;
    private @Nullable DatagramSocket sendSocket;
    private @Nullable Thread workerThread;
    private volatile int resolvedSourcePort;

    private volatile boolean running;
    private volatile long receivedPackets;
    private volatile long forwardedPackets;
    private volatile long droppedNoTargetPackets;
    private volatile long droppedInvalidPackets;
    private volatile long lastIncomingPacketAtMillis;
    private volatile long targetSetAtMillis;
    private volatile long lastNoInputWarningAtMillis;

    public SipBackchannelRtpRelay(String label, int listenPort, int sourcePort) {
        this.label = label;
        this.listenPort = listenPort;
        this.sourcePort = sourcePort;
        this.resolvedSourcePort = sourcePort;
    }

    public int getListenPort() {
        return listenPort;
    }

    public int getSourcePort() {
        DatagramSocket localSendSocket = sendSocket;
        if (localSendSocket != null) {
            return localSendSocket.getLocalPort();
        }
        return resolvedSourcePort;
    }

    public void start() throws IOException {
        synchronized (lifecycleLock) {
            if (running) {
                return;
            }

            DatagramSocket localReceiveSocket = new DatagramSocket(new InetSocketAddress("127.0.0.1", listenPort));
            localReceiveSocket.setSoTimeout(500);
            DatagramSocket localSendSocket = new DatagramSocket(sourcePort);
            resolvedSourcePort = localSendSocket.getLocalPort();

            running = true;
            receivedPackets = 0;
            forwardedPackets = 0;
            droppedNoTargetPackets = 0;
            droppedInvalidPackets = 0;
            lastIncomingPacketAtMillis = System.currentTimeMillis();
            targetSetAtMillis = 0;
            lastNoInputWarningAtMillis = 0;
            receiveSocket = localReceiveSocket;
            sendSocket = localSendSocket;

            Thread worker = new Thread(this::runLoop, "dahua-sip-backchannel-relay-" + listenPort);
            worker.setDaemon(true);
            worker.start();
            workerThread = worker;

            LOGGER.info("SIP backchannel RTP relay '{}' started on 127.0.0.1:{} with source port {}", label, listenPort,
                    resolvedSourcePort);
        }
    }

    public void stop() {
        Thread localWorker;
        DatagramSocket localReceiveSocket;
        DatagramSocket localSendSocket;

        synchronized (lifecycleLock) {
            if (!running) {
                relayTarget = null;
                return;
            }

            running = false;
            relayTarget = null;

            localWorker = workerThread;
            workerThread = null;

            localReceiveSocket = receiveSocket;
            receiveSocket = null;

            localSendSocket = sendSocket;
            sendSocket = null;
        }

        if (localReceiveSocket != null) {
            localReceiveSocket.close();
        }
        if (localSendSocket != null) {
            localSendSocket.close();
        }
        if (localWorker != null) {
            localWorker.interrupt();
            try {
                localWorker.join(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        LOGGER.info(
                "SIP backchannel RTP relay '{}' stopped (listenPort={}, sourcePort={}, received={}, forwarded={}, droppedNoTarget={}, droppedInvalid={})",
                label, listenPort, getSourcePort(), receivedPackets, forwardedPackets, droppedNoTargetPackets,
                droppedInvalidPackets);
    }

    public void setTarget(@Nullable SipAudioOffer offer, String reason) {
        if (offer == null) {
            relayTarget = null;
            targetSetAtMillis = 0;
            lastNoInputWarningAtMillis = 0;
            LOGGER.info("SIP backchannel relay '{}' target cleared (reason={})", label, reason);
            return;
        }

        try {
            String codecName = offer.getCodecName();
            int clockRate = offer.getClockRate();
            if (!isSupportedTarget(codecName, clockRate)) {
                relayTarget = null;
                LOGGER.warn(
                        "SIP backchannel relay '{}' target rejected (expected PCM/16000 or PCMA/PCMU 8000, got codec={} rate={}Hz, reason={})",
                        label, codecName, clockRate, reason);
                return;
            }

            InetAddress address = InetAddress.getByName(offer.getRemoteHost());
            relayTarget = new RelayTarget(address, offer.getRemotePort(), offer.getPayloadType(), codecName, clockRate);
            targetSetAtMillis = System.currentTimeMillis();
            lastNoInputWarningAtMillis = 0;
            LOGGER.info("SIP backchannel relay '{}' target set to {}:{} (pt={}, codec={}, rate={}Hz, reason={})", label,
                    offer.getRemoteHost(), offer.getRemotePort(), offer.getPayloadType(), codecName, clockRate, reason);
        } catch (IOException e) {
            relayTarget = null;
            LOGGER.warn("Failed to resolve SIP backchannel relay '{}' target '{}': {}", label, offer.getRemoteHost(),
                    e.getMessage());
        }
    }

    private void runLoop() {
        byte[] buffer = new byte[MAX_PACKET_SIZE];

        while (running) {
            DatagramSocket localReceiveSocket = receiveSocket;
            DatagramSocket localSendSocket = sendSocket;
            if (localReceiveSocket == null || localSendSocket == null) {
                return;
            }

            DatagramPacket incoming = new DatagramPacket(buffer, buffer.length);
            try {
                localReceiveSocket.receive(incoming);
            } catch (SocketTimeoutException e) {
                RelayTarget target = relayTarget;
                if (target != null) {
                    long now = System.currentTimeMillis();
                    if (now - targetSetAtMillis >= 2000 && now - lastIncomingPacketAtMillis >= 2000
                            && now - lastNoInputWarningAtMillis >= 2000) {
                        LOGGER.debug(
                                "SIP backchannel relay '{}' has active target {}:{} but no incoming RTP on 127.0.0.1:{} for {} ms",
                                label, target.remoteAddress.getHostAddress(), target.remotePort, listenPort,
                                now - lastIncomingPacketAtMillis);
                        lastNoInputWarningAtMillis = now;
                    }
                }
                continue;
            } catch (SocketException e) {
                if (running) {
                    LOGGER.debug("SIP backchannel relay '{}' receive socket closed: {}", label, e.getMessage());
                }
                continue;
            } catch (IOException e) {
                if (running) {
                    LOGGER.debug("SIP backchannel relay '{}' receive failed: {}", label, e.getMessage());
                }
                continue;
            }

            receivedPackets++;
            lastIncomingPacketAtMillis = System.currentTimeMillis();
            RelayTarget target = relayTarget;
            if (target == null) {
                droppedNoTargetPackets++;
                if (droppedNoTargetPackets == 1 || droppedNoTargetPackets % 100 == 0) {
                    LOGGER.debug(
                            "SIP backchannel relay '{}' dropping RTP packet without target (count={}, listenPort={})",
                            label, droppedNoTargetPackets, listenPort);
                }
                continue;
            }

            byte[] forwardedPacket = buildForwardPacket(incoming, target);
            if (forwardedPacket == null) {
                droppedInvalidPackets++;
                if (droppedInvalidPackets == 1 || droppedInvalidPackets % 100 == 0) {
                    LOGGER.debug("SIP backchannel relay '{}' dropped invalid RTP packet (count={}, length={})", label,
                            droppedInvalidPackets, incoming.getLength());
                }
                continue;
            }

            DatagramPacket forwarded = new DatagramPacket(forwardedPacket, forwardedPacket.length, target.remoteAddress,
                    target.remotePort);
            try {
                localSendSocket.send(forwarded);
                forwardedPackets++;
                if (forwardedPackets == 1 || forwardedPackets % 100 == 0) {
                    LOGGER.debug(
                            "SIP backchannel relay '{}' forwarded RTP packet (count={}, bytes={}, listenPort={}, sourcePort={}, target={}:{}, pt={}, codec={}, rate={}Hz)",
                            label, forwardedPackets, forwardedPacket.length, listenPort, getSourcePort(),
                            target.remoteAddress.getHostAddress(), target.remotePort, target.payloadType,
                            target.codecName, target.clockRate);
                }
            } catch (IOException e) {
                LOGGER.debug("SIP backchannel relay '{}' forward failed to {}:{}: {}", label, target.remoteAddress,
                        target.remotePort, e.getMessage());
            }
        }
    }

    static byte @Nullable [] buildForwardPacket(DatagramPacket incoming, RelayTarget target) {
        int length = incoming.getLength();
        if (length < RTP_HEADER_MIN_LEN) {
            return null;
        }

        byte[] packet = new byte[length];
        System.arraycopy(incoming.getData(), incoming.getOffset(), packet, 0, length);
        int payloadOffset = getRtpPayloadOffset(packet, length);
        if (payloadOffset < 0 || payloadOffset >= length) {
            return null;
        }

        if (CODEC_PCM.equals(target.codecName) && target.clockRate == PCM_CLOCK_RATE) {
            rewriteRtpPayloadType(packet, target.payloadType);
            return packet;
        }

        if ((CODEC_PCMA.equals(target.codecName) || CODEC_PCMU.equals(target.codecName))
                && target.clockRate == G711_CLOCK_RATE) {
            return transcodePcm16LeToG711(packet, payloadOffset, length, target);
        }

        return null;
    }

    private static boolean isSupportedTarget(String codecName, int clockRate) {
        return CODEC_PCM.equals(codecName) && clockRate == PCM_CLOCK_RATE
                || (CODEC_PCMA.equals(codecName) || CODEC_PCMU.equals(codecName)) && clockRate == G711_CLOCK_RATE;
    }

    private static byte @Nullable [] transcodePcm16LeToG711(byte[] packet, int payloadOffset, int length,
            RelayTarget target) {
        int payloadLength = length - payloadOffset;
        if (payloadLength < 4 || (payloadLength % 4) != 0) {
            return null;
        }

        int sampleCount = payloadLength / 2;
        int outputSamples = sampleCount / 2;
        if (outputSamples <= 0) {
            return null;
        }

        byte[] transcoded = new byte[payloadOffset + outputSamples];
        System.arraycopy(packet, 0, transcoded, 0, payloadOffset);

        for (int outputIndex = 0; outputIndex < outputSamples; outputIndex++) {
            int inputIndex = payloadOffset + (outputIndex * 4);
            short pcmSample = (short) ((packet[inputIndex] & 0xFF) | (packet[inputIndex + 1] << 8));
            transcoded[payloadOffset + outputIndex] = CODEC_PCMA.equals(target.codecName) ? linearToALaw(pcmSample)
                    : linearToMuLaw(pcmSample);
        }

        rewriteRtpPayloadType(transcoded, target.payloadType);
        rewriteRtpTimestamp(transcoded, readRtpTimestamp(packet) / 2L);
        return transcoded;
    }

    private static long readRtpTimestamp(byte[] packet) {
        return ((packet[RTP_TIMESTAMP_OFFSET] & 0xFFL) << 24) | ((packet[RTP_TIMESTAMP_OFFSET + 1] & 0xFFL) << 16)
                | ((packet[RTP_TIMESTAMP_OFFSET + 2] & 0xFFL) << 8) | (packet[RTP_TIMESTAMP_OFFSET + 3] & 0xFFL);
    }

    private static void rewriteRtpTimestamp(byte[] packet, long timestamp) {
        packet[RTP_TIMESTAMP_OFFSET] = (byte) ((timestamp >>> 24) & 0xFF);
        packet[RTP_TIMESTAMP_OFFSET + 1] = (byte) ((timestamp >>> 16) & 0xFF);
        packet[RTP_TIMESTAMP_OFFSET + 2] = (byte) ((timestamp >>> 8) & 0xFF);
        packet[RTP_TIMESTAMP_OFFSET + 3] = (byte) (timestamp & 0xFF);
    }

    private static byte linearToALaw(short sample) {
        int pcmValue = sample;
        int mask = 0xD5;
        if (pcmValue < 0) {
            mask = 0x55;
            pcmValue = -pcmValue - 1;
        }

        int segment = searchSegment(pcmValue);
        if (segment >= 8) {
            return (byte) (0x7F ^ mask);
        }

        int alawValue = segment << 4;
        if (segment < 2) {
            alawValue |= (pcmValue >> 4) & 0x0F;
        } else {
            alawValue |= (pcmValue >> (segment + 3)) & 0x0F;
        }
        return (byte) (alawValue ^ mask);
    }

    private static byte linearToMuLaw(short sample) {
        int pcmValue = sample;
        int sign = (pcmValue >> 8) & 0x80;
        if (sign != 0) {
            pcmValue = -pcmValue;
        }

        if (pcmValue > ULAW_CLIP) {
            pcmValue = ULAW_CLIP;
        }

        pcmValue += 0x84;
        int exponent = 7;
        for (int exponentMask = 0x4000; (pcmValue & exponentMask) == 0 && exponent > 0; exponentMask >>= 1) {
            exponent--;
        }

        int mantissa = (pcmValue >> (exponent + 3)) & 0x0F;
        return (byte) ~(sign | (exponent << 4) | mantissa);
    }

    private static int searchSegment(int pcmValue) {
        for (int index = 0; index < SEGMENT_ENDS.length; index++) {
            if (pcmValue <= SEGMENT_ENDS[index]) {
                return index;
            }
        }
        return SEGMENT_ENDS.length;
    }

    static void rewriteRtpPayloadType(byte[] packet, int payloadType) {
        int markerBit = packet[1] & 0x80;
        packet[1] = (byte) (markerBit | (payloadType & 0x7F));
    }

    static int getRtpPayloadOffset(byte[] packet, int length) {
        if (length < RTP_HEADER_MIN_LEN) {
            return -1;
        }
        if ((packet[0] & RTP_VERSION_MASK) != RTP_VERSION_2) {
            return -1;
        }

        int csrcCount = packet[0] & 0x0F;
        boolean hasExtension = (packet[0] & 0x10) != 0;
        int offset = RTP_HEADER_MIN_LEN + (csrcCount * 4);

        if (hasExtension) {
            if (length < offset + 4) {
                return -1;
            }
            int extensionWords = ((packet[offset + 2] & 0xFF) << 8) | (packet[offset + 3] & 0xFF);
            offset += 4 + (extensionWords * 4);
        }

        return offset <= length ? offset : -1;
    }
}
