"""NodeProxy and Ndef -- live coding with hot-swappable synth definitions.

A ``NodeProxy`` owns a private audio bus, a source synth, and a monitor
synth.  Hot-swapping replaces only the source synth while the monitor
stays in place, enabling seamless audio transitions.

``Ndef`` is a global named proxy registry for concise live-coding::

    from nanosynth.proxy import Ndef

    Ndef(server, "pad", lambda: SinOsc.ar(440) * 0.3)
    Ndef(server, "pad").play()
    Ndef(server, "pad", lambda: Saw.ar(220) * 0.2)  # hot-swap
    Ndef(server, "pad").clear()
"""

from __future__ import annotations

from collections.abc import Callable
from typing import Any, ClassVar

from .enums import AddAction, DoneAction
from .exceptions import EngineError
from .server import Bus, Server, Synth
from .synthdef import SynthDef, SynthDefBuilder


class NodeProxy:
    """Hot-swappable synth node backed by a private audio bus.

    The proxy owns:
    - A private audio bus (allocated via ``server.audio_bus()``)
    - A source synth (writes to the private bus)
    - A monitor synth (reads from private bus, writes to hardware output)

    Swapping the source replaces only the source synth.  The monitor
    stays in place, so the output bus never changes.

    Args:
        server: The Server instance.
        num_channels: Number of audio channels for the private bus.
    """

    def __init__(self, server: Server, num_channels: int = 2) -> None:
        self._server = server
        self._num_channels = num_channels
        self._bus: Bus | None = None
        self._source_synth: Synth | None = None
        self._monitor_synth: Synth | None = None
        self._source_value: Callable[..., Any] | SynthDef | None = None
        self._version = 0
        self._monitor_playing = False

    def _ensure_bus(self) -> Bus:
        """Lazily allocate the private audio bus."""
        if self._bus is None:
            self._bus = self._server.audio_bus(self._num_channels)
        return self._bus

    @property
    def bus(self) -> Bus | None:
        """The private audio bus, or None if not yet allocated."""
        return self._bus

    @property
    def is_playing(self) -> bool:
        """Whether the monitor is currently active."""
        return self._monitor_playing

    @property
    def source(self) -> Callable[..., Any] | SynthDef | None:
        """The current source (callable, SynthDef, or None)."""
        return self._source_value

    @source.setter
    def source(self, value: Callable[..., Any] | SynthDef | None) -> None:
        """Set the source.

        - Callable: wrapped in SynthDefBuilder automatically with an ASR
          envelope for clean crossfade.
        - SynthDef: used directly (must write to Out.ar with a ``bus`` param).
        - None: clear the source (free synth, keep bus/monitor).
        """
        if value is None:
            self._clear_source()
            self._source_value = None
            return

        bus = self._ensure_bus()

        if callable(value) and not isinstance(value, SynthDef):
            synthdef = self._build_source_synthdef(value)
        elif isinstance(value, SynthDef):
            synthdef = value
        else:
            raise TypeError(
                f"source must be callable, SynthDef, or None, got {type(value)}"
            )

        # Send the new SynthDef
        self._server.send_synthdef(synthdef)

        # Release old source synth (triggers ASR release -> FREE_SYNTH)
        if self._source_synth is not None:
            self._server.set(self._source_synth, gate=0.0)

        # Create new source synth writing to private bus
        self._source_synth = self._server.synth(
            synthdef.effective_name,
            out=float(bus.bus_id),
        )
        self._source_value = value
        self._version += 1

    def _build_source_synthdef(self, func: Callable[..., Any]) -> SynthDef:
        """Wrap a callable in a SynthDef with ASR envelope for crossfade."""
        from .envelopes import EnvGen, Envelope
        from .ugens.inout import Out

        name = f"__proxy_{id(self)}_{self._version}__"
        bus = self._ensure_bus()
        with SynthDefBuilder(out=float(bus.bus_id), gate=1.0) as builder:
            sig = func()
            env = EnvGen.kr(
                envelope=Envelope.asr(attack_time=0.01, sustain=1.0, release_time=0.01),
                gate=builder["gate"],
                done_action=DoneAction.FREE_SYNTH,
            )
            Out.ar(bus=builder["out"], source=sig * env)  # type: ignore[attr-defined]
        return builder.build(name=name)

    def _ensure_monitor_synthdef(self) -> str:
        """Build and send the monitor SynthDef (cached on server by channel count)."""
        from .ugens.inout import In, Out

        name = f"__proxy_monitor_{self._num_channels}ch__"
        if name in self._server._synthdefs:
            return name

        with SynthDefBuilder(in_bus=0.0, out_bus=0.0) as builder:
            sig = In.ar(bus=builder["in_bus"], channel_count=self._num_channels)  # type: ignore[attr-defined]
            Out.ar(bus=builder["out_bus"], source=sig)  # type: ignore[attr-defined]
        synthdef = builder.build(name=name)
        self._server.send_synthdef(synthdef)
        return name

    def play(self, out: int = 0, num_channels: int | None = None) -> None:
        """Start monitoring (create monitor synth reading from private bus).

        Args:
            out: Hardware output bus index.
            num_channels: Override channel count (defaults to proxy's channel count).
        """
        if self._monitor_playing:
            return

        bus = self._ensure_bus()
        monitor_name = self._ensure_monitor_synthdef()
        self._monitor_synth = self._server.synth(
            monitor_name,
            action=AddAction.ADD_TO_TAIL,
            target=1,
            in_bus=float(bus.bus_id),
            out_bus=float(out),
        )
        self._monitor_playing = True

    def stop(self) -> None:
        """Stop monitoring (free monitor synth).  Source keeps running."""
        if self._monitor_synth is not None:
            self._server.free(self._monitor_synth)
            self._monitor_synth = None
        self._monitor_playing = False

    def _clear_source(self) -> None:
        """Free the source synth if running."""
        if self._source_synth is not None:
            self._server.free(self._source_synth)
            self._source_synth = None

    def clear(self) -> None:
        """Free everything: source synth, monitor synth, bus."""
        self._clear_source()
        self.stop()
        if self._bus is not None:
            self._bus.free()
            self._bus = None
        self._source_value = None

    def set(self, **params: float) -> None:
        """Set parameters on the running source synth.

        Raises:
            RuntimeError: If no source synth is running.
        """
        if self._source_synth is None:
            raise EngineError("No source synth is running")
        self._source_synth.set(**params)


# ---------------------------------------------------------------------------
# Ndef -- named proxy registry
# ---------------------------------------------------------------------------

_MISSING: object = object()


class Ndef:
    """Global registry of named NodeProxy instances.

    ``Ndef`` is a factory that returns (or creates) a ``NodeProxy`` for a
    given server + name pair.  If a *source* is provided, it is assigned
    to the proxy.

    Usage::

        Ndef(server, "pad", lambda: SinOsc.ar(440) * 0.3)
        Ndef(server, "pad").play()
        Ndef(server, "pad", lambda: Saw.ar(220) * 0.2)  # hot-swap
        Ndef(server, "pad").clear()

        Ndef.clear_all(server)
    """

    _registry: ClassVar[dict[tuple[int, str], NodeProxy]] = {}

    def __new__(  # type: ignore[misc]
        cls,
        server: Server,
        name: str,
        source: Callable[..., Any] | SynthDef | None = _MISSING,  # type: ignore[assignment]
    ) -> NodeProxy:
        key = (id(server), name)
        if key not in cls._registry:
            cls._registry[key] = NodeProxy(server)
        proxy = cls._registry[key]
        if source is not _MISSING:
            proxy.source = source
        return proxy

    @classmethod
    def clear_all(cls, server: Server) -> None:
        """Clear all proxies associated with *server*."""
        server_id = id(server)
        keys_to_remove = [k for k in cls._registry if k[0] == server_id]
        for key in keys_to_remove:
            cls._registry[key].clear()
            del cls._registry[key]
