"""Solver model implementation using expression graph format.

Provides SolverModel for defining optimization problems with variables,
objectives, and constraints. Expressions are serialized to a Hash-based AST
format using numeric operator codes.

## Architecture

This module implements a declarative interface for optimization modeling:

1. **Model Building**: Users define decision variables, objectives, and constraints
   using the SolverModel API (solve_for, minimize, maximize, satisfy).

2. **Expression Rewriting**: SymbolifyContext converts DSL expressions into a
   Hash-based Abstract Syntax Tree (AST) suitable for serialization:
   - Each AST node is identified by a Hash value
   - Operators are encoded as numeric codes (see _OP_CODES table)
   - Arguments reference child nodes via Hash IDs or literal values

3. **Serialization Format**: The AST is exported to CSV tables:
   - Variables: properties (name, type, bounds, start)
   - Expressions: root nodes (objectives/constraints)
   - Operators: Hash node -> operator code
   - Arguments: Hash node -> child nodes (ordered or unordered)

4. **Solver Integration**: Serialized model is sent to external solver service,
   results are imported back from Snowflake views.

## Example AST Structure

For an expression like "x + y * 2":
- Node1 (Hash=abc123): operator=MUL(22), args=[y_hash, 2]
- Node2 (Hash=def456): operator=ADD(20), args=[x_hash, abc123]
- Expression root: Hash=def456

## Classes

    SolverModel: Main interface for building optimization models.
    Symbolic: Wrapper marking expressions containing solver variables.
    SymbolifyContext: Manages expression rewriting to symbolic Hash-based AST form.
"""

from __future__ import annotations

from typing import Any, Optional
from collections import defaultdict
import logging
import uuid
import warnings
from pandas import DataFrame

from relationalai.semantics.frontend import base as b
from relationalai.semantics import std, String, Float, Integer, Any as CoreAny
from relationalai.semantics.frontend.core import Hash, TypeVar, cast as core_cast
from relationalai.util.schema import get_provider
from relationalai.client import connect_sync
from v0.relationalai.clients.resources.snowflake import Resources as SnowflakeResources
from v0.relationalai.errors import RAIAbortedTransactionError

logger = logging.getLogger(__name__)

# Library for custom aggregation relations used in solver expression handling.
library = b.Library("aggregates")

# =============================================================================
# Constants
# =============================================================================
REASONER_TYPE = "PRESCRIPTIVE"


# =============================================================================
# Solver Serialization Format Constants
# =============================================================================
# Wire format codes for solver model serialization. Each entry maps an operator
# name to (numeric_code, category). Categories group operators for dispatch.

_OP_CODES: dict[str, tuple[int, str]] = {
    # Variable types (codes 0-9)
    "cont": (0, "var"),
    "int": (1, "var"),
    "bin": (2, "var"),
    # Expression types (codes 10-19)
    "min_objective": (10, "expr"),
    "max_objective": (11, "expr"),
    "constraint": (12, "expr"),
    # Arithmetic — infix display (codes 20-24)
    "+": (20, "arith"),
    "-": (21, "arith"),
    "*": (22, "arith"),
    "/": (23, "arith"),
    "^": (24, "arith"),
    # Math functions (codes 40-47)
    "abs": (40, "math"),
    "exp": (41, "math"),
    "natural_log": (42, "math"),
    "log": (43, "math"),
    "sqrt": (44, "math"),
    "square": (45, "math"),
    "cbrt": (46, "math"),
    "power": (47, "math"),
    # Comparisons (codes 70-75)
    "=": (70, "cmp"),
    "!=": (71, "cmp"),
    "<": (72, "cmp"),
    "<=": (73, "cmp"),
    ">": (74, "cmp"),
    ">=": (75, "cmp"),
    # Logical (codes 80-89)
    "implies": (80, "logic"),
    # Aggregates (codes 90-93)
    "sum": (90, "agg"),
    "min": (91, "agg"),
    "max": (92, "agg"),
    "count": (93, "agg"),
    # Global constraints (codes 110-112)
    "all_different": (110, "global"),
    "special_ordered_set_type_1": (111, "global"),
    "special_ordered_set_type_2": (112, "global"),
}

# Derived lookups (used by expression rewriting, display formatting, and validation).
_CODE_TO_NAME: dict[int, str] = {code: name for name, (code, _) in _OP_CODES.items()}
_VAR_CODES: dict[str, int] = {n: c for n, (c, cat) in _OP_CODES.items() if cat == "var"}
_EXPR_CODES: dict[str, int] = {n: c for n, (c, cat) in _OP_CODES.items() if cat == "expr"}
_EXPR_CONCEPT_NAMES: dict[str, str] = {
    "constraint": "Constraint",
    "min_objective": "MinObjective",
    "max_objective": "MaxObjective",
}
_EXPR_TYPE_LABELS: list[tuple[str, str]] = [
    ("min_objective", "Minimization objectives"),
    ("max_objective", "Maximization objectives"),
    ("constraint", "Constraints"),
]
_FIRST_ORDER_CODES: dict[str, int] = {n: c for n, (c, cat) in _OP_CODES.items() if cat in ("arith", "math")}
_COMPARISON_CODES: dict[str, int] = {n: c for n, (c, cat) in _OP_CODES.items() if cat in ("cmp", "logic")}
_HIGHER_ORDER_CODES: dict[str, int] = {n: c for n, (c, cat) in _OP_CODES.items() if cat in ("agg", "global")}
_INFIX: frozenset[str] = frozenset(n for n, (_, cat) in _OP_CODES.items() if cat == "arith")
_INFIX_AND_CMP: frozenset[str] = _INFIX | frozenset(n for n, (_, cat) in _OP_CODES.items() if cat == "cmp")

# Operators not supported by any solver backend (error messages reference these).
_UNSUPPORTED_OPS: frozenset[str] = frozenset({"%", "//", "floor", "ceil", "trunc", "round"})


# =============================================================================
# Utilities
# =============================================================================


def make_name(*args: Any, sep: Optional[str] = "_") -> Any:
    """Construct a name by concatenating arguments.

    Args:
        *args: Arguments to concatenate (strings, numbers, or lists).
        sep: Separator between arguments. Defaults to "_".

    Returns:
        String or expression representing the concatenated name.

    Raises:
        ValueError: If no arguments provided.
    """
    if not args:
        raise ValueError("No arguments provided to `make_name`")

    if len(args) == 1:
        arg = args[0]
        if isinstance(arg, str):
            return arg
        elif isinstance(arg, list):
            return make_name(*arg, sep=sep)
        else:
            return std.strings.string(arg)

    if sep:
        str_args: list[Any] = []
        for a in args:
            str_args.append(std.strings.string(a))
            str_args.append(sep)
        str_args.pop()  # Remove trailing separator.
    else:
        str_args = [std.strings.string(a) for a in args]

    return std.strings.concat(*str_args)


# Relation for computing hash values from argument lists.
_hash = library.Relation("hash", [b.Field.input("args", CoreAny, is_list=True), b.Field("hash", Hash)])


def hash_with_res(args: list[b.Value], res: b.Value) -> b.Expression:
    """Compute a hash of the given arguments and bind to res."""
    return _hash(b.TupleVariable(args), res)


# =============================================================================
# Constraint Constructors
# =============================================================================

_implies = library.Relation("implies", fields=[b.Field.input("left", CoreAny), b.Field.input("right", CoreAny)])


def implies(left: Any, right: Any) -> b.Expression:
    """Logical implication constraint: left => right."""
    return _implies(left, right)


_all_different = library.Relation("all_different", fields=[b.Field.input("over", TypeVar)])


def all_different(*args: b.Value) -> b.Aggregate:
    """All-different constraint: all variables must take pairwise distinct values."""
    return b.Aggregate(_all_different, *args)


_sos1 = library.Relation(
    "special_ordered_set_type_1", fields=[b.Field.input("index", Integer), b.Field.input("variables", CoreAny)]
)


def special_ordered_set_type_1(index: Any, variables: Any) -> b.Aggregate:
    """SOS1 constraint: at most one variable in the set can be non-zero."""
    return b.Aggregate(_sos1, index, variables)


_sos2 = library.Relation(
    "special_ordered_set_type_2", fields=[b.Field.input("index", Integer), b.Field.input("variables", CoreAny)]
)


def special_ordered_set_type_2(index: Any, variables: Any) -> b.Aggregate:
    """SOS2 constraint: at most two consecutive variables (by index) can be non-zero."""
    return b.Aggregate(_sos2, index, variables)


# Workaround: PyRel model defines are additive (no deletion), so results
# accumulate across re-solves. Each solve's points are offset by
# (solve_counter - 1) * _MAX_SOLUTIONS_PER_SOLVE to avoid FD conflicts.
# Used by both client-side and server-side import paths.
# TODO: Replace with proper data deletion/replacement when PyRel supports it.
_MAX_SOLUTIONS_PER_SOLVE = 100000


def _load_data_with_retry(
    resources: Any,
    app_name: str,
    table_fqn: str,
    engine_name: str,
    max_retries: int = 6,
    initial_delay: float = 2.0,
) -> None:
    """Load data via api.load_data with retry on base model ABORTED errors.

    The 2-parameter api.load_data overload installs a base model via load_base.
    If a prior base model installation hasn't fully committed, load_base
    conflicts and the transaction is ABORTED. Retrying with exponential backoff
    plus jitter lets the in-flight installation settle and avoids thundering herd
    when parallel workers retry simultaneously.
    """
    import random
    import time

    for attempt in range(max_retries):
        try:
            resources._exec(
                f"call {app_name}.api.load_data("
                f"{app_name}.api.object_reference('VIEW', '{table_fqn}'), "
                f"'{engine_name}')",
                skip_engine_db_error_retry=True,
            )
            return
        except Exception as e:
            is_aborted = isinstance(e, RAIAbortedTransactionError) or "aborted" in str(e).lower()
            if is_aborted and attempt < max_retries - 1:
                delay = initial_delay * (2 ** attempt) + random.uniform(0, 2)
                logger.info(
                    "load_data ABORTED (attempt %d/%d), retrying in %.1fs: %s",
                    attempt + 1, max_retries, delay, e,
                )
                time.sleep(delay)
                continue
            raise


# =============================================================================
# Main Solver Model Class
# =============================================================================


class SolverModel:
    """Declarative interface for building and solving optimization problems.

    Typical usage::

        s = SolverModel(model, Float)
        s.solve_for(X.value, name=["x", X.id], lower=0)
        s.minimize(sum(X.cost * X.value))
        s.satisfy(model.require(sum(X.value) <= budget))
        s.solve("highs")

    Args passed to the constructor:
        model: The RelationalAI model containing the data and relationships.
        numeric_type: ``Float`` for LP/MIP solvers (HiGHS, Gurobi, Ipopt),
            ``Integer`` for constraint programming (MiniZinc).
    """

    def __init__(self, model: b.Model, numeric_type: b.Concept) -> None:
        """Initialize a solver model.

        Args:
            model: The RelationalAI model to attach solver constructs to.
            numeric_type: ``Float`` for HiGHS/Gurobi/Ipopt (even for MIPs),
                ``Integer`` for MiniZinc. This determines the numeric data
                type used for variable bounds, solution values, and AST
                literals — not the variable types in your problem.
        """
        if not (numeric_type is Float or numeric_type is Integer):
            raise ValueError(f"Invalid numeric type '{numeric_type}'. Must be Float or Integer.")
        self._model = model
        self._numeric_type = numeric_type
        self._model_id = str(uuid.uuid4()).upper().replace("-", "_")
        self._load_point_counter = 0
        self._solve_counter = 0
        self._ov_rule_defined = False
        # Maps relationships to their corresponding variable concepts.
        self._variable_relationships: dict[int, b.Concept] = {}

        Concept = model.Concept
        Property = model.Property

        # Variable concept and its properties.
        Variable = self.Variable = Concept("Variable")
        Variable.name = Property(f"{Variable} has {String:name}")
        Variable.type = Property(f"{Variable} has {Integer:type}")
        Variable.lower = Property(f"{Variable} has {numeric_type:lower}")
        Variable.upper = Property(f"{Variable} has {numeric_type:upper}")
        Variable.start = Property(f"{Variable} has {numeric_type:start}")

        # Expression concept and its properties.
        Expression = self.Expression = Concept("Expression")
        Expression.name = Property(f"{Expression} has {String:name}")
        Expression.type = Property(f"{Expression} has {Integer:type}")
        Expression.root = Property(f"{Expression} has {Hash:root}")

        self._expr_counter = 0
        self._expr_subconcepts: list[b.Concept] = []

        # AST structure properties for expression nodes.
        self._operator = Property(f"{Hash:node} has {Integer:op}")
        self._ordered_args_hash = Property(f"{Hash:node} arg {Integer:i} is {Hash:arg}")
        self._ordered_args_data = Property(f"{Hash:node} arg {Integer:i} is {numeric_type:arg}")
        self._unordered_args_hash = Property(f"{Hash:node} arg {Hash:i} is {Hash:arg}")

        # Reachability: transitive closure from Expression roots through AST args.
        # Filters out orphaned AST nodes (from expressions where one arm is empty).
        n0, n1, i_, h_ = Hash.ref(), Hash.ref(), Integer.ref(), Hash.ref()
        self._reachable = model.Relationship(f"{Hash}")
        model.define(self._reachable(Expression.root))
        ctx = model.where(self._reachable(n0))
        ctx.define(self._reachable(n1)).where(self._ordered_args_hash(n0, i_, n1))
        ctx.define(self._reachable(n1)).where(self._unordered_args_hash(n0, h_, n1))

        # Result relations for solver outputs.
        self._points = Property(f"{Integer:sol_index} has {Hash:var_hash} with {numeric_type:value}")
        self._point = Property(f"{Hash:var_hash} has {numeric_type:value}")
        # Trace of load_point updates; max aggregation selects the latest.
        self._point_updates = Property(f"at {Integer:update} we load {Integer:point}")

        # Versioned result info: each solve writes with a new version counter.
        # result_info/objective_values are derived via max-aggregation on _result_info_updates.
        self._result_info_versioned = Property(f"solve {Integer:v} result {String:key} is {String:val}")
        self._result_info = model.Relationship(f"{String:key} has {String:val}")
        self._result_info_updates = Property(f"at {Integer:update} we use result version {Integer:v}")
        self._objective_values_versioned = Property(
            f"solve {Integer:v} solution {Integer:sol_index} has {numeric_type:obj_value}"
        )
        self._objective_values = Property(f"{Integer:sol_index} has {numeric_type:obj_value}")

    # =========================================================================
    # Subconcept Creation
    # =========================================================================

    def _new_var_subconcept(
        self, relationship: b.Relationship, var_type: str, name: Optional[str] = None,
    ) -> b.Concept:
        """Create a Variable subconcept with standing type rule (and name if scalar)."""
        Var = self._model.Concept(f"Variable_{relationship._id}", extends=[self.Variable])
        ref = Var.ref()
        standing = [ref.type(_VAR_CODES[var_type])]
        if name is not None:
            standing.append(ref.name(name))
        self._model.define(*standing).where(ref)
        return Var

    def _new_expr_subconcept(
        self, expr_type: str, name: Optional[str] = None,
    ) -> b.Concept:
        """Create an Expression subconcept with standing type rule (and name if scalar)."""
        self._expr_counter += 1
        sub = self._model.Concept(
            f"{_EXPR_CONCEPT_NAMES[expr_type]}_{self._expr_counter}",
            extends=[self.Expression],
        )
        self._expr_subconcepts.append(sub)
        ref = sub.ref()
        standing = [ref.type(_EXPR_CODES[expr_type])]
        if name is not None:
            standing.append(ref.name(name))
        self._model.define(*standing).where(ref)
        return sub

    # =========================================================================
    # Variable Handling
    # =========================================================================

    def solve_for(
        self,
        expr: b.Relationship | b.Chain | b.Expression,
        where: Optional[list[Any]] = None,
        populate: bool = True,
        name: Optional[Any | list[Any]] = None,
        type: Optional[str] = None,
        lower: Optional[b.Variable | float | int] = None,
        upper: Optional[b.Variable | float | int] = None,
        start: Optional[b.Variable | float | int] = None,
    ) -> b.Concept:
        """Declare decision variables for the solver.

        Args:
            expr: Relationship, Chain, or Expression defining the variable(s).
            where: Optional conditions filtering which variable instances to create.
            populate: If True (default), create a derivation rule that writes
                solver values back to the original relationship after solving.
                Safe for single-SolverModel workflows (including re-solves).
                Set to False when creating multiple SolverModel instances that
                solve for the same property on the same Model — otherwise the
                competing derivation rules cause a functional dependency
                violation. Use ``variable_values()`` to access results instead.
            name: Display name. String for scalar, list for indexed (e.g. ``["x", Item.i]``).
            type: ``"cont"`` (default for Float), ``"int"`` (default for Integer),
                or ``"bin"`` (binary 0/1).
            lower: Lower bound (constant or expression).
            upper: Upper bound (constant or expression).
            start: Initial value hint (warm start).

        Returns:
            Variable subconcept (Concept). Pass to ``display()`` to inspect.
        """
        relationship, args = self._extract_relationship(expr)
        key = _get_key(relationship)

        if key in self._variable_relationships:
            raise ValueError(
                "Variables already defined for this relationship. "
                "Each relationship can only be used once in solve_for()."
            )

        # Resolve name: scalar names go in the standing rule, DSL names go per-entity.
        name_val = make_name(name if name is not None else "_")
        scalar_name = None if isinstance(name_val, b.DSLBase) else name_val

        Var = self._new_var_subconcept(relationship, self._resolve_var_type(type), name=scalar_name)
        self._variable_relationships[key] = Var

        # Entity creation + DSL-valued properties (need where context for field bindings).
        fields = {f.name: args[i] for i, f in enumerate(relationship._fields[:-1])}
        var = Var.new(var_id=relationship._id, **fields)
        var_defs: list[Any] = [var]
        if isinstance(name_val, b.DSLBase):
            var_defs.append(var.name(name_val))

        # Scalar-valued optional properties: uses ref() for cleaner IR.
        ref = Var.ref()
        ref_defs: list[Any] = []
        for attr, val in [("lower", lower), ("upper", upper), ("start", start)]:
            if val is not None:
                self._check_numeric(attr, val)
                if isinstance(val, b.DSLBase):
                    var_defs.append(getattr(var, attr)(val))
                else:
                    ref_defs.append(getattr(ref, attr)(val))

        self._model.define(*var_defs).where(*(where or []))
        if ref_defs:
            self._model.define(*ref_defs).where(ref)

        if populate:
            if not hasattr(self._model, '_solver_populated'):
                self._model._solver_populated = set()  # type: ignore[attr-defined]
            if key in self._model._solver_populated:  # type: ignore[attr-defined]
                warnings.warn(
                    "Multiple SolverModels populating the same relationship will cause "
                    "a functional dependency violation. Use populate=False on all but one, "
                    "or use variable_values() to read results instead.",
                    stacklevel=2,
                )
            self._model._solver_populated.add(key)  # type: ignore[attr-defined]

            val_ref = self._numeric_type.ref()
            pop_var = Var.ref()
            var_hash = core_cast(b.MetaRef(Hash), pop_var)
            field_args = [getattr(pop_var, fn) for fn in fields.keys()]
            self._model.where(self._point(var_hash, val_ref), *(where or [])).define(relationship(*field_args, val_ref))

        return Var

    def _extract_relationship(self, expr: b.Relationship | b.Chain | b.Expression) -> tuple[b.Relationship, list[Any]]:
        """Extract relationship and args from variable expression."""
        if isinstance(expr, b.Relationship):
            return expr, [f.type for f in expr._fields]
        elif isinstance(expr, b.Chain):
            if not isinstance(expr._start, b.Concept) or not isinstance(expr._next, b.Relationship):
                raise TypeError("Chain must have Concept start and Relationship next. Example: solve_for(Entity.value)")
            return expr._next, [f.type for f in expr._next._fields]
        elif isinstance(expr, b.Expression):
            rel = expr._op
            if not isinstance(rel, b.Relationship):
                raise TypeError(
                    f"Expression operator must be Relationship, got {type(rel).__name__}. "
                    f"Example: solve_for(my_relation(x, y))"
                )
            if len(expr._args) != len(rel._fields):
                field_names = [f.name for f in rel._fields]
                raise ValueError(
                    f"Argument count mismatch: {len(expr._args)} arguments provided, "
                    f"but relationship '{rel._name}' expects {len(rel._fields)} fields: {', '.join(field_names)}. "
                    f"Example: solve_for({rel._name}({', '.join('...' for _ in rel._fields)}))"
                )
            return rel, expr._args
        raise TypeError(
            f"Invalid expression type: {type(expr).__name__}. "
            f"Expected Relationship, Chain, or Expression. "
            f"Example: solve_for(my_relation) or solve_for(Entity.value)"
        )

    def _resolve_var_type(self, var_type: Optional[str]) -> str:
        """Resolve variable type, defaulting based on model's numeric type."""
        if var_type is None:
            return "cont" if self._numeric_type is Float else "int"
        if not isinstance(var_type, str):
            raise TypeError(
                f"Variable type must be a string, got {type(var_type).__name__}. Example: solve_for(x, type='int')"
            )
        if var_type not in _VAR_CODES:
            valid_types = ", ".join(f"'{t}'" for t in _VAR_CODES.keys())
            raise ValueError(
                f"Invalid variable type '{var_type}'. Valid types are: {valid_types}. "
                f"Example: solve_for(x, type='cont', lower=0, upper=10)"
            )
        return var_type

    def _check_numeric(self, name: str, val: Any) -> None:
        """Validate that a value is numeric (Variable, float, or int)."""
        if not isinstance(val, (b.Variable, float, int)):
            raise TypeError(
                f"Parameter '{name}' must be numeric (Variable, float, or int), "
                f"got {type(val).__name__}. "
                f"Example: solve_for(x, {name}=0.0)"
            )

    # =========================================================================
    # Expression Handling
    # =========================================================================

    def minimize(
        self,
        expr: b.Variable | float | int | b.Fragment,
        name: Optional[Any | list[Any]] = None,
    ) -> b.Concept:
        """Add a minimization objective.

        The expression must reference at least one solver variable.

        Returns:
            Expression subconcept (Concept). Pass to ``display()`` to inspect.
        """
        scalar_name, dsl_name = self._split_name(name)
        sub = self._new_expr_subconcept("min_objective", name=scalar_name)
        self._add_expression(sub, expr, dsl_name)
        return sub

    def maximize(
        self,
        expr: b.Variable | float | int | b.Fragment,
        name: Optional[Any | list[Any]] = None,
    ) -> b.Concept:
        """Add a maximization objective.

        The expression must reference at least one solver variable.

        Returns:
            Expression subconcept (Concept). Pass to ``display()`` to inspect.
        """
        scalar_name, dsl_name = self._split_name(name)
        sub = self._new_expr_subconcept("max_objective", name=scalar_name)
        self._add_expression(sub, expr, dsl_name)
        return sub

    def satisfy(
        self,
        expr: b.Fragment,
        name: Optional[Any | list[Any]] = None,
    ) -> b.Concept:
        """Add constraints from a require clause.

        Args:
            expr: A Fragment created by ``model.require(...)`` or
                ``model.where(...).require(...)``.
            name: Optional constraint name or name pattern (e.g.,
                ``["c", X.id]`` for indexed constraints).

        Returns:
            Expression subconcept (Concept). Pass to ``display()`` to inspect.
        """
        if not isinstance(expr, b.Fragment):
            raise TypeError(
                f"satisfy() expects a Fragment from model.require(...), "
                f"got {type(expr).__name__}. "
                f"Example: s.satisfy(model.require(x >= 5))"
            )
        if not expr._require:
            raise ValueError("Fragment must have a require clause. Example: s.satisfy(model.require(x >= 5))")
        if expr._select or expr._define:
            raise ValueError("Fragment must not have select or define clauses.")

        # Remove constraint from model roots to prevent double-evaluation during queries.
        if expr in self._model.requires:
            self._model._remove_rule(expr)

        scalar_name, dsl_name = self._split_name(name)
        sub = self._new_expr_subconcept("constraint", name=scalar_name)
        for req in expr._require:
            self._add_expression(sub, req, dsl_name, expr._where)
        return sub

    @staticmethod
    def _split_name(name: Optional[Any | list[Any]]) -> tuple[Optional[str], Optional[Any]]:
        """Split a name into scalar and DSL components.

        Returns (scalar_name, dsl_name): one is set, the other is None.
        Scalar names go in standing rules; DSL names go per-entity.
        """
        if name is None:
            return None, None
        val = make_name(name)
        if isinstance(val, b.DSLBase):
            return None, name
        return val, None

    def _add_expression(
        self,
        expr_sub: Any,
        expr: Any,
        name: Optional[Any | list[Any]],
        where: Optional[list[Any]] = None,
    ) -> None:
        """Add an objective or constraint expression to the model."""
        ctx = SymbolifyContext(self)
        if where:
            ctx.wheres.extend(ctx.rewrite_where(*where))

        result = ctx.rewrite(expr)
        if not isinstance(result, Symbolic):
            raise ValueError(
                f"Expression of type '{type(expr).__name__}' does not reference any solver variables. "
                f"Ensure it contains at least one variable declared via solve_for()."
            )

        expr_node = expr_sub.new(root=result.expr)
        ctx.defines.append(expr_node)
        if name is not None:
            ctx.defines.append(expr_node.name(make_name(name)))

        self._apply_context(ctx)

    def _apply_context(self, ctx: SymbolifyContext) -> None:
        """Recursively apply defines and wheres from context tree."""
        self._model.define(*ctx.defines).where(*ctx.wheres)
        for subctx in ctx.subcontexts:
            self._apply_context(subctx)

    # =========================================================================
    # Model Inspection and Display
    # =========================================================================

    def _count_entities(self, concept: b.Concept, type_code: Optional[int] = None) -> int:
        """Count model entities, optionally filtered by type code."""
        if type_code is None:
            df = self._model.select(std.aggregates.count(concept) | 0).to_df()
        else:
            df = self._model.select(
                std.aggregates.count(concept).where(concept.type == type_code) | 0
            ).to_df()
        if df.shape != (1, 1):
            raise ValueError(f"Expected single count value, got shape {df.shape}.")
        return int(df.iloc[0, 0])  # type: ignore[arg-type]

    @property
    def num_variables(self) -> int:
        """Total number of decision variables (queries the model)."""
        return self._count_entities(self.Variable)

    @property
    def num_constraints(self) -> int:
        """Number of constraints (queries the model)."""
        return self._count_entities(self.Expression, _EXPR_CODES["constraint"])

    @property
    def num_min_objectives(self) -> int:
        """Number of minimization objectives (queries the model)."""
        return self._count_entities(self.Expression, _EXPR_CODES["min_objective"])

    @property
    def num_max_objectives(self) -> int:
        """Number of maximization objectives (queries the model)."""
        return self._count_entities(self.Expression, _EXPR_CODES["max_objective"])

    def display(self, part: Optional[b.Concept] = None) -> str:
        """Print and return a human-readable model summary.

        With no arguments, shows the full model (variables, objectives, constraints).
        Pass a subconcept returned by solve_for/minimize/maximize/satisfy to display
        only that part::

            x = s.solve_for(X.value, name="x", lower=0)
            cap = s.satisfy(model.require(flow <= capacity), name="cap")

            s.display()      # full model
            s.display(cap)   # just the capacity constraints
            s.display(x)     # just the x variables
        """
        if part is None:
            lines = self._display_full()
        else:
            lines = self._display_parts(part)
        output = "\n".join(lines)
        print(output)
        return output

    def _display_full(self) -> list[str]:
        """Build display lines for the full model."""
        from pandas import Series

        model = self._model
        Var, Expr = self.Variable, self.Expression

        # TODO(coey): In future, issue multiple queries at the same time to reduce overhead.
        # Currently causes failures when running the pytest suite or the run_all.py script.
        # See https://relationalai.slack.com/archives/C08ATF88BC6/p1770146491179649?thread_ts=1768955416.245459&cid=C08ATF88BC6

        var_df = model.select(Var.type, Var, Var.name, Var.lower, Var.upper).to_df()
        var_types = var_df["type"].astype(int) if not var_df.empty else Series(dtype=int)

        expr_df = model.select(Expr.type, Expr.name, Expr.root).to_df()
        expr_types = expr_df["type"].astype(int) if not expr_df.empty else Series(dtype=int)

        ast_dfs = self._query_ast(self._reachable)

        # Build summary header.
        var_counts = var_types.value_counts()
        expr_counts = expr_types.value_counts()
        def get(counts: Any, codes: dict[str, int], key: str) -> int:
            return int(counts.get(codes[key], 0))

        n_cont = get(var_counts, _VAR_CODES, "cont")
        n_int = get(var_counts, _VAR_CODES, "int")
        n_bin = get(var_counts, _VAR_CODES, "bin")
        n_min = get(expr_counts, _EXPR_CODES, "min_objective")
        n_max = get(expr_counts, _EXPR_CODES, "max_objective")
        n_con = get(expr_counts, _EXPR_CODES, "constraint")

        lines: list[str] = []
        lines.append("Solver model has:")
        lines.append(f"• {len(var_df)} variables: {n_cont} continuous, {n_int} integer, {n_bin} binary")
        lines.append(f"• {n_min} minimization objectives, {n_max} maximization objectives, {n_con} constraints")

        lines.extend(self._format_variable_lines(var_df, var_types))
        lines.extend(self._format_expression_lines(expr_df, expr_types, var_df, ast_dfs))
        return lines

    def _is_var_subconcept(self, concept: b.Concept) -> bool:
        """Check if concept is a Variable subconcept (identity comparison)."""
        return any(concept is v for v in self._variable_relationships.values())

    def _is_expr_subconcept(self, concept: b.Concept) -> bool:
        """Check if concept is an Expression subconcept (identity comparison)."""
        return any(concept is e for e in self._expr_subconcepts)

    def _display_parts(self, part: b.Concept) -> list[str]:
        """Build display lines for a single variable or expression subconcept."""
        from pandas import Series

        model = self._model

        if self._is_var_subconcept(part):
            var_df = model.select(part.type, part, part.name, part.lower, part.upper).to_df()
            var_types = var_df["type"].astype(int) if not var_df.empty else Series(dtype=int)
            return self._format_variable_lines(var_df, var_types)

        if self._is_expr_subconcept(part):
            # Query all variables (needed for formatting variable references in expressions).
            Var = self.Variable
            var_df = model.select(Var.type, Var, Var.name, Var.lower, Var.upper).to_df()

            # Reachability scoped to this subconcept's roots. These define()
            # calls add rules to the model that persist (PyRel defines are additive).
            # Transitive closure requires recursive rules, so there's no pure-query
            # alternative. The rules are cheap (anonymous Hash relationships) but each
            # call bumps the model version. For frequent partial display calls, prefer
            # display() with no argument (uses the standing _reachable rule).
            n0, n1, i_, h_ = Hash.ref(), Hash.ref(), Integer.ref(), Hash.ref()
            reachable = model.Relationship(f"{Hash}")
            sub_ref = part.ref()
            model.define(reachable(sub_ref.root)).where(sub_ref)
            ctx = model.where(reachable(n0))
            ctx.define(reachable(n1)).where(self._ordered_args_hash(n0, i_, n1))
            ctx.define(reachable(n1)).where(self._unordered_args_hash(n0, h_, n1))

            expr_df = model.select(part.type, part.name, part.root).to_df()
            expr_types = expr_df["type"].astype(int) if not expr_df.empty else Series(dtype=int)
            ast_dfs = self._query_ast(reachable)
            return self._format_expression_lines(expr_df, expr_types, var_df, ast_dfs)

        raise ValueError(
            f"Unrecognized display part: {part!r}. "
            f"Pass a subconcept returned by solve_for(), minimize(), maximize(), or satisfy()."
        )

    def _query_ast(self, reachable: Any) -> tuple[Any, Any, Any, Any]:
        """Query AST nodes filtered by a reachability relation."""
        model = self._model
        n0 = Hash.ref().alias("node")
        n1 = Hash.ref().alias("arg")
        i = Integer.ref().alias("index")
        h = Hash.ref().alias("hash")
        op = Integer.ref().alias("op")
        v = self._numeric_type.ref().alias("value")

        operators_df = model.select(n0, op).where(self._operator(n0, op), reachable(n0)).to_df()
        ordered_hash_df = model.select(n0, i, n1).where(self._ordered_args_hash(n0, i, n1), reachable(n0)).to_df()
        ordered_data_df = model.select(n0, i, v).where(self._ordered_args_data(n0, i, v), reachable(n0)).to_df()
        unordered_df = model.select(n0, n1).where(self._unordered_args_hash(n0, h, n1), reachable(n0)).to_df()
        return operators_df, ordered_hash_df, ordered_data_df, unordered_df

    @staticmethod
    def _format_variable_lines(var_df: Any, var_types: Any) -> list[str]:
        """Format variable table lines."""
        if var_df.empty:
            return []
        var_df["type"] = var_types.map(_CODE_TO_NAME)
        cols = ["name", "type"] + [c for c in ("lower", "upper") if var_df[c].notna().any()]
        return [
            "\nVariables:",
            var_df.sort_values(by="name")[cols].to_string(index=False),  # type: ignore[call-overload]
        ]

    @staticmethod
    def _format_expression_lines(
        expr_df: Any, expr_types: Any, var_df: Any, ast_dfs: tuple[Any, Any, Any, Any],
    ) -> list[str]:
        """Format expression lines grouped by type."""
        operators_df, ordered_hash_df, ordered_data_df, unordered_df = ast_dfs
        _format_expressions(expr_df, var_df, operators_df, unordered_df, ordered_data_df, ordered_hash_df)
        has_names = "name" in expr_df.columns and expr_df["name"].notna().any()

        lines: list[str] = []
        for type_key, label in _EXPR_TYPE_LABELS:
            type_code = _EXPR_CODES[type_key]
            subset = expr_df[expr_types == type_code]
            if subset.empty:
                continue
            lines.append(f"\n{label}:")
            if has_names:
                lines.append(subset.sort_values(by="name")[["name", "expression"]].to_string(index=False))  # type: ignore[call-overload]
            else:
                lines.append("\n".join(sorted(subset["expression"])))
        return lines

    # =========================================================================
    # Solve
    # =========================================================================

    def _export_model_to_csv(self) -> dict[str, str]:
        """Export model relations to CSV files for solver service.

        AST nodes are filtered through the standing _reachable relation to
        exclude orphaned nodes (from expressions where one arm is empty).
        """
        from relationalai.shims.executor import export_to_csv

        model = self._model
        Var, Expr = self.Variable, self.Expression
        reachable = self._reachable

        # Create fresh ref variables per fragment so that batching into a single
        # transaction does not create unintended cross-fragment joins.
        n0_op, op_ = Hash.ref().alias("NODE"), Integer.ref().alias("OP")
        n0_oh, i_oh, n1_oh = (
            Hash.ref().alias("NODE"),
            Integer.ref().alias("INDEX"),
            Hash.ref().alias("ARG"),
        )
        n0_od, i_od, v_od = (
            Hash.ref().alias("NODE"),
            Integer.ref().alias("INDEX"),
            self._numeric_type.ref().alias("VALUE"),
        )
        n0_uh, h_uh, n1_uh = (
            Hash.ref().alias("NODE"),
            Hash.ref().alias("HASH"),
            Hash.ref().alias("ARG"),
        )

        keys = [
            "variables",
            "expressions",
            "operators",
            "ordered_args_hash",
            "ordered_args_data",
            "unordered_args_hash",
        ]
        fragments = [
            model.select(
                Var.alias("VARIABLE"),
                Var.name.alias("NAME"),
                Var.type.alias("TYPE"),
                Var.lower.alias("LOWER"),
                Var.upper.alias("UPPER"),
                Var.start.alias("START"),
            ),
            model.select(Expr.root.alias("NODE"), Expr.name.alias("NAME"), Expr.type.alias("TYPE")),
            model.select(n0_op, op_).where(self._operator(n0_op, op_), reachable(n0_op)),
            model.select(n0_oh, i_oh, n1_oh).where(self._ordered_args_hash(n0_oh, i_oh, n1_oh), reachable(n0_oh)),
            model.select(n0_od, i_od, v_od).where(self._ordered_args_data(n0_od, i_od, v_od), reachable(n0_od)),
            model.select(n0_uh, n1_uh).where(self._unordered_args_hash(n0_uh, h_uh, n1_uh), reachable(n0_uh)),
        ]
        paths = export_to_csv(fragments)
        return dict(zip(keys, paths))

    def _import_solver_results_from_views(self, result_dfs: dict[str, DataFrame] | None = None):
        model = self._model

        if result_dfs is not None:
            _REQUIRED_KEYS = {"ancillary", "points", "objective_values"}
            missing = _REQUIRED_KEYS - result_dfs.keys()
            if missing:
                raise ValueError(f"result_dfs is missing required keys: {missing}")

        if result_dfs is None:
            resources = get_provider(config=self._model.config).resources
            if not isinstance(resources, SnowflakeResources):
                raise TypeError(f"Solver result import requires Snowflake resources, got {type(resources).__name__}.")
            app_name = resources.get_app_name()

            # Read result views via SQL into DataFrames, then inject into the model
            # via model.data(). This avoids load_data/load_base which can't handle
            # many sequential solves.
            from concurrent.futures import ThreadPoolExecutor

            view_specs = {
                "ancillary": ["NAME", "VALUE"],
                "points": ["SOL_INDEX", "VAR_HASH", "VALUE"],
                "objective_values": ["SOL_INDEX", "VALUE"],
            }
            fqns = {name: f"{app_name}.RESULTS.SOLVERS_{self._model_id}_{name.upper()}" for name in view_specs}
            num_dtype = int if self._numeric_type is Integer else float
            with ThreadPoolExecutor(max_workers=len(view_specs)) as pool:
                futures = {name: pool.submit(resources._exec, f"SELECT * FROM {fqn}") for name, fqn in fqns.items()}
                result_dfs = {
                    name: DataFrame(future.result(), columns=view_specs[name]) for name, future in futures.items()
                }
                # Cast numeric columns (SQL may return all strings).
                for key in ("points", "objective_values"):
                    result_dfs[key]["SOL_INDEX"] = result_dfs[key]["SOL_INDEX"].astype(int)
                    result_dfs[key]["VALUE"] = result_dfs[key]["VALUE"].astype(num_dtype)

        # Increment counter only after data is ready.
        self._solve_counter += 1
        solve_offset = (self._solve_counter - 1) * _MAX_SOLUTIONS_PER_SOLVE

        # Only inject points into the model (needed for _points define rule).
        # Skip if no points (e.g. infeasible) to avoid TypeError from empty data source.
        if result_dfs["points"].empty:
            p = None
        else:
            p = model.data(result_dfs["points"])
        if p is not None and "VAR_NAME" in result_dfs["points"].columns:
            # Name-based path (testing): join through Variable.name to resolve
            # entity hashes, since parse_uuid doesn't recover entity hashes.
            var = self.Variable.ref()
            var_hash = core_cast(b.MetaRef(Hash), var)
            name_ref = String.ref()
            val_ref = self._numeric_type.ref()
            model.where(
                self.Variable.name(var, name_ref),
                p.VAR_NAME == name_ref,
                p.VALUE == val_ref,
            ).define(self._points(p.SOL_INDEX + solve_offset, var_hash, val_ref))
        elif p is not None:
            # UUID-based path (production): solver returns UUID strings.
            model.define(
                self._points(
                    p.SOL_INDEX + solve_offset,
                    std.common.parse_uuid(p.VAR_HASH),
                    p.VALUE,
                )
            )

        # Insert ancillary data into versioned backing store.
        if result_dfs["ancillary"].empty:
            logger.warning("Solver returned empty ancillary data — termination_status and solve info will be unavailable.")
        else:
            ancillary_data = model.data(result_dfs["ancillary"])
            model.define(self._result_info_versioned(self._solve_counter, ancillary_data.NAME, ancillary_data.VALUE))

        # Insert objective values into versioned backing store.
        if not result_dfs["objective_values"].empty:
            obj_data = model.data(result_dfs["objective_values"])
            model.define(self._objective_values_versioned(self._solve_counter, obj_data.SOL_INDEX, obj_data.VALUE))

        # Record which result version to use.
        model.define(self._result_info_updates(self._solve_counter, self._solve_counter))

        # Derive result_info, objective_values, and _point from versioned stores.
        # Only define each rule once (first time the data exists). On re-solve,
        # new rules would be redundant (max-aggregation selects the same version).
        if self._solve_counter == 1:
            update_ref = Integer.ref()
            point_index = Integer.ref()
            var_hash = Hash.ref()
            val_ref = self._numeric_type.ref()
            model.where(
                self._point_updates(update_ref, point_index),
                update_ref == std.aggregates.max(update_ref),
                self._points(point_index, var_hash, val_ref),
            ).define(self._point(var_hash, val_ref))

            ri_update = Integer.ref()
            ri_version = Integer.ref()
            key_ref = String.ref()
            val_ref_str = String.ref()
            model.where(
                self._result_info_updates(ri_update, ri_version),
                ri_update == std.aggregates.max(ri_update),
                self._result_info_versioned(ri_version, key_ref, val_ref_str),
            ).define(self._result_info(key_ref, val_ref_str))

        # _objective_values derivation: deferred until objective data exists to
        # avoid TypeError from joining with an empty versioned Property.
        if not result_dfs["objective_values"].empty and not self._ov_rule_defined:
            ov_update = Integer.ref()
            ov_version = Integer.ref()
            ov_sol = Integer.ref()
            ov_val = self._numeric_type.ref()
            model.where(
                self._result_info_updates(ov_update, ov_version),
                ov_update == std.aggregates.max(ov_update),
                self._objective_values_versioned(ov_version, ov_sol, ov_val),
            ).define(self._objective_values(ov_sol, ov_val))
            self._ov_rule_defined = True

        # Default: load solution 1 via _point_updates trace (with solve offset).
        self._load_point_counter += 1
        model.define(self._point_updates(self._load_point_counter, 1 + solve_offset))

    def _import_solver_results_server_side(self) -> None:
        """Import solver results via api.load_data (data stays in Snowflake).

        Uses the 2-parameter api.load_data variant which handles database setup
        and base model installation automatically. Results are loaded directly
        into the logic engine without downloading to the Python client.

        Re-solve support: PyRel defines are additive, so result data accumulates
        across solves. To avoid FD conflicts, _points uses solve offsets and
        _result_info/_objective_values use versioned backing stores with
        max-aggregation (same patterns as the client-side path).

        NOTE: Currently slow (~100s overhead) due to api.load_data performance.
        A batched/LQP-native version is expected to improve this significantly.
        """
        model = self._model
        resources = get_provider(config=model.config).resources
        if not isinstance(resources, SnowflakeResources):
            raise TypeError(f"Server-side import requires Snowflake resources, got {type(resources).__name__}.")
        app_name = resources.get_app_name()
        logic_engine_name = resources.get_default_engine_name()
        num_type = Integer if self._numeric_type is Integer else Float

        schemas: dict[str, dict[str, Any]] = {
            "ancillary": {"NAME": String, "VALUE": String},
            "points": {"SOL_INDEX": Integer, "VAR_HASH": String, "VALUE": num_type},
            "objective_values": {"SOL_INDEX": Integer, "VALUE": num_type},
        }

        # Load each result view via api.load_data (sequential to avoid ABORTED errors).
        tables: dict[str, Any] = {}
        for name, schema in schemas.items():
            fqn = f"{app_name}.RESULTS.SOLVERS_{self._model_id}_{name.upper()}"
            table = model.Table(fqn, schema)
            table._skip_cdc = True  # type: ignore[attr-defined]
            tables[name] = table
            _load_data_with_retry(resources, app_name, fqn, logic_engine_name)

        ancillary_tbl = tables["ancillary"]
        points_tbl = tables["points"]
        obj_vals_tbl = tables["objective_values"]

        # Increment counter after data is ready (same as client-side path).
        self._solve_counter += 1
        solve_offset = (self._solve_counter - 1) * _MAX_SOLUTIONS_PER_SOLVE

        # Insert ancillary data into versioned backing store (keyed by solve_counter).
        model.define(self._result_info_versioned(self._solve_counter, ancillary_tbl["NAME"], ancillary_tbl["VALUE"]))

        # Insert objective values into versioned backing store.
        # No empty guard needed: Table joins on empty data produce no tuples in Rel,
        # unlike the client-side path where model.data() on an empty DataFrame raises.
        model.define(
            self._objective_values_versioned(self._solve_counter, obj_vals_tbl["SOL_INDEX"], obj_vals_tbl["VALUE"])
        )

        # Derive result_info and objective_values from versioned stores.
        if self._solve_counter == 1:
            ri_update = Integer.ref()
            ri_version = Integer.ref()
            key_ref = String.ref()
            val_ref_str = String.ref()
            model.where(
                self._result_info_updates(ri_update, ri_version),
                ri_update == std.aggregates.max(ri_update),
                self._result_info_versioned(ri_version, key_ref, val_ref_str),
            ).define(self._result_info(key_ref, val_ref_str))

        if not self._ov_rule_defined:
            ov_update = Integer.ref()
            ov_version = Integer.ref()
            ov_sol = Integer.ref()
            ov_val = self._numeric_type.ref()
            model.where(
                self._result_info_updates(ov_update, ov_version),
                ov_update == std.aggregates.max(ov_update),
                self._objective_values_versioned(ov_version, ov_sol, ov_val),
            ).define(self._objective_values(ov_sol, ov_val))
            self._ov_rule_defined = True

        # Record which result version to use.
        model.define(self._result_info_updates(self._solve_counter, self._solve_counter))

        # Points: offset solution indices to avoid FD conflicts across re-solves.
        sol_idx = Integer.ref()
        var_hash_str = String.ref()
        val_ref = self._numeric_type.ref()
        model.where(
            points_tbl["SOL_INDEX"] == sol_idx,
            points_tbl["VAR_HASH"] == var_hash_str,
            points_tbl["VALUE"] == val_ref,
        ).define(self._points(sol_idx + solve_offset, std.common.parse_uuid(var_hash_str), val_ref))

        # Define _point derivation rule once (first solve only).
        if self._solve_counter == 1:
            update_ref = Integer.ref()
            point_index = Integer.ref()
            var_hash = Hash.ref()
            val = self._numeric_type.ref()
            model.where(
                self._point_updates(update_ref, point_index),
                update_ref == std.aggregates.max(update_ref),
                self._points(point_index, var_hash, val),
            ).define(self._point(var_hash, val))

        # Default: load solution 1 via _point_updates trace (with solve offset).
        self._load_point_counter += 1
        model.define(self._point_updates(self._load_point_counter, 1 + solve_offset))

    def _wait_for_job(
        self,
        client: Any,
        job_id: str,
        log_to_console: bool = False,
        timeout_s: float = 900,
        poll_interval_s: float = 0.5,
    ) -> Any:
        """Poll job status until it reaches a terminal state, returning the job.

        Streams LogMessage events to stdout when *log_to_console* is True,
        using continuation tokens to avoid replaying events. Event streaming
        is best-effort — failures are silently ignored so they don't kill the
        solve. Performs a final event drain after the job reaches a terminal
        state (COMPLETED, FAILED, CANCELED, or ABORTED).

        Raises TimeoutError if the job has not reached a terminal state
        within *timeout_s* seconds.
        """
        import time as _time
        from relationalai.services.jobs.constants import JOB_TERMINAL_STATES

        continuation_token = ""
        deadline = _time.monotonic() + timeout_s

        def _drain_events() -> None:
            """Print LogMessage events, best-effort (errors silently ignored)."""
            nonlocal continuation_token
            try:
                events = client.jobs.get_events(
                    REASONER_TYPE,
                    job_id,
                    continuation_token=continuation_token,
                )
                for event in events.events:
                    msg = event.get("event", {}).get("message")
                    if event.get("type") == "LogMessage" and msg is not None:
                        print(msg)
                if events.continuation_token:
                    continuation_token = events.continuation_token
            except Exception:
                logger.debug("Failed to drain solver events", exc_info=True)

        while True:
            if log_to_console:
                _drain_events()

            job = client.jobs.get(REASONER_TYPE, job_id)
            if job and job.state and job.state.upper() in JOB_TERMINAL_STATES:
                if log_to_console:
                    _drain_events()
                return job

            if _time.monotonic() >= deadline:
                last_state = job.state if job and job.state else "not found"
                raise TimeoutError(f"Solver job {job_id} timed out after {timeout_s}s (last state: {last_state})")

            _time.sleep(poll_interval_s)

    def solve(
        self,
        solver: str,
        *,
        time_limit_sec: float | None = None,
        silent: bool | None = None,
        solution_limit: int | None = None,
        relative_gap_tolerance: float | None = None,
        absolute_gap_tolerance: float | None = None,
        log_to_console: bool = False,
        print_only: bool = False,
        print_format: str | None = None,
        server_side_import: bool = False,
        **solver_params: int | float | str | bool,
    ) -> None:
        """Solve the optimization problem using the specified solver.

        The named options (``time_limit_sec``, ``silent``, etc.) are
        solver-independent and work with any solver. Any additional keyword
        arguments are treated as raw solver-specific parameters and passed
        directly to the underlying solver. These may not be portable across
        solvers; a warning is emitted when they are used.

        Examples::

            # Solver-independent options (work with any solver):
            s.solve("highs", time_limit_sec=300, silent=True)

            # With raw solver-specific parameters (warning emitted):
            s.solve("highs", time_limit_sec=300, presolve="off")

        Args:
            solver: The solver to use (e.g. ``"highs"``, ``"minizinc"``, ``"ipopt"``).
            time_limit_sec: Maximum solve time in seconds. The solver service
                defaults to 300s if not provided.
            silent: Whether to suppress solver output.
            solution_limit: Maximum number of solutions to find.
            relative_gap_tolerance: Relative optimality gap tolerance in [0, 1].
            absolute_gap_tolerance: Absolute optimality gap tolerance (>= 0).
            log_to_console: Whether to stream solver logs to the console.
            print_only: Print the solver model without solving. Results
                like ``printed_model`` are still accessible afterward.
            print_format: Request a text representation of the solver model.
                The printed text is available via ``printed_model`` after the
                call. Works with or without ``print_only``. Supported formats:
                ``"moi"`` (MOI text), ``"latex"``, ``"mof"`` (MOI JSON),
                ``"lp"``, ``"mps"``, ``"nl"`` (AMPL).
            server_side_import: If True, import results via ``api.load_data``
                (data stays in Snowflake). Slower (~100s overhead) but more
                secure. Default False uses client-side download (fast but data
                leaves Snowflake). Temporary flag — will become the default
                when load_data performance improves.
            **solver_params: Raw solver-specific parameters passed directly to
                the underlying solver. Example: ``presolve="off"``.
        """
        if not self._variable_relationships:
            raise ValueError("Cannot solve: no decision variables declared. Call solve_for() first.")

        options: dict[str, Any] = {}
        if time_limit_sec is not None:
            options["time_limit_sec"] = float(time_limit_sec)
        if silent is not None:
            options["silent"] = bool(silent)
        if solution_limit is not None:
            options["solution_limit"] = int(solution_limit)
        if relative_gap_tolerance is not None:
            options["relative_gap_tolerance"] = float(relative_gap_tolerance)
        if absolute_gap_tolerance is not None:
            options["absolute_gap_tolerance"] = float(absolute_gap_tolerance)
        if print_only:
            options["print_only"] = True
        if print_format is not None:
            options["print_format"] = str(print_format)

        if solver_params:
            for value in solver_params.values():
                if not isinstance(value, (int, float, str, bool)):
                    raise TypeError(
                        f"Solver parameter values must be int, float, str, or bool, got {type(value).__name__}."
                    )
            warnings.warn(
                f"Solver-specific parameters {set(solver_params)} may not be portable across solvers.",
                stacklevel=2,
            )
            options.update(solver_params)

        import time

        client = connect_sync()
        ensure_op = client.reasoners.ensure(REASONER_TYPE)

        t0 = time.perf_counter()
        inputs = self._export_model_to_csv()
        print(f"Exported CSV ({time.perf_counter() - t0:.2f}s)")

        # Fresh model_id per solve so result views have unique FQNs (enables re-solving).
        prev_model_id = self._model_id
        prev_solve_counter = self._solve_counter
        prev_load_point_counter = self._load_point_counter
        self._model_id = str(uuid.uuid4()).upper().replace("-", "_")

        payload: dict[str, Any] = {
            "solver": solver.lower(),
            "version": 1,
            "options": options,
            "inputs": inputs,
            "model_id": self._model_id,
        }
        try:
            t1 = time.perf_counter()
            engine = ensure_op.wait()
            op = client.jobs.create(REASONER_TYPE, engine.name, payload)
            job = self._wait_for_job(client, op.id, log_to_console=log_to_console)
            if (job.state or "").upper() != "COMPLETED":
                state = (job.state or "UNKNOWN").lower()
                reason = job.abort_reason or "no details available"
                raise RuntimeError(f"Solver job {job.id} {state}: {reason}")
            print(f"Solver job completed ({time.perf_counter() - t1:.2f}s)")
            t2 = time.perf_counter()
            if server_side_import:
                self._import_solver_results_server_side()
            else:
                self._import_solver_results_from_views()
            print(f"Imported results ({time.perf_counter() - t2:.2f}s)")
        except Exception:
            # Restore counters. Note: model.define() calls are additive and cannot
            # be rolled back, so partial model state may persist on import failure.
            self._model_id = prev_model_id
            self._solve_counter = prev_solve_counter
            self._load_point_counter = prev_load_point_counter
            raise

    # =========================================================================
    # Result Handling
    # =========================================================================

    def variable_values(self, multiple: bool = False) -> b.Fragment:
        """Select variable names and values from the current solution.

        Args:
            multiple: If True, include a ``sol_index`` column (0-based) for
                multi-solution results. Only shows solutions from the most
                recent ``solve()`` call.

        Returns:
            Fragment — call ``.to_df()`` to get a DataFrame, or
            ``.inspect()`` to print.
        """
        if self._solve_counter == 0:
            raise ValueError("No solution available. Call solve() first.")
        Var = self.Variable
        val_ref = self._numeric_type.ref()
        var_hash = core_cast(b.MetaRef(Hash), Var)
        if multiple:
            # Filter to current solve's points and convert to 0-based indices.
            solve_offset = (self._solve_counter - 1) * _MAX_SOLUTIONS_PER_SOLVE
            raw_index = Integer.ref()
            return self._model.select(
                (raw_index - solve_offset - 1).alias("sol_index"),
                Var.name.alias("name"),
                val_ref.alias("value"),
            ).where(
                self._points(raw_index, var_hash, val_ref),
                raw_index > solve_offset,
                raw_index <= solve_offset + _MAX_SOLUTIONS_PER_SOLVE,
            )
        # Default: return the current solution from _point.
        return self._model.select(
            Var.name.alias("name"),
            val_ref.alias("value"),
        ).where(self._point(var_hash, val_ref))

    def load_point(self, point_index: int) -> None:
        """Load a specific solution point, making it the active solution.

        After loading, ``variable_values()`` and populated relationships
        reflect this solution. ``solve()`` resets to solution 0 automatically.

        .. note::
            Each call adds a fact via ``model.define()``, which bumps the model
            version and triggers full model retranslation on the next query
            (e.g., ``variable_values().to_df()``). The retranslation cost scales
            with model size. The retranslation cost appears in the subsequent
            query execution.

        Args:
            point_index: 0-based index (0 = first solution, valid range
                is ``0`` to ``result_count - 1``).
        """
        if isinstance(point_index, bool) or not isinstance(point_index, int) or point_index < 0:
            raise ValueError(f"Point index must be a non-negative integer, got {point_index!r}")
        if self._solve_counter == 0:
            raise ValueError("No solution available. Call solve() first.")
        # Convert 0-based user index to 1-based internal sol_index + solve offset.
        solve_offset = (self._solve_counter - 1) * _MAX_SOLUTIONS_PER_SOLVE
        self._load_point_counter += 1
        self._model.define(self._point_updates(self._load_point_counter, point_index + 1 + solve_offset))

    def display_solve_info(self) -> str:
        """Print and return solver result summary (status, objective, solve time)."""
        if self._solve_counter == 0:
            raise ValueError("No solve info available. Call solve() first.")
        key, val = String.ref(), String.ref()
        result_df = self._model.select(key, val).where(self._result_info(key, val)).to_df()
        output = "Solver result:\n" + result_df.to_string(index=False, header=False)
        print(output)
        return output

    # =========================================================================
    # Result Properties
    # =========================================================================

    def _require_solved(self, name: str) -> None:
        """Raise AttributeError if solve() has not been called."""
        if self._solve_counter == 0:
            raise AttributeError(f"'{name}' is not available before solve(). Call solve() first.")

    def _get_result_info(self, key: str) -> Optional[str]:
        """Query a result info key-value pair, returning None if absent."""
        val_ref = String.ref()
        df = self._model.select(val_ref).where(self._result_info(key, val_ref)).to_df()
        if df.empty:
            return None
        return str(df.iloc[0, 0])

    @property
    def termination_status(self) -> Optional[str]:
        """Solver termination status (e.g., ``'OPTIMAL'``, ``'INFEASIBLE'``). Queries the model."""
        self._require_solved("termination_status")
        return self._get_result_info("termination_status")

    @property
    def objective_value(self) -> Optional[int | float]:
        """Optimal objective value, or None if unavailable. Queries the model."""
        self._require_solved("objective_value")
        val_ref = self._numeric_type.ref()
        df = self._model.select(val_ref).where(self._objective_values(1, val_ref)).to_df()
        if df.empty:
            return None
        raw = df.iloc[0, 0]
        return int(raw) if self._numeric_type is Integer else float(raw)  # type: ignore[arg-type]

    @property
    def solve_time_sec(self) -> Optional[float]:
        """Solve time in seconds, or None if unavailable. Queries the model."""
        self._require_solved("solve_time_sec")
        val = self._get_result_info("solve_time_sec")
        return float(val) if val is not None else None

    @property
    def result_count(self) -> Optional[int]:
        """Number of solution points, or None if unavailable. Queries the model."""
        self._require_solved("result_count")
        val = self._get_result_info("result_count")
        return int(val) if val is not None else None

    @property
    def solver_version(self) -> Optional[str]:
        """Solver version string, or None if unavailable. Queries the model."""
        self._require_solved("solver_version")
        return self._get_result_info("solver_version")

    @property
    def printed_model(self) -> Optional[str]:
        """Text representation of the solver model, or None if not requested via ``print_format``. Queries the model."""
        self._require_solved("printed_model")
        return self._get_result_info("printed_model")

    @property
    def error(self) -> Optional[str]:
        """Solver error message, or None if no error. Queries the model."""
        self._require_solved("error")
        return self._get_result_info("error")


# =============================================================================
# Symbolic Expression Classes
# =============================================================================


class Symbolic:
    """Wrapper marking an expression as containing solver variables."""

    def __init__(self, expr: Any) -> None:
        if isinstance(expr, Symbolic):
            raise TypeError("Cannot double-wrap Symbolic.")
        self.expr = expr


def _format_expressions(
    expr_df: DataFrame,
    var_df: DataFrame,
    operators_df: DataFrame,
    unordered_df: DataFrame,
    ordered_data_df: DataFrame,
    ordered_hash_df: DataFrame,
) -> None:
    """Add 'expression' column with human-readable strings to expr_df.

    Builds lookup tables from query result DataFrames, then recursively
    formats each expression tree from its root Hash node.
    """
    if expr_df.empty:
        return

    # Build lookups from query results.
    variables: dict[Any, str] = dict(zip(var_df["variable"], var_df["name"])) if not var_df.empty else {}
    operators: dict[Any, int] = dict(zip(operators_df["node"], operators_df["op"])) if not operators_df.empty else {}

    # Build args dict: node -> [child values in order].
    args: dict[Any, list[Any]] = defaultdict(list)
    for node, child in unordered_df.itertuples(index=False):
        args[node].append(child)
    ordered: dict[Any, list[tuple[Any, Any]]] = defaultdict(list)
    for df in (ordered_data_df, ordered_hash_df):
        for node, idx, child in df.itertuples(index=False):
            ordered[node].append((idx, child))
    for node, indexed_children in ordered.items():
        indexed_children.sort(key=lambda x: x[0])
        args[node].extend(child for _, child in indexed_children)

    def fmt(node: Any) -> str:
        if node in variables:
            return variables[node]
        op_code = operators.get(node)
        if op_code is None:
            return str(node)
        op = _CODE_TO_NAME[op_code]
        children = args.get(node, [])
        is_infix = op in _INFIX
        parts: list[str] = []
        for child in children:
            s = fmt(child)
            if is_infix:
                child_op = operators.get(child)
                if child_op is not None and _CODE_TO_NAME[child_op] in _INFIX_AND_CMP:
                    s = f"({s})"
            parts.append(s)
        # Sort for stable display. SOS1/SOS2 excluded (index order is meaningful).
        if op in _HIGHER_ORDER_CODES and op not in ("special_ordered_set_type_1", "special_ordered_set_type_2"):
            parts.sort()
        if op in _INFIX_AND_CMP:
            return f"{parts[0]} {op} {parts[1]}"
        return f"{op}({', '.join(parts)})"

    expr_df["expression"] = [fmt(root) for root in expr_df["root"]]


class SymbolifyContext:
    """Rewrites DSL expressions into solver AST form using Hash nodes."""

    _REWRITE_DISPATCH: dict[type, str] = {
        b.Concept: "_rewrite_variable_ref",
        b.Ref: "_rewrite_variable_ref",
        b.Chain: "_rewrite_chain",
        b.Relationship: "_rewrite_relationship",
        b.Property: "_rewrite_relationship",  # Property is subclass of Relationship
        b.Match: "_rewrite_match",
        b.Union: "_rewrite_match",
        b.FilterBy: "_rewrite_filter_by",
        b.Expression: "_rewrite_expression",
        b.Aggregate: "_rewrite_aggregate",
        b.Fragment: "_rewrite_fragment",
    }

    def __init__(self, solver_model: SolverModel) -> None:
        self.model = solver_model._model
        self.solver_model = solver_model
        self.variable_map: dict[int, Any] = {}
        self.defines: list[Any] = []
        self.wheres: list[Any] = []
        self.subcontexts: list[SymbolifyContext] = []

    def _create_subcontext(self) -> SymbolifyContext:
        """Create child context for nested scope (e.g., aggregates)."""
        subctx = SymbolifyContext(self.solver_model)
        subctx.variable_map = self.variable_map.copy()
        subctx.defines = self.defines.copy()
        subctx.wheres = self.wheres.copy()
        self.subcontexts.append(subctx)
        return subctx

    # =========================================================================
    # Public Rewriting Methods
    # =========================================================================

    def rewrite_where(self, *exprs: Any) -> list[Any]:
        """Rewrite where clauses in two passes: variable relationships first, then rest."""
        results: list[Any] = []

        # Pass 1: Variable relationships populate variable_map.
        for expr in exprs:
            if isinstance(expr, b.Expression) and isinstance(expr._op, b.Relationship):
                if _get_key(expr._op) in self.solver_model._variable_relationships:
                    results.append(self._handle_variable_relationship(expr))
                    continue
            results.append(None)

        # Pass 2: Rewrite remaining expressions (may reference pass 1 variables).
        for i, expr in enumerate(exprs):
            if results[i] is None:
                # Scalars/literals don't have to_dict_key(); skip variable_map check.
                if hasattr(expr, "to_dict_key"):
                    key = _get_key(expr)
                    if key in self.variable_map:
                        results[i] = expr
                        continue
                results[i] = self._rewrite_nonsymbolic(expr)

        return results

    def rewrite(self, expr: Any) -> Optional[Symbolic | Any]:
        """Rewrite expression using dispatch table.

        Returns:
            - Symbolic: If expression contains solver variables
            - Rewritten expression: If expression was modified but not symbolic
            - None: If no rewriting needed
        """
        # Base cases: literals, None, and data sources (no rewriting needed).
        if expr is None or isinstance(expr, (int, float, str, b.Literal, b.Table, b.Data)):
            return None
        # Alias: delegate to source item.
        if isinstance(expr, b.Alias):
            return self.rewrite(expr._source_item)

        # Dispatch to type-specific handler.
        handler_name = self._REWRITE_DISPATCH.get(type(expr))
        if handler_name is not None:
            return getattr(self, handler_name)(expr)

        # Unsupported expression types.
        _UNSUPPORTED = [
            (b.New, "Cannot use 'New' expressions in solver constraints."),
            (
                b.FieldRef,
                "Cannot use field indexing in solver constraints. "
                "Use an explicit ref binding: qty = Float.ref(); sum(qty * X.var).where(X.rel(Y, qty)).per(Y)",
            ),
            (b.MetaRef, "Cannot use meta-references in solver constraints."),
            (b.AsBool, "Cannot use 'as_bool()' in solver constraints. Use comparison operators directly."),
            ((b.Not, b.Distinct, b.Group), "Not/Distinct/Group not supported in solver constraints."),
            ((b.DerivedTable, b.DerivedColumn), "Cannot use derived tables/columns in solver constraints."),
        ]
        for types, msg in _UNSUPPORTED:
            if isinstance(expr, types):  # type: ignore[arg-type]
                raise ValueError(msg)
        raise NotImplementedError(f"Expression type '{type(expr).__name__}' not supported in solver constraints.")

    # -------------------------------------------------------------------------
    # Type-specific rewrite handlers
    # -------------------------------------------------------------------------

    def _rewrite_variable_ref(self, expr: b.Concept | b.Ref) -> Optional[Symbolic]:
        """Rewrite Concept or Ref that may be a solver variable."""
        key = _get_key(expr)
        return Symbolic(self.variable_map[key]) if key in self.variable_map else None

    def _rewrite_chain(self, expr: b.Chain) -> Optional[Symbolic]:
        """Rewrite Chain (entity.relationship). Returns Symbolic if relationship is solver var."""
        start, next_expr = expr._start, expr._next

        # Handle nested chains (e.g., Factory.product.amount):
        # resolve intermediate chain's target concept and use it as parent for variable lookup.
        if isinstance(start, b.Chain):
            start_result = self.rewrite(start)
            if isinstance(start_result, Symbolic):
                raise ValueError("Chain start must not be symbolic.")
            if isinstance(next_expr, b.Relationship):
                # The intermediate chain (start) resolves to a concept instance.
                # Use it as parent so the variable ref is joined correctly.
                var_ref = self._get_variable_ref(next_expr, start)
                if var_ref is not None:
                    self.variable_map[_get_key(next_expr)] = var_ref
                    return Symbolic(var_ref)
            return start_result

        if not isinstance(start, (b.Concept, b.Ref)):
            raise TypeError(f"Chain start must be Concept or Ref, got {type(start).__name__}.")
        if self.rewrite(start) is not None:
            raise ValueError("Chain start must not be symbolic.")

        # Check if the relationship part is a solver variable.
        if isinstance(next_expr, b.Relationship):
            var_ref = self._get_variable_ref(next_expr, start)
            if var_ref is not None:
                self.variable_map[_get_key(next_expr)] = var_ref
                return Symbolic(var_ref)
        return None

    def _rewrite_relationship(self, expr: b.Relationship) -> Optional[Symbolic]:
        """Rewrite Relationship that may be a solver variable."""
        key = _get_key(expr)
        if key in self.variable_map:
            return Symbolic(self.variable_map[key])
        var_ref = self._get_variable_ref(expr, None)
        if var_ref is not None:
            self.variable_map[key] = var_ref
            return Symbolic(var_ref)
        return None

    def _rewrite_match(self, expr: b.Match) -> Optional[Symbolic | Any]:
        """Rewrite Match/Union expression."""
        items = expr._items
        rewritten: list[Any] = []
        has_symbolic = False

        for item in items:
            result = self.rewrite(item)
            if isinstance(result, Symbolic):
                has_symbolic = True
                rewritten.append(result.expr)
            else:
                rewritten.append(result if result is not None else item)

        if not has_symbolic and rewritten == list(items):
            return None

        # Build the appropriate Match or Union.
        if isinstance(expr, b.Union):
            combined = self.model.union(*rewritten)
        else:
            combined = b.Match(self.model, *rewritten)

        return Symbolic(combined) if has_symbolic else combined

    def _rewrite_filter_by(self, expr: b.FilterBy) -> Optional[Any]:
        """Rewrite FilterBy expression (symbolic values not allowed)."""
        new_kwargs, modified = {}, False
        for k, v in expr._kwargs.items():
            r = self.rewrite(v)
            if isinstance(r, Symbolic):
                raise ValueError(f"Cannot use symbolic value in FilterBy argument '{k}'.")
            new_kwargs[k] = r if r is not None else v
            modified = modified or r is not None

        return expr._params[0].filter_by(**new_kwargs) if modified else None

    def _rewrite_expression(self, expr: b.Expression) -> Optional[Symbolic | Any]:
        """Rewrite operator expression. Builds AST node if any arg is symbolic."""
        op = self.rewrite(expr._op)
        if isinstance(op, Symbolic):
            raise ValueError("Expression operator cannot be symbolic.")
        op = op if op is not None else expr._op

        args = expr._args
        rewritten: list[Any] = []
        has_symbolic = False
        for arg in args:
            result = self.rewrite(arg)
            if isinstance(result, Symbolic):
                has_symbolic = True
            rewritten.append(result if result is not None else arg)

        if not has_symbolic and rewritten == list(args):
            return None
        if not has_symbolic:
            return b.Expression(op, rewritten)

        # Build symbolic AST node.
        op_name = op._short_name
        if not isinstance(op_name, str):
            raise TypeError(f"Operator name must be str, got {type(op_name).__name__}.")

        if op_name in _FIRST_ORDER_CODES:
            op_code = _FIRST_ORDER_CODES[op_name]
            rewritten = rewritten[:-1]  # Drop auto-filled output arg.
        elif op_name in _COMPARISON_CODES:
            op_code = _COMPARISON_CODES[op_name]
        else:
            # Provide helpful error for unsupported operators
            if op_name in _UNSUPPORTED_OPS:
                msg = (
                    f"Operator '{op_name}' is not supported by any solver backend "
                    f"(not available in MOI/MiniZinc/Ipopt/HiGHS)."
                )
            else:
                supported = sorted(list(_FIRST_ORDER_CODES.keys()) + list(_COMPARISON_CODES.keys()))
                msg = (
                    f"Operator '{op_name}' is not supported by the solver engine. "
                    f"Supported operators: {', '.join(supported)}"
                )
            raise NotImplementedError(msg)

        # Create Hash node unique per (expr_id, args).
        hash_args = [a.expr if isinstance(a, Symbolic) else a for a in rewritten]
        node = Hash.ref()
        self.wheres.append(hash_with_res([expr._id, *hash_args], node))
        self.defines.append(self.solver_model._operator(node, op_code))

        for i, arg in enumerate(rewritten):
            if isinstance(arg, Symbolic):
                self.defines.append(self.solver_model._ordered_args_hash(node, i, arg.expr))
            else:
                self.defines.append(self.solver_model._ordered_args_data(node, i, arg))

        return Symbolic(node)

    def _extract_aggregate_args(self, expr: b.Aggregate, op_name: str) -> tuple[Any, list[Any], list[Any]]:
        """Extract symbolic arg and other args based on operator type."""
        args, proj_args = list(expr._args), list(expr._projection_args)
        if op_name == "count":
            return proj_args.pop(), args, proj_args
        elif op_name == "rank":
            if not isinstance(args[0], b.TupleVariable):
                raise TypeError("Rank aggregate expects TupleVariable as first arg.")
            return None, args[0]._items, []
        elif op_name in ("special_ordered_set_type_1", "special_ordered_set_type_2"):
            return args[1], args[:1], proj_args
        else:
            return args[0], args[1:], proj_args

    def _rewrite_aggregate(self, expr: b.Aggregate) -> Optional[Symbolic | Any]:
        """Rewrite aggregate (sum/min/max/count/etc)."""
        if expr._distinct:
            raise NotImplementedError("Distinct aggregates not yet supported.")

        op = self.rewrite(expr._op)
        if isinstance(op, Symbolic):
            raise ValueError("Aggregate operator cannot be symbolic.")
        op = op if op is not None else expr._op
        op_name = op._short_name

        # Extract arguments based on operator type
        sym_arg, args, proj_args = self._extract_aggregate_args(expr, op_name)

        # Rewrite components in subcontext
        subctx = self._create_subcontext()
        rw_where = subctx.rewrite_where(*expr._where._where)
        rw_proj = [subctx._rewrite_nonsymbolic(a) for a in proj_args]
        rw_args = [subctx._rewrite_nonsymbolic(a) for a in args]
        rw_group = [subctx._rewrite_nonsymbolic(a) for a in expr._group._args]
        rw_sym = None if op_name == "rank" else subctx.rewrite(sym_arg)

        # Early return if nothing changed
        if (
            rw_sym is None
            and rw_proj == list(proj_args)
            and rw_args == list(args)
            and rw_where == list(expr._where._where)
        ):
            return None

        # Non-symbolic case: rebuild aggregate
        if not isinstance(rw_sym, Symbolic):
            sym_arg_final = rw_sym if rw_sym is not None else sym_arg
            if op_name == "rank":
                new_args = [b.TupleVariable(rw_args), expr._args[1], expr._args[2]]
            elif op_name in (
                "special_ordered_set_type_1",
                "special_ordered_set_type_2",
            ):
                new_args = [*rw_proj, *rw_args, sym_arg_final]
            else:
                new_args = [*rw_proj, sym_arg_final, *rw_args]
            return b.Aggregate(op, *new_args, distinct=expr._distinct).per(*rw_group).where(*rw_where, *subctx.wheres)

        # Symbolic case: build Hash-based AST node
        if op_name not in _HIGHER_ORDER_CODES:
            supported_aggs = list(_HIGHER_ORDER_CODES.keys())
            msg = f"Aggregate '{op_name}' is not supported by the solver engine."
            if op_name in ("avg", "string_join", "rank", "limit", "top", "bottom"):
                msg += f" Only these aggregates are supported: {', '.join(sorted(supported_aggs))}"
            else:
                msg += f" Supported aggregates: {', '.join(sorted(supported_aggs))}"
            raise NotImplementedError(msg)

        sym_expr = rw_sym.expr
        node = Hash.ref()
        subctx.wheres.append(hash_with_res([expr._id, *rw_group], node))
        subctx.wheres.extend(rw_where)
        subctx.defines.append(self.solver_model._operator(node, _HIGHER_ORDER_CODES[op_name]))

        if op_name == "special_ordered_set_type_2":
            if len(rw_proj) != 0 or len(rw_args) != 1:
                raise ValueError("SOS2 expects exactly 2 arguments (rank, variable).")
            subctx.defines.append(self.solver_model._ordered_args_hash(node, rw_args[0], sym_expr))
        else:
            arg_hash = std.common.hash(*rw_proj, sym_expr)
            subctx.defines.append(self.solver_model._unordered_args_hash(node, arg_hash, sym_expr))

        return Symbolic(self.model.select(node).where(*subctx.wheres))

    def _rewrite_fragment(self, expr: b.Fragment) -> Optional[Symbolic | Any]:
        """Rewrite inline select/where fragment."""
        if expr._define or expr._require:
            raise ValueError("Fragments with define/require not supported.")
        if len(expr._select) != 1:
            raise ValueError(f"Fragment must have exactly 1 select, got {len(expr._select)}.")

        subctx = self._create_subcontext()
        rw_where = subctx.rewrite_where(*expr._where)
        rw_select = subctx.rewrite(expr._select[0])
        wheres = self.model.where(*rw_where, *subctx.wheres)

        if isinstance(rw_select, Symbolic):
            return Symbolic(wheres.select(rw_select.expr))
        if rw_select is not None:
            return wheres.select(rw_select)
        return None

    # =========================================================================
    # Private Helper Methods
    # =========================================================================

    def _handle_variable_relationship(self, expr: b.Expression) -> Any:
        """Handle variable relationship: store ref in variable_map, return field bindings."""
        relationship = expr._op
        if not isinstance(relationship, b.Relationship):
            raise TypeError(f"Expected Relationship operator, got {type(relationship).__name__}.")

        params = expr._args
        if len(params) != len(relationship._fields):
            raise ValueError(f"Parameter count {len(params)} != field count {len(relationship._fields)}.")

        # The last parameter is the output variable (the symbolic value).
        last_param = params[-1]
        if isinstance(last_param, b.Alias):
            last_param = last_param._source_item
        if not isinstance(last_param, (b.Concept, b.Ref)):
            raise TypeError(f"Last parameter must be Concept or Ref, got {type(last_param).__name__}.")

        # Build field bindings (grounding conditions).
        fields: dict[str, Any] = {}
        for i, param in enumerate(params[:-1]):
            rewritten = self.rewrite(param)
            if isinstance(rewritten, Symbolic):
                raise ValueError(f"Variable relationship parameter {i} cannot be symbolic.")
            fields[relationship._fields[i].name] = rewritten if rewritten is not None else param

        key = _get_key(relationship)
        var_ref = self.solver_model._variable_relationships[key].ref()
        self.variable_map[_get_key(last_param)] = core_cast(b.MetaRef(Hash), var_ref)

        return self.solver_model._model.where(*[getattr(var_ref, name) == val for name, val in fields.items()])

    def _rewrite_nonsymbolic(self, expr: Any) -> Any:
        """Rewrite expression, raising if result is unexpectedly symbolic."""
        result = self.rewrite(expr)
        if isinstance(result, Symbolic):
            raise ValueError(f"Unexpected Symbolic result for {expr}.")
        return result if result is not None else expr

    def _get_variable_ref(
        self, relationship: b.Relationship, parent: b.Concept | b.Ref | b.Chain | None
    ) -> Optional[Any]:
        """Get Hash-cast var ref if relationship is a solver variable, else None."""
        VarConcept = self.solver_model._variable_relationships.get(_get_key(relationship))
        if VarConcept is None:
            return None

        var_ref = VarConcept.ref()
        if parent is not None:
            self.wheres.append(getattr(var_ref, relationship._fields[0].name)(parent))
        return core_cast(b.MetaRef(Hash), var_ref)


def _get_key(expr: Any) -> int:
    """Get unique integer key for an expression."""
    key = expr.to_dict_key()
    if not isinstance(key, int):
        raise TypeError(f"Expression key must be int, got {type(key).__name__}.")
    return key
