# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0950
# Licensed under the MIT License - see LICENSE file for details

"""
Safe boolean expression parser for planner condition steps.

Replaces eval() with a small recursive-descent parser that handles only the
operators the planning prompt describes.  No code execution, no builtins,
no escape path to Python internals.

Supported syntax (after {{...}} template substitution by _render_config):

  Atoms
  -----
  true / false                 Boolean literals (case-insensitive)
  42 / 3.14                    Numeric literals
  "hello" / 'world'            String literals (single or double quotes)
  any bare word                Treated as a string value

  Comparison (left OP right)
  ----------
  ==   !=   <   <=   >   >=   Standard comparisons (type-coercing)
  contains                     right is a substring of left
  startswith                   left starts with right
  endswith                     left ends with right
  in                           left is in right (comma-separated list or string)

  Logical
  -------
  and   or   not               Boolean combinators (case-insensitive)
  ( )                          Grouping

Grammar (simplified EBNF):
  expr    = or_expr
  or_expr = and_expr  ( "or"  and_expr )*
  and_expr = not_expr ( "and" not_expr )*
  not_expr = "not" not_expr | cmp_expr
  cmp_expr = atom  ( OP atom )?
  atom    = "true" | "false" | NUMBER | STRING | "(" expr ")"

Raises ExpressionError on any syntax or type error.
Never calls eval(), exec(), or any code-execution primitive.
"""

from __future__ import annotations

import logging
import re
from typing import Any, List, Tuple

logger = logging.getLogger(__name__)


class ExpressionError(ValueError):
    """Raised when a condition expression cannot be parsed or evaluated."""


# ---------------------------------------------------------------------------
# Tokeniser
# ---------------------------------------------------------------------------

_TOKEN_RE = re.compile(
    r"""
    (?P<NUMBER>   -? \d+ (?:\.\d+)? )              |
    (?P<STRING>   " [^"\\]* (?:\\.[^"\\]*)* "      |
                  ' [^'\\]* (?:\\.[^'\\]*)* '  )   |
    (?P<WORD>     [A-Za-z_][\w\-]* )               |
    (?P<OP>       !=|==|<=|>=|<|> )                |
    (?P<LPAREN>   \( )                             |
    (?P<RPAREN>   \) )                             |
    (?P<SKIP>     \s+ )                            |
    (?P<MISMATCH> . )
    """,
    re.VERBOSE,
)

_COMPARISON_OPS = {"==", "!=", "<", "<=", ">", ">=", "contains", "startswith", "endswith", "in"}

_KEYWORDS = {"and", "or", "not", "true", "false", "contains", "startswith", "endswith", "in"}


def _tokenise(text: str) -> List[Tuple[str, str]]:
    tokens = []
    for m in _TOKEN_RE.finditer(text):
        kind = m.lastgroup
        value = m.group()
        if kind == "SKIP":
            continue
        if kind == "MISMATCH":
            raise ExpressionError(f"Unexpected character {value!r} in expression: {text!r}")
        # Classify WORD tokens more precisely
        if kind == "WORD":
            lv = value.lower()
            if lv in {"and", "or", "not"}:
                kind = "LOGIC"
            elif lv in {"true", "false"}:
                kind = "BOOL"
            elif lv in {"contains", "startswith", "endswith", "in"}:
                kind = "OP"
        tokens.append((kind, value))
    return tokens


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------


class _Parser:
    """Recursive-descent parser over a flat token list."""

    def __init__(self, tokens: List[Tuple[str, str]]):
        self._tokens = tokens
        self._pos = 0

    # ── Helpers ─────────────────────────────────────────────────────────────

    def _peek(self) -> Tuple[str, str] | None:
        if self._pos < len(self._tokens):
            return self._tokens[self._pos]
        return None

    def _consume(self) -> Tuple[str, str]:
        tok = self._tokens[self._pos]
        self._pos += 1
        return tok

    def _expect(self, kind: str, value: str | None = None) -> Tuple[str, str]:
        tok = self._peek()
        if tok is None:
            raise ExpressionError(f"Expected {kind!r} but reached end of expression")
        if tok[0] != kind:
            raise ExpressionError(f"Expected token type {kind!r} but got {tok[0]!r} ({tok[1]!r})")
        if value is not None and tok[1].lower() != value.lower():
            raise ExpressionError(f"Expected {value!r} but got {tok[1]!r}")
        return self._consume()

    def _match_logic(self, keyword: str) -> bool:
        tok = self._peek()
        if tok and tok[0] == "LOGIC" and tok[1].lower() == keyword:
            self._consume()
            return True
        return False

    # ── Grammar rules ────────────────────────────────────────────────────────

    def parse(self) -> bool:
        result = self._or_expr()
        if self._peek() is not None:
            raise ExpressionError(f"Unexpected token after expression: {self._peek()}")
        return result

    def _or_expr(self) -> bool:
        left = self._and_expr()
        while self._match_logic("or"):
            right = self._and_expr()
            left = left or right
        return left

    def _and_expr(self) -> bool:
        left = self._not_expr()
        while self._match_logic("and"):
            right = self._not_expr()
            left = left and right
        return left

    def _not_expr(self) -> bool:
        if self._match_logic("not"):
            return not self._not_expr()
        return self._cmp_expr()

    def _cmp_expr(self) -> bool:
        left = self._atom()

        tok = self._peek()
        if tok is None or tok[0] not in ("OP",):
            # No operator → treat as truthy test
            return _truthy(left)

        op = self._consume()[1].lower()
        if op not in _COMPARISON_OPS:
            raise ExpressionError(f"Unknown operator: {op!r}")

        right = self._atom()
        return _compare(left, op, right)

    def _atom(self) -> Any:
        tok = self._peek()
        if tok is None:
            raise ExpressionError("Expected an atom (value) but reached end of expression")

        kind, value = tok

        if kind == "LPAREN":
            self._consume()
            result = self._or_expr()
            self._expect("RPAREN")
            return result

        if kind == "BOOL":
            self._consume()
            return value.lower() == "true"

        if kind == "NUMBER":
            self._consume()
            return float(value) if "." in value else int(value)

        if kind == "STRING":
            self._consume()
            # Strip surrounding quotes and unescape
            inner = value[1:-1]
            inner = inner.replace('\\"', '"').replace("\\'", "'").replace("\\n", "\n")
            return inner

        if kind == "WORD":
            # Bare word → string value
            self._consume()
            return value

        raise ExpressionError(f"Unexpected token {kind!r} ({value!r}) where a value was expected")


# ---------------------------------------------------------------------------
# Comparison helpers
# ---------------------------------------------------------------------------


def _truthy(value: Any) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return value != 0
    if isinstance(value, str):
        lv = value.lower()
        if lv in ("true", "yes", "1"):
            return True
        if lv in ("false", "no", "0", ""):
            return False
        return bool(value)
    return bool(value)


def _coerce(a: Any, b: Any) -> Tuple[Any, Any]:
    """Try to coerce both values to the same type for comparison."""
    if type(a) is type(b):
        return a, b
    # Both numeric?
    if isinstance(a, (int, float)) and isinstance(b, str):
        try:
            return a, type(a)(b)
        except (ValueError, TypeError):
            pass
    if isinstance(b, (int, float)) and isinstance(a, str):
        try:
            return type(b)(a), b
        except (ValueError, TypeError):
            pass
    # Bool from string?
    if isinstance(a, bool) and isinstance(b, str):
        return a, b.lower() in ("true", "yes", "1")
    if isinstance(b, bool) and isinstance(a, str):
        return a.lower() in ("true", "yes", "1"), b
    # Fall back to string comparison
    return str(a), str(b)


def _compare(left: Any, op: str, right: Any) -> bool:
    if op == "contains":
        return str(right) in str(left)
    if op == "startswith":
        return str(left).startswith(str(right))
    if op == "endswith":
        return str(left).endswith(str(right))
    if op == "in":
        # right can be a comma-separated list or a string
        candidates = [x.strip() for x in str(right).split(",")]
        return str(left) in candidates or str(left) in str(right)

    # Numeric/equality comparisons
    left_coerced, right_coerced = _coerce(left, right)
    try:
        if op == "==":
            return left_coerced == right_coerced
        if op == "!=":
            return left_coerced != right_coerced
        if op == "<":
            return left_coerced < right_coerced
        if op == "<=":
            return left_coerced <= right_coerced
        if op == ">":
            return left_coerced > right_coerced
        if op == ">=":
            return left_coerced >= right_coerced
    except TypeError as exc:
        raise ExpressionError(
            f"Cannot compare {type(left).__name__} {op!r} {type(right).__name__}: {exc}"
        ) from exc

    raise ExpressionError(f"Unknown operator: {op!r}")


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def evaluate(expression: str) -> bool:
    """
    Safely evaluate a boolean condition expression.

    Args:
        expression: A condition string such as:
            'success == "true"'
            'count > 5 and status contains "ok"'
            'not (result == "error" or result == "failed")'

    Returns:
        bool result of the expression.

    Raises:
        ExpressionError: If the expression is syntactically invalid or uses
            an unsupported operator.  Never raises on CPython escape gadgets
            because no eval/exec is used.
    """
    expression = expression.strip()
    if not expression:
        return True  # Empty condition → always pass

    try:
        tokens = _tokenise(expression)
        parser = _Parser(tokens)
        return parser.parse()
    except ExpressionError:
        raise
    except Exception as exc:
        raise ExpressionError(f"Expression evaluation failed: {exc}") from exc
