import json
import logging
import math
import time
from collections import defaultdict
from collections.abc import Callable

from autobahn.exception import Disconnected
from autobahn.twisted.websocket import (
    WebSocketClientFactory,
    WebSocketClientProtocol,
    connectWS,
)
from twisted.internet.protocol import ReconnectingClientFactory

from ezbeq.apis.ws import WsServer
from ezbeq.catalogue import CatalogueEntry, CatalogueProvider
from ezbeq.device import DeviceState, PersistentDevice, SlotState

BEQ_FILTER_NAME_PATTERN = r"^BEQ_(Gain_\d+|\d+(?:_([a-zA-Z0-9]+))?)$"

SLOT_ID = "CamillaDSP"

NOP_LS = {
    'type': 'Lowshelf',
    'freq': 20.0,
    'gain': 0.0,
    'q': 1.0,
}

logger = logging.getLogger("ezbeq.camilladsp")


class CamillaDspSlotState(SlotState):
    def __init__(self):
        super().__init__(SLOT_ID)
        self.gains: list[dict] = []
        self.mutes: list[dict] = []

    def as_dict(self) -> dict:
        return {**super().as_dict(), "gains": self.gains, "mutes": self.mutes}


class CamillaDspState(DeviceState):
    def __init__(self, name: str):
        self.__name = name
        self.slot = CamillaDspSlotState()
        self.slot.active = True
        self.master_volume: float = 0.0
        self.mute: bool = False

    @property
    def has_volume(self) -> bool:
        return True

    def serialise(self) -> dict:
        return {
            "type": "camilladsp",
            "name": self.__name,
            "mute": self.mute,
            "has_volume": self.has_volume,
            "slots": [self.slot.as_dict()],
            "masterVolume": self.master_volume,
        }


class CamillaDsp(PersistentDevice[CamillaDspState]):
    def __init__(
            self,
            name: str,
            config_path: str,
            cfg: dict,
            ws_server: WsServer,
            catalogue: CatalogueProvider,
    ):
        super().__init__(config_path, name, ws_server)
        self.__name = name
        self.__catalogue = catalogue
        self.__ip: str = cfg["ip"]
        self.__port: int = cfg["port"]
        self.__beq_channels: list[int] = [int(c) for c in cfg.get("channels", [])]
        self.__current_config: dict = {}
        self.__levels_interval_ms = round(1000.0 / float(cfg.get("levelsFps", 10)))
        self.__config_updater: UpdateConfig | None = None
        if not self.__beq_channels:
            raise ValueError(f'No channels supplied for CamillaDSP {name} - {self.__ip}:{self.__port}')
        self.__ws_client = cfg["make_wsclient"](self.__ip, self.__port, self)
        self.__playback_peak: list[float] = []
        self.__playback_rms: list[float] = []
        ws_server.factory.set_levels_provider(name, self.start_broadcast_levels)

    @property
    def current_config(self) -> dict:
        return self.__current_config

    @current_config.setter
    def current_config(self, cfg: dict):
        self.__current_config = cfg

        def upd():
            pipeline = cfg.get("pipeline", [])
            filters = cfg.get("filters", {})
            if pipeline:
                gains = [{"id": c, "value": 0.0} for c in self.__beq_channels]
                mutes = [{"id": c, "value": False} for c in self.__beq_channels]
                for stage in pipeline:
                    if stage["type"] == "Filter":
                        gain_filters = [n for n in stage["names"] if n.startswith("BEQ_Gain")]
                        gain_channels = stage["channels"]
                        for gain_filter in gain_filters:
                            f = filters[gain_filter]
                            if f["type"] == "Gain":
                                gains = [g for g in gains if g["id"] not in gain_channels]
                                mutes = [m for m in mutes if m["id"] not in gain_channels]
                                for c in gain_channels:
                                    gains.append({"id": c, "value": f["parameters"]["gain"]})
                                    mutes.append({"id": c, "value": f["parameters"]["mute"]})
                            else:
                                logging.warn(f"Ignoring unexpected filter type {f['type']} for {gain_filter}")
                self._current_state.slot.gains = gains
                self._current_state.slot.mutes = mutes

        self._hydrate_cache_broadcast(upd)

    def start_broadcast_levels(self) -> None:
        from twisted.internet import reactor

        def sched():
            return reactor.callLater(self.__levels_interval_ms / 1000.0, __send)

        def __send():
            self.request_levels()
            if self.ws_server.has_levels_client(self.name):
                sched()

        sched()

    def _load_initial_state(self) -> CamillaDspState:
        return CamillaDspState(self.name)

    def _merge_state(self, loaded: CamillaDspState, cached: dict) -> CamillaDspState:
        if "masterVolume" in cached:
            loaded.master_volume = cached["masterVolume"]
        if "slots" in cached:
            for slot in cached["slots"]:
                if "id" in slot:
                    if slot["id"] == SLOT_ID:
                        if slot["last"]:
                            loaded.slot.last = slot["last"]
                        if slot["author"]:
                            loaded.slot.last_author = slot["author"]
                        if slot["gains"]:
                            loaded.slot.gains = slot["gains"]
                        if slot["mutes"]:
                            loaded.slot.mutes = slot["mutes"]
        return loaded

    @property
    def device_type(self) -> str:
        return self.__class__.__name__.lower()

    def update(self, params: dict) -> bool:
        def __do_it() -> bool:
            any_update = False
            if "slots" in params:
                for slot in params["slots"]:
                    if slot["id"] == SLOT_ID:
                        if "entry" in slot:
                            if slot["entry"]:
                                match = self.__catalogue.find(slot["entry"])
                                if match:
                                    mv = 0.0
                                    if "gains" in slot and slot["gains"]:
                                        mv = float(slot["gains"][0]["value"])
                                    self.load_filter(SLOT_ID, match, mv)
                                    any_update = True
                            else:
                                self.clear_filter(SLOT_ID)
                                any_update = True
                        elif "gains" in slot or "mutes" in slot:
                            merged = defaultdict(dict)
                            for g in slot.get("gains", []):
                                c_id = int(g["id"])
                                if c_id in self.__beq_channels:
                                    merged[c_id]["gain"] = g["value"]
                                else:
                                    raise ValueError(
                                        f"Invalid channel id for gain setting {c_id}"
                                    )
                            for g in slot.get("mutes", []):
                                c_id = int(g["id"])
                                if c_id in self.__beq_channels:
                                    merged[c_id]["mute"] = g["value"]
                                else:
                                    raise ValueError(
                                        f"Invalid channel id for gain setting {c_id}"
                                    )

                            def completed(success: bool):
                                logger.log(
                                    logging.INFO if success else logging.WARNING,
                                    f"Completed gain update {success}",
                                )
                                if success:
                                    css = self._current_state.slot
                                    for k, v in merged.items():
                                        if "gain" in v:
                                            to_update = next(
                                                g for g in css.gains if g["id"] == k
                                            )
                                            to_update["value"] = v["gain"]
                                        if "mute" in v:
                                            to_update = next(
                                                g for g in css.mutes if g["id"] == k
                                            )
                                            to_update["value"] = v["mute"]

                            self.__update_channel_levels(
                                merged,
                                lambda b: self._hydrate_cache_broadcast(
                                    lambda: completed(b)
                                ),
                            )
                            any_update = True
            if "mute" in params and params["mute"] != self._current_state.mute:
                if self._current_state.mute:
                    self.unmute(None, None)
                else:
                    self.mute(None, None)
                any_update = True
            if "masterVolume" in params and not math.isclose(
                    params["masterVolume"], self._current_state.master_volume
            ):
                self.set_gain(None, None, params["masterVolume"])
                any_update = True
            return any_update

        return self._hydrate_cache_broadcast(__do_it)

    def __update_channel_levels(
            self, values: dict[int, dict], on_complete: Callable[[bool], None]
    ):
        if self.__config_updater is None:
            logger.info(f"Sending {len(values)} level changes")
            self.__config_updater = UpdateGain(
                values, self.ws_server, self.__ws_client, on_complete
            )
            self.__config_updater.send_get_config()
        else:
            raise ValueError(
                f"Unable to load BEQ, config update already in progress for {self.__config_updater.name}"
            )

    def __send_filter(
            self,
            entry: CatalogueEntry | None,
            mv_adjust: float,
            on_complete: Callable[[bool], None],
    ):
        if self.__config_updater is None:
            if entry:
                logger.info(
                    f"Sending {len(entry.filters)} filters for {entry.formatted_title}"
                )
            else:
                logger.info("Clearing filters")
            self.__config_updater = LoadConfig(
                entry,
                self.__beq_channels,
                mv_adjust,
                self.ws_server,
                self.__ws_client,
                on_complete,
            )
            self.__config_updater.send_get_config()
        else:
            raise ValueError(
                f"Unable to load BEQ, config update already in progress for {self.__config_updater.name}"
            )

    def __send_command(self, set_cmd: str, value: float | bool):
        logger.info(f"Sending command {set_cmd}: {value}")
        # messages are guaranteed to be processed in order
        self.__ws_client.send(json.dumps({f"Set{set_cmd}": value}))
        self.__ws_client.send(json.dumps(f"Get{set_cmd}"))

    def activate(self, slot: str) -> None:
        def __do_it():
            self._current_state.slot.active = True

        self._hydrate_cache_broadcast(__do_it)

    def load_biquads(
            self,
            slot: str,
            overwrite: bool,
            inputs: list[int],
            outputs: list[int],
            biquads: list[dict],
    ) -> None:
        raise NotImplementedError()

    def send_commands(
            self, slot: str, inputs: list[int], outputs: list[int], commands: list[str]
    ) -> None:
        raise NotImplementedError()

    def load_filter(
            self, slot: str, entry: CatalogueEntry, mv_adjust: float = 0.0
    ) -> None:
        self._hydrate_cache_broadcast(lambda: self.__do_load_filter(entry, mv_adjust))

    def __do_load_filter(self, entry: CatalogueEntry | None, mv_adjust: float = 0.0):
        try:
            self._current_state.slot.last = "Loading" if entry else "Clearing"
            self._current_state.slot.last_author = None

            def completed(success: bool):
                if success:
                    if entry:
                        self._current_state.slot.last = entry.formatted_title
                        self._current_state.slot.last_author = entry.author
                    else:
                        self._current_state.slot.last = "Empty"
                        self._current_state.slot.last_author = None
                    for g in self._current_state.slot.gains:
                        g["value"] = mv_adjust
                    for g in self._current_state.slot.mutes:
                        g["value"] = False
                else:
                    self._current_state.slot.last = "ERROR"
                    self._current_state.slot.last_author = None

            self.__send_filter(
                entry,
                mv_adjust,
                lambda b: self._hydrate_cache_broadcast(lambda: completed(b)),
            )
        except Exception as e:
            self._current_state.slot.last = "ERROR"
            raise e

    def clear_filter(self, slot: str) -> None:
        self._hydrate_cache_broadcast(lambda: self.__do_load_filter(None))

    def mute(self, slot: str | None, channel: int | None) -> None:
        self._hydrate_cache_broadcast(lambda: self.__do_mute_op(True))

    def __do_mute_op(self, mute: bool):
        try:
            self.__send_command("Mute", mute)
        except Exception as e:
            raise e

    def unmute(self, slot: str | None, channel: int | None) -> None:
        self._hydrate_cache_broadcast(lambda: self.__do_mute_op(False))

    def set_gain(self, slot: str | None, channel: int | None, gain: float) -> None:
        if channel is None:
            self._hydrate_cache_broadcast(lambda: self.__do_volume_op(gain))
        else:
            self._hydrate_cache_broadcast(lambda: self.__do_gain_op(channel, gain))

    def __do_gain_op(self, channel: int, gain: float):
        pass

    def __do_volume_op(self, level: float):
        try:
            self.__send_command("Volume", level)
        except Exception as e:
            raise e

    def request_levels(self):
        self.__ws_client.send(json.dumps("GetPlaybackSignalPeak"))
        self.__ws_client.send(json.dumps("GetPlaybackSignalRms"))

    def levels(self) -> dict:
        return {"name": self.name, "ts": time.time(), "levels": self.__format_levels()}

    def __format_levels(self):
        return {
            **{f"RMS-{i}": v for i, v in enumerate(self.__playback_rms)},
            **{f"Peak-{i}": v for i, v in enumerate(self.__playback_peak)},
        }

    def on_open(self):
        self.__ws_client.send(json.dumps("GetConfigJson"))
        self.__ws_client.send(json.dumps("GetVolume"))
        self.__ws_client.send(json.dumps("GetMute"))
        self.__ws_client.send(
            json.dumps({"SetUpdateInterval": self.__levels_interval_ms})
        )

    def on_get_config(self, config: dict):
        if config.get("result", None) == "Ok":
            new_config = json.loads(config["value"])
            self.current_config = new_config
            if self.__config_updater is None:
                logger.info(
                    f"Received new DSP config but nothing to load, ignoring {new_config}"
                )
            else:
                self.__config_updater.on_get_config(new_config)
        else:
            if self.__config_updater is not None:
                self.__config_updater.failed("GetConfig", config)
                self.__config_updater = None
            else:
                logger.warning(f"GetConfig failed :: {config}")

    def on_set_config(self, result: str):
        if self.__config_updater is None:
            logger.info(
                f"Received response to SetConfigJson but nothing to load, ignoring {result}"
            )
        else:
            if result == "Ok":
                logger.info(f"SetConfigJson accepted")
                self.current_config = self.__config_updater.on_set_config()
            else:
                self.__config_updater.failed("SetConfig", result)
            self.__config_updater = None

    @staticmethod
    def on_reload(result: str):
        logger.warning(f"Received Reload {result}")

    def on_get_volume(self, msg):
        if msg["result"] == "Ok":
            def do_it():
                self._current_state.master_volume = msg["value"]

            self._hydrate_cache_broadcast(do_it)

    def on_set_volume(self, msg):
        if msg["result"] == "Ok":
            self.__ws_client.send(json.dumps("GetVolume"))
        else:
            # TODO send to UI
            logger.warning(f"Unsuccessful command {msg}")

    def on_set_mute(self, msg):
        if msg["result"] == "Ok":
            self.__ws_client.send(json.dumps("GetMute"))
        else:
            # TODO send to UI
            logger.warning(f"Unsuccessful command {msg}")

    def on_get_mute(self, msg):
        if msg["result"] == "Ok":
            def do_it():
                self._current_state.mute = msg["value"]

            self._hydrate_cache_broadcast(do_it)

    def on_get_playback_rms(self, msg):
        if msg["result"] == "Ok":
            self.__playback_rms = [
                v if not math.isclose(v, -1000.0) else -144.0 for v in msg["value"]
            ]
            self.ws_server.levels(self.name, self.levels())

    def on_get_playback_peak(self, msg):
        if msg["result"] == "Ok":
            self.__playback_peak = [
                v if not math.isclose(v, -1000.0) else -144.0 for v in msg["value"]
            ]


class CamillaDspClient:
    def __init__(self, ip: str, port: int, listener: CamillaDsp):
        addr = f"ws://{ip}:{port}"
        logger.info(f"Connecting to {addr}")
        self.__factory = CamillaDspClientFactory(listener, addr)
        self.__connector = connectWS(self.__factory)

    def send(self, msg: str):
        self.__factory.broadcast(msg)


class CamillaDspProtocol(WebSocketClientProtocol):
    # do_send = None

    def onConnecting(self, transport_details):
        logger.info(f"Connecting to {transport_details}")

    def onConnect(self, response):
        logger.info(f"Connected to {response.peer}")

    def onOpen(self):
        logger.info("Connected to CAMILLADSP")
        self.factory.register(self)
        self.factory.listener.on_open()

    def onClose(self, was_clean, code, reason):
        # self.do_send = None
        if was_clean:
            logger.info(f"Disconnected code: {code} reason: {reason}")
        else:
            logger.warning(f"UNCLEAN! Disconnected code: {code} reason: {reason}")

    def onMessage(self, payload, is_binary):
        if is_binary:
            logger.warning(f"Received {len(payload)} bytes in binary payload, ignoring")
        else:
            try:
                msg: dict = json.loads(payload.decode("utf8"))
                logger.debug(f">>> {msg}")
                if "GetConfigJson" in msg:
                    self.factory.listener.on_get_config(msg["GetConfigJson"])
                elif "SetConfigJson" in msg:
                    self.factory.listener.on_set_config(msg["SetConfigJson"]["result"])
                elif "Reload" in msg:
                    self.factory.listener.on_reload(msg["Reload"]["result"])
                elif "GetVolume" in msg:
                    self.factory.listener.on_get_volume(msg["GetVolume"])
                elif "GetMute" in msg:
                    self.factory.listener.on_get_mute(msg["GetMute"])
                elif "GetPlaybackSignalRms" in msg:
                    self.factory.listener.on_get_playback_rms(
                        msg["GetPlaybackSignalRms"]
                    )
                elif "GetPlaybackSignalPeak" in msg:
                    self.factory.listener.on_get_playback_peak(
                        msg["GetPlaybackSignalPeak"]
                    )
            except Exception:
                logger.exception(
                    f"Unable to decode {len(payload)} bytes in text payload"
                )


class CamillaDspClientFactory(WebSocketClientFactory, ReconnectingClientFactory):
    protocol = CamillaDspProtocol
    maxDelay = 5
    initialDelay = 0.5

    def __init__(self, listener: CamillaDsp, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__clients: list[CamillaDspProtocol] = []
        self.listener: CamillaDsp = listener
        self.setProtocolOptions(version=13)

    def clientConnectionFailed(self, connector, reason):
        logger.warning(f"Client connection failed {reason} .. retrying ..")
        super().clientConnectionFailed(connector, reason)

    def clientConnectionLost(self, connector, reason):
        logger.warning(f"Client connection failed {reason} .. retrying ..")
        super().clientConnectionLost(connector, reason)

    def register(self, client: CamillaDspProtocol):
        if client not in self.__clients:
            logger.info(f"Registered device {client.peer}")
            self.__clients.append(client)
        else:
            logger.info(f"Ignoring duplicate device {client.peer}")

    def unregister(self, client: CamillaDspProtocol):
        if client in self.__clients:
            logger.info(f"Unregistering device {client.peer}")
            self.__clients.remove(client)
        else:
            logger.info(f"Ignoring unregistered device {client.peer}")

    def broadcast(self, msg):
        if self.__clients:
            disconnected_clients = []
            for c in self.__clients:
                logger.info(f"Sending to {c.peer} - {msg}")
                try:
                    c.sendMessage(msg.encode("utf8"), isBinary=False)
                except Disconnected:
                    logger.exception(f"Failed to send to {c.peer}, discarding")
                    disconnected_clients.append(c)
            for c in disconnected_clients:
                self.unregister(c)
        else:
            raise ValueError(f"No devices connected, ignoring {msg}")


def create_cfg_for_entry(
        entry: CatalogueEntry | None,
        base_cfg: dict,
        beq_channels: list[int],
        mv_adjust: float,
        mute: bool,
) -> dict:
    from copy import deepcopy

    new_cfg = deepcopy(base_cfg)

    required_beq_filters = entry.filters if entry else []
    required_beq_filters = required_beq_filters + ([NOP_LS] * (10 - len(required_beq_filters)))
    beq_filter_desc = f"ezbeq filter {entry.digest} - {entry.title}" if entry else "ezbeq default filter"

    new_cfg_filters = new_cfg.get("filters", {})
    new_cfg["filters"] = new_cfg_filters

    gain_filter_names_by_channel = {}
    for c in beq_channels:
        name = f"BEQ_Gain_{c}"
        gain_filter_names_by_channel[c] = name
        if name in new_cfg_filters:
            new_cfg_filters[name]["parameters"]["gain"] = mv_adjust
            new_cfg_filters[name]["parameters"]["mute"] = mute
        else:
            new_cfg_filters[name] = {
                "type": "Gain",
                "parameters": {"gain": mv_adjust, "inverted": False, "mute": mute},
            }

    beq_filter_names = []
    for i, peq in enumerate(required_beq_filters):
        name = f"BEQ_{i}"
        if name in new_cfg_filters:
            new_cfg_filters[name]["description"] = beq_filter_desc
            new_cfg_filters[name]["parameters"] = {
                "type": get_filter_type(peq),
                "freq": peq["freq"],
                "q": peq["q"],
                "gain": peq["gain"],
            }
        else:
            new_cfg_filters[name] = {
                "description": beq_filter_desc,
                "type": "Biquad",
                "parameters": {
                    "type": get_filter_type(peq),
                    "freq": peq["freq"],
                    "q": peq["q"],
                    "gain": peq["gain"],
                },
            }
        beq_filter_names.append(name)

    new_cfg_pipeline = new_cfg.get("pipeline", [])
    new_cfg["pipeline"] = new_cfg_pipeline

    gain_channels_matched = []
    beq_filters_matched_by_channel = defaultdict(set)
    for f in new_cfg_pipeline:
        if f["type"] == "Filter":
            for gain_channel, gain_filter_name in gain_filter_names_by_channel.items():
                if gain_channel in f["channels"] and gain_filter_name in f["names"]:
                    gain_channels_matched.append(gain_channel)
            for beq_filter_name in beq_filter_names:
                if beq_filter_name in f["names"]:
                    for c in f["channels"]:
                        beq_filters_matched_by_channel[c].add(beq_filter_name)

    for gain_channel in gain_channels_matched:
        gain_filter_names_by_channel.pop(gain_channel, None)

    for gain_channel, gain_filter_name in gain_filter_names_by_channel.items():
        new_cfg_pipeline.append({
            "type": "Filter",
            "channels": [gain_channel],
            "names": [
                gain_filter_name,
            ]
        })

    # get the unmatched filters by channel
    beq_filters_required_by_channel = {
        c: tuple(sorted([
            n
            for n in beq_filter_names
            if n not in beq_filters_matched_by_channel.get(c, [])
        ]))
        for c in beq_channels
    }
    # group by the filters to minimise the pipeline stages
    beq_filters_to_apply: dict[tuple[str, ...], list[int]] = defaultdict(list)
    for key, value in beq_filters_required_by_channel.items():
        beq_filters_to_apply[tuple(value)].append(key)

    # append them to the pipeline
    for filters, channels in beq_filters_to_apply.items():
        if filters:
            new_cfg_pipeline.append({
                "type": "Filter",
                "channels": list(channels),
                "names": list(filters)
            })

    return new_cfg


def get_filter_type(peq):
    return (
        "Lowshelf"
        if peq["type"].lower() == "lowshelf"
        else "Highshelf"
        if peq["type"].lower() == "highshelf"
        else "Peaking"
    )


def create_cfg_for_gains(values: dict[int, dict], base_cfg: dict) -> dict:
    from copy import deepcopy

    new_cfg = deepcopy(base_cfg)
    if "filters" not in new_cfg:
        new_cfg["filters"] = {}
    for ch, v in values.items():
        gain_filter_name = f"BEQ_Gain_{ch}"
        gain_filter = new_cfg["filters"].get(gain_filter_name, None)
        if gain_filter is None:
            gain_filter = {
                "type": "Gain",
                "description": "ezbeq specified gain",
                "parameters": {
                    "gain": v.get("gain", 0.0),
                    "inverted": False,
                    "mute": v.get("mute", False),
                },
            }
            new_cfg["filters"][gain_filter_name] = gain_filter
        else:
            if "gain" in v:
                gain_filter["parameters"]["gain"] = v["gain"]
            if "mute" in v:
                gain_filter["parameters"]["mute"] = v["mute"]

        if "pipeline" in new_cfg:
            pipeline = new_cfg["pipeline"]
            empty_filter = {"type": "Filter", "names": [], "channels": [ch]}
            existing = next(
                (
                    f
                    for f in pipeline
                    if f["type"] == "Filter" and f["channels"] == [ch]
                ),
                None,
            )
            if existing is None:
                existing = empty_filter
                pipeline.append(existing)
            import re

            if gain_filter_name not in existing["names"]:
                insert_at = next(
                    (
                        i
                        for i, n in enumerate(existing["names"])
                        if re.match(BEQ_FILTER_NAME_PATTERN, n) is not None
                    ),
                    -1,
                )
                if insert_at == -1:
                    existing["names"].append(gain_filter_name)
                else:
                    existing["names"].insert(insert_at, gain_filter_name)
        else:
            raise ValueError("Unable to load BEQ, dsp config has no pipeline declared")
    return new_cfg


class UpdateConfig:
    def __init__(
            self,
            name: str,
            create_cfg: Callable[[dict], dict],
            ws_server: WsServer,
            client: CamillaDspClient,
            on_complete: Callable[[bool], None],
    ):
        self.__name = name
        self.__create_cfg = create_cfg
        self.__dsp_config = None
        self.__ws_server = ws_server
        self.__client = client
        self.__failed = False
        self.__on_complete = on_complete

    @property
    def name(self):
        return self.__name

    def on_get_config(self, cfg: dict):
        logger.info(f"[{self.__name}] Received new DSP config {cfg}")
        self.__dsp_config = cfg
        self.__do_set_config()

    def send_get_config(self):
        logger.info(f"[{self.__name}] Sending GetConfigJson")
        self.__client.send(json.dumps("GetConfigJson"))

    def __do_set_config(self):
        logger.info(f"[{self.__name}] Sending SetConfigJson")
        self.__dsp_config = self.__create_cfg(self.__dsp_config)
        self.__client.send(
            json.dumps(
                {"SetConfigJson": json.dumps(self.__dsp_config)}
            )
        )

    def on_set_config(self) -> dict:
        self.__on_complete(True)
        return self.__dsp_config

    def failed(self, stage: str, payload):
        self.__failed = True
        msg = f"{stage} failed : {payload}"
        logger.warning(f"[{self.__name}] Operation failed - {msg}")
        self.__on_complete(False)
        self.__ws_server.broadcast(json.dumps({"message": "Error", "data": msg}))


class LoadConfig(UpdateConfig):
    def __init__(
            self,
            entry: CatalogueEntry | None,
            beq_channels: list[int],
            mv_adjust: float,
            ws_server: WsServer,
            client: CamillaDspClient,
            on_complete: Callable[[bool], None],
    ):
        super().__init__(
            entry.formatted_title if entry else "Clearing BEQ",
            lambda base: create_cfg_for_entry(
                entry, base, beq_channels, mv_adjust, False
            ),
            ws_server,
            client,
            on_complete,
        )


class UpdateGain(UpdateConfig):
    def __init__(
            self,
            values: dict[int, dict],
            ws_server: WsServer,
            client: CamillaDspClient,
            on_complete: Callable[[bool], None],
    ):
        super().__init__(
            "UpdateGain",
            lambda base: create_cfg_for_gains(values, base),
            ws_server,
            client,
            on_complete,
        )
