//! Client-side navigation for islands.rs (the `nav` feature).
//!
//! Same-origin `<a>` clicks are intercepted and resolved against an in-memory
//! route to bundle manifest; the destination bundle is `import()`ed (and its
//! WASM instantiated via `await default()`), the destination HTML is fetched
//! with the `X-Islands-Nav` header and morphed into the live document, and
//! island lifecycle is driven precisely through idiomorph's callbacks — all
//! without a full document reload.
//!
//! This module implements the **normative Navigation execution contract** from
//! `.omc/plans/client-navigation.md`. The orchestration is single-sourced: a
//! nav runs contract steps 1-7 in [`navigate_to`], and **mounting happens
//! exactly once**, at step 6, inside [`lifecycle::activate_suspense_then_mount`].
//!
//! Submodules:
//!   - [`opt_out`]: the pure click opt-out predicate (AC-V2), natively tested.
//!   - [`dom`]: impure DOM adapters (reads a live click into a pure `LinkClick`).
//!   - [`manifest`]: the route to bundle manifest schema + fetch + cache.
//!   - [`lifecycle`]: morph options bound to island lifecycle + the single mount.
//!   - [`history`]: scroll restoration, pushState, focus, popstate, bfcache.
//!
//! Prefetch (Phase 3f) and View Transitions (Phase 3g) are deliberately NOT
//! here: the morph+mount of steps 4-6 is isolated in [`perform_morph_and_mount`]
//! so a view transition can wrap exactly that callback, and the fetch/import
//! helpers are split out so a prefetch commit can substitute a cached
//! speculation for a fresh fetch.

mod dom;
mod history;
mod lifecycle;
mod manifest;
mod opt_out;
mod prefetch;
mod view_transition;

use std::cell::RefCell;

use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::{spawn_local, JsFuture};

use crate::nav::history::ScrollPosition;

thread_local! {
    /// Set once nav has been initialized so [`init`] is idempotent (a bfcache
    /// `pageshow` or a duplicate call does not re-attach listeners).
    static INITIALIZED: RefCell<bool> = const { RefCell::new(false) };

    /// The `AbortController` of the in-flight nav fetch, if any. A new accepted
    /// nav aborts the prior one so the latest nav wins (AC-V10).
    static ACTIVE_FETCH_CONTROLLER: RefCell<Option<web_sys::AbortController>> =
        const { RefCell::new(None) };

    /// Monotonic generation token bumped at the start of every accepted nav.
    /// Each nav captures the value it started; after every `await` it re-checks
    /// that its generation is still current and bails if a newer nav has begun.
    /// This is what "the morph is keyed to the latest nav — no interleaved
    /// morphs" (AC-V10) means even when an unabortable `import()` interleaves:
    /// the superseded nav resolves into a no-op rather than morphing stale HTML.
    static NAV_GENERATION: RefCell<u32> = const { RefCell::new(0) };
}

/// Begin a new nav: bump the generation, abort any prior in-flight fetch, and
/// return the new generation token the caller threads through its `await`s.
fn begin_nav_generation() -> u32 {
    abort_active_fetch();
    NAV_GENERATION.with(|generation| {
        let next = generation.borrow().wrapping_add(1);
        *generation.borrow_mut() = next;
        next
    })
}

/// Whether `generation` is still the current nav (no newer nav has started).
fn is_current_generation(generation: u32) -> bool {
    NAV_GENERATION.with(|current| *current.borrow() == generation)
}

/// Initialize client navigation: attach the capture-phase click interceptor and
/// the popstate listener, set manual scroll restoration, and attach the warm-tier
/// prefetch listeners. The nav manifest is fetched lazily on the first navigation,
/// not here. Idempotent — safe to call from the runtime start path on every page
/// load; only the first call wires anything up.
///
/// Called automatically by the runtime when the `nav` feature is enabled (see
/// `crate::init_nav`). Re-invocation (including after a bfcache restore) is a
/// no-op so listeners are never doubled.
pub fn init() {
    let already = INITIALIZED.with(|flag| {
        if *flag.borrow() {
            return true;
        }
        *flag.borrow_mut() = true;
        false
    });
    if already {
        return;
    }

    if let Err(error) = wire_up() {
        web_sys::console::error_2(
            &JsValue::from_str("islands nav: init failed:"),
            &error,
        );
    }
}

/// Attach all nav-global listeners and prime the manifest. Separated from [`init`]
/// so the idempotency guard stays tiny and the error handling is in one place.
fn wire_up() -> Result<(), JsValue> {
    history::set_manual_scroll_restoration()?;
    attach_click_interceptor()?;
    attach_popstate_listener()?;
    // Nav-global warm-tier singletons (hover/focus + viewport observer).
    prefetch::attach_warm_listeners()?;

    // The nav manifest is fetched lazily on the first actual navigation
    // (`resolve_entry` → `manifest::ensure_cached`), not eagerly here. An app that
    // never morph-navigates — or never wires a nav manifest — therefore issues NO
    // request for `/static/nav-manifest.json` on page load. A missing manifest is
    // treated as "nav not configured" (a 404 caches an empty manifest), so it
    // never surfaces as a failed request or a console error in DevTools.
    Ok(())
}

/// Programmatically navigate to `url` (a same-origin path), as if a link to it
/// were clicked. Runs the same contract as an intercepted click. On any failure
/// it falls back to a full document load so the user always reaches the page.
///
/// Re-exported by the bindings + umbrella crates (task #9) as the public
/// programmatic entry point.
pub fn navigate(url: &str) {
    let url_owned = url.to_owned();
    // Bump the generation (and abort any prior fetch) synchronously, before the
    // first await, so a rapid second click always supersedes the first.
    let generation = begin_nav_generation();
    spawn_local(async move {
        if let Err(error) = navigate_to(&url_owned, generation).await {
            // A superseded nav returns Ok(()) (no-op); only a real failure of
            // the current nav reaches here and falls back to a full load.
            if is_current_generation(generation) {
                web_sys::console::error_2(
                    &JsValue::from_str("islands nav: navigation failed, falling back to full load:"),
                    &error,
                );
                fall_back_to_full_load(&url_owned);
            }
        }
    });
}

/// Capture-phase `click` listener on `document`: resolve the nearest `<a>`, apply
/// the AC-V2 opt-out rules, and for an accepted click prevent the default
/// navigation and run the nav. Capture phase so we see the click before any
/// island handler can stop propagation.
fn attach_click_interceptor() -> Result<(), JsValue> {
    let document = dom::document()?;
    let handler = Closure::<dyn FnMut(web_sys::Event)>::new(move |event: web_sys::Event| {
        if let Err(error) = handle_click(&event) {
            web_sys::console::error_2(&JsValue::from_str("islands nav: click handler error:"), &error);
        }
    });
    document.add_event_listener_with_callback_and_bool(
        "click",
        handler.as_ref().unchecked_ref(),
        true,
    )?;
    // Nav-global singleton: never cleaned up (lives for the document's life).
    handler.forget();
    Ok(())
}

/// Decide whether `event` is an interceptable link click and, if so, take it
/// over. Pure decision via [`opt_out::should_intercept`]; everything else is
/// left to the browser.
fn handle_click(event: &web_sys::Event) -> Result<(), JsValue> {
    let mouse_event = match event.dyn_ref::<web_sys::MouseEvent>() {
        Some(mouse_event) => mouse_event,
        None => return Ok(()),
    };
    // Already prevented by something upstream — respect that.
    if mouse_event.default_prevented() {
        return Ok(());
    }
    let target = match event.target() {
        Some(target) => target,
        None => return Ok(()),
    };
    let anchor = match dom::closest_anchor(&target) {
        Some(anchor) => anchor,
        None => return Ok(()),
    };

    let link = dom::link_click_from(mouse_event, &anchor, dom::document_origin()?);
    if !opt_out::should_intercept(&link) {
        return Ok(());
    }

    // Accepted: take over this click.
    event.prevent_default();
    let url = anchor.href();

    // Same-href click (resolved URL identical to the current location): a morph
    // would push a duplicate history entry + redundantly fetch the page already
    // shown. Both URLs are resolved by the same parser, so an exact match is
    // reliable; preventDefault already suppressed the browser reload, so just
    // no-op. (A pure-hash same-page link never reaches here — it opted out via
    // is_pure_fragment.)
    if let Ok(current) = dom::window().and_then(|window| window.location().href()) {
        if url == current {
            return Ok(());
        }
    }

    navigate(&url);
    Ok(())
}

/// Listen for `popstate` (back/forward) and morph to the target entry, restoring
/// its stored scroll. A bfcache `pageshow` does NOT reach here, so a restore
/// never triggers a morph.
fn attach_popstate_listener() -> Result<(), JsValue> {
    let window = dom::window()?;
    let handler = Closure::<dyn FnMut(web_sys::PopStateEvent)>::new(
        move |event: web_sys::PopStateEvent| {
            handle_popstate(&event);
        },
    );
    window.add_event_listener_with_callback("popstate", handler.as_ref().unchecked_ref())?;
    handler.forget();
    Ok(())
}

/// Handle a `popstate`: morph to the current `location` and restore the scroll
/// position recorded in the target entry's state.
///
/// Only history entries we created (tagged via [`history::is_nav_state`]) are
/// morph-navigated. A foreign or `null` state — an entry not pushed by this nav
/// layer, e.g. the document's initial entry or one created by other code — is
/// NOT morphed; it falls back to a full document load so the page is always
/// correct (the defensive AC-V11 path). This keeps the morph keyed to entries
/// whose markup the nav layer is responsible for.
fn handle_popstate(event: &web_sys::PopStateEvent) {
    let state = event.state();
    let url = match dom::window().and_then(|window| window.location().href()) {
        Ok(url) => url,
        Err(_) => return,
    };

    // Foreign / null state: not one of ours — full-load rather than morph.
    if !history::is_nav_state(&state) {
        fall_back_to_full_load(&url);
        return;
    }

    // Our entry: restore the scroll position recorded when we pushed it.
    let target_scroll = history::scroll_from_state(&state);
    let generation = begin_nav_generation();
    spawn_local(async move {
        if let Err(error) = navigate_via_popstate(&url, target_scroll, generation).await {
            if is_current_generation(generation) {
                web_sys::console::error_2(
                    &JsValue::from_str("islands nav: popstate navigation failed, full load:"),
                    &error,
                );
                fall_back_to_full_load(&url);
            }
        }
    });
}

/// The core forward-nav contract (steps 1-7) for an accepted click / programmatic
/// `navigate`. A failure returns `Err` so the caller falls back to a full load.
///
/// `generation` is captured by the caller via [`begin_nav_generation`] (which
/// also aborts any prior in-flight fetch). After each `await` we re-check it: a
/// newer nav having begun makes this one resolve into a no-op (AC-V10 — the
/// morph is keyed to the latest nav, so a superseded — possibly unabortable —
/// `import()` never morphs stale HTML).
async fn navigate_to(url: &str, generation: u32) -> Result<(), JsValue> {
    // Step 1: resolve URL → manifest entry (lazy cold-miss fetch). An unresolvable
    // route (no manifest configured, or route absent) is not an error — fall back
    // to a full document load silently so the user always reaches the page.
    let entry = match resolve_entry(url).await? {
        Some(entry) => entry,
        None => {
            if is_current_generation(generation) {
                fall_back_to_full_load(url);
            }
            return Ok(());
        }
    };
    if !is_current_generation(generation) {
        return Ok(());
    }

    // Step 2: load the destination bundle and INSTANTIATE its WASM. Awaiting the
    // default export runs wasm-bindgen init() → #[wasm_bindgen(start)] →
    // register_island. A bare import() would NOT instantiate.
    load_and_instantiate_bundle(&entry.js).await?;
    if !is_current_generation(generation) {
        return Ok(());
    }

    // Step 3: obtain the destination HTML (X-Islands-Nav) — consuming a warm
    // speculation if one is cached, else fetching fresh.
    let html = obtain_destination_html(url).await?;
    if !is_current_generation(generation) {
        return Ok(());
    }

    // Step 7a (before the morph): capture outgoing scroll + pushState.
    history::capture_scroll_and_push(url)?;

    // Steps 4-6: morph + Suspense activation + the single mount, wrapped in a
    // view transition when one is available + motion is allowed (else plain).
    view_transition::run_morph_with_optional_transition(move || perform_morph_and_mount(&html))
        .await?;

    // Steps 7b + 7c (after the morph paints): forward nav resets scroll to top,
    // then focus <main>.
    history::restore_scroll_next_frame(ScrollPosition::default())?;
    history::focus_main()?;

    clear_active_controller();
    Ok(())
}

/// The popstate variant: same steps 1-6, but scroll is RESTORED to `target` (not
/// reset) and there is no pushState (the browser already moved the entry).
async fn navigate_via_popstate(
    url: &str,
    target: ScrollPosition,
    generation: u32,
) -> Result<(), JsValue> {
    let entry = match resolve_entry(url).await? {
        Some(entry) => entry,
        None => {
            if is_current_generation(generation) {
                fall_back_to_full_load(url);
            }
            return Ok(());
        }
    };
    if !is_current_generation(generation) {
        return Ok(());
    }
    load_and_instantiate_bundle(&entry.js).await?;
    if !is_current_generation(generation) {
        return Ok(());
    }
    let html = obtain_destination_html(url).await?;
    if !is_current_generation(generation) {
        return Ok(());
    }

    // popstate morphs are VT-wrapped under the identical guards as forward nav.
    view_transition::run_morph_with_optional_transition(move || perform_morph_and_mount(&html))
        .await?;

    // Back/forward restores the prior scroll position; no focus reset (the user
    // is returning to a place they have seen).
    history::restore_scroll_next_frame(target)?;
    clear_active_controller();
    Ok(())
}

/// Contract steps 4-6 in one place: parse the fetched HTML, morph the live
/// document into it (island lifecycle via the bound callbacks), activate any
/// streamed-Suspense leftovers, then `mount_all()` exactly once.
///
/// Isolated as the unit a View Transition (Phase 3g) will wrap — it is fully
/// synchronous (morph and mount are sync), so wrapping it in a synchronous
/// `startViewTransition` callback captures island first-paint into the snapshot.
fn perform_morph_and_mount(html: &str) -> Result<(), JsValue> {
    let new_document = parse_html_document(html)?;
    let new_root = new_document
        .document_element()
        .ok_or_else(|| JsValue::from_str("fetched document has no documentElement"))?;
    let live_root = dom::document()?
        .document_element()
        .ok_or_else(|| JsValue::from_str("live document has no documentElement"))?;

    // Step 4: morph (HeadStyle::Merge; island subtrees skipped; departing
    // islands unmounted; new-doc markers stripped of client state).
    islands_morph::morph(
        live_root.as_ref(),
        new_root.as_ref(),
        lifecycle::nav_morph_options(),
    )?;

    // Steps 5 + 6: activate Suspense slots (self-gating), then the single mount.
    lifecycle::activate_suspense_then_mount()
}

/// Resolve `url`'s pathname to a manifest entry. The manifest is fetched lazily on
/// first use (cold-miss blocking fetch, contract step 1).
///
/// Returns `Ok(None)` when the route is not resolvable — either no manifest is
/// configured (a 404 caches an empty manifest) or the route is simply absent. The
/// caller treats `None` as "not a morph-nav route" and falls back to a full
/// document load silently, rather than logging a non-nav route as an error.
async fn resolve_entry(url: &str) -> Result<Option<manifest::NavEntry>, JsValue> {
    let pathname = pathname_of(url)?;
    manifest::ensure_cached().await?;
    Ok(manifest::resolved_entry(&pathname))
}

/// Contract step 2: `const m = await import(jsUrl); await m.default();`.
///
/// Awaiting the default export runs the page bundle's wasm-bindgen `init()`,
/// which instantiates the page WASM and runs its `#[wasm_bindgen(start)]`
/// (registering the island). A bare `import()` returns the module exports but
/// does NOT instantiate the WASM, so the await is load-bearing. On revisit the
/// module is import-cached and `start()` does not re-run; the island stays
/// registered.
async fn load_and_instantiate_bundle(js_url: &str) -> Result<(), JsValue> {
    let module = JsFuture::from(dynamic_import(js_url)).await?;
    let default_export = js_sys::Reflect::get(&module, &JsValue::from_str("default"))?;
    let default_function = default_export
        .dyn_ref::<js_sys::Function>()
        .ok_or_else(|| JsValue::from_str("page bundle has no default export function"))?;
    let init_result = default_function.call0(&module)?;
    // wasm-bindgen's default export returns a Promise; await it to finish
    // instantiation. If it is not a Promise (already-instantiated edge), the
    // await of a non-Promise resolves immediately.
    if let Ok(promise) = init_result.dyn_into::<js_sys::Promise>() {
        JsFuture::from(promise).await?;
    }
    Ok(())
}

/// Obtain the destination HTML, consuming a warm-tier speculation when one is
/// cached (Phase 3f commit) and otherwise fetching fresh.
///
/// The consume is **synchronous** and happens before any `await`: it removes the
/// speculation from the cache and adopts its `AbortController` as the active
/// controller in one run-to-completion step, so the TTL evictor and the
/// active-nav abort can never observe a half-consumed entry (AC-V10 abort
/// scope). The promoted fetch is then owned by the active nav, not the cache.
async fn obtain_destination_html(url: &str) -> Result<String, JsValue> {
    // Synchronous consume + controller adoption (no await in this block).
    let parked_body = prefetch::consume_speculation(url).map(|speculation| {
        let body_text_promise = speculation.body_text_promise();
        // The active nav now owns the fetch's lifetime; install its controller
        // as the active one (begin_nav_generation already aborted any prior).
        set_active_controller(speculation.into_controller());
        body_text_promise
    });

    if let Some(body_text_promise) = parked_body {
        // Commit hit: await the already-in-flight body — no duplicate fetch.
        let text_value = JsFuture::from(body_text_promise).await?;
        return text_value
            .as_string()
            .ok_or_else(|| JsValue::from_str("prefetched body was not a string"));
    }

    // Miss: fresh fetch under a new active controller.
    let controller = install_active_controller()?;
    fetch_destination_html(url, &controller).await
}

/// Fetch the destination HTML with the `X-Islands-Nav` header so the server can
/// render streaming routes inline (AC-V13). Uses `controller`'s signal so a
/// superseding nav can abort this fetch (AC-V10). A non-2xx response is an error
/// (→ full-load fallback, AC-V11).
async fn fetch_destination_html(
    url: &str,
    controller: &web_sys::AbortController,
) -> Result<String, JsValue> {
    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()?;
    let response_value = JsFuture::from(window.fetch_with_request(&request)).await?;
    let response: web_sys::Response = response_value.dyn_into()?;
    if !response.ok() {
        return Err(JsValue::from_str(&format!(
            "destination fetch failed: HTTP {}",
            response.status()
        )));
    }
    let text_value = JsFuture::from(response.text()?).await?;
    text_value
        .as_string()
        .ok_or_else(|| JsValue::from_str("destination body was not a string"))
}

/// Parse fetched HTML into a `Document` via `DOMParser` (contract step 3).
fn parse_html_document(html: &str) -> Result<web_sys::Document, JsValue> {
    let parser = web_sys::DomParser::new()?;
    parser.parse_from_string(html, web_sys::SupportedType::TextHtml)
}

/// Install a fresh `AbortController` as the active one for this nav's fetch.
///
/// Any prior in-flight fetch was already aborted by [`begin_nav_generation`] at
/// the start of this nav (AC-V10: the latest nav wins), so this only stores the
/// new controller. Returns it for the fetch's signal.
fn install_active_controller() -> Result<web_sys::AbortController, JsValue> {
    let controller = web_sys::AbortController::new()?;
    set_active_controller(controller.clone());
    Ok(controller)
}

/// Adopt an existing `AbortController` (e.g. one promoted out of the speculation
/// cache on commit) as the active controller, so subsequent active-nav aborts
/// and the completion clear target it.
fn set_active_controller(controller: web_sys::AbortController) {
    ACTIVE_FETCH_CONTROLLER.with(|slot| *slot.borrow_mut() = Some(controller));
}

/// Abort the in-flight nav fetch, if one is outstanding.
fn abort_active_fetch() {
    ACTIVE_FETCH_CONTROLLER.with(|slot| {
        if let Some(controller) = slot.borrow_mut().take() {
            controller.abort();
        }
    });
}

/// Clear the active controller slot once a nav has completed without being
/// superseded (so a later abort does not target a finished fetch).
fn clear_active_controller() {
    ACTIVE_FETCH_CONTROLLER.with(|slot| *slot.borrow_mut() = None);
}

/// AC-V11 fallback: a full document load to `url`. The console.error is logged
/// by the caller BEFORE this runs, per the contract's observability note.
fn fall_back_to_full_load(url: &str) {
    if let Ok(window) = dom::window() {
        let _ = window.location().assign(url);
    }
}

/// Extract the pathname from a (possibly absolute) URL string using the `URL`
/// API against the current document base.
fn pathname_of(url: &str) -> Result<String, JsValue> {
    let base = dom::window()?.location().href()?;
    let parsed = web_sys::Url::new_with_base(url, &base)?;
    Ok(parsed.pathname())
}

/// `import(specifier)` as a `Promise`, reached through a tiny JS glue extern
/// because dynamic `import` is a syntactic form with no `web_sys` binding. The
/// specifier is a manifest URL (already content-hashed in `--release`); it is
/// passed verbatim and never assembled from basenames, so xtask's `--release`
/// rewrite has nothing to catch here.
fn dynamic_import(specifier: &str) -> js_sys::Promise {
    dynamic_import_glue(specifier)
}

#[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#"
export function __islands_dynamic_import(specifier) { return import(specifier); }
"#)]
extern "C" {
    #[wasm_bindgen(js_name = __islands_dynamic_import)]
    fn dynamic_import_glue(specifier: &str) -> js_sys::Promise;
}
