#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""autotest_data.py - 自动化测试数据层 (v3.0 f5 从 autotest.py 拆出)

拆分自 autotest.py(1824行) 的数据层: 测试数据/登录态/cases/anchors 读写。
autotest.py(命令层) 通过 from autotest_data import * 使用。
"""
# v3.0 路径自举
import os as _o, sys as _s
_f = _o.path.abspath(__file__)
for _ in range(10):
    _f = _o.path.dirname(_f)
    _cp = _o.path.join(_f, 'foundation')
    if _o.path.isfile(_o.path.join(_cp, 'bootstrap.py')):
        break
if _cp not in _s.path: _s.path.insert(0, _cp)
from bootstrap import setup; setup()

import os, sys, json, shutil
from datetime import datetime
from pathlib import Path
from foundation.core.paths import get_repo_root, get_developer, MEMBERS_DIR, index_path
from foundation.data.task_utils import load_task_json, resolve_task_dir



# 路径常量
# ---------------------------------------------------------------------------

BASE = get_repo_root()
CONFIG_PATH = os.path.join(str(BASE), '.qoder', 'config.yaml')
HISTORY_PATH = os.path.join(str(BASE), '.qoder', 'learning', 'autotest-history.jsonl')
# 个人测试数据文档: workspace/members/{dev}/autotest-data.yaml
# ✅ 用户可见、自己填(在个人文件夹里); ✅ .gitignore 覆盖(workspace/members/*/autotest-data.yaml),
#    team_sync 永不 push/共享。跟 .signing_key/exports/journal 同级隔离。
# ⚠️ config.yaml 是团队共用会 push, 绝不能放账号密码。
LOCAL_DATA_FILENAME = 'autotest-data.yaml'
# 登录态记录(同目录, 同样 gitignored): 记住哪个环境已登录, 避免重复登录(尤其验证码慢)
LOGIN_STATE_FILENAME = 'autotest-login-state.yaml'
CASES_FILENAME = 'autotest-cases.jsonl'

SUPPORTED_ENVS = ['test', 'pre', 'prod']
SUPPORTED_PLATFORMS = ['web', 'app']
# steps.action 白名单 (生成时校验; 不在表内 → 警告但不阻塞)
KNOWN_ACTIONS = {
    'goto', 'click', 'fill', 'select', 'hover', 'press',
    'assert_visible', 'assert_text', 'assert_not_visible',
    'wait', 'screenshot', 'scroll', 'upload',
    'solve_captcha',  # 验证码: QoderWork Agent 用自带视觉识别(截图→算数学题→填入)
}

# ── 意图式 step (A方案: 意图+锚点) ──
# 新格式: {intent, desc, anchor?, value?} —— 执行时靠 read_page 的 a11y 树语义匹配,
# 有 anchor(role/name/placeholder)则确定性命中(快), 无则 LLM 兜底(self-heal)。
# ref 不存(会变), 只存稳定锚点。兼容旧 action 式(有 action 字段=legacy)。
KNOWN_INTENTS = {
    'goto',       # 导航: {intent:'goto', desc:'登录页', value:'{{base_url}}/auth/login'}
    'observe',    # 读页面: {intent:'observe'} 触发一次 read_page 拿 a11y 树
    'click',      # 点击: {intent:'click', desc:'登录按钮', anchor:{role:'button',name:'登录'}}
    'fill',       # 填表: {intent:'fill', desc:'账号框', value:'{{ask:登录账号}}', anchor:{placeholder:'尊敬的管理员'}}
    'extract',    # 语义断言: {intent:'extract', desc:'是否已进入首页(离开登录页)'}
    'ask_human',  # 人机协同: {intent:'ask_human', desc:'请手动完成验证码并点登录'}
    'assert',     # 确定性断言: {intent:'assert', desc:'URL 不再是 /auth/login'}
}
# anchor 允许的稳定锚点 key (role/name 来自 a11y 树, placeholder/text 来自属性; 都不随刷新变)
ANCHOR_KEYS = {'role', 'name', 'placeholder', 'text'}


def is_intent_step(s):
    """step 是新意图式(有 intent 字段)还是旧命令式(有 action 字段)。"""
    return isinstance(s, dict) and 'intent' in s and 'action' not in s


# ---------------------------------------------------------------------------
# 配置加载 (仿 common.feishu._load_config: yaml 优先, 缺失则简单解析)
# ---------------------------------------------------------------------------

def load_config_section(section='autotest'):
    """加载 config.yaml 的指定段 (v3.0: 优先 Config 单例, 失败回退)。"""
    # 优先: Config 单例 (带 mtime 缓存)
    try:
        from foundation.core.config import Config
        return Config.load().section(section, {}) or {}
    except Exception:
        pass
    # 回退: 直接 yaml 解析
    try:
        import yaml
        with open(CONFIG_PATH, encoding='utf-8') as f:
            full = yaml.safe_load(f) or {}
        return full.get(section) or {}
    except Exception:
        # PyYAML 缺失或解析失败 —— 简单兜底 (只兜 default_env, base_url 解析仍报错)
        return _fallback_parse_autotest()


def _fallback_parse_autotest():
    """无 PyYAML 时的极简兜底: 只抓 default_env。base_url 解析交给上层报错。"""
    config = {}
    try:
        with open(CONFIG_PATH, encoding='utf-8') as f:
            for line in f:
                if line.strip().startswith('default_env:'):
                    val = line.split(':', 1)[1].strip().strip('"').strip("'")
                    config['default_env'] = val
                    break
    except Exception:
        pass
    return config


def resolve_base_url(env, platform, override=None):
    """解析目标域名。优先级: override(--url) > config > None。

    Returns: (base_url, source)  source ∈ {'cli', 'config', 'none'}
    """
    if override:
        return override.rstrip('/'), 'cli'
    cfg = load_config_section('autotest')
    envs = cfg.get('environments') or {}
    env_cfg = envs.get(env) or {}
    plat_cfg = env_cfg.get(platform) or {}
    url = (plat_cfg.get('base_url') or '').strip()
    if url:
        return url.rstrip('/'), 'config'
    return None, 'none'


# ---------------------------------------------------------------------------
# 个人测试数据文档 (workspace/members/{dev}/autotest-data.yaml)
#   ✅ 用户可见, 自己填(就在个人文件夹里)
#   ✅ .gitignore 覆盖 (workspace/members/*/autotest-data.yaml) → team_sync 永不 push
#   ⚠️ config.yaml 是团队共用会 push, 绝不能放账号密码
# ---------------------------------------------------------------------------

def _require_developer():
    """获取当前开发者名, 身份缺失直接抛错。

    健壮性: 绝不 fallback 到 'default'。多人共用 workspace 时,
    无身份 = 会写到共享垃圾目录 = 串号。必须先 /wl-init 确立身份。
    """
    dev = get_developer()
    if not dev:
        raise RuntimeError(
            '未检测到开发者身份(无 .qoder/.developer)。请先运行 /wl-init 注册身份。'
            ' (多人共用 workspace 时, 身份隔离靠它——没身份会串到别人的目录)'
        )
    return dev


def local_data_path():
    """当前开发者的个人测试数据文档路径。

    v3.0: 收进 .private/autotest-data.yaml (向后兼容老位置)。
    """
    dev = _require_developer()
    try:
        from foundation.core.paths import get_private_file
        return Path(str(get_private_file(LOCAL_DATA_FILENAME, dev)))
    except Exception:
        return Path(str(MEMBERS_DIR)) / dev / LOCAL_DATA_FILENAME


def load_local_test_data(env=None, platform=None):
    """读个人测试数据文档。返回扁平 {描述: 值} 字典(已按 env/platform 合并)。

    优先级(文档内): 指定 env+platform 的 > 仅 platform > 仅 env > 顶层通用。
    文档不存在/解析失败 → {} (不报错)。

    文档格式 (autotest-data.yaml):
        # 顶层 = 通用(所有环境/平台都可用)
        测试手机号: "13800000000"

        # 按平台分(不分环境)
        web:
          登录账号: admin
        app:
          登录账号: appuser

        # 按环境分
        test:
          登录账号: testuser
          登录密码: "123456"

        # 精确到 环境+平台 (优先级最高)
        prod:
          web:
            登录账号: prod-admin
    """
    p = local_data_path()
    if not p.is_file():
        return {}
    try:
        import yaml
        with open(str(p), encoding='utf-8') as f:
            raw = yaml.safe_load(f) or {}
    except ImportError:
        raw = _parse_yaml_fallback(p)
    except Exception:
        return {}
    if not isinstance(raw, dict):
        return {}
    # 合并优先级: 通用 < env < platform < env.platform
    merged = {}
    # 顶层(非 env/platform 的键)
    for k, v in raw.items():
        if k in SUPPORTED_ENVS or k in SUPPORTED_PLATFORMS:
            continue
        if isinstance(v, dict):
            continue
        merged[str(k)] = v
    if platform and isinstance(raw.get(platform), dict):
        for k, v in raw[platform].items():
            if not isinstance(v, dict):
                merged[str(k)] = v
    if env and isinstance(raw.get(env), dict):
        for k, v in raw[env].items():
            if not isinstance(v, dict):
                merged[str(k)] = v
        # env.platform (精确)
        if platform and isinstance(raw[env].get(platform), dict):
            for k, v in raw[env][platform].items():
                merged[str(k)] = v
    return merged


def _parse_yaml_fallback(p):
    """无 PyYAML 时, 极简解析顶层 "key: value"(不支持嵌套)。"""
    data = {}
    try:
        for line in p.read_text(encoding='utf-8').splitlines():
            line = line.rstrip()
            if not line.strip() or line.lstrip().startswith('#'):
                continue
            if line.startswith(' ') or line.startswith('\t'):
                continue  # 跳过嵌套行(无 yaml 库时不解析嵌套)
            if ':' not in line:
                continue
            k, _, v = line.partition(':')
            data[k.strip()] = v.strip().strip('"').strip("'")
    except Exception:
        pass
    return data


def save_local_test_data(key, value, env=None, platform=None):
    """原子写一条测试数据到个人文档。

    策略: 没指定 env/platform → 写顶层; 指定了 → 写到对应嵌套块。
    保留文档里已有的其他内容。
    """
    import yaml as _yaml
    p = local_data_path()
    os.makedirs(str(p.parent), exist_ok=True)
    # 读已有(保留注释做不到, 但数据完整保留)
    existing = {}
    if p.is_file():
        try:
            with open(str(p), encoding='utf-8') as f:
                existing = _yaml.safe_load(f) or {}
        except Exception:
            existing = {}
    if not isinstance(existing, dict):
        existing = {}
    # 定位写入点
    if env and platform:
        existing.setdefault(env, {})
        if not isinstance(existing[env], dict):
            existing[env] = {}
        existing[env].setdefault(platform, {})
        if not isinstance(existing[env][platform], dict):
            existing[env][platform] = {}
        existing[env][platform][key] = value
    elif env:
        existing.setdefault(env, {})
        if not isinstance(existing[env], dict):
            existing[env] = {}
        existing[env][key] = value
    elif platform:
        existing.setdefault(platform, {})
        if not isinstance(existing[platform], dict):
            existing[platform] = {}
        existing[platform][key] = value
    else:
        existing[key] = value
    from foundation.io.atomicio import atomic_write_text
    header = ('# 个人测试数据 (本地, 已 gitignore, 不会 push/共享给团队)\n'
              '# 用 autotest.py set-data 管理, 或直接编辑本文件。\n'
              '# 顶层=通用; web/app=按平台; test/pre/prod=按环境; 环境.平台=最精确(优先级最高)\n\n')
    atomic_write_text(str(p), header + _yaml.safe_dump(
        existing, allow_unicode=True, sort_keys=False, default_flow_style=False))


def init_local_data_doc():
    """生成带注释的空模板文档(若不存在)。"""
    p = local_data_path()
    if p.is_file():
        return str(p), False  # 已存在, 不覆盖
    os.makedirs(str(p.parent), exist_ok=True)
    from foundation.io.atomicio import atomic_write_text
    template = (
        '# 个人测试数据 (本地, 已 gitignore, 不会 push/共享给团队)\n'
        '# 在这里填你的测试账号/密码。每个人只填自己的 members/{自己}/autotest-data.yaml。\n'
        '#\n'
        '# 结构(优先级从低到高):\n'
        '#   顶层 key        → 所有环境/平台通用\n'
        '#   web: / app:     → 按平台分\n'
        '#   test: / pre: / prod:  → 按环境分\n'
        '#   环境.平台        → 最精确, 优先级最高\n'
        '#\n'
        '# 用例里用 {{ask:描述}} 占位, 描述要跟这里的 key 一致(如 {{ask:登录账号}})。\n\n'
        '# ===== 通用(不分环境/平台) =====\n'
        '测试手机号: "13800000000"\n\n'
        '# ===== 按平台 =====\n'
        'web:\n'
        '  登录账号: admin\n'
        'app:\n'
        '  登录账号: appuser\n\n'
        '# ===== 按环境(test/pre/prod) =====\n'
        'test:\n'
        '  登录账号: testuser\n'
        '  登录密码: "123456"\n'
        '  # 也可精确到 环境+平台:\n'
        '  # web:\n'
        '  #   登录账号: test-web-admin\n'
    )
    atomic_write_text(str(p), template)
    return str(p), True


# ---------------------------------------------------------------------------
# 登录态记录 (workspace/members/{dev}/autotest-login-state.yaml, gitignored)
#   QoderWork 保持同一个浏览器会话 → 登录一次后 cookie 不丢 → 后续测新功能
#   直接 goto 目标页, 不用重复登录(尤其数学题验证码慢)。
# ---------------------------------------------------------------------------

def login_state_path():
    dev = _require_developer()
    return Path(str(MEMBERS_DIR)) / dev / LOGIN_STATE_FILENAME


def load_login_state():
    """读登录态。返回 {env: {platform: {time, url}}} 或 {}。"""
    p = login_state_path()
    if not p.is_file():
        return {}
    try:
        import yaml
        with open(str(p), encoding='utf-8') as f:
            data = yaml.safe_load(f) or {}
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def is_logged_in(env, platform):
    """该 env/platform 是否已标记登录。返回 (bool, time_str)。"""
    state = load_login_state()
    entry = (state.get(env) or {}).get(platform)
    if isinstance(entry, dict):
        return True, entry.get('time', '')
    return False, ''


def mark_logged_in(env, platform, base_url):
    """标记某 env/platform 已登录(登录用例 pass 后调用)。"""
    import yaml as _yaml
    from foundation.io.atomicio import atomic_write_text
    p = login_state_path()
    os.makedirs(str(p.parent), exist_ok=True)
    state = load_login_state()
    state.setdefault(env, {})
    state[env][platform] = {
        'time': datetime.now().isoformat()[:19],
        'url': base_url,
    }
    atomic_write_text(str(p), '# 登录态记录 (本地, gitignored)。'
                       'QoderWork 保持会话, 登录一次后后续直接 goto 目标页。\n'
                       + _yaml.safe_dump(state, allow_unicode=True, sort_keys=False))


def clear_login_state(env=None, platform=None):
    """清除登录态。env/platform 都不给 = 全清。"""
    from foundation.io.atomicio import atomic_write_text
    p = login_state_path()
    state = load_login_state()
    if env is None and platform is None:
        state = {}
    elif env and platform:
        (state.get(env) or {}).pop(platform, None)
    elif env:
        state.pop(env, None)
    elif platform:
        for e in list(state.keys()):
            (state.get(e) or {}).pop(platform, None)
    if state:
        atomic_write_text(str(p), '# 登录态记录 (本地, gitignored)\n'
                           + __import__('yaml').safe_dump(
                               state, allow_unicode=True, sort_keys=False))
    elif p.is_file():
        p.unlink()


# ---------------------------------------------------------------------------
# 测试页锚点索引 (data/index/test-pages.json, 团队共享)
#   A方案核心: 记住"页面→稳定锚点(role/name/placeholder)", 下次测同页直接命中, 跳过 LLM。
#   只存稳定锚点(不随刷新变), 绝不存 ref(会变)。
# ---------------------------------------------------------------------------

def test_pages_path():
    """data/index/test-pages.json 路径(团队共享, 随 team_sync 分发)。"""
    return index_path('test_pages')


def _load_test_pages():
    """读 test-pages.json。不存在返回空骨架。"""
    from foundation.io.atomicio import safe_read_json
    p = test_pages_path()
    data = safe_read_json(str(p), default=None)
    if not isinstance(data, dict):
        return {'version': '1.0', 'updated': '', 'pages': {}, 'stats': {'total_pages': 0, 'total_anchors': 0}}
    data.setdefault('pages', {})
    data.setdefault('stats', {})
    return data


def _save_test_pages(data):
    """原子写 test-pages.json, 自动更新 updated + stats。"""
    from foundation.io.atomicio import atomic_write_json
    data['updated'] = datetime.now().isoformat()[:19]
    pages = data.get('pages', {})
    n_anchors = sum(len(pg.get('elements', [])) for pg in pages.values())
    data['stats'] = {'total_pages': len(pages), 'total_anchors': n_anchors}
    atomic_write_json(str(test_pages_path()), data)


def _strip_msys_path_mangle(u):
    """修 Git-bash/MSYS 的路径转换副作用。

    Windows 上 Git-bash 会把以 '/' 开头的参数(如 /auth/login)当成 POSIX 绝对路径,
    转成其安装目录下的 Windows 路径, 例如:
        /auth/login  → D:/programming/tools/Git/Git/auth/login   (MSYS 去/加盘符)
        /auth/login  → /D:/programming/tools/Git/Git/auth/login  (有时带前导斜杠)
    特征: 形如 [可选/]盘符:/... 的 Windows 绝对路径, 末尾挂着真实 URL 段。
    识别后只保留从真实 URL 段开始的部分。
    """
    import re as _re
    # 匹配 /D:/... 或 D:/... (盘符后跟 : 和 / 或 :)
    m = _re.match(r'^/?([A-Za-z]:[/:])(.*)', u)
    if m:
        tail = m.group(2)
        # tail 形如 "programming/tools/Git/Git/auth/login" —— 找已知 URL 段作为恢复点
        for seg in ('/auth/', '/auth', '/login', '/api/', '/veh/', '/asset/', '/attendance/',
                    '/insurance', '/salary', '/abnormal', '/org/', '/user/', '/system/'):
            idx = tail.lower().find(seg)
            if idx >= 0:
                return tail[idx:]
        # 兜底: 末尾若有 /xxx/yyy 这种双段路径, 取之
        parts = tail.split('/')
        if len(parts) >= 2:
            return '/' + '/'.join(parts[-2:])
    return u


def _normalize_url_pattern(url, base_url=None):
    """把完整 URL 规整成可复用的 pattern(去域名, 保留路径)。

    https://icsqas.inforecloud.com/auth/login → /auth/login
    已是相对路径 → 原样返回。

    会先修 Git-bash 的路径转换副作用(见 _strip_msys_path_mangle),
    所以 AI 直接传 'auth/login'(无前导斜杠, 避开 MSYS 转换)或 '/auth/login' 都行。
    """
    if not url:
        return '/'
    u = url.strip()
    u = _strip_msys_path_mangle(u)  # 先修 MSYS 副作用
    if base_url and u.startswith(base_url):
        return u[len(base_url):] or '/'
    # 去 protocol + domain
    if '://' in u:
        u = u.split('://', 1)[1]
        if '/' in u:
            u = '/' + u.split('/', 1)[1]
        else:
            u = '/'
    if not u.startswith('/'):
        u = '/' + u
    # 去 query (?redirect=...) —— 登录页常带 redirect, 不影响页面元素
    u = u.split('?', 1)[0]
    return u or '/'


def learn_page_anchors(url_pattern, elements, intent_map=None):
    """把一批页面元素锚点沉淀进 test-pages.json。

    elements: read_page 返回的元素列表, 每个含 role/name/placeholder/text/ref(等)。
              只提取稳定锚点(role/name/placeholder/text), 丢弃 ref/坐标。
    intent_map: 可选 {元素描述: {intent, anchor}} —— AI 标注"这个元素对应什么意图"。
                有则把 intent 一起存, recall 时能直接生成 step。
    返回 (新增数, 总数)。
    """
    intent_map = intent_map or {}
    data = _load_test_pages()
    pages = data['pages']
    existing = pages.get(url_pattern, {}).get('elements', [])
    # 以 anchor 的规范化签名去重
    def _sig(el):
        a = el.get('anchor', {})
        return '|'.join('%s=%s' % (k, a.get(k, '')) for k in sorted(a))
    seen_sigs = {_sig(e) for e in existing}
    new_anchors = []
    for el in elements:
        if not isinstance(el, dict):
            continue
        # 从 read_page 元素提取稳定锚点
        anchor = {}
        for k in ANCHOR_KEYS:
            v = el.get(k)
            if v:
                anchor[k] = str(v)
        if not anchor:
            continue  # 无稳定锚点的元素(只有 ref/坐标)不存
        desc = el.get('desc') or el.get('name') or el.get('text') or ''
        # 查 intent_map: 用 desc/name 匹配
        im = None
        for key, val in intent_map.items():
            if key in desc or desc in key:
                im = val
                break
        entry = {
            'desc': desc,
            'anchor': anchor,
            'intent': (im or {}).get('intent', el.get('intent', '')),
        }
        sig = _sig(entry)
        if sig in seen_sigs:
            continue
        seen_sigs.add(sig)
        new_anchors.append(entry)
    merged = existing + new_anchors
    pages[url_pattern] = {
        'elements': merged,
        'last_seen': datetime.now().isoformat()[:19],
    }
    data['pages'] = pages
    _save_test_pages(data)
    return len(new_anchors), len(merged)


def recall_page_anchors(url_pattern):
    """查某页已沉淀的锚点。返回 elements 列表(可能空)。"""
    data = _load_test_pages()
    pg = data.get('pages', {}).get(url_pattern)
    if not pg:
        return []
    return pg.get('elements', [])


# ---------------------------------------------------------------------------
# 任务 / 用例文件 IO
# ---------------------------------------------------------------------------

def _cases_path(task_dir):
    return Path(task_dir) / CASES_FILENAME


def read_cases(task_dir):
    """读取某任务的全部用例 (list[dict])。无文件返回 []。"""
    p = _cases_path(task_dir)
    if not p.is_file():
        return []
    cases = []
    for lineno, raw in enumerate(p.read_text(encoding='utf-8').splitlines(), 1):
        raw = raw.strip()
        if not raw:
            continue
        try:
            cases.append(json.loads(raw))
        except json.JSONDecodeError as e:
            print('警告: %s 第 %d 行 JSON 损坏, 跳过 (%s)' % (p.name, lineno, e),
                  file=sys.stderr)
    return cases


def write_cases(task_dir, cases):
    """原子覆盖写全部用例为 jsonl。"""
    from foundation.io.atomicio import atomic_write_text
    p = _cases_path(task_dir)
    lines = [json.dumps(c, ensure_ascii=False, sort_keys=True) for c in cases]
    atomic_write_text(str(p), '\n'.join(lines) + ('\n' if lines else ''))


# ---------------------------------------------------------------------------
# 用例骨架生成 (脚本只造结构; 业务步骤由 AI 读 skill 后填充)
# ---------------------------------------------------------------------------

def _skeleton_case(task_name, case_no, platform, env):
    """造一条占位用例骨架。AI 应在此基础上据 PRD 补充 steps/expected。

    骨架用意图式(intent+anchor)格式 —— 执行时靠 a11y 树语义匹配。
    需要真实测试数据(账号/密码)的地方, 用 {{ask:描述}} 占位。
    """
    cid = 'AT-%s-%03d' % (task_name, case_no)
    return {
        'case_id': cid,
        'title': '（待补充）用例 %d 标题' % case_no,
        'platform': platform,
        'env': env,
        'preconditions': '',
        'steps': [
            {'intent': 'goto', 'desc': '打开页面', 'value': '{{base_url}}/'},
            # 需要真实测试数据时用 {{ask:描述}} 占位, 例:
            #   {'intent':'fill','desc':'账号框','value':'{{ask:登录账号}}','anchor':{'role':'textbox'}}
            {'intent': 'extract', 'desc': '页面正常加载, 出现关键元素'},
        ],
        'expected': '（待补充）预期结果',
    }


def generate_skeleton(task_dir, env, platform, count=3):
    """为任务生成 count 条骨架用例 (不覆盖已有; 追加)。"""
    task_name = Path(task_dir).name
    existing = read_cases(task_dir)
    start_no = len(existing) + 1
    new_cases = [_skeleton_case(task_name, start_no + i, platform, env)
                 for i in range(count)]
    write_cases(task_dir, existing + new_cases)
    return new_cases


def validate_cases(cases, strict=False):
    """校验用例结构, 返回 (ok, warnings)。strict=True 时缺字段视为错误。"""
    warns = []
    ids = set()
    for i, c in enumerate(cases, 1):
        cid = c.get('case_id', '')
        if not cid:
            warns.append('第 %d 条缺 case_id' % i)
        elif cid in ids:
            warns.append('case_id 重复: %s' % cid)
        else:
            ids.add(cid)
        for f in ('title', 'steps', 'expected'):
            if f not in c:
                warns.append('%s 缺字段 %s' % (cid or ('第%d条' % i), f))
        steps = c.get('steps') or []
        if not isinstance(steps, list):
            warns.append('%s steps 不是数组' % cid)
            continue
        for j, s in enumerate(steps, 1):
            if not isinstance(s, dict):
                warns.append('%s step%d 不是对象' % (cid, j))
                continue
            # 新意图式: 校验 intent + anchor 结构
            if is_intent_step(s):
                it = s.get('intent', '')
                if it and it not in KNOWN_INTENTS:
                    warns.append('%s step%d 未知 intent: %s' % (cid, j, it))
                if not s.get('desc') and it not in ('observe',):
                    warns.append('%s step%d (intent=%s) 缺 desc' % (cid, j, it))
                anc = s.get('anchor')
                if anc is not None:
                    if not isinstance(anc, dict):
                        warns.append('%s step%d anchor 不是对象' % (cid, j))
                    elif not any(k in ANCHOR_KEYS for k in anc):
                        warns.append('%s step%d anchor 无稳定键(需 %s 之一)'
                                     % (cid, j, '/'.join(sorted(ANCHOR_KEYS))))
            else:
                # 旧命令式: 校验 action
                act = s.get('action', '')
                if not act:
                    warns.append('%s step%d 既无 intent 也无 action' % (cid, j))
                elif act not in KNOWN_ACTIONS:
                    warns.append('%s step%d 未知 action: %s' % (cid, j, act))
    return (len(warns) == 0) if strict else True, warns


# ---------------------------------------------------------------------------
# 共享: 解析环境+注入数据 / 打印执行计划 (run 和 quick 复用)
# ---------------------------------------------------------------------------

