//! A variety of DOM utility functions.

use or_poisoned::OrPoisoned;
#[cfg(debug_assertions)]
use reactive_graph::diagnostics::SpecialNonReactiveZone;
use reactive_graph::owner::Owner;
use send_wrapper::SendWrapper;
use std::time::Duration;
use tachys::html::event::EventDescriptor;
#[cfg(feature = "tracing")]
use tracing::instrument;
use wasm_bindgen::{prelude::Closure, JsCast, JsValue, UnwrapThrowExt};

thread_local! {
    pub(crate) static WINDOW: web_sys::Window = web_sys::window().unwrap_throw();

    pub(crate) static DOCUMENT: web_sys::Document = web_sys::window().unwrap_throw().document().unwrap_throw();
}

/// Returns the [`Window`](https://developer.mozilla.org/en-US/docs/Web/API/Window).
///
/// This is cached as a thread-local variable, so calling `window()` multiple times
/// requires only one call out to JavaScript.
pub fn window() -> web_sys::Window {
    WINDOW.with(Clone::clone)
}

/// Returns the [`Document`](https://developer.mozilla.org/en-US/docs/Web/API/Document).
///
/// This is cached as a thread-local variable, so calling `document()` multiple times
/// requires only one call out to JavaScript.
pub fn document() -> web_sys::Document {
    DOCUMENT.with(Clone::clone)
}

/// Sets a property on a DOM element.
pub fn set_property(
    el: &web_sys::Element,
    prop_name: &str,
    value: &Option<JsValue>,
) {
    let key = JsValue::from_str(prop_name);
    match value {
        Some(value) => _ = js_sys::Reflect::set(el, &key, value),
        None => _ = js_sys::Reflect::delete_property(el, &key),
    };
}

/// Gets the value of a property set on a DOM element.
#[doc(hidden)]
pub fn get_property(
    el: &web_sys::Element,
    prop_name: &str,
) -> Result<JsValue, JsValue> {
    let key = JsValue::from_str(prop_name);
    js_sys::Reflect::get(el, &key)
}

/// Returns the current [`window.location`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location).
pub fn location() -> web_sys::Location {
    window().location()
}

/// Current [`window.location.hash`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location)
/// without the beginning #.
pub fn location_hash() -> Option<String> {
    if is_server() {
        None
    } else {
        location()
            .hash()
            .ok()
            .map(|hash| match hash.chars().next() {
                Some('#') => hash[1..].to_string(),
                _ => hash,
            })
    }
}

/// Current [`window.location.pathname`](https://developer.mozilla.org/en-US/docs/Web/API/Window/location).
pub fn location_pathname() -> Option<String> {
    location().pathname().ok()
}

/// Helper function to extract [`Event.target`](https://developer.mozilla.org/en-US/docs/Web/API/Event/target)
/// from any event.
pub fn event_target<T>(event: &web_sys::Event) -> T
where
    T: JsCast,
{
    event.target().unwrap_throw().unchecked_into::<T>()
}

/// Helper function to extract `event.target.value` from an event.
///
/// This is useful in the `on:input` or `on:change` listeners for an `<input>` element.
pub fn event_target_value<T>(event: &T) -> String
where
    T: JsCast,
{
    event
        .unchecked_ref::<web_sys::Event>()
        .target()
        .unwrap_throw()
        .unchecked_into::<web_sys::HtmlInputElement>()
        .value()
}

/// Helper function to extract `event.target.checked` from an event.
///
/// This is useful in the `on:change` listeners for an `<input type="checkbox">` element.
pub fn event_target_checked(ev: &web_sys::Event) -> bool {
    ev.target()
        .unwrap()
        .unchecked_into::<web_sys::HtmlInputElement>()
        .checked()
}

/// Handle that is generated by [request_animation_frame_with_handle] and can
/// be used to cancel the animation frame request.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct AnimationFrameRequestHandle(i32);

impl AnimationFrameRequestHandle {
    /// Cancels the animation frame request to which this refers.
    /// See [`cancelAnimationFrame()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelAnimationFrame)
    pub fn cancel(&self) {
        _ = window().cancel_animation_frame(self.0);
    }
}

/// Runs the given function between the next repaint using
/// [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame).
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
#[inline(always)]
pub fn request_animation_frame(cb: impl FnOnce() + 'static) {
    _ = request_animation_frame_with_handle(cb);
}

// Closure::once_into_js only frees the callback when it's actually
// called, so this instead uses into_js_value, which can be freed by
// the host JS engine's GC if it supports weak references (which all
// modern browser engines do).  The way this works is that the provided
// callback's captured data is dropped immediately after being called,
// as before, but it leaves behind a small stub closure rust-side that
// will be freed "eventually" by the JS GC.  If the function is never
// called (e.g., it's a cancelled timeout or animation frame callback)
// then it will also be freed eventually.
fn closure_once(cb: impl FnOnce() + 'static) -> JsValue {
    let mut wrapped_cb: Option<Box<dyn FnOnce()>> = Some(Box::new(cb));
    let closure = Closure::new(move || {
        if let Some(cb) = wrapped_cb.take() {
            cb()
        }
    });
    closure.into_js_value()
}

/// Runs the given function between the next repaint using
/// [`Window.requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame),
/// returning a cancelable handle.
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
#[inline(always)]
pub fn request_animation_frame_with_handle(
    cb: impl FnOnce() + 'static,
) -> Result<AnimationFrameRequestHandle, JsValue> {
    #[cfg(feature = "tracing")]
    let span = ::tracing::Span::current();
    #[cfg(feature = "tracing")]
    let cb = move || {
        let _guard = span.enter();
        cb();
    };

    #[inline(never)]
    fn raf(cb: JsValue) -> Result<AnimationFrameRequestHandle, JsValue> {
        window()
            .request_animation_frame(cb.as_ref().unchecked_ref())
            .map(AnimationFrameRequestHandle)
    }

    raf(closure_once(cb))
}

/// Handle that is generated by [request_idle_callback_with_handle] and can be
/// used to cancel the idle callback.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct IdleCallbackHandle(u32);

impl IdleCallbackHandle {
    /// Cancels the idle callback to which this refers.
    /// See [`cancelAnimationFrame()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelIdleCallback)
    pub fn cancel(&self) {
        window().cancel_idle_callback(self.0);
    }
}

/// Queues the given function during an idle period using
/// [`Window.requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestIdleCallback).
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
#[inline(always)]
pub fn request_idle_callback(cb: impl Fn() + 'static) {
    _ = request_idle_callback_with_handle(cb);
}

/// Queues the given function during an idle period using
/// [`Window.requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestIdleCallback),
/// returning a cancelable handle.
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(feature = "tracing", instrument(level = "trace", skip_all))]
#[inline(always)]
pub fn request_idle_callback_with_handle(
    cb: impl Fn() + 'static,
) -> Result<IdleCallbackHandle, JsValue> {
    #[cfg(feature = "tracing")]
    let span = ::tracing::Span::current();
    #[cfg(feature = "tracing")]
    let cb = move || {
        let _guard = span.enter();
        cb();
    };

    #[inline(never)]
    fn ric(cb: Box<dyn Fn()>) -> Result<IdleCallbackHandle, JsValue> {
        let cb = Closure::wrap(cb).into_js_value();

        window()
            .request_idle_callback(cb.as_ref().unchecked_ref())
            .map(IdleCallbackHandle)
    }

    ric(Box::new(cb))
}

/// A microtask is a short function which will run after the current task has
/// completed its work and when there is no other code waiting to be run before
/// control of the execution context is returned to the browser's event loop.
///
/// Microtasks are especially useful for libraries and frameworks that need
/// to perform final cleanup or other just-before-rendering tasks.
///
/// [MDN queueMicrotask](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask)
///
/// <div class="warning">The task is called outside of the ownership tree, this means that if you want to access for example the context you need to reestablish the owner.</div>
pub fn queue_microtask(task: impl FnOnce() + 'static) {
    tachys::renderer::dom::queue_microtask(task);
}

/// Handle that is generated by [set_timeout_with_handle] and can be used to clear the timeout.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct TimeoutHandle(i32);

impl TimeoutHandle {
    /// Cancels the timeout to which this refers.
    /// See [`clearTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout)
    pub fn clear(&self) {
        window().clear_timeout_with_handle(self.0);
    }
}

/// Executes the given function after the given duration of time has passed.
/// [`setTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout).
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(
  feature = "tracing",
  instrument(level = "trace", skip_all, fields(duration = ?duration))
)]
pub fn set_timeout(cb: impl FnOnce() + 'static, duration: Duration) {
    _ = set_timeout_with_handle(cb, duration);
}

/// Executes the given function after the given duration of time has passed, returning a cancelable handle.
/// [`setTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout).
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(
  feature = "tracing",
  instrument(level = "trace", skip_all, fields(duration = ?duration))
)]
#[inline(always)]
pub fn set_timeout_with_handle(
    cb: impl FnOnce() + 'static,
    duration: Duration,
) -> Result<TimeoutHandle, JsValue> {
    #[cfg(debug_assertions)]
    let cb = || {
        let _z = SpecialNonReactiveZone::enter();
        cb();
    };

    #[cfg(feature = "tracing")]
    let span = ::tracing::Span::current();
    #[cfg(feature = "tracing")]
    let cb = move || {
        let _guard = span.enter();
        cb();
    };

    #[inline(never)]
    fn st(cb: JsValue, duration: Duration) -> Result<TimeoutHandle, JsValue> {
        window()
            .set_timeout_with_callback_and_timeout_and_arguments_0(
                cb.as_ref().unchecked_ref(),
                duration.as_millis().try_into().unwrap_throw(),
            )
            .map(TimeoutHandle)
    }

    st(closure_once(cb), duration)
}

/// "Debounce" a callback function. This will cause it to wait for a period of `delay`
/// after it is called. If it is called again during that period, it will wait
/// `delay` before running, and so on. This can be used, for example, to wrap event
/// listeners to prevent them from firing constantly as you type.
///
/// ```
/// use leptos::{leptos_dom::helpers::debounce, logging::log, prelude::*, *};
///
/// #[component]
/// fn DebouncedButton() -> impl IntoView {
///     let delay = std::time::Duration::from_millis(250);
///     let on_click = debounce(delay, move |_| {
///         log!("...so many clicks!");
///     });
///
///     view! {
///       <button on:click=on_click>"Click me"</button>
///     }
/// }
/// ```
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
pub fn debounce<T: 'static>(
    delay: Duration,
    mut cb: impl FnMut(T) + 'static,
) -> impl FnMut(T) {
    use std::sync::{Arc, RwLock};

    #[cfg(debug_assertions)]
    #[allow(unused_mut)]
    let mut cb = move |value| {
        let _z = SpecialNonReactiveZone::enter();
        cb(value);
    };

    #[cfg(feature = "tracing")]
    let span = ::tracing::Span::current();
    #[cfg(feature = "tracing")]
    #[allow(unused_mut)]
    let mut cb = move |value| {
        let _guard = span.enter();
        cb(value);
    };

    let cb = Arc::new(RwLock::new(cb));
    let timer = Arc::new(RwLock::new(None::<TimeoutHandle>));

    Owner::on_cleanup({
        let timer = Arc::clone(&timer);
        move || {
            if let Some(timer) = timer.write().or_poisoned().take() {
                timer.clear();
            }
        }
    });

    move |arg| {
        if let Some(timer) = timer.write().unwrap().take() {
            timer.clear();
        }
        let handle = set_timeout_with_handle(
            {
                let cb = Arc::clone(&cb);
                move || {
                    cb.write().unwrap()(arg);
                }
            },
            delay,
        );
        if let Ok(handle) = handle {
            *timer.write().or_poisoned() = Some(handle);
        }
    }
}

/// Handle that is generated by [set_interval] and can be used to clear the interval.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct IntervalHandle(i32);

impl IntervalHandle {
    /// Cancels the repeating event to which this refers.
    /// See [`clearInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval)
    pub fn clear(&self) {
        window().clear_interval_with_handle(self.0);
    }
}

/// Repeatedly calls the given function, with a delay of the given duration between calls.
/// See [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval).
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(
  feature = "tracing",
  instrument(level = "trace", skip_all, fields(duration = ?duration))
)]
pub fn set_interval(cb: impl Fn() + 'static, duration: Duration) {
    _ = set_interval_with_handle(cb, duration);
}

/// Repeatedly calls the given function, with a delay of the given duration between calls,
/// returning a cancelable handle.
/// See [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval).
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(
  feature = "tracing",
  instrument(level = "trace", skip_all, fields(duration = ?duration))
)]
#[inline(always)]
pub fn set_interval_with_handle(
    cb: impl Fn() + 'static,
    duration: Duration,
) -> Result<IntervalHandle, JsValue> {
    #[cfg(debug_assertions)]
    let cb = move || {
        let _z = SpecialNonReactiveZone::enter();
        cb();
    };
    #[cfg(feature = "tracing")]
    let span = ::tracing::Span::current();
    #[cfg(feature = "tracing")]
    let cb = move || {
        let _guard = span.enter();
        cb();
    };

    #[inline(never)]
    fn si(
        cb: Box<dyn FnMut()>,
        duration: Duration,
    ) -> Result<IntervalHandle, JsValue> {
        let cb = Closure::wrap(cb).into_js_value();

        window()
            .set_interval_with_callback_and_timeout_and_arguments_0(
                cb.as_ref().unchecked_ref(),
                duration.as_millis().try_into().unwrap_throw(),
            )
            .map(IntervalHandle)
    }

    si(Box::new(cb), duration)
}

/// Adds an event listener to the `Window`, typed as a generic `Event`,
/// returning a cancelable handle.
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
#[cfg_attr(
  feature = "tracing",
  instrument(level = "trace", skip_all, fields(event_name = %event_name))
)]
#[inline(always)]
pub fn window_event_listener_untyped(
    event_name: &str,
    cb: impl Fn(web_sys::Event) + 'static,
) -> WindowListenerHandle {
    #[cfg(debug_assertions)]
    let cb = move |e| {
        let _z = SpecialNonReactiveZone::enter();
        cb(e);
    };
    #[cfg(feature = "tracing")]
    let span = ::tracing::Span::current();
    #[cfg(feature = "tracing")]
    let cb = move |e| {
        let _guard = span.enter();
        cb(e);
    };

    if !is_server() {
        #[inline(never)]
        fn wel(
            cb: Box<dyn FnMut(web_sys::Event)>,
            event_name: &str,
        ) -> WindowListenerHandle {
            let cb = Closure::wrap(cb).into_js_value();
            _ = window().add_event_listener_with_callback(
                event_name,
                cb.unchecked_ref(),
            );
            let event_name = event_name.to_string();
            let cb = SendWrapper::new(cb);
            WindowListenerHandle(Box::new(move || {
                _ = window().remove_event_listener_with_callback(
                    &event_name,
                    cb.unchecked_ref(),
                );
            }))
        }

        wel(Box::new(cb), event_name)
    } else {
        WindowListenerHandle(Box::new(|| ()))
    }
}

/// Creates a window event listener from a typed event, returning a
/// cancelable handle.
/// ```
/// use leptos::{
///     ev, leptos_dom::helpers::window_event_listener, logging::log,
///     prelude::*,
/// };
///
/// #[component]
/// fn App() -> impl IntoView {
///     let handle = window_event_listener(ev::keypress, |ev| {
///         // ev is typed as KeyboardEvent automatically,
///         // so .code() can be called
///         let code = ev.code();
///         log!("code = {code:?}");
///     });
///     on_cleanup(move || handle.remove());
/// }
/// ```
///
/// ### Note about Context
///
/// The callback is called outside of the reactive ownership tree. This means that it does not have access to context via [`use_context`](reactive_graph::owner::use_context). If you want to use context inside the callback, you should either call `use_context` in the body of the component, and move the value into the callback, or access the current owner inside the component body using [`Owner::current`](reactive_graph::owner::Owner::current) and reestablish it in the callback with [`Owner::with`](reactive_graph::owner::Owner::with).
pub fn window_event_listener<E: EventDescriptor + 'static>(
    event: E,
    cb: impl Fn(E::EventType) + 'static,
) -> WindowListenerHandle
where
    E::EventType: JsCast,
{
    window_event_listener_untyped(&event.name(), move |e| {
        cb(e.unchecked_into::<E::EventType>())
    })
}

/// A handle that can be called to remove a global event listener.
pub struct WindowListenerHandle(Box<dyn FnOnce() + Send + Sync>);

impl core::fmt::Debug for WindowListenerHandle {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_tuple("WindowListenerHandle").finish()
    }
}

impl WindowListenerHandle {
    /// Removes the event listener.
    pub fn remove(self) {
        (self.0)()
    }
}

/// Returns `true` if the current environment is a server.
pub fn is_server() -> bool {
    #[cfg(feature = "hydration")]
    {
        Owner::current_shared_context()
            .map(|sc| !sc.is_browser())
            .unwrap_or(false)
    }
    #[cfg(not(feature = "hydration"))]
    {
        false
    }
}

/// Returns `true` if the current environment is a browser.
pub fn is_browser() -> bool {
    !is_server()
}
