//! CSS selector tokenizer.
//!
//! Token shapes follow the CSS 2.1 lexical grammar. The lexer tries each kind
//! in order at the current offset and takes the first match. Offsets count
//! UTF-16 code units so error positions line up with the JavaScript contract.

/// Token kinds produced by the lexer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenKind {
    /// Run of whitespace `[ \t\r\n\f]+`.
    Ws,
    /// `#name`, with the leading `#` kept in the text.
    Hash,
    /// An identifier `[-]?nmstart nmchar*`.
    Ident,
    /// A single-quoted string including the quotes.
    Str1,
    /// A double-quoted string including the quotes.
    Str2,
    /// A single literal char such as `[`, `=`, `>`.
    Char(char),
}

/// A lexed token with its source text and start offset (in UTF-16 units).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
    /// The token kind.
    pub kind: TokenKind,
    /// The matched source text.
    pub text: String,
    /// Start offset in UTF-16 code units.
    pub offset: usize,
}

/// Result of lexing: the tokens and the offset reached.
///
/// When `offset` is less than the input length, tokenization stopped early.
pub struct LexResult {
    /// The tokens in source order.
    pub tokens: Vec<Token>,
    /// UTF-16 offset reached. Equals the input length on full success.
    pub offset: usize,
}

const SINGLE_CHARS: &[char] = &[
    '*', '.', ',', '[', ']', '=', '>', '|', '+', '~', '^', '$', ':', '(', ')',
];

// The source token regexes carry the `i` flag, so `[_a-z]` matches uppercase
// too. Case is preserved in the token text and only folded later by normalize.
fn is_nmstart(c: char) -> bool {
    c == '_' || c.is_ascii_alphabetic() || !c.is_ascii()
}

fn is_nmchar(c: char) -> bool {
    c == '_' || c == '-' || c.is_ascii_alphanumeric() || !c.is_ascii()
}

/// Tokenize `input` into CSS selector tokens.
///
/// The lexer is case-insensitive for `ident`, `name`, and `hash`, matching the
/// `i` flag on the source regexes. Original case is kept in the token text.
pub fn lex(input: &str) -> LexResult {
    let chars: Vec<char> = input.chars().collect();
    let mut tokens = Vec::new();
    // `pos` is a char index; `u16_offset` tracks the matching UTF-16 offset.
    let mut pos = 0;
    let mut u16_offset = 0;

    while pos < chars.len() {
        let start_u16 = u16_offset;
        let matched = match_token(&chars, pos);
        match matched {
            Some((kind, len)) => {
                let text: String = chars[pos..pos + len].iter().collect();
                let units: usize = text.chars().map(|c| c.len_utf16()).sum();
                tokens.push(Token {
                    kind,
                    text,
                    offset: start_u16,
                });
                pos += len;
                u16_offset += units;
            }
            None => break,
        }
    }

    LexResult {
        tokens,
        offset: u16_offset,
    }
}

/// Match one token starting at `chars[pos]`. Returns `(kind, char_len)`.
fn match_token(chars: &[char], pos: usize) -> Option<(TokenKind, usize)> {
    let c = chars[pos];

    // ws: [ \t\r\n\f]+
    if matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0C}') {
        let mut len = 1;
        while pos + len < chars.len()
            && matches!(chars[pos + len], ' ' | '\t' | '\r' | '\n' | '\u{0C}')
        {
            len += 1;
        }
        return Some((TokenKind::Ws, len));
    }

    // hash: # name   (name = nmchar+)
    if c == '#' {
        if let Some(len) = match_name(chars, pos + 1) {
            return Some((TokenKind::Hash, 1 + len));
        }
    }

    // str1 / str2
    if c == '\'' {
        if let Some(len) = match_string(chars, pos, '\'') {
            return Some((TokenKind::Str1, len));
        }
    }
    if c == '"' {
        if let Some(len) = match_string(chars, pos, '"') {
            return Some((TokenKind::Str2, len));
        }
    }

    // ident: [-]? nmstart nmchar*
    if let Some(len) = match_ident(chars, pos) {
        return Some((TokenKind::Ident, len));
    }

    // single-char literals
    if SINGLE_CHARS.contains(&c) {
        return Some((TokenKind::Char(c), 1));
    }

    None
}

/// Match one escape sequence at `chars[pos]`. Returns char length consumed.
///
/// `unicode` is `\` then 1 to 6 hex digits and an optional trailing whitespace
/// char. `escape` is `\` then one non-newline non-hex char.
fn match_escape(chars: &[char], pos: usize) -> Option<usize> {
    if chars.get(pos) != Some(&'\\') {
        return None;
    }
    let next = *chars.get(pos + 1)?;
    if next.is_ascii_hexdigit() {
        let mut len = 1; // the backslash
        let mut hex = 0;
        while pos + len < chars.len() && hex < 6 && chars[pos + len].is_ascii_hexdigit() {
            len += 1;
            hex += 1;
        }
        // optional trailing whitespace
        if pos + len < chars.len() {
            let w = chars[pos + len];
            if w == '\r' && chars.get(pos + len + 1) == Some(&'\n') {
                len += 2;
            } else if matches!(w, ' ' | '\n' | '\r' | '\t' | '\u{0C}') {
                len += 1;
            }
        }
        return Some(len);
    }
    if next != '\n' && next != '\r' && next != '\u{0C}' {
        return Some(2);
    }
    None
}

/// Match `name = nmchar+`. Returns char length, or `None` if empty.
fn match_name(chars: &[char], pos: usize) -> Option<usize> {
    let mut len = 0;
    loop {
        if let Some(c) = chars.get(pos + len) {
            if let Some(esc) = match_escape(chars, pos + len) {
                len += esc;
                continue;
            }
            if is_nmchar(*c) {
                len += 1;
                continue;
            }
        }
        break;
    }
    if len == 0 {
        None
    } else {
        Some(len)
    }
}

/// Match `ident = [-]? nmstart nmchar*`. Returns char length.
fn match_ident(chars: &[char], pos: usize) -> Option<usize> {
    let mut len = 0;
    if chars.get(pos) == Some(&'-') {
        len += 1;
    }
    // nmstart
    let start_ok = if let Some(esc) = match_escape(chars, pos + len) {
        len += esc;
        true
    } else if let Some(c) = chars.get(pos + len) {
        if is_nmstart(*c) {
            len += 1;
            true
        } else {
            false
        }
    } else {
        false
    };
    if !start_ok {
        return None;
    }
    // nmchar*
    loop {
        if let Some(esc) = match_escape(chars, pos + len) {
            len += esc;
            continue;
        }
        if let Some(c) = chars.get(pos + len) {
            if is_nmchar(*c) {
                len += 1;
                continue;
            }
        }
        break;
    }
    Some(len)
}

/// Match a quoted string. `quote` is `'` or `"`. Returns char length including
/// both quotes, or `None` if unterminated.
fn match_string(chars: &[char], pos: usize, quote: char) -> Option<usize> {
    let mut len = 1; // opening quote
    loop {
        let c = *chars.get(pos + len)?;
        if c == quote {
            return Some(len + 1);
        }
        if matches!(c, '\n' | '\r' | '\u{0C}') {
            return None;
        }
        if c == '\\' {
            // \nl, unicode, or escape
            let next = chars.get(pos + len + 1);
            match next {
                Some('\n') | Some('\u{0C}') => {
                    len += 2;
                    continue;
                }
                Some('\r') => {
                    if chars.get(pos + len + 2) == Some(&'\n') {
                        len += 3;
                    } else {
                        len += 2;
                    }
                    continue;
                }
                _ => {
                    if let Some(esc) = match_escape(chars, pos + len) {
                        len += esc;
                        continue;
                    }
                    // lone backslash before end-of-input or invalid; consume one
                    len += 1;
                    continue;
                }
            }
        }
        len += 1;
    }
}
