import json
import logging
import time
import websocket
import asyncio
import uuid

from threading import Lock, Thread
from typing import Dict, List, Optional
from core.config import Config
from interfaces.ws_interface import WSInterface
from utils.cipher_manager import CipherManager
from clipboard.clipboard_manager import ClipboardManager
from utils.notification_manager import NotificationManager
from utils.request_manager import RequestManager
from utils.ssl_helper import websocket_sslopt_for_config
from core.constants import *
from aiortc import (
    RTCPeerConnection,
    RTCIceServer,
    RTCIceCandidate,
    RTCSessionDescription,
    RTCConfiguration,
    RTCDataChannel,
)

if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI:
    from cli.tray import TaskbarPanel
else:
    from gui.tray import TaskbarPanel

class P2PManager(WSInterface):
    def __init__(self, config: Config, is_login_phase=True):
        self.config = config
        self.clipboard_manager = ClipboardManager(self.config)
        self.cipher_manager = CipherManager(self.config)
        self.notification_manager = NotificationManager(self.config)
        self.sys_tray: TaskbarPanel = None
        self.first_conn_lost = True
        self.is_login_phase = is_login_phase
        self.ws_client = None
        self.is_connected = False
        self.disconnected = False
        self.is_auto_reconnecting = False
        self._suppress_auto_reconnect_once = False
        self.is_clipboard_monitoring_on = False

        # Fragment variables
        self.sending_fragment_id = ""  # The id of the fragment currently being sent
        self.receiving_fragments: dict = {}  # Mapping: fragmentid:str -> fragment:list[str]
        self.sending_fragment_stats: str = None
        self.receiving_fragment_stats: str = None

        # p2p variables
        self.my_peer_id: str = None  # Own peer id assigned by the server
        self.peers: set[str] = set()  # All peers in this 'room'
        self.peer_connections: dict[str, RTCPeerConnection] = (
            {}
        )  # Mapping: peer_id -> RTCPeerConnection
        self.data_channels: dict[str, RTCDataChannel] = {}  # Mapping: peer_id -> DataChannel
        self.live_connections: int = 0  # Number of open data channels (derived; see _sync)
        self._live_connections_lock = Lock()
        # If PEER_LIST arrives before ASSIGNED_ID (e.g. right after signaling reconnect), mesh setup waits.
        self._pending_peer_list: Optional[List[str]] = None

        # True during full P2P teardown (logout / signaling reconnect prep); suppresses ICE recovery.
        self._p2p_shutting_down: bool = False
        # Serialize recover + offer handling per peer (same asyncio loop as signaling handlers).
        self._peer_recovery_locks: Dict[str, asyncio.Lock] = {}
        self._dc_heartbeat_handle: Optional[asyncio.TimerHandle] = None

        # Event loop for asyncio
        self.loop = asyncio.new_event_loop()
        self.loop_thread = Thread(
            target=self.loop.run_forever, name="P2PManagerEventLoopThread", daemon=True
        )
        self.loop_thread.start()

    def schedule_task(self, coro):
        """Submit an async function to the manager's event loop thread."""
        return asyncio.run_coroutine_threadsafe(coro, self.loop)

    def _cancel_dc_heartbeat(self) -> None:
        if self._dc_heartbeat_handle is not None:
            self._dc_heartbeat_handle.cancel()
            self._dc_heartbeat_handle = None

    def _dc_heartbeat_tick(self) -> None:
        self._dc_heartbeat_handle = None
        if self.disconnected or self._p2p_shutting_down or not self.is_connected:
            return
        ping = json.dumps({"_cc_keepalive": True})
        for ch in list(self.data_channels.values()):
            try:
                if getattr(ch, "readyState", "") == "open":
                    ch.send(ping)
            except Exception:
                pass
        self._schedule_next_dc_heartbeat()

    def _schedule_next_dc_heartbeat(self) -> None:
        if self.disconnected or self._p2p_shutting_down or not self.is_connected:
            return
        self._cancel_dc_heartbeat()
        self._dc_heartbeat_handle = self.loop.call_later(
            P2P_DC_HEARTBEAT_INTERVAL_SEC, self._dc_heartbeat_tick
        )

    def _restart_dc_heartbeat(self) -> None:
        self._cancel_dc_heartbeat()
        if not self.disconnected and not self._p2p_shutting_down and self.is_connected:
            self._schedule_next_dc_heartbeat()

    def set_tray_ref(self, sys_tray: TaskbarPanel):
        """
        Sets the system tray reference.
        """
        self.sys_tray = sys_tray
        self.clipboard_manager.set_tray_ref(sys_tray)

    def _sync_live_connections_count(self):
        """Set live_connections from open data channels (avoids +/- drift and negatives)."""
        with self._live_connections_lock:
            self.live_connections = sum(
                1 for ch in self.data_channels.values() if getattr(ch, "readyState", "") == "open"
            )

    def get_stats(self) -> str:
        self._sync_live_connections_count()
        stats = "📊"
        with self._live_connections_lock:
            n = self.live_connections
        stats += f" Peers: {n}"
        if self.sending_fragment_stats is not None:
            stats += f" | Sending: {self.sending_fragment_stats}"
        if self.receiving_fragment_stats is not None:
            stats += f" | Receiving: {self.receiving_fragment_stats}"
        return stats

    def get_total_timeout(self):
        """
        Returns the total timeout value in milliseconds."""
        return (RECONNECT_WS_TIMER * 1000) + WEBSOCKET_TIMEOUT

    def connect(self) -> tuple[bool, str]:
        """
        Connects to the P2P websocket signaling server.
        """
        try:
            if self.ws_client is not None:
                if self.is_connected:
                    return True, ""
                try:
                    self.ws_client.close()
                except Exception:
                    pass
                self.ws_client = None

            self.ws_client = websocket.WebSocketApp(
                url=self.config.data["websocket_url"],
                header={"Cookie": RequestManager.format_cookie(self.config.data["cookie"])},
                on_open=self._on_ws_open,
                on_error=self._on_ws_error,
                on_message=self._on_ws_message,
                on_close=self._on_ws_close,
            )

            ws_run_kw = {
                "ping_interval": P2P_WS_PING_INTERVAL_SEC,
                "ping_timeout": P2P_WS_PING_TIMEOUT_SEC,
            }
            ws_sslopt = websocket_sslopt_for_config(self.config)
            if ws_sslopt:
                ws_run_kw["sslopt"] = ws_sslopt
            Thread(
                target=self.ws_client.run_forever,
                kwargs=ws_run_kw,
                daemon=True,
            ).start()

            if not self.is_clipboard_monitoring_on:
                # Start clipboard monitoring
                self.is_clipboard_monitoring_on = True
                self.clipboard_manager.on_copy(self.send)

            if not self.is_login_phase:
                return True, ""

            total_ms = 0
            while self.is_connected is False:
                time.sleep(0.25)
                total_ms += 250
                if 0 < WEBSOCKET_TIMEOUT < total_ms:
                    self.ws_client.close()
                    raise TimeoutError(
                        f"Connection to {self.config.data['websocket_url']} timed out"
                    )

            return True, "Websocket connected"
        except Exception as e:
            msg = f"Failed to connect websocket: {e}"
            logging.error(msg)
            try:
                self.ws_close()
            except Exception:
                pass
            return False, msg

    def _on_ws_close(self, ws, *args):
        self.ws_client = None
        self.is_connected = False
        if self._suppress_auto_reconnect_once:
            self._suppress_auto_reconnect_once = False
            return
        # Auto Reconnect
        if not self.is_login_phase and not self.disconnected:
            self.is_auto_reconnecting = True
            if self.first_conn_lost:
                self.notification_manager.notify(
                    title=f"{APP_NAME}: WebSocket Connection Lost ⛓️‍💥",
                    message="Check your internet connection. Retrying...",
                )
                self.first_conn_lost = False
            time.sleep(RECONNECT_WS_TIMER)  # seconds
            self.connect()

    def manual_reconnect(self):
        if not self.is_auto_reconnecting:
            self.disconnected = False
            self._suppress_auto_reconnect_once = self.ws_client is not None
            self.ws_close()
            self.is_connected = False
            self.connect()

    def _on_ws_message(self, ws, message):
        self.schedule_task(self._on_ws_message_async(message))

    async def _on_ws_message_async(self, message):
        try:
            logging.debug("\n<<< " + str(message))
            data = json.loads(message)
            msg_type = data.get("type")

            if msg_type == "ASSIGNED_ID":
                if self.my_peer_id is not None and self.my_peer_id != data["peerId"]:
                    await self._cleanup_peer_connections()
                self.my_peer_id = data["peerId"]
                if self._pending_peer_list is not None:
                    pending = self._pending_peer_list
                    self._pending_peer_list = None
                    await self._handle_peer_list(pending)
            elif msg_type == "PEER_LIST":
                await self._handle_peer_list(data["peers"])
            elif msg_type == "OFFER":
                await self._handle_offer(data["fromPeerId"], data["offer"])

            elif msg_type == "ANSWER":
                await self._handle_answer(data["fromPeerId"], data["answer"])

            elif msg_type == "ICE_CANDIDATE":
                await self._handle_ice_candidate(data["fromPeerId"], data["candidate"])

        except Exception as e:
            logging.error(f"Failed to handle websocket message: {e}")

    async def _prepare_reconnect_bootstrap(self):
        """
        Runs on the asyncio signaling loop thread.
        Resets bootstrap state and tears down old mesh without clobbering newer
        ASSIGNED_ID / PEER_LIST values that may arrive during cleanup.
        """
        self.my_peer_id = None
        self._pending_peer_list = None
        await self._cleanup_peer_connections(clear_bootstrap_state=False)

    def _on_ws_open(self, ws, *args):
        try:
            if self.disconnected:
                self.disconnect()
                return

            # Tear down WebRTC after any signaling reconnect so PEER_LIST recreates PCs.
            try:
                fut = asyncio.run_coroutine_threadsafe(
                    self._prepare_reconnect_bootstrap(), self.loop
                )
                # Upper bound: N peers * P2P_PC_CLOSE_TIMEOUT + margin;
                fut.result(timeout=90)
            except Exception as e:
                logging.warning(
                    "P2P peer cleanup on signaling open: %s",
                    e if str(e) else type(e).__name__,
                )

            self.is_connected = True
            self.is_auto_reconnecting = False
            self.loop.call_soon_threadsafe(self._restart_dc_heartbeat)
            if not self.first_conn_lost:
                self.first_conn_lost = True
                self.notification_manager.notify(
                    title=f"{APP_NAME}: WebSocket Connection Restored 🔗",
                    message="Connection re-established",
                )
        except Exception as e:
            logging.error(f"Failed to open P2P signaling session: {e}")

    def _on_ws_error(self, ws, error, *args):
        logging.debug(error)

    def ws_send(self, data: dict):
        try:
            if self.ws_client is None or not self.is_connected:
                return

            message = json.dumps(data)
            logging.debug("\n>>> " + message)
            self.ws_client.send(message)
        except Exception as e:
            logging.error(f"Failed to send websocket message: {e}")

    def ws_close(self):
        if self.ws_client is not None:
            self.ws_client.close()
            self.ws_client = None

    def disconnect(self):
        self.schedule_task(self._disconnect())

    async def _disconnect(self):
        """
        Stops the clipboard manager, closes the WebSocket,
        closes all data channels, clears all peer-related data,
        and resets P2P tracking variables.
        """
        try:
            self.clipboard_manager.previous_clipboard_hash = 0
            self.disconnected = True
            self.first_conn_lost = True

            # Close the websocket
            try:
                self.ws_close()
                self.is_connected = False
                logging.info("Websocket disconnected")
            except Exception as e:
                pass  # silent catch

            # Cleanup peer connections
            await self._cleanup_peer_connections()

            # Stop the clipboard manager
            self.clipboard_manager.stop()
            self.is_clipboard_monitoring_on = False
        except Exception as e:
            logging.error(f"Failed to disconnect websocket: {e}")

    async def _cleanup_peer_connections(self, clear_bootstrap_state: bool = True):
        self._p2p_shutting_down = True
        self._cancel_dc_heartbeat()
        try:
            try:
                for dc in list(self.data_channels.values()):
                    try:
                        dc.close()
                    except Exception:
                        pass

                for pc in list(self.peer_connections.values()):
                    try:
                        await asyncio.wait_for(pc.close(), timeout=P2P_PC_CLOSE_TIMEOUT_SEC)
                    except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
                        pass
            finally:
                if clear_bootstrap_state:
                    self.my_peer_id = None
                self.peers.clear()
                self.peer_connections.clear()
                self.data_channels.clear()
                if clear_bootstrap_state:
                    self._pending_peer_list = None
                self._peer_recovery_locks.clear()
                with self._live_connections_lock:
                    self.live_connections = 0
        finally:
            self._p2p_shutting_down = False

    async def _handle_peer_list(self, peer_list):
        """
        The server has told us about the entire set of peer IDs in this 'room'.
        We create connections or data channels accordingly.
        """
        updated_peers = set(peer_list)
        # Keep room membership in sync before closing stale PCs so ICE recovery does not
        # treat removed peers as still present when data-channel close handlers run.
        self.peers = updated_peers
        await self._remove_stale_peers(updated_peers)

        if self.my_peer_id is None:
            self._pending_peer_list = list(peer_list)
            return

        # 3) For each peer, if we have no RTCPeerConnection, create one
        for pid in self.peers:
            if pid == self.my_peer_id:
                continue  # Ignore self

            if pid not in self.peer_connections:
                pc = self._create_peer_connection(pid)
                self.peer_connections[pid] = pc

                # TIE-BREAKER logic:
                # We only createOffer if we are the "lesser" peer ID (i.e. myPeerId < pid).
                # This helps avoid collisions where both sides create an offer simultaneously.
                if self.my_peer_id < pid:
                    # We are the "offerer"
                    channel = pc.createDataChannel("cliptext")
                    self.data_channels[pid] = channel
                    self._setup_data_channel(pid, channel)
                    await self._create_offer(pid)

    async def _remove_stale_peers(self, updated_peers):
        """
        Close connections and data channels for peers that no longer exist in updated_peers.
        """
        # Find which peers are no longer present
        stale_ids = set(self.peer_connections.keys()) - updated_peers
        for old_pid in stale_ids:
            logging.debug(f"Removing stale peer: {old_pid}")
            self._peer_recovery_locks.pop(old_pid, None)
            # Close data channel
            dc = self.data_channels.pop(old_pid, None)
            if dc is not None:
                try:
                    dc.close()
                except Exception:
                    pass

            # Close RTCPeerConnection
            pc = self.peer_connections.pop(old_pid, None)
            if pc is not None:
                try:
                    await asyncio.wait_for(pc.close(), timeout=P2P_PC_CLOSE_TIMEOUT_SEC)
                except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
                    pass

        self._sync_live_connections_count()

    def _get_peer_lock(self, peer_id: str) -> asyncio.Lock:
        lock = self._peer_recovery_locks.get(peer_id)
        if lock is None:
            lock = asyncio.Lock()
            self._peer_recovery_locks[peer_id] = lock
        return lock

    async def _dispose_peer_connection(self, peer_id: str) -> None:
        """Close and drop one peer's DC/PC without changing self.peers (used by recovery and offer handling)."""
        dc = self.data_channels.pop(peer_id, None)
        if dc is not None:
            try:
                dc.close()
            except Exception:
                pass
        pc = self.peer_connections.pop(peer_id, None)
        if pc is not None:
            try:
                await asyncio.wait_for(pc.close(), timeout=P2P_PC_CLOSE_TIMEOUT_SEC)
            except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
                pass
        self._sync_live_connections_count()

    async def _recover_peer_transport(
        self, remote_peer_id: str, dead_pc: Optional[RTCPeerConnection] = None
    ) -> None:
        """
        Re-establish WebRTC to a peer that is still in the room after ICE failure or data-channel loss.
        Signaling WebSocket stays up; no server change required.
        """
        if self._p2p_shutting_down or self.disconnected or self.is_login_phase:
            return
        if self.my_peer_id is None or remote_peer_id not in self.peers:
            return
        if dead_pc is not None and self.peer_connections.get(remote_peer_id) is not dead_pc:
            return

        lock = self._get_peer_lock(remote_peer_id)
        async with lock:
            if self._p2p_shutting_down or self.disconnected or self.my_peer_id is None:
                return
            if remote_peer_id not in self.peers:
                return
            if dead_pc is not None and self.peer_connections.get(remote_peer_id) is not dead_pc:
                return
            if dead_pc is None:
                ch = self.data_channels.get(remote_peer_id)
                if ch is not None and getattr(ch, "readyState", "") == "open":
                    return

            logging.info("P2P: recovering WebRTC transport to peer %s", remote_peer_id)

            await self._dispose_peer_connection(remote_peer_id)

            if (
                self._p2p_shutting_down
                or self.my_peer_id is None
                or remote_peer_id not in self.peers
            ):
                return

            pc = self._create_peer_connection(remote_peer_id)
            self.peer_connections[remote_peer_id] = pc

            if self.my_peer_id < remote_peer_id:
                channel = pc.createDataChannel("cliptext")
                self.data_channels[remote_peer_id] = channel
                self._setup_data_channel(remote_peer_id, channel)
                await self._create_offer(remote_peer_id)

    def _create_peer_connection(self, remote_peer_id: str) -> RTCPeerConnection:
        """
        Create and return a new RTCPeerConnection, wired up with ICE-candidate and data-channel handling.
        """
        pc = RTCPeerConnection(
            configuration=RTCConfiguration(
                iceServers=[RTCIceServer(urls=[self.config.data["stun_url"]])]
            )
        )

        @pc.on("icecandidate")
        def on_icecandidate(candidate: RTCIceCandidate):
            # This fires when the local side gathers an ICE candidate
            if candidate is not None:
                candidate_line = P2PManager.dict_to_ice_candidate_line(
                    {
                        "component": candidate.component,
                        "foundation": candidate.foundation,
                        "ip": candidate.ip,
                        "port": candidate.port,
                        "priority": candidate.priority,
                        "protocol": candidate.protocol,
                        "type": candidate.type,
                        "raddr": candidate.relatedAddress,
                        "rport": candidate.relatedPort,
                        "tcptype": candidate.tcpType,
                    }
                )
                self.ws_send(
                    {
                        "type": "ICE_CANDIDATE",
                        "fromPeerId": self.my_peer_id,
                        "toPeerId": remote_peer_id,
                        "candidate": {
                            "candidate": candidate_line,
                            "sdpMid": candidate.sdpMid,
                            "sdpMLineIndex": candidate.sdpMLineIndex,
                        },
                    }
                )

        @pc.on("connectionstatechange")
        def on_connectionstatechange():
            try:
                state = pc.connectionState
            except Exception:
                return
            if state in ("failed", "closed"):
                self.schedule_task(self._recover_peer_transport(remote_peer_id, pc))

        @pc.on("datachannel")
        def on_datachannel(channel):
            # This fires when the remote side creates a datachannel
            self.data_channels[remote_peer_id] = channel
            self._setup_data_channel(remote_peer_id, channel)

        return pc

    async def _create_offer(self, remote_peer_id: str):
        """
        Create an SDP offer for the specified remote peer and send it out via signaling.
        """
        pc = self.peer_connections[remote_peer_id]
        offer = await pc.createOffer()
        await pc.setLocalDescription(offer)

        self.ws_send(
            {
                "type": "OFFER",
                "fromPeerId": self.my_peer_id,
                "toPeerId": remote_peer_id,
                "offer": {
                    "type": pc.localDescription.type,
                    "sdp": pc.localDescription.sdp,
                },
            }
        )

    async def _handle_offer(self, from_peer_id: str, offer: dict):
        """
        Respond to an incoming OFFER from a remote peer.
        Always replaces any existing RTCPeerConnection for this peer before applying
        the SDP: recovery sends a full new offer; applying it on an already-connected
        PC leaves ICE/datachannel mismatched (remote shows Peers: 1, local 0).
        """
        if self._p2p_shutting_down:
            return
        # Ignore delayed/stale OFFERs for peers no longer present in the latest PEER_LIST.
        # Startup guard: allow early signaling before ASSIGNED_ID/PEER_LIST is established.
        if self.my_peer_id is not None and self.peers and from_peer_id not in self.peers:
            logging.debug("Ignoring stale OFFER for removed peer: %s", from_peer_id)
            return
        lock = self._get_peer_lock(from_peer_id)
        async with lock:
            # TOCTOU guard: PEER_LIST can change after the outer fast-path check.
            if self.my_peer_id is not None and self.peers and from_peer_id not in self.peers:
                logging.debug("Ignoring stale OFFER for removed peer (in-lock): %s", from_peer_id)
                return
            if from_peer_id in self.peer_connections:
                await self._dispose_peer_connection(from_peer_id)
            pc = self._create_peer_connection(from_peer_id)
            self.peer_connections[from_peer_id] = pc

            desc = RTCSessionDescription(sdp=offer["sdp"], type=offer["type"])
            await pc.setRemoteDescription(desc)

            answer = await pc.createAnswer()
            await pc.setLocalDescription(answer)

            self.ws_send(
                {
                    "type": "ANSWER",
                    "fromPeerId": self.my_peer_id,
                    "toPeerId": from_peer_id,
                    "answer": {
                        "type": pc.localDescription.type,
                        "sdp": pc.localDescription.sdp,
                    },
                }
            )

    async def _handle_answer(self, from_peer_id: str, answer: dict):
        """
        Handle an incoming ANSWER to our previously sent OFFER.
        """
        pc = self.peer_connections.get(from_peer_id)
        if not pc:
            logging.warning(f"No peer connection exists for id={from_peer_id}, ignoring ANSWER.")
            return
        if getattr(pc, "connectionState", "") in ("failed", "closed"):
            logging.debug("Ignoring ANSWER for dead peer connection: %s", from_peer_id)
            return
        desc = RTCSessionDescription(sdp=answer["sdp"], type=answer["type"])
        await pc.setRemoteDescription(desc)

    async def _handle_ice_candidate(self, from_peer_id: str, candidate_data: dict):
        """
        Handle an incoming ICE candidate from the remote peer.
        """
        pc = self.peer_connections.get(from_peer_id)
        if not pc:
            logging.warning(
                f"No peer connection exists for id={from_peer_id}, ignoring ICE_CANDIDATE."
            )
            return
        if getattr(pc, "connectionState", "") in ("failed", "closed"):
            logging.debug("Ignoring ICE candidate for dead peer connection: %s", from_peer_id)
            return

        parsed_candidate = P2PManager.parse_ice_candidate_line(candidate_data.get("candidate"))
        ice_candidate = RTCIceCandidate(
            component=parsed_candidate.get("component"),
            foundation=parsed_candidate.get("foundation"),
            ip=parsed_candidate.get("ip"),
            port=parsed_candidate.get("port"),
            priority=parsed_candidate.get("priority"),
            protocol=parsed_candidate.get("protocol"),
            type=parsed_candidate.get("type"),
            relatedAddress=parsed_candidate.get("raddr"),
            relatedPort=parsed_candidate.get("rport"),
            sdpMid=candidate_data.get("sdpMid"),
            sdpMLineIndex=candidate_data.get("sdpMLineIndex"),
            tcpType=parsed_candidate.get("tcptype"),
        )
        await pc.addIceCandidate(ice_candidate)

    def _setup_data_channel(self, remote_peer_id: str, channel: RTCDataChannel):
        """
        Set up handlers for an RTCDataChannel (open, message, close, error).
        """

        @channel.on("open")
        def on_open():
            self._sync_live_connections_count()

        if channel.readyState == "open":
            self._sync_live_connections_count()

        @channel.on("message")
        def on_message(message):
            self._receive(message)

        @channel.on("close")
        def on_close():
            self._sync_live_connections_count()
            self.schedule_task(self._recover_peer_transport(remote_peer_id, None))

        @channel.on("error")
        def on_error(e):
            logging.error(f"[data] Data channel error with {remote_peer_id}: {e}")
            self._sync_live_connections_count()

    def send(self, payload: str, payload_type: str = "text"):
        self.schedule_task(self._send(payload, payload_type))

    def reset_sending_fragment_id(self):
        self.sending_fragment_id = ""
        self.sending_fragment_stats = None

    async def _send(self, payload: str, payload_type: str = "text"):
        try:
            if self.clipboard_manager.has_clipboard_changed(payload):
                self.reset_sending_fragment_id()
                self.reset_receiving_fragments()

                raw_payload_size_in_bytes = len(payload.encode("utf-8"))

                if self.config.data["cipher_enabled"]:
                    payload = CipherManager.encode_to_json_string(
                        **self.cipher_manager.encrypt(payload)
                    )

                fragments = P2PManager.fragment_string(payload)
                metadata = {
                    "id": str(uuid.uuid4()),
                    "isFragmented": len(fragments) > 1,
                    "index": 0,
                    "totalFragments": len(fragments),
                    "combinedRawPayloadSizeInBytes": raw_payload_size_in_bytes,
                }

                self.sending_fragment_id = metadata["id"]
                for fragment in fragments:
                    if self.sending_fragment_id != metadata["id"]:
                        break

                    body = json.dumps(
                        {
                            "payload": fragment,
                            "type": payload_type,
                            "metadata": metadata,
                        }
                    )
                    metadata["index"] += 1

                    # Send to all open DataChannels
                    for peer_id, channel in self.data_channels.items():
                        if channel.readyState == "open":
                            channel.send(body)

                    if metadata["isFragmented"]:
                        self.sending_fragment_stats = (
                            f"{metadata['index']}/{metadata['totalFragments']}"
                        )
                else:
                    self.reset_sending_fragment_id()

        except Exception as e:
            logging.error(f"Failed to send data: {e}")

    def reset_receiving_fragments(self):
        self.receiving_fragments = {}
        self.receiving_fragment_stats = None

    def _receive(self, frame: any) -> str:
        try:
            body = json.loads(frame)
            if isinstance(body, dict) and body.get("_cc_keepalive") is True:
                return
            self.reset_sending_fragment_id()
            payload = body["payload"]
            payload_type = body.get("type", "text")
            metadata = body.get("metadata")

            # Check if the payload exceeds the maximum size: first layer protection
            if (
                metadata is not None
                and self.config.data["max_clipboard_size_local_limit_bytes"] is not None
                and metadata["combinedRawPayloadSizeInBytes"]
                > self.config.data["max_clipboard_size_local_limit_bytes"]
            ):
                self.reset_receiving_fragments()
                logging.debug(
                    f"Payload size limit exceeded: {metadata['combinedRawPayloadSizeInBytes']} bytes exceeds {self.config.data['max_clipboard_size_local_limit_bytes']} bytes"
                )
                return

            # Fragmented message handling
            if metadata is not None and metadata["isFragmented"]:
                self.receiving_fragment_stats = (
                    f"{metadata['index'] + 1}/{metadata['totalFragments']}"
                )
                if metadata["id"] in self.receiving_fragments:
                    self.receiving_fragments[metadata["id"]][metadata["index"]] = payload
                    if metadata["index"] == metadata["totalFragments"] - 1:
                        if all(s != "" for s in self.receiving_fragments[metadata["id"]]):
                            payload = "".join(self.receiving_fragments[metadata["id"]])
                        else:
                            self.reset_receiving_fragments()
                            logging.error(
                                "Failed to receive: One or more fragments are missing or clipboard has changed."
                            )
                            return
                    else:
                        return
                else:
                    self.reset_receiving_fragments()
                    self.receiving_fragments[metadata["id"]] = [""] * metadata["totalFragments"]
                    self.receiving_fragments[metadata["id"]][metadata["index"]] = payload
                    return

            if self.config.data["cipher_enabled"]:
                payload = self.cipher_manager.decrypt(
                    **CipherManager.decode_from_json_string(payload)
                )

            if self.clipboard_manager.has_clipboard_changed(payload):
                self.reset_receiving_fragments()
                self.clipboard_manager.base64_to_clipboard(
                    base64_string=payload, type_=payload_type
                )
        except json.decoder.JSONDecodeError:
            logging.error("If cipher is enabled, please make sure it is enabled on all devices")
        except Exception as e:
            logging.error(f"Failed to receive data: {e}")

    @staticmethod
    def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE) -> list[str]:
        """
        Splits a string into a list of fragments, each with a maximum size of `fragment_size` bytes.

        Args:
            s (str): The string to fragment.
            fragment_size (int): The maximum size of each fragment in bytes.

        Returns:
            list[str]: A list of string fragments.
        """
        # Encode the string to bytes to accurately split by byte size
        s_bytes = s.encode("utf-8")
        fragments = [
            s_bytes[i : i + fragment_size].decode("utf-8", errors="ignore")
            for i in range(0, len(s_bytes), fragment_size)
        ]
        return fragments

    @staticmethod
    def parse_ice_candidate_line(candidate_line: str) -> dict:
        """
        Parse a full ICE candidate line into its constituent fields.

        Returns a dict with keys:
            foundation, component, protocol, priority,
            ip, port, type,
            raddr, rport, tcptype, generation, ufrag, network-id, network-cost,
            plus any other unrecognized "key value" pairs.

        Example ICE candidate line:
            "candidate:3365442476 1 udp 2113939711 4c76248e-9690-49db-9043-6cd01cc3a9b4.local 59478 typ host generation 0 ufrag HoZP network-cost 999"
        """
        candidate_line = candidate_line.strip()
        tokens = candidate_line.split()

        # 1) Must start with something like "candidate:..."
        if not tokens or not tokens[0].startswith("candidate:"):
            raise ValueError("Not a valid ICE candidate line (must start with 'candidate:').")

        # 2) Basic mandatory fields (RFC 5245)
        #
        #    candidate:<foundation> <component> <protocol> <priority> <ip> <port> typ <candType>
        #    e.g.:
        #    "candidate:3365442476 1 udp 2113939711 192.168.1.10 5000 typ host ..."
        foundation = tokens[0].split(":", 1)[1]
        component = int(tokens[1])
        protocol = tokens[2].lower()
        priority = int(tokens[3])
        ip = tokens[4]
        port = int(tokens[5])

        # Next token should be "typ"
        if tokens[6] != "typ":
            raise ValueError(
                f"Expected 'typ' after port but found '{tokens[6]}' in ICE candidate line."
            )

        candidate_type = tokens[7]

        # 3) Optional fields come after index 8, usually as "key value" pairs
        #    e.g. "raddr 1.2.3.4 rport 1234 tcptype active generation 0 ufrag ABCD network-cost 999"
        extras = {}
        idx = 8
        while idx < len(tokens):
            key = tokens[idx]
            # If there's no "value" after this key, store None and break.
            if idx + 1 >= len(tokens):
                extras[key] = None
                break
            value = tokens[idx + 1]

            # Store it
            extras[key] = value
            idx += 2

        # 4) Pull out commonly known keys if they exist in extras
        raddr = extras.pop("raddr", None)
        rport = extras.pop("rport", None)
        tcptype = extras.pop("tcptype", None)
        generation = extras.pop("generation", None)
        ufrag = extras.pop("ufrag", None)
        network_id = extras.pop("network-id", None)
        network_cost = extras.pop("network-cost", None)

        # 5) Build final dictionary
        candidate_dict = {
            "foundation": foundation,
            "component": component,
            "protocol": protocol,
            "priority": priority,
            "ip": ip,
            "port": port,
            "type": candidate_type,
            "raddr": raddr,
            "rport": rport,
            "tcptype": tcptype,
            "generation": generation,
            "ufrag": ufrag,
            "network-id": network_id,
            "network-cost": network_cost,
            # Put anything else we didn't explicitly handle into 'extras'
            "extras": extras,
        }

        return candidate_dict

    @staticmethod
    def dict_to_ice_candidate_line(candidate_dict: dict) -> str:
        """
        Convert a dictionary of ICE candidate fields back into the ICE candidate line string.

        Expects a dict with at least the following keys:
            - foundation (str)
            - component (int)
            - protocol (str)
            - priority (int)
            - ip (str)
            - port (int)
            - type (str)

        The known optional keys are:
            - raddr, rport, tcptype, generation, ufrag
            - network-id, network-cost
        These will be appended as "key value" pairs if they exist (and are not None).

        Additionally, any leftover entries in candidate_dict["extras"] are appended at the end.

        Returns a line starting with "candidate:..."
        """
        foundation = candidate_dict["foundation"]
        component = candidate_dict["component"]
        protocol = candidate_dict["protocol"]
        priority = candidate_dict["priority"]
        ip = candidate_dict["ip"]
        port = candidate_dict["port"]
        candidate_type = candidate_dict["type"]

        line_parts = [
            f"candidate:{foundation}",
            str(component),
            protocol.lower(),
            str(priority),
            ip,
            str(port),
            "typ",
            candidate_type,
        ]

        # Append known optional fields if present.
        # (Only add if the value is not None)
        if candidate_dict.get("raddr") is not None:
            line_parts += ["raddr", str(candidate_dict["raddr"])]

        if candidate_dict.get("rport") is not None:
            line_parts += ["rport", str(candidate_dict["rport"])]

        if candidate_dict.get("tcptype") is not None:
            line_parts += ["tcptype", str(candidate_dict["tcptype"])]

        if candidate_dict.get("generation") is not None:
            line_parts += ["generation", str(candidate_dict["generation"])]

        if candidate_dict.get("ufrag") is not None:
            line_parts += ["ufrag", str(candidate_dict["ufrag"])]

        if candidate_dict.get("network-id") is not None:
            line_parts += ["network-id", str(candidate_dict["network-id"])]

        if candidate_dict.get("network-cost") is not None:
            line_parts += ["network-cost", str(candidate_dict["network-cost"])]

        # Finally, any 'extras' are appended at the end
        extras = candidate_dict.get("extras", {})
        for k, v in extras.items():
            line_parts.append(str(k))
            if v is not None:
                line_parts.append(str(v))

        return " ".join(line_parts)
