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

## Package Structure

    problem.py      — Problem class: API surface, solve orchestration, result import.
    wire_format.py  — Serialization protocol: op codes, derived lookups, hash library.
    rewriter.py     — ExpressionCompiler: DSL → Hash AST compilation.
    display.py      — Formatting: AST/variable/expression → human-readable strings.

## Classes

    Problem: Main interface for defining and solving decision problems.
"""

from __future__ import annotations

from typing import Any, Literal, Optional
from weakref import WeakKeyDictionary
import logging
import uuid
import warnings
from pandas import DataFrame

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

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

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 _make_name(*args: Any, sep: Optional[str] = "_") -> Any:
    """Construct a name by concatenating arguments.

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

    Returns:
        String or expression representing the concatenated name.

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

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

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

    return std.strings.concat(*str_args)


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

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


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

    Args:
        left: Antecedent expression (comparison or constraint).
        right: Consequent expression (comparison or constraint).

    Both arguments must reference solver variables (declared via solve_for).
    The solver enforces: whenever ``left`` is satisfied, ``right`` must also hold.
    """
    return _implies(left, right)


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


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

    Args:
        *args: Decision variable expressions to constrain. All must be
            distinct in any feasible solution. Typically used with ``.per()``
            to scope the constraint per entity group.
    """
    return b.Aggregate(_all_different, *args)


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


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

    Args:
        index: Ordering index for the variables in the set.
        variables: Decision variable expressions in the SOS1 set.
    """
    return b.Aggregate(_sos1, index, variables)


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


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

    Args:
        index: Ordering index that defines adjacency in the set.
        variables: Decision variable expressions in the SOS2 set.

    Used for piecewise-linear modeling: at most two variables with
    consecutive indices can be non-zero simultaneously.
    """
    return b.Aggregate(_sos2, index, variables)


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

# Maximum rows per model.data() chunk in client-side result import.
# The EliminateData LQP pass converts each row into a union branch with
# literal values. At 3 columns the AST limit (~1000 nodes) is hit between
# 1000-2000 rows. 500 gives comfortable headroom.
_DATA_CHUNK_SIZE = 500


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


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

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

    total_backoff = 0.0
    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,
            )
            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
# =============================================================================


class Problem:
    """Declarative interface for defining and solving decision problems.

    Supports optimization (minimize/maximize), constraint satisfaction, and
    feasibility problems. Typical usage::

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

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

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

        Args:
            model: The RelationalAI model to attach solver constructs to.
            numeric_type: ``Float`` for HiGHS/Gurobi/Ipopt (even for MIPs),
                ``Integer`` for MiniZinc. This determines the numeric data
                type used for variable bounds, solution values, and AST
                literals — not the variable types in your problem.
        """
        if not (numeric_type is Float or numeric_type is Integer):
            raise ValueError(f"Invalid numeric type '{numeric_type}'. Must be Float or Integer.")
        self._model = model
        self._numeric_type = numeric_type
        self._model_id = str(uuid.uuid4()).upper().replace("-", "_")
        self._load_point_counter = 0
        self._solve_counter = 0
        self._ov_rule_defined = False
        self._degraded = False  # True if a solve() failed after partial model mutation
        # 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._points = Property(f"{Integer:sol_index} has {Hash:var_hash} with {numeric_type:value}")
        self._point = Property(f"{Hash:var_hash} has {numeric_type:value}")
        # Trace of load_point updates; max aggregation selects the latest.
        self._point_updates = Property(f"at {Integer:update} we load {Integer:point}")

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

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

    def _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
    # =========================================================================

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

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

        Returns:
            Variable subconcept (Concept). Pass to ``display()`` to inspect.
        """
        relationship, args = self._extract_relationship(expr)
        key = _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

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

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

        # Scalar-valued optional properties: uses ref() for cleaner IR.
        ref = Var.ref()
        ref_defs: list[Any] = []
        for attr, val in [("lower", lower), ("upper", upper), ("start", start)]:
            if val is not None:
                self._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))

        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

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

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

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

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

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

        The expression must reference at least one solver variable.

        Returns:
            Expression subconcept (Concept). Pass to ``display()`` to inspect.
        """
        return self._add_objective("min_objective", expr, name)

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

        The expression must reference at least one solver variable.

        Returns:
            Expression subconcept (Concept). Pass to ``display()`` to inspect.
        """
        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

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

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

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

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

        scalar_name, dsl_name = self._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))

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        ast_dfs = self._query_ast(self._reachable)

        # Build summary header.
        var_counts = var_types.value_counts()
        expr_counts = expr_types.value_counts()

        def get(counts: Any, codes: dict[str, int], key: str) -> int:
            return int(counts.get(codes[key], 0))

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

        lines: list[str] = []
        lines.append("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_from_views(self, result_dfs: dict[str, DataFrame] | None = None):
        model = self._model

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

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

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

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

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

        # Inject points into the model (needed for _points define rule).
        # Skip if no points (e.g. infeasible) to avoid TypeError from empty data source.
        # Chunked to stay under the EliminateData AST node limit — each model.data()
        # call becomes a union of literal rows in the LQP, and large unions exceed
        # the engine's AST normalization limit (~1000 nodes).
        points_df = result_dfs["points"]
        if not points_df.empty:
            is_name_based = "VAR_NAME" in points_df.columns
            for start in range(0, len(points_df), _DATA_CHUNK_SIZE):
                p = model.data(points_df.iloc[start : start + _DATA_CHUNK_SIZE])
                if is_name_based:
                    # Name-based path (testing): join through Variable.name to resolve
                    # entity hashes, since parse_uuid doesn't recover entity hashes.
                    var = self.Variable.ref()
                    var_hash = core_cast(b.MetaRef(Hash), var)
                    name_ref = String.ref()
                    val_ref = self._numeric_type.ref()
                    model.where(
                        self.Variable.name(var, name_ref),
                        p.VAR_NAME == name_ref,
                        p.VALUE == val_ref,
                    ).define(self._points(p.SOL_INDEX + solve_offset, var_hash, val_ref))
                else:
                    # UUID-based path (production): solver returns UUID strings.
                    model.define(
                        self._points(
                            p.SOL_INDEX + solve_offset,
                            std.common.parse_uuid(p.VAR_HASH),
                            p.VALUE,
                        )
                    )

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

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

        self._define_result_rules(solve_offset, has_objective_data=not result_dfs["objective_values"].empty)

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

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

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

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

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

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

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

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

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

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

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

        # Derivation rules + version trace + load point (shared with client-side path).
        # has_objective_data=True: Table joins on empty data produce no tuples in Rel.
        self._define_result_rules(solve_offset, has_objective_data=True)

    def _define_result_rules(self, solve_offset: int, has_objective_data: bool) -> None:
        """Define derivation rules for result projection and record version trace.

        Shared by both client-side and server-side import paths. Defines the
        max-aggregation rules that project versioned backing stores into
        _result_info, _objective_values, and _point, then records the
        _point_updates default for loading solution 1.

        Called after data has been inserted into versioned stores and
        _solve_counter has been incremented.

        Parameters
        ----------
        solve_offset : int
            Offset applied to solution indices to avoid FD conflicts across re-solves.
        has_objective_data : bool
            Whether objective data is available. Client-side passes
            ``not result_dfs["objective_values"].empty``; server-side passes ``True``
            (Table joins handle empty data without error).
        """
        model = self._model

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

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

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

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

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

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

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

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

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

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

        while True:
            if log_to_console:
                _drain_events()

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

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

            _time.sleep(poll_interval_s)

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

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

        Examples::

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

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

        Args:
            solver: The solver to use (e.g. ``"highs"``, ``"minizinc"``, ``"ipopt"``).
            time_limit_sec: Maximum solve time in seconds. The solver service
                defaults to 300s if not provided.
            silent: Whether to suppress solver output.
            solution_limit: Maximum number of solutions to find.
            relative_gap_tolerance: Relative optimality gap tolerance in [0, 1].
            absolute_gap_tolerance: Absolute optimality gap tolerance (>= 0).
            log_to_console: Whether to stream solver logs to the console.
            print_only: Print the problem without solving. Results
                like ``printed_model`` are still accessible afterward.
            print_format: Request a text representation of the problem.
                The printed text is available via ``printed_model`` after the
                call. Works with or without ``print_only``. Supported formats:
                ``"moi"`` (MOI text), ``"latex"``, ``"mof"`` (MOI JSON),
                ``"lp"``, ``"mps"``, ``"nl"`` (AMPL).
            _server_side_import: Internal option controlling how solver results
                are imported. Default ``True`` (server-side). Pass ``False``
                for faster imports (~0.5s vs ~250s) at the cost of downloading
                results to the client. Temporary parameter — will be removed
                in a future release.
            **solver_params: Raw solver-specific parameters passed directly to
                the underlying solver. Example: ``presolve="off"``.
        """
        if not self._variable_relationships:
            raise ValueError("Cannot solve: no decision variables declared. Call solve_for() first.")
        if self._degraded:
            raise RuntimeError(
                "Problem is in a degraded state after a prior solve() failure left "
                "partial model state. Create a new Problem instance to continue."
            )

        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()
        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)

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

        payload: dict[str, Any] = {
            "solver": solver.lower(),
            "version": 1,
            "options": options,
            "inputs": inputs,
            "model_id": self._model_id,
        }
        try:
            t1 = time.perf_counter()
            engine = ensure_op.wait()
            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)
            job = self._wait_for_job(client, op.id, log_to_console=log_to_console)
            if (job.state or "").upper() != "COMPLETED":
                state = (job.state or "UNKNOWN").lower()
                reason = job.abort_reason or "no details available"
                raise RuntimeError(f"Solver job {job.id} {state}: {reason}")
            logger.info("Solver job %s completed (%.2fs)", job.id, time.perf_counter() - t1)
            t2 = time.perf_counter()
            # TODO: Once api.load_data performance improves, always use
            # server-side import and remove _server_side_import + the
            # client-side (_import_solver_results_from_views) path entirely.
            if _server_side_import:
                self._import_solver_results_server_side()
            else:
                self._import_solver_results_from_views()
            logger.info("Imported results (%.2fs)", time.perf_counter() - t2)
        except Exception:
            # Restore counters. Note: model.define() calls are additive and cannot
            # be rolled back, so partial model state may persist on import failure.
            self._model_id = prev_model_id
            self._solve_counter = prev_solve_counter
            self._load_point_counter = prev_load_point_counter
            self._degraded = True
            raise

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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