//! Two-tier prefetch (Phase 3f): warm the destination on hover / focus /
//! viewport-enter, then commit cheaply on click.
//!
//! ## Two tiers
//!
//! **Warm** (on `mouseenter` / `focusin` after a short dwell, or when the link
//! scrolls into view via an `IntersectionObserver`): a **bare `import(jsUrl)`**
//! — which warms the JS module graph and lets the browser fetch the referenced
//! `.wasm` — PLUS a headered HTML `fetch` whose response-text promise is parked
//! in a capped, TTL'd, abortable speculation cache (keyed by URL, each entry
//! owning its own `AbortController`). **Warm never calls `default()`**: no WASM
//! instantiation, no `start()`, no `register_island`, no `mount_all` (AC-V18 —
//! a hovered-not-clicked link leaves `SCOPE_REGISTRY` / `ISLAND_REGISTRY`
//! untouched).
//!
//! **Commit** (on click, driven from `mod.rs`): [`consume_speculation`]
//! synchronously removes the entry and hands its `AbortController` + parked body
//! promise to the active nav in a single run-to-completion step (before any
//! `await`), so neither the TTL evictor nor the active-nav abort can observe a
//! half-consumed entry (AC-V10 abort-scope rule). The active nav then runs
//! contract step 2's `await m.default()` (a cheap re-`import()` from cache + one
//! instantiation) and awaits the parked body — no duplicate fetch.
//!
//! ## Abort scope (AC-V10)
//!
//! Each speculation owns its own `AbortController`. The active-nav abort targets
//! only the *active* controller, never a cached speculation. Only un-consumed
//! speculations are abortable, and only by cap eviction or TTL expiry — at which
//! point their controller is aborted and their entry dropped. Consuming a
//! speculation transfers its controller out of the cache so it can never be
//! killed by the TTL or by a later active-nav abort.

use std::cell::RefCell;
use std::collections::HashMap;

use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;

use super::{dom, manifest, opt_out};

/// Maximum number of live speculations. Beyond this, the oldest is evicted (its
/// fetch aborted) before a new one is admitted — bounding memory and in-flight
/// requests under aggressive hover/scroll.
const MAX_SPECULATIONS: usize = 4;

/// How long an un-consumed speculation lives before it is evicted and its fetch
/// aborted, in milliseconds. Short enough that a stale prefetch does not linger.
const SPECULATION_TTL_MILLIS: i32 = 10_000;

/// Dwell before a hover/focus warms the destination, in milliseconds. A brief
/// dwell avoids warming every link the pointer merely passes over.
const HOVER_DWELL_MILLIS: i32 = 65;

/// A parked warm-tier speculation: the in-flight headered HTML fetch (as a
/// promise resolving to the response *text*), the controller that can abort it,
/// and the `setTimeout` handle for its TTL eviction.
///
/// The body is stored as a `js_sys::Promise` rather than an awaited `String` so
/// the fetch runs in the background and the commit path awaits it only if and
/// when the link is actually clicked.
pub(crate) struct Speculation {
    /// Resolves to the destination HTML text. Awaited by the commit path.
    body_text_promise: js_sys::Promise,
    /// Aborts the in-flight fetch. Transferred to the active nav on consume so
    /// the TTL evictor / active-nav abort can never kill a promoted fetch.
    controller: web_sys::AbortController,
    /// `window.setTimeout` handle for the TTL eviction; cleared on consume so a
    /// half-consumed entry can never be observed.
    ttl_timeout_handle: i32,
}

impl Speculation {
    /// The parked response-text promise (awaited by the active nav on commit).
    pub(crate) fn body_text_promise(&self) -> js_sys::Promise {
        self.body_text_promise.clone()
    }

    /// The controller the active nav adopts so it — not the cache — owns the
    /// fetch's lifetime after consume.
    pub(crate) fn into_controller(self) -> web_sys::AbortController {
        self.controller
    }
}

thread_local! {
    /// URL → parked speculation. The sole owner of each `Speculation`; removing
    /// an entry (consume or evict) drops it.
    ///
    /// Not a `const {}` initializer: `HashMap::new()` is not a `const fn`, so the
    /// thread-local can't be const-constructed (and clippy does not flag it).
    static SPECULATIONS: RefCell<HashMap<String, Speculation>> =
        RefCell::new(HashMap::new());

    /// Bundle JS URLs already bare-`import()`ed this session, so warming the
    /// same destination twice does not re-issue the dynamic import.
    ///
    /// Grows monotonically over a session (entries are never removed): a
    /// re-`import()` is what we are avoiding, so a URL stays "warmed" for the
    /// page's life. The set is bounded by the number of distinct route bundles —
    /// a small, fixed count — so the unbounded-looking growth is accepted. (Not a
    /// `const {}` initializer for the same reason as `SPECULATIONS`.)
    static WARMED_MODULES: RefCell<HashMap<String, ()>> = RefCell::new(HashMap::new());

    /// The pending hover-dwell `setTimeout` handle, if a dwell is in progress.
    /// A new hover/focus replaces it so only a sustained dwell warms.
    static HOVER_DWELL_HANDLE: RefCell<Option<i32>> = const { RefCell::new(None) };

    /// The nav-global viewport `IntersectionObserver`, kept alive past init so it
    /// can re-observe anchors that arrive in a later morph (see
    /// [`observe_current_anchors`]). `None` until [`attach_viewport_observer`]
    /// creates it.
    static VIEWPORT_OBSERVER: RefCell<Option<web_sys::IntersectionObserver>> =
        const { RefCell::new(None) };
}

/// Consume the speculation for `url`, if one is cached, in a single synchronous
/// step: remove the entry AND clear its TTL timer before returning. The caller
/// (the active nav's commit) then adopts the returned controller and awaits the
/// parked body — so the TTL evictor and the active-nav abort can never observe a
/// half-consumed entry (AC-V10).
///
/// Returns `None` on a cache miss; the caller then does a fresh fetch.
pub(crate) fn consume_speculation(url: &str) -> Option<Speculation> {
    SPECULATIONS.with(|cache| {
        let speculation = cache.borrow_mut().remove(url)?;
        clear_timeout(speculation.ttl_timeout_handle);
        Some(speculation)
    })
}

/// Whether `Save-Data` is requested, in which case all prefetch is suppressed
/// (warming would spend bytes the user asked us not to). Reads
/// `navigator.connection.saveData` defensively — absent / unsupported → `false`.
fn save_data_enabled() -> bool {
    let navigator = match dom::window() {
        Ok(window) => window.navigator(),
        Err(_) => return false,
    };
    let connection = match js_sys::Reflect::get(&navigator, &JsValue::from_str("connection")) {
        Ok(connection) if !connection.is_undefined() && !connection.is_null() => connection,
        _ => return false,
    };
    js_sys::Reflect::get(&connection, &JsValue::from_str("saveData"))
        .ok()
        .and_then(|value| value.as_bool())
        .unwrap_or(false)
}

/// Warm the destination of `anchor`: bare-`import()` its bundle (no `default()`)
/// and park a headered HTML fetch in the speculation cache. Suppressed when the
/// link is not interceptable (AC-V2 opt-outs), when `Save-Data` is on, or when
/// the route is unknown / already speculated.
///
/// This is the single warm entry point shared by the hover/focus and viewport
/// observers. It performs NO WASM instantiation (AC-V18).
fn warm_anchor(anchor: &web_sys::HtmlAnchorElement) {
    if save_data_enabled() {
        return;
    }
    // Respect every AC-V2 opt-out: never warm a link we would not intercept.
    let document_origin = match dom::document_origin() {
        Ok(origin) => origin,
        Err(_) => return,
    };
    // A synthetic primary-button, no-modifier click models "would this be
    // intercepted if clicked?" — reusing the one opt-out predicate.
    let link = dom::link_click_for_prefetch(anchor, document_origin);
    if !opt_out::should_intercept(&link) {
        return;
    }

    let url = anchor.href();
    let pathname = match pathname_of(&url) {
        Some(pathname) => pathname,
        None => return,
    };
    let entry = match manifest::resolved_entry(&pathname) {
        Some(entry) => entry,
        None => return,
    };

    warm_module(&entry.js);
    warm_fetch(&url);
}

/// Bare-`import()` a bundle's JS URL to warm the module graph + `.wasm` network,
/// at most once per URL per session. Deliberately does NOT call `default()`, so
/// no WASM is instantiated and no island is registered (AC-V18). Errors are
/// swallowed — a failed warm just means the commit pays full cost.
fn warm_module(js_url: &str) {
    let already = WARMED_MODULES.with(|warmed| {
        if warmed.borrow().contains_key(js_url) {
            return true;
        }
        warmed.borrow_mut().insert(js_url.to_owned(), ());
        false
    });
    if already {
        return;
    }
    // Fire-and-forget bare import; ignore the returned promise. No default().
    let _ = super::dynamic_import(js_url);
}

/// Start a headered HTML fetch for `url` and park its response-text promise in
/// the speculation cache under a fresh `AbortController` + TTL timer. A second
/// warm of the same URL is a no-op (the first speculation stands).
fn warm_fetch(url: &str) {
    let exists = SPECULATIONS.with(|cache| cache.borrow().contains_key(url));
    if exists {
        return;
    }
    if let Err(error) = start_speculation_fetch(url) {
        web_sys::console::warn_2(
            &JsValue::from_str("islands nav: prefetch warm fetch failed:"),
            &error,
        );
    }
}

/// Issue the headered fetch, build its response-text promise, install the TTL
/// eviction timer, and admit the speculation (evicting the oldest if at cap).
fn start_speculation_fetch(url: &str) -> Result<(), JsValue> {
    let controller = web_sys::AbortController::new()?;

    let headers = web_sys::Headers::new()?;
    headers.set(manifest::NAV_HEADER_NAME, manifest::NAV_HEADER_VALUE)?;
    let options = web_sys::RequestInit::new();
    options.set_headers(&headers);
    options.set_signal(Some(&controller.signal()));
    let request = web_sys::Request::new_with_str_and_init(url, &options)?;

    let window = dom::window()?;
    // Drive fetch → response.text() in a background future, exposed as a Promise
    // the commit path awaits on demand. The future starts running immediately
    // (the warm fetch is in flight before any click); a non-2xx response rejects
    // so commit falls back to a full load (AC-V11) rather than morphing an error
    // page. `future_to_promise` keeps the work alive without a leaked closure.
    let fetch_promise = window.fetch_with_request(&request);
    let body_text_promise = wasm_bindgen_futures::future_to_promise(async move {
        let response_value = wasm_bindgen_futures::JsFuture::from(fetch_promise).await?;
        let response: web_sys::Response = response_value.dyn_into()?;
        if !response.ok() {
            return Err(JsValue::from_str(&format!(
                "prefetch response not ok: HTTP {}",
                response.status()
            )));
        }
        wasm_bindgen_futures::JsFuture::from(response.text()?).await
    });

    let ttl_timeout_handle = install_ttl_eviction(&window, url)?;

    admit_speculation(
        url,
        Speculation {
            body_text_promise,
            controller,
            ttl_timeout_handle,
        },
    );
    Ok(())
}

/// Install a `setTimeout` that evicts (and aborts) the speculation for `url`
/// after the TTL. Returns the timeout handle so consume can cancel it.
fn install_ttl_eviction(window: &web_sys::Window, url: &str) -> Result<i32, JsValue> {
    let url_owned = url.to_owned();
    let evict = Closure::once_into_js(move || {
        evict_speculation(&url_owned);
    });
    window.set_timeout_with_callback_and_timeout_and_arguments_0(
        evict.unchecked_ref(),
        SPECULATION_TTL_MILLIS,
    )
}

/// Admit `speculation` for `url`, first evicting the oldest entry if the cache
/// is at capacity (its fetch aborted, its TTL timer cleared).
fn admit_speculation(url: &str, speculation: Speculation) {
    let to_evict = SPECULATIONS.with(|cache| {
        let cache_ref = cache.borrow();
        if cache_ref.len() < MAX_SPECULATIONS {
            return None;
        }
        // Evict an arbitrary existing entry (HashMap has no order; at this small
        // cap any victim bounds memory + in-flight requests acceptably).
        cache_ref.keys().next().cloned()
    });
    if let Some(victim) = to_evict {
        evict_speculation(&victim);
    }
    SPECULATIONS.with(|cache| {
        cache.borrow_mut().insert(url.to_owned(), speculation);
    });
}

/// Evict the speculation for `url`: abort its fetch and clear its TTL timer.
/// Used by the TTL timeout and by cap eviction. A consumed entry is already
/// gone, so this is a no-op for it.
fn evict_speculation(url: &str) {
    if let Some(speculation) = SPECULATIONS.with(|cache| cache.borrow_mut().remove(url)) {
        clear_timeout(speculation.ttl_timeout_handle);
        speculation.controller.abort();
    }
}

/// Attach the nav-global warm-tier listeners + viewport observer. Called once
/// from `nav::init` alongside the click interceptor — these are singletons, not
/// island-scoped. Idempotency is handled by the caller's init guard.
pub(crate) fn attach_warm_listeners() -> Result<(), JsValue> {
    attach_hover_focus_listeners()?;
    attach_viewport_observer()?;
    Ok(())
}

/// Capture-phase `mouseover` + `focusin` on `document`: after a short dwell,
/// warm the anchor under the pointer / focus. Capture phase + delegation keeps
/// these a single pair of document-level listeners regardless of link count.
fn attach_hover_focus_listeners() -> Result<(), JsValue> {
    let document = dom::document()?;

    let hover_handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
        schedule_warm_from_event(&event);
    });
    document.add_event_listener_with_callback_and_bool(
        "mouseover",
        hover_handler.as_ref().unchecked_ref(),
        true,
    )?;
    hover_handler.forget();

    let focus_handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
        schedule_warm_from_event(&event);
    });
    document.add_event_listener_with_callback_and_bool(
        "focusin",
        focus_handler.as_ref().unchecked_ref(),
        true,
    )?;
    focus_handler.forget();
    Ok(())
}

/// Resolve the anchor for a hover/focus `event` and, after [`HOVER_DWELL_MILLIS`],
/// warm it. A new hover/focus cancels the prior pending dwell so only a sustained
/// dwell warms.
fn schedule_warm_from_event(event: &web_sys::Event) {
    let target = match event.target() {
        Some(target) => target,
        None => return,
    };
    let anchor = match dom::closest_anchor(&target) {
        Some(anchor) => anchor,
        None => return,
    };

    // Cancel any pending dwell; this hover/focus supersedes it.
    cancel_pending_dwell();

    let window = match dom::window() {
        Ok(window) => window,
        Err(_) => return,
    };
    let dwell = Closure::once_into_js(move || {
        HOVER_DWELL_HANDLE.with(|handle| *handle.borrow_mut() = None);
        warm_anchor(&anchor);
    });
    if let Ok(handle) = window.set_timeout_with_callback_and_timeout_and_arguments_0(
        dwell.unchecked_ref(),
        HOVER_DWELL_MILLIS,
    ) {
        HOVER_DWELL_HANDLE.with(|stored| *stored.borrow_mut() = Some(handle));
    }
}

/// Cancel a pending hover-dwell timer, if one is scheduled.
fn cancel_pending_dwell() {
    HOVER_DWELL_HANDLE.with(|handle| {
        if let Some(pending) = handle.borrow_mut().take() {
            clear_timeout(pending);
        }
    });
}

/// Observe every `<a>` (delegated through a single `IntersectionObserver`) and
/// warm a link when it scrolls into view.
fn attach_viewport_observer() -> Result<(), JsValue> {
    let callback = Closure::<dyn FnMut(js_sys::Array)>::new(move |entries: js_sys::Array| {
        for entry_value in entries.iter() {
            let entry: web_sys::IntersectionObserverEntry = match entry_value.dyn_into() {
                Ok(entry) => entry,
                Err(_) => continue,
            };
            if !entry.is_intersecting() {
                continue;
            }
            if let Ok(anchor) = entry.target().dyn_into::<web_sys::HtmlAnchorElement>() {
                warm_anchor(&anchor);
            }
        }
    });
    let observer = web_sys::IntersectionObserver::new(callback.as_ref().unchecked_ref())?;
    callback.forget();

    // Keep the observer alive past init so a later morph can re-observe new
    // anchors through it (a dropped observer stops firing).
    VIEWPORT_OBSERVER.with(|slot| *slot.borrow_mut() = Some(observer));

    // Observe the anchors present on the initial page.
    observe_current_anchors();
    Ok(())
}

/// Observe every current `a[href]` through the nav-global viewport observer, so
/// links that scrolled into view warm their destination.
///
/// Called once at init and again after each successful morph (from the lifecycle
/// step-6 path) so anchors swapped in by a nav also get the viewport tier — AC-V16
/// lists viewport-enter as a warm trigger, and without re-observing it would only
/// work on the initial page. Idempotent: `IntersectionObserver::observe` on an
/// element already being observed is a no-op per the DOM spec, so re-scanning the
/// shared shell's links each nav does not double-warm. No-op until the observer
/// exists (i.e. before [`attach_viewport_observer`] has run).
pub(crate) fn observe_current_anchors() {
    VIEWPORT_OBSERVER.with(|slot| {
        let borrowed = slot.borrow();
        let observer = match borrowed.as_ref() {
            Some(observer) => observer,
            None => return,
        };
        let document = match dom::document() {
            Ok(document) => document,
            Err(_) => return,
        };
        let anchors = match document.query_selector_all("a[href]") {
            Ok(anchors) => anchors,
            Err(_) => return,
        };
        for index in 0..anchors.length() {
            if let Some(node) = anchors.get(index) {
                if let Ok(element) = node.dyn_into::<web_sys::Element>() {
                    observer.observe(&element);
                }
            }
        }
    });
}

/// `window.clearTimeout` for a handle, best-effort.
fn clear_timeout(handle: i32) {
    if let Ok(window) = dom::window() {
        window.clear_timeout_with_handle(handle);
    }
}

/// Pathname of a (possibly absolute) URL against the current base, or `None` if
/// it cannot be parsed. Mirrors `mod.rs::pathname_of` but returns `Option` for
/// the fire-and-forget warm path (a parse failure simply skips warming).
fn pathname_of(url: &str) -> Option<String> {
    let base = dom::window().ok()?.location().href().ok()?;
    web_sys::Url::new_with_base(url, &base)
        .ok()
        .map(|parsed| parsed.pathname())
}
