//! Snippet lookup and expansion.
//!
//! Snippets map short aliases to fuller Glyf strings. The expansion
//! happens *before* attribute parsing, so `"img"` becomes `"img:src=:alt="`
//! which is then split into identifier `img` and attributes `:src= :alt=`.
//!
//! ## Parametric keys
//!
//! A snippet key may contain a single `$` as a digit wildcard. The `$` matches
//! one or more consecutive ASCII digits in the user's input; that number is then
//! substituted for every `$` in the expansion.
//!
//! ```text
//! Key: col$   Expansion: div.flex.flex-col.gap-$
//! Input: col3  →  div.flex.flex-col.gap-3
//! Input: col12 →  div.flex.flex-col.gap-12
//! ```
//!
//! Only one `$` per key is supported. Longest-key-wins applies across both
//! plain and parametric keys.

use std::collections::HashMap;

const SNIPPET_BOUNDARIES: &[char] = &[':', '.', '#', '>', '+', '*', '/'];

/// Attempts to match `value` against a parametric snippet `key` containing `$`.
///
/// Returns `(rest_after_match, Some(matched_digits))` where `rest_after_match`
/// is the unmatched suffix after the digits (and any key suffix), ready for
/// the boundary check in [`parse_snippet`]. Returns `None` if the key pattern
/// does not match.
fn transform_parametric_snippet<'a>(
    value: &'a str,
    key: &'a str,
) -> Option<(&'a str, Option<&'a str>)> {
    let index = key.find('$').unwrap();
    let key_prefix = &key[..index];
    let key_suffix = &key[index + 1..];

    if !value.starts_with(key_prefix) {
        return None;
    }

    let rest = &value[key_prefix.len()..];
    let number_len = rest.bytes().take_while(|c| c.is_ascii_digit()).count();

    if number_len == 0 {
        return None;
    }

    let number = &rest[..number_len];
    let rest_after_number = &rest[number_len..];

    if !key_suffix.is_empty() && !rest_after_number.starts_with(key_suffix) {
        return None;
    }

    Some((&rest_after_number[key_suffix.len()..], Some(number)))
}

/// Expands a snippet alias to its fuller Glyf form.
///
/// Looks up the longest key in `snippets` that matches `value`. Two matching
/// strategies are used:
///
/// - **Plain keys** — the key must be a prefix of `value`, followed by end-of-string
///   or a boundary character (`:`, `.`, `#`, `>`, `+`, `*`, `/`).
/// - **Parametric keys** (key contains `$`) — the part of the key before `$` must
///   be a prefix of `value`, followed by one or more ASCII digits, optionally
///   followed by the part of the key after `$`, then a boundary or end-of-string.
///   The matched digits are substituted for every `$` in the expansion.
///
/// Longest-key-wins across both strategies. If no key matches, `value` is
/// returned unchanged. The returned string is a Glyf string, not HTML — it is
/// fed back into the parser for identifier and attribute extraction.
pub(super) fn parse_snippet(value: &str, snippets: &HashMap<String, String>) -> String {
    let longest_match = snippets
        .iter()
        .filter_map(|(key, expanded)| {
            let mut number: Option<&str> = None;
            let rest: &str;

            if key.contains('$') {
                (rest, number) = transform_parametric_snippet(value, key)?;
            } else {
                if !value.starts_with(key.as_str()) {
                    return None;
                }

                rest = &value[key.len()..];
            }

            if !rest.is_empty() && !rest.starts_with(SNIPPET_BOUNDARIES) {
                return None;
            }

            Some((key, expanded, number, rest))
        })
        .max_by_key(|(key, _, _, _)| key.len());

    match longest_match {
        Some((_, expanded, number, suffix)) => {
            let is_self_closing = expanded.ends_with('/');
            let base = expanded.strip_suffix('/').unwrap_or(expanded);

            let cleaned_expand = match number {
                Some(v) => base.replace('$', v), // String
                None => base.to_string(),
            };
            let cleaned_value = if is_self_closing {
                suffix.strip_suffix('/').unwrap_or(suffix)
            } else {
                suffix
            };

            format!(
                "{}{}{}",
                cleaned_expand,
                cleaned_value,
                if is_self_closing { "/" } else { "" }
            )
        }
        None => value.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn s(pairs: &[(&str, &str)]) -> HashMap<String, String> {
        pairs
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect()
    }

    // -------------------------------------------------------------------------
    // parse_snippet
    // -------------------------------------------------------------------------
    mod parse_snippet_tests {
        use super::*;

        #[test]
        fn unknown_tag_is_returned_unchanged() {
            let snips = s(&[]);
            assert_eq!(parse_snippet("div", &snips), "div");
            assert_eq!(parse_snippet("section", &snips), "section");
        }

        #[test]
        fn expands_anchor_shorthand() {
            let snips = s(&[("a", "a:href")]);
            assert_eq!(parse_snippet("a", &snips), "a:href");
        }

        #[test]
        fn expands_self_closing_tag() {
            let snips = s(&[("br", "br/"), ("hr", "hr/")]);
            assert_eq!(parse_snippet("br", &snips), "br/");
            assert_eq!(parse_snippet("hr", &snips), "hr/");
        }

        #[test]
        fn expands_img_shorthand() {
            let snips = s(&[("img", "img:src:alt")]);
            assert_eq!(parse_snippet("img", &snips), "img:src:alt");
        }

        #[test]
        fn expands_btn_alias() {
            let snips = s(&[("btn", "button")]);
            assert_eq!(parse_snippet("btn", &snips), "button");
        }

        #[test]
        fn expands_bq_alias() {
            let snips = s(&[("bq", "blockquote")]);
            assert_eq!(parse_snippet("bq", &snips), "blockquote");
        }

        #[test]
        fn longer_key_wins_over_shorter_prefix() {
            // both "a" and "a:blank" match — "a:blank" is longer
            let snips = s(&[
                ("a", "a:href"),
                (
                    "a:blank",
                    "a:href=${0}:target=_blank:rel=noopener noreferrer",
                ),
            ]);
            assert_eq!(
                parse_snippet("a:blank", &snips),
                "a:href=${0}:target=_blank:rel=noopener noreferrer"
            );
        }

        #[test]
        fn appends_extra_attributes_after_snippet_expansion() {
            // "a" expands to "a:href"; extra ":id=main" is appended
            let snips = s(&[("a", "a:href")]);
            assert_eq!(parse_snippet("a:id=main", &snips), "a:href:id=main");
        }

        #[test]
        fn no_expansion_when_rest_is_not_colon_prefixed() {
            // "input" snippet only matches when followed by nothing or ':'
            // "inputxyz" is NOT a valid snippet call (rest = "xyz", no ':')
            let snips = s(&[("input", "input/")]);
            assert_eq!(parse_snippet("inputxyz", &snips), "inputxyz");
        }

        #[test]
        fn user_typed_self_closing_matches_self_closing_snippet() {
            // input/ should not expand to input// via the "input → input/" snippet
            let snips = s(&[("input", "input/")]);
            assert_eq!(parse_snippet("input/", &snips), "input/");
        }

        #[test]
        fn parametric_snippet_substitutes_number() {
            let snips = s(&[("col$", "div.flex.flex-col.gap-$")]);
            assert_eq!(parse_snippet("col3", &snips), "div.flex.flex-col.gap-3");
        }

        #[test]
        fn parametric_snippet_multi_digit() {
            let snips = s(&[("col$", "div.flex.flex-col.gap-$")]);
            assert_eq!(parse_snippet("col12", &snips), "div.flex.flex-col.gap-12");
        }

        #[test]
        fn parametric_snippet_with_suffix() {
            let snips = s(&[("col$", "div.flex.flex-col.gap-$")]);
            assert_eq!(
                parse_snippet("col3.extra", &snips),
                "div.flex.flex-col.gap-3.extra"
            );
        }

        #[test]
        fn parametric_snippet_no_digit_no_match() {
            let snips = s(&[("col$", "div.flex.flex-col.gap-$")]);
            // no digits after "col" → no match → unchanged
            assert_eq!(parse_snippet("col", &snips), "col");
            assert_eq!(parse_snippet("col.foo", &snips), "col.foo");
        }

        #[test]
        fn parametric_snippet_with_suffix_in_key() {
            // key "p$px" → only matches "p3px", not "p3"
            let snips = s(&[("p$px", "div.p-$px")]);
            assert_eq!(parse_snippet("p3px", &snips), "div.p-3px");
            assert_eq!(parse_snippet("p3", &snips), "p3"); // no match
        }
    }
}
