  # #region SqlTableExtractorModule [C:2] [TYPE Module] [SEMANTICS sql, jinja, table, extraction, parsing]
  # @defgroup Services Module group.
  # @BRIEF Three-phase SQL+Jinja table name extractor for virtual datasets as per FR-003.
  #   Phase 1: Detect Jinja spans vs SQL spans
  #   Phase 2: In Jinja spans, extract "schema.table" from string values
  #   Phase 3: In SQL spans, regex pattern + sqlparse filter to reject string literal false positives
  # @LAYER Service
  # @RELATION DEPENDS_ON -> [EXT:Library:sqlparse]
  # @INVARIANT Only exact schema.table matches (case-insensitive); unqualified references are NOT matched.
  # @INVARIANT Returns a set[str] of fully-qualified table names (lowercased for case-insensitive matching).
  
> from collections.abc import Iterable
> import re
  
> import sqlparse
> from sqlparse.sql import Token, TokenList
> from sqlparse.tokens import Literal
  
  # ── Phase 1: Jinja span detection ───────────────────────────
  
  # Minimal Jinja token detection: {% ... %}, {{ ... }}, {# ... #}
> _JINJA_BLOCK_RE = re.compile(r"{%-?\s*.*?\s*-?%}", re.DOTALL)
> _JINJA_EXPR_RE = re.compile(r"{{-?\s*.*?\s*-?}}", re.DOTALL)
> _JINJA_COMMENT_RE = re.compile(r"{#.*?#}", re.DOTALL)
  
  # ── Phase 3: schema.table regex (case-insensitive) ───────────
> _SCHEMA_TABLE_RE = re.compile(
>     r"""
>     (?<!['"`\w])                     # not preceded by string delimiter or word char
>     \b                                # word boundary
>     (?:                               # begin: non-capturing group for full match
>         (?:"([a-zA-Z_][\w]*)")?       # optional quoted schema
>         (?:([a-zA-Z_][\w]*))          # schema (unquoted)
>         \.
>         (?:([a-zA-Z_][\w]*))          # table
>     )
>     \b                                # word boundary
>     (?!['"`])                         # not followed by a string delimiter
>     """,
>     re.VERBOSE | re.IGNORECASE,
> )
  
  
  # #region detect_jinja_spans [C:2] [TYPE Function]
  # @ingroup Services
  # @BRIEF Phase 1: Split raw SQL text into Jinja spans and SQL spans.
  # @PRE raw_sql is a string (possibly empty).
  # @POST Returns a list of (span_type: str, text: str) tuples.
  #   span_type is "jinja" or "sql".
> def detect_jinja_spans(raw_sql: str) -> list[tuple[str, str]]:
>     """Split raw SQL+Jinja text into alternating Jinja and SQL spans.
  
>     Phase 1 detects Jinja blocks ({%%}, {{}}, {##}) and returns the
>     remaining text as SQL spans.
>     """
>     if not raw_sql or not raw_sql.strip():
!         return [("sql", raw_sql or "")]
  
>     spans: list[tuple[str, str]] = []
      # Collect all Jinja match positions
>     jinja_matches: list[tuple[int, int]] = []
>     for pattern in (_JINJA_BLOCK_RE, _JINJA_EXPR_RE, _JINJA_COMMENT_RE):
>         for m in pattern.finditer(raw_sql):
>             jinja_matches.append((m.start(), m.end()))
  
      # Merge overlapping Jinja spans
>     if jinja_matches:
>         jinja_matches.sort()
>         merged: list[tuple[int, int]] = [jinja_matches[0]]
>         for start, end in jinja_matches[1:]:
!             if start <= merged[-1][1]:
!                 merged[-1] = (merged[-1][0], max(merged[-1][1], end))
!             else:
!                 merged.append((start, end))
  
          # Build alternating sql/jinja spans
>         cursor = 0
>         for start, end in merged:
>             if cursor < start:
>                 sql_chunk = raw_sql[cursor:start].strip()
>                 if sql_chunk:
>                     spans.append(("sql", sql_chunk))
>             jinja_chunk = raw_sql[start:end].strip()
>             if jinja_chunk:
>                 spans.append(("jinja", jinja_chunk))
>             cursor = end
>         if cursor < len(raw_sql):
>             remaining = raw_sql[cursor:].strip()
>             if remaining:
>                 spans.append(("sql", remaining))
>     else:
>         spans.append(("sql", raw_sql.strip()))
  
>     return spans
  # #endregion detect_jinja_spans
  
  
  # #region extract_tables_from_jinja [C:2] [TYPE Function]
  # @ingroup Services
  # @BRIEF Phase 2: Extract "schema.table" references from Jinja string values.
  #   Looks for patterns like "raw.sales" inside Jinja text.
  # @PRE jinja_text is a string from a Jinja span.
  # @POST Returns a set of lowercased schema.table strings.
> def extract_tables_from_jinja(jinja_text: str) -> set[str]:
>     """Extract ``schema.table`` references from within Jinja template blocks.
  
>     Looks for double-quoted or single-quoted string values that match
>     the ``schema.table`` pattern, e.g. ``"raw.sales"`` or ``'raw.inventory'``.
>     """
>     tables: set[str] = set()
      # Match string values: "schema.table" or 'schema.table'
>     string_pattern = re.compile(
>         r"""["']([a-zA-Z_][\w]*\.[a-zA-Z_][\w]*)["']"""
>     )
>     for m in string_pattern.finditer(jinja_text):
>         tables.add(m.group(1).lower())
>     return tables
  # #endregion extract_tables_from_jinja
  
  
  # #region is_string_literal [C:1] [TYPE Function]
  # @BRIEF Check if a sqlparse Token is a string literal (not a schema.table identifier).
  # @PRE token is a sqlparse Token.
  # @POST Returns True if the token is a string/Single/Literal.String.
> def is_string_literal(token: Token) -> bool:
>     """Return True if token is a string literal type in sqlparse."""
>     return token.ttype is Literal.String.Single or token.ttype is Literal.String
  # #endregion is_string_literal
  
  
  # #region extract_tables_from_sql_span [C:2] [TYPE Function]
  # @ingroup Services
  # @BRIEF Phase 3: Extract schema.table references from SQL text using regex + sqlparse filtering.
  #   Uses regex to find all schema.table candidates, then sqlparse to filter out
  #   false positives inside string literals.
  # @PRE sql_text is a string from a SQL span (non-Jinja).
  # @POST Returns a set of lowercased schema.table strings.
> def extract_tables_from_sql_span(sql_text: str) -> set[str]:
>     """Extract ``schema.table`` references from plain SQL text.
  
>     Uses regex to find all ``schema.table`` candidates, then sqlparse
>     to reject matches that fall inside string literals.
>     """
>     tables: set[str] = set()
>     raw_matches = _SCHEMA_TABLE_RE.findall(sql_text)
>     if not raw_matches:
>         return tables
  
      # Use sqlparse to identify string literal positions
>     parsed = sqlparse.parse(sql_text)
>     string_literal_ranges: list[tuple[int, int]] = []
  
>     def walk_tokens(tokens: Iterable[Token], base_offset: int = 0) -> None:
>         offset = base_offset
>         for token in tokens:
>             if isinstance(token, TokenList):
!                 walk_tokens(token.flatten(), offset)
>             else:
>                 ttype = token.ttype
>                 val = token.value
>                 if is_string_literal(token):
>                     string_literal_ranges.append(
>                         (offset, offset + len(val))
>                     )
>                 offset += len(val)
  
>     for stmt in parsed:
>         if stmt is None:
!             continue
>         walk_tokens(stmt.flatten(), base_offset=0)
  
>     def is_in_string(pos: int) -> bool:
>         for s_start, s_end in string_literal_ranges:
>             if s_start <= pos <= s_end:
!                 return True
>         return False
  
      # Re-scan with full match positions to filter
>     for m in _SCHEMA_TABLE_RE.finditer(sql_text):
>         if not is_in_string(m.start()):
              # Extract schema and table from match groups
>             schema = m.group(2) or m.group(1) or ""
>             table = m.group(3) or ""
              # Filter out column aliases (single-char schemas like "a.id", "o.id")
>             if len(schema) < 2 and schema.isalpha() and schema.islower():
>                 continue
>             if schema and table:
>                 tables.add(f"{schema.lower()}.{table.lower()}")
  
>     return tables
  # #endregion extract_tables_from_sql_span
  
  
  # #region extract_tables_from_sql [C:2] [TYPE Function]
  # @ingroup Services
  # @BRIEF Three-phase entry point: extract all schema.table references from raw SQL+Jinja text.
  # @PRE raw_sql is a string (possibly empty).
  # @POST Returns a set of lowercased fully-qualified table names (schema.table format).
  # @RELATION CALLS -> [detect_jinja_spans]
  # @RELATION CALLS -> [extract_tables_from_jinja]
  # @RELATION CALLS -> [extract_tables_from_sql_span]
> def extract_tables_from_sql(raw_sql: str) -> set[str]:
>     """Extract all ``schema.table`` references from raw SQL + Jinja text.
  
>     Three-phase approach per FR-003:
>     1. Detect Jinja spans vs SQL spans
>     2. In Jinja spans, extract ``schema.table`` from quoted string values
>     3. In SQL spans, regex + sqlparse filter to reject string literal false positives
  
>     Returns a set of lowercased ``schema.table`` strings for case-insensitive matching.
  
>     Example:
>         >>> extract_tables_from_sql("SELECT * FROM raw.sales JOIN raw.inventory ON ...")
>         {'raw.sales', 'raw.inventory'}
>     """
>     if not raw_sql or not raw_sql.strip():
>         return set()
  
>     all_tables: set[str] = set()
>     spans = detect_jinja_spans(raw_sql)
  
>     for span_type, text in spans:
>         if span_type == "jinja":
>             all_tables.update(extract_tables_from_jinja(text))
>         else:
>             all_tables.update(extract_tables_from_sql_span(text))
  
>     return all_tables
  # #endregion extract_tables_from_sql
  
  # #endregion SqlTableExtractorModule
