//! The pure click opt-out predicate (AC-V2).
//!
//! Deciding whether a click on an `<a>` should be handled by client navigation
//! or left to the browser is the one piece of the nav module that depends on
//! nothing but the anchor's attributes and the click's modifier state. It is
//! factored out here as a pure function over plain values — no `web_sys`, no DOM
//! — so it can be exhaustively unit-tested under a native `cargo test` (this
//! workspace has no browser, so the DOM-driven parts of nav can only be
//! compiled, not run; this predicate is the part that MUST be proven correct).
//!
//! The click interceptor in `nav/mod.rs` reads the live `MouseEvent` /
//! `HtmlAnchorElement` into a [`LinkClick`] and calls [`should_intercept`].

/// Everything the opt-out decision needs about a single left-click on an `<a>`,
/// extracted from the live `MouseEvent` + `HtmlAnchorElement` by the caller.
///
/// Keeping this a plain-value struct (no DOM handles) is what makes
/// [`should_intercept`] a pure function and lets the table-driven tests below
/// run without a browser.
#[derive(Clone, Debug)]
pub(crate) struct LinkClick {
    /// `event.button` — only the primary button (0) is a candidate for
    /// interception; any other button is the browser's to handle.
    pub button: i16,
    /// Whether any of the meta/ctrl/shift/alt modifier keys were held. A
    /// modifier-click means "open in new tab / download / etc." — always native.
    pub has_modifier_key: bool,
    /// `true` when the anchor declares `target` and it is not `_self`. A
    /// `target=_blank` (or framed) link must open natively.
    pub has_foreign_target: bool,
    /// Whether the anchor carries a `download` attribute.
    pub has_download_attribute: bool,
    /// Whether the anchor carries `data-no-morph` (the explicit opt-out).
    pub has_no_morph_attribute: bool,
    /// The space-separated `rel` tokens, lower-cased. `rel="external"` (or a
    /// list containing the `external` token) opts out.
    pub rel_tokens: Vec<String>,
    /// The anchor's resolved `href` origin (scheme + host + port), e.g.
    /// `"https://example.com"`. `None` when the href has no origin (a bare
    /// fragment, a `mailto:`/`tel:`, or an otherwise opaque/unparseable URL).
    pub href_origin: Option<String>,
    /// The current document's origin to compare against for the same-origin
    /// rule.
    pub document_origin: String,
    /// The href's scheme in lower case (e.g. `"https"`, `"mailto"`, `"tel"`),
    /// when one is present.
    pub href_scheme: Option<String>,
    /// `true` when the href is a pure same-document fragment navigation — the
    /// path + query match the current document and only the `#fragment` differs
    /// (or the href is exactly `"#"` / `"#section"`). These must scroll
    /// natively, never morph.
    pub is_pure_fragment: bool,
    /// `true` when the anchor has no usable `href` at all (missing or empty).
    pub has_no_href: bool,
}

/// Schemes that are never client-navigable: they hand off to the OS / mail
/// client / dialer rather than loading a document.
///
/// `http` and `https` are the only schemes client nav handles; everything else
/// (including these) falls through to the browser. Listing the common
/// document-external schemes explicitly keeps the intent legible, but the
/// same-origin + scheme checks below would reject them regardless.
const NON_DOCUMENT_SCHEMES: [&str; 6] = ["mailto", "tel", "sms", "javascript", "blob", "data"];

/// Whether this click should be intercepted and handled by client navigation.
///
/// Returns `false` (let the browser do it) for every AC-V2 opt-out:
/// non-primary or modifier clicks; `data-no-morph`; a foreign `target`; a
/// `download` attribute; `rel="external"`; a missing href; non-`http(s)`
/// schemes (`mailto:`/`tel:`/…); cross-origin hrefs; and pure same-document
/// fragment links. Only a plain primary-button click on a same-origin
/// `http(s)` anchor that changes the document returns `true`.
pub(crate) fn should_intercept(link: &LinkClick) -> bool {
    // Modifier / non-primary clicks are the browser's (new tab, save, etc.).
    if link.button != 0 || link.has_modifier_key {
        return false;
    }

    // Explicit and structural opt-outs.
    if link.has_no_morph_attribute
        || link.has_foreign_target
        || link.has_download_attribute
        || link.has_no_href
    {
        return false;
    }

    // rel="external" (any token in the list).
    if link
        .rel_tokens
        .iter()
        .any(|token| token == "external")
    {
        return false;
    }

    // Scheme must be http(s); mailto/tel/etc. hand off to the OS.
    let scheme = match &link.href_scheme {
        Some(scheme) => scheme,
        None => return false,
    };
    if scheme != "http" && scheme != "https" {
        return false;
    }
    if NON_DOCUMENT_SCHEMES.contains(&scheme.as_str()) {
        return false;
    }

    // Same-origin only — a cross-origin href is a full navigation.
    let origin = match &link.href_origin {
        Some(origin) => origin,
        None => return false,
    };
    if origin != &link.document_origin {
        return false;
    }

    // A pure fragment link scrolls within the current document; never morph.
    if link.is_pure_fragment {
        return false;
    }

    true
}

#[cfg(test)]
mod tests {
    //! Native, table-driven tests for the pure opt-out predicate. These run
    //! under a plain `cargo test` (no browser needed) and are the load-bearing
    //! correctness proof for AC-V2 in a no-browser environment.

    use super::{should_intercept, LinkClick};

    /// A baseline same-origin, primary-button, plain `http(s)` link that SHOULD
    /// be intercepted. Each test clones this and flips exactly one field to
    /// isolate the effect of that one opt-out.
    fn navigable_link() -> LinkClick {
        LinkClick {
            button: 0,
            has_modifier_key: false,
            has_foreign_target: false,
            has_download_attribute: false,
            has_no_morph_attribute: false,
            rel_tokens: Vec::new(),
            href_origin: Some("https://example.com".to_owned()),
            document_origin: "https://example.com".to_owned(),
            href_scheme: Some("https".to_owned()),
            is_pure_fragment: false,
            has_no_href: false,
        }
    }

    #[test]
    fn plain_same_origin_left_click_is_intercepted() {
        assert!(should_intercept(&navigable_link()));
    }

    #[test]
    fn each_opt_out_falls_through_to_the_browser() {
        // (description, mutation) — each mutation turns the navigable baseline
        // into an opt-out case that must return false.
        type OptOutCase = (&'static str, fn(&mut LinkClick));
        let cases: Vec<OptOutCase> = vec![
            ("middle-button click", |link| link.button = 1),
            ("right-button click", |link| link.button = 2),
            ("modifier key held (cmd/ctrl/shift/alt)", |link| {
                link.has_modifier_key = true
            }),
            ("data-no-morph attribute", |link| {
                link.has_no_morph_attribute = true
            }),
            ("target=_blank (foreign target)", |link| {
                link.has_foreign_target = true
            }),
            ("download attribute", |link| {
                link.has_download_attribute = true
            }),
            ("missing href", |link| link.has_no_href = true),
            ("rel=external", |link| {
                link.rel_tokens = vec!["external".to_owned()]
            }),
            ("rel list containing external", |link| {
                link.rel_tokens = vec!["noopener".to_owned(), "external".to_owned()]
            }),
            ("mailto: scheme", |link| {
                link.href_scheme = Some("mailto".to_owned());
                link.href_origin = None;
            }),
            ("tel: scheme", |link| {
                link.href_scheme = Some("tel".to_owned());
                link.href_origin = None;
            }),
            ("cross-origin href", |link| {
                link.href_origin = Some("https://other.example".to_owned())
            }),
            ("cross-origin via different scheme", |link| {
                link.href_scheme = Some("http".to_owned());
                link.href_origin = Some("http://example.com".to_owned());
                // document is https://example.com — origin differs by scheme.
            }),
            ("pure fragment link", |link| link.is_pure_fragment = true),
            ("missing scheme", |link| {
                link.href_scheme = None;
            }),
        ];

        for (description, mutate) in cases {
            let mut link = navigable_link();
            mutate(&mut link);
            assert!(
                !should_intercept(&link),
                "expected opt-out (no interception) for case: {description}",
            );
        }
    }

    #[test]
    fn target_self_does_not_count_as_foreign() {
        // The caller is responsible for treating target=_self as "not foreign";
        // here we just confirm a non-foreign target is still navigable.
        let link = navigable_link();
        assert!(should_intercept(&link));
    }

    #[test]
    fn rel_without_external_token_is_still_navigable() {
        let mut link = navigable_link();
        link.rel_tokens = vec!["noopener".to_owned(), "noreferrer".to_owned()];
        assert!(should_intercept(&link));
    }

    #[test]
    fn http_same_origin_is_navigable() {
        let mut link = navigable_link();
        link.href_scheme = Some("http".to_owned());
        link.href_origin = Some("http://example.com".to_owned());
        link.document_origin = "http://example.com".to_owned();
        assert!(should_intercept(&link));
    }
}
