# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations

import asyncio
import collections
import contextlib
import dataclasses
import functools
import logging
import secrets
import sys
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
from typing import (
    Any,
)

import click

if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib

try:
    import lc3  # type: ignore  # pylint: disable=E0401
except ImportError as e:
    raise ImportError(
        "Try `python -m pip install \"git+https://github.com/google/liblc3.git\"`."
    ) from e

import bumble.device
import bumble.logging
import bumble.transport
import bumble.utils
from bumble import company_ids, core, data_types, gatt, hci
from bumble.audio import io as audio_io
from bumble.colors import color
from bumble.profiles import bap, bass, le_audio, pbp

# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)


# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
AURACAST_DEFAULT_DEVICE_NAME = 'Bumble Auracast'
AURACAST_DEFAULT_DEVICE_ADDRESS = hci.Address('F0:F1:F2:F3:F4:F5')
AURACAST_DEFAULT_SYNC_TIMEOUT = 5.0
AURACAST_DEFAULT_ATT_MTU = 256
AURACAST_DEFAULT_FRAME_DURATION = 10000
AURACAST_DEFAULT_TRANSMIT_BITRATE = 80000
AURACAST_DEFAULT_BROADCAST_ID = 123456
AURACAST_DEFAULT_BROADCAST_NAME = 'Bumble Auracast'
AURACAST_DEFAULT_LANGUAGE = 'en'
AURACAST_DEFAULT_PROGRAM_INFO = 'Disco'


# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
def codec_config_string(
    codec_config: bap.CodecSpecificConfiguration, indent: str
) -> str:
    lines = []
    if codec_config.sampling_frequency is not None:
        lines.append(f'Sampling Frequency: {codec_config.sampling_frequency.hz} hz')
    if codec_config.frame_duration is not None:
        lines.append(f'Frame Duration:     {codec_config.frame_duration.us} µs')
    if codec_config.octets_per_codec_frame is not None:
        lines.append(f'Frame Size:         {codec_config.octets_per_codec_frame} bytes')
    if codec_config.codec_frames_per_sdu is not None:
        lines.append(f'Frames Per SDU:     {codec_config.codec_frames_per_sdu}')
    if codec_config.audio_channel_allocation is not None:
        lines.append(
            f'Audio Location:     {codec_config.audio_channel_allocation.name}'
        )
    return '\n'.join(indent + line for line in lines)


def broadcast_code_bytes(broadcast_code: str) -> bytes:
    """
    Convert a broadcast code string to a 16-byte value.

    If `broadcast_code` is `0x` followed by 32 hex characters, it is interpreted as a
    raw 16-byte raw broadcast code in big-endian byte order.
    Otherwise, `broadcast_code` is converted to a 16-byte value as specified in
    BLUETOOTH CORE SPECIFICATION Version 6.0 | Vol 3, Part C , section 3.2.6.3
    """
    if broadcast_code.startswith("0x") and len(broadcast_code) == 34:
        return bytes.fromhex(broadcast_code[2:])[::-1]

    broadcast_code_utf8 = broadcast_code.encode("utf-8")
    if len(broadcast_code_utf8) > 16:
        raise ValueError("broadcast code must be <= 16 bytes in utf-8 encoding")
    padding = bytes(16 - len(broadcast_code_utf8))
    return broadcast_code_utf8 + padding


def parse_broadcast_list(filename: str) -> Sequence[Broadcast]:
    broadcasts: list[Broadcast] = []

    with open(filename, "rb") as config_file:
        config = tomllib.load(config_file)
        for broadcast in config.get("broadcasts", []):
            sources = []
            for source in broadcast.get("sources", []):
                sources.append(
                    BroadcastSource(
                        input=source["input"],
                        input_format=source.get("format", "auto"),
                        bitrate=source.get(
                            "bitrate", AURACAST_DEFAULT_TRANSMIT_BITRATE
                        ),
                    )
                )

            manufacturer_data = broadcast.get("manufacturer_data")
            if manufacturer_data is not None:
                manufacturer_data = (
                    manufacturer_data.get("company_id"),
                    bytes.fromhex(manufacturer_data["data"]),
                )
            broadcasts.append(
                Broadcast(
                    sources=sources,
                    public=broadcast.get("public", True),
                    broadcast_id=broadcast.get("id", AURACAST_DEFAULT_BROADCAST_ID),
                    broadcast_name=broadcast["name"],
                    broadcast_code=broadcast.get("code"),
                    manufacturer_data=broadcast.get("manufacturer_data"),
                    language=broadcast.get("language"),
                    program_info=broadcast.get("program_info"),
                )
            )

    return broadcasts


def assign_broadcast_ids(broadcasts: Sequence[Broadcast]) -> None:
    broadcast_ids = set()
    for broadcast in broadcasts:
        if broadcast.broadcast_id:
            if broadcast.broadcast_id in broadcast_ids:
                raise ValueError(f'duplicate broadcast ID {broadcast.broadcast_id}')
            broadcast_ids.add(broadcast.broadcast_id)
        else:
            while True:
                broadcast.broadcast_id = 1 + secrets.randbelow(0xFFFFFF)
                if broadcast.broadcast_id not in broadcast_ids:
                    broadcast_ids.add(broadcast.broadcast_id)
                    break


@dataclasses.dataclass
class Broadcast:
    sources: list[BroadcastSource]
    public: bool
    broadcast_id: int  # 0 means unassigned
    broadcast_name: str
    broadcast_code: str | None
    manufacturer_data: tuple[int, bytes] | None = None
    language: str | None = None
    program_info: str | None = None
    audio_sources: list[AudioSource] = dataclasses.field(default_factory=list)
    iso_queues: list[bumble.device.IsoPacketStream] = dataclasses.field(
        default_factory=list
    )


@dataclasses.dataclass
class BroadcastSource:
    input: str
    input_format: str
    bitrate: int


@dataclasses.dataclass
class AudioSource:
    audio_input: audio_io.AudioInput
    pcm_format: audio_io.PcmFormat
    pcm_bit_depth: int | None
    lc3_encoder: lc3.Encoder
    lc3_frame_samples: int
    lc3_frame_size: int
    audio_frames: AsyncGenerator


# -----------------------------------------------------------------------------
# Scan For Broadcasts
# -----------------------------------------------------------------------------
class BroadcastScanner(bumble.utils.EventEmitter):
    @dataclasses.dataclass
    class Broadcast(bumble.utils.EventEmitter):
        name: str | None
        sync: bumble.device.PeriodicAdvertisingSync
        broadcast_id: int
        rssi: int = 0
        public_broadcast_announcement: pbp.PublicBroadcastAnnouncement | None = None
        broadcast_audio_announcement: bap.BroadcastAudioAnnouncement | None = None
        basic_audio_announcement: bap.BasicAudioAnnouncement | None = None
        appearance: core.Appearance | None = None
        biginfo: bumble.device.BigInfoAdvertisement | None = None
        manufacturer_data: tuple[str, bytes] | None = None
        device_name: str | None = None

        def __post_init__(self) -> None:
            super().__init__()
            self.sync.on('establishment', self.on_sync_establishment)
            self.sync.on('loss', self.on_sync_loss)
            self.sync.on('periodic_advertisement', self.on_periodic_advertisement)
            self.sync.on('biginfo_advertisement', self.on_biginfo_advertisement)

        def update(self, advertisement: bumble.device.Advertisement) -> None:
            self.rssi = advertisement.rssi
            for service_data in advertisement.data.get_all(
                core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
            ):
                service_uuid, data = service_data

                if service_uuid == gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE:
                    self.public_broadcast_announcement = (
                        pbp.PublicBroadcastAnnouncement.from_bytes(data)
                    )
                    continue

                if service_uuid == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE:
                    self.broadcast_audio_announcement = (
                        bap.BroadcastAudioAnnouncement.from_bytes(data)
                    )
                    continue

            self.device_name = advertisement.data.get(
                core.AdvertisingData.Type.COMPLETE_LOCAL_NAME
            )

            self.appearance = advertisement.data.get(
                core.AdvertisingData.Type.APPEARANCE
            )

            if manufacturer_data := advertisement.data.get(
                core.AdvertisingData.Type.MANUFACTURER_SPECIFIC_DATA
            ):
                company_id, data = manufacturer_data
                self.manufacturer_data = (
                    company_ids.COMPANY_IDENTIFIERS.get(
                        company_id, f'0x{company_id:04X}'
                    ),
                    data,
                )

            self.emit('update')

        def print(self) -> None:
            print(
                color('Broadcast:', 'yellow'),
                self.sync.advertiser_address,
                color(self.sync.state.name, 'green'),
            )
            if self.name is not None:
                print(f'  {color("Broadcast Name", "cyan")}: {self.name}')
            if self.device_name:
                print(f'  {color("Device Name", "cyan")}:    {self.device_name}')
            if self.appearance:
                print(f'  {color("Appearance", "cyan")}:     {str(self.appearance)}')
            print(f'  {color("RSSI", "cyan")}:           {self.rssi}')
            print(f'  {color("SID", "cyan")}:            {self.sync.sid}')

            if self.manufacturer_data:
                print(
                    f'  {color("Manufacturer Data", "cyan")}: '
                    f'{self.manufacturer_data[0]} -> {self.manufacturer_data[1].hex()}'
                )

            if self.broadcast_audio_announcement:
                print(
                    f'  {color("Broadcast ID", "cyan")}:   '
                    f'{self.broadcast_audio_announcement.broadcast_id}'
                )

            if self.public_broadcast_announcement:
                print(
                    f'  {color("Features", "cyan")}:       '
                    f'{self.public_broadcast_announcement.features.name}'
                )
                if self.public_broadcast_announcement.metadata.entries:
                    print(f'  {color("Metadata", "cyan")}:  ')
                    print(
                        self.public_broadcast_announcement.metadata.pretty_print(
                            '      '
                        )
                    )

            if self.basic_audio_announcement:
                print(color('  Audio:', 'cyan'))
                print(
                    color('    Presentation Delay:', 'magenta'),
                    self.basic_audio_announcement.presentation_delay,
                    "µs",
                )
                for subgroup in self.basic_audio_announcement.subgroups:
                    print(color('    Subgroup:', 'magenta'))
                    print(color('      Codec ID:', 'yellow'))
                    print(
                        color('        Coding Format:           ', 'green'),
                        subgroup.codec_id.codec_id.name,
                    )
                    if subgroup.codec_id.company_id:
                        print(
                            color('        Company ID:              ', 'green'),
                            subgroup.codec_id.company_id,
                        )
                        print(
                            color('        Vendor Specific Codec ID:', 'green'),
                            subgroup.codec_id.vendor_specific_codec_id,
                        )
                    print(color('      Codec Config:', 'yellow'))
                    print(
                        codec_config_string(
                            subgroup.codec_specific_configuration, '        '
                        ),
                    )
                    if subgroup.metadata.entries:
                        print(color('      Metadata:    ', 'yellow'))
                        print(subgroup.metadata.pretty_print('        '))

                    for bis in subgroup.bis:
                        print(color(f'      BIS [{bis.index}]:', 'yellow'))
                        print(color('       Codec Config:', 'green'))
                        print(
                            codec_config_string(
                                bis.codec_specific_configuration, '         '
                            ),
                        )

            if self.biginfo:
                print(color('  BIG:', 'cyan'))
                print(color('    Number of BIS:', 'magenta'), self.biginfo.num_bis)
                print(color('    ISO Interval: ', 'magenta'), self.biginfo.iso_interval)
                print(color('    Max PDU:      ', 'magenta'), self.biginfo.max_pdu)
                print(color('    SDU Interval: ', 'magenta'), self.biginfo.sdu_interval)
                print(color('    Max SDU:      ', 'magenta'), self.biginfo.max_sdu)
                print(color('    PHY:          ', 'magenta'), self.biginfo.phy.name)
                print(color('    Framing:      ', 'magenta'), self.biginfo.framing.name)
                print(
                    color('    Encryption:   ', 'magenta'), self.biginfo.encryption.name
                )

        def on_sync_establishment(self) -> None:
            self.emit('sync_establishment')

        def on_sync_loss(self) -> None:
            self.basic_audio_announcement = None
            self.biginfo = None
            self.emit('sync_loss')

        def on_periodic_advertisement(
            self, advertisement: bumble.device.PeriodicAdvertisement
        ) -> None:
            if advertisement.data is None:
                return

            for service_data in advertisement.data.get_all(
                core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
            ):
                service_uuid, data = service_data

                if service_uuid == gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
                    self.basic_audio_announcement = (
                        bap.BasicAudioAnnouncement.from_bytes(data)
                    )
                    break

            self.emit('change')

        def on_biginfo_advertisement(
            self, advertisement: bumble.device.BigInfoAdvertisement
        ) -> None:
            self.biginfo = advertisement
            self.emit('change')

    def __init__(
        self,
        device: bumble.device.Device,
        filter_duplicates: bool,
        sync_timeout: float,
    ):
        super().__init__()
        self.device = device
        self.filter_duplicates = filter_duplicates
        self.sync_timeout = sync_timeout
        self.broadcasts = dict[tuple[hci.Address, int], BroadcastScanner.Broadcast]()
        device.on('advertisement', self.on_advertisement)

    async def start(self) -> None:
        await self.device.start_scanning(
            active=False,
            filter_duplicates=False,
        )

    async def stop(self) -> None:
        await self.device.stop_scanning()

    def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
        if not (
            ads := advertisement.data.get_all(
                core.AdvertisingData.Type.SERVICE_DATA_16_BIT_UUID
            )
        ) or not (
            broadcast_audio_announcement_ad := next(
                (
                    ad
                    for ad in ads
                    if ad[0] == gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
                ),
                None,
            )
        ):
            return

        broadcast_name = advertisement.data.get_all(
            core.AdvertisingData.Type.BROADCAST_NAME
        )

        broadcast_audio_announcement = bap.BroadcastAudioAnnouncement.from_bytes(
            broadcast_audio_announcement_ad[1]
        )

        if broadcast := self.broadcasts.get(
            (advertisement.address, broadcast_audio_announcement.broadcast_id)
        ):
            broadcast.update(advertisement)
            return

        bumble.utils.AsyncRunner.spawn(
            self.on_new_broadcast(
                broadcast_name[0] if broadcast_name else None,
                advertisement,
                broadcast_audio_announcement.broadcast_id,
            )
        )

    async def on_new_broadcast(
        self,
        name: str | None,
        advertisement: bumble.device.Advertisement,
        broadcast_id: int,
    ) -> None:
        periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
            advertiser_address=advertisement.address,
            sid=advertisement.sid,
            sync_timeout=self.sync_timeout,
            filter_duplicates=self.filter_duplicates,
        )
        broadcast = self.Broadcast(name, periodic_advertising_sync, broadcast_id)
        broadcast.update(advertisement)
        self.broadcasts[(advertisement.address, broadcast_id)] = broadcast
        periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
        self.emit('new_broadcast', broadcast)

    def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
        del self.broadcasts[(broadcast.sync.advertiser_address, broadcast.broadcast_id)]
        bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
        self.emit('broadcast_loss', broadcast)


class PrintingBroadcastScanner(bumble.utils.EventEmitter):
    def __init__(
        self, device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
    ) -> None:
        super().__init__()
        self.scanner = BroadcastScanner(device, filter_duplicates, sync_timeout)
        self.scanner.on('new_broadcast', self.on_new_broadcast)
        self.scanner.on('broadcast_loss', self.on_broadcast_loss)
        self.scanner.on('update', self.refresh)
        self.status_message = ''

    async def start(self) -> None:
        self.status_message = color('Scanning...', 'green')
        await self.scanner.start()

    def on_new_broadcast(self, broadcast: BroadcastScanner.Broadcast) -> None:
        self.status_message = color(
            f'+Found {len(self.scanner.broadcasts)} broadcasts', 'green'
        )
        broadcast.on('change', self.refresh)
        broadcast.on('update', self.refresh)
        self.refresh()

    def on_broadcast_loss(self, broadcast: BroadcastScanner.Broadcast) -> None:
        self.status_message = color(
            f'-Found {len(self.scanner.broadcasts)} broadcasts', 'green'
        )
        self.refresh()

    def refresh(self) -> None:
        # Clear the screen from the top
        print('\033[H')
        print('\033[0J')
        print('\033[H')

        # Print the status message
        print(self.status_message)
        print("==========================================")

        # Print all broadcasts
        for broadcast in self.scanner.broadcasts.values():
            broadcast.print()
            print('------------------------------------------')

        # Clear the screen to the bottom
        print('\033[0J')


@contextlib.asynccontextmanager
async def create_device(transport: str) -> AsyncGenerator[bumble.device.Device, Any]:
    async with await bumble.transport.open_transport(transport) as (
        hci_source,
        hci_sink,
    ):
        device_config = bumble.device.DeviceConfiguration(
            name=AURACAST_DEFAULT_DEVICE_NAME,
            address=AURACAST_DEFAULT_DEVICE_ADDRESS,
            keystore='JsonKeyStore',
        )

        device = bumble.device.Device.from_config_with_hci(
            device_config,
            hci_source,
            hci_sink,
        )
        await device.power_on()

        yield device


async def find_broadcast_by_name(
    device: bumble.device.Device, name: str | None
) -> BroadcastScanner.Broadcast:
    result = asyncio.get_running_loop().create_future()

    def on_broadcast_change(broadcast: BroadcastScanner.Broadcast) -> None:
        if broadcast.basic_audio_announcement and not result.done():
            print(color('Broadcast basic audio announcement received', 'green'))
            result.set_result(broadcast)

    def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
        if name is None or broadcast.name == name:
            print(color('Broadcast found:', 'green'), broadcast.name)
            broadcast.on('change', lambda: on_broadcast_change(broadcast))
            return

        print(color(f'Skipping broadcast {broadcast.name}'))

    scanner = BroadcastScanner(device, False, AURACAST_DEFAULT_SYNC_TIMEOUT)
    scanner.on('new_broadcast', on_new_broadcast)
    await scanner.start()

    broadcast = await result
    await scanner.stop()

    return broadcast


async def run_scan(
    device: bumble.device.Device, filter_duplicates: bool, sync_timeout: float
) -> None:
    if not device.supports_le_periodic_advertising:
        print(color('Periodic advertising not supported', 'red'))
        return

    scanner = PrintingBroadcastScanner(device, filter_duplicates, sync_timeout)
    await scanner.start()
    await asyncio.get_running_loop().create_future()


async def run_assist(
    device: bumble.device.Device,
    broadcast_name: str | None,
    source_id: int | None,
    command: str,
    address: str,
) -> None:
    if not device.supports_le_periodic_advertising:
        print(color('Periodic advertising not supported', 'red'))
        return

    # Connect to the server
    print(f'=== Connecting to {address}...')
    connection = await device.connect(address)
    peer = bumble.device.Peer(connection)
    print(f'=== Connected to {peer}')

    print("+++ Encrypting connection...")
    await peer.connection.encrypt()
    print("+++ Connection encrypted")

    # Request a larger MTU
    mtu = AURACAST_DEFAULT_ATT_MTU
    print(color(f'$$$ Requesting MTU={mtu}', 'yellow'))
    await peer.request_mtu(mtu)

    # Get the BASS service
    bass_client = await peer.discover_service_and_create_proxy(
        bass.BroadcastAudioScanServiceProxy
    )

    # Check that the service was found
    if not bass_client:
        print(color('!!! Broadcast Audio Scan Service not found', 'red'))
        return

    # Subscribe to and read the broadcast receive state characteristics
    def on_broadcast_receive_state_update(
        value: bass.BroadcastReceiveState | None, index: int
    ) -> None:
        if value is not None:
            print(
                f"{color(f'Broadcast Receive State Update [{index}]:', 'green')} {value}"
            )

    for i, broadcast_receive_state in enumerate(bass_client.broadcast_receive_states):
        try:
            await broadcast_receive_state.subscribe(
                functools.partial(on_broadcast_receive_state_update, index=i)
            )
        except core.ProtocolError as error:
            print(
                color(
                    '!!! Failed to subscribe to Broadcast Receive State characteristic',
                    'red',
                ),
                error,
            )
        value = await broadcast_receive_state.read_value()
        print(f'{color(f"Initial Broadcast Receive State [{i}]:", "green")} {value}')

    if command == 'monitor-state':
        await peer.sustain()
        return

    if command == 'add-source':
        # Find the requested broadcast
        await bass_client.remote_scan_started()
        if broadcast_name:
            print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
        else:
            print(color('Scanning for any broadcast', 'cyan'))
        broadcast = await find_broadcast_by_name(device, broadcast_name)

        if broadcast.broadcast_audio_announcement is None:
            print(color('No broadcast audio announcement found', 'red'))
            return

        if (
            broadcast.basic_audio_announcement is None
            or not broadcast.basic_audio_announcement.subgroups
        ):
            print(color('No subgroups found', 'red'))
            return

        # Add the source
        print(color('Adding source:', 'blue'), broadcast.sync.advertiser_address)
        await bass_client.add_source(
            broadcast.sync.advertiser_address,
            broadcast.sync.sid,
            broadcast.broadcast_audio_announcement.broadcast_id,
            bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_AVAILABLE,
            0xFFFF,
            [
                bass.SubgroupInfo(
                    bass.SubgroupInfo.ANY_BIS,
                    bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
                )
            ],
        )

        # Initiate a PA Sync Transfer
        await broadcast.sync.transfer(peer.connection)

        # Notify the sink that we're done scanning.
        await bass_client.remote_scan_stopped()

        await peer.sustain()
        return

    if command == 'modify-source':
        if source_id is None:
            print(color('!!! modify-source requires --source-id'))
            return

        # Find the requested broadcast
        await bass_client.remote_scan_started()
        if broadcast_name:
            print(color('Scanning for broadcast:', 'cyan'), broadcast_name)
        else:
            print(color('Scanning for any broadcast', 'cyan'))
        broadcast = await find_broadcast_by_name(device, broadcast_name)

        if broadcast.broadcast_audio_announcement is None:
            print(color('No broadcast audio announcement found', 'red'))
            return

        if (
            broadcast.basic_audio_announcement is None
            or not broadcast.basic_audio_announcement.subgroups
        ):
            print(color('No subgroups found', 'red'))
            return

        # Modify the source
        print(
            color('Modifying source:', 'blue'),
            source_id,
        )
        await bass_client.modify_source(
            source_id,
            bass.PeriodicAdvertisingSyncParams.SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE,
            0xFFFF,
            [
                bass.SubgroupInfo(
                    bass.SubgroupInfo.ANY_BIS,
                    bytes(broadcast.basic_audio_announcement.subgroups[0].metadata),
                )
            ],
        )
        await peer.sustain()
        return

    if command == 'remove-source':
        if source_id is None:
            print(color('!!! remove-source requires --source-id'))
            return

        # Remove the source
        print(color('Removing source:', 'blue'), source_id)
        await bass_client.remove_source(source_id)
        await peer.sustain()
        return

    print(color(f'!!! invalid command {command}'))


async def run_pair(device: bumble.device.Device, address: str) -> None:
    # Connect to the server
    print(f'=== Connecting to {address}...')
    async with device.connect_as_gatt(address) as peer:
        print(f'=== Connected to {peer}')

        print("+++ Initiating pairing...")
        await peer.connection.pair()
        print("+++ Paired")


async def run_receive(
    device: bumble.device.Device,
    broadcast_id: int | None,
    output: str,
    broadcast_code: str | None,
    sync_timeout: float,
    subgroup_index: int,
) -> None:
    # Run a pre-flight check for the output.
    try:
        if not audio_io.check_audio_output(output):
            return
    except ValueError as error:
        print(error)
        return

    if not device.supports_le_periodic_advertising:
        print(color('Periodic advertising not supported', 'red'))
        return

    scanner = BroadcastScanner(device, False, sync_timeout)
    scan_result: asyncio.Future[BroadcastScanner.Broadcast] = (
        asyncio.get_running_loop().create_future()
    )

    def on_new_broadcast(broadcast: BroadcastScanner.Broadcast) -> None:
        if scan_result.done():
            return
        if broadcast_id is None or broadcast.broadcast_id == broadcast_id:
            scan_result.set_result(broadcast)

    scanner.on('new_broadcast', on_new_broadcast)
    await scanner.start()
    print('Start scanning...')
    broadcast = await scan_result
    print('Advertisement found:')
    broadcast.print()
    basic_audio_announcement_scanned = asyncio.Event()

    def on_change() -> None:
        if (
            broadcast.basic_audio_announcement and broadcast.biginfo
        ) and not basic_audio_announcement_scanned.is_set():
            basic_audio_announcement_scanned.set()

    broadcast.on('change', on_change)
    if not broadcast.basic_audio_announcement or not broadcast.biginfo:
        print('Wait for Basic Audio Announcement and BIG Info...')
        await basic_audio_announcement_scanned.wait()
    print('Basic Audio Announcement found')
    broadcast.print()
    print('Stop scanning')
    await scanner.stop()
    print('Start sync to BIG')

    assert broadcast.basic_audio_announcement
    subgroup = broadcast.basic_audio_announcement.subgroups[subgroup_index]
    configuration = subgroup.codec_specific_configuration
    assert configuration
    assert (sampling_frequency := configuration.sampling_frequency)
    assert (frame_duration := configuration.frame_duration)

    big_sync = await device.create_big_sync(
        broadcast.sync,
        bumble.device.BigSyncParameters(
            big_sync_timeout=0x4000,
            bis=[bis.index for bis in subgroup.bis],
            broadcast_code=(
                broadcast_code_bytes(broadcast_code) if broadcast_code else None
            ),
        ),
    )
    num_bis = len(big_sync.bis_links)
    decoder = lc3.Decoder(
        frame_duration_us=frame_duration.us,
        sample_rate_hz=sampling_frequency.hz,
        num_channels=num_bis,
    )
    lc3_queues: list[collections.deque[bytes]] = [
        collections.deque() for i in range(num_bis)
    ]
    packet_stats = [0, 0]

    audio_output = await audio_io.create_audio_output(output)
    # This try should be replaced with contextlib.aclosing() when python 3.9 is no
    # longer needed.
    try:
        await audio_output.open(
            audio_io.PcmFormat(
                audio_io.PcmFormat.Endianness.LITTLE,
                audio_io.PcmFormat.SampleType.FLOAT32,
                sampling_frequency.hz,
                num_bis,
            )
        )

        def sink(queue: collections.deque[bytes], packet: hci.HCI_IsoDataPacket):
            # TODO: re-assemble fragments and detect errors
            queue.append(packet.iso_sdu_fragment)

            while all(lc3_queues):
                # This assumes SDUs contain one LC3 frame each, which may not
                # be correct for all cases. TODO: revisit this assumption.
                frame = b''.join([lc3_queue.popleft() for lc3_queue in lc3_queues])
                if not frame:
                    print(color('!!! received empty frame', 'red'))
                    continue

                packet_stats[0] += len(frame)
                packet_stats[1] += 1
                print(
                    f'\rRECEIVED: {packet_stats[0]} bytes in '
                    f'{packet_stats[1]} packets',
                    end='',
                )

                try:
                    pcm = decoder.decode(frame).tobytes()
                except lc3.BaseError as error:
                    print(color(f'!!! LC3 decoding error: {error}'))
                    continue

                audio_output.write(pcm)

        for i, bis_link in enumerate(big_sync.bis_links):
            print(f'Setup ISO for BIS {bis_link.handle}')
            bis_link.sink = functools.partial(sink, lc3_queues[i])
            await bis_link.setup_data_path(
                direction=bis_link.Direction.CONTROLLER_TO_HOST
            )

        terminated = asyncio.Event()
        big_sync.on(big_sync.Event.TERMINATION, lambda _: terminated.set())
        await terminated.wait()
    finally:
        await audio_output.aclose()


async def run_transmit(
    device: bumble.device.Device, broadcasts: Iterable[Broadcast]
) -> None:
    # Run a pre-flight check for the input(s).
    try:
        for broadcast in broadcasts:
            for source in broadcast.sources:
                if not audio_io.check_audio_input(source.input):
                    return
    except ValueError as error:
        print(error)
        return

    if not device.supports_le_periodic_advertising:
        print(color('Periodic advertising not supported', 'red'))
        return

    def on_flow():
        pending = []
        queued = []
        completed = []
        for broadcast in broadcasts:
            for iso_queue in broadcast.iso_queues:
                data_packet_queue = iso_queue.data_packet_queue
                pending.append(str(data_packet_queue.pending))
                queued.append(str(data_packet_queue.queued))
                completed.append(str(data_packet_queue.completed))
        print(
            f'\rPACKETS: '
            f'pending={",".join(pending)} | '
            f'queued={",".join(queued)} | '
            f'completed={",".join(completed)}',
            end='',
        )

    audio_inputs: list[audio_io.AudioInput] = []
    try:
        # Setup audio sources
        for broadcast_index, broadcast in enumerate(broadcasts):
            channel_count = 0
            max_lc3_frame_size = 0
            max_sample_rate = 0
            for source in broadcast.sources:
                print(f'Setting up audio input: {source.input}')

                # Open the audio input
                audio_input = await audio_io.create_audio_input(
                    source.input, source.input_format
                )
                pcm_format = await audio_input.open()
                audio_inputs.append(audio_input)

                # Check that the number of channels is supported
                if pcm_format.channels not in (1, 2):
                    print("Only 1 and 2 channels PCM configurations are supported")
                    return
                channel_count += pcm_format.channels

                # Check that the sample type is supported
                if pcm_format.sample_type == audio_io.PcmFormat.SampleType.INT16:
                    pcm_bit_depth = 16
                elif pcm_format.sample_type == audio_io.PcmFormat.SampleType.FLOAT32:
                    pcm_bit_depth = None
                else:
                    print("Only INT16 and FLOAT32 sample types are supported")
                    return

                # Check that the sample rate is supported
                if pcm_format.sample_rate not in (16000, 24000, 48000):
                    print(f'Sample rate {pcm_format.sample_rate} not supported')
                    return
                max_sample_rate = max(max_sample_rate, pcm_format.sample_rate)

                # Compute LC3 parameters and create and encoder
                encoder = lc3.Encoder(
                    frame_duration_us=AURACAST_DEFAULT_FRAME_DURATION,
                    sample_rate_hz=pcm_format.sample_rate,
                    num_channels=pcm_format.channels,
                    input_sample_rate_hz=pcm_format.sample_rate,
                )
                lc3_frame_samples = encoder.get_frame_samples()
                lc3_frame_size = encoder.get_frame_bytes(source.bitrate)
                max_lc3_frame_size = max(max_lc3_frame_size, lc3_frame_size)
                print(
                    f'Encoding {source.input} with {lc3_frame_samples} '
                    f'PCM samples per {lc3_frame_size} byte frame'
                )

                broadcast.audio_sources.append(
                    AudioSource(
                        audio_input=audio_input,
                        pcm_format=pcm_format,
                        pcm_bit_depth=pcm_bit_depth,
                        lc3_encoder=encoder,
                        lc3_frame_samples=lc3_frame_samples,
                        lc3_frame_size=lc3_frame_size,
                        audio_frames=audio_input.frames(lc3_frame_samples),
                    )
                )

            # Setup advertising and BIGs
            metadata = le_audio.Metadata()
            if broadcast.language is not None:
                metadata.entries.append(
                    le_audio.Metadata.Entry(
                        tag=le_audio.Metadata.Tag.LANGUAGE,
                        data=broadcast.language.encode('utf-8'),
                    )
                )
            if broadcast.program_info is not None:
                metadata.entries.append(
                    le_audio.Metadata.Entry(
                        tag=le_audio.Metadata.Tag.PROGRAM_INFO,
                        data=broadcast.program_info.encode('utf-8'),
                    )
                )

            if broadcast.public:
                # Infer features from sources
                features = pbp.PublicBroadcastAnnouncement.Features(0)
                if broadcast.broadcast_code is not None:
                    features |= pbp.PublicBroadcastAnnouncement.Features.ENCRYPTED
                for audio_source in broadcast.audio_sources:
                    if audio_source.pcm_format.sample_rate == 48000:
                        features |= (
                            pbp.PublicBroadcastAnnouncement.Features.HIGH_QUALITY_CONFIGURATION
                        )
                    else:
                        features |= (
                            pbp.PublicBroadcastAnnouncement.Features.STANDARD_QUALITY_CONFIGURATION
                        )

                public_broadcast_announcement = pbp.PublicBroadcastAnnouncement(
                    features=features, metadata=metadata
                )
            else:
                public_broadcast_announcement = None

            broadcast_audio_announcement = bap.BroadcastAudioAnnouncement(
                broadcast.broadcast_id
            )

            basic_audio_announcement = bap.BasicAudioAnnouncement(
                presentation_delay=40000,
                subgroups=[
                    bap.BasicAudioAnnouncement.Subgroup(
                        codec_id=hci.CodingFormat(codec_id=hci.CodecID.LC3),
                        codec_specific_configuration=bap.CodecSpecificConfiguration(
                            sampling_frequency=bap.SamplingFrequency.from_hz(
                                audio_source.pcm_format.sample_rate
                            ),
                            frame_duration=bap.FrameDuration.DURATION_10000_US,
                            octets_per_codec_frame=audio_source.lc3_frame_size,
                        ),
                        metadata=le_audio.Metadata(),
                        bis=(
                            [
                                bap.BasicAudioAnnouncement.BIS(
                                    index=1,
                                    codec_specific_configuration=bap.CodecSpecificConfiguration(
                                        audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
                                    ),
                                ),
                                bap.BasicAudioAnnouncement.BIS(
                                    index=2,
                                    codec_specific_configuration=bap.CodecSpecificConfiguration(
                                        audio_channel_allocation=bap.AudioLocation.FRONT_RIGHT
                                    ),
                                ),
                            ]
                            if audio_source.pcm_format.channels == 2
                            else [
                                bap.BasicAudioAnnouncement.BIS(
                                    index=1,
                                    codec_specific_configuration=bap.CodecSpecificConfiguration(
                                        audio_channel_allocation=bap.AudioLocation.FRONT_LEFT
                                    ),
                                ),
                            ]
                        ),
                    )
                    for audio_source in broadcast.audio_sources
                ],
            )

            advertising_data_types: list[core.DataType] = [
                data_types.CompleteLocalName(AURACAST_DEFAULT_DEVICE_NAME),
                data_types.Appearance(
                    core.Appearance.Category.AUDIO_SOURCE,
                    core.Appearance.AudioSourceSubcategory.BROADCASTING_DEVICE,
                ),
                data_types.BroadcastName(broadcast.broadcast_name),
            ]

            if broadcast.manufacturer_data is not None:
                advertising_data_types.append(
                    data_types.ManufacturerSpecificData(*broadcast.manufacturer_data)
                )

            advertising_data = bytes(core.AdvertisingData(advertising_data_types))
            if public_broadcast_announcement:
                advertising_data += bytes(
                    public_broadcast_announcement.get_advertising_data()
                )
            if broadcast_audio_announcement:
                advertising_data += broadcast_audio_announcement.get_advertising_data()

            print('Starting Periodic Advertising:')
            print(f"  Extended Advertising data size: {len(advertising_data)}")
            print(
                f"  Periodic Advertising data size: {len(basic_audio_announcement.get_advertising_data())}"
            )
            advertising_set = await device.create_advertising_set(
                advertising_parameters=bumble.device.AdvertisingParameters(
                    advertising_event_properties=bumble.device.AdvertisingEventProperties(
                        is_connectable=False
                    ),
                    primary_advertising_interval_min=100,
                    primary_advertising_interval_max=1000,
                    advertising_sid=broadcast_index,
                ),
                advertising_data=advertising_data,
                periodic_advertising_parameters=bumble.device.PeriodicAdvertisingParameters(
                    periodic_advertising_interval_min=100,
                    periodic_advertising_interval_max=1000,
                ),
                periodic_advertising_data=basic_audio_announcement.get_advertising_data(),
                auto_restart=True,
                auto_start=True,
            )
            await advertising_set.start_periodic()

            print('Setting up BIG')
            big = await device.create_big(
                advertising_set,
                parameters=bumble.device.BigParameters(
                    num_bis=channel_count,
                    sdu_interval=AURACAST_DEFAULT_FRAME_DURATION,
                    max_sdu=max_lc3_frame_size,
                    max_transport_latency=65,
                    rtn=4,
                    broadcast_code=(
                        broadcast_code_bytes(broadcast.broadcast_code)
                        if broadcast.broadcast_code
                        else None
                    ),
                ),
            )

            for i, bis_link in enumerate(big.bis_links):
                print(f'Setup ISO for BIS {bis_link.handle}')
                await bis_link.setup_data_path(
                    direction=bis_link.Direction.HOST_TO_CONTROLLER
                )
                iso_queue = bumble.device.IsoPacketStream(bis_link, 64)
                iso_queue.data_packet_queue.on('flow', on_flow)
                broadcast.iso_queues.append(iso_queue)

        print('Transmitting audio from source(s)')
        while True:
            for broadcast in broadcasts:
                iso_queue_index = 0
                for audio_source in broadcast.audio_sources:
                    pcm_frame = await anext(audio_source.audio_frames)
                    lc3_frame = audio_source.lc3_encoder.encode(
                        pcm_frame,
                        num_bytes=audio_source.pcm_format.channels
                        * audio_source.lc3_frame_size,
                        bit_depth=audio_source.pcm_bit_depth,
                    )

                    for lc3_chunk_start in range(
                        0, len(lc3_frame), audio_source.lc3_frame_size
                    ):
                        await broadcast.iso_queues[iso_queue_index].write(
                            lc3_frame[
                                lc3_chunk_start : lc3_chunk_start
                                + audio_source.lc3_frame_size
                            ]
                        )
                        iso_queue_index += 1
    finally:
        for audio_input in audio_inputs:
            await audio_input.aclose()


def run_async(
    async_command: Callable[..., Awaitable[Any]],
    transport: str,
    *args,
) -> None:
    async def run_with_transport():
        async with create_device(transport) as device:
            await async_command(device, *args)

    try:
        asyncio.run(run_with_transport())
    except core.ProtocolError as error:
        if error.error_namespace == 'att' and error.error_code in list(
            bass.ApplicationError
        ):
            message = bass.ApplicationError(error.error_code).name
        else:
            message = str(error)

        print(
            color('!!! An error occurred while executing the command:', 'red'), message
        )


# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
@click.group()
@click.pass_context
def auracast(ctx):
    ctx.ensure_object(dict)


@auracast.command('scan')
@click.option(
    '--filter-duplicates', is_flag=True, default=False, help='Filter duplicates'
)
@click.option(
    '--sync-timeout',
    metavar='SYNC_TIMEOUT',
    type=float,
    default=AURACAST_DEFAULT_SYNC_TIMEOUT,
    help='Sync timeout (in seconds)',
)
@click.argument('transport')
@click.pass_context
def scan(ctx, filter_duplicates, sync_timeout, transport):
    """Scan for public broadcasts"""
    run_async(run_scan, transport, filter_duplicates, sync_timeout)


@auracast.command('assist')
@click.option(
    '--broadcast-name',
    metavar='BROADCAST_NAME',
    help='Broadcast Name to tune to',
)
@click.option(
    '--source-id',
    metavar='SOURCE_ID',
    type=int,
    help='Source ID (for remove-source command)',
)
@click.option(
    '--command',
    type=click.Choice(
        ['monitor-state', 'add-source', 'modify-source', 'remove-source']
    ),
    required=True,
)
@click.argument('transport')
@click.argument('address')
@click.pass_context
def assist(ctx, broadcast_name, source_id, command, transport, address):
    """Scan for broadcasts on behalf of an audio server"""
    run_async(run_assist, transport, broadcast_name, source_id, command, address)


@auracast.command('pair')
@click.argument('transport')
@click.argument('address')
@click.pass_context
def pair(ctx, transport, address):
    """Pair with an audio server"""
    run_async(run_pair, transport, address)


@auracast.command('receive')
@click.argument('transport')
@click.argument(
    'broadcast_id',
    type=int,
    required=False,
)
@click.option(
    '--output',
    default='device',
    help=(
        "Audio output. "
        "'device' -> use the host's default sound output device, "
        "'device:<DEVICE_ID>' -> use one of the  host's sound output device "
        "(specify 'device:?' to get a list of available sound output devices), "
        "'stdout' -> send audio to stdout, "
        "'file:<filename> -> write audio to a raw float32 PCM file, "
        "'ffplay' -> pipe the audio to ffplay"
    ),
)
@click.option(
    '--broadcast-code',
    metavar='BROADCAST_CODE',
    type=str,
    help='Broadcast encryption code (string or raw hex format prefixed with 0x)',
)
@click.option(
    '--sync-timeout',
    metavar='SYNC_TIMEOUT',
    type=float,
    default=AURACAST_DEFAULT_SYNC_TIMEOUT,
    help='Sync timeout (in seconds)',
)
@click.option(
    '--subgroup',
    metavar='SUBGROUP',
    type=int,
    default=0,
    help='Index of Subgroup',
)
@click.pass_context
def receive(
    ctx,
    transport,
    broadcast_id,
    output,
    broadcast_code,
    sync_timeout,
    subgroup,
):
    """Receive a broadcast source"""
    run_async(
        run_receive,
        transport,
        broadcast_id,
        output,
        broadcast_code,
        sync_timeout,
        subgroup,
    )


@auracast.command('transmit')
@click.argument('transport')
@click.option(
    '--broadcast-list',
    metavar='BROADCAST_LIST',
    help=(
        'Filename of a TOML broadcast list with specification(s) for one or more '
        'broadcast sources. When used, single-source options, including --input, '
        '--input-format and others, are ignored.'
    ),
)
@click.option(
    '--input',
    help=(
        "Audio input. "
        "'device' -> use the host's default sound input device, "
        "'device:<DEVICE_ID>' -> use one of the host's sound input devices "
        "(specify 'device:?' to get a list of available sound input devices), "
        "'stdin' -> receive audio from stdin as int16 PCM, "
        "'file:<filename> -> read audio from a .wav or raw int16 PCM file. "
        "(The file: prefix may be omitted if the file path does not start with "
        "the substring 'device:' or 'file:' and is not 'stdin')"
    ),
)
@click.option(
    '--input-format',
    metavar="FORMAT",
    default='auto',
    help=(
        "Audio input format. "
        "Use 'auto' for .wav files, or for the default setting with the devices. "
        "For other inputs, the format is specified as "
        "<sample-type>,<sample-rate>,<channels> (supported <sample-type>: 'int16le' "
        "for 16-bit signed integers with little-endian byte order or 'float32le' for "
        "32-bit floating point with little-endian byte order)"
    ),
)
@click.option(
    '--broadcast-id',
    metavar='BROADCAST_ID',
    type=int,
    default=AURACAST_DEFAULT_BROADCAST_ID,
    help='Broadcast ID',
)
@click.option(
    '--broadcast-code',
    metavar='BROADCAST_CODE',
    help='Broadcast encryption code in hex format or as a string',
)
@click.option(
    '--broadcast-name',
    metavar='BROADCAST_NAME',
    default=AURACAST_DEFAULT_BROADCAST_NAME,
    help='Broadcast name',
)
@click.option(
    '--bitrate',
    type=int,
    default=AURACAST_DEFAULT_TRANSMIT_BITRATE,
    help='Bitrate, per channel, in bps',
)
@click.option(
    '--manufacturer-data',
    metavar='VENDOR-ID:DATA-HEX',
    help='Manufacturer data (specify as <vendor-id>:<data-hex>)',
)
@click.pass_context
def transmit(
    ctx,
    transport,
    broadcast_list,
    input,
    input_format,
    broadcast_id,
    broadcast_code,
    broadcast_name,
    bitrate,
    manufacturer_data,
):
    """Transmit a broadcast source"""
    if broadcast_list:
        broadcasts = parse_broadcast_list(broadcast_list)
        if not broadcasts:
            print(color('!!! Broadcast list is empty or invalid', 'red'))
            return
        for broadcast in broadcasts:
            if not broadcast.sources:
                print(
                    color(
                        f'!!! Broadcast "{broadcast.broadcast_name}" has no sources',
                        'red',
                    )
                )
                return
    else:
        if input is None and broadcast_list is None:
            print(
                color('!!! --input is required if --broadcast-list is not used', 'red')
            )
            return

        if (
            input == 'device' or input.startswith('device:')
        ) and input_format == 'auto':
            # Use a default format for device inputs
            input_format = 'int16le,48000,1'

        if manufacturer_data:
            vendor_id_str, data_hex = manufacturer_data.split(':')
            vendor_id = int(vendor_id_str)
            data = bytes.fromhex(data_hex)
            manufacturer_data_tuple = (vendor_id, data)
        else:
            manufacturer_data_tuple = None

        broadcasts = [
            Broadcast(
                sources=[
                    BroadcastSource(
                        input=input,
                        input_format=input_format,
                        bitrate=bitrate,
                    )
                ],
                public=True,
                broadcast_id=broadcast_id,
                broadcast_name=broadcast_name,
                broadcast_code=broadcast_code,
                manufacturer_data=manufacturer_data_tuple,
                language=AURACAST_DEFAULT_LANGUAGE,
                program_info=AURACAST_DEFAULT_PROGRAM_INFO,
            )
        ]

    assign_broadcast_ids(broadcasts)

    run_async(run_transmit, transport, broadcasts)


def main():
    bumble.logging.setup_basic_logging()
    auracast()


# -----------------------------------------------------------------------------
if __name__ == "__main__":
    main()  # pylint: disable=no-value-for-parameter
