#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：keyword_matcher.py
模块描述：关键词提取与元素匹配模块，用于自然语言步骤定位

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

Phase 1 更新：
- 引入jieba分词替代暴力替换
- 精简修饰词列表
- extract_value正则增强支持中文值
"""

import logging
import re
import jieba
import jieba.posseg as pseg
from typing import List, Tuple, Optional, Dict, Any

logger = logging.getLogger(__name__)

# jieba分词初始化（首次调用时自动加载，后续缓存）
_jieba_initialized = False


# ==================== 动作词表 ====================

ACTION_WORDS = [
    # 输入类
    "输入", "填写", "填入", "键入", "写入", "填写", "输入",
    # 点击类
    "点击", "按下", "单击", "双击", "选择",
    # 导航类
    "打开", "导航", "访问", "进入", "跳转", "前往",
    # 等待类
    "等待", "延时", "等待",
    # 验证类
    "断言", "验证", "检查", "确认",
    # 其他操作
    "勾选", "取消", "悬停", "滚动", "截图"
]

# 元素类型词表
ELEMENT_WORDS = [
    "按钮", "输入框", "下拉框", "复选框", "单选框",
    "链接", "菜单", "图标", "标签", "表格", "列表",
    "卡片", "对话框", "抽屉", "弹窗", "导航"
]


# ==================== 关键词提取 ====================

def _init_jieba():
    """初始化jieba分词（懒加载）"""
    global _jieba_initialized
    if not _jieba_initialized:
        # 添加项目特定词汇到词典（提高分词准确度）
        # 使用 suggest_freq 确保词汇被正确识别
        project_words = [
            # 菜单名称
            '功能中心', '系统设置', '信息发布', '通知公告',
            '会议预约', '会议列表', '新建会议', '会议室管理',
            '壁纸推送', '脚本命令', '集控控制',
            '工单列表', '我的工单', '告警工单',
            '资产信息', '资产设备', '资产管理',
            '运维设备', '运维巡检', '会议运维',
            '会务统筹', '会务工单', '会务管理',
            '信息窗管理', '消息通知', '信息管理',
            '数据统计', '数据分析', '管理看板',
            # 按钮名称
            '新建', '确定', '取消', '保存', '删除', '编辑', '查询', '重置',
            '展开', '收起', '上传', '下载', '导出', '导入',
            # 其他常见词汇
            '用户名', '密码', '账号', '备注', '会议名称',
        ]

        for word in project_words:
            jieba.add_word(word, freq=1000)  # 高频确保被识别

        _jieba_initialized = True
        logger.debug(f"jieba分词初始化完成，已添加 {len(project_words)} 个项目词汇")


def extract_keywords(description: str) -> List[str]:
    """
    从自然语言描述中提取关键词（jieba分词版本）

    策略：
    1. 提取【】内完整词组（最高优先级）
    2. jieba分词，按词性过滤保留名词、动词
    3. 去除动作词和元素类型词
    4. 过滤单字和无意义词汇

    Args:
        description (str): 步骤描述，如 "输入用户名 admin@xty"

    Returns:
        List[str]: 关键词列表，如 ["用户名", "admin@xty"]

    Example:
        >>> extract_keywords("点击【新建会议】按钮")
        ["新建会议"]
        >>> extract_keywords("点击展开")
        ["展开"]
        >>> extract_keywords("输入会议名称：自动化新建会议")
        ["会议名称", "自动化", "新建会议"]
    """
    _init_jieba()

    # 项目特定词汇集合（这些词汇应该被保留）
    project_vocab = {
        # 菜单名称
        '功能中心', '系统设置', '信息发布', '通知公告',
        '会议预约', '会议列表', '新建会议', '会议室管理',
        '壁纸推送', '脚本命令', '集控控制',
        '工单列表', '我的工单', '告警工单',
        '资产信息', '资产设备', '资产管理',
        '运维设备', '运维巡检', '会议运维',
        '会务统筹', '会务工单', '会务管理',
        '信息窗管理', '消息通知', '信息管理',
        '数据统计', '数据分析', '管理看板',
        # 常见按钮名称
        '新建', '确定', '取消', '保存', '删除', '编辑', '查询', '重置',
        '展开', '收起', '上传', '下载', '导出', '导入',
        # 其他
        '用户名', '密码', '账号', '备注', '会议名称',
    }

    # 1. 提取【】内完整词组（最高优先级）
    bracket_keywords = re.findall(r'【([^】]+)】', description)

    # 2. jieba分词
    words = pseg.cut(description)

    # 3. 按词性过滤：保留名词(n/nr/ns/nz)、动词
    # 词性标记说明：
    # n: 普通名词, nr: 人名, ns: 地名, nz: 其他专名
    # vn: 名动词, v: 动词, eng: 英文
    valid_pos_tags = {'n', 'nr', 'ns', 'nz', 'vn', 'v', 'eng', 'l'}  # l: 成语

    # 单字词汇白名单（这些单字应该保留）
    single_char_whitelist = {
        '确定', '取消', '保存', '删除', '编辑', '查询', '新建',
        '展开', '收起', '上传', '下载', '导出', '导入',
    }

    filtered_words = []
    for word, flag in words:
        word = word.strip()
        if not word:
            continue

        # 项目特定词汇，直接保留
        if word in project_vocab:
            filtered_words.append(word)
            continue

        # 英文（可能是ID、变量名等）
        if flag == 'eng':
            if len(word) >= 2 and word not in ACTION_WORDS and word not in ELEMENT_WORDS:
                filtered_words.append(word)
            continue

        # 中文词汇过滤
        if flag in valid_pos_tags:
            # 长度>=2 或 在单字白名单中
            if len(word) >= 2:
                # 不在动作词/元素词表中
                if word not in ACTION_WORDS and word not in ELEMENT_WORDS:
                    # 不是纯数字
                    if not word.isdigit():
                        filtered_words.append(word)
            elif word in single_char_whitelist:
                filtered_words.append(word)

    # 4. 精简修饰词移除（仅保留真正的无意义修饰词）
    minimal_modifiers = {'的', '了', '着', '过', '一下', '是否', '是否成功', '正确'}
    filtered_words = [w for w in filtered_words if w not in minimal_modifiers]

    # 5. 合并结果：【】内词组优先
    result = []
    seen = set()

    # 先添加【】内的核心关键词
    for kw in bracket_keywords:
        if kw not in seen:
            seen.add(kw)
            result.append(kw)

    # 再添加jieba分词结果
    for kw in filtered_words:
        if kw not in seen:
            seen.add(kw)
            result.append(kw)

    logger.debug(f"关键词提取: '{description}' -> {result}")
    return result


def extract_value_from_description(description: str) -> Optional[str]:
    """
    从步骤描述中提取操作值

    例如："输入用户名 admin@xty" -> "admin@xty"
    例如："输入账号：admin@xty" -> "admin@xty"
    例如："输入密码：Ubains@13579" -> "Ubains@13579"
    例如："输入参考价值：100" -> "100"
    例如："输入备注：这是一个测试备注" -> "这是一个测试备注"

    Args:
        description (str): 步骤描述

    Returns:
        Optional[str]: 提取的值，未找到返回 None
    """
    # 匹配模式1：动作 + 目标 + 冒号 + 值（支持中文值、空格、特殊字符）
    # 改进：将 [\w@.\-]+ 改为 .+? 以支持中文值
    # 例如："输入备注：这是一个测试备注" -> "这是一个测试备注"
    match = re.search(r'(?:输入|填写|填入|键入|写入)\s*[^：:]*[：:]\s*(.+?)\s*$', description)
    if match:
        value = match.group(1).strip()
        # 过滤掉明显不是值的内容（如"按钮"、"输入框"等元素类型词）
        if value and not any(word in value for word in ELEMENT_WORDS):
            return value

    # 匹配模式2：动作 + 目标 + 空格 + 值（英文字母数字）
    # 例如："输入用户名 admin@xty" -> "admin@xty"
    match = re.search(r'(?:输入|填写|填入|键入|写入)\s*.*?\s+([\w@.\-]+)\s*$', description)
    if match:
        return match.group(1)

    # 匹配模式3：值在引号中
    match = re.search(r'["\']([^"\']+)["\']', description)
    if match:
        return match.group(1)

    return None


def detect_action_type(description: str) -> str:
    """
    根据描述推断动作类型

    Args:
        description (str): 步骤描述

    Returns:
        str: 动作类型 (navigate/click/fill/wait/select/assert)
    """
    desc_lower = description.lower()

    # 导航
    if any(kw in desc_lower for kw in ['访问', '打开', '导航', '进入', '跳转', '前往']):
        return 'navigate'

    # 输入
    if any(kw in desc_lower for kw in ['输入', '填写', '填入', '键入', '写入']):
        return 'fill'

    # 选择
    if any(kw in desc_lower for kw in ['选择', '下拉']):
        return 'select'

    # 等待
    if any(kw in desc_lower for kw in ['等待', '延时']):
        return 'wait'

    # 验证（优先级提高）
    if any(kw in desc_lower for kw in ['断言', '验证', '检查', '确认', '查看', '是否', '是否成功']):
        return 'assert'

    # 默认为点击
    return 'click'


def is_verification_step(description: str) -> bool:
    """
    判断是否为验证类步骤（需要检查操作结果提示）

    验证类步骤特征：
    - 包含"查看"、"检查"、"验证"等关键词
    - 包含"是否成功"、"是否正确"等疑问句式
    - 包含"新增成功"、"添加成功"等期望结果

    Args:
        description (str): 步骤描述

    Returns:
        bool: 是否为验证类步骤
    """
    verification_keywords = [
        '查看', '检查', '验证', '确认', '判断',
        '是否成功', '是否正确', '是否新增',
        '新增成功', '添加成功', '保存成功', '删除成功',
        '操作成功', '提交成功', '创建成功',
        '列表是否', '数据是否'
    ]

    for kw in verification_keywords:
        if kw in description:
            return True

    return False


# ==================== 元素匹配 ====================

# ==================== 评分体系常量（Phase 2）====================

# 精确匹配 vs 包含匹配
EXACT_MATCH_SCORE = 1.0       # 精确匹配（完全相等）
CONTAINS_MATCH_SCORE = 0.3    # 包含匹配（子串包含）

# 维度权重（总和为1.0）
DIMENSION_WEIGHTS = {
    'data-testid': 0.20,
    'id': 0.18,
    'name': 0.12,
    'placeholder': 0.15,
    'aria-label': 0.12,
    'text': 0.15,
    'action_type': 0.08,  # 动作类型适配
}

def _calculate_match_score(keyword: str, target: str) -> float:
    """
    计算关键词与目标文本的匹配分数

    Args:
        keyword: 关键词
        target: 目标文本（如ID、placeholder、text等）

    Returns:
        float: 匹配分数 (0.0 ~ 1.0)
    """
    if not keyword or not target:
        return 0.0

    keyword_lower = keyword.lower()
    target_lower = target.lower()

    # 精确匹配（完全相等）
    if keyword_lower == target_lower:
        return EXACT_MATCH_SCORE

    # 包含匹配（关键词是目标的子串）
    if keyword_lower in target_lower:
        # 根据匹配长度比例调整分数
        ratio = len(keyword) / len(target)
        return CONTAINS_MATCH_SCORE * min(1.0, ratio * 2)  # 长度比例越高，分数越高

    return 0.0


def _apply_action_type_adjustment(score: float, tag: str, action: str, keywords: List[str]) -> float:
    """
    根据动作类型调整分数

    Args:
        score: 原始分数
        tag: 元素标签名
        action: 动作类型
        keywords: 关键词列表

    Returns:
        float: 调整后的分数
    """
    tag = tag.upper()

    if action == 'click':
        # click动作对INPUT/TEXTAREA大幅减分
        if tag in ['INPUT', 'TEXTAREA']:
            input_keywords = ['输入框', '输入', '框', '填写', 'input']
            if not any(kw in input_keywords for kw in keywords):
                score *= 0.1  # 更激进（原0.3）

        # click动作对BUTTON/A/DIV/SPAN/LI/I加分
        elif tag in ['BUTTON', 'A', 'DIV', 'SPAN', 'LI', 'I']:
            score += 0.15

    elif action == 'fill':
        # fill动作对INPUT/TEXTAREA加分
        if tag in ['INPUT', 'TEXTAREA']:
            score += 0.15

        # fill动作对非输入元素减分
        elif tag in ['BUTTON', 'A', 'DIV', 'SPAN']:
            fill_keywords = ['按钮', '点击', 'click']
            if not any(kw in fill_keywords for kw in keywords):
                score *= 0.3

    elif action == 'select':
        if tag == 'SELECT':
            score += 0.15

    # 确保分数在[0, 1.5]范围内（允许略微超过1.0以体现动作适配优势）
    return min(max(score, 0.0), 1.5)


def _detect_parent_container(element) -> Dict[str, Any]:
    """
    检测元素的父容器信息，用于生成组合选择器

    通过 JavaScript 向上遍历 DOM 查找父容器：
    - 弹窗(.el-dialog)
    - 抽屉(.el-drawer)
    - 表单(form)
    - 表格(.el-table)
    - Tab(.el-tabs)

    Args:
        element: Playwright Locator 元素对象

    Returns:
        Dict: 父容器信息，如 {"type": "dialog", "selector": ".el-dialog", "title": "新建会议"}
    """
    container_info = {}
    try:
        js_code = """
        (el) => {
            let parent = el.parentElement;
            let depth = 0;
            while (parent && depth < 8) {
                const cls = (parent.className || '') + ' ' + (parent.classList ? Array.from(parent.classList).join(' ') : '');
                const tag = parent.tagName || '';
                // 检测弹窗
                if (cls.includes('el-dialog') || cls.includes('el-dialog__wrapper')) {
                    const titleEl = parent.querySelector('.el-dialog__title');
                    return { type: 'dialog', selector: '.el-dialog', title: titleEl ? titleEl.innerText.trim() : '' };
                }
                // 检测抽屉
                if (cls.includes('el-drawer')) {
                    const headerEl = parent.querySelector('.el-drawer__header');
                    return { type: 'drawer', selector: '.el-drawer', title: headerEl ? headerEl.innerText.trim() : '' };
                }
                // 检测消息弹窗
                if (cls.includes('el-message-box') || cls.includes('el-message-box__wrapper')) {
                    return { type: 'message-box', selector: '.el-message-box', title: '' };
                }
                // 检测表单
                if (tag === 'FORM') {
                    const formId = parent.id ? '#' + parent.id : 'form';
                    return { type: 'form', selector: formId };
                }
                // 检测表格行
                if (cls.includes('el-table__row') || cls.includes('el-table__body') || cls.includes('el-table')) {
                    return { type: 'table', selector: '.el-table' };
                }
                // 检测 Tab 容器
                if (cls.includes('el-tabs') || cls.includes('el-tab-pane')) {
                    return { type: 'tab', selector: '.el-tabs' };
                }
                parent = parent.parentElement;
                depth++;
            }
            return {};
        }
        """
        container_info = element.evaluate(js_code) or {}
    except Exception as e:
        logger.debug(f"检测父容器失败: {e}")
    return container_info


def match_element_by_keywords(
    page,
    keywords: List[str],
    action: str,
    timeout: int = 5000,
    max_candidates: int = 10
) -> Tuple[Optional[Any], List[Dict[str, Any]]]:
    """
    通过关键词在页面元素中直接匹配（Phase 2 评分归一化版本）

    评分策略：
    1. 每个维度计算精确匹配/包含匹配分数
    2. 按维度权重加权求和
    3. 归一化到[0, 1]区间
    4. 动作类型适配调整

    Args:
        page: Playwright Page 对象
        keywords (List[str]): 关键词列表
        action (str): 动作类型
        timeout (int): 超时时间（毫秒）
        max_candidates (int): 最大返回候选数量

    Returns:
        Tuple[Optional[Any], List[Dict]]: (元素对象列表, 候选选择器列表)
            元素对象列表按置信度降序排列（最多 max_candidates 个）
            候选选择器列表按置信度降序排列
    """
    candidates = []

    # 获取所有可交互元素（包含主页面 + iframe/微前端）
    # Phase 6: 记录元素的 frame 来源
    element_frame_map = {}  # element -> frame_path

    try:
        # 扩展元素查询范围，包含 Element Plus 组件和常见可点击元素
        base_selector = (
            'input:visible, button:visible, a:visible, select:visible, textarea:visible, '
            # 语义化 role 元素
            '[role="button"]:visible, [role="link"]:visible, [role="checkbox"]:visible, '
            '[role="radio"]:visible, [role="tab"]:visible, [role="menuitem"]:visible, '
            # Element Plus 组件
            '.el-button:visible, .el-checkbox:visible, .el-tabs__item:visible, '
            # 常见可点击元素（图标、自定义按钮）
            'i[onclick]:visible, div[onclick]:visible, span[onclick]:visible, '
            'div[class*="btn"]:visible, span[class*="btn"]:visible, '
            # ⚠️ data-key 属性元素：抽屉/列表菜单项（如 item_content_item）常带 data-key 但无
            # btn/block 等 class，不加入则关键字定位永远匹配不到（如 新建会议 → reserve_list.create）
            '[data-key]:visible, '
            # 微前端中的常见可点击元素
            '.block:visible, p:visible'
        )

        elements = page.locator(base_selector).all()
        total_elements = len(elements)

        # 在所有 iframe 中查找元素（Phase 6: 记录 frame 路径）
        for frame in page.frames:
            if frame == page.main_frame:
                continue
            try:
                # 生成 frame 选择器
                frame_name = frame.name if frame.name else ""
                frame_url = frame.url if hasattr(frame, 'url') else ""

                # 优先使用 name，其次使用 src 匹配
                if frame_name:
                    frame_path = f'iframe[name="{frame_name}"]'
                elif frame_url:
                    # 提取 URL 关键部分
                    url_part = frame_url.split('//')[-1].split('/')[0] if '//' in frame_url else frame_url[:30]
                    frame_path = f'iframe[src*="{url_part}"]'
                else:
                    frame_path = 'iframe'

                frame_elements = frame.locator(base_selector).all()
                for fe in frame_elements:
                    elements.append(fe)
                    element_frame_map[id(fe)] = frame_path

                if frame_elements:
                    logger.debug(f"在 iframe ({frame_path}) 中找到 {len(frame_elements)} 个元素")
            except Exception as e:
                logger.debug(f"iframe 元素查询失败（忽略）: {e}")

    except Exception as e:
        logger.warning(f"获取页面元素失败: {e}")
        return None, []

    logger.info(f"页面找到 {len(elements)} 个可交互元素（主页面 {total_elements} + iframe {len(elements) - total_elements}），关键词: {keywords}")

    for el in elements:
        try:
            # 提取元素属性
            tag = el.evaluate('el => el.tagName')
            el_type = el.get_attribute('type') or ''
            placeholder = el.get_attribute('placeholder') or ''
            # 扩展文本提取范围，支持 div/span 等可点击元素
            text_extractable_tags = ['BUTTON', 'A', 'DIV', 'SPAN', 'I', 'LABEL', 'LI']
            text = el.inner_text().strip() if tag in text_extractable_tags else ''
            el_id = el.get_attribute('id') or ''
            name = el.get_attribute('name') or ''
            aria_label = el.get_attribute('aria-label') or ''
            data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or ''
            el_data_key = el.get_attribute('data-key') or ''
            class_name = el.get_attribute('class') or ''

            # ==================== Phase 2: 归一化评分 ====================
            score_details = {}  # 各维度得分详情
            selectors = []      # 选择器列表

            # 对每个关键词计算各维度得分
            for kw in keywords:
                # 1. data-testid 匹配（最高优先级）
                if data_testid:
                    match_score = _calculate_match_score(kw, data_testid)
                    if match_score > 0:
                        dim_score = match_score * DIMENSION_WEIGHTS['data-testid']
                        score_details['data-testid'] = max(score_details.get('data-testid', 0), dim_score)
                        if match_score == EXACT_MATCH_SCORE:
                            selectors.append({
                                'type': 'css',
                                'value': f'[data-testid="{data_testid}"]',
                                'confidence': 0.95,
                                'priority': 1,
                                'match_type': 'exact'
                            })

                # 2. ID 匹配
                if el_id:
                    match_score = _calculate_match_score(kw, el_id)
                    if match_score > 0:
                        dim_score = match_score * DIMENSION_WEIGHTS['id']
                        score_details['id'] = max(score_details.get('id', 0), dim_score)
                        if match_score == EXACT_MATCH_SCORE:
                            selectors.append({
                                'type': 'css',
                                'value': f'#{el_id}',
                                'confidence': 0.90,
                                'priority': 2,
                                'match_type': 'exact'
                            })
                        elif not any(s['value'] == f'#{el_id}' for s in selectors):
                            selectors.append({
                                'type': 'css',
                                'value': f'#{el_id}',
                                'confidence': 0.60,
                                'priority': 4,
                                'match_type': 'contains'
                            })

                # 3. name 匹配
                if name:
                    match_score = _calculate_match_score(kw, name)
                    if match_score > 0:
                        dim_score = match_score * DIMENSION_WEIGHTS['name']
                        score_details['name'] = max(score_details.get('name', 0), dim_score)
                        if match_score == EXACT_MATCH_SCORE:
                            selectors.append({
                                'type': 'css',
                                'value': f'[name="{name}"]',
                                'confidence': 0.85,
                                'priority': 5,
                                'match_type': 'exact'
                            })

                # 4. placeholder 匹配（输入框）
                if placeholder and tag == 'INPUT':
                    match_score = _calculate_match_score(kw, placeholder)
                    if match_score > 0:
                        dim_score = match_score * DIMENSION_WEIGHTS['placeholder']
                        score_details['placeholder'] = max(score_details.get('placeholder', 0), dim_score)
                        if match_score >= CONTAINS_MATCH_SCORE:
                            selectors.append({
                                'type': 'css',
                                'value': f'input[placeholder*="{placeholder}"]',
                                'confidence': 0.80 if match_score == EXACT_MATCH_SCORE else 0.60,
                                'priority': 3,
                                'match_type': 'exact' if match_score == EXACT_MATCH_SCORE else 'contains'
                            })

                # 5. aria-label 匹配
                if aria_label:
                    match_score = _calculate_match_score(kw, aria_label)
                    if match_score > 0:
                        dim_score = match_score * DIMENSION_WEIGHTS['aria-label']
                        score_details['aria-label'] = max(score_details.get('aria-label', 0), dim_score)
                        if match_score >= CONTAINS_MATCH_SCORE:
                            selectors.append({
                                'type': 'css',
                                'value': f'[aria-label*="{aria_label}"]',
                                'confidence': 0.80 if match_score == EXACT_MATCH_SCORE else 0.55,
                                'priority': 4,
                                'match_type': 'exact' if match_score == EXACT_MATCH_SCORE else 'contains'
                            })

                # 6. 文本匹配（按钮、链接、可点击的 div/span）
                text_matchable_tags = ['BUTTON', 'A', 'DIV', 'SPAN', 'I', 'LABEL', 'LI']
                if text and tag in text_matchable_tags:
                    match_score = _calculate_match_score(kw, text)
                    if match_score > 0:
                        dim_score = match_score * DIMENSION_WEIGHTS['text']
                        score_details['text'] = max(score_details.get('text', 0), dim_score)
                        # 根据元素类型和动作类型调整选择器
                        if tag in ['BUTTON', 'A']:
                            selector_value = f'{tag.lower()}:has-text("{text}")'
                        elif action == 'click' and tag in ['DIV', 'SPAN', 'I']:
                            # 点击动作下，对非按钮标签降级处理
                            selector_value = f'{tag.lower()}:visible:has-text("{text}")'
                            # 同时生成 button 替代选择器（优先级更高）
                            selectors.append({
                                'type': 'css',
                                'value': f'button:has-text("{text}")',
                                'confidence': 0.70,
                                'priority': 3,
                                'match_type': 'contains'
                            })
                        else:
                            selector_value = f'{tag.lower()}:visible:has-text("{text}")'
                        selectors.append({
                            'type': 'css',
                            'value': selector_value,
                            'confidence': 0.85 if match_score == EXACT_MATCH_SCORE else 0.65,
                            'priority': 5,
                            'match_type': 'exact' if match_score == EXACT_MATCH_SCORE else 'contains'
                        })

            # 如果没有任何匹配，跳过该元素
            if not score_details:
                continue

            # 计算归一化分数
            matched_weights = sum(DIMENSION_WEIGHTS[k] for k in score_details.keys() if k != 'action_type')
            if matched_weights > 0:
                base_score = sum(score_details.values()) / matched_weights
            else:
                base_score = 0.0

            # 动作类型适配
            final_score = _apply_action_type_adjustment(base_score, tag, action, keywords)

            # 记录动作类型调整分数
            if final_score != base_score:
                score_details['action_type'] = final_score - base_score

            # 只保留有效候选
            if final_score > 0.1 and selectors:
                # Phase 6: iframe 内元素选择器添加 frame 路径前缀
                frame_path = element_frame_map.get(id(el))
                if frame_path:
                    for sel in selectors:
                        if not sel['value'].startswith('iframe'):
                            sel['value'] = f"{frame_path} >> {sel['value']}"
                            sel['frame_path'] = frame_path

                # 缺陷4修复：检测父容器并生成组合选择器
                try:
                    container_info = _detect_parent_container(el)
                    if container_info:
                        parent_selector = container_info.get('selector', '')
                        container_type = container_info.get('type', '')
                        # ⚠️ data-key 属性组合选择器（最高优先级）：
                        # 文本选择器可能命中外层容器导致"点击无效果"（如首页快捷 block 被遮挡，
                        # div:visible:has-text 匹配到 main_item 容器而非可点的 item_content_item）。
                        # 元素有稳定 data-key 且位于容器内时，容器限定的属性选择器最精确
                        # （实测 .el-drawer [data-key="reserve_list.create"] 点击后进入 CreateMeeting 页面）。
                        if el_data_key and parent_selector:
                            attr_sel = f'{parent_selector} [data-key="{el_data_key}"]'
                            if not any(s['value'] == attr_sel for s in selectors):
                                selectors.insert(0, {
                                    'type': 'css',
                                    'value': attr_sel,
                                    'confidence': 0.92,
                                    'priority': 1,
                                    'match_type': 'attribute'
                                })
                        if parent_selector:
                            for sel in list(selectors):  # 使用 list() 复制，避免迭代中修改
                                sel_value = sel.get('value', '')
                                # 避免重复添加父容器前缀
                                if not sel_value.startswith(parent_selector) and not sel_value.startswith('iframe'):
                                    combined_sel = f"{parent_selector} {sel_value}"
                                    selectors.append({
                                        'type': 'css',
                                        'value': combined_sel,
                                        'confidence': min(sel.get('confidence', 0.5) + 0.05, 1.0),
                                        'priority': 3,  # 组合选择器优先级 3
                                        'match_type': sel.get('match_type', 'contains'),
                                        'container': container_type
                                    })
                except Exception as e:
                    logger.debug(f"生成组合选择器失败: {e}")

                # 按优先级排序选择器
                selectors.sort(key=lambda x: (x['priority'], -x['confidence']))
                # 去重选择器
                seen_selectors = set()
                unique_selectors = []
                for sel in selectors:
                    if sel['value'] not in seen_selectors:
                        seen_selectors.add(sel['value'])
                        unique_selectors.append(sel)

                candidates.append({
                    'element': el,
                    'score': final_score,
                    'score_details': score_details,
                    'selectors': unique_selectors,
                    'frame_path': frame_path,  # Phase 6: 记录 frame 路径
                    'info': {
                        'tag': tag,
                        'type': el_type,
                        'placeholder': placeholder,
                        'text': text[:50] if text else '',  # 限制长度
                        'id': el_id
                    }
                })

        except Exception as e:
            logger.debug(f"处理元素时出错（跳过）: {e}")
            continue

    # 按分数降序排序
    candidates.sort(key=lambda x: x['score'], reverse=True)

    if not candidates:
        return None, []

    # 限制候选数量
    limited_candidates = candidates[:max_candidates]

    # 返回所有候选元素和选择器（供 Claude 语义排序使用）
    elements_list = [c['element'] for c in limited_candidates]
    # ⚠️ data-key 属性选择器（priority=1, match_type=attribute）全局优先：
    # 候选按元素分数降序连接，外层容器（如首页快捷 block）文本选择器分数更高会排前面，
    # 但属性选择器更精确（容器限定），必须排最前，否则被 div:visible:has-text 假成功截断
    # （实测 .el-drawer [data-key="reserve_list.create"] 点击进入 CreateMeeting 页面）
    attr_selectors = [
        s for c in limited_candidates for s in c['selectors']
        if s.get('priority') == 1 and s.get('match_type') == 'attribute'
    ]
    rest_selectors = [
        s for c in limited_candidates for s in c['selectors']
        if not (s.get('priority') == 1 and s.get('match_type') == 'attribute')
    ]
    all_selectors = attr_selectors + rest_selectors

    # 去重选择器
    seen_selectors = set()
    unique_selectors = []
    for sel in all_selectors:
        if sel['value'] not in seen_selectors:
            seen_selectors.add(sel['value'])
            unique_selectors.append(sel)

    # 日志
    best = limited_candidates[0]
    logger.info(f"最佳匹配元素: {best['info']}, 分数: {best['score']:.3f}, 详情: {best['score_details']}, 候选数: {len(limited_candidates)}")

    return elements_list, unique_selectors


def get_candidate_details(
    page,
    elements: List[Any]
) -> List[Dict[str, Any]]:
    """
    获取候选元素的详细信息（供 Claude 语义分析使用）

    Args:
        page: Playwright Page 对象
        elements: 元素对象列表

    Returns:
        List[Dict]: 候选元素详情列表，格式：
            [
                {
                    "index": 0,
                    "tag": "BUTTON",
                    "text": "新建会议",
                    "selector": "button:has-text('新建会议')",
                    "attributes": {...},
                    "position": {"x": 100, "y": 200, "width": 80, "height": 32}
                },
                ...
            ]
    """
    candidates = []

    for idx, el in enumerate(elements):
        try:
            # 提取元素属性
            tag = el.evaluate('el => el.tagName')
            text = ""
            try:
                text = el.inner_text().strip()[:100]  # 限制长度
            except Exception:
                pass

            # 提取关键属性
            el_id = el.get_attribute('id') or ''
            el_class = el.get_attribute('class') or ''
            el_type = el.get_attribute('type') or ''
            placeholder = el.get_attribute('placeholder') or ''
            aria_label = el.get_attribute('aria-label') or ''
            name = el.get_attribute('name') or ''
            data_testid = el.get_attribute('data-testid') or el.get_attribute('data-test-id') or ''

            # 获取元素位置
            position = {}
            try:
                box = el.bounding_box()
                if box:
                    position = {
                        'x': int(box.get('x', 0)),
                        'y': int(box.get('y', 0)),
                        'width': int(box.get('width', 0)),
                        'height': int(box.get('height', 0))
                    }
            except Exception:
                pass

            # 构建选择器（优先级：ID > data-testid > class > text）
            selector = ""
            if el_id:
                selector = f'#{el_id}'
            elif data_testid:
                selector = f'[data-testid="{data_testid}"]'
            elif el_class and len(el_class.split()) > 0:
                selector = f'.{el_class.split()[0]}'
            elif text:
                selector = f'{tag.lower()}:has-text("{text[:30]}")'
            else:
                selector = tag.lower()

            # 构建候选元素信息
            candidate = {
                'index': idx,
                'tag': tag,
                'text': text,
                'selector': selector,
                'attributes': {
                    'id': el_id,
                    'class': el_class,
                    'type': el_type,
                    'placeholder': placeholder,
                    'aria-label': aria_label,
                    'name': name,
                    'data-testid': data_testid
                },
                'position': position
            }

            candidates.append(candidate)

        except Exception as e:
            logger.debug(f"获取元素详情失败（跳过）: {e}")
            continue

    return candidates


def find_element_by_semantic(
    page,
    keywords: List[str],
    action: str,
    timeout: int = 5000
) -> Tuple[Optional[Any], List[Dict[str, Any]]]:
    """
    通过语义推断匹配元素（当关键词无法直接匹配时）

    根据动作类型推断目标元素类型：
    - fill + 有 value -> 找输入框
    - click + 包含"按钮" -> 找按钮
    - click + 包含"菜单" -> 找菜单项
    - wait + 包含"页面" -> 找容器元素

    Args:
        page: Playwright Page 对象
        keywords (List[str]): 关键词列表
        action (str): 动作类型
        timeout (int): 超时时间（毫秒）

    Returns:
        Tuple[Optional[Any], List[Dict]]: (元素对象, 候选选择器列表)
    """
    logger.info(f"语义推断匹配: action={action}, keywords={keywords}")

    # fill 动作：找输入框
    if action == 'fill':
        try:
            # 优先找可见的输入框
            inputs = page.locator('input:visible, textarea:visible').all()
            if inputs:
                # 1. 优先找 placeholder 匹配关键词的输入框（精确）
                for inp in inputs:
                    placeholder = inp.get_attribute('placeholder') or ''
                    if placeholder and any(kw.lower() in placeholder.lower() for kw in keywords):
                        return inp, [{
                            'type': 'css',
                            'value': f'input[placeholder*="{placeholder}"]',
                            'confidence': 0.85,
                            'priority': 1
                        }]

                # 2. 其次找 label 关联的输入框（通过 for/id 关联）
                for inp in inputs:
                    el_id = inp.get_attribute('id') or ''
                    if el_id:
                        try:
                            label = page.locator(f'label[for="{el_id}"]').first
                            if label:
                                label_text = label.inner_text().strip()
                                if any(kw.lower() in label_text.lower() for kw in keywords):
                                    return inp, [{
                                        'type': 'css',
                                        'value': f'#{el_id}',
                                        'confidence': 0.80,
                                        'priority': 1
                                    }]
                        except Exception:
                            continue

                # 3. 其次找 type=text 且 name 匹配关键词的输入框
                for inp in inputs:
                    inp_type = inp.get_attribute('type') or 'text'
                    name = inp.get_attribute('name') or ''
                    if inp_type in ['text', 'email', 'tel', 'search', '']:
                        if name and any(kw.lower() in name.lower() for kw in keywords):
                            return inp, [{
                                'type': 'css',
                                'value': f'input[name="{name}"]',
                                'confidence': 0.75,
                                'priority': 1
                            }]

                # 4. 回退：选择第一个文本输入框，但选择器带具体属性（不再用宽泛的 input:visible）
                text_inputs = [inp for inp in inputs
                               if (inp.get_attribute('type') or 'text') in
                               ['text', 'email', 'tel', 'password', 'search', '']]
                if text_inputs:
                    first = text_inputs[0]
                    name = first.get_attribute('name') or ''
                    placeholder = first.get_attribute('placeholder') or ''
                    el_id = first.get_attribute('id') or ''
                    if name:
                        selector = f'input[name="{name}"]'
                    elif placeholder:
                        selector = f'input[placeholder*="{placeholder}"]'
                    elif el_id:
                        selector = f'#{el_id}'
                    else:
                        selector = 'input[type="text"]:visible'
                    return first, [{
                        'type': 'css',
                        'value': selector,
                        'confidence': 0.60,
                        'priority': 1
                    }]

                # 绝对回退：最后一个可见输入框（保留原逻辑但降低优先级）
                return inputs[0], [{
                    'type': 'css',
                    'value': 'input:visible',
                    'confidence': 0.40,
                    'priority': 3
                }]
        except Exception as e:
            logger.warning(f"语义推断查找输入框失败: {e}")

    # click 动作：找按钮
    if action == 'click':
        # 先尝试 button 定位（最精确）—— 独立于关键词，优先生成 button:has-text()
        try:
            for kw in keywords:
                if len(kw) >= 2:  # 避免过短关键词匹配太多
                    btn_selector = f'button:has-text("{kw}")'
                    btn = page.locator(btn_selector).first
                    if btn and btn.is_visible():
                        return btn, [{
                            'type': 'css',
                            'value': btn_selector,
                            'confidence': 0.85,
                            'priority': 1
                        }]
        except Exception as e:
            logger.debug(f"button 优先定位失败: {e}")

        # 再尝试 role="button" 定位
        try:
            for kw in keywords:
                if len(kw) >= 2:
                    role_selector = f'[role="button"]:has-text("{kw}")'
                    el = page.locator(role_selector).first
                    if el and el.is_visible():
                        return el, [{
                            'type': 'css',
                            'value': role_selector,
                            'confidence': 0.80,
                            'priority': 2
                        }]
        except Exception as e:
            logger.debug(f"role button 定位失败: {e}")

        # 如果描述包含"按钮"，优先找按钮（原有逻辑）
        if any('按钮' in kw or 'button' in kw.lower() for kw in keywords):
            try:
                buttons = page.locator('button:visible').all()
                if buttons:
                    # 找文本匹配的按钮
                    for btn in buttons:
                        text = btn.inner_text().strip()
                        if any(kw.lower() in text.lower() for kw in keywords):
                            return btn, [{
                                'type': 'css',
                                'value': f'button:has-text("{text}")',
                                'confidence': 0.80,
                                'priority': 1
                            }]

                    # 默认返回第一个按钮
                    return buttons[0], [{
                        'type': 'css',
                        'value': 'button:visible',
                        'confidence': 0.60,
                        'priority': 1
                    }]
            except Exception as e:
                logger.warning(f"语义推断查找按钮失败: {e}")

        # 如果描述包含"菜单"或"导航"，找菜单项
        if any('菜单' in kw or '导航' in kw or 'menu' in kw.lower() for kw in keywords):
            try:
                menu_items = page.locator('[role="menuitem"]:visible, .el-menu-item:visible').all()
                if menu_items:
                    for item in menu_items:
                        text = item.inner_text().strip()
                        if any(kw in text for kw in keywords):
                            return item, [{
                                'type': 'css',
                                'value': f'[role="menuitem"]:has-text("{text}"), .el-menu-item:has-text("{text}")',
                                'confidence': 0.75,
                                'priority': 1
                            }]
            except Exception as e:
                logger.warning(f"语义推断查找菜单失败: {e}")

        # ⚠️ 新增：如果描述包含"图标"或"icon"，查找 i 元素
        if any('图标' in kw or 'icon' in kw.lower() for kw in keywords):
            try:
                icon_elements = page.locator('i:visible, .icon:visible').all()
                if icon_elements:
                    for icon in icon_elements:
                        aria_label = icon.get_attribute('aria-label') or ''
                        title = icon.get_attribute('title') or ''
                        class_name = icon.get_attribute('class') or ''
                        icon_info = f"{aria_label} {title} {class_name}"
                        if any(kw.lower() in icon_info.lower() for kw in keywords):
                            return icon, [{
                                'type': 'css',
                                'value': f'i.{class_name.split()[0]}' if class_name else 'i',
                                'confidence': 0.70,
                                'priority': 1
                            }]
                # 回退：返回第一个可见图标
                if icon_elements:
                    return icon_elements[0], [{
                        'type': 'css',
                        'value': 'i:visible',
                        'confidence': 0.60,
                        'priority': 1
                    }]
            except Exception as e:
                logger.warning(f"语义推断查找图标失败: {e}")

        # ⚠️ 新增：如果描述包含"复选框"或"勾选"，查找 checkbox
        if any('复选框' in kw or '勾选' in kw or 'checkbox' in kw.lower() for kw in keywords):
            try:
                checkboxes = page.locator('.el-checkbox:visible, [role="checkbox"]:visible').all()
                if checkboxes:
                    for cb in checkboxes:
                        text = cb.inner_text().strip()
                        if any(kw in text for kw in keywords):
                            return cb, [{
                                'type': 'css',
                                'value': f'.el-checkbox:has-text("{text}")',
                                'confidence': 0.75,
                                'priority': 1
                            }]
                if checkboxes:
                    return checkboxes[0], [{
                        'type': 'css',
                        'value': '.el-checkbox:visible',
                        'confidence': 0.60,
                        'priority': 1
                    }]
            except Exception as e:
                logger.warning(f"语义推断查找复选框失败: {e}")

        # ⚠️ 新增：如果描述包含"按钮"或"button"，找所有可见按钮（包括 Element Plus）
        if any('按钮' in kw or 'button' in kw.lower() for kw in keywords):
            try:
                buttons = page.locator('button:visible, .el-button:visible').all()
                if buttons:
                    for btn in buttons:
                        text = btn.inner_text().strip()
                        if any(kw.lower() in text.lower() for kw in keywords):
                            return btn, [{
                                'type': 'css',
                                'value': f'button:has-text("{text}")',
                                'confidence': 0.80,
                                'priority': 1
                            }]
                    return buttons[0], [{
                        'type': 'css',
                        'value': 'button:visible',
                        'confidence': 0.60,
                        'priority': 1
                    }]
            except Exception as e:
                logger.warning(f"语义推断查找Element Plus按钮失败: {e}")

    # wait 动作：找容器元素
    if action == 'wait':
        container_selectors = [
            '.container', '.el-main', '.main', '#app',
            '[class*="content"]', '[class*="page"]'
        ]
        for sel in container_selectors:
            try:
                el = page.locator(sel).first
                if el:
                    return el, [{
                        'type': 'css',
                        'value': sel,
                        'confidence': 0.50,
                        'priority': 1
                    }]
            except Exception:
                continue

    # 兜底：未匹配到任何元素（此前返回 None 会导致调用方 tuple 解包崩溃）
    return None, []


def resolve_selectors(
    page,
    step: Dict[str, Any],
    max_chain: int = 4,
) -> List[str]:
    """
    keyword_matcher 选择器链 facade（legacy 自然语言步骤的过渡路径）

    把"步骤名关键词 → 候选选择器"包成执行器可直接注入 params["selectors"] 的
    字符串链（最高分在前）。供 SemanticResolver legacy 路径复用，也便于独立接入。

    Args:
        page: Playwright Page 对象
        step: 步骤 dict（name/action）
        max_chain: 选择器链最大长度

    Returns:
        List[str] 选择器字符串链（降序），未匹配返回 []
    """
    name = (step.get("name") or "") if isinstance(step, dict) else ""
    action = (step.get("action") or "click") if isinstance(step, dict) else "click"
    if not name:
        return []

    keywords = extract_keywords(name)
    if not keywords:
        return []

    elements, selectors = match_element_by_keywords(page, keywords, action)
    if elements and selectors:
        return [str(s.get("value")) for s in selectors if s.get("value")][:max_chain]

    el, sels = find_element_by_semantic(page, keywords, action)
    if sels:
        return [str(s.get("value")) for s in sels if s.get("value")][:max_chain]

    return []

    return None, []