/**
 * Copyright © 2020-2025, Luis Andrés Lange <https://javacomm.net>
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 *
 * ----------------------------------------------------------------------------
 *
 * Exhibit B - "Incompatible With Secondary Licenses" Notice
 *
 * This Source Code Form is "Incompatible With Secondary Licenses",
 * as defined by the Mozilla Public License, v. 2.0.
 *
 * In short:
 * - This file may be used, modified, and distributed under MPL 2.0 only.
 * - It may NOT be relicensed under GPL, LGPL, AGPL, or any other Secondary License.
 *
 * Rationale:
 * - Ensures that the code remains MPL-2.0.
 * - Avoids legal conflicts with GPL-licensed libraries (e.g., VideoLAN).
 * - Maximizes usability for commercial and security-critical applications.
 *
 */
package net.javacomm.client.phone;

import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.sound.sampled.FloatControl;
import javax.sound.sampled.LineUnavailableException;
import javax.sound.sampled.Mixer.Info;
import javax.sound.sampled.SourceDataLine;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;



/**
 * Speaker mit Jitter-Buffer (PriorityBlockingQueue) und adaptiver
 * Latenzregelung. Optimiert für Netzwerk-Jitter bei paralleler Bildübertragung
 * durch eine lückenlose Zeitketten-Berechnung (verhindert das "Stapeln"
 * verzögerter Pakete).
 */
public class Speaker {

  /* --- Hilfsklasse --- */
  private static class AudioPacket implements Comparable<AudioPacket> {
    final long playbackTime;
    final byte[] data;

    AudioPacket(long playbackTime, byte[] data) {
      this.playbackTime = playbackTime;
      this.data = data;
    }



    @Override
    public int compareTo(AudioPacket o) {
      return Long.compare(playbackTime, o.playbackTime);
    }
  }

  private static final Logger log = LogManager.getLogger(Speaker.class);
  /* --- Adaptionsparameter --- */
  private static final int INCREASE_STEP = 10;
  private static final int DECREASE_STEP = 5;
  private static final int STABLE_REQUIRED_COUNT = 3;
  private static final int QUEUE_SIZE_THRESHOLD = 4;

  private static final double DROP_RATE_THRESHOLD = 0.05;

  // KORREKTUR: Schwellenwert für Jitter-Reset. Erst wenn die Zeitkette
  // um mehr als 25ms abweicht, wird hart zurückgesetzt. Verhindert 0ms-Resets.
  private static final int JITTER_THRESHOLD_MS = 75;

  private final SourceDataLine sourceDataline;
  private final PriorityBlockingQueue<AudioPacket> queue;

  private final AtomicBoolean running = new AtomicBoolean(true);
  private final Thread audioWorker;

  private final Telefonformat telefonFormat;
  /* --- Zeitketten-Steuerung gegen Netzwerk-Jitter --- */
  private long lastAssignedPlaybackTime = 0;

  /* --- Konfigurierbare Parameter / Konstanten --- */
  private volatile int targetLatencyMs = 50;
  private volatile int maxDelayMs = 200;
  private final int minLatencyMs = 20;
  private final int maxLatencyMs = 2000;
  private final int frameIntervalMs = 20;

  private final int maxQueueSize = 2048;
  /* --- Adaptionszähler (threadsafe) --- */
  private final AtomicInteger droppedSinceLastAdapt = new AtomicInteger(0);

  private final AtomicInteger playedSinceLastAdapt = new AtomicInteger(0);
  private int stableChecksInARow = 0;

  private long lastAdaptMs = System.currentTimeMillis();

  /**
   * Konstruktor
   */
  public Speaker(String speakername, Telefonformat format)
      throws MixerNotFoundException, LineUnavailableException {
    telefonFormat = format;
    Info info = TelefonUtil.toSpeakermixer(speakername);
    sourceDataline = TelefonUtil.getSourceDataLine(info, format);

    // PriorityBlockingQueue sortiert AudioPacket nach playbackTime (kleinstes
    // zuerst)
    queue = new PriorityBlockingQueue<>();

    sourceDataline.open(TelefonUtil.getAudioFormat(format));

    audioWorker = new Thread(this::audioLoop, "Speaker-AudioWorker-" + speakername);
    audioWorker.setDaemon(true);
    audioWorker.start();
  }



  /**
   * Adaptive Latenzregelung
   */
  private void adaptLatency() {
    int dropped = droppedSinceLastAdapt.get();
    int played = playedSinceLastAdapt.get();
    int total = dropped + played;
    int queueSize = queue.size();

    if (total < 5) {
      if (log.isDebugEnabled()) {
        log.debug("Adapt: zu wenige Daten (total={}), skip", total);
      }
      return;
    }

    double dropRate = (double) dropped / (double) total;

    // Idle-Stau: Queue voll aber keine Drops -> targetLatencyMs ist zu hoch
    // Aggressiv reduzieren bis die Queue sich leert
    if (dropRate == 0.0 && queueSize > QUEUE_SIZE_THRESHOLD && targetLatencyMs > minLatencyMs + 50) {
      int reduktion = Math.max(50, targetLatencyMs / 4);
      int newLatency = targetLatencyMs - reduktion;
      if (newLatency < minLatencyMs) {
        newLatency = minLatencyMs;
      }
      targetLatencyMs = newLatency;
      stableChecksInARow = 0;
      if (log.isDebugEnabled()) {
        log.debug(
            "Adapt: Idle-Stau erkannt (dropRate=0, Queue={}) -> aggressiv reduziere targetLatencyMs={}",
            queueSize, targetLatencyMs
        );
      }
      return;
    }

    if (dropRate > DROP_RATE_THRESHOLD || queueSize > QUEUE_SIZE_THRESHOLD) {
      int newLatency = targetLatencyMs + INCREASE_STEP;

      if (newLatency > maxLatencyMs) {
        newLatency = maxLatencyMs;
      }

      if (newLatency != targetLatencyMs) {
        targetLatencyMs = newLatency;
        stableChecksInARow = 0;
        if (log.isDebugEnabled()) {
          log.debug(
              "Adapt: Drop-Rate {} oder Queue {} > {} -> erhöhe targetLatencyMs={}",
              String.format("%.2f", dropRate), queueSize, QUEUE_SIZE_THRESHOLD, targetLatencyMs
          );
        }
      }
    }
    else if (dropRate == 0.0 && queueSize <= QUEUE_SIZE_THRESHOLD) {
      stableChecksInARow++;
      if (stableChecksInARow >= STABLE_REQUIRED_COUNT) {
        int newLatency = targetLatencyMs - DECREASE_STEP;
        int clampedMin = minLatencyMs;
        if (newLatency < clampedMin) {
          newLatency = clampedMin;
        }

        if (newLatency != targetLatencyMs) {
          targetLatencyMs = newLatency;
          if (log.isDebugEnabled()) {
            log.debug(
                "Adapt: stabile Verbindung über {} Checks -> reduziere targetLatencyMs={}",
                STABLE_REQUIRED_COUNT, targetLatencyMs
            );
          }
        }
        stableChecksInARow = 0;
      }
    }
    else {
      stableChecksInARow = 0;
      if (log.isDebugEnabled()) {
        log.debug(
            "Adapt: keine Änderung (dropRate={}, queueSize={})", String.format("%.2f", dropRate), queueSize
        );
      }
    }
  }



  /**
   * Worker-Thread für die Audio-Ausgabe
   */
  private void audioLoop() {
    try {
      sourceDataline.start();

      while (running.get()) {
        AudioPacket head = queue.peek();

        if (head == null) {
          // keine Pakete: kurz schlafen, damit CPU nicht busy-loopt
          try {
            Thread.sleep(5);
          }
          // CHECKSTYLE:OFF
          catch (InterruptedException ie) {
            // CHECKSTYLE:ON
            Thread.currentThread().interrupt();
          }
          continue;
        }

        long now = System.currentTimeMillis();
        int currentQueueSize = queue.size();

        // Catch-up Logik bei extremem Stau (beschleunigt das Abarbeiten der Queue)
        // Wenn die Queue über den Schwellwert steigt (hier 15 Pakete, ca. 300-400ms
        // Audio),
        // ignorieren wir das delayMs und spielen sofort ab. Dadurch "rafft" der Client
        // die Zeit und baut die 3-5 Sekunden Verzögerung aktiv ab.
        boolean forcePlayback = currentQueueSize > 15;
        long delayMs = head.playbackTime - now;

        // Nur warten, wenn wir nicht im Catch-up Modus sind
        if (delayMs > 0 && !forcePlayback) {
          // Kopf ist noch nicht fällig -> schlafen bis fällig (oder maximal
          // frameIntervalMs)
          long sleepMs = Math.min(delayMs, frameIntervalMs);
          try {
            Thread.sleep(Math.max(1, (int) sleepMs));
          }
          // CHECKSTYLE:OFF
          catch (InterruptedException ie) {
            // CHECKSTYLE:ON
            Thread.currentThread().interrupt();
          }
          continue;
        }

        AudioPacket pkt = queue.poll();
        if (pkt == null) continue;

        long ageMs = now - pkt.playbackTime;

        // KORREKTUR: Ein veraltetes Paket wird IMMER gedroppt, um Pufferbildung im OS
        // zu verhindern.
        if (ageMs > maxDelayMs) {
          droppedSinceLastAdapt.incrementAndGet();
          if (log.isDebugEnabled()) {
            log.debug("Droppe veraltetes Paket im Loop: ageMs={}ms (maxDelayMs ist {}ms)", ageMs, maxDelayMs);
          }

          // KORREKTUR: Wenn die Warteschlange kollabiert war, müssen wir auch den bereits
          // geschriebenen Hardwarerest aus der Soundkarte werfen.
          if (currentQueueSize > 10) {
            try {
              sourceDataline.flush();
            }
            catch (Throwable t) { /* ignore */ }
          }
        }
        else {
          // abspielen (write blockiert ggf. wenn Puffer der Soundkarte voll)
          try {
            sourceDataline.write(pkt.data, 0, pkt.data.length);
            playedSinceLastAdapt.incrementAndGet();
          }
          catch (Throwable t) {
            log.error("Fehler beim Schreiben in SourceDataLine: {}", t.getMessage(), t);
          }
        }

        // adaptive Anpassung ungefähr jede Sekunde prüfen
        long nowAdapt = System.currentTimeMillis();
        if (nowAdapt - lastAdaptMs >= 1000) {
          adaptLatency();
          droppedSinceLastAdapt.set(0);
          playedSinceLastAdapt.set(0);
          lastAdaptMs = nowAdapt;
        }
      }

      // sauber beenden
      try {
        sourceDataline.drain();
      }
      catch (Throwable t) { /* ignore */ }
    }
    finally {
      try {
        sourceDataline.stop();
      }
      catch (Throwable t) { /* ignore */ }
      try {
        sourceDataline.close();
      }
      catch (Throwable t) { /* ignore */ }
    }
  }



  /**
   * Berechnet die Spieldauer eines Byte-Arrays in Millisekunden exakt anhand
   * deines Telefonformats.
   */
  private long calculatePacketDurationMs(int byteLength) {
    if (telefonFormat == null) return 20; // Sicherer Fallback

    // Ermittlung der Kanäle (Mono = 1, Stereo = 2) aus deinem Enum-Setup
    int channelCount = telefonFormat.channels() == Channels.STEREO ? 2 : 1;

    // Ermittlung der Bits pro Sample (16 Bit = 2 Bytes)
    int bytesPerSample = telefonFormat.getSampleSizeInBits() == SampleSizeInBits.BITS16 ? 2 : 1;

    // Bytes pro Sekunde = Frequenz (Hz) * Bytes pro Sample * Kanäle
    double bytesPerSecond = telefonFormat.getFrequenz() * bytesPerSample * channelCount;

    if (bytesPerSecond <= 0) return 20;

    return (long) (byteLength / bytesPerSecond * 1000.0);
  }



  public int getMaxDelayMs() {
    return maxDelayMs;
  }



  public int getQueueSize() {
    return queue.size();
  }



  public SourceDataLine getSourceDataline() {
    return sourceDataline;
  }



  public SourceDataLine getSourceDataLine() {
    return sourceDataline;
  }



  public int getTargetLatencyMs() {
    return targetLatencyMs;
  }



  /**
   * Rückwärtskompatible play-Methode (kein Timestamp gegeben) — wir nehmen
   * System.currentTimeMillis() als Empfangszeit.
   *
   * Wird typischerweise vom Netzwerk-Thread aufgerufen.
   */
  public void play(byte[] audiodata) {
    play(audiodata, System.currentTimeMillis());
  }



  /**
   * Play mit Eingangstimestamp in Millisekunden (z. B. System.currentTimeMillis()
   * oder serverseitiger timestamp + Korrektur).
   *
   * @param audiodata
   *                    PCM-Bytes
   * @param timestampMs
   *                    Empfangszeitpunkt in Millisekunden
   */
  public void play(byte[] audiodata, long timestampMs) {
    if (!running.get()) return;
    if (audiodata == null || audiodata.length == 0) return;

    // Defensive copy
    byte[] copy = new byte[audiodata.length];
    System.arraycopy(audiodata, 0, copy, 0, audiodata.length);

    long now = System.currentTimeMillis();
    long realNetworkDelay = now - timestampMs;
    long playbackTime;

    // Konferenz-Deckel: Verhindert das Aufschaukeln der Kette bei parallelen
    // Streams
    if (now > lastAssignedPlaybackTime || (lastAssignedPlaybackTime - now > targetLatencyMs + 40)) {
      playbackTime = now + targetLatencyMs;
    }
    else {
      long durationMs = calculatePacketDurationMs(copy.length);
      playbackTime = lastAssignedPlaybackTime + durationMs;
    }

    // Jitter-Messung für dieses spezifische Paket
    long packageDrift = Math.abs(now - (timestampMs + targetLatencyMs));

    // KORREKTUR: JITTER_THRESHOLD_MS (z.B. auf 50ms oder 100ms gesetzt) macht
    // wieder Sinn!
    // Wenn die Paket-Drift diesen Schwellenwert überschreitet, korrigieren wir die
    // Zeitkette.
    if (realNetworkDelay > maxDelayMs || packageDrift > JITTER_THRESHOLD_MS) {
      if (log.isDebugEnabled()) {
        log.debug(
            "Jitter-Abweichung erkannt (NetDelay: {}ms, PackDrift: {}ms > Thresh: {}ms). Passe Kette an.",
            realNetworkDelay, packageDrift, JITTER_THRESHOLD_MS
        );
      }
      playbackTime = now + targetLatencyMs;
    }

    lastAssignedPlaybackTime = playbackTime;

    AudioPacket pkt = new AudioPacket(playbackTime, copy);
    queue.offer(pkt);

    if (queue.size() > maxQueueSize) {
      AudioPacket[] arr = queue.toArray(new AudioPacket[0]);
      AudioPacket max = arr[0];
      for (AudioPacket p : arr) {
        if (p.playbackTime > max.playbackTime) max = p;
      }
      if (max != null && queue.remove(max)) {
        droppedSinceLastAdapt.incrementAndGet();
        if (log.isDebugEnabled()) {
          log.debug("Queue über max -> entferne neuestes Paket.");
        }
      }
    }
  }



  /**
   * Release / close: stoppe Worker, lösche Queue und schließe Line.
   */
  public void release() {
    running.set(false);
    audioWorker.interrupt();

    // clear queue
    queue.clear();

    try {
      if (sourceDataline.isRunning()) sourceDataline.stop();
    }
    catch (Throwable t) { /* ignore */ }
    try {
      if (sourceDataline.isOpen()) sourceDataline.close();
    }
    catch (Throwable t) { /* ignore */ }
  }



  /**
   * Setze max. Delay (Pakete, die später als playbackTime + maxDelayMs are,
   * werden verworfen).
   */
  public void setMaxDelayMs(int maxDelayMs) {
    this.maxDelayMs = Math.max(0, maxDelayMs);
  }



  /**
   * Setze die Ziel-Latenz manuell (ms). Extern nutzbar, z.B. durch GUI oder
   * Mess-Komponente.
   */
  public void setTargetLatencyMs(int targetLatencyMs) {
    if (targetLatencyMs < minLatencyMs) targetLatencyMs = minLatencyMs;
    if (targetLatencyMs > maxLatencyMs) targetLatencyMs = maxLatencyMs;
    this.targetLatencyMs = targetLatencyMs;
    if (log.isDebugEnabled()) log.debug("Manuell setTargetLatencyMs={}", targetLatencyMs);
  }



  /**
   * Lautstärke wie vorher (0..102).
   */
  public void setVolume(int value) {
    if (sourceDataline == null) {
      if (log.isDebugEnabled()) log.debug("Eine Audioleitung ist nicht geöffnet.");
      return;
    }
    float changedVolume = 0.843339215f * value - 80f;
    try {
      FloatControl volume = (FloatControl) sourceDataline.getControl(FloatControl.Type.MASTER_GAIN);
      float gain = Math.min(Math.max(changedVolume, volume.getMinimum()), volume.getMaximum());
      volume.setValue(gain);
    }
    catch (IllegalArgumentException e) {
      if (log.isDebugEnabled()) log.debug("Volume control nicht verfügbar: {}", e.getMessage());
    }
  }
}