"""Tests for TLS (TELNETS) support."""

from __future__ import annotations

# std imports
import os
import ssl
import sys
import time as _time
import codecs
import signal
import asyncio
import warnings
import threading
import contextlib

# 3rd party
import pytest
import trustme

# local
from telnetlib3.tests.accessories import create_server, open_connection, init_subproc_coverage


@pytest.fixture()
def ca():
    return trustme.CA()


@pytest.fixture()
def server_ssl_ctx(ca):
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ca.issue_cert("127.0.0.1", "localhost").configure_cert(ctx)
    return ctx


@pytest.fixture()
def client_ssl_ctx(ca):
    ctx = ssl.create_default_context()
    ca.configure_trust(ctx)
    return ctx


def _echo_shell(send, reply):
    """Return a shell coroutine that expects *send* and writes *reply*."""
    waiter: asyncio.Future[bool] = asyncio.Future()

    async def shell(reader, writer):
        inp = await reader.readexactly(len(send))
        assert inp == send
        writer.write(reply)
        await writer.drain()
        waiter.set_result(True)

    return shell, waiter


_FAST_CLIENT = {"encoding": "ascii", "connect_maxwait": 0.5}


async def _ping_pong(bind_host, port, server_kw, client_kw):
    """Start server with *server_kw*, connect with *client_kw*, exchange ping/pong."""
    shell, waiter = _echo_shell("ping", "pong")
    async with create_server(host=bind_host, port=port, shell=shell, **server_kw):
        async with open_connection(bind_host, port, **_FAST_CLIENT, **client_kw) as (
            reader,
            writer,
        ):
            writer.write("ping")
            await writer.drain()
            await asyncio.wait_for(waiter, 2.0)
            result = await asyncio.wait_for(reader.readexactly(4), 2.0)
            assert result == "pong"


_start_tls_xfail = pytest.mark.xfail(
    sys.version_info < (3, 11),
    reason="loop.start_tls(server_side=True) hangs on Python <3.11, "
    "https://github.com/python/cpython/issues/79156",
    strict=False,
)
_start_tls_timeout = pytest.mark.timeout(5 if sys.version_info < (3, 11) else 15)


@pytest.mark.parametrize(
    "server_kw, client_kw",
    [
        pytest.param(
            {"ssl": "server_ssl_ctx"},
            {"ssl": "client_ssl_ctx", "server_hostname": "localhost"},
            id="tls-only",
        ),
        pytest.param(
            {"ssl": "server_ssl_ctx", "tls_auto": 0.15},
            {"ssl": "client_ssl_ctx", "server_hostname": "localhost"},
            id="tls-auto-tls-client",
            marks=[_start_tls_xfail, _start_tls_timeout],
        ),
        pytest.param(
            {"ssl": "server_ssl_ctx", "tls_auto": 0.15, "connect_maxwait": 0.35},
            {},
            id="tls-auto-plain-client",
        ),
    ],
)
async def test_ping_pong(
    bind_host, unused_tcp_port, server_ssl_ctx, client_ssl_ctx, server_kw, client_kw
):
    """Server accepts client, shell runs, ping/pong data exchanged."""
    fixtures = {"server_ssl_ctx": server_ssl_ctx, "client_ssl_ctx": client_ssl_ctx}
    resolved_server = {
        k: fixtures.get(v, v) if isinstance(v, str) else v for k, v in server_kw.items()
    }
    resolved_client = {
        k: fixtures.get(v, v) if isinstance(v, str) else v for k, v in client_kw.items()
    }
    await _ping_pong(bind_host, unused_tcp_port, resolved_server, resolved_client)


@_start_tls_xfail
@_start_tls_timeout
async def test_tls_auto_both_clients(bind_host, unused_tcp_port, server_ssl_ctx, client_ssl_ctx):
    """Both TLS and plain clients connect sequentially to a tls_auto server."""
    _tls_ok: asyncio.Future[bool] = asyncio.Future()
    _plain_ok: asyncio.Future[bool] = asyncio.Future()

    async def shell(reader, writer):
        inp = await reader.readexactly(3)
        if inp == "tls":
            _tls_ok.set_result(True)
        elif inp == "raw":
            _plain_ok.set_result(True)
        writer.write("ok")
        await writer.drain()

    async with create_server(
        host=bind_host, port=unused_tcp_port, shell=shell, ssl=server_ssl_ctx, tls_auto=1.0
    ):
        async with open_connection(
            bind_host,
            unused_tcp_port,
            ssl=client_ssl_ctx,
            server_hostname="localhost",
            **_FAST_CLIENT,
        ) as (reader, writer):
            writer.write("tls")
            await writer.drain()
            await asyncio.wait_for(_tls_ok, 2.0)

        async with open_connection(bind_host, unused_tcp_port, **_FAST_CLIENT) as (reader, writer):
            writer.write("raw")
            await writer.drain()
            await asyncio.wait_for(_plain_ok, 2.0)


async def test_tls_auto_silent_plain_client(bind_host, unused_tcp_port, server_ssl_ctx):
    """A plain TCP client that sends nothing is handed off after the detect timeout."""

    async def shell(reader, writer):
        pass

    detect_timeout = 0.15
    async with create_server(
        host=bind_host,
        port=unused_tcp_port,
        shell=shell,
        ssl=server_ssl_ctx,
        tls_auto=detect_timeout,
        connect_maxwait=0.35,
    ):
        reader, writer = await asyncio.open_connection(host=bind_host, port=unused_tcp_port)
        try:
            stime = _time.monotonic()
            data = await asyncio.wait_for(reader.read(1024), 3.0)
            elapsed = _time.monotonic() - stime
            assert data.startswith(b"\xff")
            assert detect_timeout * 0.8 <= elapsed <= detect_timeout * 4
        finally:
            writer.close()
            await writer.wait_closed()


@pytest.mark.parametrize(
    "client_ssl",
    [
        pytest.param(ssl.create_default_context, id="untrusted-ctx"),
        pytest.param(lambda: True, id="ssl-true"),
    ],
)
async def test_tls_cert_rejection(bind_host, unused_tcp_port, server_ssl_ctx, client_ssl):
    """Client without CA trust or with ssl=True rejects the server cert."""

    async def shell(reader, writer):
        pass

    async with create_server(host=bind_host, port=unused_tcp_port, shell=shell, ssl=server_ssl_ctx):
        with pytest.raises(ssl.SSLCertVerificationError):
            async with open_connection(
                bind_host,
                unused_tcp_port,
                ssl=client_ssl(),
                server_hostname="localhost",
                **_FAST_CLIENT,
            ):
                pass


async def test_plain_client_rejected_by_tls_server(bind_host, unused_tcp_port, server_ssl_ctx):
    """Plain TCP client cannot connect to a TLS-only server."""

    async def shell(reader, writer):
        pass

    async with create_server(host=bind_host, port=unused_tcp_port, shell=shell, ssl=server_ssl_ctx):
        reader, writer = await asyncio.open_connection(host=bind_host, port=unused_tcp_port)
        try:
            writer.write(b"hello\r\n")
            await writer.drain()
            data = await asyncio.wait_for(reader.read(1024), 2.0)
            assert data == b""
        except OSError:
            pass
        finally:
            writer.close()
            await writer.wait_closed()


async def test_tls_server_hostname_defaults_to_host(
    bind_host, unused_tcp_port, server_ssl_ctx, client_ssl_ctx
):
    """When server_hostname is omitted, it defaults to host."""
    waiter: asyncio.Future[bool] = asyncio.Future()

    async def shell(reader, writer):
        waiter.set_result(True)

    async with create_server(host=bind_host, port=unused_tcp_port, shell=shell, ssl=server_ssl_ctx):
        async with open_connection(
            "localhost", unused_tcp_port, ssl=client_ssl_ctx, **_FAST_CLIENT
        ) as (reader, writer):
            await asyncio.wait_for(waiter, 2.0)
            writer.close()


async def test_tls_fingerprint_end_to_end(
    bind_host, unused_tcp_port, server_ssl_ctx, client_ssl_ctx
):
    """Fingerprint client connects to TLS server via open_connection."""
    waiter: asyncio.Future[bool] = asyncio.Future()

    async def shell(reader, writer):
        writer.write(b"banner\r\n")
        await writer.drain()
        waiter.set_result(True)

    async with create_server(
        host=bind_host, port=unused_tcp_port, shell=shell, encoding=False, ssl=server_ssl_ctx
    ):
        async with open_connection(
            bind_host,
            unused_tcp_port,
            ssl=client_ssl_ctx,
            server_hostname="localhost",
            encoding=False,
            connect_maxwait=0.5,
        ) as (reader, writer):
            await asyncio.wait_for(waiter, 2.0)
            data = await asyncio.wait_for(reader.read(1024), 2.0)
            assert b"banner" in data
            writer.close()


async def test_tls_auto_requires_ssl_context():
    """tls_auto without ssl raises ValueError."""
    with pytest.raises(ValueError, match="tls_auto.*requires"):
        async with create_server(host="localhost", port=0, tls_auto=1.0):
            pass
    with pytest.raises(ValueError, match="tls_auto.*requires"):
        async with create_server(host="localhost", port=0, tls_auto=True):
            pass


def _write_cert_files(ca, tmp_path):
    """Write PEM cert+key files, return (cert_path, key_path)."""
    cert = ca.issue_cert("127.0.0.1", "localhost")
    cert_pem = tmp_path / "cert.pem"
    key_pem = tmp_path / "key.pem"
    cert.private_key_pem.write_to_path(str(key_pem))
    with open(str(cert_pem), "wb") as f:
        for blob in cert.cert_chain_pems:
            f.write(blob.bytes())
    return str(cert_pem), str(key_pem)


@contextlib.contextmanager
def _override_argv(argv):
    """Temporarily replace sys.argv."""
    orig = sys.argv
    sys.argv = argv
    try:
        yield
    finally:
        sys.argv = orig


@pytest.mark.parametrize(
    "extra_argv, expect_key, expect_val",
    [pytest.param([], "ssl", None, id="no-ssl"), pytest.param([], "tls_auto", 0, id="no-tls-auto")],
)
async def test_server_cli_defaults(extra_argv, expect_key, expect_val):
    """Server arg parser defaults for SSL/TLS options."""
    from telnetlib3.server import parse_server_args

    with _override_argv(["prog", "localhost", "6023"] + extra_argv):
        result = parse_server_args()
    assert result[expect_key] == expect_val


async def test_server_ssl_certfile_cli_args(tmp_path, ca):
    """--ssl-certfile produces SSLContext in parsed server args."""
    from telnetlib3.server import parse_server_args

    cert_pem, key_pem = _write_cert_files(ca, tmp_path)
    with _override_argv(
        ["prog", "--ssl-certfile", cert_pem, "--ssl-keyfile", key_pem, "localhost", "6023"]
    ):
        result = parse_server_args()
    assert isinstance(result["ssl"], ssl.SSLContext)


async def test_tls_auto_cli_args(tmp_path, ca):
    """--tls-auto flag is parsed and passed through."""
    from telnetlib3.server import parse_server_args

    cert_pem, key_pem = _write_cert_files(ca, tmp_path)
    with _override_argv(
        [
            "prog",
            "--ssl-certfile",
            cert_pem,
            "--ssl-keyfile",
            key_pem,
            "localhost",
            "6023",
            "--tls-auto",
        ]
    ):
        result = parse_server_args()
    assert result["tls_auto"] == 0.5
    assert isinstance(result["ssl"], ssl.SSLContext)

    with _override_argv(
        [
            "prog",
            "--ssl-certfile",
            cert_pem,
            "--ssl-keyfile",
            key_pem,
            "--tls-auto=0.5",
            "localhost",
            "6023",
        ]
    ):
        result = parse_server_args()
    assert result["tls_auto"] == 0.5


@pytest.mark.parametrize(
    "argv, key, check",
    [
        pytest.param(
            ["--ssl", "example.com", "992"],
            "ssl",
            lambda v: isinstance(v, ssl.SSLContext),
            id="client-ssl",
        ),
        pytest.param(["example.com"], "ssl", lambda v: v is None, id="client-no-ssl"),
        pytest.param(
            ["--ssl-no-verify", "example.com", "992"],
            "ssl",
            lambda v: isinstance(v, ssl.SSLContext)
            and not v.check_hostname
            and v.verify_mode == ssl.CERT_NONE,  # noqa: E501
            id="client-no-verify",
        ),
        pytest.param(["--raw-mode", "example.com"], "raw_mode", lambda v: v is True, id="raw-mode"),
        pytest.param(
            ["--line-mode", "example.com"], "raw_mode", lambda v: v is False, id="line-mode"
        ),
        pytest.param(["example.com"], "raw_mode", lambda v: v is None, id="default-mode"),
        pytest.param(
            ["--ascii-eol", "example.com"], "ascii_eol", lambda v: v is True, id="ascii-eol"
        ),
        pytest.param(
            ["--ansi-keys", "example.com"], "ansi_keys", lambda v: v is True, id="ansi-keys"
        ),
    ],
)
async def test_client_cli_args(argv, key, check):
    """Client argument parser produces expected transformed values."""
    from telnetlib3.client import _transform_args, _get_argument_parser

    result = _transform_args(_get_argument_parser().parse_args(argv))
    assert check(result.get(key))


async def test_client_ssl_cafile_cli_args(tmp_path, ca):
    """--ssl --ssl-cafile produces SSLContext with custom CA."""
    from telnetlib3.client import _transform_args, _get_argument_parser

    ca_pem = tmp_path / "ca.pem"
    ca.cert_pem.write_to_path(str(ca_pem))
    args = _get_argument_parser().parse_args(
        ["--ssl", "--ssl-cafile", str(ca_pem), "example.com", "992"]
    )
    assert isinstance(_transform_args(args)["ssl"], ssl.SSLContext)


async def test_fingerprint_ssl_cli_args():
    """--ssl flag is accepted by fingerprint argument parser."""
    from telnetlib3.client import _get_fingerprint_argument_parser

    args = _get_fingerprint_argument_parser().parse_args(["--ssl", "example.com", "992"])
    assert args.ssl is True
    assert args.ssl_cafile is None


async def test_fingerprint_ssl_cafile_cli_args(tmp_path, ca):
    """--ssl --ssl-cafile accepted by fingerprint argument parser."""
    from telnetlib3.client import _get_fingerprint_argument_parser

    ca_pem = tmp_path / "ca.pem"
    ca.cert_pem.write_to_path(str(ca_pem))
    args = _get_fingerprint_argument_parser().parse_args(
        ["--ssl", "--ssl-cafile", str(ca_pem), "example.com", "992"]
    )
    assert args.ssl is True
    assert args.ssl_cafile == str(ca_pem)


@pytest.mark.parametrize(
    "input_val, expected",
    [
        pytest.param("TTYPE", bytes([24]), id="by-name"),
        pytest.param("91", bytes([91]), id="by-number"),
    ],
)
async def test_parse_option_arg(input_val, expected):
    """_parse_option_arg resolves option names and numbers."""
    from telnetlib3.client import _parse_option_arg

    assert _parse_option_arg(input_val) == expected


@pytest.mark.parametrize(
    "values, expected",
    [
        pytest.param(["TTYPE,NAWS"], {bytes([24]), bytes([31])}, id="comma-separated"),
        pytest.param(["91,TTYPE"], {bytes([91]), bytes([24])}, id="mixed-comma"),
        pytest.param(["TTYPE", "91"], {bytes([24]), bytes([91])}, id="repeated"),
        pytest.param(["TTYPE, 91"], {bytes([24]), bytes([91])}, id="comma-with-spaces"),
        pytest.param([], set(), id="empty"),
    ],
)
async def test_parse_option_list(values, expected):
    from telnetlib3.client import _parse_option_list

    assert _parse_option_list(values) == expected


_MAX_SUBPROC_SECONDS = 8


def _read_until_eof(fd: int, encoding: str = "utf8") -> str:
    decoder = codecs.getincrementaldecoder(encoding)()
    outp = ""
    while True:
        try:
            chunk = os.read(fd, 4096)
        except OSError:
            break
        if not chunk:
            break
        outp += decoder.decode(chunk, final=False)
    return outp


def _run_in_pty(child_func, timeout: float = _MAX_SUBPROC_SECONDS) -> str:
    """Fork a PTY, run *child_func* in the child with coverage, return output."""
    if sys.platform == "win32":
        pytest.skip("POSIX-only test")

    import pty

    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=DeprecationWarning)
        pid, master_fd = pty.fork()

    if pid == 0:
        cov = init_subproc_coverage("tls-pty-test")
        exit_code = 0
        try:
            child_func()
        except SystemExit:
            pass
        except BaseException:
            import traceback

            traceback.print_exc()
            exit_code = 1
        finally:
            if cov is not None:
                cov.stop()
                cov.save()
        os._exit(exit_code)

    output = _read_until_eof(master_fd)
    os.close(master_fd)

    start = _time.monotonic()
    while True:
        pid_result, status = os.waitpid(pid, os.WNOHANG)
        if pid_result != 0:
            break
        if _time.monotonic() - start > timeout:
            try:
                os.kill(pid, signal.SIGKILL)
                os.waitpid(pid, 0)
            except OSError:
                pass
            raise AssertionError(f"Child hung after {timeout}s.\nOutput:\n{output}")
        _time.sleep(0.05)

    assert os.WEXITSTATUS(status) == 0, f"Child exited {os.WEXITSTATUS(status)}.\nOutput:\n{output}"
    return output


class _EchoClose(asyncio.Protocol):
    """Write a message then close after a short delay."""

    def __init__(self, msg: bytes):
        self._msg = msg
        self._transport: asyncio.BaseTransport | None = None

    def connection_made(self, transport: asyncio.BaseTransport) -> None:
        self._transport = transport
        transport.write(self._msg)
        asyncio.get_event_loop().call_later(1.0, transport.close)


@contextlib.contextmanager
def _threaded_echo_server(host, port, msg=b"ok\r\n", ssl_ctx=None):
    """Run a simple echo-and-close TCP server on a background thread."""
    srv_loop = asyncio.new_event_loop()
    ready = threading.Event()
    stop_holder: list[asyncio.Event] = []
    accepted: list[asyncio.BaseTransport] = []

    def _run():
        asyncio.set_event_loop(srv_loop)

        async def _serve():
            def _factory():
                proto = _EchoClose(msg)
                accepted.append(proto)
                return proto

            kwargs = {"ssl": ssl_ctx} if ssl_ctx else {}
            srv = await srv_loop.create_server(_factory, host, port, **kwargs)
            stop_evt = asyncio.Event()
            stop_holder.append(stop_evt)
            ready.set()
            try:
                await stop_evt.wait()
            finally:
                for proto in accepted:
                    tr = proto._transport
                    if tr is not None and not tr.is_closing():
                        tr.abort()
                srv.close()
                await srv.wait_closed()

        srv_loop.run_until_complete(_serve())

    thread = threading.Thread(target=_run, daemon=True)
    thread.start()
    ready.wait(timeout=5)
    try:
        yield
    finally:
        if stop_holder:
            srv_loop.call_soon_threadsafe(stop_holder[0].set)
        thread.join(timeout=3)
        srv_loop.close()


def _pty_run_client(bind_host, port, extra_argv, server_ssl_ctx=None):
    """Start a plain/TLS echo server, fork, run run_client() in the child."""
    marker = b"pty-marker-ok"

    def _child():
        sys.argv = ["telnetlib3-client", bind_host, str(port), "--connect-maxwait=0.5"] + extra_argv
        from telnetlib3.client import run_client

        asyncio.run(run_client())

    with _threaded_echo_server(bind_host, port, marker + b"\r\n", server_ssl_ctx):
        output = _run_in_pty(_child)

    assert "pty-marker-ok" in output, f"Marker not found in output:\n{output}"


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only tests")
@pytest.mark.parametrize(
    "extra_argv, use_ssl",
    [
        pytest.param(["--ssl-no-verify", "--raw-mode"], True, id="ssl-no-verify"),
        pytest.param(["--raw-mode"], False, id="raw-mode"),
        pytest.param(["--raw-mode", "--ascii-eol", "--ansi-keys"], False, id="ascii-eol-ansi-keys"),
        pytest.param(["--raw-mode", "--encoding=atascii"], False, id="atascii"),
    ],
)
def test_cli_run_client(bind_host, unused_tcp_port, server_ssl_ctx, extra_argv, use_ssl):
    """run_client() with various CLI flags exercises expected code paths."""
    ssl_ctx = server_ssl_ctx if use_ssl else None
    _pty_run_client(bind_host, unused_tcp_port, extra_argv, ssl_ctx)


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only tests")
def test_cli_run_server_ssl(bind_host, unused_tcp_port, ca, tmp_path, client_ssl_ctx):
    """run_server() with --ssl-certfile exercises the ssl pass-through."""
    import pty

    port = unused_tcp_port
    cert_pem, key_pem = _write_cert_files(ca, tmp_path)

    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=DeprecationWarning)
        pid, master_fd = pty.fork()

    if pid == 0:
        cov = init_subproc_coverage("tls-server-pty")
        exit_code = 0
        try:
            sys.argv = [
                "telnetlib3-server",
                bind_host,
                str(port),
                "--ssl-certfile",
                cert_pem,
                "--ssl-keyfile",
                key_pem,
                "--connect-maxwait=0.05",
                "--loglevel=warning",
            ]
            from telnetlib3.server import main

            main()
        except SystemExit:
            pass
        except BaseException:
            import traceback

            traceback.print_exc()
            exit_code = 1
        finally:
            if cov is not None:
                cov.stop()
                cov.save()
        os._exit(exit_code)

    try:
        _time.sleep(0.5)

        async def _connect_and_close():
            async with open_connection(
                bind_host, port, ssl=client_ssl_ctx, server_hostname="localhost", **_FAST_CLIENT
            ) as (reader, writer):
                pass

        asyncio.run(_connect_and_close())
    finally:
        try:
            os.kill(pid, signal.SIGTERM)
        except OSError:
            pass
        try:
            os.waitpid(pid, 0)
        except ChildProcessError:
            pass
        os.close(master_fd)


def _pty_run_fingerprint(bind_host, port, extra_argv, server_ssl_ctx):
    """Run fingerprint client in PTY against echo server."""

    def _child():
        sys.argv = [
            "telnetlib3-fingerprint",
            "--connect-timeout=3",
            bind_host,
            str(port),
        ] + extra_argv
        from telnetlib3.client import run_fingerprint_client  # noqa: PLC0415

        asyncio.run(run_fingerprint_client())

    with _threaded_echo_server(bind_host, port, b"fingerprint-ok\r\n", server_ssl_ctx):
        _run_in_pty(_child)


@pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only tests")
@pytest.mark.parametrize(
    "extra_argv_factory",
    [
        pytest.param(lambda ca_pem: ["--ssl-no-verify"], id="no-verify"),
        pytest.param(lambda ca_pem: ["--ssl", "--ssl-cafile", ca_pem], id="cafile"),
    ],
)
def test_cli_run_fingerprint_client_ssl(
    bind_host, unused_tcp_port, server_ssl_ctx, ca, tmp_path, extra_argv_factory
):
    """run_fingerprint_client() with TLS flags exercises context paths."""
    ca_pem = str(tmp_path / "ca.pem")
    ca.cert_pem.write_to_path(ca_pem)
    extra_argv = extra_argv_factory(ca_pem)
    _pty_run_fingerprint(bind_host, unused_tcp_port, extra_argv, server_ssl_ctx)


@_start_tls_xfail
@_start_tls_timeout
async def test_tls_handshake_logged(
    bind_host, unused_tcp_port, server_ssl_ctx, client_ssl_ctx, caplog
):
    """Successful TLS handshake logs version and cipher at debug level."""
    shell, waiter = _echo_shell("hi", "ok")
    import logging

    with caplog.at_level(logging.DEBUG):
        async with create_server(
            host=bind_host, port=unused_tcp_port, shell=shell, ssl=server_ssl_ctx
        ):
            async with open_connection(
                bind_host,
                unused_tcp_port,
                ssl=client_ssl_ctx,
                server_hostname="localhost",
                **_FAST_CLIENT,
            ) as (reader, writer):
                writer.write("hi")
                await writer.drain()
                await asyncio.wait_for(waiter, 2.0)

    tls_logs = [r for r in caplog.records if "TLS handshake" in r.message]
    assert len(tls_logs) >= 1
    assert any("TLSv1" in r.message for r in tls_logs)
