import asyncio
from contextlib import suppress
import logging
from pathlib import Path
import sqlite3
import pytest
import aiosqlite

from sqlalchemy import select
from sqlalchemy.dialects import mysql, postgresql, sqlite
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.schema import CreateTable

from amqtt.broker import Broker, BrokerContext, RetainedApplicationMessage
from amqtt.client import MQTTClient
from amqtt.mqtt.constants import QOS_1
from amqtt.contrib.persistence import Base, RetainedMessage, SessionDBPlugin, StoredMessage, StoredSession, Subscription
from amqtt.session import Session

formatter = "[%(asctime)s] %(name)s {%(filename)s:%(lineno)d} %(levelname)s - %(message)s"
logging.basicConfig(level=logging.DEBUG, format=formatter)

logger = logging.getLogger(__name__)

@pytest.fixture
def db_file(tmp_path):
    return tmp_path / "amqtt.db"


async def disconnect_client(client: MQTTClient | None) -> None:
    if client is not None and client.session and client.session.transitions.is_connected():
        with suppress(Exception):
            await client.disconnect()


async def shutdown_broker(broker: Broker | None) -> None:
    if broker is not None and not broker.transitions.is_stopped():
        with suppress(Exception):
            await broker.shutdown()


class BrokerTestResources:
    def __init__(self) -> None:
        self.brokers: list[Broker] = []
        self.clients: list[MQTTClient] = []

    def broker(self, broker: Broker) -> Broker:
        self.brokers.append(broker)
        return broker

    def client(self, client: MQTTClient) -> MQTTClient:
        self.clients.append(client)
        return client

    async def cleanup(self) -> None:
        for client in reversed(self.clients):
            await disconnect_client(client)
        for broker in reversed(self.brokers):
            await shutdown_broker(broker)


@pytest.fixture
async def broker_test_resources():
    resources = BrokerTestResources()
    yield resources
    await resources.cleanup()

@pytest.fixture
async def broker_context():
    cfg = {
        'listeners': { 'default': {'type': 'tcp', 'bind': 'localhost:1883' }},
        'plugins': {}
    }

    context = BrokerContext(broker=Broker(config=cfg))
    yield context

@pytest.fixture
async def db_session_factory(db_file):
    engine = create_async_engine(f"sqlite+aiosqlite:///{str(db_file)}")
    factory = async_sessionmaker(engine, expire_on_commit=False)
    try:
        yield factory
    finally:
        await engine.dispose()


@pytest.fixture
async def session_db_plugin_factory():
    plugins: list[SessionDBPlugin] = []

    def make_plugin(context: BrokerContext) -> SessionDBPlugin:
        plugin = SessionDBPlugin(context)
        plugins.append(plugin)
        return plugin

    yield make_plugin

    for plugin in reversed(plugins):
        await plugin.close()


def assert_stored_sessions_table_initialized(db_file: Path) -> None:
    conn = sqlite3.connect(str(db_file))
    try:
        cursor = conn.cursor()
        cursor.execute("PRAGMA table_info(stored_sessions);")
        rows = cursor.fetchall()
    finally:
        conn.close()

    column_names = [row[1] for row in rows]
    assert len(column_names) > 1


def table_row_count(db_file: Path, table_name: str) -> int:
    conn = sqlite3.connect(str(db_file))
    try:
        cursor = conn.cursor()
        cursor.execute(f"SELECT COUNT(*) FROM {table_name};")
        count = cursor.fetchone()[0]
    finally:
        conn.close()

    return count


@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect(), mysql.dialect()])
def test_persistence_schema_compiles_for_supported_dialects(dialect):
    for table in Base.metadata.sorted_tables:
        str(CreateTable(table).compile(dialect=dialect))


@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect(), mysql.dialect()])
def test_persistence_statements_compile_for_supported_dialects(dialect):
    statements = [
        select(StoredSession).where(StoredSession.client_id == "client-1"),
        select(StoredMessage).where(StoredMessage.topic == "topic/1"),
        StoredSession.__table__.insert(),
        StoredMessage.__table__.delete().where(StoredMessage.topic == "topic/1"),
        *[table.delete() for table in Base.metadata.sorted_tables],
    ]

    for statement in statements:
        str(statement.compile(dialect=dialect))


@pytest.mark.asyncio
async def test_initialize_tables(db_file, broker_context, session_db_plugin_factory):

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    assert db_file.exists()

    assert_stored_sessions_table_initialized(db_file)


@pytest.mark.asyncio
async def test_legacy_file_config_initializes_sqlite_db(db_file, broker_context, session_db_plugin_factory):
    broker_context.config = SessionDBPlugin.Config(file=db_file)

    with pytest.deprecated_call(match="`file` option is now deprecated"):
        session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    assert db_file.exists()
    assert_stored_sessions_table_initialized(db_file)


@pytest.mark.asyncio
async def test_blank_file_config_initializes_default_sqlite_db(tmp_path, monkeypatch, broker_context, session_db_plugin_factory):
    monkeypatch.chdir(tmp_path)
    db_file = tmp_path / "amqtt.db"
    broker_context.config = SessionDBPlugin.Config()

    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    assert db_file.exists()
    assert_stored_sessions_table_initialized(db_file)


@pytest.mark.asyncio
async def test_clear_on_shutdown_deletes_legacy_sqlite_file(db_file, broker_context, session_db_plugin_factory):
    broker_context.config = SessionDBPlugin.Config(file=db_file)

    with pytest.deprecated_call(match="`file` option is now deprecated"):
        session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    assert db_file.exists()

    await session_db_plugin.on_broker_post_shutdown()

    assert not db_file.exists()


@pytest.mark.asyncio
async def test_clear_on_shutdown_clears_database_tables_for_connection(db_file, broker_context, session_db_plugin_factory):
    broker_context.config = SessionDBPlugin.Config(connection=f"sqlite+aiosqlite:///{db_file}")
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    async with session_db_plugin._db_session_maker() as db_session, db_session.begin():
        db_session.add(StoredSession(client_id="client-1"))
        db_session.add(StoredMessage(topic="topic/1", data=b"payload", qos=1))

    assert table_row_count(db_file, "stored_sessions") == 1
    assert table_row_count(db_file, "stored_messages") == 1

    await session_db_plugin.on_broker_post_shutdown()

    assert db_file.exists()
    assert_stored_sessions_table_initialized(db_file)
    assert table_row_count(db_file, "stored_sessions") == 0
    assert table_row_count(db_file, "stored_messages") == 0


@pytest.mark.asyncio
async def test_create_stored_session(db_file, broker_context, db_session_factory, session_db_plugin_factory):

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    async with db_session_factory() as db_session:
        async with db_session.begin():
            stored_session = await session_db_plugin._get_or_create_session(db_session, 'test_client_1')
            assert stored_session.client_id == 'test_client_1'

    async with aiosqlite.connect(str(db_file)) as db:
        async with await db.execute("SELECT * FROM stored_sessions") as cursor:
            async for row in cursor:
                assert row[1] == 'test_client_1'

@pytest.mark.asyncio
async def test_get_stored_session(db_file, broker_context, db_session_factory, session_db_plugin_factory):
    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    async with aiosqlite.connect(str(db_file)) as db:
        sql = """INSERT INTO stored_sessions (
    client_id, clean_session, will_flag,
    will_qos, keep_alive,
    retained, subscriptions
) VALUES (
    'test_client_1',
    1,
    0,
    1,
    60,
    '[]',
    '[{"topic":"sensors/#","qos":1}]'
)"""
        await db.execute(sql)
        await db.commit()

    async with db_session_factory() as db_session:
        async with db_session.begin():
            stored_session = await session_db_plugin._get_or_create_session(db_session, 'test_client_1')
            assert stored_session.subscriptions == [Subscription(topic='sensors/#', qos=1)]


@pytest.mark.asyncio
async def test_update_stored_session(db_file, broker_context, db_session_factory, session_db_plugin_factory):
    broker_context.config = SessionDBPlugin.Config(file=db_file)

    # create session for client id (without subscription)
    await broker_context.add_subscription('test_client_1', None, None)

    session = broker_context.get_session('test_client_1')
    assert session is not None
    session.clean_session = False

    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    # initialize with stored client session
    async with aiosqlite.connect(str(db_file)) as db:
        sql = """INSERT INTO stored_sessions (
    client_id, clean_session, will_flag,
    will_qos, keep_alive,
    retained, subscriptions
) VALUES (
    'test_client_1',
    1,
    0,
    1,
    60,
    '[]',
    '[{"topic":"sensors/#","qos":1}]'
)"""
        await db.execute(sql)
        await db.commit()

    await session_db_plugin.on_broker_client_subscribed(client_id='test_client_1', topic='my/topic', qos=2)

    # verify that the stored session has been updated with the new subscription
    has_stored_session = False
    async with aiosqlite.connect(str(db_file)) as db:
        async with await db.execute("SELECT * FROM stored_sessions") as cursor:
            async for row in cursor:
                assert row[1] == 'test_client_1'
                assert row[-1] == '[{"topic": "sensors/#", "qos": 1}, {"topic": "my/topic", "qos": 2}]'
                has_stored_session = True
    assert has_stored_session, "stored session wasn't updated"


@pytest.mark.asyncio
async def test_client_connected_with_clean_session(
    db_file, broker_context, db_session_factory, session_db_plugin_factory
) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    session = Session()
    session.client_id = 'test_client_connected'
    session.is_anonymous = False
    session.clean_session = True

    await session_db_plugin.on_broker_client_connected(client_id='test_client_connected', client_session=session)

    async with aiosqlite.connect(str(db_file)) as db_conn:
        db_conn.row_factory = sqlite3.Row

        async with await db_conn.execute("SELECT * FROM stored_sessions") as cursor:
            assert len(await cursor.fetchall()) == 0


@pytest.mark.asyncio
async def test_client_connected_anonymous_session(
    db_file, broker_context, db_session_factory, session_db_plugin_factory
) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    session = Session()
    session.is_anonymous = True
    session.client_id = 'test_client_connected'

    await session_db_plugin.on_broker_client_connected(client_id='test_client_connected', client_session=session)

    async with aiosqlite.connect(str(db_file)) as db_conn:
        db_conn.row_factory = sqlite3.Row  # Set the row_factory
        async with await db_conn.execute("SELECT * FROM stored_sessions") as cursor:
            assert len(await cursor.fetchall()) == 0


@pytest.mark.asyncio
async def test_client_connected_and_stored_session(
    db_file, broker_context, db_session_factory, session_db_plugin_factory
) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    session = Session()
    session.client_id = 'test_client_connected'
    session.is_anonymous = False
    session.clean_session = False
    session.will_flag = True
    session.will_qos = 1
    session.will_topic = 'my/will/topic'
    session.will_retain = False
    session.will_message = b'test connected client has a last will (and testament) message'
    session.keep_alive = 42

    await session_db_plugin.on_broker_client_connected(client_id='test_client_connected', client_session=session)

    has_stored_session = False
    async with aiosqlite.connect(str(db_file)) as db_conn:
        db_conn.row_factory = sqlite3.Row  # Set the row_factory

        async with await db_conn.execute("SELECT * FROM stored_sessions") as cursor:
            for row in await cursor.fetchall():
                assert row['client_id'] == 'test_client_connected'
                assert row['clean_session'] == False
                assert row['will_flag'] == True
                assert row['will_qos'] == 1
                assert row['will_topic'] == 'my/will/topic'
                assert row['will_retain'] == False
                assert row['will_message'] == b'test connected client has a last will (and testament) message'
                assert row['keep_alive'] == 42
                has_stored_session = True
    assert has_stored_session, "client session wasn't stored"


@pytest.mark.asyncio
async def test_repopulate_stored_sessions(db_file, broker_context, db_session_factory, session_db_plugin_factory) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    async with aiosqlite.connect(str(db_file)) as db:
        sql = """INSERT INTO stored_sessions (
        client_id, clean_session, will_flag,
        will_qos, keep_alive,
        retained, subscriptions
    ) VALUES (
        'test_client_1',
        1,
        0,
        1,
        60,
        '[{"topic":"sensors/#","data":"this message is retained when client reconnects","qos":1}]',
        '[{"topic":"sensors/#","qos":1}]'
    )"""
        await db.execute(sql)
        await db.commit()

    await session_db_plugin.on_broker_post_start()

    session = broker_context.get_session('test_client_1')
    assert session is not None
    assert session.retained_messages.qsize() == 1

    assert 'sensors/#' in broker_context._broker_instance._subscriptions

    # ugly: b/c _subscriptions is a list of dictionaries of tuples
    assert broker_context._broker_instance._subscriptions['sensors/#'][0][1] == 1


@pytest.mark.asyncio
async def test_client_retained_message(db_file, broker_context, db_session_factory, session_db_plugin_factory) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    # add a session to the broker
    await broker_context.add_subscription('test_retained_client', None, None)

    # update the session so that it's retained
    session = broker_context.get_session('test_retained_client')
    assert session is not None
    session.is_anonymous = False
    session.clean_session = False
    session.transitions.disconnect()

    retained_message = RetainedApplicationMessage(source_session=session, topic='my/retained/topic', data=b'retain message for disconnected client', qos=2)
    await session_db_plugin.on_broker_retained_message(client_id='test_retained_client', retained_message=retained_message)

    async with db_session_factory() as db_session:
        async with db_session.begin():
            stmt = select(StoredSession).filter(StoredSession.client_id == 'test_retained_client')
            stored_session = await db_session.scalar(stmt)
            assert stored_session is not None
            assert len(stored_session.retained) > 0
            assert RetainedMessage(topic='my/retained/topic', data='retained message', qos=2)


@pytest.mark.asyncio
async def test_topic_retained_message(db_file, broker_context, db_session_factory, session_db_plugin_factory) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file, clear_on_shutdown=False)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    # add a session to the broker
    await broker_context.add_subscription('test_retained_client', None, None)

    # update the session so that it's retained
    session = broker_context.get_session('test_retained_client')
    assert session is not None
    session.is_anonymous = False
    session.clean_session = False
    session.transitions.disconnect()

    retained_message = RetainedApplicationMessage(source_session=session, topic='my/retained/topic', data=b'retained message', qos=2)
    await session_db_plugin.on_broker_retained_message(client_id=None, retained_message=retained_message)

    has_stored_message = False
    async with aiosqlite.connect(str(db_file)) as db_conn:
        db_conn.row_factory = sqlite3.Row  # Set the row_factory

        async with await db_conn.execute("SELECT * FROM stored_messages") as cursor:
            # assert(len(await cursor.fetchall()) > 0)
            for row in await cursor.fetchall():
                assert row['topic'] == 'my/retained/topic'
                assert row['data'] == b'retained message'
                assert row['qos'] == 2
                has_stored_message = True

    assert has_stored_message, "retained topic message wasn't stored"


@pytest.mark.asyncio
async def test_topic_clear_retained_message(
    db_file, broker_context, db_session_factory, session_db_plugin_factory
) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    # add a session to the broker
    await broker_context.add_subscription('test_retained_client', None, None)

    # update the session so that it's retained
    session = broker_context.get_session('test_retained_client')
    assert session is not None
    session.is_anonymous = False
    session.clean_session = False
    session.transitions.disconnect()

    retained_message = RetainedApplicationMessage(source_session=session, topic='my/retained/topic', data=b'', qos=0)
    await session_db_plugin.on_broker_retained_message(client_id=None, retained_message=retained_message)

    async with aiosqlite.connect(str(db_file)) as db_conn:
        db_conn.row_factory = sqlite3.Row

        async with await db_conn.execute("SELECT * FROM stored_messages") as cursor:
            assert(len(await cursor.fetchall()) == 0)


@pytest.mark.asyncio
async def test_restoring_retained_message(db_file, broker_context, db_session_factory, session_db_plugin_factory) -> None:

    broker_context.config = SessionDBPlugin.Config(file=db_file)
    session_db_plugin = session_db_plugin_factory(broker_context)
    await session_db_plugin.on_broker_pre_start()

    stmts = ("INSERT INTO stored_messages VALUES(1,'my/retained/topic1',X'72657461696e6564206d657373616765',2)",
             "INSERT INTO stored_messages VALUES(2,'my/retained/topic2',X'72657461696e6564206d65737361676532',2)",
             "INSERT INTO stored_messages VALUES(3,'my/retained/topic3',X'72657461696e6564206d65737361676533',2)")

    async with aiosqlite.connect(str(db_file)) as db:
        for stmt in stmts:
            await db.execute(stmt)
        await db.commit()

    await session_db_plugin.on_broker_post_start()

    assert len(broker_context.retained_messages) == 3
    assert 'my/retained/topic1' in broker_context.retained_messages
    assert 'my/retained/topic2' in broker_context.retained_messages
    assert 'my/retained/topic3' in broker_context.retained_messages


@pytest.fixture
def db_config(db_file, unused_tcp_port):
    return {
        'listeners': {
            'default': {
                'type': 'tcp',
                'bind': f'127.0.0.1:{unused_tcp_port}'
            }
        },
        'plugins': {
            'amqtt.plugins.authentication.AnonymousAuthPlugin': {'allow_anonymous': False},
            'amqtt.contrib.persistence.SessionDBPlugin': {
                'file': db_file,
                'clear_on_shutdown': False

            }
        }
    }


def broker_uri(db_config, username):
    bind = db_config['listeners']['default']['bind']
    return f"mqtt://{username}@{bind}"


@pytest.mark.asyncio
async def test_broker_client_no_cleanup(db_config, broker_test_resources) -> None:

    b1 = broker_test_resources.broker(Broker(config=db_config))
    await b1.start()
    await asyncio.sleep(0.1)

    c1 = broker_test_resources.client(MQTTClient(client_id='test_client1', config={'auto_reconnect':False}))
    await c1.connect(broker_uri(db_config, "myUsername"), cleansession=False)

    # test that this message is retained for the topic upon restore
    await c1.publish("my/retained/topic", b'retained message for topic my/retained/topic', retain=True)
    await asyncio.sleep(0.2)
    await c1.disconnect()
    await b1.shutdown()

    # new broker should load the previous broker's db file since clean_on_shutdown is false in config
    b2 = broker_test_resources.broker(Broker(config=db_config))
    await b2.start()
    await asyncio.sleep(0.1)

    # upon subscribing to topic with retained message, it should be received
    c2 = broker_test_resources.client(MQTTClient(client_id='test_client2', config={'auto_reconnect':False}))
    await c2.connect(broker_uri(db_config, "myOtherUsername"), cleansession=False)
    await c2.subscribe([
        ('my/retained/topic', QOS_1)
    ])
    msg = await c2.deliver_message(timeout_duration=1)
    assert msg is not None
    assert msg.topic == "my/retained/topic"
    assert msg.data == b'retained message for topic my/retained/topic'

    await c2.disconnect()
    await b2.shutdown()


@pytest.mark.asyncio
async def test_broker_client_retain_subscription(db_config, broker_test_resources) -> None:

    b1 = broker_test_resources.broker(Broker(config=db_config))
    await b1.start()
    await asyncio.sleep(0.1)

    c1 = broker_test_resources.client(MQTTClient(client_id='test_client1', config={'auto_reconnect':False}))
    await c1.connect(broker_uri(db_config, "myUsername"), cleansession=False)

    # test to make sure the subscription is re-established upon reconnection after broker restart (clear_on_shutdown = False)
    ret = await c1.subscribe([
        ('my/offline/topic', QOS_1)
    ])
    assert ret == [QOS_1,]
    await asyncio.sleep(0.2)
    await c1.disconnect()
    await asyncio.sleep(0.1)
    await b1.shutdown()

    # new broker should load the previous broker's db file
    b2 = broker_test_resources.broker(Broker(config=db_config))
    await b2.start()
    await asyncio.sleep(0.1)

    # client1's subscription should have been restored, so when it connects, it should receive this message
    c2 = broker_test_resources.client(MQTTClient(client_id='test_client2', config={'auto_reconnect':False}))
    await c2.connect(broker_uri(db_config, "myOtherUsername"), cleansession=False)
    await c2.publish('my/offline/topic', b'standard message to be retained for offline clients')
    await asyncio.sleep(0.1)
    await c2.disconnect()

    await c1.reconnect(cleansession=False)
    await asyncio.sleep(0.1)
    msg = await c1.deliver_message(timeout_duration=2)
    assert msg is not None
    assert msg.topic == "my/offline/topic"
    assert msg.data == b'standard message to be retained for offline clients'

    await c1.disconnect()
    await b2.shutdown()


@pytest.mark.asyncio
async def test_broker_client_retain_message(db_config, broker_test_resources) -> None:
    """test to make sure that the retained message because client1 is offline,
     gets sent when back online after broker restart."""

    b1 = broker_test_resources.broker(Broker(config=db_config))
    await b1.start()
    await asyncio.sleep(0.1)

    c1 = broker_test_resources.client(MQTTClient(client_id='test_client1', config={'auto_reconnect':False}))
    await c1.connect(broker_uri(db_config, "myUsername"), cleansession=False)

    # subscribe to a topic with QOS_1 so that we receive messages, even if we're disconnected when sent
    ret = await c1.subscribe([
        ('my/offline/topic', QOS_1)
    ])
    assert ret == [QOS_1,]
    await asyncio.sleep(0.2)
    # go offline
    await c1.disconnect()
    await asyncio.sleep(0.1)

    # another client sends a message to previously subscribed to topic
    c2 = broker_test_resources.client(MQTTClient(client_id='test_client2', config={'auto_reconnect':False}))
    await c2.connect(broker_uri(db_config, "myOtherUsername"), cleansession=False)

    # this message should be delivered after broker stops and restarts (and client connects)
    await c2.publish('my/offline/topic', b'standard message to be retained for offline clients')
    await asyncio.sleep(0.1)
    await c2.disconnect()
    await asyncio.sleep(0.1)
    await b1.shutdown()

    # new broker should load the previous broker's db file since we declared clear_on_shutdown = False in config
    b2 = broker_test_resources.broker(Broker(config=db_config))
    await b2.start()
    await asyncio.sleep(0.1)

    # when first client reconnects, it should receive the message that had been previously retained for it
    await c1.reconnect(cleansession=False)
    await asyncio.sleep(0.1)
    msg = await c1.deliver_message(timeout_duration=2)
    assert msg is not None
    assert msg.topic == "my/offline/topic"
    assert msg.data == b'standard message to be retained for offline clients'

    # client should also receive a message if send on this topic
    await c1.publish("my/offline/topic", b"online message should also be received")
    await asyncio.sleep(0.1)
    msg = await c1.deliver_message(timeout_duration=2)
    assert msg is not None
    assert msg.topic == "my/offline/topic"
    assert msg.data == b'online message should also be received'

    await c1.disconnect()
    await b2.shutdown()
