//! DOM adapters shared across the nav module.
//!
//! These are the impure counterparts to the pure logic elsewhere: reading the
//! live `MouseEvent`/`HtmlAnchorElement` into a plain [`LinkClick`] (so
//! `opt_out::should_intercept` stays pure), resolving the clicked anchor, and
//! the small `window`/`document` accessors the orchestration leans on.

use wasm_bindgen::JsCast;
use wasm_bindgen::JsValue;

use super::opt_out::LinkClick;

/// The shared `Window`, or an error when called off the main browser thread.
pub(crate) fn window() -> Result<web_sys::Window, JsValue> {
    web_sys::window().ok_or_else(|| JsValue::from_str("no window"))
}

/// The current `Document`.
pub(crate) fn document() -> Result<web_sys::Document, JsValue> {
    window()?
        .document()
        .ok_or_else(|| JsValue::from_str("no document"))
}

/// The current document's origin (scheme + host + port), used for the
/// same-origin opt-out comparison.
pub(crate) fn document_origin() -> Result<String, JsValue> {
    window()?.location().origin()
}

/// Walk up from the click target to the nearest enclosing `<a>` element, if any.
///
/// Mirrors `event.target.closest("a")`: a click on a `<span>` inside a link
/// still resolves to the link. Returns `None` when the click was not inside any
/// anchor.
pub(crate) fn closest_anchor(target: &web_sys::EventTarget) -> Option<web_sys::HtmlAnchorElement> {
    let element = target.dyn_ref::<web_sys::Element>()?;
    let matched = element.closest("a").ok().flatten()?;
    matched.dyn_into::<web_sys::HtmlAnchorElement>().ok()
}

/// Build the pure [`LinkClick`] view of a live click on `anchor`.
///
/// All DOM reads happen here so that `opt_out::should_intercept` operates only on
/// plain values. `document_origin` is threaded in by the caller (it reads it
/// once per click).
pub(crate) fn link_click_from(
    event: &web_sys::MouseEvent,
    anchor: &web_sys::HtmlAnchorElement,
    document_origin: String,
) -> LinkClick {
    let has_modifier_key =
        event.meta_key() || event.ctrl_key() || event.shift_key() || event.alt_key();

    // `target` is foreign when present and not `_self`. An empty target is the
    // same as `_self` (current browsing context).
    let target_attribute = anchor.target();
    let has_foreign_target = !target_attribute.is_empty() && target_attribute != "_self";

    let has_download_attribute = anchor.has_attribute("download");
    let has_no_morph_attribute = anchor.has_attribute("data-no-morph");

    let rel_tokens = anchor
        .rel()
        .split_whitespace()
        .map(|token| token.to_ascii_lowercase())
        .collect::<Vec<_>>();

    // The `href` *attribute* (raw) tells us whether one was authored at all and
    // whether it is a pure fragment; the `href` *property* (resolved, absolute)
    // gives scheme/origin via the anchor's URL parts.
    let raw_href = anchor.get_attribute("href");
    let has_no_href = raw_href.as_deref().map(str::is_empty).unwrap_or(true);

    let href_scheme = scheme_of(anchor);
    let href_origin = origin_of(anchor);
    let is_pure_fragment = is_pure_fragment(anchor, raw_href.as_deref());

    LinkClick {
        button: event.button(),
        has_modifier_key,
        has_foreign_target,
        has_download_attribute,
        has_no_morph_attribute,
        rel_tokens,
        href_origin,
        document_origin,
        href_scheme,
        is_pure_fragment,
        has_no_href,
    }
}

/// Build a [`LinkClick`] for the prefetch warm path: "would this anchor be
/// intercepted if the user left-clicked it?".
///
/// Models a plain primary-button, no-modifier click (button 0, no modifier keys)
/// so the warm tier reuses the single opt-out predicate
/// (`opt_out::should_intercept`) and never warms a link a real click would let
/// the browser handle (AC-V2 opt-outs suppress prefetch).
pub(crate) fn link_click_for_prefetch(
    anchor: &web_sys::HtmlAnchorElement,
    document_origin: String,
) -> LinkClick {
    let target_attribute = anchor.target();
    let has_foreign_target = !target_attribute.is_empty() && target_attribute != "_self";
    let rel_tokens = anchor
        .rel()
        .split_whitespace()
        .map(|token| token.to_ascii_lowercase())
        .collect::<Vec<_>>();
    let raw_href = anchor.get_attribute("href");
    let has_no_href = raw_href.as_deref().map(str::is_empty).unwrap_or(true);

    LinkClick {
        // A synthetic plain primary click: never a modifier / non-primary click,
        // since "would a plain click be intercepted?" is the question.
        button: 0,
        has_modifier_key: false,
        has_foreign_target,
        has_download_attribute: anchor.has_attribute("download"),
        has_no_morph_attribute: anchor.has_attribute("data-no-morph"),
        rel_tokens,
        href_origin: origin_of(anchor),
        document_origin,
        href_scheme: scheme_of(anchor),
        is_pure_fragment: is_pure_fragment(anchor, raw_href.as_deref()),
        has_no_href,
    }
}

/// The anchor's resolved scheme without the trailing colon, lower-cased, e.g.
/// `"https"`. `HtmlAnchorElement::protocol` returns `"https:"`; we strip the
/// colon. An empty protocol yields `None`.
fn scheme_of(anchor: &web_sys::HtmlAnchorElement) -> Option<String> {
    let protocol = anchor.protocol();
    let trimmed = protocol.strip_suffix(':').unwrap_or(&protocol);
    if trimmed.is_empty() {
        return None;
    }
    Some(trimmed.to_ascii_lowercase())
}

/// The anchor's resolved origin (scheme + host + port). For non-http(s) URLs the
/// browser may return the string `"null"`; that is treated as no origin so the
/// same-origin check correctly rejects it.
fn origin_of(anchor: &web_sys::HtmlAnchorElement) -> Option<String> {
    let origin = anchor.origin();
    if origin.is_empty() || origin == "null" {
        return None;
    }
    Some(origin)
}

/// Whether the href is a pure same-document fragment navigation.
///
/// True when the raw href starts with `#` (`"#"`, `"#section"`), or when the
/// resolved href shares the current document's path + search and differs only in
/// its `#fragment`. Such links must scroll natively, never morph.
fn is_pure_fragment(anchor: &web_sys::HtmlAnchorElement, raw_href: Option<&str>) -> bool {
    if let Some(raw) = raw_href {
        if raw.starts_with('#') {
            return true;
        }
    }
    // Resolved comparison: same pathname + search, non-empty hash.
    let location = match window().ok().map(|window| window.location()) {
        Some(location) => location,
        None => return false,
    };
    if anchor.hash().is_empty() {
        return false;
    }
    let same_path = location.pathname().map(|path| path == anchor.pathname()).unwrap_or(false);
    let same_search = location
        .search()
        .map(|search| search == anchor.search())
        .unwrap_or(false);
    same_path && same_search
}
