"""Telnet server shell implementations."""

from __future__ import annotations

# std imports
import types
import asyncio
from typing import Any, Union, Optional, Coroutine, Generator

# 3rd party
from wcwidth import wcswidth as _wcswidth
from wcwidth import iter_graphemes_reverse as _iter_graphemes_reverse
from wcwidth.escape_sequences import ZERO_WIDTH_PATTERN as _ZERO_WIDTH_PATTERN

# local
from . import slc, telopt, accessories
from .stream_reader import TelnetReader, TelnetReaderUnicode
from .stream_writer import TelnetWriter, TelnetWriterUnicode

CR, LF, NUL = ("\r", "\n", "\x00")
ESC = "\x1b"

# Characters after ESC that start multi-byte output sequences.
# Fe sequences (ESC + 0x40-0x5F) that overlap with these starters
# are not matched as 2-byte sequences, avoiding premature consumption
# of a CSI, OSC, DCS, APC, PM, or charset designation start.
_SEQ_STARTERS = frozenset("[])P_^(")

# SS3 (ESC O) introduces 3-byte input sequences: F1-F4 (ESC O P-S),
# application-mode arrows (ESC O A-D), and keypad keys (ESC O j-y, M, X).
# wcwidth's ZERO_WIDTH_PATTERN matches ESC O as a 2-byte Fe sequence
# (0x4F is in Fe range 0x40-0x5F), missing the third byte.  We handle
# SS3 explicitly since it is an input sequence, not an output sequence.
_SS3 = "O"


def _write(writer: Union[TelnetWriter, TelnetWriterUnicode], data: str) -> None:
    """Write string data to writer in the appropriate type (str or bytes)."""
    if writer.is_binary_writer:
        writer.write(data.encode("latin-1"))
    else:
        writer.write(data)  # type: ignore[arg-type]


def _echo(writer: Union[TelnetWriter, TelnetWriterUnicode], data: str) -> None:
    """Echo string data to writer in the appropriate type (str or bytes)."""
    if writer.is_binary_writer:
        writer.echo(data.encode("latin-1"))
    else:
        writer.echo(data)  # type: ignore[arg-type]


async def filter_ansi(
    reader: Union[TelnetReader, TelnetReaderUnicode],
    _writer: Union[TelnetWriter, TelnetWriterUnicode],
) -> str:
    """
    Read and return the next non-ANSI-escape character from reader.

    When wcwidth is available, handles CSI, OSC, DCS, APC, PM, charset designation, Fe, Fp, and SS3
    sequences. Otherwise falls back to CSI and SS3 only.
    """
    binary = reader.is_binary_reader

    def _rch() -> "Coroutine[Any, Any, Union[str, bytes]]":
        """Read one character."""
        return reader.read(1)

    async def _next() -> str:
        ch = await _rch()
        if not ch:
            return ""
        return ch.decode("latin-1") if binary else ch  # type: ignore[return-value]

    char = await _next()

    while True:
        if char != ESC:
            return char

        next_char = await _next()
        if not next_char:
            return ""

        if next_char == _SS3:
            # SS3: ESC O + one final byte (F1-F4, keypad, app-mode arrows).
            # Handled before wcwidth's ZERO_WIDTH_PATTERN which would match
            # ESC O as a 2-byte Fe sequence, missing the third byte.
            await _rch()  # consume SS3 final byte
            char = await _next()
            if not char:
                return ""
            continue

        buf = ESC + next_char
        if next_char in _SEQ_STARTERS:
            # Multi-byte: CSI, OSC, DCS, APC, PM, or charset
            while len(buf) < 256:
                seq_char = await _next()
                if not seq_char:
                    break
                buf += seq_char
                match = _ZERO_WIDTH_PATTERN.match(buf)
                # Skip spurious 2-byte Fe matches on the
                # ESC+starter prefix -- the real sequence is
                # longer (CSI 3+, charset 3, OSC/DCS/APC/PM 4+)
                if match and match.end() > 2:
                    if match.end() < len(buf):
                        return buf[match.end()]
                    break
        else:
            # Check for 2-byte Fe/Fp sequence
            match = _ZERO_WIDTH_PATTERN.match(buf)
            if not match:
                return next_char

        char = await _next()
        if not char:
            return ""


def _backspace_grapheme(command: str) -> tuple[str, str]:
    """Remove last grapheme cluster, return (new_command, echo_str)."""
    if not command:
        return command, ""
    last = next(_iter_graphemes_reverse(command))
    new_command = command[: len(command) - len(last)]
    w = int(_wcswidth(last))
    w = max(w, 1)
    return new_command, "\b \b" * w


def _visible_width(text: str) -> int:
    """Return visible display width of text."""
    result = int(_wcswidth(text))
    return max(0, result)


class _LineEditor:
    """Shared line-editing state machine for readline and readline_async."""

    def __init__(self, max_visible_width: int = 0) -> None:
        self.command: str = ""
        self.last_char: str = ""
        self.max_visible_width: int = max_visible_width

    def feed(self, char: str) -> tuple[str, Optional[str]]:
        """Feed one character, return (echo_str, command_or_none)."""
        # LF/NUL after CR: silently consume
        if char in (LF, NUL) and self.last_char == CR:
            self.last_char = char
            return "", None

        # Line terminator (CR or LF)
        if char in (CR, LF):
            self.last_char = char
            cmd = self.command
            self.command = ""
            return "", cmd

        # Backspace
        if char in ("\b", "\x7f"):
            self.last_char = char
            if self.command:
                self.command, echo = _backspace_grapheme(self.command)
                return echo, None
            return "", None

        # Regular character -- check max_visible_width
        self.last_char = char
        if self.max_visible_width and _visible_width(self.command + char) > self.max_visible_width:
            return "", None
        self.command += char
        return char, None


__all__ = (
    "telnet_server_shell",
    "readline_async",
    "readline",
    "get_linemode",
    "get_slcdata",
    "do_toggle",
)


async def telnet_server_shell(
    reader: Union[TelnetReader, TelnetReaderUnicode],
    writer: Union[TelnetWriter, TelnetWriterUnicode],
) -> None:
    """
    A default telnet shell, appropriate for use with telnetlib3.create_server.

    This shell provides a very simple REPL, allowing introspection and state toggling of the
    connected client session.
    """
    ssl_obj = writer.get_extra_info("ssl_object")
    if ssl_obj is not None:
        version = ssl_obj.version() or "TLS"
        _write(writer, f"Ready (secure: {version})." + CR + LF)
    else:
        _write(writer, "Ready." + CR + LF)

    command = None
    while not writer.is_closing():
        if command:
            _write(writer, CR + LF)
        _write(writer, "tel:sh> ")
        if not getattr(writer.protocol, "never_send_ga", False):
            writer.send_ga()
        await writer.drain()

        command = await readline_async(reader, writer)
        if command is None:
            return
        _write(writer, CR + LF)

        if command == "quit":
            # server hangs up on client
            _write(writer, "Goodbye." + CR + LF)
            break
        if command == "help":
            _write(writer, "quit, writer, slc, linemode, toggle [option|all], reader, proto, dump")
        elif command == "writer":
            # show 'writer' status
            _write(writer, repr(writer))
        elif command == "reader":
            # show 'reader' status
            _write(writer, repr(reader))
        elif command == "proto":
            # show 'proto' details of writer
            _write(writer, repr(writer.protocol))
        elif command == "version":
            _write(writer, accessories.get_version())
        elif command == "slc":
            # show 'slc' support and data tables
            _write(writer, get_slcdata(writer))
        elif command == "linemode":
            _write(writer, get_linemode(writer))
        elif command.startswith("toggle"):
            # toggle specified options
            option = command[len("toggle ") :] or None
            _write(writer, do_toggle(writer, option))
        elif command.startswith("dump"):
            # dump [kb] [ms_delay] [drain|nodrain] [close|noclose]
            #
            # this allows you to experiment with the effects of
            # 'drain', and, some longer-running programs that check
            # for early break through writer.is_closing().
            try:
                kb_limit = int(command.split()[1])
            except (ValueError, IndexError):
                kb_limit = 1000
            try:
                delay = int(float(command.split()[2]) / 1000)
            except (ValueError, IndexError):
                delay = 0
            # experiment with large sizes and 'nodrain', the server
            # pretty much locks up and stops talking to new clients.
            try:
                drain = command.split()[3].lower() == "nodrain"
            except IndexError:
                drain = True
            try:
                do_close = command.split()[4].lower() == "close"
            except IndexError:
                do_close = False
            msg = f"kb_limit={kb_limit}, delay={delay}," f" drain={drain}, do_close={do_close}:\r\n"
            _write(writer, msg)
            for lineout in character_dump(kb_limit):
                if writer.is_closing():
                    break
                _write(writer, lineout)
                if drain:
                    await writer.drain()
                if delay:
                    await asyncio.sleep(delay)

            if not writer.is_closing():
                _write(writer, f"\r\n{kb_limit} OK")
            if do_close:
                break
        elif command:
            _write(writer, "no such command.")
    writer.close()


def character_dump(kb_limit: int) -> Generator[str, None, None]:
    """Generate character dump output up to kb_limit kilobytes."""
    num_bytes = 0
    while (num_bytes) < (kb_limit * 1024):
        for char in ("/", "\\"):
            lineout = (char * 80) + "\033[1G"
            yield lineout
            num_bytes += len(lineout)
    yield "\033[1G" + "wrote " + str(num_bytes) + " bytes"


@types.coroutine
def readline(
    _reader: Union[TelnetReader, TelnetReaderUnicode],
    writer: Union[TelnetWriter, TelnetWriterUnicode],
    max_visible_width: int = 0,
) -> Generator[Optional[str], str, None]:
    """
    Blocking readline using generator yield/send protocol.

    Characters are fed in via ``send()`` and complete lines are yielded.
    Uses ``_LineEditor`` for grapheme-aware backspace and max_visible_width
    support.
    """
    editor = _LineEditor(max_visible_width=max_visible_width)
    inp = yield None
    while True:
        echo, cmd = editor.feed(inp)
        if echo:
            if writer.is_binary_writer:
                writer.echo(echo.encode("latin-1"))
            else:
                writer.echo(echo)  # type: ignore[arg-type]
        inp = yield cmd


async def readline_async(
    reader: Union[TelnetReader, TelnetReaderUnicode],
    writer: Union[TelnetWriter, TelnetWriterUnicode],
    max_visible_width: int = 0,
) -> Optional[str]:
    """
    Async readline that filters ANSI escape sequences.

    Uses ``filter_ansi()`` to strip escape sequences and
    ``_LineEditor`` for grapheme-aware backspace and max_visible_width support.
    """
    editor = _LineEditor(max_visible_width=max_visible_width)
    while True:
        next_char = await filter_ansi(reader, writer)
        if not next_char:
            return None
        # Skip leading LF/NUL on empty buffer -- accounts for
        # CR+LF pairs split across successive readline_async calls
        if next_char in (LF, NUL) and not editor.command:
            continue
        echo, cmd = editor.feed(next_char)
        if echo:
            if writer.is_binary_writer:
                writer.echo(echo.encode("latin-1"))
            else:
                writer.echo(echo)  # type: ignore[arg-type]
        if cmd is not None:
            return cmd


readline2 = readline_async


def get_slcdata(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> str:
    """Display Special Line Editing (SLC) characters."""
    _slcs = sorted(
        [
            f"{slc.name_slc_command(slc_func):>15}: {slc_def}"
            for (slc_func, slc_def) in sorted(writer.slctab.items())
            if not (slc_def.nosupport or slc_def.val == slc.theNULL)
        ]
    )
    _unset = sorted(
        [
            slc.name_slc_command(slc_func)
            for (slc_func, slc_def) in sorted(writer.slctab.items())
            if slc_def.val == slc.theNULL
        ]
    )
    _nosupport = sorted(
        [
            slc.name_slc_command(slc_func)
            for (slc_func, slc_def) in sorted(writer.slctab.items())
            if slc_def.nosupport
        ]
    )

    return (
        "Special Line Characters:\r\n"
        + "\r\n".join(_slcs)
        + "\r\nUnset by client: "
        + ", ".join(_unset)
        + "\r\nNot supported by server: "
        + ", ".join(_nosupport)
    )


def get_linemode(writer: Union[TelnetWriter, TelnetWriterUnicode]) -> str:
    """Display current LINEMODE negotiation state."""
    active = writer.remote_option.enabled(telopt.LINEMODE)
    if not active:
        return "LINEMODE not negotiated."
    lm = writer.linemode
    bits = (
        f"EDIT={'on' if lm.edit else 'off'}"
        f" TRAPSIG={'on' if lm.trapsig else 'off'}"
        f" SOFT_TAB={'on' if lm.soft_tab else 'off'}"
        f" LIT_ECHO={'on' if lm.lit_echo else 'off'}"
        f" ACK={'on' if lm.ack else 'off'}"
    )
    return f"LINEMODE active. Mode: {writer.mode}\r\n{bits}"


def do_toggle(writer: Union[TelnetWriter, TelnetWriterUnicode], option: Optional[str]) -> str:
    """Display or toggle telnet session parameters."""
    linemode_active = writer.remote_option.enabled(telopt.LINEMODE)
    tbl_opt = {
        "echo": writer.local_option.enabled(telopt.ECHO),
        "goahead": not writer.local_option.enabled(telopt.SGA),
        "outbinary": writer.outbinary,
        "inbinary": writer.inbinary,
        "binary": writer.outbinary and writer.inbinary,
        "xon-any": writer.xon_any,
        "lflow": writer.lflow,
        "linemode": linemode_active,
        "linemode-edit": writer.linemode.edit if linemode_active else False,
        "linemode-trapsig": writer.linemode.trapsig if linemode_active else False,
    }

    if not option:
        return "\r\n".join(
            f"{opt} {'ON' if enabled else 'off'}" for opt, enabled in sorted(tbl_opt.items())
        )

    msgs = []
    if option in ("echo", "all"):
        cmd = telopt.WONT if tbl_opt["echo"] else telopt.WILL
        writer.iac(cmd, telopt.ECHO)
        msgs.append(f"{telopt.name_command(cmd).lower()} echo.")

    if option in ("goahead", "all"):
        cmd = telopt.WILL if tbl_opt["goahead"] else telopt.WONT
        writer.iac(cmd, telopt.SGA)
        msgs.append(f"{telopt.name_command(cmd).lower()}" " suppress go-ahead.")

    if option in ("outbinary", "binary", "all"):
        cmd = telopt.WONT if tbl_opt["outbinary"] else telopt.WILL
        writer.iac(cmd, telopt.BINARY)
        msgs.append(f"{telopt.name_command(cmd).lower()} outbinary.")

    if option in ("inbinary", "binary", "all"):
        cmd = telopt.DONT if tbl_opt["inbinary"] else telopt.DO
        writer.iac(cmd, telopt.BINARY)
        msgs.append(f"{telopt.name_command(cmd).lower()} inbinary.")

    if option in ("xon-any", "all"):
        writer.xon_any = not tbl_opt["xon-any"]
        writer.send_lineflow_mode()
        msgs.append(f"xon-any {'en' if writer.xon_any else 'dis'}abled.")

    if option in ("lflow", "all"):
        writer.lflow = not tbl_opt["lflow"]
        writer.send_lineflow_mode()
        msgs.append(f"lineflow {'en' if writer.lflow else 'dis'}abled.")

    if option in ("linemode",):
        cmd = telopt.DONT if tbl_opt["linemode"] else telopt.DO
        writer.iac(cmd, telopt.LINEMODE)
        msgs.append(f"{telopt.name_command(cmd).lower()} linemode.")

    if option in ("linemode-edit",):
        if not tbl_opt["linemode"]:
            msgs.append("linemode not active.")
        else:
            writer.request_linemode_change(edit=not tbl_opt["linemode-edit"])
            msgs.append(f"linemode-edit {'dis' if tbl_opt['linemode-edit'] else 'en'}abled.")

    if option in ("linemode-trapsig",):
        if not tbl_opt["linemode"]:
            msgs.append("linemode not active.")
        else:
            writer.request_linemode_change(trapsig=not tbl_opt["linemode-trapsig"])
            msgs.append(f"linemode-trapsig {'dis' if tbl_opt['linemode-trapsig'] else 'en'}abled.")

    if option not in tbl_opt and option != "all":
        msgs.append("toggle: not an option.")

    return "\r\n".join(msgs)
