/* eslint-disable @typescript-eslint/no-non-null-assertion */
/**
 * Manages RNNoise-based noise reduction for microphone audio.
 *
 * Uses the `@timephy/rnnoise-wasm` AudioWorklet to process the raw
 * microphone stream through a neural-network noise gate, producing
 * a clean output stream that can be sent to peers instead.
 *
 * Architecture:
 *   raw mic -> AudioContext.createMediaStreamSource
 *             -> NoiseSuppressorWorklet (AudioWorkletNode)
 *             -> MediaStreamDestination -> clean MediaStream
 *
 * The manager is intentionally stateless w.r.t. Angular signals;
 * the owning MediaManager / WebRTCService drives signals.
 */
import { WebRTCLogger } from '../logging/webrtc-logger';

/** Name used to register / instantiate the AudioWorklet processor. */
const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet';
/** RNNoise is trained on 48 kHz audio - the AudioContext must match. */
const RNNOISE_SAMPLE_RATE = 48_000;
/**
 * Relative path (from the served application root) to the **bundled**
 * worklet script placed in `public/` and served as a static asset.
 */
const WORKLET_MODULE_PATH = 'rnnoise-worklet.js';

export class NoiseReductionManager {
  /** The AudioContext used for the noise-reduction graph. */
  private audioContext: AudioContext | null = null;

  /** Source node wrapping the raw microphone stream. */
  private sourceNode: MediaStreamAudioSourceNode | null = null;

  /** The RNNoise AudioWorklet node. */
  private workletNode: AudioWorkletNode | null = null;

  /** Destination node that exposes the cleaned stream. */
  private destinationNode: MediaStreamAudioDestinationNode | null = null;

  /** Whether the worklet module has been loaded into the AudioContext. */
  private workletLoaded = false;

  /** Whether noise reduction is currently active. */
  private _isEnabled = false;

  constructor(private readonly logger: WebRTCLogger) {}

  /** Whether noise reduction is currently active. */
  get isEnabled(): boolean {
    return this._isEnabled;
  }

  /**
   * Enable noise reduction on a raw microphone stream.
   *
   * Builds the AudioWorklet processing graph and returns a new
   * {@link MediaStream} whose audio has been denoised.
   *
   * If the worklet cannot be loaded (e.g. unsupported browser),
   * the original stream is returned unchanged and an error is logged.
   *
   * @param rawStream - The raw `getUserMedia` microphone stream.
   * @returns A denoised {@link MediaStream}, or the original if setup fails.
   */
  async enable(rawStream: MediaStream): Promise<MediaStream> {
    if (this._isEnabled && this.destinationNode) {
      this.logger.info('Noise reduction already enabled, returning existing clean stream');
      return this.destinationNode.stream;
    }

    try {
      await this.buildProcessingGraph(rawStream);
      this._isEnabled = true;
      this.logger.info('Noise reduction enabled');
      return this.destinationNode!.stream;
    } catch (err) {
      this.logger.error('Failed to enable noise reduction, returning raw stream', err);
      this.teardownGraph();
      return rawStream;
    }
  }

  /**
   * Disable noise reduction and tear down the processing graph.
   *
   * After calling this, the original raw microphone stream should be
   * used again (the caller is responsible for re-binding tracks).
   */
  disable(): void {
    if (!this._isEnabled)
      return;

    this.teardownGraph();
    this._isEnabled = false;
    this.logger.info('Noise reduction disabled');
  }

  /**
   * Re-pipe a new raw stream through the existing noise-reduction graph.
   *
   * Useful when the microphone device changes but noise reduction
   * should stay active.
   *
   * @param rawStream - The new raw microphone stream.
   * @returns The denoised stream, or the raw stream on failure.
   */
  async replaceInputStream(rawStream: MediaStream): Promise<MediaStream> {
    if (!this._isEnabled)
      return rawStream;

    try {
      // Disconnect old source but keep the rest of the graph alive
      this.sourceNode?.disconnect();

      if (!this.audioContext || !this.workletNode || !this.destinationNode) {
        throw new Error('Processing graph not initialised');
      }

      this.sourceNode = this.audioContext.createMediaStreamSource(rawStream);
      this.sourceNode.connect(this.workletNode);

      this.logger.info('Noise reduction input stream replaced');
      return this.destinationNode.stream;
    } catch (err) {
      this.logger.error('Failed to replace noise reduction input', err);
      return rawStream;
    }
  }

  /** Clean up all resources. Safe to call multiple times. */
  destroy(): void {
    this.disable();
    this.audioContext = null;
    this.workletLoaded = false;
  }

  /**
   * Build the AudioWorklet processing graph:
   *   rawStream -> source -> workletNode -> destination
   */
  private async buildProcessingGraph(rawStream: MediaStream): Promise<void> {
    // Reuse or create the AudioContext (must be 48 kHz for RNNoise)
    if (!this.audioContext || this.audioContext.state === 'closed') {
      this.audioContext = new AudioContext({ sampleRate: RNNOISE_SAMPLE_RATE });
      this.workletLoaded = false;
    }

    // Resume if suspended (browsers auto-suspend until user gesture)
    if (this.audioContext.state === 'suspended') {
      await this.audioContext.resume();
    }

    // Load the worklet module once per AudioContext lifetime
    if (!this.workletLoaded) {
      await this.audioContext.audioWorklet.addModule(WORKLET_MODULE_PATH);
      this.workletLoaded = true;
      this.logger.info('RNNoise worklet module loaded');
    }

    // Build the node graph
    this.sourceNode = this.audioContext.createMediaStreamSource(rawStream);
    this.workletNode = new AudioWorkletNode(this.audioContext, WORKLET_PROCESSOR_NAME);
    this.destinationNode = this.audioContext.createMediaStreamDestination();

    this.sourceNode.connect(this.workletNode).connect(this.destinationNode);
  }

  /** Disconnect and release all graph nodes. */
  private teardownGraph(): void {
    try {
      this.sourceNode?.disconnect();
    } catch (error) {
      this.logger.warn('Noise reduction source node already disconnected', error);
    }

    try {
      this.workletNode?.disconnect();
    } catch (error) {
      this.logger.warn('Noise reduction worklet node already disconnected', error);
    }

    try {
      this.destinationNode?.disconnect();
    } catch (error) {
      this.logger.warn('Noise reduction destination node already disconnected', error);
    }

    this.sourceNode = null;
    this.workletNode = null;
    this.destinationNode = null;

    // Close the context to free hardware resources
    if (this.audioContext && this.audioContext.state !== 'closed') {
      this.audioContext.close().catch((error) => {
        this.logger.warn('Failed to close RNNoise audio context', error);
      });
    }

    this.audioContext = null;
    this.workletLoaded = false;
  }
}
