"""Prescriptive reasoning: define and solve decision problems.

Provides Problem for defining and solving decision problems — optimization,
constraint satisfaction, and feasibility — with variables, objectives, and
constraints. Problems are serialized and sent to an external solver service;
results are imported back after the solve completes.
"""

from __future__ import annotations

from typing import Any, Literal, Optional
from weakref import WeakKeyDictionary
from decimal import Decimal
import json
import logging
import math
import uuid
import warnings

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

from .wire_format import (
    _library,
    _VAR_CODES,
    _EXPR_CODES,
    _EXPR_CONCEPT_NAMES,
)
from .rewriter import ExpressionCompiler, SymbolicNode, _expr_dict_key
from .display import format_variable_table, format_expression_table
from ....util.docutils import include_in_docs

# include this module in documentation
__include_in_docs__ = True

logger = logging.getLogger(__name__)

# Tracks which variable relationships have populate=True per Model, to warn on
# duplicate populate rules (FD violations). WeakKeyDictionary so entries are
# cleaned up when the Model is garbage-collected.
_populated_relationships: WeakKeyDictionary[b.Model, set[int]] = WeakKeyDictionary()

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


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


def _check_name_arg(arg: Any) -> None:
    """Raise if *arg* is a bare Concept (not a scalar DSL expression)."""
    if isinstance(arg, b.Concept):
        raise TypeError(
            f"Cannot use Concept '{arg._name}' as a name component. "
            f"Use a property that returns a scalar value instead "
            f'(for example, name=["x", {arg._name}.id]).'
        )


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

    Parameters
    ----------
    *args : Any
        Arguments to concatenate (strings, numbers, or lists).
    sep : str or None, optional
        Separator between arguments. Defaults to ``"_"``.

    Returns
    -------
    str or Expression
        The concatenated name.

    Raises
    ------
    ValueError
        If no arguments are 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:
            _check_name_arg(arg)
            return std.strings.string(arg)

    for a in args:
        if not isinstance(a, str):
            _check_name_arg(a)

    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)


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

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


@include_in_docs
def implies(left: Any, right: Any) -> b.Expression:
    """Return an implication constraint (``left`` => ``right``).

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    When passed to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`,
    both sides must reference at least one decision variable declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.

    Parameters
    ----------
    left : Any
        Antecedent condition (a comparison or constraint).
    right : Any
        Consequent condition (a comparison or constraint).

    Returns
    -------
    Expression
        A solver expression representing the implication constraint.

    Examples
    --------
    Constrain two binary variables:

    >>> from relationalai.semantics import Float, Model
    >>> from relationalai.semantics.reasoners.prescriptive import Problem, implies
    >>> m = Model("implies_demo")
    >>> x = m.Relationship(f"{Float:x}")
    >>> y = m.Relationship(f"{Float:y}")
    >>> p = Problem(m, Float)
    >>> p.solve_for(x, name="x", type="bin")
    >>> p.solve_for(y, name="y", type="bin")
    >>> p.satisfy(m.require(implies(x == 1, y == 1)))
    """
    return _implies(left, right)


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


@include_in_docs
def all_different(*args: b.Value) -> b.Aggregate:
    """Return an all-different constraint.

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    Arguments must reference decision variables declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.
    For grouped constraints (for example, "all values are distinct per row"),
    scope it with :meth:`~relationalai.semantics.frontend.base.Aggregate.per`.

    Parameters
    ----------
    *args : Value
        One or more decision-variable expressions that must take distinct values.

    Returns
    -------
    Aggregate
        A solver aggregate representing the all-different constraint.

    Examples
    --------
    Constrain values to be distinct per row:

    >>> from relationalai.semantics import Integer, Model
    >>> from relationalai.semantics.reasoners.prescriptive import Problem, all_different
    >>> m = Model("all_different_demo")
    >>> Cell = m.Concept("Cell", identify_by={"row": Integer, "col": Integer})
    >>> Cell.val = m.Property(f"{Cell} has {Integer:val}")
    >>> m.define(
    ...     Cell.new(row=0, col=0),
    ...     Cell.new(row=0, col=1),
    ...     Cell.new(row=1, col=0),
    ...     Cell.new(row=1, col=1),
    ... )
    >>> p = Problem(m, Integer)
    >>> p.solve_for(Cell.val, name=["x", Cell.row, Cell.col], lower=1, upper=4)
    >>> p.satisfy(m.require(all_different(Cell.val).per(Cell.row)).where(Cell))
    """
    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)]
)


@include_in_docs
def special_ordered_set_type_1(index: Any, variables: Any) -> b.Aggregate:
    """Return an SOS1 constraint (at most one variable is non-zero).

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    Arguments must reference decision variables declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.
    Commonly used for piecewise-linear formulations.

    Parameters
    ----------
    index : Any
        Ordering index used to define the set.
    variables : Any
        Decision-variable expression(s) in the SOS1 set.

    Returns
    -------
    Aggregate
        A solver aggregate representing the SOS1 constraint.

    Examples
    --------
    Create nonnegative weights and constrain them with SOS1:

    >>> from relationalai.semantics import Float, Integer, Model, std
    >>> from relationalai.semantics.reasoners.prescriptive import Problem, special_ordered_set_type_1
    >>> m = Model("sos1_demo")
    >>> Point = m.Concept("Point", identify_by={"i": Integer})
    >>> Point.w = m.Property(f"{Point} has {Float:w}")
    >>> m.define(Point.new(i=std.common.range(3)))
    >>> p = Problem(m, Float)
    >>> p.solve_for(Point.w, name=["w", Point.i], lower=0)
    >>> p.satisfy(m.require(special_ordered_set_type_1(Point.i, Point.w)))
    """
    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)]
)


@include_in_docs
def special_ordered_set_type_2(index: Any, variables: Any) -> b.Aggregate:
    """Return an SOS2 constraint (at most two consecutive variables are non-zero).

    Use this inside :meth:`~relationalai.semantics.frontend.base.Model.require` and
    pass the resulting fragment to :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.satisfy`.
    Arguments must reference decision variables declared via
    :meth:`~relationalai.semantics.reasoners.prescriptive.problem.Problem.solve_for`.
    The ordering in the set is defined by ``index`` (often a rank or position),
    and the constraint is commonly used for piecewise-linear formulations.
    For the SOS1 variant, see :func:`~relationalai.semantics.reasoners.prescriptive.problem.special_ordered_set_type_1`.

    Parameters
    ----------
    index : Any
        Ordering index that defines adjacency in the set.
    variables : Any
        Decision-variable expression(s) in the SOS2 set.

    Returns
    -------
    Aggregate
        A solver aggregate representing the SOS2 constraint.
    """
    return b.Aggregate(_sos2, index, variables)


# =============================================================================
# Result Import Utilities
# =============================================================================


def _load_data_with_retry(
    model: Any,
    resources: Any,
    app_name: str,
    table_fqns: list[str],
    engine_name: str,
    sources: dict[str, str] | None = None,
    max_retries: int = 6,
    initial_delay: float = 2.0,
) -> None:
    """Load result tables via batched ``experimental.load_data`` with retries.

    This sends all result views in a single payload, so table registration and
    loading happen in one call.

    If the logic engine transaction aborted we retry with exponential backoff + jitter.
    """
    import random
    import time

    if not table_fqns:
        return

    def target_relation_name(fqn: str) -> str:
        return fqn.lower().replace(".", "_")

    payload = {
        "tables": [
            {
                "table_reference": sources.get(fqn, fqn) if sources else fqn,
                "target_relation": target_relation_name(fqn),
            }
            for fqn in table_fqns
        ]
    }
    payload_json = json.dumps(payload)

    total_backoff = 0.0
    for attempt in range(max_retries):
        try:
            resources._exec(
                f"call {app_name}.experimental.load_data(?, ?, parse_json(?))",
                [model.name, engine_name, payload_json],
                skip_engine_db_error_retry=True,  # type: ignore[reportCallIssue]
            )
            if attempt > 0:
                logger.warning(
                    "load_data succeeded after %d retries (%.1fs total backoff)",
                    attempt,
                    total_backoff,
                )
            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)
                total_backoff += delay
                logger.warning(
                    "load_data ABORTED (attempt %d/%d), retrying in %.1fs: %s", attempt + 1, max_retries, delay, e
                )
                time.sleep(delay)
                continue
            raise


# =============================================================================
# Main Problem Class
# =============================================================================


@include_in_docs
class Problem:
    """Define and solve a decision problem on a model.

    Use :meth:`solve_for` to declare decision variables, :meth:`minimize`/
    :meth:`maximize` to add objectives, and :meth:`satisfy` to add constraints.
    Then call :meth:`solve` and read results via :meth:`variable_values` or
    :meth:`load_point`.

    Parameters
    ----------
    model : Model
        The :class:`~relationalai.semantics.frontend.base.Model` to attach solver
        constructs (variables, objectives, constraints, and results) to.
    numeric_type : Concept
        Numeric type used for bounds, solution values, and numeric literals.
        Use :data:`~relationalai.semantics.Float` for HiGHS/Gurobi/Ipopt (even
        for MIPs), and :data:`~relationalai.semantics.Integer` for MiniZinc.

    Attributes
    ----------
    Variable : Concept
        Concept for the declared solver variables (useful for inspection via
        :meth:`display`).
    Expression : Concept
        Concept for objectives and constraints (useful for inspection via
        :meth:`display`).

    Examples
    --------
    Declare a variable and objective:

    >>> from relationalai.semantics import Float, Model
    >>> from relationalai.semantics.reasoners.prescriptive import Problem
    >>> m = Model("demo")
    >>> x = m.Relationship(f"{Float:x}")
    >>> p = Problem(m, Float)
    >>> p.solve_for(x, name="x", lower=0)
    >>> p.minimize(x)
    """

    def __init__(self, model: b.Model, numeric_type: b.Concept) -> None:
        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._result_rules_defined = False
        self._result_tables: dict[str, Any] = {}
        # 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 solve outputs.
        self._result_info = model.Relationship(f"{String:key} has {String:val}")
        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}")
        self._objective_values = Property(f"{Integer:sol_index} has {numeric_type:obj_value}")

        update_ref = Integer.ref()
        point_index = Integer.ref()
        var_hash = Hash.ref()
        point_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, point_val),
        ).define(self._point(var_hash, point_val))

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

    def _create_variable_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 _create_expression_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
    # =========================================================================

    @include_in_docs
    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[std.NumberValue] = None,
        upper: Optional[std.NumberValue] = None,
        start: Optional[std.NumberValue] = None,
    ) -> b.Concept:
        """Declare decision variables for the problem.

        Call this before adding objectives or constraints. The returned concept
        can be passed to :meth:`display` to inspect the generated variables.

        Parameters
        ----------
        expr : Relationship or Chain or Expression
            Expression describing the variable(s) to create (for example,
            a scalar relationship like ``x`` or an indexed property like
            ``Item.cost``).
        where : list[Any], optional
            Optional conditions restricting which variable instances are created.
        populate : bool, optional
            If True (default), write solved values back to the original
            relationship/property after :meth:`solve`. Set to False when you
            create multiple :class:`Problem` instances that solve for the same
            relationship on the same model.
        name : Any or list[Any], optional
            Display name for variables. Use a string for scalars or a list
            pattern for indexed variables (for example, ``["x", Item.i]``).
        type : str, optional
            Variable type: ``"cont"`` (default for ``Float``), ``"int"``
            (default for ``Integer``), or ``"bin"`` (binary 0/1).
        lower, upper, start : Variable or float or int or Decimal, optional
            Lower/upper bounds and an optional initial value hint.

        Returns
        -------
        Concept
            A ``Variable`` subconcept representing the declared decision variables.

        Raises
        ------
        ValueError
            If variables are already defined for this relationship, or if an
            argument has an invalid value (for example, an unknown ``type``).
        TypeError
            If an argument has an invalid type.
        """
        relationship, args = self._extract_relationship(expr)
        key = _expr_dict_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

        resolved_type = self._resolve_var_type(type)
        Var = self._create_variable_subconcept(relationship, resolved_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:
                val = self._validate_numeric_bound(attr, val)
                if isinstance(val, b.DSLBase):
                    var_defs.append(getattr(var, attr)(val))
                else:
                    ref_defs.append(getattr(ref, attr)(val))

        # Warn if binary variable has non-standard bounds (after validation).
        if resolved_type == "bin":
            for attr, val in [("lower", lower), ("upper", upper)]:
                if val is not None and not isinstance(val, b.Variable) and val not in (0, 1):
                    warnings.warn(
                        f"Binary variable has {attr}={val}. Binary variables are "
                        f"restricted to 0/1; non-standard bounds may be ignored by the solver.",
                        UserWarning,
                        stacklevel=2,
                    )

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

        if populate:
            populated = _populated_relationships.setdefault(self._model, set())
            if key in populated:
                warnings.warn(
                    "Multiple Problems 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.",
                    UserWarning,
                    stacklevel=2,
                )
            populated.add(key)

            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

    @staticmethod
    def _check_not_reading(rel: b.Relationship, context: str) -> None:
        """Raise if rel is a Reading (.alt()). Readings cannot be used in solve_for()."""
        if isinstance(rel, b.Reading):
            parent_name = rel._relationship._short_name or rel._relationship._name
            raise TypeError(
                f"Cannot use alternative reading (`.alt()`) in {context}. "
                f"Use the original relationship '{parent_name}' instead."
            )

    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):
            self._check_not_reading(expr, "solve_for()")
            rel, args = 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)")
            self._check_not_reading(expr._next, "solve_for()")
            rel, args = 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))"
                )
            self._check_not_reading(rel, "solve_for()")
            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)}))"
                )
            rel, args = rel, expr._args
        else:
            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)"
            )

        # The last field is the value — it must be numeric.
        value_type = rel._fields[-1].type
        if value_type is not Float and value_type is not Integer:
            raise TypeError(
                f"solve_for() requires a numeric (Float or Integer) property, "
                f"but '{rel._short_name}' has value type {value_type._name}. "
                f"Create a separate numeric property for decision variables."
            )
        return rel, args

    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 _validate_numeric_bound(self, name: str, val: Any) -> Any:
        """Validate and coerce a bound value to a DSL-compatible numeric type.

        Returns the (possibly coerced) value. Numpy float/int subclasses are
        converted to plain Python types since the DSL uses ``type(v) is float``
        identity checks.
        """
        if isinstance(val, bool):
            raise TypeError(
                f"Parameter '{name}' must be numeric (Variable, float, int, or Decimal), "
                f"got bool. Use 0/1 instead of False/True. "
                f"Example: solve_for(x, {name}=0)"
            )
        if not isinstance(val, (b.Variable, float, int, Decimal)):
            raise TypeError(
                f"Parameter '{name}' must be numeric (Variable, float, int, or Decimal), "
                f"got {type(val).__name__}. "
                f"Example: solve_for(x, {name}=0.0)"
            )
        if isinstance(val, float) and not math.isfinite(val):
            raise ValueError(
                f"Parameter '{name}' must be a finite number, got {val}. Example: solve_for(x, {name}=0.0)"
            )
        if isinstance(val, Decimal) and not val.is_finite():
            raise ValueError(
                f"Parameter '{name}' must be a finite number, got {val}. Example: solve_for(x, {name}=0.0)"
            )
        # Coerce numpy subclasses to plain Python types (DSL requires exact types).
        if isinstance(val, float) and type(val) is not float:
            return float(val)
        if isinstance(val, int) and type(val) is not int:
            return int(val)
        return val

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

    @include_in_docs
    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 decision variable declared via
        :meth:`solve_for`.

        Parameters
        ----------
        expr : Variable or float or int or Fragment
            Objective expression to minimize.
        name : Any or list[Any], optional
            Optional objective name (string for scalar, or a list pattern for
            indexed objectives).

        Returns
        -------
        Concept
            An ``Expression`` subconcept representing the objective. Pass it to
            :meth:`display` to inspect.

        Raises
        ------
        ValueError
            If the objective does not reference any declared decision variables.
        """
        return self._add_objective("min_objective", expr, name)

    @include_in_docs
    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 decision variable declared via
        :meth:`solve_for`.

        Parameters
        ----------
        expr : Variable or float or int or Fragment
            Objective expression to maximize.
        name : Any or list[Any], optional
            Optional objective name (string for scalar, or a list pattern for
            indexed objectives).

        Returns
        -------
        Concept
            An ``Expression`` subconcept representing the objective. Pass it to
            :meth:`display` to inspect.

        Raises
        ------
        ValueError
            If the objective does not reference any declared decision variables.
        """
        return self._add_objective("max_objective", expr, name)

    def _add_objective(
        self,
        expr_type: Literal["min_objective", "max_objective"],
        expr: b.Variable | float | int | b.Fragment,
        name: Optional[Any | list[Any]],
    ) -> b.Concept:
        """Add an objective (minimize or maximize)."""
        scalar_name, dsl_name = self._extract_scalar_name(name)
        sub = self._create_expression_subconcept(expr_type, name=scalar_name)
        self._add_expression(sub, expr, dsl_name)
        return sub

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

        Use this to turn a require-clause fragment into solver constraints.
        The returned concept can be passed to :meth:`display` to inspect.

        Parameters
        ----------
        expr : Fragment
            A fragment created by :meth:`~relationalai.semantics.frontend.base.Model.require`
            (optionally scoped with :meth:`~relationalai.semantics.frontend.base.Model.where`).
        name : Any or list[Any], optional
            Optional constraint name (string for scalar, or a list pattern for
            indexed constraints).

        Returns
        -------
        Concept
            An ``Expression`` subconcept representing the added constraints.

        Raises
        ------
        TypeError
            If ``expr`` is not a fragment.
        ValueError
            If the fragment has no require clause, or if it includes select/define
            clauses.
        """
        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._extract_scalar_name(name)
        sub = self._create_expression_subconcept("constraint", name=scalar_name)
        for req in expr._require:
            self._add_expression(sub, req, dsl_name, expr._where)
        return sub

    @staticmethod
    def _extract_scalar_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,
        subconcept: Any,
        expr: Any,
        name: Optional[Any | list[Any]],
        where: Optional[list[Any]] = None,
    ) -> None:
        """Add an objective or constraint expression to the model."""
        compiler = ExpressionCompiler(self)
        if where:
            compiler.wheres.extend(compiler.rewrite_where(*where))

        if expr is None:
            raise TypeError("Expression must not be None.")
        result = compiler.rewrite(expr)
        if not isinstance(result, SymbolicNode):
            raise ValueError(
                "Expression does not reference any decision variables. "
                "Ensure it contains at least one variable declared via solve_for(). "
                "Constant objectives and constraints are not supported."
            )

        expr_node = subconcept.new(root=result.expr)
        compiler.defines.append(expr_node)
        if name is not None:
            compiler.defines.append(expr_node.name(_make_name(name)))

        self._emit_defines(compiler)

    def _emit_defines(self, compiler: ExpressionCompiler) -> None:
        """Recursively emit model.define calls from the compiler's output."""
        self._model.define(*compiler.defines).where(*compiler.wheres)
        for child in compiler.children:
            self._emit_defines(child)

    # =========================================================================
    # 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]

    @include_in_docs
    @property
    def num_variables(self) -> int:
        """Return the number of declared decision variables.

        Returns
        -------
        int
            Total number of variables declared via :meth:`solve_for`.

        Notes
        -----
        This property queries the model.
        """
        return self._count_entities(self.Variable)

    @include_in_docs
    @property
    def num_constraints(self) -> int:
        """Return the number of declared constraints.

        Returns
        -------
        int
            Total number of constraints added via :meth:`satisfy`.

        Notes
        -----
        This property queries the model.
        """
        return self._count_entities(self.Expression, _EXPR_CODES["constraint"])

    @include_in_docs
    @property
    def num_min_objectives(self) -> int:
        """Return the number of minimization objectives.

        Returns
        -------
        int
            Total number of objectives added via :meth:`minimize`.

        Notes
        -----
        This property queries the model.
        """
        return self._count_entities(self.Expression, _EXPR_CODES["min_objective"])

    @include_in_docs
    @property
    def num_max_objectives(self) -> int:
        """Return the number of maximization objectives.

        Returns
        -------
        int
            Total number of objectives added via :meth:`maximize`.

        Notes
        -----
        This property queries the model.
        """
        return self._count_entities(self.Expression, _EXPR_CODES["max_objective"])

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

        With no arguments, this shows all declared variables, objectives, and
        constraints. Pass a subconcept returned by :meth:`solve_for`,
        :meth:`minimize`, :meth:`maximize`, or :meth:`satisfy` to display only
        that part.

        Parameters
        ----------
        part : Concept, optional
            Specific variable/objective/constraint subconcept to display.

        Returns
        -------
        str
            The formatted summary (also printed).

        Raises
        ------
        ValueError
            If ``part`` is not a subconcept returned by :meth:`solve_for`,
            :meth:`minimize`, :meth:`maximize`, or :meth:`satisfy`.

        Examples
        --------
        Display the full problem:

        >>> from relationalai.semantics import Float, Model
        >>> from relationalai.semantics.reasoners.prescriptive import Problem
        >>> m = Model("demo")
        >>> x = m.Relationship(f"{Float:x}")
        >>> p = Problem(m, Float)
        >>> x_vars = p.solve_for(x, name="x", lower=0)
        >>> p.minimize(x)
        >>> p.display()

        Display a single part (a subconcept returned by ``solve_for``/``minimize``/``satisfy``):

        >>> p.display(x_vars)

        Notes
        -----
        This method queries the model.
        """
        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("Problem 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(format_variable_table(var_df, var_types))
        lines.extend(format_expression_table(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 format_variable_table(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 format_expression_table(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

    # =========================================================================
    # 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(self, result_views: dict[str, str] | None = None) -> None:
        """Import solver results into model relations.

        All solver result views (ancillary, points, objective_values) are loaded
        in one batched payload and mapped to target relations in a single call.
        Data stays in Snowflake and is consumed directly by the logic engine.

        ``experimental.load_data`` has replace semantics, so each solve refreshes
        the same target relations and this model can reuse one set of derivation
        rules for all solves. ``result_views`` may override the source view for
        each result table while keeping stable target relations.
        """
        model = self._model

        resources = get_provider(config=model.config).resources
        if not isinstance(resources, SnowflakeResources):
            raise TypeError(f"Importing solver results 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},
        }

        if result_views is not None:
            expected = set(schemas.keys())
            actual = set(result_views.keys())
            if actual != expected:
                raise ValueError(
                    f"result_views must contain exactly {sorted(expected)}, got {sorted(actual)}"
                )

        if not self._result_tables:
            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]
                self._result_tables[name] = table

        table_fqns = [table._name for table in self._result_tables.values()]
        sources: dict[str, str] | None = None
        if result_views is not None:
            sources = {
                self._result_tables[name]._name: result_views[name]
                for name in schemas.keys()
            }
        _load_data_with_retry(
            model,
            resources,
            app_name,
            table_fqns,
            logic_engine_name,
            sources=sources,
        )

        if not self._result_rules_defined:
            ancillary_tbl = self._result_tables["ancillary"]
            points_tbl = self._result_tables["points"]
            obj_vals_tbl = self._result_tables["objective_values"]

            model.define(self._result_info(ancillary_tbl["NAME"], ancillary_tbl["VALUE"]))
            model.define(self._objective_values(obj_vals_tbl["SOL_INDEX"], obj_vals_tbl["VALUE"]))

            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, std.common.parse_uuid(var_hash_str), val_ref))

            self._result_rules_defined = True

        # By default we load the solution with index `1` into `point`.
        self._load_point_counter += 1
        self._model.define(self._point_updates(self._load_point_counter, 1))
        self._solve_counter += 1

    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)

    @include_in_docs
    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,
        **solver_params: int | float | str | bool,
    ) -> None:
        """Solve the decision problem using a solver backend.

        Declare decision variables first with :meth:`solve_for`. After solving,
        inspect results via :meth:`variable_values` and result properties such as
        ``termination_status`` and ``objective_value``.

        Parameters
        ----------
        solver : str
            Solver name (for example ``"highs"``, ``"minizinc"``, ``"ipopt"``).
        time_limit_sec : float, optional
            Maximum solve time in seconds. The solver service defaults to 300s
            if not provided.
        silent : bool, optional
            Whether to suppress solver output.
        solution_limit : int, optional
            Maximum number of solutions to return (when supported).
        relative_gap_tolerance : float, optional
            Relative optimality gap tolerance in ``[0, 1]``.
        absolute_gap_tolerance : float, optional
            Absolute optimality gap tolerance (>= 0).
        log_to_console : bool, default False
            Whether to stream solver logs to stdout while the job runs.
        print_only : bool, default False
            If True, request a text representation without solving. Results
            such as :attr:`printed_model` are still accessible afterward.
        print_format : str, optional
            Text format for the printed model. Supported formats: ``"moi"``
            (MOI text), ``"latex"``, ``"mof"`` (MOI JSON), ``"lp"``, ``"mps"``,
            ``"nl"`` (AMPL).
        **solver_params : int or float or str or bool
            Raw solver-specific parameters passed through to the solver service.

        Raises
        ------
        ValueError
            If no decision variables have been declared via :meth:`solve_for`.
        TypeError
            If any solver-specific parameter value is not an ``int``, ``float``,
            ``str``, or ``bool``.
        RuntimeError
            If the solver job fails.
        TimeoutError
            If the solver job does not reach a terminal state in time.

        Notes
        -----
        Passing ``**solver_params`` emits a warning because options may not be
        portable across solvers.
        """
        if not self._variable_relationships:
            raise ValueError("Cannot solve: no decision variables declared. Call solve_for() first.")
        if not isinstance(solver, str):
            raise TypeError(f"solver must be a string, got {type(solver).__name__}.")

        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.",
                UserWarning,
                stacklevel=2,
            )
            options.update(solver_params)

        import time

        client = connect_sync(config=self._model.config)
        ensure_op = client.reasoners.ensure(REASONER_TYPE)

        t0 = time.perf_counter()
        inputs = self._export_model_to_csv()
        logger.info("Exported CSV (%.2fs)", time.perf_counter() - t0)

        payload: dict[str, Any] = {
            "solver": solver.lower(),
            "version": 1,
            "options": options,
            "inputs": inputs,
            "model_id": self._model_id,
        }

        t1 = time.perf_counter()
        engine = ensure_op.wait()
        logger.info(
            "Solve: solver=%s, engine=%s, model_id=%s, options=%s",
            solver,
            engine.name,
            self._model_id,
            {k: v for k, v in options.items() if k != "inputs"},
        )
        op = client.jobs.create(REASONER_TYPE, engine.name, payload)
        # Add a 60s buffer on top of the solver's time limit so the client
        # doesn't time out before the solver does.
        wait_kwargs: dict[str, float] = {}
        if time_limit_sec is not None:
            wait_kwargs["timeout_s"] = float(time_limit_sec) + 60
        job = self._wait_for_job(client, op.id, log_to_console=log_to_console, **wait_kwargs)
        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}")
        logger.info("Solver job %s completed (%.2fs)", job.id, time.perf_counter() - t1)
        t2 = time.perf_counter()
        self._import_solver_results()
        logger.info("Imported results (%.2fs)", time.perf_counter() - t2)

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

    @include_in_docs
    def variable_values(self, multiple: bool = False) -> b.Fragment:
        """Return decision variable values from the active solution.

        Use this after :meth:`solve` (and optionally :meth:`load_point`) to read
        variable values as a fragment you can materialize with ``.to_df()`` or
        print with ``.inspect()``.

        Parameters
        ----------
        multiple : bool, default False
            If True, return values for all solutions from the most recent
            :meth:`solve` call and include a 0-based ``sol_index`` column.

        Returns
        -------
        Fragment
            A fragment with columns ``name`` and ``value`` (and ``sol_index`` if
            *multiple* is True).

        Raises
        ------
        ValueError
            If :meth:`solve` has not been called.
        """
        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)
        raw_index = Integer.ref()
        if multiple:
            return self._model.select(
                (raw_index - 1).alias("sol_index"),
                Var.name.alias("name"),
                val_ref.alias("value"),
            ).where(self._points(raw_index, var_hash, val_ref))

        return self._model.select(
            Var.name.alias("name"),
            val_ref.alias("value"),
        ).where(self._point(var_hash, val_ref))

    @include_in_docs
    def load_point(self, point_index: int) -> None:
        """Make a specific solution point the active solution.

        After calling this, :meth:`variable_values` and populated properties
        reflect the selected solution.

        Parameters
        ----------
        point_index : int
            0-based solution index (0 selects the first solution).

        Raises
        ------
        ValueError
            Raised in the following cases:

            - *point_index* is not a non-negative integer.
            - :meth:`solve` has not been called.
            - *point_index* is out of range (>= :attr:`result_count`).

        Notes
        -----
        Each call records a new selection via ``model.define(...)``. The next
        query may be slower because the model needs to re-evaluate.
        """
        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.")
        count = self.result_count
        if count is not None and point_index >= count:
            if count == 0:
                raise ValueError(f"Point index {point_index} is out of range. This solve returned 0 solutions.")
            raise ValueError(
                f"Point index {point_index} is out of range. "
                f"This solve returned {count} solution(s) (valid indices: 0 to {count - 1})."
            )
        self._load_point_counter += 1
        self._model.define(self._point_updates(self._load_point_counter, point_index + 1))

    @include_in_docs
    def display_solve_info(self) -> str:
        """Print and return a summary of solve metadata.

        This includes solver metadata fields such as ``termination_status``
        and ``solve_time_sec``.

        Returns
        -------
        str
            A formatted summary string (also printed).

        Raises
        ------
        ValueError
            If :meth:`solve` has not been called.

        Notes
        -----
        The objective value is not included; use :attr:`objective_value`.
        """
        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])

    @include_in_docs
    @property
    def termination_status(self) -> Optional[str]:
        """Return the solver termination status.

        This is typically a short string such as ``"OPTIMAL"`` or
        ``"INFEASIBLE"``.

        Returns
        -------
        str or None
            Termination status, or None if unavailable.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property queries the model.
        """
        self._require_solved("termination_status")
        return self._get_result_info("termination_status")

    @include_in_docs
    @property
    def objective_value(self) -> Optional[int | float]:
        """Return the objective value, if available.

        Returns
        -------
        int or float or None
            Objective value. In ``Integer`` mode this is an ``int``; in ``Float``
            mode this is a ``float``. Returns None when the solver did not
            provide an objective value.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property queries the model.
        """
        self._require_solved("objective_value")
        update_ref = Integer.ref()
        point_index = Integer.ref()
        val_ref = self._numeric_type.ref()
        df = self._model.select(val_ref).where(
            self._point_updates(update_ref, point_index),
            update_ref == std.aggregates.max(update_ref),
            self._objective_values(point_index, 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]

    @include_in_docs
    @property
    def solve_time_sec(self) -> Optional[float]:
        """Return solve time in seconds, if available.

        Returns
        -------
        float or None
            Solve time in seconds.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property 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

    @include_in_docs
    @property
    def result_count(self) -> Optional[int]:
        """Return the number of solution points, if available.

        Returns
        -------
        int or None
            Number of solution points.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property 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

    @include_in_docs
    @property
    def solver_version(self) -> Optional[str]:
        """Return the solver version string, if available.

        Returns
        -------
        str or None
            Solver version.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property queries the model.
        """
        self._require_solved("solver_version")
        return self._get_result_info("solver_version")

    @include_in_docs
    @property
    def printed_model(self) -> Optional[str]:
        """Return the solver-provided text representation of the problem.

        Request this output via :meth:`solve` using ``print_only=True`` or by
        specifying ``print_format``.

        Returns
        -------
        str or None
            Printed model text.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property queries the model.
        """
        self._require_solved("printed_model")
        return self._get_result_info("printed_model")

    @include_in_docs
    @property
    def error(self) -> Optional[str]:
        """Return a solver error message, if available.

        Returns
        -------
        str or None
            Error message.

        Raises
        ------
        AttributeError
            If :meth:`solve` has not been called.

        Notes
        -----
        Accessing this property queries the model.
        """
        self._require_solved("error")
        return self._get_result_info("error")
