#!/usr/bin/env python3
"""Sound Effects Library for SaltyBot.

Event-driven audio feedback system with priority queue, volume control,
and programmatic sound generation. Supports WAV/OGG formats with fallback
synthesis if files missing.

Subscribed topics (dynamic based on config):
  /saltybot/system/boot_complete (std_msgs/Bool)
  /saltybot/perception/person_detected (std_msgs/Bool)
  /saltybot/voice/wake_word (std_msgs/Bool)
  ... (see config for all)

Published topics:
  /saltybot/sound_playing (std_msgs/String) - JSON current sound status
"""

import json
import time
import threading
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Dict, Optional, List, Callable
from enum import Enum
from collections import deque
from queue import PriorityQueue
import warnings

import yaml
import numpy as np
from scipy import signal
from scipy.io import wavfile
import rclpy
from rclpy.node import Node
from std_msgs.msg import String, Bool, Float32


@dataclass
class SoundEvent:
    """Sound event with priority."""
    name: str
    filename: str
    duration: float
    priority: int
    timestamp: float

    def __lt__(self, other):
        """Compare by priority (higher priority first)."""
        return self.priority > other.priority  # Inverted for max-heap


class SoundSynthesizer:
    """Programmatically generate sounds if files missing."""

    def __init__(self, sample_rate: int = 16000):
        """Initialize synthesizer.
        
        Args:
            sample_rate: Sample rate in Hz
        """
        self.sample_rate = sample_rate

    def generate_sine(self, frequency: float, duration: float,
                      amplitude: float = 0.3) -> np.ndarray:
        """Generate sine wave."""
        t = np.linspace(0, duration, int(self.sample_rate * duration))
        return (amplitude * np.sin(2 * np.pi * frequency * t)).astype(np.float32)

    def generate_chime(self, frequencies: List[float], duration: float) -> np.ndarray:
        """Generate ascending chime."""
        num_notes = len(frequencies)
        note_duration = duration / num_notes
        samples = []

        for freq in frequencies:
            note = self.generate_sine(freq, note_duration)
            # Add fade envelope
            envelope = np.linspace(0, 1, len(note) // 2).tolist() +                       np.linspace(1, 0, len(note) // 2).tolist()
            note = note * np.array(envelope)
            samples.append(note)

        return np.concatenate(samples)

    def generate_descending(self, frequencies: List[float],
                           duration: float) -> np.ndarray:
        """Generate descending tone."""
        num_notes = len(frequencies)
        note_duration = duration / num_notes
        samples = []

        for freq in frequencies:
            note = self.generate_sine(freq, note_duration)
            envelope = signal.windows.hann(len(note))
            note = note * envelope
            samples.append(note)

        return np.concatenate(samples)

    def generate_power_up(self, start_freq: float, end_freq: float,
                         duration: float) -> np.ndarray:
        """Generate rising power-up tone."""
        t = np.linspace(0, duration, int(self.sample_rate * duration))
        freq = np.linspace(start_freq, end_freq, len(t))
        phase = 2 * np.pi * np.cumsum(freq) / self.sample_rate
        return (0.3 * np.sin(phase)).astype(np.float32)

    def generate_beep(self, frequency: float, duration: float) -> np.ndarray:
        """Generate simple beep with envelope."""
        t = np.linspace(0, duration, int(self.sample_rate * duration))
        sine = 0.3 * np.sin(2 * np.pi * frequency * t)
        envelope = signal.windows.hann(len(sine))
        return (sine * envelope).astype(np.float32)

    def generate_proximity(self, frequency: float, duration: float,
                          pulse_rate: float = 5) -> np.ndarray:
        """Generate rapid proximity beeps."""
        pulse_duration = 1.0 / pulse_rate
        samples = []
        elapsed = 0

        while elapsed < duration:
            beep = self.generate_beep(frequency, min(pulse_duration / 2, duration - elapsed))
            silence = np.zeros(int(self.sample_rate * pulse_duration / 2))
            samples.append(np.concatenate([beep, silence]))
            elapsed += pulse_duration

        return np.concatenate(samples[:int(duration * pulse_rate)])


class SoundEffectsNode(Node):
    """ROS2 node for sound effects playback."""

    def __init__(self):
        super().__init__("sound_effects")

        # Load config
        self.declare_parameter("config_file", "sound_config.yaml")
        config_path = self.get_parameter("config_file").value
        self.config = self._load_config(config_path)

        # State
        self.current_sound: Optional[SoundEvent] = None
        self.sound_queue = PriorityQueue()
        self.synthesizer = SoundSynthesizer(
            self.config["synthesis"]["sample_rate"]
        )
        self.volume = self.config["volume"]["default_volume"]
        self.is_playing = False
        self.quiet_mode = False
        self.night_mode = False

        # Load sounds
        self.sounds_cache: Dict[str, np.ndarray] = {}
        self._load_sounds()

        # Subscribe to event topics
        self._subscribe_to_events()

        # Publisher
        self.sound_state_pub = self.create_publisher(
            String, "/saltybot/sound_playing", 10
        )

        # Playback thread
        self.playback_thread = threading.Thread(target=self._playback_loop, daemon=True)
        self.playback_thread.start()

        # Timer for queue processing
        self.timer = self.create_timer(0.1, self._process_queue)

        self.get_logger().info("Sound Effects node initialized")

    def _load_config(self, config_path: str) -> Dict:
        """Load YAML configuration."""
        try:
            with open(config_path) as f:
                return yaml.safe_load(f)
        except FileNotFoundError:
            self.get_logger().warn(f"Config not found: {config_path}")
            return {}

    def _load_sounds(self):
        """Load sound files and cache them."""
        sounds_dir = Path(self.config.get("audio", {}).get("sounds_directory", "/tmp"))

        for event_name, event_config in self.config.get("events", {}).items():
            filename = event_config.get("filename")
            if not filename:
                continue

            sound_path = sounds_dir / filename
            if sound_path.exists():
                try:
                    rate, data = wavfile.read(str(sound_path))
                    # Normalize to float
                    if data.dtype != np.float32:
                        data = data.astype(np.float32) / 32768.0
                    self.sounds_cache[event_name] = data
                    self.get_logger().info(f"Loaded sound: {event_name}")
                except Exception as e:
                    self.get_logger().warn(f"Failed to load {filename}: {e}")
            else:
                self.get_logger().info(f"Sound file missing: {filename}, will use synthesis")

    def _subscribe_to_events(self):
        """Subscribe to all event topics from config."""
        for event_name, topic in self.config.get("subscriptions", {}).items():
            def callback(msg, event=event_name):
                if msg.data:
                    self._queue_sound(event)

            self.create_subscription(Bool, topic, callback, 10)

    def _queue_sound(self, event_name: str):
        """Queue a sound for playback."""
        event_config = self.config.get("events", {}).get(event_name)
        if not event_config:
            return

        event = SoundEvent(
            name=event_name,
            filename=event_config.get("filename", ""),
            duration=event_config.get("duration", 1.0),
            priority=event_config.get("priority", 50),
            timestamp=time.time(),
        )

        self.sound_queue.put(event)
        self.get_logger().info(f"Queued sound: {event_name} (priority: {event.priority})")

    def _process_queue(self):
        """Process sound queue."""
        if self.is_playing:
            return  # Still playing current sound

        if not self.sound_queue.empty():
            try:
                self.current_sound = self.sound_queue.get_nowait()
                self.is_playing = True
            except:
                pass

    def _playback_loop(self):
        """Background thread for audio playback."""
        while True:
            if self.current_sound and self.is_playing:
                audio_data = self._get_audio_data(self.current_sound.name)
                if audio_data is not None:
                    self._play_audio(audio_data, self.current_sound.name)

                self.is_playing = False
                self.current_sound = None

            time.sleep(0.05)

    def _get_audio_data(self, event_name: str) -> Optional[np.ndarray]:
        """Get audio data for event (cached or synthesized)."""
        if event_name in self.sounds_cache:
            return self.sounds_cache[event_name]

        if not self.config.get("synthesis", {}).get("enabled", True):
            return None

        # Synthesize default sound
        return self._synthesize_sound(event_name)

    def _synthesize_sound(self, event_name: str) -> Optional[np.ndarray]:
        """Synthesize sound programmatically."""
        defaults = self.config.get("synthesis", {}).get("defaults", {})
        event_config = defaults.get(event_name)

        if not event_config:
            return None

        sound_type = event_config.get("type", "sine")
        duration = event_config.get("duration", 1.0)

        try:
            if sound_type == "sine":
                freq = event_config.get("frequency", 440)
                return self.synthesizer.generate_sine(freq, duration)
            elif sound_type == "chime":
                freqs = event_config.get("frequencies", [523.25, 659.25])
                return self.synthesizer.generate_chime(freqs, duration)
            elif sound_type == "descending":
                freqs = event_config.get("frequencies", [800, 600, 400])
                return self.synthesizer.generate_descending(freqs, duration)
            elif sound_type == "power_up":
                start = event_config.get("start_freq", 400)
                end = event_config.get("end_freq", 1200)
                return self.synthesizer.generate_power_up(start, end, duration)
            elif sound_type in ["beep", "warning", "alert"]:
                freq = event_config.get("frequency", 1000)
                return self.synthesizer.generate_beep(freq, duration)
            elif sound_type == "proximity":
                freq = event_config.get("frequency", 1200)
                pulse = event_config.get("pulse_rate", 5)
                return self.synthesizer.generate_proximity(freq, duration, pulse)
            elif sound_type == "jingle":
                freqs = event_config.get("frequencies", [523.25, 659.25, 783.99])
                return self.synthesizer.generate_chime(freqs, duration)
        except Exception as e:
            self.get_logger().warn(f"Sound synthesis failed for {event_name}: {e}")

        return None

    def _play_audio(self, audio_data: np.ndarray, event_name: str):
        """Play audio data (mock implementation)."""
        # In real implementation, would use pygame, pyaudio, or similar
        duration = len(audio_data) / self.config["synthesis"]["sample_rate"]

        state = {
            "event": event_name,
            "playing": True,
            "duration": duration,
            "volume": self.volume,
        }
        self.sound_state_pub.publish(String(data=json.dumps(state)))

        # Simulate playback
        time.sleep(duration)

        state["playing"] = False
        self.sound_state_pub.publish(String(data=json.dumps(state)))


def main(args=None):
    """Main entry point."""
    rclpy.init(args=args)
    node = SoundEffectsNode()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()


if __name__ == "__main__":
    main()
