# Copyright (c) 2026 MergeGuide, Inc. All rights reserved.
# SPDX-License-Identifier: Proprietary
"""Conditional policy evaluation engine.

This module provides context-aware condition evaluation for policies,
allowing rules to be applied only when specific conditions are met.

Supported Conditions:
    - branch: Match on Git branch name
    - path: Match on file path patterns (glob)
    - author: Match on commit/PR author
    - time: Match on time-of-day or day-of-week
    - file_extension: Match on file extension
    - directory: Match on directory path

Condition Syntax:
    Conditions use a simple expression syntax:
    - Equality: branch == "main"
    - Inequality: branch != "develop"
    - Pattern match: path matches "src/**/*.py"
    - Negation: not (path matches "**/test/**")
    - Logical AND: branch == "main" and path matches "src/**"
    - Logical OR: author == "bot" or author == "ci"
    - Time-based: time.hour >= 9 and time.hour < 17
    - Day-based: time.weekday in ["mon", "tue", "wed", "thu", "fri"]

Example Usage:
    >>> context = ConditionContext(
    ...     branch="main",
    ...     file_path="src/api/handler.py",
    ...     author="developer@example.com",
    ... )
    >>> evaluator = ConditionEvaluator()
    >>> evaluator.evaluate('branch == "main"', context)
    True
    >>> evaluator.evaluate('path matches "**/test/**"', context)
    False
"""

from __future__ import annotations

import fnmatch
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any


@dataclass
class ConditionContext:
    """Context for condition evaluation.

    This class holds all the contextual information that can be used
    in condition expressions.

    Attributes:
        branch: Git branch name (e.g., "main", "feature/xyz").
        file_path: Path to the file being evaluated.
        author: Author of the commit or PR.
        pr_number: Pull request number (if applicable).
        org_id: Organization ID.
        repo: Repository name.
        commit_sha: Git commit SHA.
        evaluation_time: Time of evaluation (defaults to now).
        labels: PR labels (if applicable).
        base_branch: Base branch for PR (e.g., "main").
    """

    branch: str = ""
    file_path: str = ""
    author: str = ""
    pr_number: int | None = None
    org_id: str = ""
    repo: str = ""
    commit_sha: str = ""
    evaluation_time: datetime = field(default_factory=lambda: datetime.now(UTC))
    labels: list[str] = field(default_factory=list)
    base_branch: str = ""

    @property
    def file_extension(self) -> str:
        """Get the file extension (without dot)."""
        if "." in self.file_path:
            return self.file_path.rsplit(".", 1)[-1].lower()
        return ""

    @property
    def directory(self) -> str:
        """Get the directory path."""
        if "/" in self.file_path:
            return self.file_path.rsplit("/", 1)[0]
        return ""

    @property
    def filename(self) -> str:
        """Get the filename without directory."""
        if "/" in self.file_path:
            return self.file_path.rsplit("/", 1)[-1]
        return self.file_path

    def to_dict(self) -> dict[str, Any]:
        """Convert context to dictionary for expression evaluation."""
        return {
            "branch": self.branch,
            "file_path": self.file_path,
            "path": self.file_path,  # Alias
            "file": self.file_path,  # Alias
            "author": self.author,
            "pr_number": self.pr_number,
            "org_id": self.org_id,
            "repo": self.repo,
            "commit_sha": self.commit_sha,
            "file_extension": self.file_extension,
            "extension": self.file_extension,  # Alias
            "ext": self.file_extension,  # Alias
            "directory": self.directory,
            "dir": self.directory,  # Alias
            "filename": self.filename,
            "labels": self.labels,
            "base_branch": self.base_branch,
            "time": TimeContext(self.evaluation_time),
        }


@dataclass
class TimeContext:
    """Time context for time-based conditions.

    Attributes:
        dt: The datetime object.
    """

    dt: datetime

    @property
    def hour(self) -> int:
        """Hour of day (0-23)."""
        return self.dt.hour

    @property
    def minute(self) -> int:
        """Minute (0-59)."""
        return self.dt.minute

    @property
    def weekday(self) -> str:
        """Day of week as lowercase string (mon, tue, wed, etc.)."""
        days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
        return days[self.dt.weekday()]

    @property
    def weekday_num(self) -> int:
        """Day of week as number (0=Monday, 6=Sunday)."""
        return self.dt.weekday()

    @property
    def is_weekend(self) -> bool:
        """True if Saturday or Sunday."""
        return self.dt.weekday() >= 5

    @property
    def is_weekday(self) -> bool:
        """True if Monday through Friday."""
        return self.dt.weekday() < 5

    @property
    def day(self) -> int:
        """Day of month (1-31)."""
        return self.dt.day

    @property
    def month(self) -> int:
        """Month (1-12)."""
        return self.dt.month

    @property
    def year(self) -> int:
        """Year."""
        return self.dt.year


class ConditionError(Exception):
    """Exception raised when condition evaluation fails."""

    pass


class ConditionEvaluator:
    """Evaluates condition expressions against a context.

    The evaluator uses a safe subset of Python expressions with
    additional operators for pattern matching.

    Supported Operators:
        - ==, !=: Equality comparison
        - <, >, <=, >=: Numeric comparison
        - and, or, not: Logical operators
        - in: Membership test
        - matches: Glob pattern matching
        - contains: Substring check

    Security:
        The evaluator does NOT use Python's eval(). It parses and
        evaluates expressions safely to prevent code injection.
    """

    # Regex patterns for tokenizing (pattern, token_type)
    # Token types map to how the match is processed
    TOKEN_PATTERNS = [
        (r"\s+", "SKIP"),  # Whitespace (skip)
        (r"matches\b", "MATCHES"),
        (r"contains\b", "CONTAINS"),
        (r"startswith\b", "STARTSWITH"),
        (r"endswith\b", "ENDSWITH"),
        (r"and\b", "AND"),
        (r"or\b", "OR"),
        (r"not\b", "NOT"),
        (r"in\b", "IN"),
        (r"true\b", "TRUE"),
        (r"false\b", "FALSE"),
        (r"==", "EQ"),
        (r"!=", "NE"),
        (r"<=", "LE"),
        (r">=", "GE"),
        (r"<", "LT"),
        (r">", "GT"),
        (r"\(", "LPAREN"),
        (r"\)", "RPAREN"),
        (r"\[", "LBRACKET"),
        (r"\]", "RBRACKET"),
        (r",", "COMMA"),
        (r'"[^"]*"', "STRING_DOUBLE"),
        (r"'[^']*'", "STRING_SINGLE"),
        (r"-?\d+\.?\d*", "NUMBER"),
        (r"[a-zA-Z_][a-zA-Z0-9_\.]*", "IDENTIFIER"),
    ]

    def __init__(self) -> None:
        """Initialize the condition evaluator."""
        # Build token regex with unique group names
        patterns = []
        self._token_types: list[str] = []
        for i, (pattern, token_type) in enumerate(self.TOKEN_PATTERNS):
            group_name = f"T{i}"
            patterns.append(f"(?P<{group_name}>{pattern})")
            self._token_types.append(token_type)
        self._token_regex = re.compile("|".join(patterns))

    def evaluate(self, expression: str, context: ConditionContext) -> bool:
        """Evaluate a condition expression.

        Args:
            expression: The condition expression to evaluate.
            context: The context containing variable values.

        Returns:
            True if the condition is met, False otherwise.

        Raises:
            ConditionError: If the expression is invalid.
        """
        if not expression or not expression.strip():
            return True  # Empty condition means always apply

        # Prevent excessive input that could cause slow regex matching
        if len(expression) > 10_000:
            raise ConditionError(
                f"Condition expression too long ({len(expression)} chars, max 10000)"
            )

        try:
            tokens = self._tokenize(expression)
            result = self._parse_expression(tokens, context.to_dict())
            return bool(result)
        except Exception as e:
            raise ConditionError(f"Invalid condition expression: {expression}. Error: {e}") from e

    def validate(self, expression: str) -> tuple[bool, str | None]:
        """Validate a condition expression syntax.

        Args:
            expression: The condition expression to validate.

        Returns:
            Tuple of (is_valid, error_message).
        """
        if not expression or not expression.strip():
            return True, None

        try:
            tokens = self._tokenize(expression)
            # Create a dummy context for validation
            dummy_context = ConditionContext().to_dict()
            self._parse_expression(tokens, dummy_context)
            return True, None
        except Exception as e:
            return False, str(e)

    def _tokenize(self, expression: str) -> list[tuple[str, Any]]:
        """Tokenize the expression."""
        tokens = []
        pos = 0

        while pos < len(expression):
            match = self._token_regex.match(expression, pos)
            if not match:
                raise ConditionError(f"Invalid character at position {pos}: {expression[pos]}")

            # Find which group matched and get the token type
            group_name = match.lastgroup
            if group_name is None:
                raise ConditionError(f"No group matched at position {pos}")

            # Extract group index from group name (T0, T1, etc.)
            group_idx = int(group_name[1:])
            token_type = self._token_types[group_idx]
            token_value = match.group()

            if token_type != "SKIP":
                # Process token value based on type
                if token_type in ("STRING_DOUBLE", "STRING_SINGLE"):
                    token_value = token_value[1:-1]  # Remove quotes
                    token_type = "STRING"  # Normalize type
                elif token_type == "NUMBER":
                    token_value = float(token_value) if "." in token_value else int(token_value)
                elif token_type == "TRUE":
                    token_value = True
                elif token_type == "FALSE":
                    token_value = False

                tokens.append((token_type, token_value))

            pos = match.end()

        return tokens

    def _parse_expression(self, tokens: list[tuple[str, Any]], context: dict[str, Any]) -> Any:
        """Parse and evaluate an expression (handles OR)."""
        self._pos = 0
        self._tokens = tokens
        self._context = context
        return self._parse_or()

    def _current_token(self) -> tuple[str, Any] | None:
        """Get the current token."""
        if self._pos < len(self._tokens):
            return self._tokens[self._pos]
        return None

    def _consume(self, expected_type: str | None = None) -> tuple[str, Any]:
        """Consume and return the current token."""
        token = self._current_token()
        if token is None:
            raise ConditionError("Unexpected end of expression")
        if expected_type and token[0] != expected_type:
            raise ConditionError(f"Expected {expected_type}, got {token[0]}")
        self._pos += 1
        return token

    def _parse_or(self) -> Any:
        """Parse OR expression."""
        left = self._parse_and()

        while self._current_token() and self._current_token()[0] == "OR":
            self._consume("OR")
            right = self._parse_and()
            left = left or right

        return left

    def _parse_and(self) -> Any:
        """Parse AND expression."""
        left = self._parse_not()

        while self._current_token() and self._current_token()[0] == "AND":
            self._consume("AND")
            right = self._parse_not()
            left = left and right

        return left

    def _parse_not(self, _depth: int = 0) -> Any:
        """Parse NOT expression."""
        if _depth > 50:
            raise ConditionError("Maximum NOT nesting depth exceeded (50)")
        if self._current_token() and self._current_token()[0] == "NOT":
            self._consume("NOT")
            return not self._parse_not(_depth + 1)
        return self._parse_comparison()

    def _parse_comparison(self) -> Any:
        """Parse comparison expression."""
        left = self._parse_primary()

        token = self._current_token()
        if token:
            op = token[0]
            if op in ("EQ", "NE", "LT", "GT", "LE", "GE"):
                self._consume()
                right = self._parse_primary()
                if op == "EQ":
                    return left == right
                elif op == "NE":
                    return left != right
                elif op == "LT":
                    try:
                        return left < right
                    except TypeError:
                        return False
                elif op == "GT":
                    try:
                        return left > right
                    except TypeError:
                        return False
                elif op == "LE":
                    try:
                        return left <= right
                    except TypeError:
                        return False
                elif op == "GE":
                    try:
                        return left >= right
                    except TypeError:
                        return False
            elif op == "MATCHES":
                self._consume()
                pattern = self._parse_primary()
                return fnmatch.fnmatch(str(left), str(pattern))
            elif op == "CONTAINS":
                self._consume()
                substring = self._parse_primary()
                return str(substring) in str(left)
            elif op == "STARTSWITH":
                self._consume()
                prefix = self._parse_primary()
                return str(left).startswith(str(prefix))
            elif op == "ENDSWITH":
                self._consume()
                suffix = self._parse_primary()
                return str(left).endswith(str(suffix))
            elif op == "IN":
                self._consume()
                collection = self._parse_primary()
                if isinstance(collection, (list, tuple)):
                    return left in collection
                return str(left) in str(collection)

        return left

    def _parse_primary(self) -> Any:
        """Parse primary expression (literals, identifiers, parentheses)."""
        token = self._current_token()

        if token is None:
            raise ConditionError("Unexpected end of expression")

        if token[0] == "LPAREN":
            self._consume("LPAREN")
            result = self._parse_or()
            self._consume("RPAREN")
            return result

        if token[0] == "LBRACKET":
            return self._parse_list()

        if token[0] == "STRING":
            self._consume()
            return token[1]

        if token[0] == "NUMBER":
            self._consume()
            return token[1]

        if token[0] in ("TRUE", "FALSE"):
            self._consume()
            return token[1]

        if token[0] == "IDENTIFIER":
            self._consume()
            return self._resolve_identifier(token[1])

        raise ConditionError(f"Unexpected token: {token}")

    def _parse_list(self) -> list[Any]:
        """Parse a list literal."""
        self._consume("LBRACKET")
        items = []

        while self._current_token() and self._current_token()[0] != "RBRACKET":
            items.append(self._parse_primary())
            if self._current_token() and self._current_token()[0] == "COMMA":
                self._consume("COMMA")

        self._consume("RBRACKET")
        return items

    def _resolve_identifier(self, name: str) -> Any:
        """Resolve an identifier to its value in the context."""
        parts = name.split(".")
        value = self._context

        for part in parts:
            # Block access to dunder attributes to prevent introspection attacks
            if part.startswith("_"):
                raise ConditionError(f"Access to private/dunder attributes is not allowed: {part}")
            if isinstance(value, dict):
                if part in value:
                    value = value[part]
                else:
                    return ""  # Unknown identifier returns empty string
            elif hasattr(value, part):
                value = getattr(value, part)
            else:
                return ""  # Unknown attribute returns empty string

        return value


# Singleton instance for convenience
_evaluator = ConditionEvaluator()


def evaluate_condition(expression: str, context: ConditionContext) -> bool:
    """Evaluate a condition expression.

    Convenience function using the singleton evaluator.

    Args:
        expression: The condition expression.
        context: The evaluation context.

    Returns:
        True if the condition is met.
    """
    return _evaluator.evaluate(expression, context)


def validate_condition(expression: str) -> tuple[bool, str | None]:
    """Validate a condition expression.

    Convenience function using the singleton evaluator.

    Args:
        expression: The condition expression.

    Returns:
        Tuple of (is_valid, error_message).
    """
    return _evaluator.validate(expression)
