/**
 *  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.
 *
 * - Rückwärtskompatibel: play(byte[]) ruft play(byte[], now) auf. - Neu:
 * play(byte[], timestampMs) erlaubt Eingangstimestamps (in ms). - Adaptive
 * Logik passt targetLatencyMs basierend auf Drop-Rate und Queue-Stand an.
 *
 * Hinweis: Dieses Speaker ersetzt die alte Implementierung und funktioniert mit
 * ConferenceSpeaker (keine Änderung an ConferenceSpeaker nötig).
 */
public class Speaker {

  private static final Logger log = LogManager.getLogger(Speaker.class);

  private final SourceDataLine sourceDataline;
  private final PriorityBlockingQueue<AudioPacket> queue;
  private final AtomicBoolean running = new AtomicBoolean(true);
  private final Thread audioWorker;

  /* --- Konfigurierbare Parameter / Konstanten --- */
  // Ziel-Latenz (Startwert) in ms
  private volatile int targetLatencyMs = 50;

  // Wenn ein Paket älter als maxDelayMs wird (bezogen auf playbackTime), wird es
  // gedroppt
  private volatile int maxDelayMs = 200;

  // Minimal / Maximal erlaubte Target-Latenz
  // Anhand deiner Messwerte: stabile Netzauslenkung ~<= 300ms, wir setzen min
  // höher
  // um "frühe" Pakete nicht wegzuwerfen.
  private final int minLatencyMs = 320; // GEÄNDERT: vorher 10
  private final int maxLatencyMs = 2000; // großzügiger Obergrenze (verhindert overflow)

  // Frame-Interval für kleine Sleeps (ms)
  private final int frameIntervalMs = 20;

  // Max Elemente in Queue bevor wir aggressive Drops durchführen (safety)
  private final int maxQueueSize = 2048;

  /* --- Adaptionsparameter --- */
  private static final int INCREASE_STEP = 10; // ms, wenn schlechte Bedingungen
  private static final int DECREASE_STEP = 5; // ms, vorsichtiger bei Reduktion
  private static final int STABLE_REQUIRED_COUNT = 3; // Anzahl aufeinander folgender Checks für Reduktion
  private static final int QUEUE_SIZE_THRESHOLD = 4; // größer -> erhöhe latency
  private static final double DROP_RATE_THRESHOLD = 0.05; // 5%

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

  // Zähler für aufeinander folgende stabile Checks
  private int stableChecksInARow = 0;

  // Zeitpunkt der letzten Adaption (ms)
  private long lastAdaptMs = System.currentTimeMillis();

  /**
   * Konstruktor: gleiche Signatur wie in deinem Projekt (benutze TelefonUtil).
   */
  public Speaker(String speakername, Telefonformat format)
      throws MixerNotFoundException, LineUnavailableException {
    Info info = TelefonUtil.toSpeakermixer(speakername);
    sourceDataline = TelefonUtil.getSourceDataLine(info, format);

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

    // Öffne die Line (wie zuvor)
    sourceDataline.open(TelefonUtil.getAudioFormat(format));

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

  /* --- Hilfsklasse --- */
  private static class AudioPacket implements Comparable<AudioPacket> {
    final long playbackTime; // ms
    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);
    }
  }

  /**
   * Einfache adaptive Regel:
   *
   * - Wenn Drop-Rate hoch (z.B. > 5%) oder Queue sehr groß -> erhöhe
   * targetLatency - Wenn keine Drops und Queue klein über mehrere Checks ->
   * reduziere targetLatency (vorsichtig)
   *
   * Reduktion erfolgt erst nach STABLE_REQUIRED_COUNT aufeinander folgenden
   * Checks.
   */
  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;

    // --- Erhöhung der Latenz (schneller reagieren) ---
    if (dropRate > DROP_RATE_THRESHOLD || queueSize > QUEUE_SIZE_THRESHOLD) {
      int newLatency = targetLatencyMs + INCREASE_STEP;

      // Clamp an Obergrenze
      if (newLatency > maxLatencyMs) {
        newLatency = maxLatencyMs;
      }

      if (newLatency != targetLatencyMs) {
        targetLatencyMs = newLatency;
        stableChecksInARow = 0; // Reset stabile Zähler
        if (log.isDebugEnabled()) {
          log.debug(
              "Adapt: Drop-Rate {} oder Queue {} > {} -> erhöhe targetLatencyMs={}",
              String.format("%.2f", dropRate), queueSize, QUEUE_SIZE_THRESHOLD, targetLatencyMs
          );
        }
      }
      else {
        if (log.isDebugEnabled()) {
          log.debug(
              "Adapt: Erhöhen gewünscht aber bereits bei max/gleich (targetLatencyMs={})", targetLatencyMs
          );
        }
      }
    }
    // --- Reduktion der Latenz (vorsichtig, nur wenn stabil über mehrere Checks)
    // ---
    else if (dropRate == 0.0 && queueSize <= QUEUE_SIZE_THRESHOLD) {
      stableChecksInARow++;
      if (stableChecksInARow >= STABLE_REQUIRED_COUNT) {
        int newLatency = targetLatencyMs - DECREASE_STEP;

        // Clamp an definierte Minimallatenz; wichtig: minLatencyMs kann > maxLatencyMs
        // aus altem Code sein,
        // daher setzen wir logische Ober-/Untergrenze korrekt:
        int clampedMin = minLatencyMs; // minLatencyMs ist absichtlich gesetzt (z.B. 320)
        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
            );
          }
        }
        // Reset counter nach Versuch (erfolgreich oder nicht, wir warten wieder neu)
        stableChecksInARow = 0;
      }
      else {
        if (log.isDebugEnabled()) {
          log.debug(
              "Adapt: stabile Verbindung ({} / {}), warte auf weitere Checks vor Reduktion",
              stableChecksInARow, STABLE_REQUIRED_COUNT
          );
        }
      }
    }
    // --- keine Änderung nötig: reset stabile Zähler (wenn halbstabil, aber nicht
    // dropFree) ---
    else {
      stableChecksInARow = 0;
      if (log.isDebugEnabled()) {
        log.debug(
            "Adapt: keine Änderung (dropRate={}, queueSize={})", String.format("%.2f", dropRate), queueSize
        );
      }
    }
  }



  /**
   * Worker-Thread: Gibt fällige Pakete aus, droppt zu alte Pakete und passt die
   * targetLatency periodisch an.
   */
  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);
          }
          catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
          }
          continue;
        }

        long now = System.currentTimeMillis();
        long delayMs = head.playbackTime - now;

        if (delayMs > 0) {
          // 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));
          }
          catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
          }
          continue;
        }

        // fällig oder bereits "late"
        AudioPacket pkt = queue.poll(); // entfernen
        if (pkt == null) continue; // race-guard

        long ageMs = now - pkt.playbackTime;
        if (ageMs > maxDelayMs) {
          // zu alt -> verwerfen
          droppedSinceLastAdapt.incrementAndGet();
          if (log.isDebugEnabled()) {
            log.debug("Droppe Paket: ageMs={}ms", ageMs);
          }
        }
        else {
          // abspielen (write blockiert ggf. wenn Puffer 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 */ }
    }
  }



  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 playbackTime = timestampMs + targetLatencyMs;

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

    // Safety: falls Queue aus dem Ruder läuft, entferne das "neueste" Paket
    // (größter playbackTime)
    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 (sicherheitsdrop).");
        }
      }
    }
  }



  /**
   * 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 sind,
   * 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());
    }
  }
}
