"""RouterOS API client – synchronous, asynchronous and listener modes.

Quick start::

    import routeros as ros

    with ros.dial("192.168.1.1:8728", "admin", "") as c:
        reply = c.run("/ip/address/print")
        for sentence in reply.re:
            print(sentence.map)

TLS::

    import ssl, routeros as ros

    ctx = ssl.create_default_context()
    ctx.load_verify_locations("ca.pem")
    with ros.dial_tls("192.168.1.1:8729", "admin", "", tls_context=ctx) as c:
        ...

Source address binding::

    with ros.dial("192.168.1.1:8728", "admin", "",
                  source_address=("10.0.0.5", 0)) as c:
        ...
"""

from __future__ import annotations

import hashlib
import logging
import socket
import ssl
import threading
from typing import Dict, List, Optional, Tuple

from routeros.errors import (
    AlreadyAsyncError,
    AsyncLoopEndedError,
    InvalidChallengeReceivedError,
    NoChallengeReceivedError,
)
from routeros.proto import Reader, Writer
from routeros.reply import ListenReply, Reply
from routeros.sentence import Sentence

log = logging.getLogger("routeros")


# ---------------------------------------------------------------------------
# Internal async-reply bookkeeping
# ---------------------------------------------------------------------------


class _AsyncReplySlot:
    """Holds an in-flight async request waiting for its reply."""

    def __init__(self) -> None:
        self._reply = Reply()
        self._event = threading.Event()
        self._err: Optional[Exception] = None

    def process_sentence(self, sen: Sentence) -> tuple[bool, Optional[Exception]]:
        done, err = self._reply.process_sentence(sen)
        return done, err

    def close(self, err: Optional[Exception]) -> None:
        if self._err is None and err is not None:
            self._err = err
        self._event.set()

    def wait(self) -> Tuple[Reply, Optional[Exception]]:
        self._event.wait()
        return self._reply, self._err


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------


class Client:
    """RouterOS API client.

    Do not instantiate directly – use :func:`dial` or :func:`dial_tls`.

    The client supports three operation modes:

    **Synchronous** (default):
        :meth:`run` / :meth:`run_args` block until the device sends
        ``!done``.  Only one in-flight request at a time.

    **Asynchronous**:
        Call :meth:`async_start` once.  A background daemon thread reads
        incoming sentences and routes them to the matching request via a
        string tag.  Multiple in-flight requests are supported.

    **Listener**:
        :meth:`listen` / :meth:`listen_args` automatically enable async
        mode and return a :class:`~routeros.reply.ListenReply`
        that streams ``!re`` sentences via a :class:`queue.Queue`.
    """

    def __init__(
        self,
        sock: socket.socket,
        *,
        logger: Optional[logging.Logger] = None,
        queue_size: int = 0,
    ) -> None:
        self._sock = sock
        self._logger = logger or log
        self._queue_size = queue_size

        self._reader = Reader(sock)
        self._writer = Writer(sock)

        self._mu = threading.Lock()
        self._async = False
        self._closing = False
        self._next_tag = 0
        self._tags: Optional[Dict[str, object]] = None  # None ⇒ not yet async

    # ------------------------------------------------------------------
    # Synchronous API
    # ------------------------------------------------------------------

    def run(self, *words: str) -> Reply:
        """Send *words* as a sentence and wait for the complete reply."""
        return self.run_args(list(words))

    def run_args(self, words: List[str]) -> Reply:
        """Send *words* as a sentence and wait for the complete reply."""
        self._logger.debug(">>> %s", words)

        with self._mu:
            is_async = self._async

        if not is_async:
            return self._run_sync(words)
        return self._run_async(words)

    # ------------------------------------------------------------------
    # Async API
    # ------------------------------------------------------------------

    def async_start(self) -> None:
        """Start the background reader thread (async mode).

        Safe to call only once.  Raises :class:`AlreadyAsyncError` if
        called a second time.
        """
        with self._mu:
            if self._async:
                raise AlreadyAsyncError()
            self._async = True
            self._tags = {}

        t = threading.Thread(target=self._async_loop, daemon=True, name="routeros-async")
        t.start()
        self._logger.debug("async loop started")

    # ------------------------------------------------------------------
    # Listener API
    # ------------------------------------------------------------------

    def listen(self, *words: str, queue_size: int = 0) -> ListenReply:
        """Send *words* and stream ``!re`` replies indefinitely."""
        return self.listen_args(list(words), queue_size=queue_size)

    def listen_args(self, words: List[str], *, queue_size: int = 0) -> ListenReply:
        """Send *words* and stream ``!re`` replies indefinitely."""
        qs = queue_size or self._queue_size
        self._logger.debug("listen >>> %s", words)

        # Auto-enable async mode for listeners.
        with self._mu:
            if not self._async:
                self._async = True
                self._tags = {}
                t = threading.Thread(
                    target=self._async_loop, daemon=True, name="routeros-async"
                )
                t.start()
                self._logger.debug("async loop auto-started for listener")

        tag = self._make_tag("l")
        listen_reply = ListenReply(self, tag, queue_size=qs)

        tag_words = words + [f".tag={tag}"]
        self._logger.debug("listen tag=%s", tag)

        with self._mu:
            if self._tags is None:
                raise AsyncLoopEndedError()
            self._tags[tag] = listen_reply

        self._writer.write_sentence(tag_words)
        return listen_reply

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def close(self) -> None:
        """Close the connection to the RouterOS device."""
        with self._mu:
            if self._closing:
                return
            self._closing = True
        try:
            self._sock.close()
        except OSError:
            pass
        self._logger.debug("connection closed")

    def __enter__(self) -> "Client":
        return self

    def __exit__(self, *_: object) -> None:
        self.close()

    # ------------------------------------------------------------------
    # Login (called by dial / dial_tls)
    # ------------------------------------------------------------------

    def _login(self, username: str, password: str) -> None:
        """Authenticate against the RouterOS device.

        Automatically detects whether to use the single-stage (post-6.43)
        cleartext method or the two-stage (pre-6.43) MD5 challenge-response
        method.
        """
        reply = self.run_args(["/login", f"=name={username}", f"=password={password}"])

        ret = reply.done.map.get("ret") if reply.done else None

        if ret is None:
            # Post-6.43: single-stage cleartext – we're already logged in.
            if reply.done is not None:
                self._logger.debug("login ok, mode=post-6.43 (cleartext)")
                return
            raise NoChallengeReceivedError()

        # Pre-6.43: two-stage MD5 challenge-response.
        try:
            challenge = bytes.fromhex(ret)
        except ValueError as exc:
            raise InvalidChallengeReceivedError(str(exc)) from exc

        response = self._challenge_response(challenge, password)
        self.run_args(["/login", f"=name={username}", f"=response={response}"])
        self._logger.debug("login ok, mode=pre-6.43 (MD5 challenge)")

    def _challenge_response(self, challenge: bytes, password: str) -> str:
        """Compute the MD5 challenge-response string."""
        h = hashlib.md5()
        h.update(b"\x00")
        h.update(password.encode("utf-8"))
        h.update(challenge)
        return "00" + h.hexdigest()

    # ------------------------------------------------------------------
    # Internal sync run
    # ------------------------------------------------------------------

    def _run_sync(self, words: List[str]) -> Reply:
        """Send *words* and block reading until ``!done`` (sync mode)."""
        self._writer.write_sentence(words)

        out = Reply()
        last_err: Optional[Exception] = None
        while True:
            sen = self._reader.read_sentence()
            self._logger.debug("<<< %s", sen)
            done, err = out.process_sentence(sen)
            if err is not None and done:
                raise err
            if err is not None:
                last_err = err
            elif done:
                if last_err is not None:
                    raise last_err
                return out

    # ------------------------------------------------------------------
    # Internal async run
    # ------------------------------------------------------------------

    def _run_async(self, words: List[str]) -> Reply:
        """Send *words* with a tag and wait for the tagged reply (async mode)."""
        tag = self._make_tag("r")
        slot = _AsyncReplySlot()

        with self._mu:
            if self._tags is None:
                raise AsyncLoopEndedError()
            self._tags[tag] = slot

        tagged_words = words + [f".tag={tag}"]
        self._logger.debug("async tag=%s", tag)
        self._writer.write_sentence(tagged_words)

        reply, err = slot.wait()
        if err is not None:
            raise err
        return reply

    # ------------------------------------------------------------------
    # Async loop (background thread)
    # ------------------------------------------------------------------

    def _async_loop(self) -> None:
        """Background thread: read sentences and dispatch by tag."""
        try:
            while True:
                try:
                    sen = self._reader.read_sentence()
                except OSError as exc:
                    self._close_tags(exc)
                    return
                except EOFError as exc:
                    self._close_tags(exc)
                    return

                self._logger.debug("<<< %s", sen)

                with self._mu:
                    handler = self._tags.get(sen.tag) if self._tags else None

                if handler is None:
                    self._logger.warning("async loop: no handler for tag %r", sen.tag)
                    continue

                done, err = handler.process_sentence(sen)  # type: ignore[union-attr]
                if done or err is not None:
                    with self._mu:
                        if self._tags is not None:
                            self._tags.pop(sen.tag, None)
                    _close_handler(handler, err)
        except Exception as exc:
            self._close_tags(exc)

    def _close_tags(self, err: Optional[Exception]) -> None:
        """Notify all pending handlers that the connection is gone."""
        with self._mu:
            tags = self._tags
            self._tags = None

        if not tags:
            return

        with self._mu:
            closing = self._closing

        for handler in tags.values():
            _close_handler(handler, None if closing else err)

    # ------------------------------------------------------------------
    # Tag generation
    # ------------------------------------------------------------------

    def _make_tag(self, prefix: str) -> str:
        with self._mu:
            self._next_tag += 1
            n = self._next_tag
        return f"{prefix}{n}"


# ---------------------------------------------------------------------------
# Handler close helper
# ---------------------------------------------------------------------------


def _close_handler(handler: object, err: Optional[Exception]) -> None:
    """Call ``close(err)`` on *handler* if it exposes that method."""
    closer = getattr(handler, "close", None)
    if callable(closer):
        closer(err)


# ---------------------------------------------------------------------------
# Public factory functions
# ---------------------------------------------------------------------------


def _parse_address(address: str) -> Tuple[str, int]:
    """Split ``"host:port"`` → ``(host, port)``.

    Handles IPv6 addresses enclosed in brackets (e.g. ``"[::1]:8728"``).
    """
    if address.startswith("["):
        # IPv6: [::1]:8728
        bracket_end = address.index("]")
        host = address[1:bracket_end]
        port = int(address[bracket_end + 2 :])
    elif ":" in address:
        last_colon = address.rfind(":")
        host = address[:last_colon]
        port = int(address[last_colon + 1 :])
    else:
        raise ValueError(f"cannot parse address {address!r}: expected host:port")
    return host, port


def dial(
    address: str,
    username: str,
    password: str,
    *,
    timeout: Optional[float] = None,
    source_address: Optional[Tuple[str, int]] = None,
    logger: Optional[logging.Logger] = None,
) -> Client:
    """Connect and log in to a RouterOS device over plain TCP.

    Args:
        address:        ``"host:port"`` of the RouterOS device
                        (default API port is 8728).
        username:       RouterOS username.
        password:       RouterOS password.
        timeout:        Optional socket timeout in seconds.
        source_address: Optional ``(host, port)`` tuple to bind the local
                        socket before connecting.  Use port ``0`` to let
                        the OS choose an ephemeral port.
        logger:         Optional :class:`logging.Logger` instance.  If
                        omitted the library-wide ``"routeros"`` logger is
                        used.

    Returns:
        An authenticated :class:`Client`.

    Raises:
        :class:`OSError`: if the connection fails.
        :class:`~routeros.errors.LoginError`: if authentication fails.
    """
    host, port = _parse_address(address)
    sock = socket.create_connection(
        (host, port),
        timeout=timeout,
        source_address=source_address,
    )
    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
    client = Client(sock, logger=logger)
    client._login(username, password)
    return client


def dial_tls(
    address: str,
    username: str,
    password: str,
    *,
    tls_context: Optional[ssl.SSLContext] = None,
    timeout: Optional[float] = None,
    source_address: Optional[Tuple[str, int]] = None,
    logger: Optional[logging.Logger] = None,
) -> Client:
    """Connect and log in to a RouterOS device over TLS/SSL.

    Args:
        address:        ``"host:port"`` of the RouterOS device
                        (default TLS API port is 8729).
        username:       RouterOS username.
        password:       RouterOS password.
        tls_context:    Optional :class:`ssl.SSLContext`.  If omitted,
                        :func:`ssl.create_default_context` is used
                        (verifies the server certificate).
        timeout:        Optional socket timeout in seconds.
        source_address: Optional ``(host, port)`` tuple to bind locally.
        logger:         Optional :class:`logging.Logger` instance.

    Returns:
        An authenticated :class:`Client`.
    """
    host, port = _parse_address(address)
    ctx = tls_context or _routeros_tls_context()

    raw = socket.create_connection(
        (host, port),
        timeout=timeout,
        source_address=source_address,
    )
    raw.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
    sock = ctx.wrap_socket(raw, server_hostname=host)

    client = Client(sock, logger=logger)
    client._login(username, password)
    return client


def _routeros_tls_context() -> ssl.SSLContext:
    """Build a permissive SSLContext suitable for RouterOS self-signed certs.

    RouterOS typically uses TLSv1.2 with RSA key-exchange cipher suites and
    a self-signed certificate.  Python 3.10+ defaults reject these; this
    context relaxes security level while still encrypting the connection.

    Pass your own ``tls_context`` to :func:`dial_tls` for stricter settings
    (e.g. certificate verification with a trusted CA).
    """
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    try:
        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
    except AttributeError:
        pass
    try:
        ctx.set_ciphers("DEFAULT:@SECLEVEL=1")
    except ssl.SSLError:
        pass
    return ctx
