'''
This file should create a petl select function from a SCIM2 filter string.

'''

import re
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Union
import logging
logger = logging.getLogger(__name__)


# Type alias for the compiled filter function
FilterFunction = Callable[[Dict[str, Any]], bool]

# Regex pattern to tokenize the SCIM filter string.
# Captures: [, ], (, ), strings, operators, and attribute paths.
TOKEN_PATTERN = re.compile(
    r'(\[|\]|\(|\)|"[^"]*"|and|or|not|eq|ne|co|sw|ew|pr|gt|ge|lt|le|'
    r'true|false|null|[\w\.\-:]+)'
)


def compile_scim_filter(filter_str: str) -> FilterFunction:
    """
    Parses a SCIM filter string and returns a callable function.

    The returned function accepts a dictionary (the resource) and returns
    True if the resource matches the filter, False otherwise.

    Args:
        filter_str (str): The SCIM filter string (e.g., 'userName eq "bjensen"').

    Returns:
        FilterFunction: A function that takes a dict and returns a boolean.
    """

    if not filter_str:
        return lambda _: True

    # Tokenize the input string
    tokens: List[str] = [
        t for t in TOKEN_PATTERN.findall(filter_str) if t.strip()
    ]
    pos: int = 0

    # --- Parser State Helpers ---

    def peek() -> Optional[str]:
        """Returns the current token without advancing the position."""

        if pos < len(tokens):
            return tokens[pos]

        return None

    def consume(expected: Optional[str] = None) -> str:
        """
        Returns the current token and advances the position.
        Raises ValueError if the current token does not match 'expected'.
        """
        nonlocal pos
        current = peek()

        if expected and current != expected:
            raise ValueError(
                f"Expected '{expected}' but found '{current}' at position {pos}"
            )
        
        if pos < len(tokens):
            pos += 1

        return current

    # --- Data Access & Type Conversion ---

    def get_value(obj: Dict[str, Any], path: str) -> List[Any]:
        """
        Retrieves values from nested dictionaries or lists using dot notation.
        Always returns a list of found values.
        """
        keys = path.split('.')
        current_objects = [obj]

        for key in keys:
            next_values = []

            for item in current_objects:
                # TODO validate is correct type
                val = item.get(key)

                if isinstance(val, list):
                    next_values.extend(val)
                elif val is not None:
                    next_values.append(val)
            current_objects = next_values
        
        return current_objects

    def parse_val(val_str: str) -> Union[str, int, float, bool, None]:
        """Converts a token string into its Python native type."""

        if val_str == 'true':
            return True

        if val_str == 'false':
            return False

        if val_str == 'null':
            return None

        if val_str.startswith('"') and val_str.endswith('"'):
            return val_str[1:-1]
        
        # Try converting to numbers
        try:
            if '.' in val_str:
                return float(val_str)

            return int(val_str)
        except ValueError:
            return val_str

    def to_date(val: Any) -> Optional[datetime]:
        """Attempts to convert a value to a datetime object."""

        if isinstance(val, datetime):
            return val

        if isinstance(val, str):
            try:
                # Replace 'Z' with +00:00 for strict ISO parsing

                return datetime.fromisoformat(val.replace('Z', '+00:00'))
            except ValueError:
                return None

        return None

    # --- Comparison Logic ---

    def compare(op: str, val_a: Any, val_b: Any) -> bool:
        """
        Compares two values based on the SCIM operator.
        Handles Strings, Numbers, and ISO Dates.
        """
        # 1. Date Comparison Strategy
        date_a = to_date(val_a)
        date_b = to_date(val_b)

        if date_a and date_b:
            # Use the datetime objects for comparison
            val_a, val_b = date_a, date_b
        
        # 2. String Comparison Strategy (Case-insensitive)

        if isinstance(val_a, str) and isinstance(val_b, str):
            str_a = val_a.lower()
            str_b = val_b.lower()

            if op == 'eq':
                return str_a == str_b

            if op == 'ne':
                return str_a != str_b

            if op == 'co':
                return str_b in str_a

            if op == 'sw':
                return str_a.startswith(str_b)

            if op == 'ew':
                return str_a.endswith(str_b)
            
            # String ordering (lexicographical)

            if op == 'gt':
                return val_a > val_b

            if op == 'ge':
                return val_a >= val_b

            if op == 'lt':
                return val_a < val_b

            if op == 'le':
                return val_a <= val_b

        # 3. Generic Comparison Strategy
        try:
            if op == 'eq':
                return val_a == val_b

            if op == 'ne':
                return val_a != val_b

            if op == 'co':
                return str(val_b) in str(val_a)

            if op == 'gt':
                return val_a > val_b

            if op == 'ge':
                return val_a >= val_b

            if op == 'lt':
                return val_a < val_b

            if op == 'le':
                return val_a <= val_b
        except TypeError:
            # Fallback for incompatible types (e.g., int vs str)

            return False
            
        return False

    def evaluate_atom(
        attr: str, op: str, val: Any, data: Dict[str, Any]
    ) -> bool:
        """Evaluates a single condition (attr op value) against the data."""
        values = get_value(data, attr)
        
        if op == 'pr':
            return len(values) > 0
        
        target_val = parse_val(val)
        
        # If any value in the list matches, the expression is True

        for item in values:
            if compare(op, item, target_val):
                return True

        return False

    def evaluate_scoped_filter(
        parent_attr: str, sub_filter: FilterFunction, data: Dict[str, Any]
    ) -> bool:
        """
        Evaluates a scoped filter (e.g., emails[type eq "work"]).
        Iterates over the list at `parent_attr` and applies `sub_filter`.
        """
        items = get_value(data, parent_attr)

        for item in items:
            if sub_filter(item):
                return True

        return False

    # --- Recursive Descent Parser ---

    def parse_or() -> FilterFunction:
        """Parses 'or' logic."""
        left = parse_and()
        
        while peek() and peek().lower() == 'or':
            consume()  # Consume 'or'
            right = parse_and()
            
            # Capture closure variables
            prev_left, prev_right = left, right
            left = lambda d: prev_left(d) or prev_right(d)
        
        return left

    def parse_and() -> FilterFunction:
        """Parses 'and' logic."""
        left = parse_atom()
        
        while peek() and peek().lower() == 'and':
            consume()  # Consume 'and'
            right = parse_atom()
            
            # Capture closure variables
            prev_left, prev_right = left, right
            left = lambda d: prev_left(d) and prev_right(d)
        
        return left

    def parse_atom() -> FilterFunction:
        """Parses atomic expressions, groups, scoped lists, and 'not' ops."""
        token = peek()

        # Case 1: Grouping (...)

        if token == '(':
            consume('(')
            expr = parse_or()
            consume(')')

            return expr

        # Case 2: Logical NOT

        if token.lower() == 'not':
            consume()
            expr = parse_atom()

            return lambda d: not expr(d)

        # Standard Attribute Path
        attr_path = consume()

        # Case 3: Scoped Filter [ ... ]

        if peek() == '[':
            consume('[')
            sub_filter = parse_or()
            consume(']')

            return lambda d: evaluate_scoped_filter(
                attr_path, sub_filter, d
            )

        # Case 4: Presence check (pr)

        if peek() and peek().lower() == 'pr':
            consume()

            return lambda d: evaluate_atom(attr_path, 'pr', None, d)

        # Case 5: Standard Comparison (eq, gt, etc.)
        op = consume()
        val_token = consume()
        
        return lambda d: evaluate_atom(attr_path, op, val_token, d)

    # --- Execution ---
    
    matcher = parse_or()

    def inner_matcher(rec):
        # logger.debug('rec', rec.emails)
        logger.debug('matcher res: ', matcher(rec))

        return matcher(rec)

    if pos < len(tokens):
        raise ValueError(f"Unexpected token at end of string: {tokens[pos]}")

    return inner_matcher
