use proc_macro2::Span;
use syn::{
    Ident, LitStr,
    ext::IdentExt,
    parse::{Parse, ParseStream},
    token::{At, Colon, Dollar, Dot, Minus, Pound},
};

/// The name of an HTML attribute in the template AST.
///
/// Attribute names support various syntaxes to accommodate different frameworks
/// and HTML conventions. See the `parse_attribute_name` function for detailed
/// syntax rules.
///
/// # Examples
///
/// Standard attributes: `class`, `id`, `href`
/// Data attributes: `data-value`, `data-user-id`
/// Framework-specific: `@click`, `:class`, `hx-get`, `hx-on:click`
#[derive(Debug, Clone)]
pub struct AttributeName {
    /// The parsed attribute name as a string literal.
    pub name: LitStr,
}

impl Parse for AttributeName {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        parse_attribute_name(input)
    }
}

/// Parses an HTML attribute name with practical restrictions for a templating library.
///
/// ## Restrictions
///
/// 1. **Starting character**: Must be ASCII letter, `@`, `_`, `$`, `:`, or `#`
/// 2. **Contains identifier**: Must contain at least one identifier (letters/digits/underscore)
/// 3. **No trailing punctuation**: Must end with an identifier, not `-`, `:`, or `.`
/// 4. **Allowed separators**: `-`, `:`, `.` between identifiers
///
/// ## Valid Examples
/// - `class`, `data-value`, `hx-get`
/// - `@click`, `@click.prevent`, `@keyup.page-down`
/// - `hx-on:click`, `xml:lang`
/// - `:class` (Vue shorthand)
/// - `#ref` (possible anchor ref)
/// - `_internal`, `$var`
///
/// ## Invalid Examples
/// - `@` (no identifier)
/// - `data-` (trailing punctuation)
/// - `123` (starts with number)
/// - `a&b` (disallowed punctuation)
fn parse_attribute_name(input: ParseStream<'_>) -> syn::Result<AttributeName> {
    // Must have at least one token
    if input.is_empty() {
        return Err(input.error("expected attribute name"));
    }

    let mut name = String::new();
    let mut first_span: Option<Span> = None;
    let mut last_span: Option<Span>;

    // Parse optional prefix: @, $, :, #
    if input.peek(At) {
        let token: At = input.parse()?;
        name.push('@');
        first_span = Some(token.span);
    } else if input.peek(Dollar) {
        let token: Dollar = input.parse()?;
        name.push('$');
        first_span = Some(token.span);
    } else if input.peek(Colon) {
        let token: Colon = input.parse()?;
        name.push(':');
        first_span = Some(token.span);
    } else if input.peek(Pound) {
        let token: Pound = input.parse()?;
        name.push('#');
        first_span = Some(token.span);
    }

    // Must have an identifier after optional prefix (or as first token)
    if !input.peek(Ident::peek_any) {
        if name.is_empty() {
            return Err(
                input.error("attribute name must start with a letter, '_', '@', '$', ':', or '#'")
            );
        } else {
            return Err(input.error(format!(
                "expected identifier after '{}'",
                name.chars().last().unwrap()
            )));
        }
    }

    // Parse first identifier
    let ident: Ident = input.call(Ident::parse_any)?;
    name.push_str(&ident.to_string());
    if first_span.is_none() {
        first_span = Some(ident.span());
    }
    last_span = Some(ident.span());

    // Continue parsing separator + identifier parts
    loop {
        if input.peek(Minus) {
            let _: Minus = input.parse()?;
            name.push('-');

            if !input.peek(Ident::peek_any) {
                return Err(input.error("expected identifier after '-' in attribute name"));
            }
            let ident: Ident = input.call(Ident::parse_any)?;
            name.push_str(&ident.to_string());
            last_span = Some(ident.span());
        } else if input.peek(Colon) {
            let _: Colon = input.parse()?;
            name.push(':');

            if !input.peek(Ident::peek_any) {
                return Err(input.error("expected identifier after ':' in attribute name"));
            }
            let ident: Ident = input.call(Ident::parse_any)?;
            name.push_str(&ident.to_string());
            last_span = Some(ident.span());
        } else if input.peek(Dot) {
            if input.peek2(Dot) {
                // Start of a spread pattern
                break;
            }

            let _: Dot = input.parse()?;
            name.push('.');

            if !input.peek(Ident::peek_any) {
                return Err(input.error("expected identifier after '.' in attribute name"));
            }
            let ident: Ident = input.call(Ident::parse_any)?;
            name.push_str(&ident.to_string());
            last_span = Some(ident.span());
        } else {
            break;
        }
    }

    let first = first_span.unwrap();
    let last = last_span.unwrap();
    let span = first.join(last).unwrap_or(first);

    Ok(AttributeName {
        name: LitStr::new(&name, span),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use proc_macro2::TokenTree;

    fn parse(input: &str) -> syn::Result<AttributeName> {
        syn::parse_str(input)
    }

    /// Helper to parse and return both the result and remaining tokens
    fn parse_partial(input: &str) -> syn::Result<(AttributeName, String)> {
        use std::str::FromStr;

        syn::parse::Parser::parse2(
            |stream: syn::parse::ParseStream| {
                let attr = parse_attribute_name(stream)?;
                let remaining = stream.cursor().token_stream().to_string();
                // Consume remaining to avoid "unexpected token" error
                while !stream.is_empty() {
                    let _: TokenTree = stream.parse()?;
                }
                Ok((attr, remaining))
            },
            proc_macro2::TokenStream::from_str(input).unwrap(),
        )
    }

    // === Valid attribute names ===

    #[test]
    fn simple_identifier() {
        assert_eq!(parse("class").unwrap().name.value(), "class");
        assert_eq!(parse("href").unwrap().name.value(), "href");
        assert_eq!(parse("id").unwrap().name.value(), "id");
        assert_eq!(parse("_private").unwrap().name.value(), "_private");
    }

    #[test]
    fn with_hyphens() {
        assert_eq!(parse("data-value").unwrap().name.value(), "data-value");
        assert_eq!(parse("hx-get").unwrap().name.value(), "hx-get");
        assert_eq!(parse("x-data").unwrap().name.value(), "x-data");
        assert_eq!(parse("aria-label").unwrap().name.value(), "aria-label");
    }

    #[test]
    fn with_colons() {
        assert_eq!(parse("hx-on:click").unwrap().name.value(), "hx-on:click");
        assert_eq!(parse("xml:lang").unwrap().name.value(), "xml:lang");
        assert_eq!(parse("xlink:href").unwrap().name.value(), "xlink:href");
    }

    #[test]
    fn with_dots() {
        assert_eq!(parse("x.data").unwrap().name.value(), "x.data");
        assert_eq!(parse("v.model").unwrap().name.value(), "v.model");
    }

    #[test]
    fn at_prefixed() {
        assert_eq!(parse("@click").unwrap().name.value(), "@click");
        assert_eq!(parse("@submit").unwrap().name.value(), "@submit");
        assert_eq!(parse("@input").unwrap().name.value(), "@input");
    }

    #[test]
    fn dollar_prefixed() {
        assert_eq!(parse("$var").unwrap().name.value(), "$var");
        assert_eq!(parse("$data").unwrap().name.value(), "$data");
    }

    #[test]
    fn colon_prefixed() {
        // Vue shorthand for v-bind
        assert_eq!(parse(":class").unwrap().name.value(), ":class");
        assert_eq!(parse(":style").unwrap().name.value(), ":style");
    }

    #[test]
    fn pound_prefixed() {
        assert_eq!(parse("#ref").unwrap().name.value(), "#ref");
        assert_eq!(parse("#anchor").unwrap().name.value(), "#anchor");
    }

    #[test]
    fn complex_combinations() {
        assert_eq!(parse("@click.shift").unwrap().name.value(), "@click.shift");
        assert_eq!(
            parse("@keyup.page-down").unwrap().name.value(),
            "@keyup.page-down"
        );
        assert_eq!(
            parse("x-on:click.prevent").unwrap().name.value(),
            "x-on:click.prevent"
        );
        assert_eq!(
            parse("hx-on:htmx:after-request").unwrap().name.value(),
            "hx-on:htmx:after-request"
        );
        assert_eq!(
            parse("@click.stop.prevent").unwrap().name.value(),
            "@click.stop.prevent"
        );
    }

    // === Error cases: empty input ===

    #[test]
    fn error_empty_input() {
        let result = parse("");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected attribute name")
        );
    }

    // === Error cases: invalid starting characters ===

    #[test]
    fn error_starts_with_hyphen() {
        let result = parse("-data");
        assert!(result.is_err());
    }

    #[test]
    fn error_starts_with_dot() {
        let result = parse(".class");
        assert!(result.is_err());
    }

    #[test]
    fn error_starts_with_equals() {
        let result = parse("=");
        assert!(result.is_err());
    }

    #[test]
    fn error_starts_with_greater_than() {
        let result = parse(">");
        assert!(result.is_err());
    }

    #[test]
    fn error_starts_with_slash() {
        let result = parse("/");
        assert!(result.is_err());
    }

    #[test]
    fn error_starts_with_less_than() {
        let result = parse("<");
        assert!(result.is_err());
    }

    // === Error cases: prefix without identifier ===

    #[test]
    fn error_at_only() {
        let result = parse("@");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after '@'")
        );
    }

    #[test]
    fn error_dollar_only() {
        let result = parse("$");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after '$'")
        );
    }

    #[test]
    fn error_colon_only() {
        let result = parse(":");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after ':'")
        );
    }

    #[test]
    fn error_pound_only() {
        let result = parse("#");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after '#'")
        );
    }

    // === Error cases: trailing punctuation ===

    #[test]
    fn error_trailing_hyphen() {
        let result = parse("data-");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after '-'")
        );
    }

    #[test]
    fn error_trailing_colon() {
        let result = parse("hx-on:");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after ':'")
        );
    }

    #[test]
    fn error_trailing_dot() {
        let result = parse("click.");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("expected identifier after '.'")
        );
    }

    // === Stops at disallowed characters ===

    #[test]
    fn stops_at_equals() {
        let (attr, remaining) = parse_partial("data=value").unwrap();
        assert_eq!(attr.name.value(), "data");
        assert!(remaining.starts_with("="), "remaining was: {}", remaining);
    }

    #[test]
    fn stops_at_greater_than() {
        let (attr, remaining) = parse_partial("class>").unwrap();
        assert_eq!(attr.name.value(), "class");
        assert!(remaining.starts_with(">"), "remaining was: {}", remaining);
    }

    #[test]
    fn stops_at_slash() {
        let (attr, remaining) = parse_partial("disabled/").unwrap();
        assert_eq!(attr.name.value(), "disabled");
        assert!(remaining.starts_with("/"), "remaining was: {}", remaining);
    }

    #[test]
    fn stops_at_disallowed_punct() {
        // Stops at punctuation that's not -, :, or .
        let (attr, remaining) = parse_partial("data&more").unwrap();
        assert_eq!(attr.name.value(), "data");
        assert!(remaining.starts_with("&"), "remaining was: {}", remaining);
    }

    // === Whitespace handling ===

    #[test]
    fn spaced_hyphen() {
        // "hx - get" has spaces in source, but Rust's tokenizer strips whitespace.
        // Token stream contains: [Ident(hx), Punct(-), Ident(get)]
        // Parser consumes all three and concatenates them.
        let (attr, remaining) = parse_partial("hx - get").unwrap();
        assert_eq!(attr.name.value(), "hx-get");
        assert!(remaining.is_empty());
    }
}
