"""Tests for the Bluetooth base scanner models."""

from __future__ import annotations

import asyncio
import time
from datetime import timedelta
from typing import Any
from unittest.mock import ANY, MagicMock, Mock, patch

import pytest
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak_retry_connector import Allocations
from bluetooth_data_tools import monotonic_time_coarse

from habluetooth import (
    BaseHaRemoteScanner,
    BaseHaScanner,
    BluetoothManager,
    BluetoothScannerDevice,
    BluetoothScanningMode,
    HaBluetoothConnector,
    HaScannerDetails,
    HaScannerModeChange,
    HaScannerType,
    get_manager,
    set_manager,
)
from habluetooth.const import (
    CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
    FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
    SCANNER_WATCHDOG_INTERVAL,
    SCANNER_WATCHDOG_TIMEOUT,
)
from habluetooth.storage import (
    DiscoveredDeviceAdvertisementData,
)

from . import (
    HCI0_SOURCE_ADDRESS,
    MockBleakClient,
    async_fire_time_changed,
    generate_advertisement_data,
    generate_ble_device,
    patch_bluetooth_time,
    utcnow,
)
from . import (
    InjectableRemoteScanner as FakeScanner,
)
from .conftest import FakeBluetoothAdapters, MockBluetoothManagerWithCallbacks


@pytest.mark.parametrize("name_2", [None, "w"])
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner(name_2: str | None) -> None:
    """Test the remote scanner base class merges advertisement_data."""
    manager = get_manager()

    switchbot_device = generate_ble_device(
        "44:44:33:11:23:45",
        "wohand",
        {},
        rssi=-100,
    )
    switchbot_device_adv = generate_advertisement_data(
        local_name="wohand",
        service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
        service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
        manufacturer_data={1: b"\x01"},
        rssi=-100,
    )
    switchbot_device_2 = generate_ble_device(
        "44:44:33:11:23:45",
        name_2,
        {},
        rssi=-100,
    )
    switchbot_device_adv_2 = generate_advertisement_data(
        local_name=name_2,
        service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
        service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
        manufacturer_data={1: b"\x01", 2: b"\x02"},
        rssi=-100,
    )
    switchbot_device_3 = generate_ble_device(
        "44:44:33:11:23:45",
        "wohandlonger",
        {},
        rssi=-100,
    )
    switchbot_device_adv_3 = generate_advertisement_data(
        local_name="wohandlonger",
        service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
        service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
        manufacturer_data={1: b"\x01", 2: b"\x02"},
        rssi=-100,
    )
    switchbot_device_adv_4 = generate_advertisement_data(
        local_name="wohandlonger",
        service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
        service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
        manufacturer_data={1: b"\x04", 2: b"\x02", 3: b"\x03"},
        rssi=-100,
    )
    switchbot_device_adv_5 = generate_advertisement_data(
        local_name="wohandlonger",
        service_uuids=["00000001-0000-1000-8000-00805f9b34fb"],
        service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"},
        manufacturer_data={1: b"\x04", 2: b"\x01"},
        rssi=-100,
    )
    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    details = scanner.details
    assert details == HaScannerDetails(
        source=scanner.source,
        connectable=scanner.connectable,
        name=scanner.name,
        adapter=scanner.adapter,
        scanner_type=HaScannerType.REMOTE,
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    scanner.inject_advertisement(switchbot_device, switchbot_device_adv)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[switchbot_device.address]
    assert discovered_device.address == switchbot_device.address
    assert discovered_device.name == switchbot_device.name
    assert (
        discovered_adv_data.manufacturer_data == switchbot_device_adv.manufacturer_data
    )
    assert discovered_adv_data.service_data == switchbot_device_adv.service_data
    assert discovered_adv_data.service_uuids == switchbot_device_adv.service_uuids
    scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[switchbot_device.address]
    assert discovered_device.address == switchbot_device.address
    assert discovered_device.name == switchbot_device.name
    assert discovered_adv_data.manufacturer_data == {1: b"\x01", 2: b"\x02"}
    assert discovered_adv_data.service_data == {
        "050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff",
        "00000001-0000-1000-8000-00805f9b34fb": b"\n\xff",
    }
    assert set(discovered_adv_data.service_uuids) == {
        "050a021a-0000-1000-8000-00805f9b34fb",
        "00000001-0000-1000-8000-00805f9b34fb",
    }

    # The longer name should be used
    scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3)
    assert discovered_device.name == switchbot_device_3.name

    # Inject the shorter name / None again to make
    # sure we always keep the longer name
    scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2)
    assert discovered_device.name == switchbot_device_3.name

    scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_4)
    assert scanner.discovered_devices_and_advertisement_data[
        switchbot_device_2.address
    ][1].manufacturer_data == {1: b"\x04", 2: b"\x02", 3: b"\x03"}
    scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_5)
    assert scanner.discovered_devices_and_advertisement_data[
        switchbot_device_2.address
    ][1].manufacturer_data == {1: b"\x04", 2: b"\x01", 3: b"\x03"}

    assert (
        "00090401-0052-036b-3206-ff0a050a021a"
        not in scanner.discovered_devices_and_advertisement_data[
            switchbot_device_2.address
        ][1].service_data
    )

    scanner.inject_raw_advertisement(
        switchbot_device_2.address,
        0,
        b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04",
    )

    assert (
        "00090401-0052-036b-3206-ff0a050a021a"
        in scanner.discovered_devices_and_advertisement_data[
            switchbot_device_2.address
        ][1].service_data
    )

    assert scanner.serialize_discovered_devices() == DiscoveredDeviceAdvertisementData(
        connectable=True,
        expire_seconds=195,
        discovered_device_advertisement_datas={"44:44:33:11:23:45": ANY},
        discovered_device_timestamps={"44:44:33:11:23:45": ANY},
        discovered_device_raw={
            "44:44:33:11:23:45": b"\x12!\x1a\x02"
            b"\n\x05\n\xff"
            b"\x062k\x03"
            b"R\x00\x01\x04"
            b"\t\x00\x04"
        },
    )

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_raw_advertisement_fast_path_unchanged() -> None:
    """Test that sending the same raw bytes twice uses the fast path."""
    manager = get_manager()

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    raw_adv = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04"
    address = "44:44:33:11:23:45"

    # First raw advertisement — takes the slow path (parse + merge)
    scanner.inject_raw_advertisement(address, -60, raw_adv, 1.0)
    info1 = scanner._previous_service_info[address]
    assert info1.rssi == -60
    assert info1.raw == raw_adv

    # Second raw advertisement with same bytes — takes the fast path
    scanner.inject_raw_advertisement(address, -50, raw_adv, 2.0)
    info2 = scanner._previous_service_info[address]
    assert info2.rssi == -50
    assert info2.time == 2.0
    # Fast path reuses parsed data from prev_info
    assert info2.manufacturer_data is info1.manufacturer_data
    assert info2.service_data is info1.service_data
    assert info2.service_uuids is info1.service_uuids
    assert info2.name is info1.name
    assert info2.device is info1.device
    assert info2.raw is info1.raw

    # Third raw advertisement with different bytes — takes the slow path
    raw_adv_changed = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x05"
    scanner.inject_raw_advertisement(address, -40, raw_adv_changed, 3.0)
    info3 = scanner._previous_service_info[address]
    assert info3.rssi == -40
    assert info3.raw == raw_adv_changed
    # Slow path re-parsed, so objects may differ
    assert info3.raw is not info1.raw

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_connectable() -> None:
    """Test the remote scanner expires stale connectable data."""
    manager = get_manager()

    switchbot_device = generate_ble_device(
        "44:44:33:11:23:45",
        "wohand",
        {},
        rssi=-100,
    )
    switchbot_device_adv = generate_advertisement_data(
        local_name="wohand",
        service_uuids=[],
        manufacturer_data={1: b"\x01"},
        rssi=-100,
    )

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    start_time_monotonic = time.monotonic()
    scanner.inject_advertisement(switchbot_device, switchbot_device_adv)

    devices = scanner.discovered_devices
    assert len(scanner.discovered_devices) == 1
    assert len(scanner.discovered_devices_and_advertisement_data) == 1
    assert devices[0].name == "wohand"

    expire_monotonic = (
        start_time_monotonic
        + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
        + 1
    )
    expire_utc = utcnow() + timedelta(
        seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
    )
    with patch_bluetooth_time(expire_monotonic):
        async_fire_time_changed(expire_utc)
        await asyncio.sleep(0)

    devices = scanner.discovered_devices
    assert len(scanner.discovered_devices) == 0
    assert len(scanner.discovered_devices_and_advertisement_data) == 0

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_expires_non_connectable() -> None:
    """Test the remote scanner expires stale non connectable data."""
    manager = get_manager()

    switchbot_device = generate_ble_device(
        "44:44:33:11:23:45",
        "wohand",
        {},
        rssi=-100,
    )
    switchbot_device_adv = generate_advertisement_data(
        local_name="wohand",
        service_uuids=[],
        manufacturer_data={1: b"\x01"},
        rssi=-100,
    )

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    start_time_monotonic = time.monotonic()
    scanner.inject_advertisement(switchbot_device, switchbot_device_adv)

    devices = scanner.discovered_devices
    assert len(scanner.discovered_devices) == 1
    assert len(scanner.discovered_devices_and_advertisement_data) == 1
    assert len(scanner.discovered_device_timestamps) == 1
    dev_adv = scanner.get_discovered_device_advertisement_data(switchbot_device.address)
    assert dev_adv is not None
    dev, adv = dev_adv
    assert dev.name == "wohand"
    assert adv.local_name == "wohand"
    assert adv.manufacturer_data == switchbot_device_adv.manufacturer_data
    assert devices[0].name == "wohand"

    assert (
        FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
        > CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
    )

    # The connectable timeout is used for all devices
    # as the manager takes care of availability and the scanner
    # if only concerned about making a connection
    expire_monotonic = (
        start_time_monotonic
        + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS
        + 1
    )
    expire_utc = utcnow() + timedelta(
        seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
    )
    with patch_bluetooth_time(expire_monotonic):
        async_fire_time_changed(expire_utc)
        await asyncio.sleep(0)

    assert len(scanner.discovered_devices) == 0
    assert len(scanner.discovered_devices_and_advertisement_data) == 0
    assert len(scanner.discovered_device_timestamps) == 0
    assert (
        scanner.get_discovered_device_advertisement_data(switchbot_device.address)
        is None
    )

    expire_monotonic = (
        start_time_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
    )
    expire_utc = utcnow() + timedelta(
        seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1
    )
    with patch_bluetooth_time(expire_monotonic):
        async_fire_time_changed(expire_utc)
        await asyncio.sleep(0)

    assert len(scanner.discovered_devices) == 0
    assert len(scanner.discovered_devices_and_advertisement_data) == 0
    assert len(scanner.discovered_device_timestamps) == 0
    assert (
        scanner.get_discovered_device_advertisement_data(switchbot_device.address)
        is None
    )

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_discovered_device_timestamps_deprecated() -> None:
    """The leading-underscore shim still returns timestamps but warns."""
    manager = get_manager()
    switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}, rssi=-100)
    switchbot_device_adv = generate_advertisement_data(
        local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-100
    )
    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)
    scanner.inject_advertisement(switchbot_device, switchbot_device_adv)

    with pytest.warns(FutureWarning, match="_discovered_device_timestamps"):
        legacy = scanner._discovered_device_timestamps

    assert legacy == scanner.discovered_device_timestamps
    assert len(legacy) == 1

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_base_scanner_connecting_behavior() -> None:
    """Test the default behavior is to mark the scanner as not scanning on connect."""
    manager = get_manager()

    switchbot_device = generate_ble_device(
        "44:44:33:11:23:45",
        "wohand",
        {},
        rssi=-100,
    )
    switchbot_device_adv = generate_advertisement_data(
        local_name="wohand",
        service_uuids=[],
        manufacturer_data={1: b"\x01"},
        rssi=-100,
    )

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    with scanner.connecting():
        assert scanner.scanning is False

        # We should still accept new advertisements while connecting
        # since advertisements are delivered asynchronously and
        # we don't want to miss any even when we are willing to
        # accept advertisements from another scanner in the brief window
        # between when we start connecting and when we stop scanning
        scanner.inject_advertisement(switchbot_device, switchbot_device_adv)

    devices = scanner.discovered_devices
    assert len(scanner.discovered_devices) == 1
    assert len(scanner.discovered_devices_and_advertisement_data) == 1
    assert devices[0].name == "wohand"

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_stops_responding() -> None:
    """Test we mark a scanner are not scanning when it stops responding."""
    manager = get_manager()

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner(
        "esp32",
        "esp32",
        connector,
        True,
        current_mode=BluetoothScanningMode.ACTIVE,
        requested_mode=BluetoothScanningMode.ACTIVE,
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    start_time_monotonic = time.monotonic()

    assert scanner.scanning is True
    failure_reached_time = (
        start_time_monotonic
        + SCANNER_WATCHDOG_TIMEOUT
        + SCANNER_WATCHDOG_INTERVAL.total_seconds()
    )
    # We hit the timer with no detections,
    # so we reset the adapter and restart the scanner
    with patch_bluetooth_time(failure_reached_time):
        async_fire_time_changed(utcnow() + SCANNER_WATCHDOG_INTERVAL)
        await asyncio.sleep(0)

    assert scanner.scanning is False

    bparasite_device = generate_ble_device(  # type: ignore[unreachable]
        "44:44:33:11:23:45",
        "bparasite",
        {},
        rssi=-100,
    )
    bparasite_device_adv = generate_advertisement_data(
        local_name="bparasite",
        service_uuids=[],
        manufacturer_data={1: b"\x01"},
        rssi=-100,
    )

    failure_reached_time += 1

    with patch_bluetooth_time(failure_reached_time):
        scanner.inject_advertisement(
            bparasite_device, bparasite_device_adv, failure_reached_time
        )

    # As soon as we get a detection, we know the scanner is working again
    assert scanner.scanning is True
    assert scanner.requested_mode == BluetoothScanningMode.ACTIVE
    assert scanner.current_mode == BluetoothScanningMode.ACTIVE

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_existing() -> None:
    """Test merging manufacturer data history."""
    manager = get_manager()

    sensor_push_device = generate_ble_device(
        "44:44:33:11:23:45",
        "",
        {},
        rssi=-60,
    )
    sensor_push_device_adv = generate_advertisement_data(
        local_name="",
        rssi=-60,
        manufacturer_data={
            64256: b"B\r.\xa9\xb6",
            31488: b"\x98\xfa\xb6\x91\xb6",
        },
        service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
        service_data={},
    )

    sensor_push_adv_2 = generate_advertisement_data(
        local_name="",
        service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
        service_data={},
        manufacturer_data={
            31488: b"\x98\xfa\xb6\x91\xb6",
        },
        rssi=-100,
    )

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    details = scanner.details
    assert details == HaScannerDetails(
        source=scanner.source,
        connectable=scanner.connectable,
        name=scanner.name,
        adapter=scanner.adapter,
        scanner_type=HaScannerType.REMOTE,
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[sensor_push_device.address]
    assert discovered_device.address == sensor_push_device.address
    assert discovered_device.name == sensor_push_device.name
    assert (
        discovered_adv_data.manufacturer_data
        == sensor_push_device_adv.manufacturer_data
    )
    assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
    assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
    scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[sensor_push_device.address]
    assert discovered_device.address == sensor_push_device.address
    assert discovered_device.name == sensor_push_device.name
    assert discovered_adv_data.manufacturer_data == {
        **sensor_push_device_adv.manufacturer_data,
        **sensor_push_adv_2.manufacturer_data,
    }
    assert discovered_adv_data.service_data == {}
    assert set(discovered_adv_data.service_uuids) == {
        *sensor_push_device_adv.service_uuids
    }

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_merge_manufacturer_data_history_new() -> None:
    """Test merging manufacturer data history."""
    manager = get_manager()

    sensor_push_device = generate_ble_device(
        "44:44:33:11:23:45",
        "",
        {},
        rssi=-60,
    )
    sensor_push_device_adv = generate_advertisement_data(
        local_name="",
        rssi=-60,
        manufacturer_data={
            64256: b"B\r.\xa9\xb6",
            31488: b"\x98\xfa\xb6\x91\xb6",
        },
        service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
        service_data={},
    )

    sensor_push_adv_2 = generate_advertisement_data(
        local_name="",
        service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
        service_data={},
        manufacturer_data={
            21248: b"\xb9\xe9\xe1\xb9\xb6",
        },
        rssi=-100,
    )

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    details = scanner.details
    assert details == HaScannerDetails(
        source=scanner.source,
        connectable=scanner.connectable,
        name=scanner.name,
        adapter=scanner.adapter,
        scanner_type=HaScannerType.REMOTE,
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    scanner.inject_advertisement(sensor_push_device, sensor_push_device_adv)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[sensor_push_device.address]
    assert discovered_device.address == sensor_push_device.address
    assert discovered_device.name == sensor_push_device.name
    assert (
        discovered_adv_data.manufacturer_data
        == sensor_push_device_adv.manufacturer_data
    )
    assert discovered_adv_data.service_data == sensor_push_device_adv.service_data
    assert discovered_adv_data.service_uuids == sensor_push_device_adv.service_uuids
    scanner.inject_advertisement(sensor_push_device, sensor_push_adv_2)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[sensor_push_device.address]
    assert discovered_device.address == sensor_push_device.address
    assert discovered_device.name == sensor_push_device.name
    assert discovered_adv_data.manufacturer_data == {
        **sensor_push_device_adv.manufacturer_data,
        **sensor_push_adv_2.manufacturer_data,
    }
    assert discovered_adv_data.service_data == {}
    assert set(discovered_adv_data.service_uuids) == {
        *sensor_push_device_adv.service_uuids
    }

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_filter_apple_data() -> None:
    """Test filtering apple data accepts bytes that start with 01."""
    manager = get_manager()

    device = generate_ble_device(
        "44:44:33:11:23:45",
        "",
        {},
        rssi=-60,
    )
    device_adv = generate_advertisement_data(
        local_name="",
        rssi=-60,
        manufacturer_data={
            76: b"\x01\r.\xa9\xb6",
        },
        service_uuids=["ef090000-11d6-42ba-93b8-9dd7ec090ab0"],
        service_data={},
    )

    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    details = scanner.details
    assert details == HaScannerDetails(
        source=scanner.source,
        connectable=scanner.connectable,
        name=scanner.name,
        adapter=scanner.adapter,
        scanner_type=HaScannerType.REMOTE,
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    scanner.inject_advertisement(device, device_adv)

    data = scanner.discovered_devices_and_advertisement_data
    discovered_device, discovered_adv_data = data[device.address]
    assert discovered_device.address == device.address
    assert discovered_device.name == device.name
    assert discovered_adv_data.manufacturer_data == device_adv.manufacturer_data
    unsetup()
    cancel()


@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_count_in_progress() -> None:
    """Test connection history in process counting."""
    manager = get_manager()
    device1_address = "44:44:33:11:23:12"
    device2_address = "44:44:33:11:23:13"
    hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
    assert hci0_scanner is not None
    hci0_scanner._add_connecting(device1_address)
    assert hci0_scanner._connections_in_progress() == 1
    hci0_scanner._add_connecting(device1_address)
    hci0_scanner._add_connecting(device2_address)
    assert hci0_scanner._connections_in_progress() == 3
    hci0_scanner._finished_connecting(device1_address, True)
    assert hci0_scanner._connections_in_progress() == 2
    hci0_scanner._finished_connecting(device1_address, False)
    assert hci0_scanner._connections_in_progress() == 1
    hci0_scanner._finished_connecting(device2_address, False)
    assert hci0_scanner._connections_in_progress() == 0


@pytest.mark.usefixtures("register_hci0_scanner")
def test_connection_history_failure_count(caplog: pytest.LogCaptureFixture) -> None:
    """Test connection history failure count."""
    manager = get_manager()
    device1_address = "44:44:33:11:23:12"
    device2_address = "44:44:33:11:23:13"
    hci0_scanner = manager.async_scanner_by_source(HCI0_SOURCE_ADDRESS)
    assert hci0_scanner is not None
    hci0_scanner._add_connecting(device1_address)
    hci0_scanner._finished_connecting(device1_address, False)
    assert hci0_scanner._connection_failures(device1_address) == 1
    hci0_scanner._add_connecting(device1_address)
    hci0_scanner._add_connecting(device2_address)
    hci0_scanner._finished_connecting(device1_address, False)
    assert hci0_scanner._connection_failures(device1_address) == 2
    hci0_scanner._finished_connecting(device2_address, False)
    assert hci0_scanner._connection_failures(device2_address) == 1
    hci0_scanner._add_connecting(device1_address)
    hci0_scanner._finished_connecting(device1_address, True)
    # On success, we should reset the failure count
    assert hci0_scanner._connection_failures(device1_address) == 0

    assert "Removing a non-existing connecting" not in caplog.text
    hci0_scanner._finished_connecting(device1_address, True)
    assert "Removing a non-existing connecting" in caplog.text


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_scanner_mode_changes() -> None:
    """Test scanner mode change methods notify the manager."""
    manager = get_manager()

    # Track mode changes
    mode_changes: list[HaScannerModeChange] = []

    def mode_callback(change: HaScannerModeChange) -> None:
        """Track mode changes."""
        mode_changes.append(change)

    cancel = manager.async_register_scanner_mode_change_callback(mode_callback, None)

    # Create a scanner with initial modes
    scanner = FakeScanner(
        HCI0_SOURCE_ADDRESS,
        "hci0",
        connectable=True,
        requested_mode=BluetoothScanningMode.PASSIVE,
        current_mode=BluetoothScanningMode.PASSIVE,
    )

    # Set up the scanner
    unsetup = scanner.async_setup()

    # Test changing requested mode
    scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
    assert len(mode_changes) == 1
    assert mode_changes[0].scanner == scanner
    assert mode_changes[0].requested_mode == BluetoothScanningMode.ACTIVE
    assert mode_changes[0].current_mode == BluetoothScanningMode.PASSIVE
    assert scanner.requested_mode == BluetoothScanningMode.ACTIVE

    # Test changing current mode
    scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
    assert len(mode_changes) == 2
    assert mode_changes[1].scanner == scanner
    assert mode_changes[1].requested_mode == BluetoothScanningMode.ACTIVE
    assert mode_changes[1].current_mode == BluetoothScanningMode.ACTIVE
    assert scanner.current_mode == BluetoothScanningMode.ACTIVE

    # Test no notification when mode doesn't change
    scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
    assert len(mode_changes) == 2  # No new notification

    # Test setting to None
    scanner.set_requested_mode(None)
    assert len(mode_changes) == 3
    assert mode_changes[2].requested_mode is None
    assert scanner.requested_mode is None

    scanner.set_current_mode(None)  # type: ignore[unreachable]
    assert len(mode_changes) == 4
    assert mode_changes[3].current_mode is None
    assert scanner.current_mode is None

    # Clean up
    unsetup()
    cancel()


def test_remote_scanner_type() -> None:
    """Test that remote scanners have REMOTE type."""

    class TestRemoteScanner(BaseHaRemoteScanner):
        """Test remote scanner implementation."""

    scanner = TestRemoteScanner("test_source", "test_adapter")
    assert scanner.details.scanner_type is HaScannerType.REMOTE


def test_base_scanner_with_connector() -> None:
    """Test BaseHaScanner with connector and adapter type."""
    manager = get_manager()

    mock_adapters: dict[str, dict[str, Any]] = {
        "test_adapter": {
            "address": "00:1A:7D:DA:71:04",
            "adapter_type": "usb",
        }
    }

    connector = HaBluetoothConnector(
        client=MagicMock, source="test_source", can_connect=lambda: True
    )

    with patch.object(manager, "_adapters", mock_adapters):
        scanner = BaseHaScanner(
            source="test_source",
            adapter="test_adapter",
            connector=connector,
            connectable=True,
        )
        assert scanner.details.scanner_type is HaScannerType.USB


class TestScanner(BaseHaScanner):
    """Test scanner without slots for mocking."""

    __test__ = False

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._test_allocations = None

    def get_allocations(self):
        """Override to return test allocations."""
        return self._test_allocations


def test_score_with_no_allocations():
    """Test scoring when no allocation info is available."""
    scanner = TestScanner(
        source="test_source",
        adapter="test_adapter",
        connectable=True,
    )

    ble_device = BLEDevice(
        address="00:11:22:33:44:55",
        name="Test Device",
        details={},
    )
    advertisement = AdvertisementData(
        local_name="Test Device",
        manufacturer_data={},
        service_data={},
        service_uuids=[],
        rssi=-50,
        platform_data=(),
        tx_power=None,
    )
    scanner_device = BluetoothScannerDevice(
        scanner=scanner,
        ble_device=ble_device,
        advertisement=advertisement,
    )

    # No allocation info available, should just use RSSI
    score = scanner._score_connection_paths(10, scanner_device)
    assert score == -50


def test_score_with_all_slots_free():
    """Test scoring when all slots are free."""
    scanner = TestScanner(
        source="test_source",
        adapter="test_adapter",
        connectable=True,
    )

    # Set test allocations to return all slots free
    scanner._test_allocations = Allocations(
        adapter="test_adapter",
        slots=5,
        free=5,
        allocated=[],
    )

    ble_device = BLEDevice(
        address="00:11:22:33:44:55",
        name="Test Device",
        details={},
    )
    advertisement = AdvertisementData(
        local_name="Test Device",
        manufacturer_data={},
        service_data={},
        service_uuids=[],
        rssi=-50,
        platform_data=(),
        tx_power=None,
    )
    scanner_device = BluetoothScannerDevice(
        scanner=scanner,
        ble_device=ble_device,
        advertisement=advertisement,
    )

    # All slots free, no penalty
    score = scanner._score_connection_paths(10, scanner_device)
    assert score == -50


def test_score_with_one_slot_remaining():
    """Test scoring when only one slot remains."""
    scanner = TestScanner(
        source="test_source",
        adapter="test_adapter",
        connectable=True,
    )

    # Set test allocations to return only 1 slot free
    scanner._test_allocations = Allocations(
        adapter="test_adapter",
        slots=5,
        free=1,
        allocated=[
            "AA:BB:CC:DD:EE:01",
            "AA:BB:CC:DD:EE:02",
            "AA:BB:CC:DD:EE:03",
            "AA:BB:CC:DD:EE:04",
        ],
    )

    ble_device = BLEDevice(
        address="00:11:22:33:44:55",
        name="Test Device",
        details={},
    )
    advertisement = AdvertisementData(
        local_name="Test Device",
        manufacturer_data={},
        service_data={},
        service_uuids=[],
        rssi=-50,
        platform_data=(),
        tx_power=None,
    )
    scanner_device = BluetoothScannerDevice(
        scanner=scanner,
        ble_device=ble_device,
        advertisement=advertisement,
    )

    # One slot remaining, small penalty (rssi_diff * 0.76)
    score = scanner._score_connection_paths(10, scanner_device)
    assert score == -50 - (10 * 0.76)
    assert score == -57.6


def test_score_with_no_slots_available():
    """Test scoring when no slots are available."""
    scanner = TestScanner(
        source="test_source",
        adapter="test_adapter",
        connectable=True,
    )

    # Set test allocations to return no slots free
    scanner._test_allocations = Allocations(
        adapter="test_adapter",
        slots=5,
        free=0,
        allocated=[
            "AA:BB:CC:DD:EE:01",
            "AA:BB:CC:DD:EE:02",
            "AA:BB:CC:DD:EE:03",
            "AA:BB:CC:DD:EE:04",
            "AA:BB:CC:DD:EE:05",
        ],
    )

    ble_device = BLEDevice(
        address="00:11:22:33:44:55",
        name="Test Device",
        details={},
    )
    advertisement = AdvertisementData(
        local_name="Test Device",
        manufacturer_data={},
        service_data={},
        service_uuids=[],
        rssi=-50,
        platform_data=(),
        tx_power=None,
    )
    scanner_device = BluetoothScannerDevice(
        scanner=scanner,
        ble_device=ble_device,
        advertisement=advertisement,
    )

    # No slots available, returns NO_RSSI_VALUE (-127)
    score = scanner._score_connection_paths(10, scanner_device)
    assert score == -127


def test_score_comparison_with_different_slot_availability():
    """Test that scanners with more free slots score better."""
    # Scanner with all slots free
    scanner_all_free = TestScanner(
        source="adapter1",
        adapter="adapter1",
        connectable=True,
    )

    # Scanner with one slot remaining
    scanner_one_slot = TestScanner(
        source="adapter2",
        adapter="adapter2",
        connectable=True,
    )

    # Scanner with no slots
    scanner_no_slots = TestScanner(
        source="adapter3",
        adapter="adapter3",
        connectable=True,
    )

    ble_device = BLEDevice(
        address="00:11:22:33:44:55",
        name="Test Device",
        details={},
    )
    advertisement = AdvertisementData(
        local_name="Test Device",
        manufacturer_data={},
        service_data={},
        service_uuids=[],
        rssi=-50,
        platform_data=(),
        tx_power=None,
    )

    # Create scanner devices for each scanner
    device_all_free = BluetoothScannerDevice(
        scanner=scanner_all_free,
        ble_device=ble_device,
        advertisement=advertisement,
    )
    device_one_slot = BluetoothScannerDevice(
        scanner=scanner_one_slot,
        ble_device=ble_device,
        advertisement=advertisement,
    )
    device_no_slots = BluetoothScannerDevice(
        scanner=scanner_no_slots,
        ble_device=ble_device,
        advertisement=advertisement,
    )

    # Set allocations for each scanner
    scanner_all_free._test_allocations = Allocations(
        adapter="adapter1",
        slots=5,
        free=5,
        allocated=[],
    )
    scanner_one_slot._test_allocations = Allocations(
        adapter="adapter2",
        slots=5,
        free=1,
        allocated=[
            "AA:BB:CC:DD:EE:01",
            "AA:BB:CC:DD:EE:02",
            "AA:BB:CC:DD:EE:03",
            "AA:BB:CC:DD:EE:04",
        ],
    )
    scanner_no_slots._test_allocations = Allocations(
        adapter="adapter3",
        slots=5,
        free=0,
        allocated=[
            "AA:BB:CC:DD:EE:01",
            "AA:BB:CC:DD:EE:02",
            "AA:BB:CC:DD:EE:03",
            "AA:BB:CC:DD:EE:04",
            "AA:BB:CC:DD:EE:05",
        ],
    )

    score_all_free = scanner_all_free._score_connection_paths(10, device_all_free)
    score_one_slot = scanner_one_slot._score_connection_paths(10, device_one_slot)
    score_no_slots = scanner_no_slots._score_connection_paths(10, device_no_slots)

    # Verify the scoring order: all_free > one_slot > no_slots
    assert score_all_free > score_one_slot
    assert score_one_slot > score_no_slots

    # Verify specific values
    assert score_all_free == -50
    assert score_one_slot == -57.6
    assert score_no_slots == -127  # NO_RSSI_VALUE


def test_score_with_connections_in_progress_and_slots():
    """Test that both connection progress and slot availability are considered."""
    scanner = TestScanner(
        source="test_source",
        adapter="test_adapter",
        connectable=True,
    )

    # Add a connection in progress
    scanner._add_connecting("FF:EE:DD:CC:BB:AA")

    # Set test allocations to return only 1 slot free
    scanner._test_allocations = Allocations(
        adapter="test_adapter",
        slots=5,
        free=1,
        allocated=[
            "AA:BB:CC:DD:EE:01",
            "AA:BB:CC:DD:EE:02",
            "AA:BB:CC:DD:EE:03",
            "AA:BB:CC:DD:EE:04",
        ],
    )

    ble_device = BLEDevice(
        address="00:11:22:33:44:55",
        name="Test Device",
        details={},
    )
    advertisement = AdvertisementData(
        local_name="Test Device",
        manufacturer_data={},
        service_data={},
        service_uuids=[],
        rssi=-50,
        platform_data=(),
        tx_power=None,
    )
    scanner_device = BluetoothScannerDevice(
        scanner=scanner,
        ble_device=ble_device,
        advertisement=advertisement,
    )

    # Both penalties should apply
    score = scanner._score_connection_paths(10, scanner_device)
    # -50 (RSSI) - 10.1 (connection in progress) - 7.6 (last slot)
    assert score == -50 - 10.1 - 7.6
    assert score == -67.7


@pytest.mark.asyncio
async def test_on_scanner_start_callback_remote_scanner(
    async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
    """Test that on_scanner_start is called when a remote scanner starts."""
    manager = async_mock_manager_with_scanner_callbacks

    # Create a fake remote scanner
    scanner = FakeScanner(
        source="esp32_proxy",
        adapter="esp32_proxy",
        connector=None,
        connectable=True,
    )

    # Simulate scanner start success
    scanner._on_start_success()

    # Verify the callback was called
    assert len(manager.scanner_start_calls) == 1
    assert manager.scanner_start_calls[0] is scanner


@pytest.mark.asyncio
async def test_on_scanner_start_multiple_scanners(
    async_mock_manager_with_scanner_callbacks: MockBluetoothManagerWithCallbacks,
) -> None:
    """Test that on_scanner_start is called for multiple scanners."""
    manager = async_mock_manager_with_scanner_callbacks

    # Create multiple scanners
    scanner1 = FakeScanner(
        source="scanner1",
        adapter="scanner1",
        connector=None,
        connectable=True,
    )

    scanner2 = FakeScanner(
        source="scanner2",
        adapter="scanner2",
        connector=None,
        connectable=True,
    )

    # Simulate both scanners starting
    scanner1._on_start_success()
    scanner2._on_start_success()

    # Verify both callbacks were called
    assert len(manager.scanner_start_calls) == 2
    assert scanner1 in manager.scanner_start_calls
    assert scanner2 in manager.scanner_start_calls


@pytest.mark.asyncio
async def test_scanner_without_manager() -> None:
    """Test that _on_start_success handles scanner without manager gracefully."""
    # Set a temporary manager for scanner creation
    mock_bluetooth_adapters = FakeBluetoothAdapters()
    temp_manager = BluetoothManager(
        mock_bluetooth_adapters,
        slot_manager=Mock(),
    )

    original_manager = get_manager()
    set_manager(temp_manager)

    try:
        # Create a scanner
        scanner = FakeScanner(
            source="test",
            adapter="test",
            connector=None,
            connectable=True,
        )

        # Clear the manager to simulate no manager scenario
        scanner._manager = None  # type: ignore[assignment]

        # Should not raise an exception
        scanner._on_start_success()

    finally:
        # Restore original manager
        set_manager(original_manager)


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_restore_discovered_devices() -> None:
    """Test serialize→restore round-trip on a BaseHaRemoteScanner."""
    manager = get_manager()
    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )

    # First scanner: receives advertisements, then serializes.
    src_scanner = FakeScanner("esp32", "esp32", connector, True)
    src_unsetup = src_scanner.async_setup()
    src_cancel = manager.async_register_scanner(src_scanner)

    address = "44:44:33:11:23:45"
    device = generate_ble_device(address, "wohand", {})
    adv = generate_advertisement_data(
        local_name="wohand",
        service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"],
        service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"},
        manufacturer_data={1: b"\x01"},
        rssi=-60,
        tx_power=4,
    )
    now = monotonic_time_coarse()
    src_scanner.inject_advertisement(device, adv, now=now)
    raw_adv = b"\x12\x21\x1a\x02\n\x05\n\xff\x062k\x03R\x00\x01\x04\t\x00\x04"
    src_scanner.inject_raw_advertisement(address, -55, raw_adv, now=now + 1.0)

    history = src_scanner.serialize_discovered_devices()
    assert history.discovered_device_raw[address] == raw_adv
    assert history.discovered_device_timestamps[address] == now + 1.0

    # Second scanner: fresh instance, restore from serialized blob.
    dst_scanner = FakeScanner("esp32-replay", "esp32-replay", connector, True)
    dst_unsetup = dst_scanner.async_setup()
    dst_cancel = manager.async_register_scanner(dst_scanner)

    assert dst_scanner.discovered_devices == []
    dst_scanner.restore_discovered_devices(history)

    restored = dst_scanner.discovered_devices_and_advertisement_data
    assert set(restored) == {address}
    rest_device, rest_adv = restored[address]
    assert rest_device.address == address
    assert rest_device.name == "wohand"
    assert rest_adv.manufacturer_data == adv.manufacturer_data
    assert rest_adv.service_uuids == adv.service_uuids
    # Restored source overrides the previous scanner's source — the device
    # belongs to the scanner that owns it now.
    assert dst_scanner._previous_service_info[address].source == "esp32-replay"
    # Raw advertisement bytes survive the round-trip.
    assert dst_scanner._previous_service_info[address].raw == raw_adv

    src_cancel()
    src_unsetup()
    dst_cancel()
    dst_unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_remote_scanner_async_diagnostics() -> None:
    """Test BaseHaRemoteScanner.async_diagnostics shape."""
    manager = get_manager()
    connector = HaBluetoothConnector(
        MockBleakClient, "mock_bleak_client", lambda: False
    )
    scanner = FakeScanner("esp32", "esp32", connector, True)
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    address = "44:44:33:11:23:45"
    device = generate_ble_device(address, "wohand", {})
    adv = generate_advertisement_data(
        local_name="wohand",
        service_uuids=[],
        service_data={},
        manufacturer_data={1: b"\x01"},
        rssi=-60,
    )
    now = monotonic_time_coarse()
    scanner.inject_advertisement(device, adv, now=now)
    raw = b"\x02\x01\x06"
    scanner.inject_raw_advertisement(address, -55, raw, now=now + 1.0)

    diagnostics = await scanner.async_diagnostics()
    # Remote scanner adds these three keys on top of the base diagnostics.
    assert diagnostics["raw_advertisement_data"] == {address: raw}
    assert diagnostics["discovered_device_timestamps"] == {address: now + 1.0}
    assert set(diagnostics["time_since_last_device_detection"]) == {address}
    # Base keys still present.
    assert diagnostics["source"] == "esp32"
    assert diagnostics["connectable"] is True
    # Connection counters start empty.
    assert diagnostics["connect_in_progress"] == {}
    assert diagnostics["connect_failures"] == {}

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_diagnostics_surface_connect_state() -> None:
    """async_diagnostics should expose connect-in-progress and failure counts."""
    manager = get_manager()
    scanner = FakeScanner(
        source="diag", adapter="diag", connector=None, connectable=True
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    addr_a = "AA:BB:CC:DD:EE:01"
    addr_b = "AA:BB:CC:DD:EE:02"

    # Two attempts in flight for addr_a, one for addr_b.
    scanner._add_connecting(addr_a)
    scanner._add_connecting(addr_a)
    scanner._add_connecting(addr_b)

    # addr_a then succeeds (clears its failures), addr_b fails twice.
    scanner._finished_connecting(addr_a, connected=True)
    scanner._finished_connecting(addr_b, connected=False)
    scanner._add_connecting(addr_b)
    scanner._finished_connecting(addr_b, connected=False)

    diagnostics = await scanner.async_diagnostics()
    # addr_a still has one in-flight attempt, addr_b is fully drained.
    assert diagnostics["connect_in_progress"] == {addr_a: 1}
    assert diagnostics["connect_failures"] == {addr_b: 2}
    # Lifetime counters: one success (addr_a), two failures (addr_b twice).
    assert diagnostics["connect_completed_total"] == 1
    assert diagnostics["connect_failed_total"] == 2
    assert diagnostics["last_connect_completed_time"] > 0.0
    # Returned dicts must be copies — mutating them doesn't poison scanner state.
    diagnostics["connect_in_progress"].clear()
    diagnostics["connect_failures"].clear()
    assert scanner._connect_in_progress == {addr_a: 1}
    assert scanner._connect_failures == {addr_b: 2}

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_lifetime_connect_counters_reset_on_history_clear() -> None:
    """Lifetime counters should reset whenever connection history is cleared."""
    manager = get_manager()
    scanner = FakeScanner(
        source="counters", adapter="counters", connector=None, connectable=True
    )
    unsetup = scanner.async_setup()
    cancel = manager.async_register_scanner(scanner)

    addr = "AA:BB:CC:DD:EE:99"
    # Three successes, one failure → totals reflect lifetime, not per-address state.
    for _ in range(3):
        scanner._add_connecting(addr)
        scanner._finished_connecting(addr, connected=True)
    scanner._add_connecting(addr)
    scanner._finished_connecting(addr, connected=False)

    assert scanner._connect_completed_total == 3
    assert scanner._connect_failed_total == 1
    assert scanner._last_connect_completed_time > 0.0
    # Per-address failure count survives because success preceded the last failure.
    assert scanner._connect_failures == {addr: 1}

    scanner._clear_connection_history()
    assert scanner._connect_completed_total == 0
    assert scanner._connect_failed_total == 0
    assert scanner._last_connect_completed_time == 0.0
    assert scanner._connect_failures == {}
    assert scanner._connect_in_progress == {}

    cancel()
    unsetup()


@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.asyncio
async def test_set_mode_noop_when_unchanged() -> None:
    """set_requested_mode and set_current_mode should be no-ops on same value."""
    manager = get_manager()
    scanner = FakeScanner(
        source="modes",
        adapter="modes",
        connector=None,
        connectable=True,
        requested_mode=BluetoothScanningMode.ACTIVE,
    )
    with patch.object(manager, "scanner_mode_changed") as notify:
        # Same value → setter is a no-op, no notify.
        scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
        notify.assert_not_called()

        # Different value → notify fires once.
        scanner.set_requested_mode(BluetoothScanningMode.PASSIVE)
        assert notify.call_count == 1

        # current_mode was None at init; setting None is a no-op.
        scanner.set_current_mode(None)
        assert notify.call_count == 1

        # Different value → notify fires again.
        scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
        assert notify.call_count == 2

        # Repeating same value is a no-op.
        scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
        assert notify.call_count == 2
