#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：element_mapping_service.py
模块描述：元素映射表服务，从前端提供的 key-value 映射表 JSON 中根据步骤描述匹配稳定选择器

作者：czj
创建日期：2026-08-11
最后修改：2026-08-11

设计说明：
- 映射表 JSON 是 Playwright 执行器的最高优先级选择器来源（selector priority: element_mapping > ...）
- 支持三种匹配策略：精确匹配（关键词包含）、模糊匹配（jieba 分词 + 相似度）、上下文辅助
- 容错：JSON 缺失/损坏时自动禁用，静默回退到原有定位策略
"""

import json
import logging
import os
import re
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional

import jieba

logger = logging.getLogger(__name__)

# 默认映射表文件路径（相对 backend/ 工作目录）
DEFAULT_MAPPING_FILE = os.path.join(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
    "data",
    "elements_mapping.json",
)

# 模糊匹配相似度阈值
FUZZY_MATCH_THRESHOLD = 0.7

# 精确匹配最低置信度阈值（低于此值则回退到模糊匹配）
EXACT_MATCH_MIN_SCORE = 6.0

# 同义词映射（用于精确匹配无法覆盖的语义等价关系）
SYNONYM_MAP = {
    "新建": "创建",
    "创建": "新建",
    "新增": "创建",
    # P0 修复：创建会议页的"会议室选择：北京展厅会议室"步骤。
    # 在创建会议页，房间选择是通过右侧会议室表格行的勾选实现的，
    # 语义上等价于"勾选会议室"，必须命中"创建会议-会议室选择"条目。
    # 同时避免误配同义词"会议预约-会议室"层级的"会议室-区域树"。
    "会议室选择": "勾选会议室",
    "勾选会议室": "会议室选择",
    # P1 修复：创建会议页"勾选右侧的用户：admin@xty12"步骤。
    # 参会人表格列名含"参会人"，步骤名用"用户"，语义等价（参会人=用户）。
    # 不加此同义词则"用户"单 token 无候选，导致映射返回 None、回退到裸 .el-checkbox
    # 误点页面上第一个复选框（短信/邮件开关）。
    "用户": "参会人",
    "参会人": "用户",
}

# 映射表优先级（最高）
MAPPING_PRIORITY = 1


class ElementMappingService:
    """
    元素映射表服务

    从元素映射表 JSON 文件中加载元素 -> 选择器映射，根据步骤描述匹配返回稳定选择器。

    Attributes:
        mapping_file (str): 映射表 JSON 文件路径
        _elements (dict): 已加载的元素映射 {映射键: {selector, type, tier, description}}
        _enabled (bool): 映射表是否可用（JSON 缺失/损坏时为 False）
        _index (dict): 预构建的匹配索引，加速查询
    """

    def __init__(self, mapping_file: str = DEFAULT_MAPPING_FILE):
        self.mapping_file = mapping_file
        self._elements: Dict[str, Dict[str, Any]] = {}
        self._enabled = False
        self._index: Dict[str, List[str]] = {}  # 中文关键词 -> 映射键列表
        self._action_map: Dict[str, str] = {}   # 动作类型 -> 映射键
        self._load()

    # ==================== 加载 ====================

    def _load(self) -> None:
        """加载映射表 JSON 文件，失败时自动禁用"""
        self._elements = {}
        self._index = {}
        self._action_map = {}
        self._enabled = False

        if not os.path.exists(self.mapping_file):
            logger.warning(f"[元素映射表] 文件不存在，映射表已禁用: {self.mapping_file}")
            return

        try:
            with open(self.mapping_file, "r", encoding="utf-8") as f:
                data = json.load(f)

            elements = data.get("elements", {})
            if not isinstance(elements, dict) or not elements:
                logger.warning("[元素映射表] JSON 中 elements 为空，映射表已禁用")
                return

            self._elements = elements

            # 构建中文关键词 -> 映射键索引
            for key in elements:
                for kw in self._split_keywords(key):
                    self._index.setdefault(kw, []).append(key)

            self._enabled = True
            logger.info(f"[元素映射表] 加载成功: {len(elements)} 个元素, 来源: {data.get('source', '未知')}")
        except json.JSONDecodeError as e:
            logger.error(f"[元素映射表] JSON 解析失败，映射表已禁用: {e}")
        except Exception as e:
            logger.error(f"[元素映射表] 加载异常，映射表已禁用: {e}")

    def reload(self) -> bool:
        """热重载映射表文件

        Returns:
            bool: 重载后映射表是否可用
        """
        self._load()
        return self._enabled

    # ==================== 查询 ====================

    def get_selector(
        self,
        step_name: str,
        action: str = "click",
        context: Optional[Dict] = None,
    ) -> Optional[Dict[str, Any]]:
        """
        根据步骤描述和上下文，从映射表获取选择器

        Args:
            step_name: 步骤描述（如"点击创建会议按钮"）
            action: 动作类型（click / fill 等）
            context: 上下文信息（当前页面、前序步骤等）

        Returns:
            {"selector": "...", "type": "css", "confidence": 1.0, "key": "..."} 或 None
        """
        if not self._enabled or not step_name:
            return None

        # 1. 精确匹配（关键词包含）
        exact = self._exact_match(step_name)
        if exact:
            return self._build_result(exact, confidence=1.0)

        # 2. 模糊匹配（jieba 分词 + 相似度）
        fuzzy = self._fuzzy_match(step_name)
        if fuzzy:
            return self._build_result(fuzzy[0], confidence=fuzzy[1])

        # 3. 上下文辅助匹配（结合前序步骤判断所在功能分类）
        if context:
            ctx_match = self._context_match(step_name, context)
            if ctx_match:
                return self._build_result(ctx_match, confidence=0.9)

        return None

    def get_all_keys(self) -> List[str]:
        """返回所有映射键列表（用于调试）"""
        return list(self._elements.keys())

    def is_enabled(self) -> bool:
        """映射表是否可用"""
        return self._enabled

    # ==================== 匹配策略 ====================

    @staticmethod
    def _split_keywords(key: str) -> List[str]:
        """
        从映射键中拆分中文关键词

        例如："全部功能-会议预约-创建会议" -> ["会议预约", "创建会议"]
        去除前缀层级词（导航栏/首页/功能中心/全部功能/置顶功能/常用功能/筛选器）
        """
        # 去除层级前缀
        cleaned = key
        for prefix in ["导航栏-", "首页-", "功能中心-置顶功能-", "功能中心-常用功能-",
                       "功能中心-搜索", "全部功能-筛选器-", "全部功能-", "功能中心-"]:
            if cleaned.startswith(prefix):
                cleaned = cleaned[len(prefix):]
                break

        # 按 - 拆分得到各段
        parts = [p for p in cleaned.split("-") if p]
        result = []

        # 每段作为一个关键词（如"会议预约"、"创建会议"）
        result.extend(parts)

        # 整个 cleaned 也作为一个关键词（如"会议预约-创建会议"）
        if len(parts) > 1:
            result.append(cleaned)

        return result

    def _expand_synonyms(self, keyword: str) -> List[str]:
        """
        将步骤关键词中的常见同义词替换为映射表用词，返回所有变体

        例如："新建会议" -> ["新建会议", "创建会议"]（新建↔创建）
        "创建会议按钮" -> ["创建会议按钮", "新建会议按钮"]
        """
        variants = {keyword}
        for src, tgt in SYNONYM_MAP.items():
            if src in keyword:
                variants.add(keyword.replace(src, tgt))
            if tgt in keyword:
                variants.add(keyword.replace(tgt, src))
        return list(variants)

    def _exact_match(self, step_name: str) -> Optional[str]:
        """
        精确匹配：步骤描述包含映射键中的完整关键词

        采用"强匹配/弱匹配"评分模型：
        - 强匹配：步骤关键词完整包含映射键关键词（sk==kk 或 kk in sk），权重高
        - 弱匹配：步骤关键词是映射键关键词的子串（sk in kk），如"会议" in "会议服务统计"，权重低

        要求至少 1 个强匹配，或 2 个以上弱匹配，才判定为精确命中。
        这解决了"创建会议"误配"会议服务统计"（仅弱匹配"会议"）的问题。
        """
        step_clean = self._clean_step_name(step_name)
        step_keywords = self._tokenize(self._clean_step_name_without_elem(step_name))

        best_key = None
        best_score = 0.0

        for key in self._elements:
            key_kws = self._split_keywords(key)
            score = 0.0
            strong_count = 0
            weak_count = 0

            # 已匹配过的映射键关键词（避免同一 kk 被多个 token 重复计分）
            matched_key_kws = set()

            for sk in step_keywords:
                if len(sk) < 2:
                    continue

                # 展开同义词变体
                variants = self._expand_synonyms(sk)

                # 强匹配：映射键关键词完整包含在步骤关键词（或其同义词变体）中
                strong_hit = False
                for var in variants:
                    for kk in key_kws:
                        if kk in matched_key_kws:
                            continue  # 同一映射键关键词只计分一次，避免"会议室"被多 token 重复命中
                        if var == kk or kk in var:
                            score += len(kk) * 2.0
                            strong_count += 1
                            matched_key_kws.add(kk)
                            strong_hit = True
                            break
                    if strong_hit:
                        break

                if strong_hit:
                    continue

                # 弱匹配：步骤关键词是映射键关键词的子串
                # 已匹配过强匹配的 kk 不再计入弱匹配（same-key 防重复）
                for kk in key_kws:
                    if kk in matched_key_kws or sk in kk:
                        if kk in matched_key_kws:
                            continue
                        score += 1.0
                        weak_count += 1
                        break

            # 动作-元素类型匹配：输入动作匹配"输入框"算作一次弱匹配，帮助达到 2 弱匹配阈值
            # 解决"输入密码：Ubains@13579"只有"密码"1个弱匹配，无法命中"密码输入框"的问题
            if ("输入" in step_name or "填写" in step_name) and "输入框" in key:
                weak_count += 1
                score += 1.0
            if "勾选" in step_name and "复选" in key:
                weak_count += 1
                score += 1.0

            # P1 修复：编辑/完成按钮 → "创建会议-编辑会议室"（.room .delimg）
            # "点击左侧会议室框的【完成】按钮"只有"会议室"1个弱匹配
            # （"会议室" in "编辑会议室"，反向"编辑会议室" in "会议室"不成立），
            # 且"完成"无法强/弱匹配"编辑会议室"，在此补足 weak_count 才可通过阈值。
            # 编辑与完成共用 .room .delimg（切换参会人配置模式）。
            if ("编辑" in step_name or "完成" in step_name) and "编辑" in key:
                weak_count += 1
                score += 1.0
            # P1 修复（完成按钮独立）：步骤含"完成"时，为含"完成"的映射键补足弱匹配，
            # 使"创建会议-编辑完成按钮"（.room .user_list 完成按钮）可通过阈值，
            # 不再误配"创建会议-编辑会议室"。
            if "完成" in step_name and "完成" in key:
                weak_count += 1
                score += 1.0

            # P1 修复：勾选用户 → "创建会议-参会人勾选"（参会人表格复选框）
            # "勾选右侧的用户：admin@xty12"中"用户"经 SYNONYM_MAP 展开为"参会人"，
            # 但弱匹配循环用原始 sk 无法命中"参会人勾选"（"用户" not in "参会人勾选"），
            # 在此补足阈值；配合后置"用户→参会人"加分规则选中"参会人勾选"。
            # 注意不能依赖"参会人表格"——它无"勾选"关键词，弱匹配数不足会被阈值跳过。
            if "用户" in step_name and "参会人" in key:
                weak_count += 1
                score += 1.0
            if "勾选" in step_name and "勾选" in key:
                weak_count += 1
                score += 1.0

            # 名称输入：步骤含"名称"且为输入动作时，优先带"名称"的输入框
            # 解决"会议名称输入：自动化新建会议"误配"创建会议-新建会议"（值里含"新建会议"）
            if "名称" in step_clean and ("输入" in step_name or "填写" in step_name) and "名称" in key:
                score += 5.0

            # 置信度要求：至少 1 个强匹配，或至少 2 个弱匹配
            if strong_count == 0 and weak_count < 2:
                continue

            # 动作适配：步骤含"分类/筛选"时优先筛选器，含"抽屉"时优先导航栏开关
            if ("分类" in step_clean or "筛选" in step_clean) and "筛选器" in key:
                score += 3.0
            if "搜索" in step_clean and "搜索" in key:
                score += 3.0
            if "抽屉" in step_clean and "抽屉" in key:
                score += 3.0

            # 功能中心复合词优先：步骤含"功能中心"时，优先映射键也包含"功能中心"的条目
            # 解决"点击【功能中心】展开"误配"创建会议-功能"（"功能"作为单字强匹配，但"功能中心"复合词更精确）
            if "功能中心" in step_clean and "功能中心" in key:
                score += 8.0

            # 页面/分类上下文适配：步骤含"登录页"等页面名时，优先同页面元素
            # 解决"登录页密码登录Tab"误配"个人日程-今日-Tab"（Tab 泛匹配）
            for page_prefix in ["登录页", "首页", "导航栏", "功能中心", "会议预约",
                                "创建会议", "会议模板", "个人日程", "会议室", "会议列表"]:
                if page_prefix in step_clean and key.startswith(page_prefix):
                    score += 3.0
                    break

            # 动作-元素类型匹配：输入动作优先"输入框"，勾选动作优先"复选框"
            # 解决"输入登录页密码"误配"登录页-密码登录Tab"（拼音节匹配）
            if ("输入" in step_name or "填写" in step_name) and "输入框" in key:
                score += 3.0
            if "勾选" in step_name and "复选" in key:
                score += 3.0
            if "点击" in step_name and ("按钮" in key or "Tab" in key):
                score += 1.0

            # 完整映射键段出现在步骤中，额外加分（更精确的命中）
            # 注意：'选择' 是动作词，不单独加分
            for kk in key_kws:
                if len(kk) >= 3 and kk != "选择" and kk in step_clean:
                    score += 2.0
                    break

            # 选择时间特殊处理：步骤名含"选择时间"时，优先含"选择时间"关键词的条目
            # 解决"点击选择时间"被动作词剥离后只剩"时间"，误配"会议列表-全部-时间"的问题
            # 期望命中"创建会议-选择时间"（选择器 .time_btn .btns）
            if "选择时间" in step_name and "选择时间" in key:
                score += 8.0

            # 时间切换特殊处理：步骤名含"预定会议"/"立即开始"时，优先同名词精确条目
            # 解决"时间切换为：预定会议"误配"会议列表-全部-时间"（仅"时间"+"会议"两个弱匹配）的问题
            # 期望命中"创建会议-预定会议"/"创建会议-立即开始"（.time_btn 内的两个具体按钮）
            # 注意：不能加给"创建会议-选择时间"（.time_btn .btns 会点到第1个"立即开始"）
            if "预定会议" in step_name and "预定会议" in key:
                score += 20.0
            if "立即开始" in step_name and "立即开始" in key:
                score += 20.0
            # "时间切换为/时间选择为"步骤且条目在创建会议tier的"选择时间"族（预定会议/立即开始/选择时间）
            # 给兜底加成，确保压过"会议列表-全部-时间"等跨页弱匹配
            if ("切换为" in step_name or "选择为" in step_name) and (
                    "预定会议" in key or "立即开始" in key or "选择时间" in key):
                score += 10.0

            # ============ P1 修复：创建会议流程步骤 13-21 映射 ============

            # 会议室列表 tie-break：步骤含"会议室列表"且条目属于"创建会议"tier 时优先
            # 解决"点击会议室列表"三候选平局（首页-置顶功能/全部功能-预定2.0/创建会议），
            # dict 顺序导致命中"首页-置顶功能-会议室列表"（[data-key=meetingRoomList] 导航入口）
            # 期望命中"创建会议-会议室列表"（.el-table 创建会议页右侧会议室表格）
            if "会议室列表" in step_clean and key.startswith("创建会议"):
                score += 3.0

            # 议题输入：步骤含"议题"且为输入动作时，优先含"议题"的输入框条目
            # 解决"会议议题输入：自动化新建会议的议题"误配"创建会议-新建会议"（值含"新建会议"）
            # 期望命中"创建会议-议题输入框"（.topic_item input）
            if "议题" in step_clean and ("输入" in step_name or "填写" in step_name) and "议题" in key:
                score += 8.0

            # 编辑按钮：步骤含"编辑"时，优先"创建会议-编辑会议室"（.room .delimg img 编辑图标）
            # 解决"点击左侧会议室框的编辑按钮"误配"会议室-区域树"（仅"会议室"强匹配）
            # 注意排除含"完成"的键，避免与"创建会议-编辑完成按钮"竞争
            if "编辑" in step_clean and "编辑" in key and "完成" not in key:
                score += 12.0
            # 完成按钮（P1 独立）：步骤含"完成"时，优先"创建会议-编辑完成按钮"
            # 解决"点击左侧会议室框的【完成】按钮"误配"创建会议-编辑会议室"（.room .delimg）
            # 完成按钮 = .room .user_list button:has-text("完成")，保存参会人配置
            if "完成" in step_clean and "完成" in key:
                score += 16.0

            # 勾选用户：步骤含"用户"（同义词含"参会人"）且条目含"参会人"时优先
            # 解决"勾选右侧的用户：admin@xty12"无候选（SYNONYM_MAP 提供 用户↔参会人）
            # 期望命中"创建会议-参会人勾选"（.el-table 参会人表格复选框）
            if "用户" in step_clean and "参会人" in key:
                score += 10.0

            # 查看详情：步骤含"查看详情"时，优先"会议创建成功-查看详情"
            # 解决"点击弹窗的【查看详情】按钮"误配"会议详情-操作按钮"（.buttom_btn 泛匹配）
            # 期望命中创建成功消息框中的查看详情按钮（.el-message-box）
            if "查看详情" in step_clean and "查看详情" in key:
                score += 10.0

            # 取消会议：步骤含"取消会议"时，优先"会议详情-取消会议"
            # 解决"点击底部的【取消会议】按钮"误配"创建会议-人员配置按钮"（.personnelConfigure 泛匹配）
            # 期望命中详情页底部取消会议按钮（.buttom_btn 内 el-button--info）
            if "取消会议" in step_clean and "取消会议" in key:
                score += 10.0

            # 二次确认：步骤含"二次确认"时，优先含"确认"的确认弹窗条目
            # 解决"点击二次确认弹窗的【确定】按钮"误配"筛选-确定"（.RoomFilter 弹窗确定按钮）
            # 期望命中取消会议二次确认弹窗的确定按钮（.el-dialog footer 主按钮）
            if "二次确认" in step_clean and "确认" in key:
                score += 8.0

            if score > best_score:
                best_score = score
                best_key = key

        return best_key if best_key else None

    def _fuzzy_match(self, step_name: str) -> Optional[tuple]:
        """
        模糊匹配：jieba 分词后计算相似度，取最高分（阈值 >= 0.7）

        Returns:
            (映射键, 相似度) 或 None
        """
        step_clean = self._clean_step_name(step_name)
        step_keywords = self._tokenize(step_clean)
        if not step_keywords:
            return None

        best_key = None
        best_score = FUZZY_MATCH_THRESHOLD

        for key, info in self._elements.items():
            key_kws = self._split_keywords(key)
            # 计算步骤关键词与映射键关键词的最大匹配度
            score = 0.0
            for sk in step_keywords:
                if len(sk) < 2:
                    continue
                for kk in key_kws:
                    sim = SequenceMatcher(None, sk, kk).ratio()
                    # 包含关系也算高分
                    if sk in kk or kk in sk:
                        sim = max(sim, 0.85)
                    score = max(score, sim)

            # 结合 description 匹配
            desc = info.get("description", "")
            for sk in step_keywords:
                if len(sk) >= 2 and sk in desc:
                    score = max(score, 0.9)

            if score >= best_score:
                best_score = score
                best_key = key

        if best_key:
            return best_key, best_score
        return None

    def _context_match(self, step_name: str, context: Dict) -> Optional[str]:
        """
        上下文辅助匹配：结合当前页面 URL、前序步骤判断所在功能分类

        通过在映射键中加入当前上下文分类过滤，缩小匹配范围。
        """
        category = context.get("category") or context.get("current_module")
        if not category:
            return None

        step_clean = self._clean_step_name(step_name)
        step_keywords = self._tokenize(step_clean)

        # 在指定分类下匹配
        prefix = f"全部功能-{category}-"
        for kk in self._elements:
            if not kk.startswith(prefix):
                continue
            key_kws = self._split_keywords(kk)
            for sk in step_keywords:
                if len(sk) < 2:
                    continue
                for kkw in key_kws:
                    if sk in kkw or kkw in sk:
                        return kk
        return None

    # ==================== 辅助方法 ====================

    def _build_result(self, key: str, confidence: float) -> Dict[str, Any]:
        """构建匹配结果"""
        info = self._elements.get(key, {})
        return {
            "selector": info.get("selector", key),
            "type": info.get("type", "css"),
            "confidence": confidence,
            "key": key,
            "description": info.get("description", ""),
            "tier": info.get("tier", ""),
            "priority": MAPPING_PRIORITY,
        }

    @staticmethod
    def _clean_step_name(step_name: str) -> str:
        """清理步骤描述：去除动作词、标点、元素类型词"""
        # 去除【】内容保留（如"点击【新建会议】" -> "新建会议"）
        cleaned = step_name
        # "时间切换为：预定会议" / "时间选择为：立即开始" 规范化为"选择时间-预定会议"等
        # 否则"时间切换为"被 jieba 分词为"时间"+"切换为"，"切换为"无意义，只剩"时间"弱匹配
        # 导致"时间切换为：预定会议"误配"会议列表-全部-时间"（仅"时间"+"会议"两个弱匹配）
        # 使用 __SELECT_TIME__ 占位符保护，避免后续"选择"被动作词剥离
        cleaned = re.sub(r"时间(切换为|选择为)[：:]\s*", "__SELECT_TIME__-", cleaned)
        # "选择会议室"/"勾选会议室" 规范化为 "会议室选择"
        # 否则动作词"选择/勾选"被下方 ^ 前缀正则剥离后只剩"会议室"，丢失语义
        # （"会议室选择"是创建会议页选房间的动作，不能坍缩成"会议室"去匹配区域树）
        cleaned = re.sub(r"(选择|勾选)会议室", "会议室选择", cleaned)
        # "选择房间"/"勾选房间" 规范化为 "房间勾选"
        # 否则动作词"选择/勾选"被下方 ^ 前缀正则剥离后只剩"房间"（单 token 不达匹配阈值）
        # （"房间勾选"对应会议室列表页房间卡片的勾选复选框：会议室-房间勾选）
        cleaned = re.sub(r"(选择|勾选)房间", "房间勾选", cleaned)
        # "选择开始时间"/"选择结束时间" 规范化为"选择时间"并按占位符保护
        # 否则"选择"被剥离后只剩"开始时间"/"时间"，token"时间"弱匹配"选择时间"仅1次
        # 达不到 2 弱匹配阈值，或强匹配"开始时间"误配"会议列表-全部-时间"
        cleaned = re.sub(r"选择(开始|结束)?时间", "__SELECT_TIME__", cleaned)
        # 去除常见动作前缀
        # 注意："验证"不能作为动作词去除，否则会破坏"验证码"（如"输入验证码：csba"）
        for action_word in ["点击", "单击", "双击", "输入", "填写", "填入", "选择",
                            "打开", "展开", "收起", "导航到", "进入", "按下", "勾选",
                            "取消", "悬停", "滚动", "等待", "检查", "查看"]:
            cleaned = re.sub(rf"^{action_word}", "", cleaned)
        # 去除元素类型词
        for elem_word in ["按钮", "输入框", "链接", "图标", "菜单", "标签", "选项卡",
                          "下拉框", "复选框", "单选框", "抽屉", "导航", "选项"]:
            cleaned = cleaned.replace(elem_word, "")
        # 去除括号和引号
        cleaned = re.sub(r"[【】\[\]（）()\"']", "", cleaned)
        # 恢复"选择时间"占位符（__SELECT_TIME__-预定会议 → 选择时间-预定会议）
        cleaned = cleaned.replace("__SELECT_TIME__", "选择时间")
        return cleaned.strip()

    @staticmethod
    def _clean_step_name_without_elem(step_name: str) -> str:
        """清理步骤描述：仅去除动作词和标点，保留元素类型词用于匹配"""
        cleaned = step_name
        # "时间切换为：预定会议" / "时间选择为：立即开始" 规范化为"选择时间-预定会议"等
        # 与 _clean_step_name 保持同步（两个清理函数必须同规则，否则 token 不一致导致匹配漂移）
        # 使用 __SELECT_TIME__ 占位符保护（含义：选择时间）
        cleaned = re.sub(r"时间(切换为|选择为)[：:]\s*", "__SELECT_TIME__-", cleaned)
        # "选择会议室"/"勾选会议室" 规范化为 "会议室选择"
        # 与 _clean_step_name 保持同步，避免动作词剥离后"会议室"变成孤立 token
        cleaned = re.sub(r"(选择|勾选)会议室", "会议室选择", cleaned)
        # "选择房间"/"勾选房间" 规范化为 "房间勾选"（与 _clean_step_name 同步）
        cleaned = re.sub(r"(选择|勾选)房间", "房间勾选", cleaned)
        # "选择开始时间"/"选择结束时间" 规范化为"选择时间"并按占位符保护（与 _clean_step_name 同步）
        cleaned = re.sub(r"选择(开始|结束)?时间", "__SELECT_TIME__", cleaned)
        for action_word in ["点击", "单击", "双击", "输入", "填写", "填入", "选择",
                            "打开", "展开", "收起", "导航到", "进入", "按下", "勾选",
                            "取消", "悬停", "滚动", "等待", "检查", "查看"]:
            cleaned = re.sub(rf"^{action_word}", "", cleaned)
        cleaned = re.sub(r"[【】\[\]（）()\"']", "", cleaned)
        # 恢复"选择时间"占位符
        cleaned = cleaned.replace("__SELECT_TIME__", "选择时间")
        return cleaned.strip()

    @staticmethod
    def _tokenize(text: str) -> List[str]:
        """jieba 分词 + 提取关键段"""
        if not text:
            return []
        # 优先按 - 拆分（映射键风格）
        parts = [p for p in re.split(r"[-—\s]", text) if p]
        tokens = []
        for p in parts:
            tokens.append(p)
            # 对中文段做 jieba 分词
            for seg in jieba.lcut(p):
                seg = seg.strip()
                if len(seg) >= 2 and seg not in tokens:
                    tokens.append(seg)
        # 去重
        seen = set()
        result = []
        for t in tokens:
            if t not in seen:
                seen.add(t)
                result.append(t)
        return result


# 全局单例（懒加载）
_global_service: Optional[ElementMappingService] = None


def get_element_mapping_service() -> ElementMappingService:
    """获取全局 ElementMappingService 单例"""
    global _global_service
    if _global_service is None:
        _global_service = ElementMappingService()
    return _global_service