/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=8 sts=2 et sw=2 tw=80: */
/* 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/. */

#include "AudioChannelService.h"

#include "base/basictypes.h"

#include "mozilla/Services.h"
#include "mozilla/StaticPtr.h"
#include "mozilla/Unused.h"
#include "mozilla/dom/Document.h"

#include "nsContentUtils.h"
#include "nsISupportsPrimitives.h"
#include "nsThreadUtils.h"
#include "nsHashPropertyBag.h"
#include "nsComponentManagerUtils.h"
#include "nsGlobalWindow.h"
#include "nsPIDOMWindow.h"
#include "nsServiceManagerUtils.h"

#include "mozilla/Preferences.h"

using namespace mozilla;
using namespace mozilla::dom;

mozilla::LazyLogModule gAudioChannelLog("AudioChannel");

namespace {

bool sXPCOMShuttingDown = false;

class AudioPlaybackRunnable final : public Runnable {
 public:
  AudioPlaybackRunnable(nsPIDOMWindowOuter* aWindow, bool aActive,
                        AudioChannelService::AudibleChangedReasons aReason)
      : mozilla::Runnable("AudioPlaybackRunnable"),
        mWindow(aWindow),
        mActive(aActive),
        mReason(aReason) {}

  NS_IMETHOD Run() override {
    nsCOMPtr<nsIObserverService> observerService =
        services::GetObserverService();
    if (NS_WARN_IF(!observerService)) {
      return NS_ERROR_FAILURE;
    }

    nsAutoString state;
    GetActiveState(state);

    observerService->NotifyObservers(ToSupports(mWindow), "audio-playback",
                                     state.get());

    MOZ_LOG(AudioChannelService::GetAudioChannelLog(), LogLevel::Debug,
            ("AudioPlaybackRunnable, active = %s, reason = %s\n",
             mActive ? "true" : "false", AudibleChangedReasonToStr(mReason)));

    return NS_OK;
  }

 private:
  void GetActiveState(nsAString& aState) {
    if (mActive) {
      aState.AssignLiteral("active");
    } else {
      if (mReason ==
          AudioChannelService::AudibleChangedReasons::ePauseStateChanged) {
        aState.AssignLiteral("inactive-pause");
      } else {
        aState.AssignLiteral("inactive-nonaudible");
      }
    }
  }

  nsCOMPtr<nsPIDOMWindowOuter> mWindow;
  bool mActive;
  AudioChannelService::AudibleChangedReasons mReason;
};

}  // anonymous namespace

namespace mozilla::dom {

const char* SuspendTypeToStr(const nsSuspendedTypes& aSuspend) {
  MOZ_ASSERT(aSuspend == nsISuspendedTypes::NONE_SUSPENDED ||
             aSuspend == nsISuspendedTypes::SUSPENDED_BLOCK);

  switch (aSuspend) {
    case nsISuspendedTypes::NONE_SUSPENDED:
      return "none";
    case nsISuspendedTypes::SUSPENDED_BLOCK:
      return "block";
    default:
      return "unknown";
  }
}

const char* AudibleStateToStr(
    const AudioChannelService::AudibleState& aAudible) {
  MOZ_ASSERT(aAudible == AudioChannelService::AudibleState::eNotAudible ||
             aAudible == AudioChannelService::AudibleState::eMaybeAudible ||
             aAudible == AudioChannelService::AudibleState::eAudible);

  switch (aAudible) {
    case AudioChannelService::AudibleState::eNotAudible:
      return "not-audible";
    case AudioChannelService::AudibleState::eMaybeAudible:
      return "maybe-audible";
    case AudioChannelService::AudibleState::eAudible:
      return "audible";
    default:
      return "unknown";
  }
}

const char* AudibleChangedReasonToStr(
    const AudioChannelService::AudibleChangedReasons& aReason) {
  MOZ_ASSERT(
      aReason == AudioChannelService::AudibleChangedReasons::eVolumeChanged ||
      aReason ==
          AudioChannelService::AudibleChangedReasons::eDataAudibleChanged ||
      aReason ==
          AudioChannelService::AudibleChangedReasons::ePauseStateChanged);

  switch (aReason) {
    case AudioChannelService::AudibleChangedReasons::eVolumeChanged:
      return "volume";
    case AudioChannelService::AudibleChangedReasons::eDataAudibleChanged:
      return "data-audible";
    case AudioChannelService::AudibleChangedReasons::ePauseStateChanged:
      return "pause-state";
    default:
      return "unknown";
  }
}

StaticRefPtr<AudioChannelService> gAudioChannelService;

/* static */
void AudioChannelService::CreateServiceIfNeeded() {
  MOZ_ASSERT(NS_IsMainThread());

  if (!gAudioChannelService) {
    gAudioChannelService = new AudioChannelService();
  }
}

/* static */
already_AddRefed<AudioChannelService> AudioChannelService::GetOrCreate() {
  if (sXPCOMShuttingDown) {
    return nullptr;
  }

  CreateServiceIfNeeded();
  RefPtr<AudioChannelService> service = gAudioChannelService.get();
  return service.forget();
}

/* static */
already_AddRefed<AudioChannelService> AudioChannelService::Get() {
  if (sXPCOMShuttingDown) {
    return nullptr;
  }

  RefPtr<AudioChannelService> service = gAudioChannelService.get();
  return service.forget();
}

/* static */
LogModule* AudioChannelService::GetAudioChannelLog() {
  return gAudioChannelLog;
}

/* static */
void AudioChannelService::Shutdown() {
  if (gAudioChannelService) {
    nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
    if (obs) {
      obs->RemoveObserver(gAudioChannelService, "xpcom-shutdown");
      obs->RemoveObserver(gAudioChannelService, "outer-window-destroyed");
    }

    gAudioChannelService->mWindows.Clear();

    gAudioChannelService = nullptr;
  }
}

NS_INTERFACE_MAP_BEGIN(AudioChannelService)
  NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIObserver)
  NS_INTERFACE_MAP_ENTRY(nsIObserver)
NS_INTERFACE_MAP_END

NS_IMPL_ADDREF(AudioChannelService)
NS_IMPL_RELEASE(AudioChannelService)

AudioChannelService::AudioChannelService() {
  nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
  if (obs) {
    obs->AddObserver(this, "xpcom-shutdown", false);
    obs->AddObserver(this, "outer-window-destroyed", false);
  }
}

AudioChannelService::~AudioChannelService() = default;

void AudioChannelService::RegisterAudioChannelAgent(AudioChannelAgent* aAgent,
                                                    AudibleState aAudible) {
  MOZ_ASSERT(aAgent);

  uint64_t windowID = aAgent->WindowID();
  AudioChannelWindow* winData = GetWindowData(windowID);
  if (!winData) {
    winData = new AudioChannelWindow(windowID);
    mWindows.AppendElement(WrapUnique(winData));
  }

  // To make sure agent would be alive because AppendAgent() would trigger the
  // callback function of AudioChannelAgentOwner that means the agent might be
  // released in their callback.
  RefPtr<AudioChannelAgent> kungFuDeathGrip(aAgent);
  winData->AppendAgent(aAgent, aAudible);
}

void AudioChannelService::UnregisterAudioChannelAgent(
    AudioChannelAgent* aAgent) {
  MOZ_ASSERT(aAgent);

  AudioChannelWindow* winData = GetWindowData(aAgent->WindowID());
  if (!winData) {
    return;
  }

  // To make sure agent would be alive because AppendAgent() would trigger the
  // callback function of AudioChannelAgentOwner that means the agent might be
  // released in their callback.
  RefPtr<AudioChannelAgent> kungFuDeathGrip(aAgent);
  winData->RemoveAgent(aAgent);
}

AudioPlaybackConfig AudioChannelService::GetMediaConfig(
    nsPIDOMWindowOuter* aWindow) const {
  AudioPlaybackConfig config(1.0, false, nsISuspendedTypes::NONE_SUSPENDED);

  if (!aWindow) {
    config.mVolume = 0.0;
    config.mMuted = true;
    config.mSuspend = nsISuspendedTypes::SUSPENDED_BLOCK;
    return config;
  }

  AudioChannelWindow* winData = nullptr;
  nsCOMPtr<nsPIDOMWindowOuter> window = aWindow;

  // The volume must be calculated based on the window hierarchy. Here we go up
  // to the top window and we calculate the volume and the muted flag.
  do {
    winData = GetWindowData(window->WindowID());
    if (winData) {
      config.mVolume *= winData->mConfig.mVolume;
      config.mMuted = config.mMuted || winData->mConfig.mMuted;
      config.mCapturedAudio = winData->mIsAudioCaptured;
    }

    config.mMuted = config.mMuted || window->GetAudioMuted();
    if (window->ShouldDelayMediaFromStart()) {
      config.mSuspend = nsISuspendedTypes::SUSPENDED_BLOCK;
    }

    nsCOMPtr<nsPIDOMWindowOuter> win =
        window->GetInProcessScriptableParentOrNull();
    if (!win) {
      break;
    }

    window = win;

    // If there is no parent, or we are the toplevel we don't continue.
  } while (window && window != aWindow);

  return config;
}

void AudioChannelService::AudioAudibleChanged(AudioChannelAgent* aAgent,
                                              AudibleState aAudible,
                                              AudibleChangedReasons aReason) {
  MOZ_ASSERT(aAgent);

  uint64_t windowID = aAgent->WindowID();
  AudioChannelWindow* winData = GetWindowData(windowID);
  if (winData) {
    winData->AudioAudibleChanged(aAgent, aAudible, aReason);
  }
}

NS_IMETHODIMP
AudioChannelService::Observe(nsISupports* aSubject, const char* aTopic,
                             const char16_t* aData) {
  if (!strcmp(aTopic, "xpcom-shutdown")) {
    sXPCOMShuttingDown = true;
    Shutdown();
  } else if (!strcmp(aTopic, "outer-window-destroyed")) {
    nsCOMPtr<nsISupportsPRUint64> wrapper = do_QueryInterface(aSubject);
    NS_ENSURE_TRUE(wrapper, NS_ERROR_FAILURE);

    uint64_t outerID;
    nsresult rv = wrapper->GetData(&outerID);
    if (NS_WARN_IF(NS_FAILED(rv))) {
      return rv;
    }

    UniquePtr<AudioChannelWindow> winData;
    {
      nsTObserverArray<UniquePtr<AudioChannelWindow>>::ForwardIterator iter(
          mWindows);
      while (iter.HasMore()) {
        auto& next = iter.GetNext();
        if (next->mWindowID == outerID) {
          winData = std::move(next);
          iter.Remove();
          break;
        }
      }
    }

    if (winData) {
      for (AudioChannelAgent* agent : winData->mAgents.ForwardRange()) {
        agent->WindowVolumeChanged(winData->mConfig.mVolume,
                                   winData->mConfig.mMuted);
      }
    }
  }

  return NS_OK;
}

void AudioChannelService::RefreshAgents(
    nsPIDOMWindowOuter* aWindow,
    const std::function<void(AudioChannelAgent*)>& aFunc) {
  MOZ_ASSERT(aWindow);

  nsCOMPtr<nsPIDOMWindowOuter> topWindow = aWindow->GetInProcessScriptableTop();
  if (!topWindow) {
    return;
  }

  AudioChannelWindow* winData = GetWindowData(topWindow->WindowID());
  if (!winData) {
    return;
  }

  for (AudioChannelAgent* agent : winData->mAgents.ForwardRange()) {
    aFunc(agent);
  }
}

void AudioChannelService::RefreshAgentsVolume(nsPIDOMWindowOuter* aWindow,
                                              float aVolume, bool aMuted) {
  RefreshAgents(aWindow, [aVolume, aMuted](AudioChannelAgent* agent) {
    agent->WindowVolumeChanged(aVolume, aMuted);
  });
}

void AudioChannelService::RefreshAgentsSuspend(nsPIDOMWindowOuter* aWindow,
                                               nsSuspendedTypes aSuspend) {
  RefreshAgents(aWindow, [aSuspend](AudioChannelAgent* agent) {
    agent->WindowSuspendChanged(aSuspend);
  });
}

void AudioChannelService::SetWindowAudioCaptured(nsPIDOMWindowOuter* aWindow,
                                                 uint64_t aInnerWindowID,
                                                 bool aCapture) {
  MOZ_ASSERT(NS_IsMainThread());
  MOZ_ASSERT(aWindow);

  MOZ_LOG(GetAudioChannelLog(), LogLevel::Debug,
          ("AudioChannelService, SetWindowAudioCaptured, window = %p, "
           "aCapture = %d\n",
           aWindow, aCapture));

  nsCOMPtr<nsPIDOMWindowOuter> topWindow = aWindow->GetInProcessScriptableTop();
  if (!topWindow) {
    return;
  }

  AudioChannelWindow* winData = GetWindowData(topWindow->WindowID());

  // This can happen, but only during shutdown, because the the outer window
  // changes ScriptableTop, so that its ID is different.
  // In this case either we are capturing, and it's too late because the window
  // has been closed anyways, or we are un-capturing, and everything has already
  // been cleaned up by the HTMLMediaElements or the AudioContexts.
  if (!winData) {
    return;
  }

  if (aCapture != winData->mIsAudioCaptured) {
    winData->mIsAudioCaptured = aCapture;
    for (AudioChannelAgent* agent : winData->mAgents.ForwardRange()) {
      agent->WindowAudioCaptureChanged(aInnerWindowID, aCapture);
    }
  }
}

AudioChannelService::AudioChannelWindow*
AudioChannelService::GetOrCreateWindowData(nsPIDOMWindowOuter* aWindow) {
  MOZ_ASSERT(NS_IsMainThread());
  MOZ_ASSERT(aWindow);

  AudioChannelWindow* winData = GetWindowData(aWindow->WindowID());
  if (!winData) {
    winData = new AudioChannelWindow(aWindow->WindowID());
    mWindows.AppendElement(WrapUnique(winData));
  }

  return winData;
}

AudioChannelService::AudioChannelWindow* AudioChannelService::GetWindowData(
    uint64_t aWindowID) const {
  const auto [begin, end] = mWindows.NonObservingRange();
  const auto foundIt = std::find_if(begin, end, [aWindowID](const auto& next) {
    return next->mWindowID == aWindowID;
  });
  return foundIt != end ? foundIt->get() : nullptr;
}

bool AudioChannelService::IsWindowActive(nsPIDOMWindowOuter* aWindow) {
  MOZ_ASSERT(NS_IsMainThread());

  auto* window = nsPIDOMWindowOuter::From(aWindow)->GetInProcessScriptableTop();
  if (!window) {
    return false;
  }

  AudioChannelWindow* winData = GetWindowData(window->WindowID());
  if (!winData) {
    return false;
  }

  return !winData->mAudibleAgents.IsEmpty();
}

void AudioChannelService::NotifyResumingDelayedMedia(
    nsPIDOMWindowOuter* aWindow) {
  MOZ_ASSERT(aWindow);

  nsCOMPtr<nsPIDOMWindowOuter> topWindow = aWindow->GetInProcessScriptableTop();
  if (!topWindow) {
    return;
  }

  AudioChannelWindow* winData = GetWindowData(topWindow->WindowID());
  if (!winData) {
    return;
  }

  winData->NotifyMediaBlockStop(aWindow);
  RefreshAgentsSuspend(aWindow, nsISuspendedTypes::NONE_SUSPENDED);
}

void AudioChannelService::AudioChannelWindow::AppendAgent(
    AudioChannelAgent* aAgent, AudibleState aAudible) {
  MOZ_ASSERT(aAgent);

  AppendAgentAndIncreaseAgentsNum(aAgent);
  AudioAudibleChanged(aAgent, aAudible,
                      AudibleChangedReasons::eDataAudibleChanged);
}

void AudioChannelService::AudioChannelWindow::RemoveAgent(
    AudioChannelAgent* aAgent) {
  MOZ_ASSERT(aAgent);

  RemoveAgentAndReduceAgentsNum(aAgent);
  AudioAudibleChanged(aAgent, AudibleState::eNotAudible,
                      AudibleChangedReasons::ePauseStateChanged);
}

void AudioChannelService::AudioChannelWindow::NotifyMediaBlockStop(
    nsPIDOMWindowOuter* aWindow) {
  if (mShouldSendActiveMediaBlockStopEvent) {
    mShouldSendActiveMediaBlockStopEvent = false;
    nsCOMPtr<nsPIDOMWindowOuter> window = aWindow;
    NS_DispatchToCurrentThread(NS_NewRunnableFunction(
        "dom::AudioChannelService::AudioChannelWindow::NotifyMediaBlockStop",
        [window]() -> void {
          nsCOMPtr<nsIObserverService> observerService =
              services::GetObserverService();
          if (NS_WARN_IF(!observerService)) {
            return;
          }

          observerService->NotifyObservers(ToSupports(window), "audio-playback",
                                           u"activeMediaBlockStop");
        }));
  }
}

void AudioChannelService::AudioChannelWindow::AppendAgentAndIncreaseAgentsNum(
    AudioChannelAgent* aAgent) {
  MOZ_ASSERT(aAgent);
  MOZ_ASSERT(!mAgents.Contains(aAgent));

  mAgents.AppendElement(aAgent);

  ++mConfig.mNumberOfAgents;
}

void AudioChannelService::AudioChannelWindow::RemoveAgentAndReduceAgentsNum(
    AudioChannelAgent* aAgent) {
  MOZ_ASSERT(aAgent);
  MOZ_ASSERT(mAgents.Contains(aAgent));

  mAgents.RemoveElement(aAgent);

  MOZ_ASSERT(mConfig.mNumberOfAgents > 0);
  --mConfig.mNumberOfAgents;
}

void AudioChannelService::AudioChannelWindow::AudioAudibleChanged(
    AudioChannelAgent* aAgent, AudibleState aAudible,
    AudibleChangedReasons aReason) {
  MOZ_ASSERT(aAgent);

  if (aAudible == AudibleState::eAudible) {
    AppendAudibleAgentIfNotContained(aAgent, aReason);
  } else {
    RemoveAudibleAgentIfContained(aAgent, aReason);
  }

  if (aAudible != AudibleState::eNotAudible) {
    MaybeNotifyMediaBlockStart(aAgent);
  }
}

void AudioChannelService::AudioChannelWindow::AppendAudibleAgentIfNotContained(
    AudioChannelAgent* aAgent, AudibleChangedReasons aReason) {
  MOZ_ASSERT(aAgent);
  MOZ_ASSERT(mAgents.Contains(aAgent));

  if (!mAudibleAgents.Contains(aAgent)) {
    mAudibleAgents.AppendElement(aAgent);
    if (IsFirstAudibleAgent()) {
      NotifyAudioAudibleChanged(aAgent->Window(), AudibleState::eAudible,
                                aReason);
    }
  }
}

void AudioChannelService::AudioChannelWindow::RemoveAudibleAgentIfContained(
    AudioChannelAgent* aAgent, AudibleChangedReasons aReason) {
  MOZ_ASSERT(aAgent);

  if (mAudibleAgents.Contains(aAgent)) {
    mAudibleAgents.RemoveElement(aAgent);
    if (IsLastAudibleAgent()) {
      NotifyAudioAudibleChanged(aAgent->Window(), AudibleState::eNotAudible,
                                aReason);
    }
  }
}

bool AudioChannelService::AudioChannelWindow::IsFirstAudibleAgent() const {
  return (mAudibleAgents.Length() == 1);
}

bool AudioChannelService::AudioChannelWindow::IsLastAudibleAgent() const {
  return mAudibleAgents.IsEmpty();
}

void AudioChannelService::AudioChannelWindow::NotifyAudioAudibleChanged(
    nsPIDOMWindowOuter* aWindow, AudibleState aAudible,
    AudibleChangedReasons aReason) {
  RefPtr<AudioPlaybackRunnable> runnable = new AudioPlaybackRunnable(
      aWindow, aAudible == AudibleState::eAudible, aReason);
  DebugOnly<nsresult> rv = NS_DispatchToCurrentThread(runnable);
  NS_WARNING_ASSERTION(NS_SUCCEEDED(rv), "NS_DispatchToCurrentThread failed");
}

void AudioChannelService::AudioChannelWindow::MaybeNotifyMediaBlockStart(
    AudioChannelAgent* aAgent) {
  nsCOMPtr<nsPIDOMWindowOuter> window = aAgent->Window();
  if (!window) {
    return;
  }

  nsCOMPtr<nsPIDOMWindowInner> inner = window->GetCurrentInnerWindow();
  if (!inner) {
    return;
  }

  nsCOMPtr<Document> doc = inner->GetExtantDoc();
  if (!doc) {
    return;
  }

  if (!window->ShouldDelayMediaFromStart() || !doc->Hidden()) {
    return;
  }

  if (!mShouldSendActiveMediaBlockStopEvent) {
    mShouldSendActiveMediaBlockStopEvent = true;
    NS_DispatchToCurrentThread(NS_NewRunnableFunction(
        "dom::AudioChannelService::AudioChannelWindow::"
        "MaybeNotifyMediaBlockStart",
        [window]() -> void {
          nsCOMPtr<nsIObserverService> observerService =
              services::GetObserverService();
          if (NS_WARN_IF(!observerService)) {
            return;
          }

          observerService->NotifyObservers(ToSupports(window), "audio-playback",
                                           u"activeMediaBlockStart");
        }));
  }
}

}  // namespace mozilla::dom
