"""
工作流转换模块 - JSON 到 WorkflowGraph 的转换流程

==================== 输入格式 (JSON) ====================

{
    "workflow": [
        {"action": "transfer_liquid", "action_args": {"sources": "cell_lines", "targets": "Liquid_1", "asp_vol": 100.0, "dis_vol": 74.75, ...}},
        ...
    ],
    "reagent": {
        "cell_lines": {"slot": 4, "well": ["A1", "A3", "A5"], "labware": "DRUG + YOYO-MEDIA"},
        "Liquid_1": {"slot": 1, "well": ["A4", "A7", "A10"], "labware": "rep 1"},
        ...
    }
}

==================== 转换步骤 ====================

第一步: 按 slot 去重创建 create_resource 节点（创建板子）
--------------------------------------------------------------------------------
- 首先创建一个 Group 节点（type="Group", minimized=true），用于包含所有 create_resource 节点
- 遍历所有 reagent，按 slot 去重，为每个唯一的 slot 创建一个板子
- 所有 create_resource 节点的 parent_uuid 指向 Group 节点，minimized=true
- 生成参数:
    res_id / 节点 name / display_name: {匹配后的 prcxi 类名}_slot_{槽位}
    device_id: /PRCXI
    class_name: 与 res_id 中类型一致（如 PRCXI 384/96 孔板注册类）
    parent: /PRCXI/PRCXI_Deck
    slot_on_deck: "{slot}"
- 输出端口: labware（用于连接 set_liquid_from_plate）
- 控制流: create_resource 之间通过 ready 端口串联

示例: slot=1, slot=4 -> 创建 1 个 Group + 2 个 create_resource 节点

第二步: 为每个 reagent 创建 set_liquid_from_plate 节点（设置液体）
--------------------------------------------------------------------------------
- 首先创建一个 Group 节点（type="Group", minimized=true），用于包含所有 set_liquid_from_plate 节点
- 遍历所有 reagent，为每个试剂创建 set_liquid_from_plate 节点
- 所有 set_liquid_from_plate 节点的 parent_uuid 指向 Group 节点，minimized=true
- 生成参数:
    plate: []（通过连接传递，来自 create_resource 的 labware）
    well_names: ["A1", "A3", "A5"]（来自 reagent 的 well 数组）
    liquid_names: ["cell_lines", "cell_lines", "cell_lines"]（与 well 数量一致）
    volumes: [1e5, 1e5, 1e5]（与 well 数量一致，默认体积）
- 输入连接: create_resource (labware) -> set_liquid_from_plate (input_plate)
- 输出端口: output_wells（用于连接 transfer_liquid）
- 控制流: set_liquid_from_plate 连接在所有 create_resource 之后，通过 ready 端口串联

第三步: 解析 workflow，创建 transfer_liquid 等动作节点
--------------------------------------------------------------------------------
- 遍历 workflow 数组，为每个动作创建步骤节点
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
- 参数输入转换: liquid_height（按 wells 扩展）；mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量
- 参数扩展: 根据 targets 的 wells 数量，将单值扩展为数组
    例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接，参数值改为 []
- 输入连接: set_liquid_from_plate (output_wells) -> transfer_liquid (sources_identifier / targets_identifier)
- 输出端口: sources_out, targets_out（用于连接下一个 transfer_liquid）

==================== 连接关系图 ====================

控制流 (ready 端口串联):
    - create_resource 之间: 无 ready 连接
    - set_liquid_from_plate 之间: 无 ready 连接
    - create_resource 与 set_liquid_from_plate 之间: 无 ready 连接
    - transfer_liquid 之间: 通过 ready 端口串联
        transfer_liquid_1 -> transfer_liquid_2 -> transfer_liquid_3 -> ...

物料流:
    [create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
          (slot=1)                    (cell_lines)           (input_plate)     (sources_identifier)                          (sources_identifier)
          (slot=4)                    (Liquid_1)                               (targets_identifier)                          (targets_identifier)

==================== 端口映射 ====================

create_resource:
    输出: labware

set_liquid_from_plate:
    输入: input_plate
    输出: output_plate, output_wells

transfer_liquid:
    输入: sources -> sources_identifier, targets -> targets_identifier
    输出: sources -> sources_out, targets -> targets_out

==================== 设备名配置 (device_name) ====================

每个节点都有 device_name 字段，指定在哪个设备上执行:
- create_resource: device_name = "host_node"（固定）
- set_liquid_from_plate: device_name = "PRCXI"（可配置，见 DEVICE_NAME_DEFAULT）
- transfer_liquid 等动作: device_name = "PRCXI"（可配置，见 DEVICE_NAME_DEFAULT）

==================== 校验规则 ====================

- 检查 sources/targets 是否在 reagent 中定义
- 检查 sources 和 targets 的 wells 数量是否匹配
- 检查参数数组长度是否与 wells 数量一致
- 如有问题，在 footer 中添加 [WARN: ...] 标记
"""

import re
import uuid

import networkx as nx
from networkx.drawing.nx_agraph import to_agraph
import matplotlib.pyplot as plt
from typing import Dict, List, Any, Tuple, Optional

Json = Dict[str, Any]


# ==================== 默认配置 ====================

# 设备名配置
DEVICE_NAME_HOST = "host_node"  # create_resource 固定在 host_node 上执行
DEVICE_NAME_DEFAULT = "PRCXI"  # transfer_liquid, set_liquid_from_plate 等动作的默认设备名

# 节点类型
NODE_TYPE_DEFAULT = "ILab"  # 所有节点的默认类型

CLASS_NAMES_MAPPING = {
    "plate": "PRCXI_BioER_96_wellplate",
    "tip_rack": "PRCXI_300ul_Tips",
}
# create_resource 节点默认参数
CREATE_RESOURCE_DEFAULTS = {
    "device_id": "/PRCXI",
    "parent_template": "/PRCXI/PRCXI_Deck",
}

# 默认液体体积 (uL)
DEFAULT_LIQUID_VOLUME = 1e5

# 参数重命名映射：单数 -> 复数（用于 transfer_liquid 等动作）
PARAM_RENAME_MAPPING = {
    "asp_vol": "asp_vols",
    "dis_vol": "dis_vols",
    "asp_flow_rate": "asp_flow_rates",
    "dis_flow_rate": "dis_flow_rates",
}


def _map_deck_slot(raw_slot: str, object_type: str = "") -> str:
    """协议槽位 -> 实际 deck：4→13，8→14，12+trash→16，其余不变。"""
    s = "" if raw_slot is None else str(raw_slot).strip()
    if not s:
        return ""
    if s == "12" and (object_type or "").strip().lower() == "trash":
        return "16"
    return {"4": "13", "8": "14"}.get(s, s)


def _labware_def_index(labware_defs: Optional[List[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
    m: Dict[str, Dict[str, Any]] = {}
    for d in labware_defs or []:
        for k in ("id", "name", "reagent_id", "reagent"):
            key = d.get(k)
            if key is not None and str(key):
                m[str(key)] = d
    return m


def _labware_hint_text(labware_id: str, item: Dict[str, Any]) -> str:
    """合并 id 与协议里的 labware 描述（OpenTrons 全名常在 labware 字段）。"""
    parts = [str(labware_id), str(item.get("labware", "") or "")]
    return " ".join(parts).lower()


def _infer_reagent_kind(labware_id: str, item: Dict[str, Any]) -> str:
    ot = (item.get("object") or "").strip().lower()
    if ot == "trash":
        return "trash"
    if ot == "tiprack":
        return "tip_rack"
    lid = _labware_hint_text(labware_id, item)
    if "trash" in lid:
        return "trash"
    # tiprack / tip + rack（顺序在 tuberack 之前）
    if "tiprack" in lid or ("tip" in lid and "rack" in lid):
        return "tip_rack"
    # 离心管架 / OpenTrons tuberack（勿与 96 tiprack 混淆）
    if "tuberack" in lid or "tube_rack" in lid:
        return "tube_rack"
    if "eppendorf" in lid and "rack" in lid:
        return "tube_rack"
    if "safelock" in lid and "rack" in lid:
        return "tube_rack"
    if "rack" in lid and "tip" not in lid:
        return "tube_rack"
    return "plate"


def _infer_tube_rack_num_positions(labware_id: str, item: Dict[str, Any]) -> int:
    """从 ``24_tuberack`` 等命名中解析孔位数；解析不到则默认 24（与 PRCXI_EP_Adapter 4×6 一致）。"""
    hint = _labware_hint_text(labware_id, item)
    m = re.search(r"(\d+)_tuberack", hint)
    if m:
        return int(m.group(1))
    m = re.search(r"tuberack[_\s]*(\d+)", hint)
    if m:
        return int(m.group(1))
    m = re.search(r"(\d+)\s*[-_]?\s*pos(?:ition)?s?", hint)
    if m:
        return int(m.group(1))
    return 96


def _infer_plate_num_children_from_wells(wells: Any) -> Optional[int]:
    """根据 well 名推断孔板总孔数档位：列>12 或 行>H(8) 视为 384，否则 96。"""
    if not isinstance(wells, list) or not wells:
        return None
    max_row = 0
    max_col = 0
    for w in wells:
        m = re.match(r"^([A-Za-z]+)(\d+)$", str(w).strip())
        if not m:
            continue
        row_s, col_s = m.group(1).upper(), m.group(2)
        ri = 0
        for ch in row_s:
            ri = ri * 26 + (ord(ch) - ord("A") + 1)
        max_row = max(max_row, ri)
        max_col = max(max_col, int(col_s))
    if max_col <= 0:
        return None
    if max_col > 12 or max_row > 8:
        return 384
    return 96


def _infer_plate_num_children_from_labware_hint(labware_id: str, item: Dict[str, Any]) -> Optional[int]:
    """从 labware 命名（如 custom_384_wellplate、nest_96_wellplate）解析孔数，供模板匹配。"""
    hint = _labware_hint_text(labware_id, item)
    m = re.search(
        r"\b(1536|384|96|48|24|12|6)(\s*[-_]?\s*well|wellplate|_well_)",
        hint,
    )
    if m:
        return int(m.group(1))
    m = re.search(r"[_\s](1536|384|96|48|24|12|6)[_\s]", hint)
    if m and ("well" in hint or "plate" in hint):
        return int(m.group(1))
    return None


def _infer_plate_num_children(
    labware_id: str,
    item: Dict[str, Any],
    wells: Any,
    num_from_def: int,
) -> int:
    """孔板用于 PRCXI 匹配的孔数：优先定义表，其次命名，再 well 地址，最后默认 96。"""
    if num_from_def > 0:
        return num_from_def
    hinted = _infer_plate_num_children_from_labware_hint(labware_id, item)
    if hinted is not None:
        return hinted
    from_wells = _infer_plate_num_children_from_wells(wells)
    if from_wells is not None:
        return from_wells
    return 96


def _tip_volume_hint(item: Dict[str, Any], labware_id: str) -> Optional[float]:
    s = _labware_hint_text(labware_id, item)
    for v in (1250, 1000, 300, 200, 10):
        if f"{v}ul" in s or f"{v}μl" in s or f"{v}u" in s:
            return float(v)
        if f" {v} " in f" {s} ":
            return float(v)
    return None


def _flatten_transfer_vols(value: Any) -> List[float]:
    """将 asp_vols/dis_vols 标量或列表展平为 float 列表，无法转换的项跳过。"""
    if value is None:
        return []
    if isinstance(value, (list, tuple)):
        out: List[float] = []
        for v in value:
            try:
                out.append(float(v))
            except (TypeError, ValueError):
                continue
        return out
    try:
        return [float(value)]
    except (TypeError, ValueError):
        return []


def _tip_prcxi_class_for_max_ul(max_ul: float) -> str:
    """按移液最大体积分档推介 PRCXI tip 类名：≤10 µL → 10µL；<300 → 300µL；否则 1000µL。"""
    if max_ul <= 10:
        return "PRCXI_10uL_Tips"
    if max_ul < 300:
        return "PRCXI_300ul_Tips"
    return "PRCXI_1000uL_Tips"


def _apply_tip_rack_class_from_transfer_volumes(
    labware_info: Dict[str, Dict[str, Any]],
    protocol_steps_refactored: List[Dict[str, Any]],
) -> None:
    """根据各 ``transfer_liquid`` 的 asp_vols/dis_vols 为对应 ``tip_racks`` 写入 ``prcxi_class_name``。"""
    tip_to_max_ul: Dict[str, float] = {}

    for step in protocol_steps_refactored:
        if step.get("template_name") != "transfer_liquid":
            continue
        p = step.get("param") or {}
        tip_key_raw = p.get("tip_racks")
        if tip_key_raw is None or str(tip_key_raw).strip() == "":
            continue
        tip_key = str(tip_key_raw).strip()
        if tip_key not in labware_info:
            continue

        nums = _flatten_transfer_vols(p.get("asp_vols", p.get("asp_vol"))) + _flatten_transfer_vols(
            p.get("dis_vols", p.get("dis_vol"))
        )
        if not nums:
            continue
        step_max = max(nums)
        tip_to_max_ul[tip_key] = max(tip_to_max_ul.get(tip_key, 0.0), step_max)

    for tip_key, max_ul in tip_to_max_ul.items():
        item = labware_info.get(tip_key)
        if item is None:
            continue
        if _infer_reagent_kind(tip_key, item) != "tip_rack":
            continue
        item["prcxi_class_name"] = _tip_prcxi_class_for_max_ul(max_ul)


def _volume_template_covers_requirement(template: Dict[str, Any], req: Optional[float], kind: str) -> bool:
    """有明确需求体积时，模板标称 Volume 必须 >= 需求；无 Volume 的模板不参与（trash 除外）。"""
    if kind == "trash":
        return True
    if req is None or req <= 0:
        return True
    mv = float(template.get("Volume") or 0)
    if mv <= 0:
        return False
    return mv >= req


def _direct_labware_class_name(item: Dict[str, Any]) -> str:
    """仅用于 tip_rack 且 ``preserve_tip_rack_incoming_class=True``：``class_name``/``class`` 原样；否则 ``labware`` → ``lab_*``。"""
    explicit = item.get("class_name") or item.get("class")
    if explicit is not None and str(explicit).strip() != "":
        return str(explicit).strip()
    lw = str(item.get("labware", "") or "").strip()
    if lw:
        return f"lab_{lw.lower().replace('.', 'point').replace(' ', '_')}"
    return ""


def _match_score_prcxi_template(
    template: Dict[str, Any],
    num_children: int,
    child_max_volume: Optional[float],
) -> float:
    """孔数差主导；有需求体积且模板已满足 >= 时，余量比例 (模板-需求)/需求 越小越好（优先选刚好够的）。"""
    hole_count = int(template.get("hole_count") or 0)
    hole_diff = abs(num_children - hole_count)
    material_volume = float(template.get("Volume") or 0)
    req = child_max_volume
    if req is not None and req > 0 and material_volume > 0:
        vol_diff = (material_volume - req) / max(req, 1e-9)
    elif material_volume > 0 and req is not None:
        vol_diff = abs(float(req) - material_volume) / material_volume
    else:
        vol_diff = 0.0
    return hole_diff * 1000 + vol_diff


def _apply_prcxi_labware_auto_match(
    labware_info: Dict[str, Dict[str, Any]],
    labware_defs: Optional[List[Dict[str, Any]]] = None,
    *,
    preserve_tip_rack_incoming_class: bool = True,
) -> None:
    """上传构建图前：按孔数+容量将 reagent 条目匹配到 ``prcxi_labware`` 注册类名，写入 ``prcxi_class_name``。
    若给出需求体积，仅选用模板标称 Volume >= 该值的物料，并在满足条件的模板中选余量最小者。

    ``preserve_tip_rack_incoming_class=True``（默认）时：**仅 tip_rack** 不做模板匹配，类名由 ``class_name``/``class`` 或
    ``labware``（``lab_*``）直接给出；**plate / tube_rack / trash 等**仍按注册模板匹配。
    ``False`` 时 **全部**（含 tip_rack）走模板匹配。"""
    if not labware_info:
        return

    default_prcxi_tip_class = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")

    try:
        from unilabos.devices.liquid_handling.prcxi.prcxi_labware import get_prcxi_labware_template_specs
    except Exception:
        return

    templates = get_prcxi_labware_template_specs()
    if not templates:
        return

    def_map = _labware_def_index(labware_defs)

    for labware_id, item in labware_info.items():
        if item.get("prcxi_class_name"):
            continue

        kind = _infer_reagent_kind(labware_id, item)

        if preserve_tip_rack_incoming_class and kind == "tip_rack":
            inc_s = _direct_labware_class_name(item)
            if inc_s == default_prcxi_tip_class:
                inc_s = ""
            if inc_s:
                item["prcxi_class_name"] = inc_s
            continue

        explicit = item.get("class_name") or item.get("class")
        if explicit and str(explicit).startswith("PRCXI_"):
            item["prcxi_class_name"] = str(explicit)
            continue

        extra = def_map.get(str(labware_id), {})

        wells = item.get("well") or []
        well_n = len(wells) if isinstance(wells, list) else 0
        num_from_def = int(extra.get("num_wells") or extra.get("well_count") or item.get("num_wells") or 0)

        if kind == "trash":
            num_children = 0
        elif kind == "tip_rack":
            num_children = num_from_def if num_from_def > 0 else 96
        elif kind == "tube_rack":
            if num_from_def > 0:
                num_children = num_from_def
            elif well_n > 0:
                num_children = well_n
            else:
                num_children = _infer_tube_rack_num_positions(labware_id, item)
        else:
            # plate：勿在无 labware_defs 时默认 96，否则 384 板会被错配成 96 模板
            num_children = _infer_plate_num_children(labware_id, item, wells, num_from_def)

        child_max_volume = item.get("max_volume")
        if child_max_volume is None:
            child_max_volume = extra.get("max_volume")
        try:
            child_max_volume_f = float(child_max_volume) if child_max_volume is not None else None
        except (TypeError, ValueError):
            child_max_volume_f = None

        if kind == "tip_rack" and child_max_volume_f is None:
            child_max_volume_f = _tip_volume_hint(item, labware_id) or 300.0

        candidates = [t for t in templates if t["kind"] == kind]
        if not candidates:
            continue

        best = None
        best_score = float("inf")
        for t in candidates:
            if kind != "trash" and int(t.get("hole_count") or 0) <= 0:
                continue
            if not _volume_template_covers_requirement(t, child_max_volume_f, kind):
                continue
            sc = _match_score_prcxi_template(t, num_children, child_max_volume_f)
            if sc < best_score:
                best_score = sc
                best = t

        if best:
            item["prcxi_class_name"] = best["class_name"]


def _reconcile_slot_carrier_prcxi_class(
    labware_info: Dict[str, Dict[str, Any]],
    *,
    preserve_tip_rack_incoming_class: bool = False,
) -> None:
    """同一 deck 槽位上多条 reagent 时，按载体类型优先级统一 ``prcxi_class_name``，避免先遍历到 96 板后槽位被错误绑定。

    ``preserve_tip_rack_incoming_class=True`` 时：tip_rack 条目不参与同槽类名合并（不被覆盖、也不把 tip 类名扩散到同槽其它条目）。"""
    by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
    for lid, item in labware_info.items():
        ot = item.get("object", "") or ""
        slot = _map_deck_slot(str(item.get("slot", "")), ot)
        if not slot:
            continue
        by_slot.setdefault(str(slot), []).append((lid, item))

    priority = {"trash": 0, "tube_rack": 1, "tip_rack": 2, "plate": 3}

    for _slot, pairs in by_slot.items():
        if len(pairs) < 2:
            continue

        def _rank(p: Tuple[str, Dict[str, Any]]) -> int:
            return priority.get(_infer_reagent_kind(p[0], p[1]), 9)

        pairs_sorted = sorted(pairs, key=_rank)
        best_cls = None
        for lid, it in pairs_sorted:
            if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
                continue
            c = it.get("prcxi_class_name")
            if c:
                best_cls = c
                break
        if not best_cls:
            continue
        for lid, it in pairs:
            if preserve_tip_rack_incoming_class and _infer_reagent_kind(lid, it) == "tip_rack":
                continue
            it["prcxi_class_name"] = best_cls


# ---------------- Graph ----------------


class WorkflowGraph:
    """简单的有向图实现：使用 params 单层参数；inputs 内含连线；支持 node-link 导出"""

    def __init__(self):
        self.nodes: Dict[str, Dict[str, Any]] = {}
        self.edges: List[Dict[str, Any]] = []

    def add_node(self, node_id: str, **attrs):
        self.nodes[node_id] = attrs

    def add_edge(self, source: str, target: str, **attrs):
        # 将 source_port/target_port 映射为服务端期望的 source_handle_key/target_handle_key
        source_handle_key = attrs.pop("source_port", "") or attrs.pop("source_handle_key", "")
        target_handle_key = attrs.pop("target_port", "") or attrs.pop("target_handle_key", "")

        edge = {
            "source": source,
            "target": target,
            "source_node_uuid": source,
            "target_node_uuid": target,
            "source_handle_key": source_handle_key,
            "source_handle_io": attrs.pop("source_handle_io", "source"),
            "target_handle_key": target_handle_key,
            "target_handle_io": attrs.pop("target_handle_io", "target"),
            **attrs,
        }
        self.edges.append(edge)

    def _materialize_wiring_into_inputs(
        self,
        obj: Any,
        inputs: Dict[str, Any],
        variable_sources: Dict[str, Dict[str, Any]],
        target_node_id: str,
        base_path: List[str],
    ):
        has_var = False

        def walk(node: Any, path: List[str]):
            nonlocal has_var
            if isinstance(node, dict):
                if "__var__" in node:
                    has_var = True
                    varname = node["__var__"]
                    placeholder = f"${{{varname}}}"
                    src = variable_sources.get(varname)
                    if src:
                        key = ".".join(path)  # e.g. "params.foo.bar.0"
                        inputs[key] = {"node": src["node_id"], "output": src.get("output_name", "result")}
                        self.add_edge(
                            str(src["node_id"]),
                            target_node_id,
                            source_handle_io=src.get("output_name", "result"),
                            target_handle_io=key,
                        )
                    return placeholder
                return {k: walk(v, path + [k]) for k, v in node.items()}
            if isinstance(node, list):
                return [walk(v, path + [str(i)]) for i, v in enumerate(node)]
            return node

        replaced = walk(obj, base_path[:])
        return replaced, has_var

    def add_workflow_node(
        self,
        node_id: int,
        *,
        device_key: Optional[str] = None,  # 实例名，如 "ser"
        resource_name: Optional[str] = None,  # registry key（原 device_class）
        module: Optional[str] = None,
        template_name: Optional[str] = None,  # 动作/模板名（原 action_key）
        params: Dict[str, Any],
        variable_sources: Dict[str, Dict[str, Any]],
        add_ready_if_no_vars: bool = True,
        prev_node_id: Optional[int] = None,
        **extra_attrs,
    ) -> None:
        """添加工作流节点：params 单层；自动变量连线与 ready 串联；支持附加属性"""
        node_id_str = str(node_id)
        inputs: Dict[str, Any] = {}

        params, has_var = self._materialize_wiring_into_inputs(
            params, inputs, variable_sources, node_id_str, base_path=["params"]
        )

        if add_ready_if_no_vars and not has_var:
            last_id = str(prev_node_id) if prev_node_id is not None else "-1"
            inputs["ready"] = {"node": int(last_id), "output": "ready"}
            self.add_edge(last_id, node_id_str, source_handle_io="ready", target_handle_io="ready")

        node_obj = {
            "device_key": device_key,
            "resource_name": resource_name,  # ✅ 新名字
            "module": module,
            "template_name": template_name,  # ✅ 新名字
            "params": params,
            "inputs": inputs,
        }
        node_obj.update(extra_attrs or {})
        self.add_node(node_id_str, parameters=node_obj)

    # 顺序工作流导出（连线在 inputs，不返回 edges）
    def to_dict(self) -> List[Dict[str, Any]]:
        result = []
        for node_id, attrs in self.nodes.items():
            node = {"uuid": node_id}
            params = dict(attrs.get("parameters", {}) or {})
            flat = {k: v for k, v in attrs.items() if k != "parameters"}
            flat.update(params)
            node.update(flat)
            result.append(node)
        return sorted(result, key=lambda n: int(n["uuid"]) if str(n["uuid"]).isdigit() else n["uuid"])

    # node-link 导出（含 edges）
    def to_node_link_dict(self) -> Dict[str, Any]:
        nodes_list = []
        for node_id, attrs in self.nodes.items():
            node_attrs = attrs.copy()
            params = node_attrs.pop("parameters", {}) or {}
            node_attrs.update(params)
            nodes_list.append({"uuid": node_id, **node_attrs})
        return {
            "directed": True,
            "multigraph": False,
            "graph": {},
            "nodes": nodes_list,
            "edges": self.edges,
            "links": self.edges,
        }


def refactor_data(
    data: List[Dict[str, Any]],
    action_resource_mapping: Optional[Dict[str, str]] = None,
) -> List[Dict[str, Any]]:
    """统一的数据重构函数，根据操作类型自动选择模板

    Args:
        data: 原始步骤数据列表
        action_resource_mapping: action 到 resource_name 的映射字典，可选
    """
    refactored_data = []

    # 定义操作映射，包含生物实验和有机化学的所有操作
    OPERATION_MAPPING = {
        # 生物实验操作
        "transfer_liquid": "transfer_liquid",
        "transfer": "transfer",
        "incubation": "incubation",
        "move_labware": "move_labware",
        "oscillation": "oscillation",
        # 有机化学操作
        "HeatChillToTemp": "HeatChillProtocol",
        "StopHeatChill": "HeatChillStopProtocol",
        "StartHeatChill": "HeatChillStartProtocol",
        "HeatChill": "HeatChillProtocol",
        "Dissolve": "DissolveProtocol",
        "Transfer": "TransferProtocol",
        "Evaporate": "EvaporateProtocol",
        "Recrystallize": "RecrystallizeProtocol",
        "Filter": "FilterProtocol",
        "Dry": "DryProtocol",
        "Add": "AddProtocol",
    }

    UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]

    for step in data:
        operation = step.get("action")
        if not operation or operation in UNSUPPORTED_OPERATIONS:
            continue

        # 处理重复操作
        if operation == "Repeat":
            times = step.get("times", step.get("parameters", {}).get("times", 1))
            sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
            for i in range(int(times)):
                sub_data = refactor_data(sub_steps, action_resource_mapping)
                refactored_data.extend(sub_data)
            continue

        # 获取模板名称
        template_name = OPERATION_MAPPING.get(operation)
        if not template_name:
            # 自动推断模板类型
            if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
                template_name = f"biomek-{operation}"
            else:
                template_name = f"{operation}Protocol"

        # 获取 resource_name
        resource_name = f"device.{operation.lower()}"
        if action_resource_mapping:
            resource_name = action_resource_mapping.get(operation, resource_name)

        # 获取步骤编号，生成 name 字段
        step_number = step.get("step_number")
        name = f"Step {step_number}" if step_number is not None else None

        # 创建步骤数据
        step_data = {
            "template_name": template_name,
            "resource_name": resource_name,
            "description": step.get("description", step.get("purpose", f"{operation} operation")),
            "lab_node_type": "ILab",
            "param": step.get("parameters", step.get("action_args", {})),
            "footer": f"{template_name}-{resource_name}",
        }
        if name:
            step_data["name"] = name
        refactored_data.append(step_data)

    return refactored_data


def build_protocol_graph(
    labware_info: Dict[str, Dict[str, Any]],
    protocol_steps: List[Dict[str, Any]],
    workstation_name: str,
    action_resource_mapping: Optional[Dict[str, str]] = None,
    labware_defs: Optional[List[Dict[str, Any]]] = None,
    preserve_tip_rack_incoming_class: bool = False,
) -> WorkflowGraph:
    """统一的协议图构建函数，根据设备类型自动选择构建逻辑

    Args:
        labware_info: labware 信息字典，格式为 {name: {slot, well, labware, ...}, ...}
        protocol_steps: 协议步骤列表
        workstation_name: 工作站名称
        action_resource_mapping: action 到 resource_name 的映射字典，可选
        labware_defs: 可选，``[{"id": "...", "num_wells": 96, "max_volume": 2200}, ...]`` 等，辅助 PRCXI 模板匹配
        preserve_tip_rack_incoming_class: 默认 True 时**仅 tip_rack** 不跑模板匹配（类名由传入的 class/labware 决定）；
            **其它载体**仍按 PRCXI 模板匹配。False 时 **全部**（含 tip_rack）都走模板匹配。

    会先 ``refactor_data`` 规范化步骤，再根据 ``transfer_liquid`` 的 ``asp_vols``/``dis_vols`` 为对应
    ``tip_racks`` 写入 ``prcxi_class_name``（最大体积 ``≤10`` → ``PRCXI_10uL_Tips``，``<300`` → ``PRCXI_300ul_Tips``，
    否则 ``PRCXI_1000uL_Tips``）；无有效体积的步骤不覆盖。
    """
    G = WorkflowGraph()
    resource_last_writer = {}  # reagent_name -> "node_id:port"
    slot_to_create_resource = {}  # slot -> create_resource node_id

    protocol_steps = refactor_data(protocol_steps, action_resource_mapping)
    _apply_tip_rack_class_from_transfer_volumes(labware_info, protocol_steps)

    _apply_prcxi_labware_auto_match(
        labware_info,
        labware_defs,
        preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
    )
    _reconcile_slot_carrier_prcxi_class(
        labware_info,
        preserve_tip_rack_incoming_class=preserve_tip_rack_incoming_class,
    )

    # ==================== 第一步：按 slot 去重创建 create_resource 节点 ====================
    # 按槽聚合：同一 slot 多条 reagent 时不能只取遍历顺序第一条，否则 tip 的 prcxi_class_name / object 会被其它条目盖住
    by_slot: Dict[str, List[Tuple[str, Dict[str, Any]]]] = {}
    for labware_id, item in labware_info.items():
        object_type = item.get("object", "") or ""
        slot = _map_deck_slot(str(item.get("slot", "")), object_type)
        if not slot:
            continue
        by_slot.setdefault(slot, []).append((labware_id, item))

    slots_info: Dict[str, Dict[str, Any]] = {}
    for slot, pairs in by_slot.items():
        def _ot_tip(it: Dict[str, Any]) -> bool:
            return str(it.get("object", "") or "").strip().lower() == "tiprack"

        tip_pairs = [(lid, it) for lid, it in pairs if _ot_tip(it)]
        chosen_lid = ""
        chosen_item: Dict[str, Any] = {}
        prcxi_val: Optional[str] = None

        scan = tip_pairs if tip_pairs else pairs
        for lid, it in scan:
            c = it.get("prcxi_class_name")
            if c:
                chosen_lid, chosen_item, prcxi_val = lid, it, str(c)
                break
        if not chosen_lid and scan:
            chosen_lid, chosen_item = scan[0]
            pv = chosen_item.get("prcxi_class_name")
            prcxi_val = str(pv) if pv else None

        labware = str(chosen_item.get("labware", "") or "")
        slots_info[slot] = {
            "labware": labware,
            "labware_id": chosen_lid,
            "object": chosen_item.get("object", "") or "",
            "prcxi_class_name": prcxi_val,
        }

    # 创建 Group 节点，包含所有 create_resource 节点
    group_node_id = str(uuid.uuid4())
    G.add_node(
        group_node_id,
        name="Resources Group",
        type="Group",
        parent_uuid="",
        lab_node_type="Device",
        template_name="",
        resource_name="",
        footer="",
        minimized=True,
        param=None,
    )

    trash_create_node_id = None  # 记录 trash 的 create_resource 节点

    # 为每个唯一的 slot 创建 create_resource 节点
    for slot, info in slots_info.items():
        node_id = str(uuid.uuid4())
        object_type = info.get("object", "") or ""
        ot_lo = str(object_type).strip().lower()
        matched = info.get("prcxi_class_name")
        if ot_lo == "trash":
            res_type_name = "PRCXI_trash"
        elif matched:
            res_type_name = matched
        elif ot_lo == "tiprack":
            if preserve_tip_rack_incoming_class:
                lid = str(info.get("labware_id") or "").strip() or "tip_rack"
                res_type_name = f"lab_{lid.lower().replace('.', 'point').replace(' ', '_')}"
            else:
                res_type_name = CLASS_NAMES_MAPPING.get("tip_rack", "PRCXI_300ul_Tips")
        else:
            res_type_name = f"lab_{info['labware'].lower().replace('.', 'point')}"
        # 上传物料：匹配后的类型名 + _slot_ + 槽位（name / display_name / res_id 一致）
        res_id = f"{res_type_name}_slot_{slot}".replace(" ", "_")
        G.add_node(
            node_id,
            template_name="create_resource",
            resource_name="host_node",
            name=res_id,
            display_name=res_id,
            description=f"Create plate on slot {slot}",
            lab_node_type="Labware",
            footer="create_resource-host_node",
            device_name=DEVICE_NAME_HOST,
            type=NODE_TYPE_DEFAULT,
            parent_uuid=group_node_id,  # 指向 Group 节点
            minimized=True,  # 折叠显示
            param={
                "res_id": res_id,
                "device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
                "class_name": res_type_name,
                "parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
                "bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
                "slot_on_deck": slot,
            },
        )
        slot_to_create_resource[slot] = node_id
        if ot_lo == "tiprack":
            resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
        if ot_lo == "trash":
            trash_create_node_id = node_id
        # create_resource 之间不需要 ready 连接

    # ==================== 第二步：为每个 reagent 创建 set_liquid_from_plate 节点 ====================
    # 创建 Group 节点，包含所有 set_liquid_from_plate 节点
    set_liquid_group_id = str(uuid.uuid4())
    G.add_node(
        set_liquid_group_id,
        name="SetLiquid Group",
        type="Group",
        parent_uuid="",
        lab_node_type="Device",
        template_name="",
        resource_name="",
        footer="",
        minimized=True,
        param=None,
    )

    set_liquid_index = 0

    for labware_id, item in labware_info.items():
        # 跳过 Tip/Rack 类型
        if "Rack" in str(labware_id) or "Tip" in str(labware_id):
            continue
        if item.get("type") == "hardware":
            continue

        object_type = item.get("object", "") or ""
        slot = _map_deck_slot(str(item.get("slot", "")), object_type)
        wells = item.get("well", [])
        if not wells or not slot:
            continue

        # res_id 不能有空格（液体名仍用协议中的 reagent key）
        res_id = str(labware_id).replace(" ", "_")
        well_count = len(wells)
        liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0

        node_id = str(uuid.uuid4())
        set_liquid_index += 1
        prcxi_mat = item.get("prcxi_class_name")
        if prcxi_mat:
            sl_node_title = f"{prcxi_mat}_slot_{slot}_{res_id}"
        else:
            sl_node_title = f"lab_{res_id.lower()}_slot_{slot}_{set_liquid_index}"

        G.add_node(
            node_id,
            template_name="set_liquid_from_plate",
            resource_name="liquid_handler.prcxi",
            name=sl_node_title,
            display_name=sl_node_title,
            description=f"Set liquid: {labware_id}",
            lab_node_type="Reagent",
            footer="set_liquid_from_plate-liquid_handler.prcxi",
            device_name=DEVICE_NAME_DEFAULT,
            type=NODE_TYPE_DEFAULT,
            parent_uuid=set_liquid_group_id,  # 指向 Group 节点
            minimized=True,  # 折叠显示
            param={
                "plate": [],  # 通过连接传递
                "well_names": wells,  # 孔位名数组，如 ["A1", "A3", "A5"]
                "liquid_names": [res_id] * well_count,
                "volumes": [liquid_volume] * well_count,
            },
        )

        # set_liquid_from_plate 之间不需要 ready 连接

        # 物料流：create_resource 的 labware -> set_liquid_from_plate 的 input_plate
        create_res_node_id = slot_to_create_resource.get(slot)
        if create_res_node_id:
            G.add_edge(create_res_node_id, node_id, source_port="labware", target_port="input_plate")

        # set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
        resource_last_writer[labware_id] = f"{node_id}:output_wells"

    # 收集所有 create_resource 节点 ID，用于让第一个 transfer_liquid 等待所有资源创建完成
    all_create_resource_node_ids = list(slot_to_create_resource.values())

    # transfer_liquid 之间通过 ready 串联；第一个 transfer_liquid 需要等待所有 create_resource 完成
    last_control_node_id = trash_create_node_id
    is_first_action_node = True

    # 端口名称映射：JSON 字段名 -> 实际 handle key
    INPUT_PORT_MAPPING = {
        "sources": "sources_identifier",
        "targets": "targets_identifier",
        "vessel": "vessel",
        "to_vessel": "to_vessel",
        "from_vessel": "from_vessel",
        "reagent": "reagent",
        "solvent": "solvent",
        "compound": "compound",
        "tip_racks": "tip_rack_identifier",
    }

    OUTPUT_PORT_MAPPING = {
        "sources": "sources_out",  # 输出端口是 xxx_out
        "targets": "targets_out",  # 输出端口是 xxx_out
        "vessel": "vessel_out",
        "to_vessel": "to_vessel_out",
        "from_vessel": "from_vessel_out",
        "filtrate_vessel": "filtrate_out",
        "reagent": "reagent",
        "solvent": "solvent",
        "compound": "compound",
    }

    # 需要根据 wells 数量扩展的参数列表：
    # - 复数参数（asp_vols 等）支持单值自动扩展
    # - liquid_height 按 wells 扩展为数组
    # - mix_* 参数保持标量，避免被转换为 list
    EXPAND_BY_WELLS_PARAMS = [
        "asp_vols",
        "dis_vols",
        "asp_flow_rates",
        "dis_flow_rates",
        "liquid_height",
    ]

    # 处理协议步骤
    for step in protocol_steps:
        node_id = str(uuid.uuid4())
        params = step.get("param", {}).copy()  # 复制一份，避免修改原数据
        connected_params = set()  # 记录被连接的参数
        warnings = []  # 收集警告信息

        # 参数重命名：单数 -> 复数
        for old_name, new_name in PARAM_RENAME_MAPPING.items():
            if old_name in params:
                params[new_name] = params.pop(old_name)

        # touch_tip 输入归一化：
        # - 支持 bool / 0/1 / "true"/"false" / 单元素 list
        # - 最终统一为 bool 标量，避免被下游误当作序列处理
        if "touch_tip" in params:
            touch_tip_value = params.get("touch_tip")
            if isinstance(touch_tip_value, list):
                if len(touch_tip_value) == 1:
                    touch_tip_value = touch_tip_value[0]
                elif len(touch_tip_value) == 0:
                    touch_tip_value = False
                else:
                    warnings.append(f"touch_tip 期望标量，但收到长度为 {len(touch_tip_value)} 的列表，使用首个值")
                    touch_tip_value = touch_tip_value[0]
            if isinstance(touch_tip_value, str):
                norm = touch_tip_value.strip().lower()
                if norm in {"true", "1", "yes", "y", "on"}:
                    touch_tip_value = True
                elif norm in {"false", "0", "no", "n", "off", ""}:
                    touch_tip_value = False
                else:
                    warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value}，按 True 处理")
                    touch_tip_value = True
            elif isinstance(touch_tip_value, (int, float)):
                touch_tip_value = bool(touch_tip_value)
            elif touch_tip_value is None:
                touch_tip_value = False
            else:
                touch_tip_value = bool(touch_tip_value)
            params["touch_tip"] = touch_tip_value

        # delays 输入归一化：
        # - 支持标量（int/float/字符串数字）与 list
        # - 最终统一为数字列表，供下游按 delays[0]/delays[1] 使用
        if "delays" in params:
            delays_value = params.get("delays")
            if delays_value is None or delays_value == "":
                params["delays"] = []
            else:
                raw_list = delays_value if isinstance(delays_value, list) else [delays_value]
                normalized_delays = []
                for delay_item in raw_list:
                    if isinstance(delay_item, str):
                        delay_item = delay_item.strip()
                        if delay_item == "":
                            continue
                    try:
                        normalized_delays.append(float(delay_item))
                    except (TypeError, ValueError):
                        warnings.append(f"delays 包含无法转换为数字的值: {delay_item}，已忽略")
                params["delays"] = normalized_delays

        # 处理输入连接
        for param_key, target_port in INPUT_PORT_MAPPING.items():
            resource_name = params.get(param_key)
            if resource_name and resource_name in resource_last_writer:
                source_node, source_port = resource_last_writer[resource_name].split(":")
                G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
                connected_params.add(param_key)
            elif resource_name and resource_name not in resource_last_writer:
                # 资源名在 labware_info 中不存在
                warnings.append(f"{param_key}={resource_name} 未找到")

        # 获取 targets 对应的 wells 数量，用于扩展参数
        targets_name = params.get("targets")
        sources_name = params.get("sources")
        targets_wells_count = 1
        sources_wells_count = 1

        if targets_name and targets_name in labware_info:
            target_wells = labware_info[targets_name].get("well", [])
            targets_wells_count = len(target_wells) if target_wells else 1
        elif targets_name:
            warnings.append(f"targets={targets_name} 未在 reagent 中定义")

        if sources_name and sources_name in labware_info:
            source_wells = labware_info[sources_name].get("well", [])
            sources_wells_count = len(source_wells) if source_wells else 1
        elif sources_name:
            warnings.append(f"sources={sources_name} 未在 reagent 中定义")

        # 检查 sources 和 targets 的 wells 数量是否匹配
        if targets_wells_count != sources_wells_count and targets_name and sources_name:
            warnings.append(f"wells 数量不匹配: sources={sources_wells_count}, targets={targets_wells_count}")

        # 使用 targets 的 wells 数量来扩展参数
        wells_count = targets_wells_count

        # 扩展单值参数为数组（根据 targets 的 wells 数量）
        for expand_param in EXPAND_BY_WELLS_PARAMS:
            if expand_param in params:
                value = params[expand_param]
                # 如果是单个值，扩展为数组
                if not isinstance(value, list):
                    params[expand_param] = [value] * wells_count
                # 如果已经是数组但长度不对，记录警告
                elif len(value) != wells_count:
                    warnings.append(f"{expand_param} 数量({len(value)})与 wells({wells_count})不匹配")

        # 如果 sources/targets 已通过连接传递，将参数值改为空数组
        for param_key in connected_params:
            if param_key in params:
                params[param_key] = []

        # 更新 step 的 param、footer、device_name 和 type
        step_copy = step.copy()
        step_copy["param"] = params
        step_copy["device_name"] = DEVICE_NAME_DEFAULT  # 动作节点使用默认设备名
        step_copy["type"] = NODE_TYPE_DEFAULT  # 节点类型

        # 如果有警告，修改 footer 添加警告标记（警告放前面）
        if warnings:
            original_footer = step.get("footer", "")
            step_copy["footer"] = f"[WARN: {'; '.join(warnings)}] {original_footer}"

        G.add_node(node_id, **step_copy)

        # 控制流
        if is_first_action_node:
            # 第一个 transfer_liquid 需要等待所有 create_resource 完成
            for cr_node_id in all_create_resource_node_ids:
                G.add_edge(cr_node_id, node_id, source_port="ready", target_port="ready")
            is_first_action_node = False
        elif last_control_node_id is not None:
            G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
        last_control_node_id = node_id

        # 处理输出：更新 resource_last_writer
        for param_key, output_port in OUTPUT_PORT_MAPPING.items():
            resource_name = step.get("param", {}).get(param_key)  # 使用原始参数值
            if resource_name:
                resource_last_writer[resource_name] = f"{node_id}:{output_port}"

    return G


def draw_protocol_graph(protocol_graph: WorkflowGraph, output_path: str):
    """
    (辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图，用于可视化。
    """
    if not protocol_graph:
        print("Cannot draw graph: Graph object is empty.")
        return

    G = nx.DiGraph()

    for node_id, attrs in protocol_graph.nodes.items():
        label = attrs.get("description", attrs.get("template_name", node_id[:8]))
        G.add_node(node_id, label=label, **attrs)

    for edge in protocol_graph.edges:
        G.add_edge(edge["source"], edge["target"])

    plt.figure(figsize=(20, 15))
    try:
        pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
    except Exception:
        pos = nx.shell_layout(G)  # Fallback layout

    node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
    nx.draw(
        G,
        pos,
        with_labels=False,
        node_size=2500,
        node_color="skyblue",
        node_shape="o",
        edge_color="gray",
        width=1.5,
        arrowsize=15,
    )
    nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")

    plt.title("Chemical Protocol Workflow Graph", size=15)
    plt.savefig(output_path, dpi=300, bbox_inches="tight")
    plt.close()
    print(f"  - Visualization saved to '{output_path}'")


COMPASS = {"n", "e", "s", "w", "ne", "nw", "se", "sw", "c"}


def _is_compass(port: str) -> bool:
    return isinstance(port, str) and port.lower() in COMPASS


def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
    """
    使用 Graphviz 端口语法绘制协议工作流图。
    - 若边上的 source_port/target_port 是 compass（n/e/s/w/...），直接用 compass。
    - 否则自动为节点创建 record 形状并定义命名端口 <portname>。
    最终由 PyGraphviz 渲染并输出到 output_path（后缀决定格式，如 .png/.svg/.pdf）。
    """
    if not protocol_graph:
        print("Cannot draw graph: Graph object is empty.")
        return

    # 1) 先用 networkx 搭建有向图，保留端口属性
    G = nx.DiGraph()
    for node_id, attrs in protocol_graph.nodes.items():
        label = attrs.get("description", attrs.get("template_name", node_id[:8]))
        # 保留一个干净的“中心标签”，用于放在 record 的中间槽
        G.add_node(node_id, _core_label=str(label), **{k: v for k, v in attrs.items() if k not in ("label",)})

    edges_data = []
    in_ports_by_node = {}  # 收集命名输入端口
    out_ports_by_node = {}  # 收集命名输出端口

    for edge in protocol_graph.edges:
        u = edge["source"]
        v = edge["target"]
        sp = edge.get("source_handle_key") or edge.get("source_port")
        tp = edge.get("target_handle_key") or edge.get("target_port")

        # 记录到图里（保留原始端口信息）
        G.add_edge(u, v, source_handle_key=sp, target_handle_key=tp)
        edges_data.append((u, v, sp, tp))

        # 如果不是 compass，就按“命名端口”先归类，等会儿给节点造 record
        if sp and not _is_compass(sp):
            out_ports_by_node.setdefault(u, set()).add(str(sp))
        if tp and not _is_compass(tp):
            in_ports_by_node.setdefault(v, set()).add(str(tp))

    # 2) 转为 AGraph，使用 Graphviz 渲染
    A = to_agraph(G)
    A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
    A.node_attr.update(
        shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica"
    )
    A.edge_attr.update(arrowsize="0.8", color="#666666")

    # 3) 为需要命名端口的节点设置 record 形状与 label
    #    左列 = 输入端口；中间 = 核心标签；右列 = 输出端口
    for n in A.nodes():
        node = A.get_node(n)
        core = G.nodes[n].get("_core_label", n)

        in_ports = sorted(in_ports_by_node.get(n, []))
        out_ports = sorted(out_ports_by_node.get(n, []))

        # 如果该节点涉及命名端口，则用 record；否则保留原 box
        if in_ports or out_ports:

            def port_fields(ports):
                if not ports:
                    return " "  # 必须留一个空槽占位
                # 每个端口一个小格子，<p> name
                return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)

            left = port_fields(in_ports)
            right = port_fields(out_ports)

            # 三栏：左(入) | 中(节点名) | 右(出)
            record_label = f"{{ {left} | {core} | {right} }}"
            node.attr.update(shape="record", label=record_label)
        else:
            # 没有命名端口：普通盒子，显示核心标签
            node.attr.update(label=str(core))

    # 4) 给边设置 headport / tailport
    #    - 若端口为 compass：直接用 compass（e.g., headport="e"）
    #    - 若端口为命名端口：使用在 record 中定义的 <port> 名（同名即可）
    for u, v, sp, tp in edges_data:
        e = A.get_edge(u, v)

        # Graphviz 属性：tail 是源，head 是目标
        if sp:
            if _is_compass(sp):
                e.attr["tailport"] = sp.lower()
            else:
                # 与 record label 中 <port> 名一致；特殊字符已在 label 中做了清洗
                e.attr["tailport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(sp))

        if tp:
            if _is_compass(tp):
                e.attr["headport"] = tp.lower()
            else:
                e.attr["headport"] = re.sub(r"[^A-Za-z0-9_:.|-]", "_", str(tp))

        # 可选：若想让边更贴边缘，可设置 constraint/spline 等
        # e.attr["arrowhead"] = "vee"

    # 5) 输出
    A.draw(output_path, prog="dot")
    print(f"  - Port-aware workflow rendered to '{output_path}'")


# ---------------- Registry Adapter ----------------


class RegistryAdapter:
    """根据 module 的类名（冒号右侧）反查 registry 的 resource_name（原 device_class），并抽取参数顺序"""

    def __init__(self, device_registry: Dict[str, Any]):
        self.device_registry = device_registry or {}
        self.module_class_to_resource = self._build_module_class_index()

    def _build_module_class_index(self) -> Dict[str, str]:
        idx = {}
        for resource_name, info in self.device_registry.items():
            module = info.get("module")
            if isinstance(module, str) and ":" in module:
                cls = module.split(":")[-1]
                idx[cls] = resource_name
                idx[cls.lower()] = resource_name
        return idx

    def resolve_resource_by_classname(self, class_name: str) -> Optional[str]:
        if not class_name:
            return None
        return self.module_class_to_resource.get(class_name) or self.module_class_to_resource.get(class_name.lower())

    def get_device_module(self, resource_name: Optional[str]) -> Optional[str]:
        if not resource_name:
            return None
        return self.device_registry.get(resource_name, {}).get("module")

    def get_actions(self, resource_name: Optional[str]) -> Dict[str, Any]:
        if not resource_name:
            return {}
        return (self.device_registry.get(resource_name, {}).get("class", {}).get("action_value_mappings", {})) or {}

    def get_action_schema(self, resource_name: Optional[str], template_name: str) -> Optional[Json]:
        return (self.get_actions(resource_name).get(template_name) or {}).get("schema")

    def get_action_goal_default(self, resource_name: Optional[str], template_name: str) -> Json:
        return (self.get_actions(resource_name).get(template_name) or {}).get("goal_default", {}) or {}

    def get_action_input_keys(self, resource_name: Optional[str], template_name: str) -> List[str]:
        schema = self.get_action_schema(resource_name, template_name) or {}
        goal = (schema.get("properties") or {}).get("goal") or {}
        props = goal.get("properties") or {}
        required = goal.get("required") or []
        return list(dict.fromkeys(required + list(props.keys())))
