"""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.

## Serialization Format

The AST is exported to CSV tables for the solver service:
- Variables: properties (name, type, bounds, start)
- Expressions: root nodes (objectives/constraints) with type codes
- Operators: Hash node -> operator code
- Arguments: Hash node -> child nodes (ordered or unordered)
"""

from __future__ import annotations

import textwrap
import time
import uuid
import warnings
from typing import Any, Optional

import pandas as pd

from .common import make_name
from .solver import Solver
from relationalai.errors import UninitializedPropertyException
from relationalai.semantics import std
from relationalai.semantics.internal import internal as b
from relationalai.semantics.internal.snowflake import Table
from relationalai.semantics.lqp.executor import LQPExecutor
from relationalai.semantics.rel.executor import RelExecutor

# =============================================================================
# Solver Serialization Format Constants
# =============================================================================
# These codes define the wire format for solver model serialization.
# They must match the PyRel solver service expectations.

# Variable types (codes 0-9)
_VARIABLE_TYPE_CODES: dict[str, int] = {
    "cont": 0,
    "int": 1,
    "bin": 2,
}

# Expression types (codes 10-19)
_EXPRESSION_TYPE_CODES: dict[str, int] = {
    "min_objective": 10,
    "max_objective": 11,
    "constraint": 12,
}

# Arithmetic operators (codes 20-24)
_ARITHMETIC_OPERATOR_CODES: dict[str, int] = {
    "+": 20,
    "-": 21,
    "*": 22,
    "/": 23,
    "^": 24,
}

# Math functions (codes 40-47)
_MATH_FUNCTION_CODES: dict[str, int] = {
    "abs": 40,
    "exp": 41,
    "natural_log": 42,
    "log": 43,
    "sqrt": 44,
    "square": 45,
    "cbrt": 46,
    "pow": 47,  # alias for "power" (old API uses std.math.pow)
    "power": 47,
}

# Comparison operators (codes 70-75)
_COMPARISON_OPERATOR_CODES: dict[str, int] = {
    "=": 70,
    "!=": 71,
    "<": 72,
    "<=": 73,
    ">": 74,
    ">=": 75,
}

# Logical operators (codes 80-89)
_LOGICAL_OPERATOR_CODES: dict[str, int] = {
    "implies": 80,
}

# Aggregate functions (codes 90-93)
_AGGREGATE_FUNCTION_CODES: dict[str, int] = {
    "sum": 90,
    "min": 91,
    "max": 92,
    "count": 93,
}

# Global constraints (codes 110-112)
_GLOBAL_CONSTRAINT_CODES: dict[str, int] = {
    "all_different": 110,
    "special_ordered_set_type_1": 111,
    "special_ordered_set_type_2": 112,
}

# Unsupported operators (not supported by any MOI solver backend)
_UNSUPPORTED_ARITHMETIC_OPS: set[str] = {"%", "//"}
_UNSUPPORTED_MATH_OPS: set[str] = {"floor", "ceil"}

# Combined operator dictionaries
_FIRST_ORDER_OPERATOR_CODES = _ARITHMETIC_OPERATOR_CODES | _MATH_FUNCTION_CODES
_FIRST_ORDER_COMPARISON_CODES = _COMPARISON_OPERATOR_CODES | _LOGICAL_OPERATOR_CODES
_HIGHER_ORDER_OPERATOR_CODES = _AGGREGATE_FUNCTION_CODES | _GLOBAL_CONSTRAINT_CODES

# Operators that use infix notation in display
_INFIX_OPERATORS: frozenset[str] = frozenset(_ARITHMETIC_OPERATOR_CODES.keys())
_INFIX_OPERATORS_AND_COMPARISONS: frozenset[str] = _INFIX_OPERATORS | frozenset(
    _FIRST_ORDER_COMPARISON_CODES.keys()
)

# Reverse mapping from numeric codes to names (for display/debugging)
_CODE_TO_NAME: dict[int, str] = {
    v: k
    for k, v in (
        _VARIABLE_TYPE_CODES
        | _FIRST_ORDER_OPERATOR_CODES
        | _FIRST_ORDER_COMPARISON_CODES
        | _HIGHER_ORDER_OPERATOR_CODES
    ).items()
}

_RESULT_INFO_KEYS = frozenset(
    [
        "error",
        "termination_status",
        "solver_version",
        "printed_model",
        "solve_time_sec",
        "objective_value",
        "result_count",
    ]
)


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


class SolverModel:
    """Solver model interface for optimization problems."""

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

        Args:
            model: The RelationalAI model to attach solver constructs to.
            num_type: 'cont' for HiGHS/Gurobi/Ipopt (even for MIPs),
                'int' for MiniZinc. This determines the data and solution
                value type, not the variable types in your problem.
        """
        if num_type not in ["cont", "int"]:
            raise ValueError(
                f"Invalid numerical type '{num_type}'. Must be 'cont' or 'int'."
            )
        if getattr(model, "_use_lqp", False):
            warnings.warn(
                "The solve step currently performs significantly better if you set "
                "use_lqp=False on your PyRel Model or in your config. "
                "This performance issue does not affect PyRel v1.",
                stacklevel=2,
            )
        self._model = model
        self._num_type = num_type
        self._model_id = str(uuid.uuid4()).upper().replace("-", "_")
        self._load_point_counter = 0
        self._points_offset = 0
        num_type_str = "int" if num_type == "int" else "float"
        self._variable_relationships: dict[b.Relationship, b.Concept] = {}

        sm_id = next(b._global_id)

        def _name(s: str) -> str:
            return f"{s}{sm_id}"

        Concept = model.Concept
        Property = model.Property
        Relationship = model.Relationship

        # Expression concept: common parent for all AST nodes (variables, operators, etc.)
        Expression = self.Expression = Concept(_name("Expression"))

        # Variable concept (separate from Expression — Variables appear as leaf
        # nodes in the Hash-based AST via cast, not through type inheritance).
        Variable = self.Variable = Concept(_name("Variable"))
        var = Variable._name
        Variable.name = Property(f"{{{var}}} has {{name:str}}")
        Variable.type = Property(f"{{{var}}} has {{type:int}}")
        Variable.lower = Property(f"{{{var}}} has {{lower:{num_type_str}}}")
        Variable.upper = Property(f"{{{var}}} has {{upper:{num_type_str}}}")
        Variable.start = Property(f"{{{var}}} has {{start:{num_type_str}}}")

        # Expression properties (Hash-typed node params to match how expression
        # nodes are created via hash() in _rewrite_expression/_rewrite_aggregate).
        Expression.name = Property(f"{{node:Hash}} has {{name:str}} {_name('en')}")
        Expression.type = Property(f"{{node:Hash}} has {{type:int}} {_name('et')}")

        # AST node structure
        Expression.operator = Property(f"{{node:Hash}} has {{op:int}} {_name('eo')}")
        Expression.ordered_args_hash = Property(
            f"{{node:Hash}} arg {{i:int}} is {{arg:Hash}} {_name('eoa')}"
        )
        Expression.ordered_args_data = Property(
            f"{{node:Hash}} arg {{i:int}} is {{arg:{num_type_str}}} {_name('eod')}"
        )
        Expression.unordered_args_hash = Property(
            f"{{node:Hash}} arg {{i:Hash}} is {{arg:Hash}} {_name('eua')}"
        )

        # Solver result relations (LQP path uses api.load_data replacement semantics;
        # Rel path uses additive def insert with versioned backing stores).
        # Names must be unique per SolverModel to prevent cross-contamination
        # when multiple SolverModels share the same Model (re-solve pattern).
        self.result_info = Relationship(f"{{key:str}} has {{val:str}} {sm_id}")
        # Versioned backing store for result_info on the Rel path.
        self._result_info_versioned = Property(
            f"solve {{v:int}} result {{key:str}} is {{val:str}} {sm_id}"
        )
        # Trace of result_info version updates; max aggregation selects the latest.
        self._result_info_updates = Property(
            f"at {{update:int}} we use result version {{v:int}} {sm_id}"
        )
        self.point = Property(f"{{var:Hash}} has {{point:{num_type_str}}} {sm_id}")
        self.points = Property(
            f"point {{i:int}} for {{var:Hash}} has {{point:{num_type_str}}} {sm_id}"
        )
        # Trace of load_point updates; max aggregation selects the latest
        self._point_updates = Property(
            f"at {{update:int}} we load {{point:int}} {sm_id}"
        )

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

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

        Args:
            expr: Relationship or Expression defining the variable(s).
            where: Optional conditions filtering which variable instances to create.
            populate: Whether to populate relationship with solver results.
            name: Display name. String for scalar, list for indexed (e.g. ``["x", Item.i]``).
            type: ``"cont"`` (default for 'cont'), ``"int"`` (default for 'int'),
                or ``"bin"`` (binary 0/1).
            lower: Lower bound (constant or expression).
            upper: Upper bound (constant or expression).
            start: Initial value hint (warm start).
        """
        if where is None:
            where = []
        if isinstance(expr, b.Expression):
            relationship = expr._op
            if not isinstance(relationship, b.Relationship):
                raise TypeError(
                    f"Expression operator must be a Relationship, got {relationship.__class__.__name__}."
                )
            params = expr._params
        elif isinstance(expr, b.Relationship):
            relationship = expr
            params = [
                b.field_to_type(self._model, field) for field in relationship._fields
            ]
        else:
            raise TypeError(
                f"Invalid expression type for solve_for: {expr.__class__.__name__}. "
                f"Expected Relationship or Expression."
            )

        if len(params) != len(relationship._fields):
            raise ValueError(
                f"Parameter count mismatch: Got {len(params)} params "
                f"but relationship has {len(relationship._fields)} fields."
            )
        if relationship in self._variable_relationships:
            raise ValueError(
                f"Variables are already defined for relationship {relationship}."
            )

        # Create a specialized Variable concept for this relationship
        Var = self._model.Concept(
            f"{self.Variable._name}_{str(relationship).replace('.', '_')}",
            extends=[self.Variable],
        )
        self._variable_relationships[relationship] = Var

        # Build field dict from relationship parameters (excluding the value field)
        fields = {}
        for i in range(len(params) - 1):
            if i == 0 and relationship._parent is not None:
                concept = relationship._parent
                if not isinstance(concept, b.Concept):
                    raise TypeError(
                        f"Relationship parent must be a Concept, got {concept.__class__.__name__}."
                    )
            else:
                concept = params[i]
            field_name = relationship._field_names[i]
            self.Variable._relationships[field_name] = self.Variable._get_relationship(
                field_name
            )
            fields[field_name] = concept
        var = Var.new(**fields)
        b.define(var).where(*where)

        # Variable name (empty string default — solver service null-guards it)
        b.define(
            self.Variable.name(var, make_name(name) if name is not None else "")
        ).where(*where)

        # Variable type
        if type is None:
            type = self._num_type
        if not isinstance(type, str):
            raise TypeError(
                f"Variable 'type' must be a string, but got {type.__class__.__name__}."
            )
        if type not in _VARIABLE_TYPE_CODES:
            valid_types = ", ".join(f"'{t}'" for t in _VARIABLE_TYPE_CODES.keys())
            raise ValueError(
                f"Invalid variable type '{type}'. Valid types are: {valid_types}."
            )
        b.define(self.Variable.type(var, _VARIABLE_TYPE_CODES[type])).where(*where)

        # Bounds
        for attr, val in [("lower", lower), ("upper", upper), ("start", start)]:
            if val is not None:
                if not isinstance(val, (b.Producer, float, int)):
                    raise TypeError(
                        f"Variable '{attr}' must be a number, but got {val.__class__.__name__}."
                    )
                b.define(getattr(self.Variable, attr)(var, val)).where(*where)

        if populate:
            NumericType = b.Integer if self._num_type == "int" else b.Float
            val_ref = NumericType.ref()
            pop_var = Var.ref()
            var_hash = b.Hash.ref()
            field_args = [getattr(pop_var, fn) for fn in fields.keys()]
            b.define(relationship(*field_args, val_ref)).where(
                _make_cast(b.Hash, pop_var, var_hash),
                self.point(var_hash, val_ref), *where
            )

        return Var

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

    def minimize(
        self,
        expr: b.Producer | float | int | b.Fragment,
        name: Optional[str | list[str]] = None,
    ) -> None:
        """Add minimization objective."""
        self._add_expression("min_objective", expr, name)

    def maximize(
        self,
        expr: b.Producer | float | int | b.Fragment,
        name: Optional[str | list[str]] = None,
    ) -> None:
        """Add maximization objective."""
        self._add_expression("max_objective", expr, name)

    def satisfy(
        self,
        expr: b.Fragment,
        check: bool = False,
        name: Optional[str | list[str]] = None,
    ) -> None:
        """Add constraints from a Fragment's require clause."""
        if not isinstance(expr, b.Fragment):
            raise TypeError(f"satisfy expects a Fragment, got {type(expr).__name__}.")
        if not expr._require:
            raise ValueError("Fragment must have a require clause.")
        if expr._select or expr._define:
            raise ValueError("Fragment must not have select or define clauses.")
        if not check:
            b._remove_roots([expr])
        for req in expr._require:
            self._add_expression("constraint", req, name, expr._where)

    def _add_expression(
        self,
        expr_type: str,
        expr: Any,
        name: Optional[Any | list],
        where: Optional[list] = 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"Cannot symbolify {expr}. Ensure it contains solver variables."
            )
        root = result.expr
        # Expression properties are keyed by Hash. When root is a Variable
        # concept ref (simple expressions like minimize(x)), cast to Hash.
        root_hash = b.Hash.ref()
        ctx.wheres.append(_make_cast(b.Hash, root, root_hash))
        defs = [
            *ctx.defines,
            self.Expression.type(root_hash, _EXPRESSION_TYPE_CODES[expr_type]),
            self.Expression.name(
                root_hash, make_name(name) if name is not None else ""
            ),
        ]
        self._apply_context_with_defs(ctx, defs)

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

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

    def print(self, verbose: bool = True):
        """Print the variables and components of the model in human-readable format."""
        Var = self.Variable

        expr_type_labels = [
            ("min_objective", "Minimization objectives"),
            ("max_objective", "Maximization objectives"),
            ("constraint", "Constraints"),
        ]

        # Summarize variables
        var_counts = b.select(
            *[b.count(Var.type(t)) | 0 for _, t in _VARIABLE_TYPE_CODES.items()]
        ).to_df()
        assert var_counts.shape == (1, 3)
        (cont_count, int_count, bin_count) = var_counts.iloc[0]

        # Summarize expressions by type
        expr_hash = b.Hash.ref()
        expr_counts = b.select(
            *[
                b.count(expr_hash).where(
                    self.Expression.type(expr_hash, _EXPRESSION_TYPE_CODES[key])
                )
                | 0
                for key, _ in expr_type_labels
            ]
        ).to_df()
        assert expr_counts.shape == (1, 3)
        (min_count, max_count, cons_count) = expr_counts.iloc[0]

        # Print summary
        print(f'Solver model (data type "{self._num_type}") has:')
        print(
            f"• {cont_count} continuous variables, {int_count} integer variables, {bin_count} binary variables"
        )
        print(
            f"• {min_count} minimization objectives, {max_count} maximization objectives, {cons_count} constraints"
        )
        if not verbose:
            return None

        # Print variable names and types (with optional bounds)

        try:
            var_names = b.select(Var.name, Var.type, Var.lower, Var.upper).to_df()
        except UninitializedPropertyException:
            # No bounds set on variables; fall back to core fields
            var_names = b.select(Var.name, Var.type).to_df()
        if not var_names.empty:
            var_names[var_names.columns[1]] = [
                _CODE_TO_NAME[t] for t in var_names.iloc[:, 1]
            ]
            # Filter out columns that contain all NaN values
            cols_to_keep = [var_names.columns[0], var_names.columns[1]]
            for col in var_names.columns[2:]:
                if not var_names[col].isna().all():
                    cols_to_keep.append(col)
            print()
            print("Variables:")
            print(var_names[cols_to_keep].to_string(index=False, header=True))

        # Print expressions by type
        for type_key, label in expr_type_labels:
            expr_strings = self._expr_strings(_EXPRESSION_TYPE_CODES[type_key])
            if expr_strings:
                print()
                print(label + ":")
                print("\n".join(expr_strings))
        print()

    def _expr_strings(self, type_code: int) -> list[str]:
        """Generate string representations of expressions with the given type code."""
        Expr = self.Expression
        Var = self.Variable

        # Get root nodes for this expression type (use Hash refs to match
        # Hash-typed property signatures and avoid type mismatch warnings)
        root_hash = b.Hash.ref()
        roots_df = (
            b.select(root_hash)
            .where(self.Expression.type(root_hash, type_code))
            .to_df()
        )
        if roots_df.empty:
            return []
        nodes = roots_df.iloc[:, 0].tolist()

        # Build lookup dicts
        names_dict = {k: v for k, v in b.select(Var, Var.name)}

        n0 = b.Hash.ref()
        op_ref = b.Integer.ref()
        # Filter out <NA> values from Variables (which extend Expression but have no operator)
        ops_dict = {k: v for k, v in b.select(n0, op_ref).where(
            Expr.operator(n0, op_ref)
        ) if pd.notna(v)}

        args_dict = {}
        n1 = b.Hash.ref()
        h_ref = b.Hash.ref()
        i_ref = b.Integer.ref()
        v_ref = b.Number.ref()

        # Unordered args
        for k, _, v in b.select(n0, h_ref, n1).where(
            Expr.unordered_args_hash(n0, h_ref, n1)
        ):
            if k not in args_dict:
                args_dict[k] = []
            args_dict[k].append(v)

        # Ordered args (hash)
        ordered_args_dict = {}
        for k, i, v in b.select(n0, i_ref, n1).where(
            Expr.ordered_args_hash(n0, i_ref, n1)
        ):
            if k not in ordered_args_dict:
                ordered_args_dict[k] = []
            ordered_args_dict[k].append((i, v))

        # Ordered args (data)
        for k, i, v in b.select(n0, i_ref, v_ref).where(
            Expr.ordered_args_data(n0, i_ref, v_ref)
        ):
            if k not in ordered_args_dict:
                ordered_args_dict[k] = []
            ordered_args_dict[k].append((i, v))

        # Merge ordered args into args_dict
        for k, t in ordered_args_dict.items():
            t.sort(key=lambda x: x[0])
            args_dict[k] = [v for (_, v) in t]

        return sorted(
            _expr_strings_rec(n, names_dict, ops_dict, args_dict) for n in nodes
        )

    # =========================================================================
    # Solving and Result Handling
    # =========================================================================

    def _export_solver_model_to_csv(self) -> dict[str, str]:
        """Export model relations to CSV files via LQP executor.

        All 6 tables are exported in a single batched LQP transaction,
        which the backend may execute in parallel. If the model uses a
        non-LQP executor, a temporary LQPExecutor is created for the export.
        """

        model = self._model
        executor = model._to_executor()

        # LQP executor is required for CSV export - create one if needed
        if not isinstance(executor, LQPExecutor):
            executor = LQPExecutor(
                model.name,
                dry_run=model._dry_run,
                keep_model=model._keep_model,
                wide_outputs=model._wide_outputs,
                connection=model._connection,
                config=model._config,
                intrinsic_overrides=model._intrinsic_overrides,
            )

        Var = self.Variable
        Expr = self.Expression
        num_type = b.Integer if self._num_type == "int" else b.Float

        # Variables
        var_query = b.select(
            Var.alias("VARIABLE"),
            Var.name.alias("NAME"),
            Var.type.alias("TYPE"),
            Var.lower.alias("LOWER"),
            Var.upper.alias("UPPER"),
            Var.start.alias("START"),
        )

        # Expressions (objectives/constraints) — use Hash refs to match
        # Hash-typed property signatures
        expr_hash = b.Hash.ref()
        type_ref = b.Integer.ref()
        name_ref = b.String.ref()
        expr_query = b.select(
            expr_hash.alias("NODE"),
            name_ref.alias("NAME"),
            type_ref.alias("TYPE"),
        ).where(Expr.type(expr_hash, type_ref), Expr.name(expr_hash, name_ref))

        # AST structure
        n0 = b.Hash.ref()
        n1 = b.Hash.ref()
        i = b.Integer.ref()
        h = b.Hash.ref()
        op = b.Integer.ref()
        v = num_type.ref()

        operators_query = b.select(n0.alias("NODE"), op.alias("OP")).where(
            Expr.operator(n0, op)
        )
        ordered_args_hash_query = b.select(
            n0.alias("NODE"), i.alias("INDEX"), n1.alias("ARG")
        ).where(Expr.ordered_args_hash(n0, i, n1))
        ordered_args_data_query = b.select(
            n0.alias("NODE"), i.alias("INDEX"), v.alias("VALUE")
        ).where(Expr.ordered_args_data(n0, i, v))
        unordered_args_hash_query = b.select(n0.alias("NODE"), n1.alias("ARG")).where(
            Expr.unordered_args_hash(n0, h, n1)
        )

        # Batch export all 6 queries in a single LQP transaction
        keys = [
            "variables",
            "expressions",
            "operators",
            "ordered_args_hash",
            "ordered_args_data",
            "unordered_args_hash",
        ]
        queries = [
            var_query,
            expr_query,
            operators_query,
            ordered_args_hash_query,
            ordered_args_data_query,
            unordered_args_hash_query,
        ]
        paths = executor.batch_export_to_csv(model, queries)
        return dict(zip(keys, paths))

    @staticmethod
    def _load_data_with_retry(
        resources: Any,
        app_name: str,
        table_fqn: str,
        engine_name: str,
        max_retries: int = 3,
        retry_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 @backed_by_dict base
        model via load_base. If a prior use_index call (e.g., from
        _exec_with_gi_retry during the export phase) started a base model
        installation that hasn't fully committed, load_base conflicts and the
        base model load transaction is ABORTED. Retrying after a delay lets
        the in-flight installation settle.
        """

        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,  # type: ignore[reportCallIssue]
                )
                return
            except Exception as e:
                if "aborted" in str(e).lower() and attempt < max_retries - 1:
                    time.sleep(retry_delay)
                    continue
                raise

    def _import_solver_results(self) -> None:
        """Import solver results from the solver service.

        Uses raw Rel load_csv when the model uses a RelExecutor (use_lqp=False),
        which loads all 3 result CSVs in a single transaction. Uses
        api.load_data for LQP-based models (the default).
        """

        executor = self._model._to_executor()

        if isinstance(executor, RelExecutor):
            self._import_solver_results_rel(executor)
        else:
            self._import_solver_results_lqp()

    def _import_solver_results_rel(self, executor: Any) -> None:
        """Import solver results using raw Rel load_csv from internal stage."""
        # Increment counter to create unique point index namespace per solve.
        # def insert is additive, so without offset, a second solve would accumulate
        # points alongside the first solve's data, causing FDErrors from conflicting
        # values for the same (sol_index, var_hash) key.
        self._load_point_counter += 1
        counter = self._load_point_counter
        points_offset = counter * 1000000
        self._points_offset = points_offset

        model_id = self._model_id
        result_stage_base = (
            f"snowflake://APP_STATE.RAI_INTERNAL_STAGE/SOLVERS/job_{model_id}/results"
        )
        value_parse_fn = "parse_int" if self._num_type == "int" else "parse_float"
        value_convert = "Int128" if self._num_type == "int" else "Float64"

        rel_code = textwrap.dedent(f"""\
        // Load ancillary.csv.gz - solver metadata (NAME, VALUE columns)
        def ancillary_config[:path]: "{result_stage_base}/ancillary.csv.gz"
        def ancillary_config[:syntax, :header_row]: 1
        def ancillary_config[:schema, :NAME]: "string"
        def ancillary_config[:schema, :VALUE]: "string"
        def ancillary_raw {{load_csv[ancillary_config]}}

        // Load objective_values.csv.gz (SOL_INDEX, VALUE columns)
        def obj_config[:path]: "{result_stage_base}/objective_values.csv.gz"
        def obj_config[:syntax, :header_row]: 1
        def obj_config[:schema, :SOL_INDEX]: "string"
        def obj_config[:schema, :VALUE]: "string"
        def obj_raw {{load_csv[obj_config]}}

        // Load points.csv.gz (SOL_INDEX, VAR_HASH, VALUE columns)
        def points_config[:path]: "{result_stage_base}/points.csv.gz"
        def points_config[:syntax, :header_row]: 1
        def points_config[:schema, :SOL_INDEX]: "string"
        def points_config[:schema, :VAR_HASH]: "string"
        def points_config[:schema, :VALUE]: "string"
        def points_raw {{load_csv[points_config]}}

        // Insert ancillary data into versioned result_info backing store.
        // Int128 conversion needed: DSL Properties use Int128 for int columns.
        def insert(:{self._result_info_versioned._name}, counter_128, key, val): {{
            exists((row) |
                ::std::mirror::convert(std::mirror::typeof[Int128], {counter}, counter_128) and
                ancillary_raw(:NAME, row, key) and
                ancillary_raw(:VALUE, row, val))
        }}

        // Insert objective value from first solution
        def insert(:{self._result_info_versioned._name}, counter_128, "objective_value", val): {{
            exists((row) |
                ::std::mirror::convert(std::mirror::typeof[Int128], {counter}, counter_128) and
                obj_raw(:SOL_INDEX, row, "1") and
                obj_raw(:VALUE, row, val))
        }}

        // Insert solution points with offset to avoid cross-solve FD conflicts.
        // Each solve's points are stored at (offset + sol_idx, var_hash, value),
        // where offset = counter * 1000000 (assumes < 1M solutions per solve),
        // ensuring unique keys across solves.
        def insert(:{self.points._name}, offset_idx, var, val_converted): {{
            exists((row, sol_str, sol_idx, sol_idx_128, var_hash_str, val_str, val) |
                points_raw(:SOL_INDEX, row, sol_str) and
                points_raw(:VAR_HASH, row, var_hash_str) and
                points_raw(:VALUE, row, val_str) and
                parse_int(sol_str, sol_idx) and
                ::std::mirror::convert(std::mirror::typeof[Int128], sol_idx, sol_idx_128) and
                offset_idx = sol_idx_128 + {points_offset} and
                ::std::common::parse_uuid(var_hash_str, var) and
                {value_parse_fn}(val_str, val) and
                ::std::mirror::convert(std::mirror::typeof[{value_convert}], val, val_converted))
        }}
        """)

        try:
            executor.execute_raw(rel_code)
        except Exception as e:
            raise RuntimeError(
                f"Failed to import solver results for model_id={model_id}. "
                f"The solver job may not have produced result files, or the "
                f"result data may be malformed. Check solver logs for details."
            ) from e

        # Record which result version to use (trace for max-aggregation)
        b.define(self._result_info_updates(counter, counter))

        # Derive result_info from versioned backing store (latest solve only).
        # NOTE: On re-solve, this adds a redundant rule (previous rules still
        # exist). All rules produce identical results because max-aggregation
        # on _result_info_updates selects the same latest version. Harmless
        # but accumulates; acceptable since re-solves are infrequent.
        ri_update_ref = b.Integer.ref()
        ri_version_ref = b.Integer.ref()
        key_ref, info_val_ref = b.String.ref(), b.String.ref()
        b.where(
            self._result_info_updates(ri_update_ref, ri_version_ref),
            ri_update_ref == b.max(ri_update_ref),
            self._result_info_versioned(ri_version_ref, key_ref, info_val_ref),
        ).define(self.result_info(key_ref, info_val_ref))

        # Default: load solution 1 (at offset + 1).
        # Same note as above: on re-solve, this adds a redundant derivation
        # rule for self.point that is equivalent to the previous one.
        b.define(self._point_updates(counter, points_offset + 1))

        NumericType = b.Integer if self._num_type == "int" else b.Float
        val_ref = NumericType.ref()
        update_ref = b.Integer.ref()
        point_index = b.Integer.ref()
        var_hash_ref = b.Hash.ref()
        b.where(
            self._point_updates(update_ref, point_index),
            update_ref == b.max(update_ref),
            self.points(point_index, var_hash_ref, val_ref),
        ).define(self.point(var_hash_ref, val_ref))

    def _import_solver_results_lqp(self) -> None:
        """Import solver results from Snowflake views via api.load_data."""

        resources = self._model._to_executor().resources
        app_name = resources.get_app_name()
        logic_engine_name = resources.get_default_engine_name()
        model_id = self._model_id

        # Create Table objects for result views with explicit schemas that
        # match the Snowflake view DDL created by the solver service (see
        # rai-solver-service csv_store.jl FILE_SCHEMAS).  Supplying schema=
        # bypasses SHOW COLUMNS entirely, avoiding both:
        #  - Stale-cache duplicates on re-solve (SchemaInfo.fetch appends
        #    without clearing fields)
        #  - SHOW COLUMNS timing issues where views aren't yet queryable
        #    after api.load_data
        value_type = "Int128" if self._num_type == "int" else "Float"
        ancillary_tbl = Table(
            f"{app_name}.RESULTS.SOLVERS_{model_id}_ANCILLARY",
            schema={"NAME": "String", "VALUE": "String"},
        )
        obj_vals_tbl = Table(
            f"{app_name}.RESULTS.SOLVERS_{model_id}_OBJECTIVE_VALUES",
            schema={"SOL_INDEX": "Int128", "VALUE": value_type},
        )
        points_tbl = Table(
            f"{app_name}.RESULTS.SOLVERS_{model_id}_POINTS",
            schema={"SOL_INDEX": "Int128", "VAR_HASH": "String", "VALUE": value_type},
        )

        # Load result views via api.load_data (2-parameter overload)
        for tbl in [ancillary_tbl, obj_vals_tbl, points_tbl]:
            self._load_data_with_retry(resources, app_name, tbl._fqn, logic_engine_name)

        NumericType = b.Integer if self._num_type == "int" else b.Float

        # derive result_info from ancillary table
        key, val = b.String.ref(), b.String.ref()
        b.where(ancillary_tbl.name == key, ancillary_tbl.value == val).define(
            self.result_info(key, val)
        )

        # derive objective_value from objective_values table
        b.where(
            obj_vals_tbl.sol_index == 1,
            std.strings.string(obj_vals_tbl.value) == val,
        ).define(self.result_info("objective_value", val))

        # derive points
        val_ref = NumericType.ref()
        sol_index_ref = b.Integer.ref()
        var_hash_str = b.String.ref()
        b.where(
            points_tbl.sol_index == sol_index_ref,
            points_tbl.var_hash == var_hash_str,
            points_tbl.value == val_ref,
        ).define(self.points(sol_index_ref, std.parse_uuid(var_hash_str), val_ref))

        # default: load solution 1 via _point_updates trace
        self._load_point_counter += 1
        b.define(self._point_updates(self._load_point_counter, 1))

        # derive point from points using max-aggregation on _point_updates
        update_ref = b.Integer.ref()
        point_index = b.Integer.ref()
        var_hash_ref = b.Hash.ref()
        b.where(
            self._point_updates(update_ref, point_index),
            update_ref == b.max(update_ref),
            self.points(point_index, var_hash_ref, val_ref),
        ).define(self.point(var_hash_ref, val_ref))

    def solve(
        self, solver: Solver, log_to_console: bool = False, **kwargs: Any
    ) -> None:
        """Solve the optimization problem using the specified solver."""
        options = {**kwargs, "version": 1}
        for option_key, option_value in options.items():
            if not isinstance(option_key, str):
                raise TypeError(
                    f"Solver option keys must be strings, but got {type(option_key).__name__} for key {option_key!r}."
                )
            if not isinstance(option_value, (int, float, str, bool)):
                raise TypeError(
                    f"Solver option values must be int, float, str, or bool, "
                    f"but got {type(option_value).__name__} for option {option_key!r}."
                )

        # 1. Export model to CSV via LQP
        print("Exporting solver model...")
        inputs = self._export_solver_model_to_csv()

        # 2. Execute solver job
        print("Executing solver job...")
        payload: dict[str, Any] = {
            "solver": solver.solver_name.lower(),
            "options": options,
            "inputs": inputs,
            "model_id": self._model_id,
        }
        solver._exec_job(payload, log_to_console=log_to_console)

        # 3. Import results
        print("Loading solver results...")
        self._import_solver_results()

        print("Finished solve")
        print()

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

        Uses the _point_updates trace with max-aggregation to select the
        active point index without requiring data deletion.
        """
        if not isinstance(point_index, int) or point_index < 0:
            raise ValueError(
                f"Point index must be a non-negative integer, got {point_index}"
            )
        self._load_point_counter += 1
        b.define(
            self._point_updates(
                self._load_point_counter, self._points_offset + point_index
            )
        )

    def summarize_result(self) -> Any:
        """Print and return solver result summary as a DataFrame."""
        info_keys_to_retrieve = [
            "error",
            "termination_status",
            "solve_time_sec",
            "objective_value",
            "solver_version",
            "result_count",
        ]
        key, value_ref = b.String.ref(), b.String.ref()
        result_df = (
            b.select(key, value_ref)
            .where(self.result_info(key, value_ref), key.in_(info_keys_to_retrieve))
            .to_df()
        )
        if result_df.empty:
            raise ValueError(
                "No result information is available. Has the model been solved?"
            )
        print("Solver result:")
        print(result_df.to_string(index=False, header=False))
        print()
        return result_df

    def variable_values(self, multiple: bool = False) -> b.Fragment:
        """Select variable names and values from the solution point(s)."""
        variable_ref = self.Variable.ref()
        value_ref = (b.Integer if self._num_type == "int" else b.Float).ref()
        name_ref = b.String.ref()
        var_hash = b.Hash.ref()
        if multiple:
            point_index = b.Integer.ref()
            sol_index = b.Integer.ref()
            return b.select(
                sol_index.alias("sol_index"),
                name_ref.alias("name"),
                value_ref.alias("value"),
            ).where(
                _make_cast(b.Hash, variable_ref, var_hash),
                self.points(point_index, var_hash, value_ref),
                self.Variable.name(variable_ref, name_ref),
                sol_index == point_index - self._points_offset,
                # Filter to current solve's points (Rel path accumulates across solves)
                point_index > self._points_offset,
            )
        return b.select(
            name_ref.alias("name"),
            value_ref.alias("value"),
        ).where(
            _make_cast(b.Hash, variable_ref, var_hash),
            self.point(var_hash, value_ref),
            self.Variable.name(variable_ref, name_ref),
        )

    _EXPR_COUNT_ATTRS: dict[str, int] = {
        "num_min_objectives": _EXPRESSION_TYPE_CODES["min_objective"],
        "num_max_objectives": _EXPRESSION_TYPE_CODES["max_objective"],
        "num_constraints": _EXPRESSION_TYPE_CODES["constraint"],
    }

    def __getattr__(self, name: str) -> Any:
        """Get model or result attribute (e.g., num_variables, termination_status)."""
        if name == "num_variables":
            result_df = b.select(b.count(self.Variable) | 0).to_df()
            if result_df.shape != (1, 1):
                raise ValueError(
                    f"Expected exactly one value for '{name}', got shape {result_df.shape}."
                )
            return result_df.iloc[0, 0]

        if name in self._EXPR_COUNT_ATTRS:
            type_code = self._EXPR_COUNT_ATTRS[name]
            ref = b.Hash.ref()
            result_df = b.select(
                b.count(ref).where(self.Expression.type(ref, type_code)) | 0
            ).to_df()
            if result_df.shape != (1, 1):
                raise ValueError(
                    f"Expected exactly one value for '{name}', got shape {result_df.shape}."
                )
            return result_df.iloc[0, 0]

        if name not in _RESULT_INFO_KEYS:
            raise AttributeError(
                f"'{type(self).__name__}' object has no attribute '{name}'"
            )

        value_ref = b.String.ref()
        result_df = b.select(value_ref).where(self.result_info(name, value_ref)).to_df()
        if result_df.empty:
            return None
        result_value = result_df.iloc[0, 0]
        if not isinstance(result_value, str):
            return result_value
        if name == "solve_time_sec":
            return float(result_value)
        if name == "objective_value":
            return int(result_value) if self._num_type == "int" else float(result_value)
        if name == "result_count":
            return int(result_value)
        return result_value


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


class Symbolic:
    """Wrapper for symbolified solver expressions."""

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


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

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

    def _create_subcontext(self) -> SymbolifyContext:
        """Create a subcontext that inherits state from this context."""
        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 clause expressions (two-pass: variable relationships first)."""
        rewritten_expressions: list[Any] = []
        # First pass: identify and handle variable relationship expressions.
        for expression in exprs:
            if (
                isinstance(expression, b.Expression)
                and isinstance(expression._op, b.Relationship)
                and expression._op in self.solver_model._variable_relationships
            ):
                rewritten_expressions.append(
                    self._handle_variable_relationship(expression)
                )
            else:
                rewritten_expressions.append(None)
        # Second pass: rewrite remaining non-variable expressions.
        for i, expr in enumerate(exprs):
            if rewritten_expressions[i] is None:
                rewritten_expressions[i] = (
                    expr
                    if expr in self.variable_map
                    else self._rewrite_nonsymbolic(expr)
                )
        return rewritten_expressions

    def rewrite(self, expr: Any) -> Optional[Symbolic | Any]:
        """Rewrite expression using dispatch. Returns Symbolic if it contains solver variables."""

        if expr is None:
            return None

        elif isinstance(expr, (int, float, str)):
            return None

        elif isinstance(expr, b.ConceptFilter):
            concept = expr._op
            assert isinstance(concept, b.Concept)
            (ident, kwargs) = expr._params
            assert ident is None
            assert isinstance(kwargs, dict)
            new_kwargs = {}
            values_were_rewritten = False
            for key, value in kwargs.items():
                rewritten_value = self.rewrite(value)
                if isinstance(rewritten_value, Symbolic):
                    raise ValueError(
                        f"Cannot symbolify ConceptFilter argument {key} with symbolic value."
                    )
                if rewritten_value is not None:
                    values_were_rewritten = True
                    new_kwargs[key] = rewritten_value
                else:
                    new_kwargs[key] = value
            if values_were_rewritten:
                return b.ConceptFilter(concept, ident, new_kwargs)
            return None

        elif isinstance(expr, (b.DataColumn, b.TypeRef, b.Concept)):
            return None

        elif isinstance(expr, b.Alias):
            return self.rewrite(expr._thing)

        elif isinstance(expr, b.Ref):
            if expr in self.variable_map:
                return Symbolic(self.variable_map[expr])
            thing = self.rewrite(expr._thing)
            if thing is not None:
                raise ValueError(
                    f"Internal error. Ref._thing rewrite unexpectedly returned {thing}."
                )
            return None

        elif isinstance(expr, b.Relationship):
            if expr in self.variable_map:
                return Symbolic(self.variable_map[expr])
            variable_result = self._get_variable_ref(expr, expr._parent)
            if variable_result is not None:
                self.variable_map[expr] = variable_result
                return Symbolic(variable_result)
            return None

        elif isinstance(expr, b.RelationshipRef):
            if expr in self.variable_map:
                return Symbolic(self.variable_map[expr])
            relationship = expr._relationship
            if isinstance(relationship, b.Relationship):
                variable_result = self._get_variable_ref(relationship, expr._parent)
                if variable_result is not None:
                    self.variable_map[expr] = variable_result
                    return Symbolic(variable_result)
            rewritten_parent = self.rewrite(expr._parent)
            if isinstance(rewritten_parent, Symbolic):
                raise ValueError(
                    "Internal error. RelationshipRef parent rewrite returned Symbolic."
                )
            if rewritten_parent is not None:
                return b.RelationshipRef(rewritten_parent, relationship)
            return None

        elif isinstance(expr, b.RelationshipFieldRef):
            relationship = expr._relationship
            if not isinstance(relationship, b.Relationship):
                return None

            relationship_expression = (
                relationship
                if expr._parent is None
                else b.RelationshipRef(expr._parent, relationship)
            )
            variable_result = self.rewrite(relationship_expression)
            if variable_result is None:
                return None

            if isinstance(variable_result, Symbolic):
                if expr._field_ix == len(relationship._fields) - 1:
                    return variable_result
                variable_result = variable_result.expr

            return getattr(variable_result, relationship._field_names[expr._field_ix])

        elif isinstance(expr, b.Union):
            args_were_rewritten = False
            has_symbolic_args = False
            args = []
            for union_arg in expr._args:
                rewritten_arg = self.rewrite(union_arg)
                if isinstance(rewritten_arg, Symbolic):
                    has_symbolic_args = True
                    args.append(rewritten_arg.expr)
                elif rewritten_arg is not None:
                    args_were_rewritten = True
                    args.append(rewritten_arg)
                else:
                    args.append(union_arg)
            if has_symbolic_args:
                return Symbolic(b.union(*args))
            elif args_were_rewritten:
                return b.union(*args)
            return None

        elif isinstance(expr, b.Expression):
            return self._rewrite_expression(expr)

        elif isinstance(expr, b.Aggregate):
            return self._rewrite_aggregate(expr)

        elif isinstance(expr, b.Fragment):
            return self._rewrite_fragment(expr)

        raise NotImplementedError(
            f"Solver rewrites cannot handle {expr} of type {type(expr).__name__}."
        )

    # =========================================================================
    # Expression Type Handlers
    # =========================================================================

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

        params_were_rewritten = False
        has_symbolic_params = False
        rewritten_params = []
        for param in expr._params:
            rewritten_param = self.rewrite(param)
            if rewritten_param is None:
                rewritten_params.append(param)
                continue
            elif isinstance(rewritten_param, Symbolic):
                has_symbolic_params = True
            params_were_rewritten = True
            rewritten_params.append(rewritten_param)
        if not params_were_rewritten:
            return None
        if not has_symbolic_params:
            return b.Expression(operator, *rewritten_params)

        # Some arguments are symbolic: build Hash-based AST node
        op_name = operator._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_OPERATOR_CODES:
            op_code = _FIRST_ORDER_OPERATOR_CODES[op_name]
            rewritten_params = rewritten_params[:-1]  # Drop auto-filled output arg
        elif op_name in _FIRST_ORDER_COMPARISON_CODES:
            op_code = _FIRST_ORDER_COMPARISON_CODES[op_name]
        else:
            if (
                op_name in _UNSUPPORTED_ARITHMETIC_OPS
                or op_name in _UNSUPPORTED_MATH_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_OPERATOR_CODES.keys())
                    + list(_FIRST_ORDER_COMPARISON_CODES.keys())
                )
                msg = (
                    f"Operator '{op_name}' is not supported by the solver engine. "
                    f"Supported operators: {', '.join(supported)}"
                )
            raise NotImplementedError(msg)

        sm = self.solver_model
        node = b.Hash.ref()
        hash_args = [a.expr if isinstance(a, Symbolic) else a for a in rewritten_params]
        self.wheres.append(_make_hash((expr._id, *hash_args), node))
        self.defines.append(sm.Expression.operator(node, op_code))

        for i, param in enumerate(rewritten_params):
            if isinstance(param, Symbolic):
                arg_hash = b.Hash.ref()
                self.wheres.append(_make_cast(b.Hash, param.expr, arg_hash))
                self.defines.append(
                    sm.Expression.ordered_args_hash(node, i, arg_hash)
                )
            else:
                if isinstance(param, (int, float, str, bool)):
                    self.defines.append(sm.Expression.ordered_args_data(node, i, param))
                else:
                    # Complex expressions (e.g., aggregates like count())
                    # must be bound to an intermediate variable so their
                    # evaluation is separated from the effectful define.
                    # Without this, both end up in the same IR rule and the
                    # dependency analyser detects a spurious cycle.
                    NumericType = b.Integer if sm._num_type == "int" else b.Float
                    data_ref = NumericType.ref()
                    self.wheres.append(_make_cast(NumericType, param, data_ref))
                    self.defines.append(
                        sm.Expression.ordered_args_data(node, i, data_ref)
                    )

        return Symbolic(node)

    def _rewrite_aggregate(self, expr: b.Aggregate) -> Optional[Symbolic | Any]:
        """Rewrite aggregate (sum/min/max/count/all_different/sos2/etc)."""
        operator = self.rewrite(expr._op)
        if isinstance(operator, Symbolic):
            raise ValueError(
                "Internal error: Aggregate operator rewrite returned Symbolic."
            )
        elif operator is None:
            operator = expr._op

        # Only the last argument can be symbolic
        subctx = self._create_subcontext()
        rewritten_where = subctx.rewrite_where(*expr._where._where)
        rewritten_preceding = [
            subctx._rewrite_nonsymbolic(arg) for arg in expr._args[:-1]
        ]
        rewritten_group = [subctx._rewrite_nonsymbolic(arg) for arg in expr._group]
        any_rewritten = (
            rewritten_preceding != expr._args[:-1]
            or rewritten_group != expr._group
            or rewritten_where != list(expr._where._where)
        )
        symbolic_arg = subctx.rewrite(expr._args[-1])
        if symbolic_arg is None and not any_rewritten:
            return None
        if not isinstance(symbolic_arg, Symbolic):
            if symbolic_arg is None:
                symbolic_arg = expr._args[-1]
            aggregate_expr = b.Aggregate(operator, *rewritten_preceding, symbolic_arg)
            return aggregate_expr.per(*rewritten_group).where(*rewritten_where)
        symbolic_arg = symbolic_arg.expr

        # Symbolic case: build Hash-based AST node
        op_name = operator._name
        if not isinstance(op_name, str):
            raise ValueError(
                f"Internal error. Aggregate operator name is {type(op_name).__name__}, expected str."
            )
        if op_name not in _HIGHER_ORDER_OPERATOR_CODES:
            supported_aggs = sorted(_HIGHER_ORDER_OPERATOR_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(supported_aggs)}"
                )
            else:
                msg += f" Supported aggregates: {', '.join(supported_aggs)}"
            raise NotImplementedError(msg)

        sm = self.solver_model
        node = b.Hash.ref()
        hash_condition = _make_hash((expr._id, *rewritten_group), node)
        self.wheres.append(hash_condition)
        subctx.wheres.append(hash_condition)
        subctx.wheres.append(symbolic_arg)
        subctx.wheres.extend(rewritten_where)
        subctx.defines.append(
            sm.Expression.operator(node, _HIGHER_ORDER_OPERATOR_CODES[op_name])
        )

        if op_name == "special_ordered_set_type_2":
            assert len(rewritten_preceding) == 1, (
                "special_ordered_set_type_2 expects exactly 2 arguments (rank, variables)"
            )
            sym_hash = b.Hash.ref()
            subctx.wheres.append(_make_cast(b.Hash, symbolic_arg, sym_hash))
            subctx.defines.append(
                sm.Expression.ordered_args_hash(
                    node, rewritten_preceding[0], sym_hash
                )
            )
        else:
            arg_hash = b.Hash.ref()
            sym_hash = b.Hash.ref()
            subctx.wheres.append(
                _make_hash((*rewritten_preceding, symbolic_arg), arg_hash)
            )
            subctx.wheres.append(_make_cast(b.Hash, symbolic_arg, sym_hash))
            subctx.defines.append(
                sm.Expression.unordered_args_hash(node, arg_hash, sym_hash)
            )

        return Symbolic(
            b.select(node).where(*rewritten_where, hash_condition, symbolic_arg)
        )

    def _rewrite_fragment(self, expr: b.Fragment) -> Optional[Symbolic | Any]:
        """Rewrite inline select/where fragment."""
        if expr._define or expr._require:
            raise ValueError(
                "Solver rewrites do not support fragments with define or require clauses."
            )
        if len(expr._select) != 1:
            raise ValueError(
                f"Solver rewrites require fragments with exactly one select item, "
                f"but got {len(expr._select)}."
            )

        subctx = self._create_subcontext()
        rewritten_where = subctx.rewrite_where(*expr._where)
        symbolic_select = subctx.rewrite(expr._select[0])
        if isinstance(symbolic_select, Symbolic):
            return Symbolic(b.select(symbolic_select.expr).where(*rewritten_where))
        elif symbolic_select is not None:
            return b.select(symbolic_select).where(*rewritten_where)
        return None

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

    def _handle_variable_relationship(self, expr: b.Expression) -> Any:
        """Create symbolic reference for variable relationship expression."""
        relationship = expr._op
        if not isinstance(relationship, b.Relationship):
            raise TypeError(
                f"Expected Relationship in variable expression, but got {type(relationship).__name__}."
            )
        params = expr._params
        if len(params) != len(relationship._fields):
            raise ValueError(
                f"Parameter count mismatch: Got {len(params)} params "
                f"but relationship has {len(relationship._fields)} fields."
            )
        last_param = params[-1]
        if isinstance(last_param, b.Alias):
            last_param = last_param._thing
        if not isinstance(last_param, (b.Concept, b.Ref)):
            raise TypeError(
                f"Last parameter must be a Concept or Ref, but got {type(last_param).__name__}."
            )
        fields = {}
        for i in range(len(params) - 1):
            rewritten_param = self.rewrite(params[i])
            assert not isinstance(rewritten_param, Symbolic)
            fields[relationship._field_names[i]] = (
                rewritten_param if rewritten_param is not None else params[i]
            )
        variable_ref = self.solver_model._variable_relationships[relationship].ref()
        self.variable_map[last_param] = variable_ref
        return b.where(
            *[
                getattr(variable_ref, field_name) == field_value
                for field_name, field_value in fields.items()
            ]
        )

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

    def _get_variable_ref(
        self, relationship: b.Relationship, parent_producer: b.Producer | None
    ) -> Optional[Any]:
        """Get variable reference for relationship, or None if not a solver variable."""
        VariableConcept = self.solver_model._variable_relationships.get(relationship)
        if VariableConcept is None:
            return None
        properties = {}
        if parent_producer is not None:
            properties[relationship._field_names[0]] = parent_producer
        return VariableConcept(VariableConcept.ref(), **properties)


def _make_hash(tup, res):
    return b.Expression(b.Relationship.builtins["hash"], b.TupleArg(tup), res)


def _make_cast(target_type, source, result):
    return b.Expression(b.Relationship.builtins["cast"], b.TypeRef(target_type), source, result)


def _expr_strings_rec(x, names_dict, ops_dict, args_dict):
    """Recursively build string representation of expression tree."""
    if x in names_dict:
        return names_dict[x]
    elif x not in ops_dict:
        return str(x)

    op = _CODE_TO_NAME[ops_dict[x]]
    arg_strs = []

    for k in args_dict.get(x, []):
        s = _expr_strings_rec(k, names_dict, ops_dict, args_dict)
        if (
            op in _INFIX_OPERATORS
            and k in ops_dict
            and _CODE_TO_NAME[ops_dict[k]] in _INFIX_OPERATORS_AND_COMPARISONS
        ):
            s = f"({s})"
        arg_strs.append(s)

    if op in _HIGHER_ORDER_OPERATOR_CODES and op != "special_ordered_set_type_2":
        arg_strs.sort()

    if op in _INFIX_OPERATORS_AND_COMPARISONS:
        assert len(arg_strs) == 2
        return f"{arg_strs[0]} {op} {arg_strs[1]}"
    else:
        assert len(arg_strs) >= 1
        return f"{op}({', '.join(arg_strs)})"
