#
#   Imandra Inc.
#
#   speclogician/logic/api/client.py
#



import os
import tempfile

from dataclasses import dataclass
from typing import Sequence

from imandrax_api_models import InstanceRes, EvalRes
from imandrax_api_models.client import ImandraXAsyncClient, get_imandrax_async_client
from iml_query.processing.decomp import DecompReqArgs

from speclogician.utils import IMLValidity
from speclogician.modeling.scenario import Scenario
from speclogician.modeling.complement import ScenarioComplement
from speclogician.modeling.conflict import ScenarioConsumed
from speclogician.modeling.domain import DomainModel
from speclogician.data.traces import (
    TraceArtifact, 
    TraceIMLValidity, 
    TraceScenarioMatchResult
)

from ..strings import (
    mk_predicates_only_instance_src,
    mk_scenario_expr,
    collect_pred_names,
    mk_goal_def,
    mk_spec_disjunction_expr,
    mk_instance_src_for_expr,
    mk_apply_then_expr,
    mk_conj,
    mk_apply_then_let_block
)

import structlog
structlog.configure(logger_factory=structlog.ReturnLoggerFactory()) # will drop all events


try: 
    from dotenv import load_dotenv, find_dotenv
    # Will look for .env file in parent dirs
    load_dotenv(find_dotenv(usecwd=True), override=False)
except ImportError:
    pass

@dataclass(frozen=True)
class ScenarioReachability:
    scenario_from: str
    scenario_to: str
    is_reachable: bool
    model_src: str | None
    inst_src: str


@dataclass(frozen=True)
class ScenarioConsistency:
    is_consistent : bool
    model_src: str | None
    inst_src: str

@dataclass(frozen=True)
class ScenarioIntersection:
    scenario1_name : str
    scenario2_name : str
    do_they_intersect : bool
    model_src: str | None

@dataclass(frozen=True)
class TraceValueValidation:
    ok: bool
    errors: Sequence[str]
    compiled_src: str

@dataclass(frozen=True)
class TraceScenarioMatch:
    scenario_name: str
    matches: bool
    witness_src: str | None
    reason: str | None = None

class SpecLogicianImandraX:
    def __init__(self) -> None:
        self._client_cm = None
        self._client: ImandraXAsyncClient | None = None

    async def __aenter__(self) -> "SpecLogicianImandraX":
        self._client_cm = get_imandrax_async_client(os.getenv('IMANDRA_UNI_KEY'))
        self._client = await self._client_cm.__aenter__()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        assert self._client_cm is not None
        await self._client_cm.__aexit__(exc_type, exc, tb)
        self._client = None
        self._client_cm = None

    @property
    def c(self) -> ImandraXAsyncClient:
        if self._client is None:
            raise RuntimeError("ImandraX client not initialized")
        return self._client

    async def load_model(self, iml_src: str) -> EvalRes:
        return await self.c.eval_model(src=iml_src)

    async def check_scenario_consistent(
        self,
        domain_model: DomainModel,
        scenario: Scenario,
        check_state_preds : bool,
        check_action_preds : bool
    ) -> ScenarioConsistency:
        
        if not check_state_preds and not check_action_preds:
            raise ValueError("At least `check_state_preds` or `check_action_preds` must be True!")

        state_preds = scenario.given if check_state_preds else ()
        action_preds = scenario.when if check_action_preds else ()

        inst_name = f"__speclogician_{scenario.name}"

        # Fast local validation: better UX than solver/type errors
        domain_model.assert_has_predicates(given=state_preds, when=action_preds)

        # Build a minimized IML model containing only what this scenario needs
        d_model = domain_model.make_iml_model(
            state_pred_names=list(state_preds),
            action_pred_names=list(action_preds),
            transition_names=[],
        )

        eval_res = await self.c.eval_model(src=d_model)

        if bool(getattr(eval_res, "has_errors", False)):
            errs = getattr(eval_res, "errors", None)
            if errs:
                raise ValueError(f"Domain model has errors (e.g. {errs[0]})")
            raise ValueError("Domain model has errors")

        # Predicates-only instance query in that model context
        inst_src = mk_predicates_only_instance_src(
            name=inst_name,
            given=state_preds,
            when=action_preds,
        )

        res: InstanceRes = await self.c.instance_src(src=inst_src)

        return ScenarioConsistency(
            is_consistent=res.sat is not None,
            model_src=res.sat.model.src if res.sat is not None and res.sat.model is not None else None,
            inst_src=inst_src
        )

    async def check_scenarios_intersect(
        self,
        domain_model: DomainModel,
        sc_a: Scenario,
        sc_b: Scenario,
    ) -> ScenarioIntersection:
        """
        Check whether two scenarios intersect (predicate-only semantics).

        Intersection exists iff there is a single (state, action) satisfying
        both scenarios' given/when predicates.
        """

        inst_name = f"__speclogician_intersect_{sc_a.name}_{sc_b.name}"

        # 1) Fast local validation
        domain_model.assert_has_predicates(given=sc_a.given, when=sc_a.when)
        domain_model.assert_has_predicates(given=sc_b.given, when=sc_b.when)

        # 2) Combine predicates
        given = list(set(list(sc_a.given) + list(sc_b.given)))
        when  = list(set(list(sc_a.when)  + list(sc_b.when)))

        # 3) Build minimized IML model
        d_model = domain_model.make_iml_model(
            state_pred_names=given,
            action_pred_names=when,
            transition_names=[],
        )

        eval_res = await self.c.eval_model(src=d_model)
        if bool(getattr(eval_res, "has_errors", False)):
            errs = getattr(eval_res, "errors", None)
            if errs:
                raise ValueError(f"Domain model has errors (e.g. {errs[0]})")
            raise ValueError("Domain model has errors")

        # 4) Predicates-only instance
        inst_src = mk_predicates_only_instance_src(
            name=inst_name,
            given=given,
            when=when,
        )

        res: InstanceRes = await self.c.instance_src(src=inst_src)

        return ScenarioIntersection (
            scenario1_name = sc_a.name,
            scenario2_name = sc_b.name,
            do_they_intersect = res.sat is not None,
            model_src = res.sat.model.src if res.sat is not None and res.sat.model is not None else None
        )
    
    async def decompose_goal_function(
        self,
        *,
        domain_model: DomainModel,
        goal_name: str,
        goal_def_src: str,
        state_pred_names: list[str],
        action_pred_names: list[str],
        assuming: str | None = None,
        rule_specs: list[str] | None = None,
        ctx_simp: bool | None = None,
        lift_bool: str | None = None,
        filter_out_false : bool = True
    ) -> ScenarioComplement:
        
        base_src = domain_model.make_iml_model(
            state_pred_names=state_pred_names,
            action_pred_names=action_pred_names,
            transition_names=[],
        )
        full_src = base_src + "\n\n" + goal_def_src

        eval_res = await self.c.eval_model(src=full_src)

        #print ("THE FULL MODEL is ")
        #print (full_src)
        #print ("THE FULL MODEL is ")

        if bool(getattr(eval_res, "has_errors", False)):
            errs = getattr(eval_res, "errors", None)
            if errs:
                raise ValueError(f"Domain model has errors (e.g. {errs[0]})")
            raise ValueError("Domain model has errors")

        req: DecompReqArgs = {
            "name": goal_name,
            "assuming": assuming,
            "rule_specs": rule_specs,
            "prune": True,
            "ctx_simp": ctx_simp,
            "lift_bool": lift_bool,
        }

        res = await self.c.decompose(**req)

        if res.regions_str:
            regions_str = list(filter (lambda x: x.model_eval_str != ['true', 'false'][int(filter_out_false)], res.regions_str))
        else:
            regions_str = []

        return ScenarioComplement(regions=regions_str)
    

    async def decompose_intersection (
        self,
        *,
        domain_model: DomainModel,
        sc1: Scenario,
        sc2: Scenario,
        ctx_simp: bool | None = None,
        lift_bool: str | None = None,
    ) -> ScenarioComplement:
        domain_model.assert_has_predicates(given=sc1.given, when=sc1.when)
        domain_model.assert_has_predicates(given=sc2.given, when=sc2.when)

        state_names, action_names = collect_pred_names([sc1, sc2])
        needs_action = bool(action_names)

        goal_name = f"__decomp_intersect_{sc1.name}_{sc2.name}"
        goal_expr = f"({mk_scenario_expr(sc1)}) && ({mk_scenario_expr(sc2)})"
        goal_def = mk_goal_def(goal_name, goal_expr, needs_action=needs_action)

        res = await self.decompose_goal_function(
            domain_model=domain_model,
            goal_name=goal_name,
            goal_def_src=goal_def,
            state_pred_names=state_names,
            action_pred_names=action_names,
            ctx_simp=ctx_simp,
            lift_bool=lift_bool,
            filter_out_false=True
        )

        return res
    
    async def decompose_spec_complement (
        self,
        *,
        domain_model: DomainModel,
        scenarios: Sequence[Scenario],
        ctx_simp: bool | None = None,
        lift_bool: str | None = None,
    ) -> ScenarioComplement:
        for sc in scenarios:
            domain_model.assert_has_predicates(given=sc.given, when=sc.when)

        state_names, action_names = collect_pred_names(scenarios)
        needs_action = bool(action_names)

        goal_name = "__decomp_spec_complement"
        spec_expr = mk_spec_disjunction_expr(scenarios)
        goal_expr = f"not ({spec_expr})"
        goal_def = mk_goal_def(goal_name, goal_expr, needs_action=needs_action)

        #print ("decomp <><>><>")
        #print (domain_model)
        #print ("<><><>")
        #print ("goal name: " + goal_name)
        #print ("decomp <><>><>")

        return await self.decompose_goal_function(
            domain_model=domain_model,
            goal_name=goal_name,
            goal_def_src=goal_def,
            state_pred_names=state_names,
            action_pred_names=action_names,
            ctx_simp=True,
            lift_bool=lift_bool,
            filter_out_false=True
        )
    
    # ----------------------------
    # Trace helpers (multi-step safe)
    # ----------------------------

    @staticmethod
    def _norm_iml_literal(x: object | None) -> str:
        """
        Normalize a trace literal into a string.
        None => "" (missing)
        str  => stripped
        other => str(...).strip()
        """
        if x is None:
            return ""
        if isinstance(x, str):
            return x.strip()
        return str(x).strip()

    @classmethod
    def _trace_first_state(cls, tr: TraceArtifact) -> str:
        # Prefer steps[0].state, fallback to legacy given
        if getattr(tr, "steps", None):
            if tr.steps:
                s0 = cls._norm_iml_literal(tr.steps[0].state)
                if s0:
                    return s0
        return cls._norm_iml_literal(getattr(tr, "given", None))

    @classmethod
    def _trace_first_action(cls, tr: TraceArtifact) -> str:
        # Prefer steps[0].action, fallback to legacy when
        if getattr(tr, "steps", None):
            if tr.steps:
                a0 = cls._norm_iml_literal(tr.steps[0].action)
                if a0:
                    return a0
        return cls._norm_iml_literal(getattr(tr, "when", None))

    @classmethod
    def _trace_last_state(cls, tr: TraceArtifact) -> str:
        """
        Return a post-state snapshot suitable for binding as `s_then`.

        Rules:
        1) If legacy `then` is present, use it.
        2) Otherwise, use the last step that looks like a state snapshot:
            - has state
            - has no action (None/blank)
            - is not step 0
        3) Otherwise, return "" (meaning: no post-state provided).
        """
        # 1) Legacy then wins (explicit post-state)
        then = cls._norm_iml_literal(getattr(tr, "then", None))
        if then:
            return then

        steps = list(getattr(tr, "steps", []) or [])
        if not steps:
            return ""

        # 2) Look for a final snapshot step (actionless) beyond the first step
        for i in range(len(steps) - 1, -1, -1):
            st = steps[i]
            s = cls._norm_iml_literal(getattr(st, "state", None))
            a = cls._norm_iml_literal(getattr(st, "action", None))
            if s and (not a) and i > 0:
                return s

        return ""

    async def check_trace_iml_validity(self, tr: TraceArtifact, model: DomainModel) -> TraceIMLValidity:
        errs: list[str] = []

        base_src = model.make_iml_model([], [], [])

        # Multi-step semantics:
        # - steps may have state-only snapshots (action blank/None)
        # - steps may have action-only events (state blank/None) BUT
        #     *if an action is present, we require a state in that step*
        #   (because an action needs a pre-state to typecheck meaningfully)
        steps = list(getattr(tr, "steps", []) or [])

        # If no steps somehow, fall back to legacy (still safe)
        if not steps:
            given = self._norm_iml_literal(getattr(tr, "given", None))
            when = self._norm_iml_literal(getattr(tr, "when", None))
            then = self._norm_iml_literal(getattr(tr, "then", None))

            bindings: list[str] = []
            if not given:
                errs.append("Trace missing `given` state value")
            else:
                bindings.append(f"let s : state = {given}")

            if when:
                bindings.append(f"let a : action = {when}")

            if then:
                bindings.append(f"let s_then : state = {then}")

            if errs:
                return TraceIMLValidity(iml_valid=IMLValidity.INVALID, err="\n".join(errs))

            full_src = base_src + "\n\n" + "\n".join(bindings)

            try:
                eval_res = await self.c.eval_model(src=full_src)
            except Exception as e:
                return TraceIMLValidity(iml_valid=IMLValidity.UNKNOWN, err=str(e))

            if bool(getattr(eval_res, "has_errors", False)):
                es = getattr(eval_res, "errors", None) or []
                msg = "\n".join(str(e) for e in es) if es else "ImandraX eval_model: has_errors=True"
                return TraceIMLValidity(iml_valid=IMLValidity.INVALID, err=msg)

            return TraceIMLValidity(iml_valid=IMLValidity.VALID, err=None)

        # Validate presence constraints + build bindings for typechecking
        bindings: list[str] = []

        for i, st in enumerate(steps):
            s = self._norm_iml_literal(st.state)
            a = self._norm_iml_literal(st.action)

            if i == 0 and not s:
                # Preserve legacy terminology: step0.state corresponds to `given`
                errs.append("Trace missing `given` state value")

            if a and not s:
                if i == 0:
                    # Also preserve legacy terminology for the first step
                    errs.append("Trace has `when` action but missing/blank `given` state value")
                else:
                    errs.append(f"Trace step {i} has `action` but missing/blank `state`")

            if s:
                bindings.append(f"let s{i} : state = {s}")
            if a:
                bindings.append(f"let a{i} : action = {a}")

        if errs:
            return TraceIMLValidity(iml_valid=IMLValidity.INVALID, err="\n".join(errs))

        full_src = base_src + "\n\n" + "\n".join(bindings)

        try:
            eval_res = await self.c.eval_model(src=full_src)
        except Exception as e:
            return TraceIMLValidity(iml_valid=IMLValidity.UNKNOWN, err=str(e))

        if bool(getattr(eval_res, "has_errors", False)):
            es = getattr(eval_res, "errors", None) or []
            msg = "\n".join(str(e) for e in es) if es else "ImandraX eval_model: has_errors=True"
            return TraceIMLValidity(iml_valid=IMLValidity.INVALID, err=msg)

        return TraceIMLValidity(iml_valid=IMLValidity.VALID, err=None)

    async def check_trace_match(self, tr: TraceArtifact, s: Scenario, model: DomainModel) -> TraceScenarioMatchResult:
        errs: list[str] = []

        # 0) Typecheck everything first (fast fail)
        v = await self.check_trace_iml_validity(tr, model)
        if v.iml_valid != IMLValidity.VALID:
            return TraceScenarioMatchResult(
                scenario_name=s.name,
                iml_valid=False,
                err=v.err,
                given_matches=False,
                when_matches=False,
                transition_matches=False,
            )

        # 1) Build model with scenario deps
        dm = model
        dm.assert_has_predicates(given=s.given, when=s.when)
        dm.assert_has_transitions(then=s.then)

        base_src = dm.make_iml_model(
            state_pred_names=list(s.given),
            action_pred_names=list(s.when),
            transition_names=list(s.then),
        )

        # Bind from steps (preferred) with legacy fallback
        s0 = self._trace_first_state(tr)
        a0 = self._trace_first_action(tr)
        s_last = self._trace_last_state(tr)

        if not s0:
            return TraceScenarioMatchResult(
                scenario_name=s.name,
                iml_valid=False,
                err="Trace missing initial state (cannot bind `s`)",
                given_matches=False,
                when_matches=False,
                transition_matches=False,
            )

        bindings: list[str] = [f"let s : state = {s0}"]
        if a0:
            bindings.append(f"let a : action = {a0}")
        if s_last:
            bindings.append(f"let s_then : state = {s_last}")

        ctx = base_src + "\n\n" + "\n".join(bindings)

        async def eval_bool(expr: str) -> tuple[bool, str | None]:
            import imandrax_api
            from imandrax_api.lib import read_artifact_zip

            imandrax_api_key = os.getenv("IMANDRA_UNI_KEY")
            url = imandrax_api.url_prod

            async with imandrax_api.AsyncClient(url=url, auth_token=imandrax_api_key, timeout=300) as ac:
                src = ctx + "\n\n" + f"eval({expr})"
                res = await ac.eval_src(src=src)

                art_zip = await ac.get_artifact_zip(task=res.tasks[0], kind="eval_res")
                with tempfile.NamedTemporaryFile(suffix=".zip", delete=True) as f:
                    f.write(art_zip.art_zip)
                    f.flush()
                    ev = read_artifact_zip(f.name)

                vstr = str(ev.res.v.v)
                if vstr == "Eval_Value_view_V_true()":
                    return True, None
                if vstr == "Eval_Value_view_V_false()":
                    return False, None
                return False, f"Result:{vstr}"

        # 2) given_matches
        if list(s.given):
            given_expr = " && ".join(f"({p} s)" for p in s.given)
            given_ok, given_err = await eval_bool(given_expr)
            if given_err:
                errs.append(given_err)
        else:
            given_ok = True

        # 3) when_matches
        if list(s.when):
            if not a0:
                when_ok = False
                errs.append("Scenario has action predicates but trace has no `when` action value")
            else:
                when_expr = " && ".join(f"({p} s a)" for p in s.when)
                when_ok, when_err = await eval_bool(when_expr)
                if when_err:
                    errs.append(when_err)
        else:
            when_ok = True

        # 4) transition_matches
        if not list(s.then):
            trans_ok = True
        else:
            if not a0:
                trans_ok = False
            else:
                trans_names = list(s.then)

                # Sequential apply: t1 (t2 (... s a) a) a
                if len(trans_names) == 1:
                    final_state_expr = f"({trans_names[0]} s a)"
                else:
                    lets: list[str] = []
                    prev = "s"
                    for i, tname in enumerate(trans_names, 1):
                        si = f"s{i}"
                        lets.append(f"let {si} = ({tname} {prev} a) in")
                        prev = si
                    final_state_expr = " ".join(lets) + f" {prev}"

                if not s_last:
                    # No final snapshot: just ensure it typechecks/evals
                    trans_ok, trans_err = await eval_bool(f"let _ = {final_state_expr} in true")
                else:
                    trans_ok, trans_err = await eval_bool(f"({final_state_expr} = s_then)")

                if trans_err:
                    errs.append(trans_err)

        ok_err = "\n".join(errs) if errs else None
        return TraceScenarioMatchResult(
            scenario_name=s.name,
            iml_valid=True,
            err=ok_err,
            when_matches=when_ok,
            given_matches=given_ok,
            transition_matches=trans_ok,
        )

    async def check_scenario_implies(
        self,
        domain_model: DomainModel,
        *,
        sc_a: Scenario,  # A (more general)
        sc_b: Scenario,  # B (more specific)
    ) -> ScenarioConsumed | None:
        """
        Return ScenarioConsumed(A,B) iff B ⇒ A (i.e. scenario B is a subset of scenario A).
        Otherwise return None.

        We check satisfiability of:  (B && not A)
        - UNSAT (no witness) => B ⇒ A   => consumed(A,B)
        - SAT (witness)      => not implied => None

        IMPORTANT: ImandraX usage here is:
        1) eval_model(src=<full model that *defines* the named query>)
        2) instance_src(src="<query_name>")  # NOT source; just the name
        """

        # 1) Fast local validation
        domain_model.assert_has_predicates(given=sc_a.given, when=sc_a.when)
        domain_model.assert_has_predicates(given=sc_b.given, when=sc_b.when)

        # 2) Build minimal model with union of predicates used by either scenario
        given = sorted(set(list(sc_a.given) + list(sc_b.given)))
        when = sorted(set(list(sc_a.when) + list(sc_b.when)))

        d_model = domain_model.make_iml_model(
            state_pred_names=given,
            action_pred_names=when,
            transition_names=[],
        )

        # 3) Build counterexample expression: B && not(A)
        a_expr = mk_scenario_expr(sc_a)
        b_expr = mk_scenario_expr(sc_b)
        ce_expr = f"({b_expr}) && (not ({a_expr}))"
        needs_action = bool(when)

        # 4) Define a NAMED boolean query inside the model context
        # Ensure name is a valid identifier (avoid '-' etc.)
        qname = f"__speclogician_implies_{sc_a.name}_{sc_b.name}".replace("-", "_")

        # This helper must emit a *function definition*, not an `instance { ... }` block.
        # Example shapes:
        #   let q (s: state) : bool = <expr>
        #   let q (s: state) (a: action) : bool = <expr>

        def mk_bool_query_def(*, name: str, expr: str, needs_action: bool) -> str:
            if needs_action:
                return f"""
        let {name} (s: state) (a: action) : bool =
        ({expr})
        """
            return f"""
        let {name} (s: state) : bool =
        ({expr})
        """

        query_def = mk_bool_query_def(
            name=qname,
            expr=ce_expr,
            needs_action=needs_action,
        )

        full_src = d_model + "\n\n" + query_def

        # 5) eval_model the full program (model + query)
        eval_res = await self.c.eval_model(src=full_src)
        if bool(getattr(eval_res, "has_errors", False)):
            errs = getattr(eval_res, "errors", None)
            if errs:
                raise ValueError(f"Domain model has errors (e.g. {errs[0]})")
            raise ValueError("Domain model has errors")

        # 6) instance_src just by NAME
        res: InstanceRes = await self.c.instance_src(src=qname)

        # SAT => counterexample exists => not consumed
        if res.sat is not None:
            return None

        # UNSAT => implication holds => consumed
        details = (
            "Scenario is consumed: every state/action satisfying scenario2 also satisfies scenario1.\n"
            "Checked UNSAT of: scenario2 && not(scenario1)."
        )
        return ScenarioConsumed(
            scenario_name1=sc_a.name,  # A (general)
            scenario_name2=sc_b.name,  # B (specific)
            details=details,
        )

    async def check_scenarios_reachable(
        self,
        *,
        domain_model: DomainModel,
        sc_from: Scenario,
        sc_to: Scenario,
        require_to_when: bool = False,
    ) -> ScenarioReachability:
        """
        1-step reachability (same-action variant by default):

        ∃ s a.
            FromGiven(s) ∧ FromWhen(s,a) ∧
            ToGiven(post) ∧ (optional) ToWhen(post,a)
        where post = apply(sc_from.then, s, a).

        If require_to_when=True, we also require ToWhen(post,a).
        """

        dm = domain_model

        # ---- local validation ----
        dm.assert_has_predicates(given=sc_from.given, when=sc_from.when)
        dm.assert_has_predicates(given=sc_to.given, when=sc_to.when)
        dm.assert_has_transitions(then=sc_from.then)

        # ---- build minimal model deps ----
        state_pred_names = sorted(set(list(sc_from.given) + list(sc_to.given)))
        action_pred_names = sorted(set(list(sc_from.when) + (list(sc_to.when) if require_to_when else [])))
        transition_names = list(sc_from.then)

        base_src = dm.make_iml_model(
            state_pred_names=state_pred_names,
            action_pred_names=action_pred_names,
            transition_names=transition_names,
        )

        # ---- build boolean goal q(s,a) ----

        # Conjunction helpers must be total: empty list => "true"
        from_given = mk_conj([f"({p} s)" for p in sc_from.given]) or "true"
        from_when  = mk_conj([f"({p} s a)" for p in sc_from.when]) or "true"

        # post-state is bound via a let-block (robust for 1+ transitions)
        # We reference `post` in the target predicates.
        to_given = mk_conj([f"({p} post)" for p in sc_to.given]) or "true"

        goal_expr = f"{from_given} && {from_when} && {to_given}"

        qname = f"__speclogician_reach_{sc_from.name}_{sc_to.name}".replace("-", "_")

        # Build:
        #   let post : state = <apply sc_from.then> in <goal_expr>
        post_let = mk_apply_then_let_block(
            list(sc_from.then),
            s_var="s",
            a_var="a",
            post_var="post",
        )

        qdef = (
            f"let {qname} (s: state) (a: action) : bool =\n"
            f"  {post_let} in\n"
            f"  ({goal_expr})\n"
        )

        full_src = base_src + "\n\n" + qdef

        # print ("<><><><><> DECODE <><><><><>")
        # print (full_src)
        # print ("<><><><><> DECODE <><><><><>")

        eval_res = await self.c.eval_model(src=full_src)
        if bool(getattr(eval_res, "has_errors", False)):
            errs = getattr(eval_res, "errors", None)
            if errs:
                raise ValueError(f"Domain model has errors (e.g. {errs[0]})")
            raise ValueError("Domain model has errors")

        # Instance by NAME (like your implies check)
        res: InstanceRes = await self.c.instance_src(src=qname)

        return ScenarioReachability(
            scenario_from=sc_from.name,
            scenario_to=sc_to.name,
            is_reachable=res.sat is not None,
            model_src=res.sat.model.src if res.sat is not None and res.sat.model is not None else None,
            inst_src=qname,
        )