// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::{
  borrow::Cow,
  collections::HashMap,
  fmt,
  sync::{atomic::AtomicBool, Arc, Mutex, MutexGuard},
};

use serde::Serialize;
use url::Url;

use tauri_macros::default_runtime;
use tauri_utils::{
  assets::{AssetKey, CspHash, SCRIPT_NONCE_TOKEN, STYLE_NONCE_TOKEN},
  config::{Csp, CspDirectiveSources},
};

use crate::{
  app::{
    AppHandle, ChannelInterceptor, GlobalWebviewEventListener, GlobalWindowEventListener,
    OnPageLoad,
  },
  event::{EmitArgs, Event, EventId, EventTarget, Listeners},
  ipc::{Invoke, InvokeHandler, RuntimeAuthority},
  plugin::PluginStore,
  resources::ResourceTable,
  utils::{config::Config, PackageInfo},
  Assets, Context, DebugAppIcon, EventName, Pattern, Runtime, StateManager, Webview, Window,
};

#[cfg(desktop)]
mod menu;
#[cfg(all(desktop, feature = "tray-icon"))]
mod tray;
pub mod webview;
pub mod window;

#[derive(Default)]
/// Spaced and quoted Content-Security-Policy hash values.
struct CspHashStrings {
  script: Vec<String>,
  style: Vec<String>,
}

/// Sets the CSP value to the asset HTML if needed (on Linux).
/// Returns the CSP string for access on the response header (on Windows and macOS).
pub(crate) fn set_csp<R: Runtime>(
  asset: &mut String,
  assets: &impl std::borrow::Borrow<dyn Assets<R>>,
  asset_path: &AssetKey,
  manager: &AppManager<R>,
  csp: Csp,
) -> HashMap<String, CspDirectiveSources> {
  let mut csp = csp.into();
  let hash_strings =
    assets
      .borrow()
      .csp_hashes(asset_path)
      .fold(CspHashStrings::default(), |mut acc, hash| {
        match hash {
          CspHash::Script(hash) => {
            acc.script.push(hash.into());
          }
          CspHash::Style(hash) => {
            acc.style.push(hash.into());
          }
          _csp_hash => {
            log::debug!("Unknown CspHash variant encountered: {:?}", _csp_hash);
          }
        }

        acc
      });

  let dangerous_disable_asset_csp_modification = &manager
    .config()
    .app
    .security
    .dangerous_disable_asset_csp_modification;
  if dangerous_disable_asset_csp_modification.can_modify("script-src") {
    replace_csp_nonce(
      asset,
      SCRIPT_NONCE_TOKEN,
      &mut csp,
      "script-src",
      hash_strings.script,
    );
  }

  if dangerous_disable_asset_csp_modification.can_modify("style-src") {
    replace_csp_nonce(
      asset,
      STYLE_NONCE_TOKEN,
      &mut csp,
      "style-src",
      hash_strings.style,
    );
  }

  csp
}

// inspired by <https://github.com/rust-lang/rust/blob/1be5c8f90912c446ecbdc405cbc4a89f9acd20fd/library/alloc/src/str.rs#L260-L297>
fn replace_with_callback<F: FnMut() -> String>(
  original: &str,
  pattern: &str,
  mut replacement: F,
) -> String {
  let mut result = String::new();
  let mut last_end = 0;
  for (start, part) in original.match_indices(pattern) {
    result.push_str(unsafe { original.get_unchecked(last_end..start) });
    result.push_str(&replacement());
    last_end = start + part.len();
  }
  result.push_str(unsafe { original.get_unchecked(last_end..original.len()) });
  result
}

fn replace_csp_nonce(
  asset: &mut String,
  token: &str,
  csp: &mut HashMap<String, CspDirectiveSources>,
  directive: &str,
  hashes: Vec<String>,
) {
  let mut nonces = Vec::new();
  *asset = replace_with_callback(asset, token, || {
    let nonce = getrandom::u64().expect("failed to get random bytes");
    nonces.push(nonce);
    nonce.to_string()
  });

  if !(nonces.is_empty() && hashes.is_empty()) {
    let nonce_sources = nonces
      .into_iter()
      .map(|n| format!("'nonce-{n}'"))
      .collect::<Vec<String>>();
    let sources = csp.entry(directive.into()).or_default();
    let self_source = "'self'".to_string();
    if !sources.contains(&self_source) {
      sources.push(self_source);
    }
    sources.extend(nonce_sources);
    sources.extend(hashes);
  }
}

/// A resolved asset.
#[non_exhaustive]
pub struct Asset {
  /// The asset bytes.
  pub bytes: Vec<u8>,
  /// The asset's mime type.
  pub mime_type: String,
  /// The `Content-Security-Policy` header value.
  pub csp_header: Option<String>,
}

impl Asset {
  /// The asset bytes.
  pub fn bytes(&self) -> &[u8] {
    &self.bytes
  }

  /// The asset's mime type.
  pub fn mime_type(&self) -> &str {
    &self.mime_type
  }

  /// The `Content-Security-Policy` header value.
  pub fn csp_header(&self) -> Option<&str> {
    self.csp_header.as_deref()
  }
}

#[default_runtime(crate::Wry, wry)]
pub struct AppManager<R: Runtime> {
  pub runtime_authority: Mutex<RuntimeAuthority>,
  pub window: window::WindowManager<R>,
  pub webview: webview::WebviewManager<R>,
  #[cfg(all(desktop, feature = "tray-icon"))]
  pub tray: tray::TrayManager<R>,
  #[cfg(desktop)]
  pub menu: menu::MenuManager<R>,

  pub(crate) plugins: Mutex<PluginStore<R>>,
  pub listeners: Listeners,
  pub state: Arc<StateManager>,
  pub config: Config,
  #[cfg(dev)]
  pub config_parent: Option<std::path::PathBuf>,
  pub assets: Box<dyn Assets<R>>,

  pub app_icon: Option<Vec<u8>>,

  pub package_info: PackageInfo,

  /// Application pattern.
  pub pattern: Arc<Pattern>,

  /// Global API scripts collected from plugins.
  pub plugin_global_api_scripts: Arc<Option<&'static [&'static str]>>,

  /// Application Resources Table
  pub(crate) resources_table: Arc<Mutex<ResourceTable>>,

  /// Runtime-generated invoke key.
  pub(crate) invoke_key: String,

  pub(crate) channel_interceptor: Option<ChannelInterceptor<R>>,

  /// Sets to true in [`AppHandle::request_restart`] and [`AppHandle::restart`]
  /// and we will call `restart` on the next `RuntimeRunEvent::Exit` event
  pub(crate) restart_on_exit: AtomicBool,
}

impl<R: Runtime> fmt::Debug for AppManager<R> {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    let mut d = f.debug_struct("AppManager");

    d.field("window", &self.window)
      .field("plugins", &self.plugins)
      .field("state", &self.state)
      .field("config", &self.config)
      .field("app_icon", &DebugAppIcon(&self.app_icon))
      .field("package_info", &self.package_info)
      .field("pattern", &self.pattern);

    #[cfg(all(desktop, feature = "tray-icon"))]
    {
      d.field("tray", &self.tray);
    }

    d.finish()
  }
}

pub(crate) enum EmitPayload<'a, S: Serialize> {
  Serialize(&'a S),
  Str(String),
}

impl<R: Runtime> AppManager<R> {
  #[allow(clippy::too_many_arguments, clippy::type_complexity)]
  pub(crate) fn with_handlers(
    #[allow(unused_mut)] mut context: Context<R>,
    plugins: PluginStore<R>,
    invoke_handler: Box<InvokeHandler<R>>,
    on_page_load: Option<Arc<OnPageLoad<R>>>,
    uri_scheme_protocols: HashMap<String, Arc<webview::UriSchemeProtocol<R>>>,
    state: StateManager,
    #[cfg(desktop)] menu_event_listener: Vec<crate::app::GlobalMenuEventListener<AppHandle<R>>>,
    #[cfg(all(desktop, feature = "tray-icon"))] tray_icon_event_listeners: Vec<
      crate::app::GlobalTrayIconEventListener<AppHandle<R>>,
    >,
    window_event_listeners: Vec<GlobalWindowEventListener<R>>,
    webview_event_listeners: Vec<GlobalWebviewEventListener<R>>,
    #[cfg(desktop)] window_menu_event_listeners: HashMap<
      String,
      crate::app::GlobalMenuEventListener<Window<R>>,
    >,
    invoke_initialization_script: String,
    channel_interceptor: Option<ChannelInterceptor<R>>,
    invoke_key: String,
  ) -> Self {
    // generate a random isolation key at runtime
    #[cfg(feature = "isolation")]
    if let Pattern::Isolation { key, .. } = &mut context.pattern {
      *key = uuid::Uuid::new_v4().to_string();
    }

    Self {
      runtime_authority: Mutex::new(context.runtime_authority),
      window: window::WindowManager {
        windows: Mutex::default(),
        default_icon: context.default_window_icon,
        event_listeners: Arc::new(window_event_listeners),
      },
      webview: webview::WebviewManager {
        webviews: Mutex::default(),
        invoke_handler,
        on_page_load,
        uri_scheme_protocols: Mutex::new(uri_scheme_protocols),
        event_listeners: Arc::new(webview_event_listeners),
        invoke_initialization_script,
        invoke_key: invoke_key.clone(),
      },
      #[cfg(all(desktop, feature = "tray-icon"))]
      tray: tray::TrayManager {
        icon: context.tray_icon,
        icons: Default::default(),
        global_event_listeners: Mutex::new(tray_icon_event_listeners),
        event_listeners: Default::default(),
      },
      #[cfg(desktop)]
      menu: menu::MenuManager {
        menus: Default::default(),
        menu: Default::default(),
        global_event_listeners: Mutex::new(menu_event_listener),
        event_listeners: Mutex::new(window_menu_event_listeners),
      },
      plugins: Mutex::new(plugins),
      listeners: Listeners::default(),
      state: Arc::new(state),
      config: context.config,
      #[cfg(dev)]
      config_parent: context.config_parent,
      assets: context.assets,
      app_icon: context.app_icon,
      package_info: context.package_info,
      pattern: Arc::new(context.pattern),
      plugin_global_api_scripts: Arc::new(context.plugin_global_api_scripts),
      resources_table: Arc::default(),
      invoke_key,
      channel_interceptor,
      restart_on_exit: AtomicBool::new(false),
    }
  }

  /// State managed by the application.
  pub(crate) fn state(&self) -> Arc<StateManager> {
    self.state.clone()
  }

  /// The `tauri` custom protocol URL we use to serve the embedded assets.
  /// Returns `tauri://localhost` or its `wry` workaround URL `http://tauri.localhost`/`https://tauri.localhost`
  pub(crate) fn tauri_protocol_url(&self, https: bool) -> Cow<'_, Url> {
    if cfg!(windows) || cfg!(target_os = "android") {
      let scheme = if https { "https" } else { "http" };
      Cow::Owned(Url::parse(&format!("{scheme}://tauri.localhost")).unwrap())
    } else {
      Cow::Owned(Url::parse("tauri://localhost").unwrap())
    }
  }

  /// Get the base app URL for [`WebviewUrl::App`](tauri_utils::config::WebviewUrl::App).
  ///
  /// * In dev mode, this is the [`devUrl`](tauri_utils::config::BuildConfig::dev_url) configuration value if it exsits.
  /// * In production mode, this is the [`frontendDist`](tauri_utils::config::BuildConfig::frontend_dist) configuration value if it's a [`FrontendDist::Url`](tauri_utils::config::FrontendDist::Url).
  /// * Returns [`Self::tauri_protocol_url`] (e.g. `tauri://localhost`) otherwise.
  pub(crate) fn get_app_url(&self, https: bool) -> Cow<'_, Url> {
    #[cfg(dev)]
    let url = self.config.build.dev_url.as_ref();
    #[cfg(not(dev))]
    let url = match self.config.build.frontend_dist.as_ref() {
      Some(crate::utils::config::FrontendDist::Url(url)) => Some(url),
      _ => None,
    };

    if let Some(url) = url {
      Cow::Borrowed(url)
    } else {
      self.tauri_protocol_url(https)
    }
  }

  fn csp(&self) -> Option<Csp> {
    if !crate::is_dev() {
      self.config.app.security.csp.clone()
    } else {
      self
        .config
        .app
        .security
        .dev_csp
        .clone()
        .or_else(|| self.config.app.security.csp.clone())
    }
  }

  // TODO: Change to return `crate::Result` here in v3
  pub fn get_asset(
    &self,
    mut path: String,
    _use_https_schema: bool,
  ) -> Result<Asset, Box<dyn std::error::Error>> {
    let assets = &self.assets;
    if path.ends_with('/') {
      path.pop();
    }
    path = percent_encoding::percent_decode(path.as_bytes())
      .decode_utf8_lossy()
      .to_string();
    let path = if path.is_empty() {
      // if the url is `tauri://localhost`, we should load `index.html`
      "index.html".to_string()
    } else {
      // skip the leading `/`, if it starts with one.
      path.strip_prefix('/').unwrap_or(path.as_str()).to_string()
    };

    let mut asset_path = AssetKey::from(path.as_str());

    let asset_response = assets
      .get(&asset_path)
      .or_else(|| {
        log::debug!("Asset `{path}` not found; fallback to {path}.html");
        let fallback = format!("{path}.html").into();
        let asset = assets.get(&fallback);
        asset_path = fallback;
        asset
      })
      .or_else(|| {
        log::debug!("Asset `{path}` not found; fallback to {path}/index.html",);
        let fallback = format!("{path}/index.html").into();
        let asset = assets.get(&fallback);
        asset_path = fallback;
        asset
      })
      .or_else(|| {
        log::debug!("Asset `{path}` not found; fallback to index.html");
        let fallback = AssetKey::from("index.html");
        let asset = assets.get(&fallback);
        asset_path = fallback;
        asset
      })
      .ok_or_else(|| {
        let error = crate::Error::AssetNotFound(path.clone());
        log::error!("{error}");
        Box::new(error)
      })?;

    let mut csp_header = None;
    let is_html = asset_path.as_ref().ends_with(".html");

    let final_data = if is_html {
      let mut asset = String::from_utf8_lossy(&asset_response).into_owned();
      if let Some(csp) = self.csp() {
        #[allow(unused_mut)]
        let mut csp_map = set_csp(&mut asset, &self.assets, &asset_path, self, csp);
        #[cfg(feature = "isolation")]
        if let Pattern::Isolation { schema, .. } = &*self.pattern {
          let default_src = csp_map.entry("default-src".to_owned()).or_default();
          default_src.push(crate::pattern::format_real_schema(
            schema,
            _use_https_schema,
          ));
        }

        csp_header.replace(Csp::DirectiveMap(csp_map).to_string());
      }

      asset.into_bytes()
    } else {
      asset_response.into_owned()
    };
    let mime_type = tauri_utils::mime_type::MimeType::parse(&final_data, &path);
    Ok(Asset {
      bytes: final_data,
      mime_type,
      csp_header,
    })
  }

  pub(crate) fn listeners(&self) -> &Listeners {
    &self.listeners
  }

  pub fn run_invoke_handler(&self, invoke: Invoke<R>) -> bool {
    (self.webview.invoke_handler)(invoke)
  }

  pub fn extend_api(&self, plugin: &str, invoke: Invoke<R>) -> bool {
    self
      .plugins
      .lock()
      .expect("poisoned plugin store")
      .extend_api(plugin, invoke)
  }

  pub fn initialize_plugins(&self, app: &AppHandle<R>) -> crate::Result<()> {
    self
      .plugins
      .lock()
      .expect("poisoned plugin store")
      .initialize_all(app, &self.config.plugins)
  }

  pub fn config(&self) -> &Config {
    &self.config
  }

  #[cfg(dev)]
  pub fn config_parent(&self) -> Option<&std::path::PathBuf> {
    self.config_parent.as_ref()
  }

  pub fn package_info(&self) -> &PackageInfo {
    &self.package_info
  }

  /// # Panics
  /// Will panic if `event` contains characters other than alphanumeric, `-`, `/`, `:` and `_`
  pub(crate) fn listen<F: Fn(Event) + Send + 'static>(
    &self,
    event: EventName,
    target: EventTarget,
    handler: F,
  ) -> EventId {
    self.listeners().listen(event, target, handler)
  }

  /// # Panics
  /// Will panic if `event` contains characters other than alphanumeric, `-`, `/`, `:` and `_`
  pub(crate) fn once<F: FnOnce(Event) + Send + 'static>(
    &self,
    event: EventName,
    target: EventTarget,
    handler: F,
  ) -> EventId {
    self.listeners().once(event, target, handler)
  }

  pub fn unlisten(&self, id: EventId) {
    self.listeners().unlisten(id)
  }

  #[cfg_attr(
    feature = "tracing",
    tracing::instrument("app::emit", skip(self, payload))
  )]
  pub(crate) fn emit<S: Serialize>(
    &self,
    event: EventName<&str>,
    payload: EmitPayload<'_, S>,
  ) -> crate::Result<()> {
    #[cfg(feature = "tracing")]
    let _span = tracing::debug_span!("emit::run").entered();
    let emit_args = match payload {
      EmitPayload::Serialize(payload) => EmitArgs::new(event, payload)?,
      EmitPayload::Str(payload) => EmitArgs::new_str(event, payload)?,
    };

    let listeners = self.listeners();

    listeners.emit_js(self.webview.webviews_lock().values(), &emit_args)?;
    listeners.emit(emit_args)?;

    Ok(())
  }

  #[cfg_attr(
    feature = "tracing",
    tracing::instrument("app::emit::filter", skip(self, payload, filter))
  )]
  pub(crate) fn emit_filter<S, F>(
    &self,
    event: EventName<&str>,
    payload: EmitPayload<'_, S>,
    filter: F,
  ) -> crate::Result<()>
  where
    S: Serialize,
    F: Fn(&EventTarget) -> bool,
  {
    #[cfg(feature = "tracing")]
    let _span = tracing::debug_span!("emit::run").entered();
    let emit_args = match payload {
      EmitPayload::Serialize(payload) => EmitArgs::new(event, payload)?,
      EmitPayload::Str(payload) => EmitArgs::new_str(event, payload)?,
    };

    let listeners = self.listeners();

    listeners.emit_js_filter(
      self.webview.webviews_lock().values(),
      &emit_args,
      Some(&filter),
    )?;

    listeners.emit_filter(emit_args, Some(filter))?;

    Ok(())
  }

  #[cfg_attr(
    feature = "tracing",
    tracing::instrument("app::emit::to", skip(self, target, payload), fields(target))
  )]
  pub(crate) fn emit_to<S>(
    &self,
    target: EventTarget,
    event: EventName<&str>,
    payload: EmitPayload<'_, S>,
  ) -> crate::Result<()>
  where
    S: Serialize,
  {
    #[cfg(feature = "tracing")]
    tracing::Span::current().record("target", format!("{target:?}"));

    fn filter_target(target: &EventTarget, candidate: &EventTarget) -> bool {
      match target {
        // if targeting any label, filter matching labels
        EventTarget::AnyLabel { label } => match candidate {
          EventTarget::Window { label: l }
          | EventTarget::Webview { label: l }
          | EventTarget::WebviewWindow { label: l }
          | EventTarget::AnyLabel { label: l } => l == label,
          _ => false,
        },
        EventTarget::Window { label } => match candidate {
          EventTarget::AnyLabel { label: l } | EventTarget::Window { label: l } => l == label,
          _ => false,
        },
        EventTarget::Webview { label } => match candidate {
          EventTarget::AnyLabel { label: l } | EventTarget::Webview { label: l } => l == label,
          _ => false,
        },
        EventTarget::WebviewWindow { label } => match candidate {
          EventTarget::AnyLabel { label: l } | EventTarget::WebviewWindow { label: l } => {
            l == label
          }
          _ => false,
        },
        // otherwise match same target
        _ => target == candidate,
      }
    }

    match target {
      // if targeting all, emit to all using emit without filter
      EventTarget::Any => self.emit(event, payload),
      target => self.emit_filter(event, payload, |t| filter_target(&target, t)),
    }
  }

  pub fn get_window(&self, label: &str) -> Option<Window<R>> {
    self.window.windows_lock().get(label).cloned()
  }

  pub fn get_focused_window(&self) -> Option<Window<R>> {
    self
      .window
      .windows_lock()
      .iter()
      .find(|w| w.1.is_focused().unwrap_or(false))
      .map(|w| w.1.clone())
  }

  pub(crate) fn on_window_close(&self, label: &str) {
    let window = self.window.windows_lock().remove(label);
    if let Some(window) = window {
      for webview in window.webviews() {
        self.webview.webviews_lock().remove(webview.label());
      }
    }
  }

  #[cfg(desktop)]
  pub(crate) fn on_webview_close(&self, label: &str) {
    self.webview.webviews_lock().remove(label);
  }

  pub fn windows(&self) -> HashMap<String, Window<R>> {
    self.window.windows_lock().clone()
  }

  pub fn get_webview(&self, label: &str) -> Option<Webview<R>> {
    self.webview.webviews_lock().get(label).cloned()
  }

  pub fn webviews(&self) -> HashMap<String, Webview<R>> {
    self.webview.webviews_lock().clone()
  }

  pub(crate) fn resources_table(&self) -> MutexGuard<'_, ResourceTable> {
    self
      .resources_table
      .lock()
      .expect("poisoned window manager")
  }

  pub(crate) fn invoke_key(&self) -> &str {
    &self.invoke_key
  }
}

#[cfg(desktop)]
impl<R: Runtime> AppManager<R> {
  pub fn remove_menu_from_stash_by_id(&self, id: Option<&crate::menu::MenuId>) {
    if let Some(id) = id {
      let is_used_by_a_window = self
        .window
        .windows_lock()
        .values()
        .any(|w| w.is_menu_in_use(id));
      if !(self.menu.is_menu_in_use(id) || is_used_by_a_window) {
        self.menu.menus_stash_lock().remove(id);
      }
    }
  }
}

#[cfg(test)]
mod tests {
  use super::replace_with_callback;

  #[test]
  fn string_replace_with_callback() {
    let mut tauri_index = 0;
    #[allow(clippy::single_element_loop)]
    for (src, pattern, replacement, result) in [(
      "tauri is awesome, tauri is amazing",
      "tauri",
      || {
        tauri_index += 1;
        tauri_index.to_string()
      },
      "1 is awesome, 2 is amazing",
    )] {
      assert_eq!(replace_with_callback(src, pattern, replacement), result);
    }
  }
}

#[cfg(test)]
mod test {
  use std::{
    sync::mpsc::{channel, Receiver, Sender},
    time::Duration,
  };

  use crate::{
    event::EventTarget,
    generate_context,
    plugin::PluginStore,
    test::{mock_app, MockRuntime},
    webview::WebviewBuilder,
    window::WindowBuilder,
    App, Emitter, Listener, Manager, StateManager, Webview, WebviewWindow, WebviewWindowBuilder,
    Window, Wry,
  };

  use super::AppManager;

  const APP_LISTEN_ID: &str = "App::listen";
  const APP_LISTEN_ANY_ID: &str = "App::listen_any";
  const WINDOW_LISTEN_ID: &str = "Window::listen";
  const WINDOW_LISTEN_ANY_ID: &str = "Window::listen_any";
  const WEBVIEW_LISTEN_ID: &str = "Webview::listen";
  const WEBVIEW_LISTEN_ANY_ID: &str = "Webview::listen_any";
  const WEBVIEW_WINDOW_LISTEN_ID: &str = "WebviewWindow::listen";
  const WEBVIEW_WINDOW_LISTEN_ANY_ID: &str = "WebviewWindow::listen_any";
  const TEST_EVENT_NAME: &str = "event";

  #[test]
  fn check_get_url() {
    let context = generate_context!("test/fixture/src-tauri/tauri.conf.json", crate, test = true);
    let manager: AppManager<Wry> = AppManager::with_handlers(
      context,
      PluginStore::default(),
      Box::new(|_| false),
      None,
      Default::default(),
      StateManager::new(),
      Default::default(),
      #[cfg(all(desktop, feature = "tray-icon"))]
      Default::default(),
      Default::default(),
      Default::default(),
      Default::default(),
      "".into(),
      None,
      crate::generate_invoke_key().unwrap(),
    );

    #[cfg(custom_protocol)]
    {
      assert_eq!(
        manager.get_app_url(false).to_string(),
        if cfg!(windows) || cfg!(target_os = "android") {
          "http://tauri.localhost/"
        } else {
          "tauri://localhost"
        }
      );
      assert_eq!(
        manager.get_app_url(true).to_string(),
        if cfg!(windows) || cfg!(target_os = "android") {
          "https://tauri.localhost/"
        } else {
          "tauri://localhost"
        }
      );
    }

    #[cfg(dev)]
    assert_eq!(
      manager.get_app_url(false).to_string(),
      "http://localhost:4000/"
    );
  }

  struct EventSetup {
    app: App<MockRuntime>,
    window: Window<MockRuntime>,
    webview: Webview<MockRuntime>,
    webview_window: WebviewWindow<MockRuntime>,
    tx: Sender<(&'static str, String)>,
    rx: Receiver<(&'static str, String)>,
  }

  fn setup_events(setup_any: bool) -> EventSetup {
    let app = mock_app();

    let window = WindowBuilder::new(&app, "main-window").build().unwrap();

    let webview = window
      .add_child(
        WebviewBuilder::new("main-webview", Default::default()),
        crate::LogicalPosition::new(0, 0),
        window.inner_size().unwrap(),
      )
      .unwrap();

    let webview_window = WebviewWindowBuilder::new(&app, "main-webview-window", Default::default())
      .build()
      .unwrap();

    let (tx, rx) = channel();

    macro_rules! setup_listener {
      ($type:ident, $id:ident, $any_id:ident) => {
        let tx_ = tx.clone();
        $type.listen(TEST_EVENT_NAME, move |evt| {
          tx_
            .send(($id, serde_json::from_str::<String>(evt.payload()).unwrap()))
            .unwrap();
        });

        if setup_any {
          let tx_ = tx.clone();
          $type.listen_any(TEST_EVENT_NAME, move |evt| {
            tx_
              .send((
                $any_id,
                serde_json::from_str::<String>(evt.payload()).unwrap(),
              ))
              .unwrap();
          });
        }
      };
    }

    setup_listener!(app, APP_LISTEN_ID, APP_LISTEN_ANY_ID);
    setup_listener!(window, WINDOW_LISTEN_ID, WINDOW_LISTEN_ANY_ID);
    setup_listener!(webview, WEBVIEW_LISTEN_ID, WEBVIEW_LISTEN_ANY_ID);
    setup_listener!(
      webview_window,
      WEBVIEW_WINDOW_LISTEN_ID,
      WEBVIEW_WINDOW_LISTEN_ANY_ID
    );

    EventSetup {
      app,
      window,
      webview,
      webview_window,
      tx,
      rx,
    }
  }

  fn assert_events(kind: &str, received: &[&str], expected: &[&str]) {
    for e in expected {
      assert!(received.contains(e), "{e} did not receive `{kind}` event");
    }
    assert_eq!(
      received.len(),
      expected.len(),
      "received {received:?} `{kind}` events but expected {expected:?}"
    );
  }

  #[test]
  fn emit() {
    let EventSetup {
      app,
      window,
      webview,
      webview_window,
      tx: _,
      rx,
    } = setup_events(true);

    run_emit_test("emit (app)", app, &rx);
    run_emit_test("emit (window)", window, &rx);
    run_emit_test("emit (webview)", webview, &rx);
    run_emit_test("emit (webview_window)", webview_window, &rx);
  }

  fn run_emit_test<M: Manager<MockRuntime> + Emitter<MockRuntime>>(
    kind: &str,
    m: M,
    rx: &Receiver<(&str, String)>,
  ) {
    let mut received = Vec::new();
    let payload = "global-payload";
    m.emit(TEST_EVENT_NAME, payload).unwrap();
    while let Ok((source, p)) = rx.recv_timeout(Duration::from_secs(1)) {
      assert_eq!(p, payload);
      received.push(source);
    }
    assert_events(
      kind,
      &received,
      &[
        APP_LISTEN_ID,
        APP_LISTEN_ANY_ID,
        WINDOW_LISTEN_ID,
        WINDOW_LISTEN_ANY_ID,
        WEBVIEW_LISTEN_ID,
        WEBVIEW_LISTEN_ANY_ID,
        WEBVIEW_WINDOW_LISTEN_ID,
        WEBVIEW_WINDOW_LISTEN_ANY_ID,
      ],
    );
  }

  #[test]
  fn emit_to() {
    let EventSetup {
      app,
      window,
      webview,
      webview_window,
      tx,
      rx,
    } = setup_events(false);

    run_emit_to_test(
      "emit_to (App)",
      &app,
      &window,
      &webview,
      &webview_window,
      tx.clone(),
      &rx,
    );
    run_emit_to_test(
      "emit_to (window)",
      &window,
      &window,
      &webview,
      &webview_window,
      tx.clone(),
      &rx,
    );
    run_emit_to_test(
      "emit_to (webview)",
      &webview,
      &window,
      &webview,
      &webview_window,
      tx.clone(),
      &rx,
    );
    run_emit_to_test(
      "emit_to (webview_window)",
      &webview_window,
      &window,
      &webview,
      &webview_window,
      tx.clone(),
      &rx,
    );
  }

  fn run_emit_to_test<M: Manager<MockRuntime> + Emitter<MockRuntime>>(
    kind: &str,
    m: &M,
    window: &Window<MockRuntime>,
    webview: &Webview<MockRuntime>,
    webview_window: &WebviewWindow<MockRuntime>,
    tx: Sender<(&'static str, String)>,
    rx: &Receiver<(&'static str, String)>,
  ) {
    let mut received = Vec::new();
    let payload = "global-payload";

    macro_rules! test_target {
      ($target:expr, $id:ident) => {
        m.emit_to($target, TEST_EVENT_NAME, payload).unwrap();
        while let Ok((source, p)) = rx.recv_timeout(Duration::from_secs(1)) {
          assert_eq!(p, payload);
          received.push(source);
        }
        assert_events(kind, &received, &[$id]);

        received.clear();
      };
    }

    test_target!(EventTarget::App, APP_LISTEN_ID);
    test_target!(window.label(), WINDOW_LISTEN_ID);
    test_target!(webview.label(), WEBVIEW_LISTEN_ID);
    test_target!(webview_window.label(), WEBVIEW_WINDOW_LISTEN_ID);

    let other_webview_listen_id = "OtherWebview::listen";
    let other_webview = WebviewWindowBuilder::new(
      window,
      kind.replace(['(', ')', ' '], ""),
      Default::default(),
    )
    .build()
    .unwrap();

    other_webview.listen(TEST_EVENT_NAME, move |evt| {
      tx.send((
        other_webview_listen_id,
        serde_json::from_str::<String>(evt.payload()).unwrap(),
      ))
      .unwrap();
    });
    m.emit_to(other_webview.label(), TEST_EVENT_NAME, payload)
      .unwrap();
    while let Ok((source, p)) = rx.recv_timeout(Duration::from_secs(1)) {
      assert_eq!(p, payload);
      received.push(source);
    }
    assert_events("emit_to", &received, &[other_webview_listen_id]);
  }
}
