from __future__ import annotations

import typing as t
from enum import IntEnum, auto

from sqlglot.errors import TokenError

# dict lookup is faster than .upper() and .isdigit()
_CHAR_UPPER: dict[str, str] = {chr(i): chr(i).upper() for i in range(97, 123)}
_DIGIT_CHARS: frozenset[str] = frozenset("0123456789")


class TokenType(IntEnum):
    L_PAREN = auto()
    R_PAREN = auto()
    L_BRACKET = auto()
    R_BRACKET = auto()
    L_BRACE = auto()
    R_BRACE = auto()
    COMMA = auto()
    DOT = auto()
    DASH = auto()
    PLUS = auto()
    COLON = auto()
    DOTCOLON = auto()
    DOTCARET = auto()
    DCOLON = auto()
    DCOLONDOLLAR = auto()
    DCOLONPERCENT = auto()
    DCOLONQMARK = auto()
    DQMARK = auto()
    SEMICOLON = auto()
    STAR = auto()
    BACKSLASH = auto()
    SLASH = auto()
    LT = auto()
    LTE = auto()
    GT = auto()
    GTE = auto()
    NOT = auto()
    EQ = auto()
    NEQ = auto()
    NULLSAFE_EQ = auto()
    COLON_EQ = auto()
    COLON_GT = auto()
    NCOLON_GT = auto()
    AND = auto()
    OR = auto()
    AMP = auto()
    DPIPE = auto()
    PIPE_GT = auto()
    PIPE = auto()
    PIPE_SLASH = auto()
    DPIPE_SLASH = auto()
    CARET = auto()
    CARET_AT = auto()
    TILDE = auto()
    ARROW = auto()
    DARROW = auto()
    FARROW = auto()
    HASH = auto()
    HASH_ARROW = auto()
    DHASH_ARROW = auto()
    LR_ARROW = auto()
    DAT = auto()
    LT_AT = auto()
    AT_GT = auto()
    DOLLAR = auto()
    PARAMETER = auto()
    SESSION = auto()
    SESSION_PARAMETER = auto()
    SESSION_USER = auto()
    DAMP = auto()
    AMP_LT = auto()
    AMP_GT = auto()
    ADJACENT = auto()
    XOR = auto()
    DSTAR = auto()
    QMARK_AMP = auto()
    QMARK_PIPE = auto()
    HASH_DASH = auto()
    EXCLAMATION = auto()

    URI_START = auto()

    BLOCK_START = auto()
    BLOCK_END = auto()

    SPACE = auto()
    BREAK = auto()

    STRING = auto()
    NUMBER = auto()
    IDENTIFIER = auto()
    DATABASE = auto()
    COLUMN = auto()
    COLUMN_DEF = auto()
    SCHEMA = auto()
    TABLE = auto()
    WAREHOUSE = auto()
    STAGE = auto()
    STREAM = auto()
    STREAMLIT = auto()
    VAR = auto()
    BIT_STRING = auto()
    HEX_STRING = auto()
    BYTE_STRING = auto()
    NATIONAL_STRING = auto()
    RAW_STRING = auto()
    HEREDOC_STRING = auto()
    UNICODE_STRING = auto()

    # types
    BIT = auto()
    BOOLEAN = auto()
    TINYINT = auto()
    UTINYINT = auto()
    SMALLINT = auto()
    USMALLINT = auto()
    MEDIUMINT = auto()
    UMEDIUMINT = auto()
    INT = auto()
    UINT = auto()
    BIGINT = auto()
    UBIGINT = auto()
    BIGNUM = auto()
    INT128 = auto()
    UINT128 = auto()
    INT256 = auto()
    UINT256 = auto()
    FLOAT = auto()
    DOUBLE = auto()
    UDOUBLE = auto()
    DECIMAL = auto()
    DECIMAL32 = auto()
    DECIMAL64 = auto()
    DECIMAL128 = auto()
    DECIMAL256 = auto()
    DECFLOAT = auto()
    UDECIMAL = auto()
    BIGDECIMAL = auto()
    CHAR = auto()
    NCHAR = auto()
    VARCHAR = auto()
    NVARCHAR = auto()
    BPCHAR = auto()
    TEXT = auto()
    MEDIUMTEXT = auto()
    LONGTEXT = auto()
    BLOB = auto()
    MEDIUMBLOB = auto()
    LONGBLOB = auto()
    TINYBLOB = auto()
    TINYTEXT = auto()
    NAME = auto()
    BINARY = auto()
    VARBINARY = auto()
    JSON = auto()
    JSONB = auto()
    TIME = auto()
    TIMETZ = auto()
    TIME_NS = auto()
    TIMESTAMP = auto()
    TIMESTAMPTZ = auto()
    TIMESTAMPLTZ = auto()
    TIMESTAMPNTZ = auto()
    TIMESTAMP_S = auto()
    TIMESTAMP_MS = auto()
    TIMESTAMP_NS = auto()
    DATETIME = auto()
    DATETIME2 = auto()
    DATETIME64 = auto()
    SMALLDATETIME = auto()
    DATE = auto()
    DATE32 = auto()
    INT4RANGE = auto()
    INT4MULTIRANGE = auto()
    INT8RANGE = auto()
    INT8MULTIRANGE = auto()
    NUMRANGE = auto()
    NUMMULTIRANGE = auto()
    TSRANGE = auto()
    TSMULTIRANGE = auto()
    TSTZRANGE = auto()
    TSTZMULTIRANGE = auto()
    DATERANGE = auto()
    DATEMULTIRANGE = auto()
    UUID = auto()
    GEOGRAPHY = auto()
    GEOGRAPHYPOINT = auto()
    NULLABLE = auto()
    GEOMETRY = auto()
    POINT = auto()
    RING = auto()
    LINESTRING = auto()
    LOCALTIME = auto()
    LOCALTIMESTAMP = auto()
    SYSTIMESTAMP = auto()
    MULTILINESTRING = auto()
    POLYGON = auto()
    MULTIPOLYGON = auto()
    HLLSKETCH = auto()
    HSTORE = auto()
    SUPER = auto()
    SERIAL = auto()
    SMALLSERIAL = auto()
    BIGSERIAL = auto()
    XML = auto()
    YEAR = auto()
    USERDEFINED = auto()
    MONEY = auto()
    SMALLMONEY = auto()
    ROWVERSION = auto()
    IMAGE = auto()
    VARIANT = auto()
    OBJECT = auto()
    INET = auto()
    IPADDRESS = auto()
    IPPREFIX = auto()
    IPV4 = auto()
    IPV6 = auto()
    ENUM = auto()
    ENUM8 = auto()
    ENUM16 = auto()
    FIXEDSTRING = auto()
    LOWCARDINALITY = auto()
    NESTED = auto()
    AGGREGATEFUNCTION = auto()
    SIMPLEAGGREGATEFUNCTION = auto()
    TDIGEST = auto()
    UNKNOWN = auto()
    VECTOR = auto()
    DYNAMIC = auto()
    VOID = auto()

    # keywords
    ALIAS = auto()
    ALTER = auto()
    ALL = auto()
    ANTI = auto()
    ANY = auto()
    APPLY = auto()
    ARRAY = auto()
    ASC = auto()
    ASOF = auto()
    ATTACH = auto()
    AUTO_INCREMENT = auto()
    BEGIN = auto()
    BETWEEN = auto()
    BULK_COLLECT_INTO = auto()
    CACHE = auto()
    CASE = auto()
    CHARACTER_SET = auto()
    CLUSTER_BY = auto()
    COLLATE = auto()
    COMMAND = auto()
    COMMENT = auto()
    COMMIT = auto()
    CONNECT_BY = auto()
    CONSTRAINT = auto()
    COPY = auto()
    CREATE = auto()
    CROSS = auto()
    CUBE = auto()
    CURRENT_DATE = auto()
    CURRENT_DATETIME = auto()
    CURRENT_SCHEMA = auto()
    CURRENT_TIME = auto()
    CURRENT_TIMESTAMP = auto()
    CURRENT_USER = auto()
    CURRENT_ROLE = auto()
    CURRENT_CATALOG = auto()
    DECLARE = auto()
    DEFAULT = auto()
    DELETE = auto()
    DESC = auto()
    DESCRIBE = auto()
    DETACH = auto()
    DICTIONARY = auto()
    DISTINCT = auto()
    DISTRIBUTE_BY = auto()
    DIV = auto()
    DROP = auto()
    ELSE = auto()
    END = auto()
    ESCAPE = auto()
    EXCEPT = auto()
    EXECUTE = auto()
    EXISTS = auto()
    FALSE = auto()
    FETCH = auto()
    FILE = auto()
    FILE_FORMAT = auto()
    FILTER = auto()
    FINAL = auto()
    FIRST = auto()
    FOR = auto()
    FORCE = auto()
    FOREIGN_KEY = auto()
    FORMAT = auto()
    FROM = auto()
    FULL = auto()
    FUNCTION = auto()
    GET = auto()
    GLOB = auto()
    GLOBAL = auto()
    GRANT = auto()
    GROUP_BY = auto()
    GROUPING_SETS = auto()
    HAVING = auto()
    HINT = auto()
    IGNORE = auto()
    ILIKE = auto()
    IN = auto()
    INDEX = auto()
    INDEXED_BY = auto()
    INNER = auto()
    INSERT = auto()
    INSTALL = auto()
    INTEGRATION = auto()
    INTERSECT = auto()
    INTERVAL = auto()
    INTO = auto()
    INTRODUCER = auto()
    IRLIKE = auto()
    IS = auto()
    ISNULL = auto()
    JOIN = auto()
    JOIN_MARKER = auto()
    KEEP = auto()
    KEY = auto()
    KILL = auto()
    LANGUAGE = auto()
    LATERAL = auto()
    LEFT = auto()
    LIKE = auto()
    LIMIT = auto()
    LIST = auto()
    LOAD = auto()
    LOCK = auto()
    MAP = auto()
    MATCH = auto()
    MATCH_CONDITION = auto()
    MATCH_RECOGNIZE = auto()
    MEMBER_OF = auto()
    MERGE = auto()
    MOD = auto()
    MODEL = auto()
    NATURAL = auto()
    NEXT = auto()
    NOTHING = auto()
    NOTNULL = auto()
    NULL = auto()
    OBJECT_IDENTIFIER = auto()
    OFFSET = auto()
    ON = auto()
    ONLY = auto()
    OPERATOR = auto()
    ORDER_BY = auto()
    ORDER_SIBLINGS_BY = auto()
    ORDERED = auto()
    ORDINALITY = auto()
    OUT = auto()
    INOUT = auto()
    OUTER = auto()
    OVER = auto()
    OVERLAPS = auto()
    OVERWRITE = auto()
    PACKAGE = auto()
    PARTITION = auto()
    PARTITION_BY = auto()
    PERCENT = auto()
    PIVOT = auto()
    PLACEHOLDER = auto()
    POLICY = auto()
    POOL = auto()
    POSITIONAL = auto()
    PRAGMA = auto()
    PREWHERE = auto()
    PRIMARY_KEY = auto()
    PROCEDURE = auto()
    PROPERTIES = auto()
    PSEUDO_TYPE = auto()
    PUT = auto()
    QUALIFY = auto()
    QUOTE = auto()
    QDCOLON = auto()
    RANGE = auto()
    RECURSIVE = auto()
    REFRESH = auto()
    RENAME = auto()
    REPLACE = auto()
    RETURNING = auto()
    REVOKE = auto()
    REFERENCES = auto()
    RIGHT = auto()
    RLIKE = auto()
    ROLE = auto()
    ROLLBACK = auto()
    ROLLUP = auto()
    ROW = auto()
    ROWS = auto()
    RULE = auto()
    SELECT = auto()
    SEMI = auto()
    SEPARATOR = auto()
    SEQUENCE = auto()
    SERDE_PROPERTIES = auto()
    SET = auto()
    SETTINGS = auto()
    SHOW = auto()
    SIMILAR_TO = auto()
    SOME = auto()
    SORT_BY = auto()
    SOUNDS_LIKE = auto()
    SQL_SECURITY = auto()
    START_WITH = auto()
    STORAGE_INTEGRATION = auto()
    STRAIGHT_JOIN = auto()
    STRUCT = auto()
    SUMMARIZE = auto()
    TABLE_SAMPLE = auto()
    TAG = auto()
    TEMPORARY = auto()
    TOP = auto()
    THEN = auto()
    TRUE = auto()
    TRUNCATE = auto()
    TRIGGER = auto()
    UNCACHE = auto()
    UNION = auto()
    UNNEST = auto()
    UNPIVOT = auto()
    UPDATE = auto()
    USE = auto()
    USING = auto()
    VALUES = auto()
    VARIADIC = auto()
    VIEW = auto()
    SEMANTIC_VIEW = auto()
    VOLATILE = auto()
    VOLUME = auto()
    WHEN = auto()
    WHERE = auto()
    WINDOW = auto()
    WITH = auto()
    UNIQUE = auto()
    UTC_DATE = auto()
    UTC_TIME = auto()
    UTC_TIMESTAMP = auto()
    VERSION_SNAPSHOT = auto()
    TIMESTAMP_SNAPSHOT = auto()
    OPTION = auto()
    SINK = auto()
    SOURCE = auto()
    ANALYZE = auto()
    NAMESPACE = auto()
    EXPORT = auto()

    # sentinels
    HIVE_TOKEN_STREAM = auto()
    SENTINEL = auto()

    def __str__(self) -> str:
        return f"TokenType.{self.name}"


class Token:
    # mypyc doesn't expose slots
    _attrs: t.ClassVar[tuple[str, ...]] = (
        "token_type",
        "text",
        "line",
        "col",
        "start",
        "end",
        "comments",
    )
    __slots__ = _attrs

    @classmethod
    def number(cls, number: int) -> Token:
        """Returns a NUMBER token with `number` as its text."""
        return cls(TokenType.NUMBER, str(number))

    @classmethod
    def string(cls, string: str) -> Token:
        """Returns a STRING token with `string` as its text."""
        return cls(TokenType.STRING, string)

    @classmethod
    def identifier(cls, identifier: str) -> Token:
        """Returns an IDENTIFIER token with `identifier` as its text."""
        return cls(TokenType.IDENTIFIER, identifier)

    @classmethod
    def var(cls, var: str) -> Token:
        """Returns an VAR token with `var` as its text."""
        return cls(TokenType.VAR, var)

    def __init__(
        self,
        token_type: TokenType,
        text: str,
        line: int = 1,
        col: int = 1,
        start: int = 0,
        end: int = 0,
        comments: list[str] | None = None,
    ) -> None:
        self.token_type = token_type
        self.text = text
        self.line = line
        self.col = col
        self.start = start
        self.end = end
        self.comments = [] if comments is None else comments

    def __bool__(self) -> bool:
        return self.token_type != TokenType.SENTINEL

    def __repr__(self) -> str:
        attributes = ", ".join(
            f"{k}: TokenType.{self.token_type.name}"
            if k == "token_type"
            else f"{k}: {getattr(self, k)}"
            for k in self._attrs
        )
        return f"<Token {attributes}>"


class TokenizerCore:
    __slots__ = (
        "sql",
        "size",
        "tokens",
        "_start",
        "_current",
        "_line",
        "_col",
        "_comments",
        "_char",
        "_end",
        "_peek",
        "_prev_token_line",
        "single_tokens",
        "keywords",
        "quotes",
        "format_strings",
        "identifiers",
        "comments",
        "string_escapes",
        "byte_string_escapes",
        "identifier_escapes",
        "escape_follow_chars",
        "commands",
        "command_prefix_tokens",
        "nested_comments",
        "hint_start",
        "tokens_preceding_hint",
        "has_bit_strings",
        "has_hex_strings",
        "numeric_literals",
        "var_single_tokens",
        "string_escapes_allowed_in_raw_strings",
        "heredoc_tag_is_identifier",
        "heredoc_string_alternative",
        "keyword_trie",
        "numbers_can_be_underscore_separated",
        "numbers_can_have_decimals",
        "identifiers_can_start_with_digit",
        "unescaped_sequences",
    )

    def __init__(
        self,
        single_tokens: dict[str, TokenType],
        keywords: dict[str, TokenType],
        quotes: dict[str, str],
        format_strings: dict[str, tuple[str, TokenType]],
        identifiers: dict[str, str],
        comments: dict[str, str | None],
        string_escapes: set[str],
        byte_string_escapes: set[str],
        identifier_escapes: set[str],
        escape_follow_chars: set[str],
        commands: set[TokenType],
        command_prefix_tokens: set[TokenType],
        nested_comments: bool,
        hint_start: str,
        tokens_preceding_hint: set[TokenType],
        has_bit_strings: bool,
        has_hex_strings: bool,
        numeric_literals: dict[str, str],
        var_single_tokens: set[str],
        string_escapes_allowed_in_raw_strings: bool,
        heredoc_tag_is_identifier: bool,
        heredoc_string_alternative: TokenType,
        keyword_trie: dict,
        numbers_can_be_underscore_separated: bool,
        numbers_can_have_decimals: bool,
        identifiers_can_start_with_digit: bool,
        unescaped_sequences: dict[str, str],
    ) -> None:
        self.single_tokens = single_tokens
        self.keywords = keywords
        self.quotes = quotes
        self.format_strings = format_strings
        self.identifiers = identifiers
        self.comments = comments
        self.string_escapes = string_escapes
        self.byte_string_escapes = byte_string_escapes
        self.identifier_escapes = identifier_escapes
        self.escape_follow_chars = escape_follow_chars
        self.commands = commands
        self.command_prefix_tokens = command_prefix_tokens
        self.nested_comments = nested_comments
        self.hint_start = hint_start
        self.tokens_preceding_hint = tokens_preceding_hint
        self.has_bit_strings = has_bit_strings
        self.has_hex_strings = has_hex_strings
        self.numeric_literals = numeric_literals
        self.var_single_tokens = var_single_tokens
        self.string_escapes_allowed_in_raw_strings = string_escapes_allowed_in_raw_strings
        self.heredoc_tag_is_identifier = heredoc_tag_is_identifier
        self.heredoc_string_alternative = heredoc_string_alternative
        self.keyword_trie = keyword_trie
        self.numbers_can_be_underscore_separated = numbers_can_be_underscore_separated
        self.numbers_can_have_decimals = numbers_can_have_decimals
        self.identifiers_can_start_with_digit = identifiers_can_start_with_digit
        self.unescaped_sequences = unescaped_sequences
        self.sql = ""
        self.size = 0
        self.tokens: list[Token] = []
        self._start = 0
        self._current = 0
        self._line = 1
        self._col = 0
        self._comments: list[str] = []
        self._char = ""
        self._end = False
        self._peek = ""
        self._prev_token_line = -1

    def reset(self) -> None:
        self.sql = ""
        self.size = 0
        self.tokens = []
        self._start = 0
        self._current = 0
        self._line = 1
        self._col = 0
        self._comments = []
        self._char = ""
        self._end = False
        self._peek = ""
        self._prev_token_line = -1

    def tokenize(self, sql: str) -> list[Token]:
        """Returns a list of tokens corresponding to the SQL string `sql`."""
        self.reset()
        self.sql = sql
        self.size = len(sql)

        try:
            self._scan()
        except Exception as e:
            start = max(self._current - 50, 0)
            end = min(self._current + 50, self.size - 1)
            context = self.sql[start:end]
            raise TokenError(f"Error tokenizing '{context}'") from e

        return self.tokens

    def _scan(self, check_semicolon: bool = False) -> None:
        identifiers = self.identifiers
        digit_chars = _DIGIT_CHARS

        while self.size and not self._end:
            current = self._current

            # Skip spaces here rather than iteratively calling advance() for performance reasons
            while current < self.size:
                char = self.sql[current]

                if char == " " or char == "\t":
                    current += 1
                else:
                    break

            offset = current - self._current if current > self._current else 1

            self._start = current
            self._advance(offset)

            if not self._char.isspace():
                if self._char in digit_chars:
                    self._scan_number()
                elif self._char in identifiers:
                    self._scan_identifier(identifiers[self._char])
                else:
                    self._scan_keywords()

            if check_semicolon and self._peek == ";":
                break

        if self.tokens and self._comments:
            self.tokens[-1].comments.extend(self._comments)

    def _chars(self, size: int) -> str:
        if size == 1:
            return self._char

        start = self._current - 1
        end = start + size

        return self.sql[start:end] if end <= self.size else ""

    def _advance(self, i: int = 1, alnum: bool = False) -> None:
        char = self._char

        if char == "\n" or char == "\r":
            # Ensures we don't count an extra line if we get a \r\n line break sequence
            if not (char == "\r" and self._peek == "\n"):
                self._col = i
                self._line += 1
        else:
            self._col += i

        self._current += i
        sql = self.sql
        size = self.size
        self._end = self._current >= size
        self._char = sql[self._current - 1]
        self._peek = "" if self._end else sql[self._current]

        if alnum and self._char.isalnum():
            # Cache to local variables instead of attributes for better performance
            _col = self._col
            _current = self._current
            _end = self._end
            _peek = self._peek

            while _peek.isalnum():
                _col += 1
                _current += 1
                _end = _current >= size
                _peek = "" if _end else sql[_current]

            self._col = _col
            self._current = _current
            self._end = _end
            self._peek = _peek
            self._char = sql[_current - 1]

    @property
    def _text(self) -> str:
        return self.sql[self._start : self._current]

    def _add(self, token_type: TokenType, text: str | None = None) -> None:
        self._prev_token_line = self._line

        if self._comments and token_type == TokenType.SEMICOLON and self.tokens:
            self.tokens[-1].comments.extend(self._comments)
            self._comments = []

        if text is None:
            text = self.sql[self._start : self._current]

        self.tokens.append(
            Token(
                token_type,
                text=text,
                line=self._line,
                col=self._col,
                start=self._start,
                end=self._current - 1,
                comments=self._comments,
            )
        )
        self._comments = []

        # If we have either a semicolon or a begin token before the command's token, we'll parse
        # whatever follows the command's token as a string
        if (
            token_type in self.commands
            and self._peek != ";"
            and (len(self.tokens) == 1 or self.tokens[-2].token_type in self.command_prefix_tokens)
        ):
            start = self._current
            tokens = len(self.tokens)
            self._scan(check_semicolon=True)
            self.tokens = self.tokens[:tokens]
            text = self.sql[start : self._current].strip()
            if text:
                self._add(TokenType.STRING, text)

    def _scan_keywords(self) -> None:
        sql = self.sql
        sql_size = self.size
        single_tokens = self.single_tokens
        char_upper = _CHAR_UPPER
        size = 0
        word = None
        chars = self._char
        char = chars
        prev_space = False
        skip = False
        trie = self.keyword_trie
        single_token = char in single_tokens

        while chars:
            if not skip:
                sub = trie.get(char_upper.get(char, char))
                if sub is None:
                    break
                trie = sub
                if 0 in trie:
                    word = chars

            end = self._current + size
            size += 1

            if end < sql_size:
                char = sql[end]
                single_token = single_token or char in single_tokens
                is_space = char.isspace()

                if not is_space or not prev_space:
                    if is_space:
                        char = " "
                    chars += char
                    prev_space = is_space
                    skip = False
                else:
                    skip = True
            else:
                char = ""
                break

        if word:
            if self._scan_string(word):
                return
            if self._scan_comment(word):
                return
            if prev_space or single_token or not char:
                self._advance(size - 1)
                word = word.upper()
                self._add(self.keywords[word], text=word)
                return

        if self._char in single_tokens:
            self._add(single_tokens[self._char], text=self._char)
            return

        self._scan_var()

    def _scan_comment(self, comment_start: str) -> bool:
        if comment_start not in self.comments:
            return False

        comment_start_line = self._line
        comment_start_size = len(comment_start)
        comment_end = self.comments[comment_start]

        if comment_end:
            # Skip the comment's start delimiter
            self._advance(comment_start_size)

            comment_count = 1
            comment_end_size = len(comment_end)
            nested_comments = self.nested_comments

            while not self._end:
                if self._chars(comment_end_size) == comment_end:
                    comment_count -= 1
                    if not comment_count:
                        break

                self._advance(alnum=True)

                # Nested comments are allowed by some dialects, e.g. databricks, duckdb, postgres
                if (
                    nested_comments
                    and not self._end
                    and self._chars(comment_end_size) == comment_start
                ):
                    self._advance(comment_start_size)
                    comment_count += 1

            self._comments.append(self._text[comment_start_size : -comment_end_size + 1])
            self._advance(comment_end_size - 1)
        else:
            _peek = self._peek
            while not self._end and _peek != "\n" and _peek != "\r":
                self._advance(alnum=True)
                _peek = self._peek
            self._comments.append(self._text[comment_start_size:])

        if (
            comment_start == self.hint_start
            and self.tokens
            and self.tokens[-1].token_type in self.tokens_preceding_hint
        ):
            self._add(TokenType.HINT)

        # Leading comment is attached to the succeeding token, whilst trailing comment to the preceding.
        # Multiple consecutive comments are preserved by appending them to the current comments list.
        if comment_start_line == self._prev_token_line:
            self.tokens[-1].comments.extend(self._comments)
            self._comments = []
            self._prev_token_line = self._line

        return True

    def _scan_number(self) -> None:
        if self._char == "0":
            peek = _CHAR_UPPER.get(self._peek, self._peek)
            if peek == "B":
                return self._scan_bits() if self.has_bit_strings else self._add(TokenType.NUMBER)
            elif peek == "X":
                return self._scan_hex() if self.has_hex_strings else self._add(TokenType.NUMBER)

        decimal = False
        scientific = 0
        numbers_can_be_underscore_separated = self.numbers_can_be_underscore_separated
        single_tokens = self.single_tokens
        keywords = self.keywords
        numeric_literals = self.numeric_literals
        identifiers_can_start_with_digit = self.identifiers_can_start_with_digit

        is_underscore_separated: bool = False
        number_text: str = ""
        numeric_literal: str = ""
        numeric_type: TokenType | None = None

        while True:
            if self._peek in _DIGIT_CHARS:
                # Batch consecutive digits: scan ahead to find how many
                sql = self.sql
                end = self._current + 1
                size = self.size
                while end < size and sql[end] in _DIGIT_CHARS:
                    end += 1
                self._advance(end - self._current)
            elif self._peek == "." and not decimal:
                if (
                    self.tokens and self.tokens[-1].token_type == TokenType.PARAMETER
                ) or not self.numbers_can_have_decimals:
                    break
                decimal = True
                self._advance()
            elif self._peek in ("-", "+") and scientific == 1:
                # Only consume +/- if followed by a digit
                if self._current + 1 < self.size and self.sql[self._current + 1] in _DIGIT_CHARS:
                    scientific += 1
                    self._advance()
                else:
                    break
            elif _CHAR_UPPER.get(self._peek, self._peek) == "E" and not scientific:
                scientific += 1
                self._advance()
            elif self._peek == "_" and numbers_can_be_underscore_separated:
                is_underscore_separated = True
                self._advance()
            elif self._peek.isidentifier():
                number_text = self._text

                while self._peek and not self._peek.isspace() and self._peek not in single_tokens:
                    numeric_literal += self._peek
                    self._advance()

                numeric_type = keywords.get(numeric_literals.get(numeric_literal.upper(), ""))

                if numeric_type:
                    break
                elif identifiers_can_start_with_digit:
                    return self._add(TokenType.VAR)

                self._advance(-len(numeric_literal))
                break
            else:
                break

        number_text = number_text or self.sql[self._start : self._current]

        # Normalize inputs such as 100_000 to 100000
        if is_underscore_separated:
            number_text = number_text.replace("_", "")

        self._add(TokenType.NUMBER, number_text)

        # Normalize inputs such as 123L to 123::BIGINT so that they're parsed as casts
        if numeric_type:
            self._add(TokenType.DCOLON, "::")
            self._add(numeric_type, numeric_literal)

    def _scan_bits(self) -> None:
        self._advance()
        value = self._extract_value()
        try:
            # If `value` can't be converted to a binary, fallback to tokenizing it as an identifier
            int(value, 2)
            self._add(TokenType.BIT_STRING, value[2:])  # Drop the 0b
        except ValueError:
            self._add(TokenType.IDENTIFIER)

    def _scan_hex(self) -> None:
        self._advance()
        value = self._extract_value()
        try:
            # If `value` can't be converted to a hex, fallback to tokenizing it as an identifier
            int(value, 16)
            self._add(TokenType.HEX_STRING, value[2:])  # Drop the 0x
        except ValueError:
            self._add(TokenType.IDENTIFIER)

    def _extract_value(self) -> str:
        single_tokens = self.single_tokens

        while True:
            char = self._peek.strip()
            if char and char not in single_tokens:
                self._advance(alnum=True)
            else:
                break

        return self._text

    def _scan_string(self, start: str) -> bool:
        base = None
        token_type = TokenType.STRING

        if start in self.quotes:
            end = self.quotes[start]
        elif start in self.format_strings:
            end, token_type = self.format_strings[start]

            if token_type == TokenType.HEX_STRING:
                base = 16
            elif token_type == TokenType.BIT_STRING:
                base = 2
            elif token_type == TokenType.HEREDOC_STRING:
                self._advance()

                if self._char == end:
                    tag = ""
                else:
                    tag = self._extract_string(
                        end,
                        raw_string=True,
                        raise_unmatched=not self.heredoc_tag_is_identifier,
                    )

                if (
                    tag
                    and self.heredoc_tag_is_identifier
                    and (self._end or tag.isdigit() or any(c.isspace() for c in tag))
                ):
                    if not self._end:
                        self._advance(-1)

                    self._advance(-len(tag))
                    self._add(self.heredoc_string_alternative)
                    return True

                end = f"{start}{tag}{end}"
        else:
            return False

        self._advance(len(start))
        text = self._extract_string(
            end,
            escapes=(
                self.byte_string_escapes
                if token_type == TokenType.BYTE_STRING
                else self.string_escapes
            ),
            raw_string=token_type == TokenType.RAW_STRING,
        )

        if base and text:
            try:
                int(text, base)
            except Exception:
                raise TokenError(
                    f"Numeric string contains invalid characters from {self._line}:{self._start}"
                )

        self._add(token_type, text)
        return True

    def _scan_identifier(self, identifier_end: str) -> None:
        self._advance()
        text = self._extract_string(
            identifier_end, escapes=self.identifier_escapes | {identifier_end}
        )
        self._add(TokenType.IDENTIFIER, text)

    def _scan_var(self) -> None:
        var_single_tokens = self.var_single_tokens
        single_tokens = self.single_tokens

        while True:
            peek = self._peek
            if not peek or peek.isspace():
                break
            if peek not in var_single_tokens and peek in single_tokens:
                break
            self._advance(alnum=True)

        self._add(
            TokenType.VAR
            if self.tokens and self.tokens[-1].token_type == TokenType.PARAMETER
            else self.keywords.get(self.sql[self._start : self._current].upper(), TokenType.VAR)
        )

    def _extract_string(
        self,
        delimiter: str,
        escapes: set[str] | None = None,
        raw_string: bool = False,
        raise_unmatched: bool = True,
    ) -> str:
        text = ""
        delim_size = len(delimiter)
        escapes = self.string_escapes if escapes is None else escapes
        unescaped_sequences = self.unescaped_sequences
        escape_follow_chars = self.escape_follow_chars
        string_escapes_allowed_in_raw_strings = self.string_escapes_allowed_in_raw_strings
        quotes = self.quotes
        sql = self.sql

        # use str.find() when the string is simple... no \ or other escapes
        if delim_size == 1:
            pos = self._current - 1
            end = sql.find(delimiter, pos)

            if (
                # the closing delimiter was found
                end != -1
                # there's no doubled delimiter (e.g. '' escape), or the delimiter isn't an escape char
                and (end + 1 >= self.size or sql[end + 1] != delimiter or delimiter not in escapes)
                # no backslash in the string that would need escape processing
                and (not (unescaped_sequences or "\\" in escapes) or sql.find("\\", pos, end) == -1)
            ):
                newlines = sql.count("\n", pos, end)
                if newlines:
                    self._line += newlines
                    self._col = end - sql.rfind("\n", pos, end)
                else:
                    self._col += end - pos

                self._current = end + 1
                self._end = self._current >= self.size
                self._char = sql[end]
                self._peek = "" if self._end else sql[self._current]
                return sql[pos:end]

        while True:
            if not raw_string and unescaped_sequences and self._peek and self._char in escapes:
                unescaped_sequence = unescaped_sequences.get(self._char + self._peek)
                if unescaped_sequence:
                    self._advance(2)
                    text += unescaped_sequence
                    continue

            is_valid_custom_escape = (
                escape_follow_chars and self._char == "\\" and self._peek not in escape_follow_chars
            )

            if (
                (string_escapes_allowed_in_raw_strings or not raw_string)
                and self._char in escapes
                and (self._peek == delimiter or self._peek in escapes or is_valid_custom_escape)
                and (self._char not in quotes or self._char == self._peek)
            ):
                if self._peek == delimiter:
                    text += self._peek
                elif is_valid_custom_escape and self._char != self._peek:
                    text += self._peek
                else:
                    text += self._char + self._peek

                if self._current + 1 < self.size:
                    self._advance(2)
                else:
                    raise TokenError(f"Missing {delimiter} from {self._line}:{self._current}")
            else:
                if self._chars(delim_size) == delimiter:
                    if delim_size > 1:
                        self._advance(delim_size - 1)
                    break

                if self._end:
                    if not raise_unmatched:
                        return text + self._char

                    raise TokenError(f"Missing {delimiter} from {self._line}:{self._start}")

                current = self._current - 1
                self._advance(alnum=True)
                text += sql[current : self._current - 1]

        return text
