"""Async client runtime for CRP sessions."""

from __future__ import annotations

import asyncio
import base64
import logging
import uuid
from contextlib import suppress

from cliproom.clipboard import (
    ClipboardAdapter,
    ClipboardUnavailableError,
    create_default_adapter,
)
from cliproom.protocol import (
    ClipMessage,
    ClipboardFormat,
    ErrorMessage,
    HeartbeatMessage,
    JoinAckMessage,
    JoinMessage,
    LeaveMessage,
    decode_message_line,
    encode_message,
    hash_passphrase,
)


LOG = logging.getLogger("cliproom.client")


class ClipRoomClient:
    """Minimal CRP client for joining a session and exchanging clip events."""

    def __init__(
        self,
        *,
        server_host: str,
        server_port: int,
        session: str,
        client_name: str,
        passphrase: str = "",
    ) -> None:
        self.server_host = server_host
        self.server_port = server_port
        self.session = session
        self.client_name = client_name
        self.auth_hash = hash_passphrase(passphrase)
        self._reader: asyncio.StreamReader | None = None
        self._writer: asyncio.StreamWriter | None = None
        self._connected = False
        self._clipboard_adapter: ClipboardAdapter | None = None
        self._last_local_signature: tuple[tuple[str, str], ...] | None = None
        self._last_remote_signature: tuple[tuple[str, str], ...] | None = None
        self._last_polled_signature: tuple[tuple[str, str], ...] | None = None

    async def connect(self) -> None:
        self._reader, self._writer = await asyncio.open_connection(
            self.server_host,
            self.server_port,
        )
        join = JoinMessage(
            session=self.session,
            auth=self.auth_hash,
            client_name=self.client_name,
        )
        await self._send(encode_message(join))

        line = await self._reader.readline()
        if not line:
            raise RuntimeError("Server closed connection before join_ack")
        message = decode_message_line(line)
        if isinstance(message, JoinAckMessage):
            self._connected = True
            LOG.info("Joined session '%s' as '%s'", self.session, self.client_name)
            return
        if isinstance(message, ErrorMessage):
            raise RuntimeError(f"Server error: {message.message}")
        raise RuntimeError(f"Unexpected first message: {message.type}")

    async def send_clip_text(self, text: str) -> None:
        encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
        await self.send_clip_formats(
            [
                ClipboardFormat(
                    mime="text/plain",
                    encoding="utf-8",
                    data=encoded,
                )
            ]
        )

    async def send_clip_formats(self, formats: list[ClipboardFormat]) -> None:
        if not self._connected:
            raise RuntimeError("Client is not connected")
        clip = ClipMessage(
            session=self.session,
            message_id=str(uuid.uuid4()),
            formats=formats,
        )
        await self._send(encode_message(clip))
        self._last_local_signature = _formats_signature(formats)

    async def receive_forever(self) -> None:
        if self._reader is None:
            raise RuntimeError("Client is not connected")
        while True:
            line = await self._reader.readline()
            if not line:
                LOG.info("Server closed connection")
                return
            message = decode_message_line(line)
            if isinstance(message, ClipMessage):
                LOG.info(
                    "Received clip message_id=%s formats=%d",
                    message.message_id,
                    len(message.formats),
                )
                self._last_remote_signature = _formats_signature(message.formats)
                if self._clipboard_adapter is not None:
                    try:
                        await asyncio.to_thread(
                            self._clipboard_adapter.set_formats,
                            message.formats,
                        )
                    except ClipboardUnavailableError as exc:
                        LOG.warning("Clipboard write failed: %s", exc)
                else:
                    self._print_fallback(message.formats)
            elif isinstance(message, ErrorMessage):
                raise RuntimeError(f"Server error: {message.message}")

    async def run_forever(
        self,
        *,
        sync_clipboard: bool = True,
        poll_interval: float = 0.4,
        heartbeat_interval: float = 10.0,
    ) -> None:
        if sync_clipboard:
            self._init_clipboard_adapter()

        tasks = [asyncio.create_task(self.receive_forever(), name="receive")]
        tasks.append(
            asyncio.create_task(
                self._send_heartbeat_forever(interval=heartbeat_interval),
                name="heartbeat",
            )
        )
        if sync_clipboard and self._clipboard_adapter is not None:
            tasks.append(
                asyncio.create_task(
                    self._monitor_local_clipboard_forever(poll_interval=poll_interval),
                    name="clipboard-monitor",
                )
            )

        done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
        for task in pending:
            task.cancel()
        for task in pending:
            with suppress(asyncio.CancelledError):
                await task
        for task in done:
            exc = task.exception()
            if exc is not None:
                raise exc

    async def _send_heartbeat_forever(self, *, interval: float) -> None:
        while True:
            await asyncio.sleep(interval)
            if not self._connected:
                return
            with suppress(Exception):
                await self._send(encode_message(HeartbeatMessage()))

    async def _monitor_local_clipboard_forever(self, *, poll_interval: float) -> None:
        if self._clipboard_adapter is None:
            return
        while True:
            try:
                formats = await asyncio.to_thread(self._clipboard_adapter.get_formats)
            except ClipboardUnavailableError as exc:
                LOG.warning("Clipboard read failed: %s", exc)
                await asyncio.sleep(poll_interval)
                continue
            if not formats:
                await asyncio.sleep(poll_interval)
                continue
            signature = _formats_signature(formats)
            if signature != self._last_polled_signature:
                self._last_polled_signature = signature
                if signature != self._last_remote_signature:
                    await self.send_clip_formats(formats)
            await asyncio.sleep(poll_interval)

    def _init_clipboard_adapter(self) -> None:
        if self._clipboard_adapter is not None:
            return
        try:
            self._clipboard_adapter = create_default_adapter()
            LOG.info("Clipboard sync enabled")
        except ClipboardUnavailableError as exc:
            LOG.warning("Clipboard sync disabled: %s", exc)
            self._clipboard_adapter = None

    async def close(self) -> None:
        if self._writer is None:
            return
        with suppress(Exception):
            await self._send(encode_message(LeaveMessage()))
        self._writer.close()
        with suppress(Exception):
            await self._writer.wait_closed()
        self._writer = None
        self._reader = None
        self._connected = False

    async def _send(self, payload: bytes) -> None:
        if self._writer is None:
            raise RuntimeError("Writer is not available")
        self._writer.write(payload)
        await self._writer.drain()

    def _print_fallback(self, formats: list[ClipboardFormat]) -> None:
        for item in formats:
            if item.mime in {"text/plain", "text/html", "application/x-file-list"}:
                with suppress(Exception):
                    print(base64.b64decode(item.data).decode("utf-8"))
                    return
        if formats:
            print(f"<received {formats[0].mime}>")


def _formats_signature(formats: list[ClipboardFormat]) -> tuple[tuple[str, str], ...]:
    return tuple((item.mime, item.data) for item in formats)
