import logging
import weakref
from collections.abc import Iterator, Mapping, Sequence
from contextvars import Token
from contextlib import contextmanager
from typing import Any, ClassVar, Literal

from pydantic import BaseModel, ConfigDict

from kele.config import Config, init_config_logger
from kele.control import GroundingRuleSelector, InferencePath, RunMode, StatusChecker, create_main_loop_manager
from kele.control.conflict import ConflictEvaluator, ConflictReason
from kele.control.grounding_selector import GroundingFlatTermWithWildCardSelector
from kele.control.infer_path import FactStep
from kele.control.metrics import PhaseTimer, end_run, inc_iter, init_metrics, measure, observe_counts, start_run
from kele.control.status import InferenceStatus, QuerySolutionManager
from kele.equality import Equivalence
from kele.executer import Executor
from kele.grounder import GroundedRule, GroundedRuleDS, Grounder
from kele.knowledge_bases import FactBase, RuleBase, load_ontologies
from kele.syntax import (
    FACT_TYPE,
    Assertion,
    CompoundTerm,
    Concept,
    Constant,
    Operator,
    ConflictRule,
    Question,
    Rule,
    SankuManagementSystem,
    Variable,
    _QuestionRule,
)
from kele.utils import summarize_items

logger = logging.getLogger(__name__)
TerminatedBy = Literal["initial_check", "executor", "main_loop", "conflict", "unknown"]


class QueryStructure(BaseModel):
    """Input data structure for inference queries.

    :param premises: Premises asserted for the query.
    :param question: Facts representing the question to solve.
    """
    premises: Sequence[Assertion]
    question: Sequence[FACT_TYPE]

    model_config: ClassVar = ConfigDict(
        arbitrary_types_allowed=True,
        extra="forbid",
    )


class EngineRunResult(BaseModel):
    """Result data structure returned by the inference engine.

    :param status: Terminal status of the run.
    :param final_facts: Facts accumulated at the end of execution.
    :param fact_num: Total number of facts at termination.
    :param include_final_facts: Whether ``final_facts`` are included in serialization.
    :param question: Question that was executed.
    :param iterations: Number of complete grounding/execution cycles.
    :param execute_steps: Number of executor steps completed (rules executed).
    :param terminated_by: Component that triggered termination.
    :param solution_count: Number of solutions found.
    :param solutions: Variable bindings for each solution (may be empty).
    """
    model_config: ClassVar = ConfigDict(
        arbitrary_types_allowed=True,
        extra="forbid",
    )

    status: InferenceStatus
    final_facts: list[FACT_TYPE]
    fact_num: int
    include_final_facts: bool
    question: Question
    iterations: int
    execute_steps: int
    terminated_by: TerminatedBy
    solution_count: int  # Number of solutions found

    # Detailed solution bindings (empty list if save_solutions=False in config)
    solutions: list[Mapping[Variable, Constant | CompoundTerm]]
    conflict_reason: ConflictReason | None = None

    @property
    def has_solution(self) -> bool:
        """Return whether any solution exists."""
        return self.solution_count > 0

    @property
    def is_success(self) -> bool | None:  # None means unknown/undetermined
        """
        Determine whether the run is considered successful.

        - ``SUCCESS``: success.
        - ``FIXPOINT_REACHED`` with solutions: success.
        - ``MAX_*``/``EXTERNALLY_INTERRUPTED`` with solutions: partial success.
        """
        if self.status == InferenceStatus.SUCCESS:
            return True

        if self.status == InferenceStatus.FIXPOINT_REACHED:
            return self.has_solution

        return False

    @property
    def is_partial_success(self) -> bool | None:  # None means unknown/undetermined
        """
        Has solutions, but stopped early due to resource limits or external interruption.
        There may be more solutions; no solutions does not imply failure.
        """
        return self.has_solution and self.status in {
                InferenceStatus.MAX_STEPS_REACHED,
                InferenceStatus.MAX_ITERATIONS_REACHED,
                InferenceStatus.EXTERNALLY_INTERRUPTED,
            }

    def log_message(self) -> str:
        """Build a log-friendly message."""
        msg = (f"Inference finished.\n"
               f"status={self.status}, success={self.is_success}, partial_success=={self.is_partial_success}, "
               f"terminated_by={self.terminated_by}, iterations={self.iterations}, facts_num={self.fact_num}, "
               f"has_solution={self.has_solution}, solution_count={self.solution_count}")

        # Show detailed solutions if stored
        if self.solutions:
            for i, sol in enumerate(self.solutions):
                msg += f"\n  Solution {i + 1}: " + ", ".join(f"{var.display_name}={val}" for var, val in sol.items())

        return msg

    def to_dict(self, *, include_final_facts: bool | None = None) -> dict[str, Any]:
        """Serialize the result (solutions may be empty if save_solutions=False)."""
        if include_final_facts is None:
            include_final_facts = self.include_final_facts
        if include_final_facts:
            return self.model_dump()
        return self.model_dump(exclude={"final_facts"})


class InferenceEngine:
    """KELE inference module that runs grounding and execution."""

    # TODO: Refactor __init__ into smaller helpers and remove PLR0915 suppression.
    def __init__(self,  # noqa: PLR0913, PLR0915
                 facts: Sequence[FACT_TYPE] | str | None,
                 rules: Sequence[Rule] | str | None,
                 *,
                 concept_dir_or_path: str = 'knowledge_bases/builtin_base/builtin_concepts.py',
                 operator_dir_or_path: str = 'knowledge_bases/builtin_base/builtin_operators.py',
                 user_config: Config | None = None,
                 config_file_path: str | None = None,  # TODO: Consider moving custom log file into Config.
                 enable_description_registry: bool = False,
                 ) -> None:
        """
        Initialize the inference engine with facts and rules.

        :param facts: Facts or path to facts; None loads defaults.
        :param rules: Rules or path to rules; None loads defaults.
        :param concept_dir_or_path: Path to concept ontology definitions.
        :param operator_dir_or_path: Path to operator ontology definitions.
        :param user_config: Optional configuration overrides.
        :param config_file_path: Optional config file path.
        :param enable_description_registry: Whether to record description registry entries during loading.
        """
        self.args = init_config_logger(user_config, config_file_path)
        self.description_registry_namespace: str | None = None
        self._description_registry_finalizer: weakref.finalize[Any, Any] | None = None
        self._configure_description_registry(enable_description_registry=enable_description_registry)

        def _get_source_info(obj: Sequence[FACT_TYPE] | Sequence[Rule] | str | None, name: str) -> str:
            if isinstance(obj, str):  # Note that str is also a Sequence.
                return f"{name} from file: {obj}"
            if isinstance(obj, Sequence):
                return f"{name} from list, length={len(obj)}"
            if obj is None:
                return f"{name} is None"

            raise TypeError(f"Unsupported type for obj: {type(obj).__name__}")

        logger.info("Initializing inference engine: Load %s; Load %s",
                    _get_source_info(facts, "facts"),
                    _get_source_info(rules, "rules"))

        self.equivalence = Equivalence(args=self.args)
        sk_system_handler = SankuManagementSystem()
        # TODO: Knowledge base declarations may require db_url from args; not implemented yet.

        facts = self.args.path.fact_dir if facts is None else facts
        rules = self.args.path.rule_dir if rules is None else rules

        scope_tokens = self._activate_runtime_scope()
        try:
            load_ontologies(
                concept_dir_or_path=concept_dir_or_path,
                operator_dir_or_path=operator_dir_or_path,
            )

            # selector
            self.rule_selector = GroundingRuleSelector(
                strategy=self.args.strategy.grounding_rule_strategy,
                question_rule_interval=self.args.strategy.question_rule_interval,
            )

            self.term_selector = GroundingFlatTermWithWildCardSelector(
                equivalence=self.equivalence,
                args=self.args,
            )

            # knowledge base
            self.fact_base = FactBase(
                initial_facts_or_dir_or_path=facts,
                equivalence_handler=self.equivalence,
                term_selector=self.term_selector,
                sk_system_handler=sk_system_handler,
                args=self.args.engineering,
            )
            # only one global fact_base is maintained.

            source_rules = rules
            normal_rules: Sequence[Rule] | str
            if isinstance(source_rules, Sequence) and not isinstance(source_rules, str):
                self.conflict_rules = [r for r in source_rules if isinstance(r, ConflictRule)]
                normal_rules = [r for r in source_rules if not isinstance(r, ConflictRule)]
            else:
                self.conflict_rules = []
                normal_rules = source_rules

            self.rule_base = RuleBase(normal_rules, args=self.args.engineering)

            if logger.isEnabledFor(logging.DEBUG):
                logger.debug("Fact base created with %s facts", len(self.fact_base.facts))

            if logger.isEnabledFor(logging.DEBUG):
                logger.debug("Rule base created with %s rules", len(self.rule_base.rules))
            logger.info("Inference engine created successfully.")
        except Exception:
            logger.exception("Initialization failed: ontologies_path=(concept=%s, operator=%s)\n(facts=%s, rules=%s)",
                             concept_dir_or_path,
                             operator_dir_or_path,
                             facts[:2] if facts else None,
                             rules[:2] if rules else None)
            raise
        finally:
            # Always restore previous runtime scope to avoid leaking namespace state.
            self._restore_runtime_scope(scope_tokens)
        # Create the solution manager.
        self.solution_manager = QuerySolutionManager(args=self.args.run)

        # Shared status checker across executor/main-loop.
        self.status_checker = StatusChecker(self.equivalence, sk_system_handler)

        # Create the main loop manager.
        self.main_loop_manager = create_main_loop_manager(
            self.equivalence,
            sk_system_handler,
            args=self.args.run,
            status_checker=self.status_checker,
        )

        # Create inference path dealer
        self.inference_path = InferencePath(self.args.run, self.equivalence)

        # Prepare conflict evaluator using the same preprocessing/grounding pipeline.
        self.conflict_evaluator = ConflictEvaluator(
            conflict_rules=self.conflict_rules,
        )
        self._empty_question = Question(premises=[], question=[])
        self._conflict_reason: ConflictReason | None = None

        if self.conflict_evaluator.has_rules() and self.args.run.interactive_query_mode == "first":
            logger.warning(
                "[conflict] conflict rules are enabled while interactive_query_mode='first'; "
                "early termination after the first solution can miss conflicts derived later. "
                "A final conflict check still runs at termination on currently derived facts. "
                "If interactive_query_mode='all', more conflicts can be discovered, but cyclic "
                "dependencies may require many rounds and terminate only at fixpoint or configured "
                "step/iteration limits."
            )

        # Create the Grounder.
        grounded_structure = GroundedRuleDS(equivalence=self.equivalence, sk_system_handler=sk_system_handler,
                                            args=self.args, inference_path=self.inference_path)
        # FIXME: Extract DS into a standalone component.
        self.grounder = Grounder(rule_selector=self.rule_selector,
                                 term_selector=self.term_selector,
                                 grounded_structure=grounded_structure,
                                 args=self.args.grounder)

        self.executor = Executor(equivalence=self.equivalence,
                                 sk_system_handler=sk_system_handler,
                                 fact_base=self.fact_base,
                                 main_loop_manager=self.main_loop_manager,
                                 solution_manager=self.solution_manager,
                                 status_checker=self.status_checker,
                                 inference_path=self.inference_path,
                                 args=self.args.executor)

        # Track whether the engine has completed at least one inference run.
        self._has_previous_run: bool = False

        # Initialize metrics monitoring.
        init_metrics(job="al_inference", grouping={"env": "dev"})

    def _configure_description_registry(self, *, enable_description_registry: bool) -> None:
        """Configure description registries for the current engine instance."""
        if not enable_description_registry:
            return

        self.description_registry_namespace = f"engine-{id(self)}"
        for registry_cls in self._registry_scope_targets():
            # Engine setup only enables its own namespace. The global `default`
            # namespace is intentionally not bootstrapped here.
            registry_cls._enable_kv_registry_namespace(  # noqa: SLF001
                enabled=True,
                namespace=self.description_registry_namespace,
            )
        self._snapshot_default_registry_to_engine_namespace()
        self._description_registry_finalizer = weakref.finalize(
            self,
            InferenceEngine._cleanup_description_registry_namespace,
            self.description_registry_namespace,
        )

    @staticmethod
    def _registry_scope_targets() -> tuple[type[Constant | Concept | Operator], ...]:
        """
        Return classes whose active namespace should follow engine runtime scope.

        This is intentionally centralized so future scoped components can be added
        in one place without touching call sites.
        """
        return (Constant, Concept, Operator)

    def _activate_runtime_scope(
        self,
    ) -> list[tuple[type[Constant | Concept | Operator], Token[str | None]]]:
        """
        Activate engine runtime scope and return activation tokens.

        This keeps runtime scoping generic: any class listed in
        ``_registry_scope_targets`` will be activated in the current context.
        """
        registry_namespace = self.description_registry_namespace
        if registry_namespace is None:
            return []

        tokens: list[tuple[type[Constant | Concept | Operator], Token[str | None]]] = []
        for registry_cls in self._registry_scope_targets():
            token = registry_cls.activate_kv_registry_namespace(registry_namespace)
            tokens.append((registry_cls, token))
        return tokens

    @staticmethod
    def _restore_runtime_scope(
        tokens: Sequence[tuple[type[Constant | Concept | Operator], Token[str | None]]],
    ) -> None:
        """
        Restore runtime scope in reverse activation order.

        This only restores active-namespace context. It does not clear registry data.
        """
        for registry_cls, token in reversed(tokens):
            registry_cls.reset_kv_registry_namespace(token)

    def _snapshot_default_registry_to_engine_namespace(self) -> None:
        """
        Copy default namespace entries into this engine namespace.

        This gives each engine an initial snapshot while retaining default as a
        global fallback source for later additions.
        """
        if self.description_registry_namespace is None:
            return

        scope_tokens = self._activate_runtime_scope()
        try:
            for registry_cls in self._registry_scope_targets():
                for key, value in registry_cls.get_kv_items(namespace="default"):
                    registry_cls.record_kv(key, value)
        finally:
            self._restore_runtime_scope(scope_tokens)

    @staticmethod
    def _cleanup_description_registry_namespace(registry_namespace: str | None) -> None:
        """Remove all registry entries for a specific namespace."""
        if registry_namespace is None:
            return
        Constant.clear_kv_registry(namespace=registry_namespace)
        Concept.clear_kv_registry(namespace=registry_namespace)
        Operator.clear_kv_registry(namespace=registry_namespace)

    def close(self) -> None:
        """Release resources owned by the engine instance."""
        if self._description_registry_finalizer is not None and self._description_registry_finalizer.alive:
            self._description_registry_finalizer()
        self.description_registry_namespace = None

    @staticmethod
    def _normalize_description_lookup_key(
        key: str | Constant | Concept | Operator,
    ) -> tuple[str, Literal["constant", "concept", "operator"] | None]:
        """
        Normalize lookup keys and infer registry type for syntax objects.

        String keys keep the existing cross-registry lookup behavior.
        """
        if isinstance(key, Constant):
            return str(key.symbol), "constant"
        if isinstance(key, Concept):
            return key.name, "concept"
        if isinstance(key, Operator):
            return key.name, "operator"
        return key, None

    @staticmethod
    def _lookup_description_by_key(
        key: str | Constant | Concept | Operator,
        *,
        registry_namespace: str | None = None,
        registry_type: Literal["constant", "concept", "operator"] | None = None,
    ) -> str | None:
        """Fetch a description from registries by key."""
        lookup_key, inferred_registry_type = InferenceEngine._normalize_description_lookup_key(key)
        if (
            registry_type is not None
            and inferred_registry_type is not None
            and registry_type != inferred_registry_type
        ):
            logger.warning(
                "Explicit registry_type %r conflicts with inferred registry_type %r for key %r; "
                "using explicit registry_type.",
                registry_type,
                inferred_registry_type,
                lookup_key,
            )
        effective_registry_type = registry_type or inferred_registry_type
        registries: dict[str, type[Constant | Concept | Operator]] = {
            "constant": Constant,
            "concept": Concept,
            "operator": Operator,
        }
        fallback_namespace = "default" if registry_namespace != "default" else None

        if effective_registry_type is not None:
            obj = registries[effective_registry_type].get_kv(lookup_key, namespace=registry_namespace)
            if obj is None and fallback_namespace is not None:
                obj = registries[effective_registry_type].get_kv(lookup_key, namespace=fallback_namespace)
            return getattr(obj, "description", None) if obj is not None else None

        ordered_registry_names = ("constant", "concept", "operator")

        def _collect_matches(namespace: str | None) -> list[tuple[str, object]]:
            namespace_matches: list[tuple[str, object]] = []
            for name in ordered_registry_names:
                obj = registries[name].get_kv(lookup_key, namespace=namespace)
                if obj is not None:
                    namespace_matches.append((name, obj))
            return namespace_matches

        matched_namespace = registry_namespace
        matches = _collect_matches(registry_namespace)
        if not matches and fallback_namespace is not None:
            matched_namespace = fallback_namespace
            matches = _collect_matches(fallback_namespace)

        if not matches:
            return None

        if len(matches) > 1:
            matched_registry_names = [name for name, _ in matches]
            logger.warning(
                "Ambiguous description key %r in namespace %r; matches=%s. "
                "Using precedence constant > concept > operator.",
                lookup_key,
                matched_namespace,
                matched_registry_names,
            )

        return getattr(matches[0][1], "description", None)

    def get_description_by_key(
        self,
        key: str | Constant | Concept | Operator,
        *,
        registry_type: Literal["constant", "concept", "operator"] | None = None,
    ) -> str | None:
        """Fetch a description from the registry tied to this engine."""
        return self._lookup_description_by_key(
            key,
            registry_namespace=self.description_registry_namespace,
            registry_type=registry_type,
        )

    @contextmanager
    def _mode_scope(self, mode: RunMode) -> Iterator[None]:
        """Temporarily switch engine modules to a run mode and restore afterwards."""
        prev_grounder_mode = self.grounder.mode
        prev_executor_mode = self.executor.mode

        self.grounder.mode = mode
        self.executor.mode = mode
        try:
            yield
        finally:
            self.grounder.mode = prev_grounder_mode
            self.executor.mode = prev_executor_mode

    @staticmethod
    def _resolve_executor_terminal(
        *,
        exec_status: InferenceStatus,
        grounded_rules: Sequence[GroundedRule],
    ) -> tuple[InferenceStatus, TerminatedBy] | None:
        if not exec_status.is_terminal_for_main_loop():
            return None

        logger.result("Inference terminated due to executor: %s", exec_status.log_message())  # type: ignore[attr-defined]
        terminated_by: TerminatedBy = "conflict" if exec_status == InferenceStatus.CONFLICT_DETECTED else "executor"
        logger.info("Executing: %i rules", len(grounded_rules))
        if logger.isEnabledFor(logging.DEBUG):
            summary = summarize_items(grounded_rules, formatter=lambda rule: str(rule.rule))
            logger.debug("Executing rules summary: %s", summary)
        return exec_status, terminated_by

    def _resolve_main_loop_terminal(
        self,
        *,
        main_status: InferenceStatus,
        question: Question,
    ) -> tuple[InferenceStatus, TerminatedBy] | None:
        if not main_status.is_terminal_for_main_loop():
            return None

        logger.result("Main loop terminating: %s", main_status.log_message())  # type: ignore[attr-defined]
        self._handle_fixpoint(final_status=main_status, question=question)
        return main_status, "main_loop"

    def _run_inference_loop(self, question: Question, *, module_name: str) -> tuple[InferenceStatus, TerminatedBy]:
        final_status: InferenceStatus | None = None
        terminated_by: TerminatedBy = "unknown"

        with self._mode_scope(RunMode.INFERENCE):
            while True:
                logger.info("Inference iteration %s...", self.main_loop_manager.iteration)

                # Grounding process produces instantiated rules based on current facts/terms.
                with PhaseTimer("grounding", module=module_name):
                    grounded_rules = self.grounder.grounding_process(question=question)
                observe_counts(grounded_rules=len(grounded_rules), facts_count=len(grounded_rules))

                selection_only_question_rules = self.grounder.selected_only_question_rules()
                if not grounded_rules:
                    if not selection_only_question_rules:
                        inc_iter(module_name)
                    logger.info("Inference iteration %s: No new groundings found.", self.main_loop_manager.iteration)
                    continue
                if not selection_only_question_rules:
                    inc_iter(module_name)

                with PhaseTimer("execute", module=module_name):
                    exec_status = self.executor.execute(
                        grounded_rules=grounded_rules,
                        question=question,
                    )
                executor_terminal = self._resolve_executor_terminal(
                    exec_status=exec_status,
                    grounded_rules=grounded_rules,
                )
                if executor_terminal is not None:
                    final_status, terminated_by = executor_terminal
                    break

                if self._check_conflict_once():
                    terminated_by = "conflict"
                    final_status = InferenceStatus.CONFLICT_DETECTED
                    logger.result("Inference terminated due to conflict check in main loop.")  # type: ignore[attr-defined]
                    break

                with PhaseTimer("main_check", module=module_name):
                    # main checks global/fixpoint status; executor checks per-step/new-facts status.
                    main_status = self.main_loop_manager.check_status([], question)
                main_terminal = self._resolve_main_loop_terminal(
                    main_status=main_status,
                    question=question,
                )
                if main_terminal is not None:
                    final_status, terminated_by = main_terminal
                    break

                if not selection_only_question_rules:
                    self.main_loop_manager.next_iteration()

        if final_status is None:
            raise RuntimeError("Inference loop exited without terminal status.")
        return final_status, terminated_by

    def _apply_final_conflict_check(self, status: InferenceStatus, terminated_by: TerminatedBy) -> tuple[InferenceStatus, TerminatedBy]:
        if status != InferenceStatus.CONFLICT_DETECTED and self._check_conflict_once():
            return InferenceStatus.CONFLICT_DETECTED, "conflict"
        return status, terminated_by

    def _check_conflict_once(self) -> bool:
        """Run one conflict-check cycle and cache the latest conflict reason."""
        evidence_facts = self._run_conflict_probe()
        self._conflict_reason = self.conflict_evaluator.check(evidence_facts=evidence_facts)
        return self._conflict_reason is not None

    def _run_conflict_probe(self) -> list[FACT_TYPE]:
        """Run conflict probing and return as soon as the first evidence appears."""
        if not self.conflict_evaluator.has_rules():
            return []

        with self._mode_scope(RunMode.CONFLICT_CHECK):
            # Conflict probing follows incremental selector progress in CONFLICT_CHECK mode.
            # We intentionally do not reset selector cursors here; when no new terms are
            # introduced, prior combinations are considered stable and need not be replayed.

            rule_count = self.rule_selector.mode_rules_count(RunMode.CONFLICT_CHECK)
            if rule_count == 0:
                return []

            for _ in range(rule_count):
                grounded_rules = self.grounder.grounding_process(question=self._empty_question)
                if not grounded_rules:
                    continue

                self.executor.execute(
                    grounded_rules=grounded_rules,
                    question=self._empty_question,
                )
                probe_facts = self.executor.get_conflict_probe_buffer()
                if probe_facts:
                    return probe_facts

        return []

    def _build_engine_result(self, *, question: Question, status: InferenceStatus, terminated_by: TerminatedBy) -> EngineRunResult:
        facts = self.fact_base.get_facts()
        observe_counts(facts_count=len(facts))
        logger.result("Total facts when terminal: %s", len(facts))  # type: ignore[attr-defined]

        include_final_facts = self.args.run.include_final_facts
        final_facts = facts if include_final_facts else []
        return EngineRunResult(
            status=status,
            solution_count=self.solution_manager.get_solution_count(),
            solutions=self.solution_manager.get_all_solutions(),
            final_facts=final_facts,
            fact_num=len(facts),
            include_final_facts=include_final_facts,
            question=question,
            iterations=self.main_loop_manager.iteration,
            execute_steps=self.executor.executor_manager.step_num,
            terminated_by=terminated_by,
            conflict_reason=self._conflict_reason,
        )

    def _infer(self, question: Question) -> EngineRunResult:
        """Run a full forward-chaining inference cycle."""
        module_name = __name__
        # Initial snapshot.
        observe_counts(facts_count=len(self.fact_base.get_facts()))
        logger.info("InferenceEngine: Starting full inference...")

        # Check whether the question can be answered before the loop starts.
        initial_status, initial_result = self._check_initial_status(question)
        if initial_status is not None and initial_result is not None:
            return initial_result

        status, terminated_by = self._run_inference_loop(question, module_name=module_name)
        status, terminated_by = self._apply_final_conflict_check(status, terminated_by)
        return self._build_engine_result(question=question, status=status, terminated_by=terminated_by)

    @measure("infer_query", module="inference")
    def infer_query(self, query: QueryStructure, *, resume: bool = False) -> EngineRunResult:  # TODO: Between runs,
        # EngineRunResult is still returned per call; last result can be treated as authoritative.
        """
        Public interface for the inference engine: accept QueryStructure and return results.
        :param resume: Set True to continue a previous run after injecting new facts externally.
            HACK: logs are split into two files, so timing stats will be inaccurate.
        :raise: ValueError: The first call must have resume=False.
            If resume=True is used before any inference run, ValueError is raised.
        """
        start_run(log_dir="metrics_logs")  # Start a new metrics record per outer call.
        scope_tokens = self._activate_runtime_scope()

        try:
            if not resume:
                self._reset()
            elif not self._has_previous_run:
                # Attempting resume without any prior run is invalid.
                raise ValueError(
                    "Invalid use of `resume=True` when"
                    "no previous inference run is available to continue from. "
                    "Please set resume=False when calling infer_query(...) first."
                )

            self._has_previous_run = True  # At least one inference run completed.

            premises = query.premises
            question = Question(premises=premises, question=query.question)
            # TODO: Consider internal-only Question and avoid storing premises to reduce duplication.

            if not resume:  # Redundant check, but keeps the flow clearer.
                self._initial_engine(question=question, premises=premises)
            else:
                self.fact_base.add_facts(premises, check_free_variables=True)
                self.main_loop_manager.initial_manager(normal_rules=None, resume=resume)
                # If continue_infer is added everywhere, this branch could be omitted.

            engine_result = self._infer(question=question)
            logger.result(engine_result.log_message())  # type: ignore[attr-defined]
            return engine_result
        finally:
            self._restore_runtime_scope(scope_tokens)
            end_run(extra_meta={
                "facts_final": len(self.fact_base.get_facts()),
                "rules_total": len(self.rule_base.rules),
            })

    def get_facts(self) -> list[FACT_TYPE]:
        """Return facts used (selected by initial_fact_base) and all derived facts."""
        return self.fact_base.get_facts()

    def get_infer_path(self, terminal_fact: FACT_TYPE | None = None) -> tuple[list[FactStep], FACT_TYPE | None]:
        """
        get the infer path message, the message will be returned in a tuple,
        the first element is the infer path message(a list of FactStep), the second element is the terminal fact.

        :param terminal_fact: the terminal fact
        :type terminal_fact: FACT_TYPE, question default
        :return: the infer path message(a list of FactStep), the terminal fact.
        :rtype: tuple[list[FactStep], FACT_TYPE | None]
        """
        return self.inference_path.get_infer_graph(terminal_fact)

    def generate_infer_path_graph(self, infer_path: list[FactStep]) -> None:
        """
        generate graph through infer path message
        important: you should use "get_infer_path" method to get the infer path message first

        :param infer_path: the infer path message(a list of FactStep)
        :type infer_path: list[FactStep]
        """
        self.inference_path.gennerate_infer_path_graph(infer_path)

    def _reset(self) -> None:
        self.fact_base.reset_fact_base()
        self.rule_base.reset_rule_base()

        self.equivalence.clear()
        self.grounder.reset()
        self.executor.reset()

        self.main_loop_manager.reset()
        self.solution_manager.reset()
        self.inference_path.reset()
        self.conflict_evaluator.reset()
        self._conflict_reason = None

        # Reset resume flag.
        self._has_previous_run = False

    def _initial_engine(self, question: Question, premises: Sequence[Assertion]) -> None:
        self.fact_base.initial_fact_base(question=question, topn=self.args.strategy.select_facts_num)
        self.fact_base.add_facts(facts=premises, force_add=True, check_free_variables=True)

        self.rule_base.initial_rule_base(question=question, topn=self.args.strategy.select_rules_num)

        self._initialize_term_selector()

        question_rules = self.rule_base.get_question_rules()
        normal_rules = [r for r in self.rule_base.get_rules() if not isinstance(r, _QuestionRule)]

        self.rule_selector.set_rules(
            normal_rules=normal_rules,
            question_rules=question_rules,
            conflict_rules=self.conflict_evaluator.compiled_conflict_rules,
        )  # HACK: Not linked to fact base.
        if not normal_rules:
            self.rule_selector.set_at_fixpoint(at_fixpoint=True)

        self.main_loop_manager.initial_manager(normal_rules=normal_rules)

        if self.args.run.trace:
            for f in self.fact_base.get_facts():
                self.inference_path.add_infer_edge(consequent=f)  # FIXME: Keep change small for this PR; later use list
                # types and revert to Assertion, or at least include a CNF split.

    def _check_initial_status(self, question: Question) -> tuple[InferenceStatus | None, EngineRunResult | None]:
        """Check whether the question can be answered before the loop starts."""
        current_facts = self.fact_base.get_facts()

        if self._check_conflict_once():
            include_final_facts = self.args.run.include_final_facts
            final_facts = current_facts if include_final_facts else []
            result = EngineRunResult(
                status=InferenceStatus.CONFLICT_DETECTED,
                solution_count=0,
                solutions=[],
                final_facts=final_facts,
                fact_num=len(current_facts),
                include_final_facts=include_final_facts,
                question=question,
                iterations=self.main_loop_manager.iteration,
                execute_steps=self.executor.executor_manager.step_num,
                terminated_by="conflict",
                conflict_reason=self._conflict_reason,
            )
            return InferenceStatus.CONFLICT_DETECTED, result

        initial_status = self.main_loop_manager.check_status(current_facts, question)
        if initial_status.is_terminal_for_main_loop():
            logger.info("Initial check result: %s", initial_status.log_message())
            include_final_facts = self.args.run.include_final_facts
            final_facts = current_facts if include_final_facts else []
            result = EngineRunResult(
                status=initial_status,
                solution_count=1,
                solutions=[{}],  # The question already exists in facts; treat {} as a "true" solution for display.
                final_facts=final_facts,
                fact_num=len(current_facts),
                include_final_facts=include_final_facts,
                question=question,
                iterations=self.main_loop_manager.iteration,
                execute_steps=self.executor.executor_manager.step_num,
                terminated_by="initial_check",
                conflict_reason=None,
            )
            return initial_status, result
        return None, None

    def _handle_fixpoint(self, final_status: InferenceStatus, question: Question) -> None:
        """Handle actions when a FIXPOINT_REACHED status is detected."""
        if final_status == InferenceStatus.FIXPOINT_REACHED:
            self.rule_selector.set_at_fixpoint(at_fixpoint=True)
            with self._mode_scope(RunMode.INFERENCE):
                grounded_rules = self.grounder.grounding_process(question=question)
                if grounded_rules:
                    self.executor.execute(
                        grounded_rules=grounded_rules,
                        question=question,
                    )

    def _initialize_term_selector(self) -> None:
        """Initialize term candidates from facts and rule/question ground terms."""
        self.term_selector.update_terms(facts=self.fact_base.get_facts())

        rules = self.rule_base.get_rules()
        # FIXME: By not proactively adding non-variable terms from the question to the term selector,
        #  we can reduce the risk of generating non-trivial solutions. Although non-trivial solutions are technically correct,
        #  they may not align with users' intuitive expectations. Completely disallowing them would also be incorrect -
        #  this is something that needs further consideration.
        self.term_selector.update_terms_from_rules(rules)


if __name__ == '__main__':
    logger.info("Inference Engine Started")
