"""
Pure flow-engine state machine.

Knows nothing about Django, Picky Assist, or HTTP. Given a flow definition,
the user's current state, and an inbound message, it returns the new state
and a list of outbound Action objects.

This is the heart of the spike: one ~150-line module replaces all the
hardcoded if/elif branching in mexico/views.py.
"""
from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from typing import Any


@dataclass
class FlowState:
    current_node_id: str | None = None
    context: dict = field(default_factory=dict)

    def to_json(self) -> str:
        return json.dumps({"current_node_id": self.current_node_id, "context": self.context})

    @classmethod
    def from_json(cls, current_node_id: str | None, context_json: str) -> "FlowState":
        return cls(current_node_id=current_node_id, context=json.loads(context_json or "{}"))


@dataclass
class Action:
    type: str
    payload: dict


_INNER_RE = re.compile(r"\{\{\s*([^{}]+?)\s*\}\}")


def _option_visible(opt: dict, context: dict) -> bool:
    """
    Decide whether an ask_list/ask_buttons option should be shown to the user.

    Currently supports:
      - hide_value_in:  "<context_key>" — option's `value` (defaults to
                        choice_id) must NOT appear in that context list.
                        Used to hide already-issued cert types from the
                        Generar picker.
      - hide_when:      "<template>" — if it interpolates to a truthy
                        string ("true", "1", "yes"), the option is hidden.

    Both are optional. Missing → option is visible.
    """
    hvi = opt.get("hide_value_in")
    if hvi:
        target = _walk_path(hvi, context)
        if isinstance(target, list):
            probe = opt.get("value")
            if probe is None:
                probe = opt.get("choice_id")
            if probe in target:
                return False

    hw = opt.get("hide_when")
    if hw:
        rendered = str(interpolate(hw, context)).strip().lower()
        if rendered in ("true", "1", "yes", "y"):
            return False

    # show_when: option appears only if this template resolves to a non-empty,
    # non-falsy value. Inverse of hide_when. Used to render one option per
    # slot in a fixed-size picker when the underlying data may be shorter
    # (e.g. one row per cert when the user has <5 certs).
    sw = opt.get("show_when")
    if sw is not None:
        rendered = str(interpolate(sw, context)).strip()
        if not rendered or rendered.lower() in ("false", "0", "no", "n", "none", "null"):
            return False

    return True


def _extract_with_wildcards(data: Any, path: str) -> list:
    """
    Walk a dot-path that may contain `*` segments to iterate lists, returning
    a flat order-preserving deduplicated list of all matching leaf values.

    Examples:
      data = {"items": [{"v": "a"}, {"v": "b"}, {"v": "a"}]}
      path = "items.*.v"   →   ["a", "b"]
    """
    if not path:
        return []
    queue: list = [data]
    for raw in path.split("."):
        part = raw.strip()
        nxt: list = []
        for cur in queue:
            if part == "*":
                if isinstance(cur, list):
                    nxt.extend(x for x in cur if x is not None)
                elif isinstance(cur, dict):
                    nxt.extend(v for v in cur.values() if v is not None)
            elif isinstance(cur, dict):
                v = cur.get(part)
                if v is not None:
                    nxt.append(v)
            elif isinstance(cur, list):
                if part == "__len__":
                    nxt.append(len(cur))
                else:
                    try:
                        v = cur[int(part)]
                        if v is not None:
                            nxt.append(v)
                    except (ValueError, IndexError, TypeError):
                        pass
        queue = nxt
    # Dedupe order-preserving; skip unhashable items AND empty values
    # (None, "", []). Empty strings happen all the time when a key is
    # missing from a dict — filtering them lets callers do "first
    # non-empty across array entries" with just `result[0]`.
    seen: set = set()
    out: list = []
    for v in queue:
        if v is None or v == "" or v == []:
            continue
        try:
            if v in seen:
                continue
            seen.add(v)
        except TypeError:
            pass
        out.append(v)
    return out


def _walk_path(path_str: str, context: dict) -> Any:
    cur: Any = context
    for part in path_str.split("."):
        part = part.strip()
        if isinstance(cur, dict):
            cur = cur.get(part, "")
        elif isinstance(cur, list):
            if part == "__len__":
                return len(cur)
            try:
                cur = cur[int(part)]
            except (ValueError, IndexError, TypeError):
                return ""
        elif part == "__len__":
            return len(cur) if cur is not None else 0
        else:
            return ""
    return cur if cur is not None else ""


def interpolate(template: str, context: dict) -> str:
    """
    Forgiving template substitution that ALWAYS returns a string.

    Supports:
      {{name}}                  → context['name']
      {{a.b.c}}                 → nested dict path
      {{a.0.b}}                 → list indexing (numeric path segment)
      {{a.{{idx}}.b}}           → recursive — inner {{idx}} resolves first
      {{a.b.__len__}}           → length of list/dict (handy for branches)

    Missing keys / out-of-bounds indices render as '' (no crashes).

    For payload values where you need the raw dict/list/number to pass through
    typed (i.e. avoid stringifying a nested object), use interpolate_value().
    """
    if not template:
        return template

    cur = template
    for _ in range(8):                          # generous safety limit
        nxt = _INNER_RE.sub(lambda m: str(_walk_path(m.group(1), context)), cur)
        if nxt == cur:
            break
        cur = nxt
    return cur


def interpolate_value(value, context: dict):
    """
    Like interpolate(), but type-preserving when the template is exactly one
    `{{path}}` token (with optional surrounding whitespace).

    Recurses through dicts and lists so a payload like
        "credentialSubject": {"Nombres": "{{curp_data.names}}"}
    has its INNER template values substituted, not just the top level.

    Used for API payload values so you can write
        "verifiableCredential": "{{vc_list.verifiable_credentials.0.verifiableCredential}}"
    and the dict gets serialized as a JSON object, not its repr() string.

    Other non-string scalars are returned as-is.
    """
    if isinstance(value, dict):
        return {k: interpolate_value(v, context) for k, v in value.items()}
    if isinstance(value, list):
        return [interpolate_value(item, context) for item in value]
    if not isinstance(value, str):
        return value
    stripped = value.strip()
    if not stripped:
        return value

    # Multi-pass. Each iteration: (a) if the entire string is now exactly
    # `{{path}}` with no nested braces, return the raw walked value with its
    # type preserved. Otherwise (b) substitute one pass of innermost {{...}}
    # tokens and loop. Handles `{{a.{{idx}}.b}}` by resolving idx first, then
    # detecting the resulting pure-path on the next iteration.
    for _ in range(8):
        m = _INNER_RE.fullmatch(stripped)
        if m and "{{" not in m.group(1):
            return _walk_path(m.group(1).strip(), context)
        new_stripped = _INNER_RE.sub(
            lambda mm: str(_walk_path(mm.group(1), context)), stripped)
        if new_stripped == stripped:
            break
        stripped = new_stripped
    return stripped


def find_node(flow: dict, node_id: str) -> dict | None:
    for n in flow.get("nodes", []):
        if n["id"] == node_id:
            return n
    return None


def match_trigger(flow: dict, inbound: dict, allow_default: bool = True) -> str | None:
    """
    Find which trigger fires for this inbound message; return entry node id.

    `allow_default` lets callers opt out of the catch-all `default` trigger.
    Two callers exist and they want different semantics:

      - The webhook router probes every LIVE flow with the inbound to decide
        whether to reset state and start a fresh flow. It must NOT match on
        the default trigger — otherwise every message resets every flow's
        state. (allow_default=False)

      - The engine itself, when state.current_node_id is None, walks triggers
        to pick an entry node. Here the default trigger should fire if no
        keyword/interactive trigger matched. (allow_default=True, the default)
    """
    text = (inbound.get("message_in_raw") or "").strip().lower()
    interactive_id = (inbound.get("interactive") or {}).get("id") if isinstance(inbound.get("interactive"), dict) else None

    default_next = None
    for trig in flow.get("triggers", []):
        if trig.get("type") == "keyword":
            keywords = [k.lower() for k in trig.get("match", [])]
            if text in keywords:
                return trig["next"]
        elif trig.get("type") == "interactive_id":
            if interactive_id and interactive_id == trig.get("match"):
                return trig["next"]
        elif trig.get("type") == "default":
            # Remember it but don't return yet — a keyword/interactive trigger
            # later in the list should still get a chance to match.
            default_next = trig["next"]
    if allow_default:
        return default_next
    return None


def _resolve_setting_ref(value):
    """Resolve a `setting:KEY|default` sentinel at runtime by looking up the
    AppSetting row for KEY. Falls back to `default` (after the `|`) when the
    setting is missing or empty.

    Lets flow definitions reference per-environment values (e.g. Picky template
    IDs) without baking them in — editing the AppSetting via the web Settings
    page takes effect on the very next webhook call, no `install_full` rerun
    needed.

    Plain strings (no `setting:` prefix) and non-strings pass through unchanged.
    """
    if not isinstance(value, str) or not value.startswith("setting:"):
        return value
    spec = value[len("setting:"):]
    key, sep, default = spec.partition("|")
    try:
        from .models import AppSetting
        row = AppSetting.objects.filter(key=key.strip()).only("value").first()
        if row and row.value:
            return row.value
    except Exception:
        pass
    return default.strip()


def _input_matches_node(node: dict, inbound: dict) -> bool:
    """Decide whether an inbound message is a valid answer for the current
    ask_* node. Used so an out-of-band input (e.g. user types text at a
    Sí/No button prompt, or sends text at "share your location") doesn't
    silently strand them — the caller can emit a clarification + keep
    them on the same node.

    Validation rules:
      ask_buttons / ask_list  →  interactive_id must match one of the options
                                 (a "#" / "hola" / etc. is handled separately
                                 by the trigger reset flow upstream).
      ask_audio / ask_media   →  media_url must be present
      ask_image               →  media_url must be present
      ask_location            →  lat AND lng must be present (or parseable
                                 from the message body via the same regex
                                 capture_answer uses)
      ask_text                →  always valid (any text is captured as-is)
      everything else         →  always valid (no input expected)
    """
    ntype = node.get("type")
    interactive_id = (inbound.get("interactive") or {}).get("id") \
        if isinstance(inbound.get("interactive"), dict) else None
    raw_text = (inbound.get("message_in_raw") or "").strip()
    media_url = inbound.get("media_url") or inbound.get("media-url")
    lat = inbound.get("latitude")
    lng = inbound.get("longitude")

    if ntype in ("ask_buttons", "ask_list"):
        if not interactive_id:
            return False
        for o in node.get("options", []):
            if str(o.get("choice_id")) == str(interactive_id):
                return True
        return False

    if ntype in ("ask_audio", "ask_media"):
        return bool(media_url)

    if ntype == "ask_image":
        return bool(media_url)

    if ntype == "ask_location":
        if lat is not None and lng is not None:
            return True
        # Fallback regex (same one capture_answer uses) — text like
        # "Latitude:19.4 & Longitude:-99.6" still counts as valid.
        return bool(re.search(
            r'lat[a-z]*\s*[:=]\s*(-?\d+(?:\.\d+)?)\s*[&,;]?\s*l[oa]n[a-z]*\s*[:=]\s*(-?\d+(?:\.\d+)?)',
            raw_text, re.IGNORECASE))

    # ask_text and anything else: any input is fine.
    return True


def capture_answer(node: dict, inbound: dict, context: dict) -> tuple[dict, str | None]:
    """
    The user just answered a previous ask_* node. Stash their answer in context
    keyed by node.save_as (or node id), and return the next node id based on the
    node's choice mapping if any.
    """
    raw_text = inbound.get("message_in_raw") or ""
    interactive = inbound.get("interactive") or {}
    interactive_id = interactive.get("id") if isinstance(interactive, dict) else None
    lat = inbound.get("latitude")
    lng = inbound.get("longitude")
    media_url = inbound.get("media_url") or inbound.get("media-url")

    # Fallback parser: when the user sends a plain text message produced by our
    # /flow/loc/ capture page (or pastes "Latitude:NN & Longitude:NN" any way),
    # extract the coordinates so ask_location nodes still fire.
    if (lat is None or lng is None) and raw_text:
        m = re.search(
            r'lat[a-z]*\s*[:=]\s*(-?\d+(?:\.\d+)?)\s*[&,;]?\s*l[oa]n[a-z]*\s*[:=]\s*(-?\d+(?:\.\d+)?)',
            raw_text, re.IGNORECASE
        )
        if m:
            try:
                lat = float(m.group(1))
                lng = float(m.group(2))
            except ValueError:
                pass

    save_key = node.get("save_as") or node["id"]

    # Resolve the option that was tapped (if any) so we can also capture its
    # label AND any semantic `value` field. Flow authors can set Picky-safe
    # choice_ids (e.g. "c0") and a separate `value` field ("0") used for array
    # indexing in subsequent templates — works around Picky Assist treating
    # numeric "0" as empty.
    chosen_label = None
    chosen_value = None
    next_id = None
    if node["type"] in ("ask_buttons", "ask_list") and interactive_id:
        for opt in node.get("options", []):
            if str(opt.get("choice_id")) == str(interactive_id):
                chosen_label = opt.get("label")
                chosen_value = opt.get("value")
                next_id = opt.get("next")
                break
    if next_id is None:
        next_id = node.get("next")

    context.setdefault("answers", {})[save_key] = {
        "text": raw_text,
        "label": chosen_label,
        "choice_id": interactive_id,
        "lat": lat,
        "lng": lng,
    }

    if node["type"] in ("ask_audio", "ask_media") and media_url:
        context["media_url"] = media_url
        context["audio_url"] = media_url
        context[save_key] = media_url
    elif node["type"] == "ask_image" and media_url:
        context["media_url"] = media_url
        context["image_url"] = media_url
        context["qr_url"] = media_url
        context[save_key] = media_url
    elif node["type"] == "ask_location" and (lat or lng):
        # Expose under multiple aliases so users can reference whichever name
        # they prefer in {{...}} templates: lat/lon/lng/latitude/longitude.
        context["lat"] = lat
        context["lng"] = lng
        context["lon"] = lng
        context["latitude"] = lat
        context["longitude"] = lng
        context[save_key] = f"{lat},{lng}"
    elif node["type"] in ("ask_buttons", "ask_list"):
        # Primary value = the human-readable label the user actually picked
        # (e.g. "pH", "Azufre"). Choice id is exposed separately for branching.
        # `_value` exposes the optional semantic value (defaults to choice_id
        # when the option doesn't declare one) — handy for indexing into arrays
        # without exposing array indices to Picky Assist.
        context[save_key] = chosen_label or raw_text or interactive_id or ""
        context[f"{save_key}_id"] = interactive_id
        context[f"{save_key}_label"] = chosen_label
        context[f"{save_key}_value"] = chosen_value if chosen_value is not None else interactive_id
    else:
        context[save_key] = raw_text or interactive_id or ""

    return context, next_id


def _maybe_number(val):
    """If a string looks like a number after interpolation, convert it.
    Lets payload templates declare e.g. {"latitude": "{{lat}}"} and have it
    sent as a JSON number rather than a string — APIs are strict about this.

    IMPORTANT: do NOT coerce phone numbers (start with '+') — Python's int('+9...')
    succeeds but produces an integer, which breaks string-typed API fields like
    `identifier`/`phone`/`oldPhone`/`newPhone`."""
    if not isinstance(val, str):
        return val
    s = val.strip()
    if not s:
        return val
    if s.startswith("+"):
        return val
    try:
        if "." in s or "e" in s.lower():
            return float(s)
        return int(s)
    except ValueError:
        return val


def call_api_node(node: dict, context: dict) -> dict:
    """
    Synchronously call the configured API and write response back into context.
    Kept side-effectful but isolated here so the rest of the engine stays pure.
    """
    import requests as _requests

    url = interpolate(node["url"], context)
    method = node.get("method", "GET").upper()
    payload = node.get("payload") or {}
    # interpolate_value preserves dicts/lists/numbers when the template is
    # exactly `{{path.to.value}}` — so e.g. verifiableCredential arrives as
    # a JSON object, not the str() repr of a Python dict.
    rendered_payload = {
        k: _maybe_number(interpolate_value(v, context))
        for k, v in payload.items()
    }
    # Headers ALSO support {{var}} interpolation so flow authors can write
    # e.g. `Authorization: Identi {{cimmyt_token}}`.
    headers = {k: interpolate(str(v), context) for k, v in (node.get("headers") or {}).items()}

    # Log every outbound CIMMYT call (URL + payload summary, token redacted)
    # so we can grep stdout to see exactly what we sent if something goes
    # sideways. Cheap, structured, leaves no PII beyond what users already
    # gave us (phone, CURP, etc.).
    import logging as _logging, json as _json
    _logger = _logging.getLogger("flowbuilder.call_api")
    if any(d in url for d in ("identi.digital", "agrotutor.co", "e-agrology.org")):
        try:
            redacted_headers = {k: ("<REDACTED>" if "auth" in k.lower() else v)
                                for k, v in headers.items()}
            payload_preview = _json.dumps(rendered_payload, default=str)
            if len(payload_preview) > 800:
                payload_preview = payload_preview[:800] + "...<truncated>"
            user = context.get("number", "?")
            _logger.info("CALL %s %s user=%s headers=%s payload=%s",
                         method, url, user, redacted_headers, payload_preview)
        except Exception:
            pass

    try:
        if method == "GET":
            resp = _requests.get(url, params=rendered_payload, headers=headers, timeout=60)
        else:
            resp = _requests.request(method, url, json=rendered_payload, headers=headers, timeout=60)
        try:
            data = resp.json()
        except ValueError:
            data = {"raw": resp.text, "status_code": resp.status_code}

        # Log response for the same set of hosts
        if any(d in url for d in ("identi.digital", "agrotutor.co", "e-agrology.org")):
            try:
                _logger.info("RESP %s %s user=%s status=%s body=%s",
                             method, url, context.get("number", "?"),
                             resp.status_code, resp.text[:400])
            except Exception:
                pass
    except Exception as e:
        data = {"error": str(e)}
        _logger.warning("EXC %s %s user=%s err=%s", method, url,
                        context.get("number", "?"), str(e))

    save_key = node.get("save_as", "api_result")
    extract_path = node.get("extract")
    if extract_path:
        cur: Any = data
        for part in extract_path.split("."):
            if isinstance(cur, dict):
                cur = cur.get(part)
            else:
                cur = None
                break
        context[save_key] = cur
    else:
        context[save_key] = data

    # post_extract: derive secondary fields from the response. Each value is a
    # path that may contain `*` wildcards; the resulting flat list is stored
    # under the key. Use case: pulling `issued_types` out of /verifiable-
    # credentials so pickers can hide already-issued options.
    for derived_key, derived_path in (node.get("post_extract") or {}).items():
        context[derived_key] = _extract_with_wildcards(data, derived_path)
    return context


def step(flow: dict, state: FlowState, inbound: dict) -> tuple[FlowState, list[Action]]:
    """
    One turn of the flow.

    1. If state has no current node → match triggers, advance to entry.
    2. If state has a current node → it must be an ask_* node waiting on input.
       Capture the answer, then advance.
    3. Walk forward emitting actions. Stop when we hit an ask_* node (waits for
       user) or end (sets current_node_id back to None).
    """
    actions: list[Action] = []
    context = dict(state.context)
    if "name" not in context and inbound.get("name"):
        context["name"] = inbound["name"]
    if inbound.get("number"):
        context["number"] = inbound["number"]
        # Split E.164 phone into country code + national part. Used by the
        # Mexico-360 setCertificate notify (and any future API that expects
        # the two as separate fields). Heuristic: 2-digit country code —
        # covers MX 52, IN 91, BR 55, AR 54, ES 34, etc. Single-digit "1"
        # for US/CA would split wrong, but those aren't in CIMMYT's audience.
        _num = str(inbound["number"]).lstrip("+").strip()
        context["phone_country_code"] = _num[:2]
        context["phone_national"] = _num[2:]

    # Inject useful date helpers so flows can reference them without per-flow
    # logic. Refreshed each turn (no caching needed; cheap).
    from datetime import datetime, timezone, timedelta
    _now = datetime.now(timezone.utc)
    context["now_iso"] = _now.strftime("%Y-%m-%dT%H:%M:%S.%fZ")[:23] + "Z"
    context["now_plus_1y_iso"] = (_now + timedelta(days=365)).strftime(
        "%Y-%m-%dT%H:%M:%S.%fZ")[:23] + "Z"
    context["now_plus_100y_iso"] = (_now + timedelta(days=36500)).strftime(
        "%Y-%m-%dT%H:%M:%S.%fZ")[:23] + "Z"
    context["today"] = _now.strftime("%Y-%m-%d")
    # Bare numbers (not zero-padded) so payloads like {"month": "{{today_month}}"}
    # send "5" rather than "05" — APIs often differ on whether they accept
    # zero-padded month strings.
    context["today_month"] = str(_now.month)
    context["today_month_idx"] = str(_now.month - 1)   # 0-indexed for plot_map_stage API
    context["today_year"] = str(_now.year)
    # Forward-rolling month offsets used by the 6-month weather forecast flow.
    # `month_plus_N`        — calendar month number 1-12 (for human-readable use)
    # `month_plus_N_idx`    — calendar month number 0-11 (what plot_map_stage API
    #                          actually expects; sending 5 returns June, off-by-one)
    # `month_label_plus_N`  — Spanish month-and-year string for captions
    _SP_MONTHS = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
                  "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre",
                  "Diciembre"]
    for _i in range(6):
        _future_month = ((_now.month - 1 + _i) % 12) + 1
        _future_year = _now.year + ((_now.month - 1 + _i) // 12)
        context[f"month_plus_{_i}"] = str(_future_month)
        context[f"month_plus_{_i}_idx"] = str(_future_month - 1)
        context[f"month_label_plus_{_i}"] = f"{_SP_MONTHS[_future_month - 1]} {_future_year}"

    if state.current_node_id is None:
        next_id = match_trigger(flow, inbound)
        if next_id is None:
            return FlowState(current_node_id=None, context=context), []
    else:
        current = find_node(flow, state.current_node_id)
        if current is None:
            return FlowState(current_node_id=None, context=context), []
        # Reject obviously-mismatched input BEFORE capturing — e.g. user types
        # text at a Sí/No button prompt, or sends text at "share your location".
        # Instead of falling through to next=None (state cleared, user stuck on
        # next message via the default trigger), emit a brief clarification and
        # stay on the same node so they can retry.
        if not _input_matches_node(current, inbound):
            fallback_msg = current.get(
                "invalid_input_text",
                "🤔 No entendí tu respuesta. Por favor utiliza una de las "
                "opciones disponibles, o escribe *#* para regresar al menú "
                "principal.",
            )
            actions.append(Action("send_text", {
                "text": interpolate(fallback_msg, context),
            }))
            return FlowState(current_node_id=state.current_node_id,
                             context=context), actions
        context, next_id = capture_answer(current, inbound, context)

    safety = 0
    while next_id is not None and safety < 50:
        safety += 1
        node = find_node(flow, next_id)
        if node is None:
            break

        ntype = node["type"]
        if ntype == "send_text":
            actions.append(Action("send_text", {"text": interpolate(node["text"], context)}))
            next_id = node.get("next")
        elif ntype == "send_media":
            actions.append(Action("send_media", {
                "url": interpolate(node["url"], context),
                "caption": interpolate(node.get("caption", ""), context),
            }))
            next_id = node.get("next")
        elif ntype == "ask_buttons":
            visible = [o for o in node.get("options", []) if _option_visible(o, context)]
            if not visible:
                empty_text = node.get("empty_text")
                if empty_text:
                    actions.append(Action("send_text",
                        {"text": interpolate(empty_text, context)}))
                next_id = node.get("empty_next") or node.get("next")
                continue
            actions.append(Action("send_buttons", {
                "text": interpolate(node["text"], context),
                "options": [
                    {"label": interpolate(o["label"], context), "choice_id": o["choice_id"]}
                    for o in visible
                ],
            }))
            return FlowState(current_node_id=node["id"], context=context), actions
        elif ntype == "ask_list":
            visible = [o for o in node.get("options", []) if _option_visible(o, context)]
            if not visible:
                empty_text = node.get("empty_text")
                if empty_text:
                    actions.append(Action("send_text",
                        {"text": interpolate(empty_text, context)}))
                next_id = node.get("empty_next") or node.get("next")
                continue
            actions.append(Action("send_list", {
                "text": interpolate(node["text"], context),
                "list_title": interpolate(node.get("list_title", "Select"), context),
                "options": [
                    {"label": interpolate(o["label"], context), "choice_id": o["choice_id"],
                     "description": interpolate(o.get("description", ""), context)}
                    for o in visible
                ],
            }))
            return FlowState(current_node_id=node["id"], context=context), actions
        elif ntype == "ask_location":
            # If the node declares a `template_id`, emit Picky's approved
            # WhatsApp template (with the embedded URL button that bounces
            # the user back via wa.me with their coords). Falls back to a
            # plain text prompt otherwise. Either way the node pauses for
            # the user's location reply (capture_answer handles native GPS
            # share AND the "Latitude:X & Longitude:Y" text bounce-back).
            # `template_id` may be a raw Picky ID OR a `setting:KEY|default`
            # sentinel that the engine resolves from AppSettings at runtime.
            # Sentinel form lets the web Settings page swap template IDs live
            # without redeploying the flow.
            tmpl_id = _resolve_setting_ref(node.get("template_id"))
            if tmpl_id:
                # parameters fill the template's {{N}} URL placeholders;
                # default to a single parameter = user's phone number, which
                # is what most location templates expect ({{1}} = mob query).
                params = node.get("parameters") or ["{{number}}"]
                rendered_params = [interpolate(str(p), context) for p in params]
                actions.append(Action("send_template", {
                    "template_id": tmpl_id,
                    "language": node.get("language", "es"),
                    "parameters": rendered_params,
                }))
            else:
                actions.append(Action("send_text", {
                    "text": interpolate(node.get("text", "Please share your location."), context),
                }))
            return FlowState(current_node_id=node["id"], context=context), actions
        elif ntype == "oauth_authx":
            # Fetch (or reuse cached) Identi AuthX token, expose in context.
            # User then references it in a subsequent call_api's headers
            # template like:  Authorization: Identi {{cimmyt_token}}
            from . import authx as _authx
            save_key = node.get("save_as") or "cimmyt_token"
            try:
                context[save_key] = _authx.get_token(
                    force_refresh=bool(node.get("force_refresh"))
                )
            except Exception as e:
                context[save_key] = ""
                context[f"{save_key}_error"] = str(e)
                actions.append(Action("send_text", {
                    "text": interpolate(
                        node.get("on_error_text",
                            "⚠️ No pudimos contactar el servicio. Intenta más tarde."),
                        context,
                    ),
                }))
            next_id = node.get("next")
        elif ntype == "producer_authx":
            # POST /auth on the CIMMYT Backend to exchange the org token for
            # a per-user (producer) JWT used for /verifiable-credentials,
            # /did, /verifiable-presentations/share-link.
            from . import authx as _authx
            save_key = node.get("save_as") or "producer_token"
            try:
                org_token = context.get("cimmyt_token") or _authx.get_token()
                identifier = interpolate(node.get("identifier", "+{{number}}"), context)
                backend = (node.get("backend_url")
                           or _authx._read_setting("CIMMYT_BACKEND_URL")
                           or "https://cimmyt-service.d.identi.digital").rstrip("/")
                import requests as _req
                r = _req.post(
                    f"{backend}/auth",
                    json={"identifier": identifier},
                    headers={"Content-Type": "application/json",
                             "Authorization": f"Identi {org_token}"},
                    timeout=20,
                )
                data = r.json() if r.text.strip() else {}
                token = data.get("access_token", "")
                context[save_key] = token
                context[f"{save_key}_raw"] = data
                if not token:
                    context[f"{save_key}_error"] = data.get("message", f"HTTP {r.status_code}")
                    if node.get("on_error_text"):
                        actions.append(Action("send_text", {
                            "text": interpolate(node["on_error_text"], context),
                        }))
            except Exception as e:
                context[save_key] = ""
                context[f"{save_key}_error"] = str(e)
                if node.get("on_error_text"):
                    actions.append(Action("send_text", {
                        "text": interpolate(node["on_error_text"], context),
                    }))
            next_id = node.get("next")
        elif ntype == "send_template":
            # Picky Assist approved-template message (the only way to get real
            # WhatsApp URL/CTA buttons). Doesn't pause the flow — emits and
            # advances. Engine just hands off to the channel adapter; the
            # adapter knows how to format Picky Assist's template payload.
            params = node.get("parameters") or []
            rendered_params = [interpolate(str(p), context) for p in params]
            actions.append(Action("send_template", {
                "template_id": node["template_id"],
                "language": node.get("language", "es"),
                "parameters": rendered_params,
            }))
            next_id = node.get("next")
        elif ntype in ("ask_audio", "ask_media"):
            actions.append(Action("send_text", {
                "text": interpolate(node.get("text",
                    "Por favor envía tu pregunta como mensaje de audio."), context),
            }))
            return FlowState(current_node_id=node["id"], context=context), actions
        elif ntype == "ask_image":
            actions.append(Action("send_text", {
                "text": interpolate(node.get("text",
                    "Por favor envía una imagen (foto o PDF)."), context),
            }))
            return FlowState(current_node_id=node["id"], context=context), actions
        elif ntype == "ask_text":
            # Plain text capture: prompt the user, wait for their next message,
            # and capture_answer will store the raw text into context[save_as].
            actions.append(Action("send_text", {
                "text": interpolate(node.get("text",
                    "Por favor escribe tu respuesta."), context),
            }))
            return FlowState(current_node_id=node["id"], context=context), actions
        elif ntype == "call_api":
            context = call_api_node(node, context)
            next_id = node.get("next")
        elif ntype == "branch":
            # Supports dot-paths AND list indexing / __len__ via _walk_path,
            # e.g. "vc_list.verifiable_credentials.__len__" for empty-list
            # detection, or "results.0.status" for inspecting an array entry.
            value = _walk_path(str(node["on"]), context)
            chosen = None
            for branch in node.get("branches", []):
                if str(branch.get("when")) == str(value):
                    chosen = branch.get("next")
                    break
            next_id = chosen if chosen is not None else node.get("default_next")
        elif ntype == "test_curp_inject":
            # Resolves the user's CURP into `user_curps` so the next branch
            # can decide whether to prompt. Priority:
            #   1. user_curps already set (wallet had a CURP cert) → no-op
            #   2. curp_data.curp from this session's onboarding → use it
            #   3. manual_curp from a PRIOR session (sticky via views.py) → use it
            #   4. user is in TEST_NUMBERS → substitute TEST_CURP
            #   5. fall through (branch will then prompt)
            #
            # Step 3 is the key UX win: a legacy user types CURP once, then
            # subsequent Generar runs skip the prompt entirely.
            from . import authx as _authx
            if not context.get("user_curps"):
                onboarding_curp = (context.get("curp_data") or {}).get("curp")
                manual_curp = context.get("manual_curp")
                if onboarding_curp:
                    context["user_curps"] = [onboarding_curp]
                    context["curp_source"] = "onboarding"
                elif manual_curp:
                    context["user_curps"] = [manual_curp]
                    context["curp_source"] = "manual_sticky"
                else:
                    raw_numbers = _authx._read_setting("TEST_NUMBERS", "")
                    test_numbers = {n.strip() for n in raw_numbers.split(",") if n.strip()}
                    user_number = str(context.get("number", "")).lstrip("+")
                    if user_number in test_numbers:
                        test_curp = (_authx._read_setting("TEST_CURP", "")
                                     or "SAMJ541225HOCNNS03")
                        context["user_curps"] = [test_curp]
                        context["curp_source"] = "test_override"
            next_id = node.get("next")
        elif ntype == "coverage_check":
            # Check whether the current context's lat/lng falls inside a
            # service's coverage area. The `module` field picks which
            # coverage source: "weather_coverage" (default — used by
            # Pronóstico flow) or "soil_coverage" (used by Salud del Suelo).
            # Each module exposes the same `in_coverage(lat, lng) -> bool`
            # API. Routes to in_next or out_next.
            #
            # Test-user override: when a phone number is configured in the
            # TEST_NUMBERS setting AND its real location is outside coverage,
            # substitute the configured TEST_COORD_LAT/LNG (defaults to a
            # known-good Mexican point) so developers can exercise the rest
            # of the flow from anywhere. Real users outside coverage still
            # see the "no disponible" path.
            module_name = node.get("module", "weather_coverage")
            from importlib import import_module
            try:
                cov_mod = import_module(f".{module_name}", package="flowbuilder")
                in_coverage = cov_mod.in_coverage
            except (ImportError, AttributeError):
                # Fall back to weather coverage if a flow author misnames the
                # module — better than crashing the webhook.
                from .weather_coverage import in_coverage
            from . import authx as _authx
            lat_field = node.get("lat_field", "lat")
            lng_field = node.get("lng_field", "lng")

            ok = in_coverage(context.get(lat_field), context.get(lng_field))
            if not ok:
                raw_numbers = _authx._read_setting("TEST_NUMBERS", "")
                test_numbers = {n.strip() for n in raw_numbers.split(",") if n.strip()}
                user_number = str(context.get("number", "")).lstrip("+")
                if user_number in test_numbers:
                    try:
                        sub_lat = float(_authx._read_setting("TEST_COORD_LAT", "20.6195"))
                        sub_lng = float(_authx._read_setting("TEST_COORD_LNG", "-100.7898"))
                    except ValueError:
                        sub_lat, sub_lng = 20.6195, -100.7898
                    # Mirror to every alias the engine + flow templates use
                    for k in ("lat", "latitude"):
                        context[k] = sub_lat
                    for k in ("lng", "lon", "longitude"):
                        context[k] = sub_lng
                    context["coord_substituted"] = True
                    ok = in_coverage(sub_lat, sub_lng)
            next_id = (node.get("in_next") if ok else node.get("out_next")) or node.get("next")
        elif ntype == "goto":
            next_id = node.get("next")
        elif ntype == "wait":
            actions.append(Action("wait", {"seconds": int(node.get("seconds", 1))}))
            next_id = node.get("next")
        elif ntype == "end":
            return FlowState(current_node_id=None, context=context), actions
        else:
            actions.append(Action("send_text", {"text": f"[unknown node type: {ntype}]"}))
            next_id = node.get("next")

    return FlowState(current_node_id=None, context=context), actions
