from __future__ import annotations

import base64
import os
import time
from dataclasses import dataclass

from playwright.sync_api import Frame, Page, expect

from e2e.pages.tenant_auth_page import ENTERPRISE_CONSOLE_URL, TenantAuthPage
from e2e.utils.bug_reporter import BugReporter
from e2e.utils.dual_browser import assert_account_page, dual_browser_enabled, dual_browser_manager
from e2e.utils.image_captcha import ImageCaptchaSolver, decode_data_image
from e2e.utils.verification_code import VerificationCodeProvider


WANYOUJI_CONSOLE_URL = (
    "https://test-merchant-lianlianyun.lianlianwz.com/"
    "productConsole?productCode=P20250814095939369041"
)


@dataclass(frozen=True)
class WanyoujiMemberData:
    phone: str
    password: str
    role: str
    name: str
    product: str = "丸友集"
    selected_role: str = ""


class InvitationSmsPending(RuntimeError):
    def __init__(self, phone: str, invite_url: str):
        super().__init__(f"等待手动填写邀请短信验证码超时，手机号：{phone}，邀请链接：{invite_url}")
        self.phone = phone
        self.invite_url = invite_url


class WanyoujiMemberPage(TenantAuthPage):
    def __init__(
        self,
        page: Page,
        tenant_home_url: str,
        bug_reporter: BugReporter | None = None,
        image_captcha_solver: ImageCaptchaSolver | None = None,
        code_provider: VerificationCodeProvider | None = None,
    ):
        super().__init__(page, tenant_home_url, bug_reporter)
        self.image_captcha_solver = image_captcha_solver or ImageCaptchaSolver(mode="ddddocr")
        self.code_provider = code_provider

    def open_member_management(self) -> Frame:
        last_error: Exception | None = None
        for _ in range(4):
            try:
                self.goto(ENTERPRISE_CONSOLE_URL)
                self.wait_for_enterprise_console_shell()
                self.open_enterprise_member_management_menu()
                self.wait_for_member_frame_ready(timeout_seconds=8)
                return self.best_member_frame()
            except Exception as exc:
                last_error = exc
                self.page.wait_for_timeout(1500)
        urls = [frame.url for frame in self.page.frames]
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(
            f"未稳定进入企业-成员管理；frames={urls}；text={body_text[:1200]}"
        ) from last_error

    def open_enterprise_member_management_menu(self) -> None:
        if self.click_page_text(["成员管理"]):
            return
        try:
            self.click_sidebar_text("企业", right_edge=True)
        except Exception:
            pass
        for text in ["成员管理", "组织与部门管理", "组织管理", "员工管理", "成员"]:
            try:
                self.click_sidebar_text(text)
                return
            except Exception:
                continue
        if self.click_page_text(["成员管理", "组织与部门管理", "组织管理", "员工管理", "成员"]):
            return
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(f"未找到企业-成员管理入口；当前页面文本：{body_text[:1200]}")

    def wait_for_member_frame_ready(self, timeout_seconds: int = 8) -> None:
        for _ in range(timeout_seconds * 2):
            for frame in self.page.frames:
                if frame.url == "about:blank":
                    continue
                try:
                    text = frame.locator("body").inner_text(timeout=500)
                except Exception:
                    continue
                if any(key in text for key in ["创建员工", "添加部门", "全部成员", "成员名称", "手机号"]):
                    return
            self.page.wait_for_timeout(500)

    def activate_top_tab(self, text: str) -> None:
        clicked = self.page.evaluate(
            """text => {
                const tabs = Array.from(document.querySelectorAll('.ant-tabs-tab, .ant-tabs-tab-btn'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text === text || item.text.includes(text))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = tabs[0]?.el;
                if (!target) return false;
                target.click();
                return true;
            }""",
            text,
        )
        if clicked:
            self.page.wait_for_timeout(2500)

    def click_first_available_entry(self, texts: list[str]) -> None:
        if self.click_page_text(texts):
            return
        try:
            self.click_sidebar_text("工作室基础应用", right_edge=True)
            if self.click_page_text(texts) or self.click_first_available_sidebar(texts):
                return
        except Exception:
            pass
        if self.click_first_available_sidebar(texts):
            return
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(f"未找到成员管理入口，候选菜单：{texts}；当前页面文本：{body_text[:1200]}")

    def click_first_available_sidebar(self, texts: list[str]) -> bool:
        for text in texts:
            try:
                self.click_sidebar_text(text)
                return True
            except Exception:
                continue
        return False

    def click_page_text(self, texts: list[str]) -> bool:
        for text in texts:
            clicked = self.page.evaluate(
                """text => {
                    const nodes = Array.from(document.querySelectorAll('taro-view-core, button, a, span, div'));
                    const target = nodes
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text === text || item.text.split(/\\s+/).includes(text))
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                    if (!target) return false;
                    target.el.click();
                    return true;
                }""",
                text,
            )
            if clicked:
                self.page.wait_for_timeout(1200)
                return True
        return False

    def best_member_frame(self) -> Frame:
        candidates = [frame for frame in self.page.frames if frame.url != "about:blank"]
        for frame in candidates:
            if "/organization" in frame.url:
                return frame
        for frame in candidates:
            try:
                text = frame.locator("body").inner_text(timeout=2000)
            except Exception:
                continue
            if any(key in text for key in ["创建员工", "添加部门", "全部成员", "成员名称", "手机号"]):
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"未找到成员管理 iframe；frames={urls}")

    def invite_member(self, member: WanyoujiMemberData) -> tuple[str, bool]:
        frame = self.open_member_management()
        self.open_add_member_modal(frame)
        invite_url, selected_role = self.create_invitation_link(frame, member)
        self.verify_invitation_url_entry(invite_url)
        self.verify_invitation_qr_entry(frame)
        if not self.register_member_from_invitation_mobile(invite_url, member):
            return selected_role, False
        self.confirm_registered_member_from_main_account(member)
        self.verify_member_can_login_by_sms(member)
        return selected_role, True

    def confirm_registered_member_from_main_account(self, member: WanyoujiMemberData) -> None:
        frame = self.open_member_management()
        self.confirm_member_invitation_if_needed(frame, member)

    def verify_member_can_login_by_sms(self, member: WanyoujiMemberData) -> None:
        if dual_browser_enabled():
            self.page = dual_browser_manager().page_for_account(member.phone, seed_page=self.page)
            if self.bug_reporter:
                self.bug_reporter.bind_page(self.page)
        if self.can_reuse_member_login(member):
            if self.bug_reporter:
                self.bug_reporter.clear()
            return
        assert self.code_provider is not None, "验证码登录校验需要配置 VerificationCodeProvider"
        self.clear_desktop_login_state()
        self.goto(self.tenant_home_url.replace("/home", "/login"))
        self.wait_for_desktop_login_ready()
        self.click_text_on_page(self.page, ["验证码登录", "短信登录"])
        self.wait_for_desktop_login_ready()
        self.fill_page_input_on_page(self.page, ["手机号", "手机号码", "账号"], member.phone, required=True)
        sms_requested_at = self.request_desktop_sms_code_with_graphic_retry(member.phone)
        self.fill_desktop_sms_code(member.phone, after_timestamp=sms_requested_at)
        self.check_login_agreement_if_present()
        assert self.click_desktop_sms_login_submit(), (
            f"子成员验证码登录页未找到登录按钮；text={self.page.locator('body').inner_text(timeout=5000)[:1000]}"
        )
        self.wait_for_desktop_sms_login_result(member)
        text = self.page.locator("body").inner_text(timeout=5000)
        assert "用户名或密码错误" not in text, f"子成员验证码登录失败：{member.phone}；text={text[:1200]}"
        assert any(key in text for key in ["选择账号", "子账号", member.name, member.role]), (
            f"子成员验证码登录后未看到账号选择/子账号信息；phone={member.phone}；text={text[:1200]}"
        )
        self.select_sub_account(member)
        self.verify_sub_account_entered(member)
        self.verify_wanyouji_role_menu_permissions(member)
        setattr(self.page, "_e2e_current_member_account", member.phone)
        setattr(self.page, "_e2e_current_player_account", member.phone)
        setattr(self.page, "_e2e_current_main_account", "")
        if dual_browser_enabled():
            dual_browser_manager().set_active_page(self.page)
            assert_account_page(self.page, member.phone)

    def can_reuse_member_login(self, member: WanyoujiMemberData) -> bool:
        reuse_flag = os.getenv("E2E_REUSE_MEMBER_LOGIN", "1").strip().lower()
        if reuse_flag in {"0", "false", "no", "off"}:
            return False
        player_reuse_flag = os.getenv("E2E_REUSE_PLAYER_LOGIN", "1").strip().lower()
        if member.role == "打手" and player_reuse_flag in {"0", "false", "no", "off"}:
            return False
        current_member = getattr(self.page, "_e2e_current_member_account", "")
        current_player = getattr(self.page, "_e2e_current_player_account", "")
        if member.phone not in {current_member, current_player}:
            return False
        try:
            self.goto(WANYOUJI_CONSOLE_URL)
            self.page.wait_for_load_state("domcontentloaded")
            self.page.wait_for_timeout(1500)
            text = self.page.locator("body").inner_text(timeout=5000)
            if "登录" in text and ("验证码登录" in text or "账号登录" in text or "登录/注册" in text):
                return False
            if "选择账号" in text and "进入后台" in text:
                self.select_sub_account(member)
                self.goto(WANYOUJI_CONSOLE_URL)
                self.page.wait_for_load_state("domcontentloaded")
                self.page.wait_for_timeout(1500)
                text = self.page.locator("body").inner_text(timeout=5000)
            role_markers = [member.name, member.role, "丸友集"]
            if member.role == "打手":
                role_markers.extend(["打手应用", "我的订单", "我的收入"])
            elif member.role == "客服":
                role_markers.extend(["收单应用", "客户自助填单", "客服"])
            return any(marker and marker in text for marker in role_markers)
        except Exception:
            return False

    def select_sub_account(self, member: WanyoujiMemberData) -> None:
        body_text = self.page.locator("body").inner_text(timeout=5000)
        if "/account/select" not in self.page.url and "选择账号" not in body_text:
            return
        box = self.page.evaluate(
            """({ phone, name, role }) => {
                const allCards = Array.from(document.querySelectorAll('taro-view-core, div'))
                  .map(el => ({ text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 260 && item.r.width < 800 && item.r.height > 45 && item.r.height < 180)
                  .filter(item => item.r.y > 120)
                  .filter(item => item.text.includes('子账号'))
                  .filter(item => !item.text.includes('切换账号') && !item.text.includes('退出登录'))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const fallbackCards = Array.from(document.querySelectorAll('taro-view-core, div'))
                  .map(el => ({ text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 260 && item.r.width < 800 && item.r.height > 45 && item.r.height < 180)
                  .filter(item => item.r.y > 120)
                  .filter(item => item.text.includes(phone) || item.text.includes(name) || item.text.includes(role))
                  .filter(item => !item.text.includes('切换账号') && !item.text.includes('退出登录'))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = allCards[0] || fallbackCards[0];
                if (!target) return null;
                return { x: target.r.x, y: target.r.y, width: target.r.width, height: target.r.height, text: target.text };
            }""",
            {"phone": member.phone, "name": member.name, "role": member.role},
        )
        assert box, f"验证码登录后未找到子账号卡片；phone={member.phone}；text={body_text[:1200]}"
        self.page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(3000)

    def verify_sub_account_entered(self, member: WanyoujiMemberData) -> None:
        text = self.page.locator("body").inner_text(timeout=5000)
        assert "待管理员审核确认" not in text, (
            f"子账号已注册但仍待主账号审核确认；phone={member.phone}；请先在企业管理-成员管理中通过该成员；text={text[:1200]}"
        )
        if "选择账号" in text and "进入后台" in text:
            self.click_text_on_page(self.page, ["进入后台"])
            self.page.wait_for_load_state("domcontentloaded")
            self.page.wait_for_timeout(3000)
            text = self.page.locator("body").inner_text(timeout=5000)
        if "选择账号" in text and "账号选择成功" in text:
            self.goto(WANYOUJI_CONSOLE_URL)
            self.page.wait_for_load_state("domcontentloaded")
            self.page.wait_for_timeout(3000)
            text = self.page.locator("body").inner_text(timeout=5000)
        assert "选择账号" not in text or "进入后台" in text, (
            f"子账号卡片点击后仍停留在账号选择页；phone={member.phone}；text={text[:1200]}"
        )
        assert any(key in text for key in [member.name, member.role, "进入后台", "商户认证", "开放平台"]), (
            f"子账号登录后未进入有效上下文；phone={member.phone}；text={text[:1200]}"
        )

    def verify_wanyouji_role_menu_permissions(self, member: WanyoujiMemberData) -> None:
        self.goto(WANYOUJI_CONSOLE_URL)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(5000)
        text = self.page.locator("body").inner_text(timeout=5000)
        assert "丸友集" in text, f"子账号未能打开丸友集控制台；phone={member.phone}；text={text[:1200]}"
        if member.role == "打手":
            assert "打手应用" in text, (
                f"打手子账号打开丸友集后未看到打手应用菜单；phone={member.phone}；text={text[:1500]}"
            )
            forbidden_menus = ["企业管理", "工作室基础应用", "财务应用", "结算中心"]
            visible_forbidden = [menu for menu in forbidden_menus if menu in text]
            assert not visible_forbidden, (
                f"打手子账号看到疑似非打手权限菜单：{visible_forbidden}；phone={member.phone}；text={text[:1500]}"
            )
        elif member.role == "客服":
            assert any(key in text for key in ["收单应用", "客户自助填单", "客服"]), (
                f"客服子账号打开丸友集后未看到客服/收单相关菜单；phone={member.phone}；text={text[:1500]}"
            )

    def clear_desktop_login_state(self) -> None:
        self.page.context.clear_cookies()
        try:
            self.goto(self.tenant_home_url.replace("/home", "/login"))
            self.page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
        except Exception:
            pass
        self.page.context.clear_cookies()

    def click_desktop_sms_login_submit(self) -> bool:
        clicked = bool(
            self.page.evaluate(
                """() => {
                    const inputs = Array.from(document.querySelectorAll('input, textarea'))
                      .map(el => ({ el, r: el.getBoundingClientRect(), placeholder: el.getAttribute('placeholder') || '' }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.placeholder.includes('验证码') || item.placeholder.includes('手机号'));
                    const formRect = inputs.length
                      ? inputs.reduce((acc, item) => ({
                          left: Math.min(acc.left, item.r.left),
                          right: Math.max(acc.right, item.r.right),
                          top: Math.min(acc.top, item.r.top),
                          bottom: Math.max(acc.bottom, item.r.bottom),
                        }), { left: inputs[0].r.left, right: inputs[0].r.right, top: inputs[0].r.top, bottom: inputs[0].r.bottom })
                      : null;
                    const buttons = Array.from(document.querySelectorAll('button, taro-button-core'))
                      .map(el => ({
                        el,
                        text: (el.innerText || el.textContent || '').trim(),
                        disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true' || String(el.className || '').includes('disabled'),
                        r: el.getBoundingClientRect(),
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.disabled)
                      .filter(item => item.text === '登 录' || item.text === '登录');
                    const target = buttons
                      .filter(item => !formRect || (
                        item.r.left >= formRect.left - 80 &&
                        item.r.right <= formRect.right + 120 &&
                        item.r.top >= formRect.top &&
                        item.r.top <= formRect.bottom + 180
                      ))
                      .sort((a, b) => b.r.width * b.r.height - a.r.width * a.r.height)[0] || buttons[0];
                    if (!target) return false;
                    target.el.click();
                    return true;
                }"""
            )
        )
        if clicked:
            self.page.wait_for_timeout(1000)
            return True
        try:
            self.page.keyboard.press("Enter")
            self.page.wait_for_timeout(1000)
            return True
        except Exception:
            return False

    def check_login_agreement_if_present(self) -> None:
        try:
            checked = self.page.evaluate(
                """() => {
                    const inputs = Array.from(document.querySelectorAll("input[type='checkbox'], .ant-checkbox-input"));
                    for (const input of inputs) {
                      const r = input.getBoundingClientRect();
                      if (r.width <= 0 || r.height <= 0) continue;
                      const wrapper = input.closest('label, .ant-checkbox-wrapper, div') || input;
                      const isChecked = Boolean(input.checked) ||
                        String(wrapper.className || '').includes('checked') ||
                        String(input.parentElement?.className || '').includes('checked');
                      if (!isChecked) {
                        input.click();
                      }
                      return true;
                    }
                    return false;
                }"""
            )
            if checked:
                self.page.wait_for_timeout(300)
        except Exception:
            pass

    def wait_for_desktop_sms_login_result(self, member: WanyoujiMemberData) -> None:
        for _ in range(20):
            try:
                text = self.page.locator("body").inner_text(timeout=1000)
                if "用户名或密码错误" in text:
                    return
                if any(key in text for key in ["选择账号", "子账号", member.name, member.role]):
                    return
                if "请输入短信验证码" not in text and "获取验证码" not in text:
                    return
            except Exception:
                pass
            self.page.wait_for_timeout(1000)

    def wait_for_desktop_login_ready(self) -> None:
        for _ in range(20):
            try:
                if self.page.locator("input[placeholder*='手机号'], #password_login_phone").count() > 0:
                    return
                text = self.page.locator("body").inner_text(timeout=1000)
                if "验证码登录" in text or "账号登录" in text:
                    return
            except Exception:
                pass
            self.page.wait_for_timeout(1000)
        raise AssertionError(
            f"登录页未加载出登录表单；url={self.page.url}；"
            f"text={self.page.locator('body').inner_text(timeout=3000)[:1200]}"
        )

    def request_desktop_sms_code_with_graphic_retry(self, phone: str, max_attempts: int = 8) -> float:
        last_code = ""
        for attempt in range(max_attempts):
            last_code = self.fill_desktop_graphic_captcha_if_present()
            requested_at = time.time()
            assert self.click_text_on_page(self.page, ["获取验证码"]), (
                f"验证码登录页未找到获取验证码按钮；text={self.page.locator('body').inner_text(timeout=5000)[:1000]}"
            )
            self.page.wait_for_timeout(2500)
            if self.desktop_sms_request_succeeded():
                return requested_at
            if self.desktop_sms_request_too_frequent_visible():
                wait_seconds = int(os.getenv("E2E_SMS_FREQUENT_RETRY_SECONDS", "5"))
                print(
                    f"[E2E member SMS] 验证码请求过于频繁，等待 {wait_seconds} 秒后刷新页面重试 "
                    f"({attempt + 1}/{max_attempts})。"
                )
                self.page.wait_for_timeout(wait_seconds * 1000)
                self.reset_desktop_sms_login_form(phone)
                continue
            if self.desktop_graphic_captcha_error_visible():
                self.refresh_desktop_graphic_captcha()
                continue
            self.refresh_desktop_graphic_captcha()
        raise AssertionError(f"验证码登录页图形验证码多次识别失败，未能成功获取短信验证码；最后识别：{last_code}")

    def reset_desktop_sms_login_form(self, phone: str) -> None:
        self.page.reload(wait_until="domcontentloaded")
        self.wait_for_desktop_login_ready()
        self.click_text_on_page(self.page, ["验证码登录", "短信登录"])
        self.wait_for_desktop_login_ready()
        self.fill_page_input_on_page(self.page, ["手机号", "手机号码", "账号"], phone, required=True)

    def fill_desktop_graphic_captcha_if_present(self) -> str:
        if self.page.locator("input[placeholder*='图形验证码']").count() == 0:
            return ""
        src = self.desktop_graphic_captcha_src()
        code = self.image_captcha_solver.solve(decode_data_image(src))
        self.fill_page_input_on_page(self.page, ["图形验证码"], code, required=True)
        return code

    def desktop_graphic_captcha_src(self) -> str:
        for _ in range(12):
            src = self.page.evaluate(
                """() => {
                    const input = Array.from(document.querySelectorAll('input'))
                      .map(el => ({ el, r: el.getBoundingClientRect(), placeholder: el.getAttribute('placeholder') || '' }))
                      .filter(item => item.placeholder.includes('图形验证码'))
                      .filter(item => item.r.width > 40 && item.r.height > 10)
                      .sort((a, b) => b.r.y - a.r.y)[0];
                    if (!input) return '';
                    const images = Array.from(document.querySelectorAll('img'))
                      .map(img => ({ src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                      .filter(item => item.src.startsWith('data:image') && item.r.width > 40 && item.r.height > 20)
                      .filter(item => Math.abs((item.r.y + item.r.height / 2) - (input.r.y + input.r.height / 2)) < 35)
                      .filter(item => item.r.x > input.r.x && item.r.x < input.r.x + 600)
                      .sort((a, b) => a.r.x - b.r.x);
                    return images[0]?.src || '';
                }"""
            )
            if src:
                return str(src)
            self.page.wait_for_timeout(500)
        raise AssertionError(f"验证码登录页未找到图形验证码图片；text={self.page.locator('body').inner_text(timeout=5000)[:1000]}")

    def refresh_desktop_graphic_captcha(self) -> None:
        self.page.evaluate(
            """() => {
                const input = Array.from(document.querySelectorAll('input'))
                  .map(el => ({ el, r: el.getBoundingClientRect(), placeholder: el.getAttribute('placeholder') || '' }))
                  .filter(item => item.placeholder.includes('图形验证码'))
                  .filter(item => item.r.width > 40 && item.r.height > 10)
                  .sort((a, b) => b.r.y - a.r.y)[0];
                if (!input) return;
                const images = Array.from(document.querySelectorAll('img'))
                  .map(img => ({ img, src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                  .filter(item => item.src.startsWith('data:image') && item.r.width > 40 && item.r.height > 20)
                  .filter(item => Math.abs((item.r.y + item.r.height / 2) - (input.r.y + input.r.height / 2)) < 35)
                  .filter(item => item.r.x > input.r.x && item.r.x < input.r.x + 600)
                  .sort((a, b) => a.r.x - b.r.x);
                images[0]?.img.click();
            }"""
        )
        self.page.wait_for_timeout(500)

    def desktop_graphic_captcha_error_visible(self) -> bool:
        text = self.page.locator("body").inner_text(timeout=3000)
        return any(key in text for key in ["图形验证码错误", "图形验证码失败", "图形验证码不正确", "验证码错误", "验证码失败", "验证码不正确"])

    def desktop_sms_request_too_frequent_visible(self) -> bool:
        text = self.page.locator("body").inner_text(timeout=3000)
        return any(key in text for key in ["验证码获取过于频繁", "获取验证码过于频繁", "操作过于频繁", "请求过于频繁", "发送过于频繁", "请稍后再试", "稍后重试"])

    def desktop_sms_request_succeeded(self) -> bool:
        text = self.page.locator("body").inner_text(timeout=3000)
        if any(key in text for key in ["验证码已发送", "发送成功", "短信已发送", "获取成功", "请查收"]):
            return True
        return bool(
            self.page.evaluate(
                """() => Array.from(document.querySelectorAll('button, span, div'))
                  .some(el => {
                    const text = (el.innerText || el.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    const disabled = Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true' || String(el.className || '').includes('disabled');
                    return r.width > 0 && r.height > 0 &&
                      ((disabled && text.includes('获取验证码')) || /\\d+\\s*s/.test(text) || text.includes('重新获取'));
                  })"""
            )
        )

    def fill_desktop_sms_code(self, phone: str, after_timestamp: float | None = None) -> None:
        if self.code_provider is None or self.code_provider.mode == "manual":
            self.wait_for_manual_invitation_sms(phone)
            return
        sms_code = self.code_provider.get_code(phone, after_timestamp=after_timestamp)
        filled = bool(
            self.page.evaluate(
                """smsCode => {
                    const inputs = Array.from(document.querySelectorAll('input, textarea'))
                      .map(el => ({
                        el,
                        placeholder: el.getAttribute('placeholder') || '',
                        itemText: (el.closest('label, div, form')?.innerText || '').trim(),
                        r: el.getBoundingClientRect(),
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => {
                        const text = `${item.placeholder}\\n${item.itemText}`;
                        return text.includes('短信验证码') || (text.includes('验证码') && !text.includes('图形'));
                      })
                      .sort((a, b) => b.r.y - a.r.y);
                    const target = inputs[0]?.el;
                    if (!target) return false;
                    target.focus();
                    const setter = Object.getOwnPropertyDescriptor(
                      target instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype,
                      'value'
                    )?.set;
                    if (setter) setter.call(target, smsCode);
                    else target.value = smsCode;
                    target.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: smsCode }));
                    target.dispatchEvent(new Event('change', { bubbles: true }));
                    target.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                sms_code,
            )
        )
        assert filled, (
            f"验证码登录页未找到短信验证码输入框；phone={phone}；"
            f"inputs={self.page_input_values(self.page)}；text={self.page.locator('body').inner_text(timeout=5000)[:1000]}"
        )

    def open_add_member_modal(self, frame: Frame) -> None:
        for _ in range(3):
            if self.click_frame_text(frame, ["邀请员工", "创建员工", "新增成员", "邀请成员", "添加成员", "新增", "添加"]):
                if self.wait_for_member_modal_ready(frame):
                    return
            self.page.wait_for_timeout(1000)
        body_text = frame.locator("body").inner_text(timeout=5000)
        raise AssertionError(f"未找到新增/邀请成员按钮；当前成员页文本：{body_text[:1500]}")

    def wait_for_member_modal_ready(self, frame: Frame) -> bool:
        for _ in range(8):
            fields = self.visible_fields(frame)
            if any("角色" in field for field in fields) and (
                any("邀请链接有效期" in field for field in fields) or any("产品" in field for field in fields)
            ):
                return True
            self.page.wait_for_timeout(500)
        return False

    def create_invitation_link(self, frame: Frame, member: WanyoujiMemberData) -> tuple[str, str]:
        self.select_modal_option_by_label(frame, ["产品"], member.product, fallback_index=0)
        self.wait_for_invite_modal_product_loaded(frame)
        self.select_optional_modal_option_by_label(frame, ["部门"], "默认部门", fallback_index=1)
        selected_role = self.select_modal_option_by_label(frame, ["角色", "成员角色"], member.role, fallback_index=2)
        self.fill_optional_modal_input(frame, ["邀请链接有效期", "有效期"], "7")
        self.confirm_member_modal(frame)
        self.page.wait_for_timeout(2000)
        invite_url = self.extract_invitation_url(frame)
        assert invite_url, (
            f"创建员工后未提取到邀请链接；fields={self.visible_fields(frame)}；"
            f"buttons={self.visible_buttons(frame)}；text={frame.locator('body').inner_text(timeout=5000)[:1500]}"
        )
        return invite_url, selected_role or member.role

    def wait_for_invite_modal_product_loaded(self, frame: Frame) -> None:
        for _ in range(12):
            fields = self.visible_fields(frame)
            joined = "\n".join(fields)
            if "请先选择产品" not in joined:
                return
            self.page.wait_for_timeout(500)

    def verify_invitation_url_entry(self, invite_url: str) -> None:
        self.assert_mobile_invitation_page_openable(invite_url, entry_name="邀请URL")

    def verify_invitation_qr_entry(self, frame: Frame) -> None:
        qr_url = self.extract_invitation_qr_url(frame)
        if not qr_url:
            body_text = frame.locator("body").inner_text(timeout=5000)[:800]
            safe_body_text = body_text.encode("gbk", errors="replace").decode("gbk")
            print(
                "[E2E member invite] 创建员工后未发现可解析二维码，当前页面仅验证邀请 URL 入口；"
                f"text={safe_body_text}"
            )
            return
        self.assert_mobile_invitation_page_openable(qr_url, entry_name="邀请二维码")

    def assert_mobile_invitation_page_openable(self, invite_url: str, entry_name: str) -> None:
        context = self.page.context.browser.new_context(
            viewport={"width": 390, "height": 844},
            screen={"width": 390, "height": 844},
            is_mobile=True,
            has_touch=True,
            device_scale_factor=2,
            user_agent=(
                "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
                "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
            ),
        )
        mobile_page = context.new_page()
        try:
            self.open_mobile_invitation_page(mobile_page, invite_url)
            text = mobile_page.locator("body").inner_text(timeout=5000)
            assert "邀请您加入他的团队" in text and "立即加入" in text, (
                f"{entry_name}未正常打开移动端邀请注册页；url={invite_url}；text={text[:1000]}"
            )
        finally:
            context.close()

    def extract_invitation_qr_url(self, frame: Frame) -> str:
        image_src = frame.evaluate(
            """() => {
                const images = Array.from(document.querySelectorAll('.ant-modal img, img'))
                  .map(img => ({ src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                  .filter(item => item.src.startsWith('data:image') && item.r.width >= 80 && item.r.height >= 80);
                images.sort((a, b) => (b.r.width * b.r.height) - (a.r.width * a.r.height));
                return images[0]?.src || '';
            }"""
        )
        if not image_src:
            return ""
        return self.decode_qr_data_image(str(image_src))

    def decode_qr_data_image(self, image_src: str) -> str:
        header, _, payload = image_src.partition(",")
        if not payload or "base64" not in header:
            return ""
        image_bytes = base64.b64decode(payload)
        try:
            import cv2
            import numpy as np
        except Exception as exc:
            raise AssertionError(f"本地缺少二维码解码依赖，无法验证邀请二维码：{exc}") from exc

        data = np.frombuffer(image_bytes, dtype=np.uint8)
        image = cv2.imdecode(data, cv2.IMREAD_COLOR)
        if image is None:
            return ""
        value, _, _ = cv2.QRCodeDetector().detectAndDecode(image)
        return str(value or "")

    def register_member_from_invitation_mobile(self, invite_url: str, member: WanyoujiMemberData) -> bool:
        context = self.page.context.browser.new_context(
            viewport={"width": 390, "height": 844},
            screen={"width": 390, "height": 844},
            is_mobile=True,
            has_touch=True,
            device_scale_factor=2,
            user_agent=(
                "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
                "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1"
            ),
        )
        mobile_page = context.new_page()
        try:
            self.open_mobile_invitation_page(mobile_page, invite_url)
            self.fill_mobile_invitation_form(mobile_page, member)
            self.request_mobile_sms_code_with_graphic_retry(mobile_page, invite_url, member)
            self.fill_invitation_sms_code_on_page(mobile_page, member.phone)
            clicked = self.click_text_on_page(
                mobile_page,
                ["加入企业", "注册并加入", "立即加入", "确 认", "确认", "提交"],
            )
            assert clicked, f"移动端邀请页未找到加入/注册按钮；page_text={mobile_page.locator('body').inner_text(timeout=5000)[:1500]}"
            mobile_page.wait_for_timeout(4000)
            text = mobile_page.locator("body").inner_text(timeout=5000)
            assert any(
                key in text
                for key in [
                    "加入成功",
                    "注册成功",
                    "企业管理",
                    "工作台",
                    "主账号",
                    "子账号",
                    "账号登录",
                    "验证码登录",
                    "登录/注册",
                ]
            ), (
                f"子成员邀请注册后未看到成功状态；phone={member.phone}；page_text={text[:1500]}"
            )
            return True
        except Exception as exc:
            if isinstance(exc, InvitationSmsPending):
                raise
            raise
        finally:
            context.close()

    def open_mobile_invitation_page(self, mobile_page: Page, invite_url: str) -> None:
        try:
            mobile_page.goto(invite_url, wait_until="domcontentloaded")
        except Exception as exc:
            if "ERR_ABORTED" not in str(exc):
                raise
        mobile_page.wait_for_timeout(3000)
        for _ in range(20):
            text = mobile_page.locator("body").inner_text(timeout=1000)
            if "加载中" not in text and self.mobile_invitation_form_ready(mobile_page):
                return
            mobile_page.wait_for_timeout(1000)
        raise AssertionError(f"移动端邀请页未加载出注册表单；url={mobile_page.url}；text={mobile_page.locator('body').inner_text(timeout=3000)[:1000]}")

    def fill_mobile_invitation_form(self, mobile_page: Page, member: WanyoujiMemberData) -> None:
        self.wait_for_mobile_invitation_form_ready(mobile_page)
        self.fill_page_input_on_page(mobile_page, ["手机号", "手机号码", "账号"], member.phone, required=True)
        self.fill_page_input_on_page(mobile_page, ["姓名", "成员姓名", "昵称", "联系人"], member.name, required=False)
        self.fill_page_input_on_page(mobile_page, ["密码", "登录密码"], member.password, required=False)
        self.fill_page_input_on_page(mobile_page, ["确认密码", "再次输入密码"], member.password, required=False)

    def wait_for_mobile_invitation_form_ready(self, mobile_page: Page) -> None:
        for _ in range(20):
            if self.mobile_invitation_form_ready(mobile_page):
                return
            mobile_page.wait_for_timeout(1000)
        raise AssertionError(
            f"移动端邀请页未加载出手机号输入框；url={mobile_page.url}；"
            f"text={mobile_page.locator('body').inner_text(timeout=3000)[:1000]}"
        )

    def mobile_invitation_form_ready(self, mobile_page: Page) -> bool:
        return bool(
            mobile_page.evaluate(
                """() => Array.from(document.querySelectorAll('input, textarea, taro-input-core'))
                  .map(el => {
                    const nativeInput = el.matches('input, textarea') ? el : el.querySelector('input, textarea');
                    const target = nativeInput || el;
                    const placeholder = target.getAttribute?.('placeholder') || el.getAttribute?.('placeholder') || '';
                    const r = target.getBoundingClientRect();
                    return { placeholder, visible: r.width > 0 && r.height > 0 };
                  })
                  .some(item => item.visible && /手机|账号/.test(item.placeholder))"""
            )
        )

    def request_mobile_sms_code_with_graphic_retry(
        self,
        mobile_page: Page,
        invite_url: str,
        member: WanyoujiMemberData,
        max_attempts: int = 8,
    ) -> None:
        for attempt in range(max_attempts):
            self.fill_mobile_graphic_captcha_if_present(mobile_page)
            assert self.click_text_on_page(mobile_page, ["获取验证码"]), (
                f"移动端邀请页未找到获取验证码按钮；text={mobile_page.locator('body').inner_text(timeout=5000)[:1000]}"
            )
            mobile_page.wait_for_timeout(2500)
            if self.mobile_sms_request_succeeded(mobile_page):
                return
            if self.mobile_sms_request_too_frequent_visible(mobile_page):
                wait_seconds = int(os.getenv("E2E_SMS_FREQUENT_RETRY_SECONDS", "5"))
                print(
                    f"[E2E member SMS] 移动端验证码请求过于频繁，等待 {wait_seconds} 秒后刷新邀请页重试 "
                    f"({attempt + 1}/{max_attempts})。"
                )
                mobile_page.wait_for_timeout(wait_seconds * 1000)
                self.reset_mobile_invitation_form(mobile_page, invite_url, member)
                continue
            if self.mobile_graphic_captcha_error_visible(mobile_page):
                self.refresh_mobile_graphic_captcha(mobile_page)
                continue
            self.refresh_mobile_graphic_captcha(mobile_page)
        raise AssertionError("移动端邀请页图形验证码多次识别失败，未能成功获取短信验证码")

    def mobile_sms_request_too_frequent_visible(self, mobile_page: Page) -> bool:
        text = mobile_page.locator("body").inner_text(timeout=3000)
        return any(
            key in text
            for key in ["验证码获取过于频繁", "获取验证码过于频繁", "操作过于频繁", "请求过于频繁", "发送过于频繁", "请稍后再试", "稍后重试"]
        )

    def reset_mobile_invitation_form(self, mobile_page: Page, invite_url: str, member: WanyoujiMemberData) -> None:
        self.open_mobile_invitation_page(mobile_page, invite_url)
        self.fill_mobile_invitation_form(mobile_page, member)

    def clear_browser_storage_without_navigation(self) -> None:
        try:
            self.page.context.clear_cookies()
        except Exception:
            pass
        try:
            self.page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
        except Exception:
            pass

    def wait_for_manual_invitation_sms(self, phone: str, timeout_seconds: int = 180) -> None:
        import time

        print("")
        print("[E2E member SMS] 获取验证码已点击。")
        print(f"[E2E member SMS] 请将手机号 {phone} 收到的短信验证码填写到邀请加入页面。")
        print("[E2E member SMS] 检测到页面任意验证码输入框有 4-8 位数字后会自动继续。")
        deadline = time.time() + timeout_seconds
        while time.time() < deadline:
            values = self.page.evaluate(
                """() => Array.from(document.querySelectorAll('input, textarea, taro-input-core'))
                  .map(el => {
                    const nativeInput = el.matches('input, textarea') ? el : el.querySelector('input, textarea');
                    const target = nativeInput || el;
                    const placeholder = target.getAttribute?.('placeholder') || el.getAttribute?.('placeholder') || '';
                    const value = target.value || '';
                    return { placeholder, value };
                  })"""
            )
            for item in values:
                value = str(item.get("value", "")).strip()
                placeholder = str(item.get("placeholder", ""))
                if value.isdigit() and 4 <= len(value) <= 8 and ("验证码" in placeholder or len(values) <= 3):
                    return
            self.page.wait_for_timeout(500)
        raise AssertionError(f"等待手动填写邀请短信验证码超时，手机号：{phone}")

    def wait_for_manual_invitation_sms_on_page(self, target_page: Page, phone: str, timeout_seconds: int = 180) -> None:
        import time

        print("")
        print("[E2E member SMS] 移动端邀请页获取验证码已点击。")
        print(f"[E2E member SMS] 请将手机号 {phone} 收到的短信验证码填写到移动端邀请加入页面。")
        print("[E2E member SMS] 检测到验证码输入框有 4-8 位数字后会自动继续。")
        deadline = time.time() + timeout_seconds
        while time.time() < deadline:
            values = self.page_input_values(target_page)
            for item in values:
                value = str(item.get("value", "")).strip()
                placeholder = str(item.get("placeholder", ""))
                if value.isdigit() and 4 <= len(value) <= 8 and ("验证码" in placeholder or len(values) <= 4):
                    return
            target_page.wait_for_timeout(500)
        raise InvitationSmsPending(phone, target_page.url)

    def fill_invitation_sms_code_on_page(self, target_page: Page, phone: str) -> None:
        if self.code_provider is None or self.code_provider.mode == "manual":
            self.wait_for_manual_invitation_sms_on_page(target_page, phone)
            return

        sms_code = self.code_provider.get_code(phone)
        filled = bool(
            target_page.evaluate(
                """({ smsCode }) => {
                    const inputs = Array.from(document.querySelectorAll('input, textarea, taro-input-core'))
                      .map(el => {
                        const nativeInput = el.matches('input, textarea') ? el : el.querySelector('input, textarea');
                        const target = nativeInput || el;
                        const placeholder = target.getAttribute?.('placeholder') || el.getAttribute?.('placeholder') || '';
                        const r = target.getBoundingClientRect();
                        return { el: target, placeholder, r };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.placeholder.includes('验证码') && !item.placeholder.includes('图形'));
                    const target = inputs[0]?.el;
                    if (!target) return false;
                    target.focus();
                    const proto = target instanceof HTMLTextAreaElement
                      ? window.HTMLTextAreaElement.prototype
                      : window.HTMLInputElement.prototype;
                    const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
                    if (setter) setter.call(target, smsCode);
                    else target.value = smsCode;
                    target.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: smsCode }));
                    target.dispatchEvent(new Event('change', { bubbles: true }));
                    target.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"smsCode": sms_code},
            )
        )
        assert filled, (
            f"移动端邀请页未找到短信验证码输入框；phone={phone}；"
            f"text={target_page.locator('body').inner_text(timeout=5000)[:1000]}"
        )

    def fill_page_input_on_page(self, target_page: Page, labels: list[str], value: str, required: bool) -> bool:
        filled = bool(
            target_page.evaluate(
                """({ labels, value }) => {
                    const inputs = Array.from(document.querySelectorAll('input, textarea, taro-input-core'))
                      .map(el => {
                        const nativeInput = el.matches('input, textarea') ? el : el.querySelector('input, textarea');
                        const target = nativeInput || el;
                        const r = target.getBoundingClientRect();
                        const placeholder = target.getAttribute?.('placeholder') || el.getAttribute?.('placeholder') || '';
                        const itemText = (target.closest?.('label, div, taro-view-core')?.innerText || '').trim();
                        return { el: target, placeholder, itemText, r };
                      })
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => labels.some(label => item.placeholder.includes(label) || item.itemText.includes(label)));
                    const target = inputs[0]?.el;
                    if (!target) return false;
                    target.focus();
                    const proto = target instanceof HTMLTextAreaElement
                      ? window.HTMLTextAreaElement.prototype
                      : window.HTMLInputElement.prototype;
                    const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
                    if (setter) setter.call(target, value);
                    else target.value = value;
                    target.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    target.dispatchEvent(new Event('change', { bubbles: true }));
                    target.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"labels": labels, "value": value},
            )
        )
        assert filled or not required, (
            f"移动端邀请页未找到输入项：{labels}；text={target_page.locator('body').inner_text(timeout=5000)[:1000]}"
        )
        return filled

    def fill_mobile_graphic_captcha_if_present(self, target_page: Page) -> None:
        captcha_input = target_page.locator("input[placeholder*='图形验证码'], taro-input-core[placeholder*='图形验证码']").first
        if captcha_input.count() == 0:
            return
        src = self.mobile_graphic_captcha_src(target_page)
        code = self.image_captcha_solver.solve(decode_data_image(src))
        self.fill_page_input_on_page(target_page, ["图形验证码"], code, required=True)

    def mobile_graphic_captcha_src(self, target_page: Page) -> str:
        src = ""
        for _ in range(10):
            src = target_page.evaluate(
                """() => {
                    const inputs = Array.from(document.querySelectorAll('input, taro-input-core'))
                      .map(el => {
                        const nativeInput = el.matches('input') ? el : el.querySelector('input');
                        const target = nativeInput || el;
                        return { el: target, placeholder: target.getAttribute?.('placeholder') || el.getAttribute?.('placeholder') || '', r: target.getBoundingClientRect() };
                      })
                      .filter(item => item.placeholder.includes('图形验证码'));
                    const input = inputs[0];
                    if (!input) return '';
                    const images = Array.from(document.querySelectorAll('img'))
                      .map(img => ({ img, src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                      .filter(item => item.src.startsWith('data:image') && item.r.width > 30 && item.r.height > 20)
                      .filter(item => Math.abs(item.r.y - input.r.y) < 50 || item.r.y > input.r.y - 20);
                    images.sort((a, b) => Math.abs(a.r.y - input.r.y) - Math.abs(b.r.y - input.r.y));
                    return images[0]?.src || '';
                }"""
            )
            if src:
                break
            target_page.wait_for_timeout(500)
        assert src, f"移动端邀请页未找到图形验证码图片；text={target_page.locator('body').inner_text(timeout=5000)[:1000]}"
        return str(src)

    def refresh_mobile_graphic_captcha(self, target_page: Page) -> None:
        target_page.evaluate(
            """() => {
                const images = Array.from(document.querySelectorAll('img'))
                  .map(img => ({ img, src: img.getAttribute('src') || '', r: img.getBoundingClientRect() }))
                  .filter(item => item.src.startsWith('data:image') && item.r.width > 30 && item.r.height > 20);
                images[0]?.img.click();
            }"""
        )
        target_page.wait_for_timeout(500)

    def mobile_graphic_captcha_error_visible(self, target_page: Page) -> bool:
        text = target_page.locator("body").inner_text(timeout=3000)
        return any(key in text for key in ["图形验证码错误", "图形验证码失败", "图形验证码不正确", "验证码错误", "验证码失败"])

    def mobile_sms_request_succeeded(self, target_page: Page) -> bool:
        text = target_page.locator("body").inner_text(timeout=3000)
        if any(key in text for key in ["验证码已发送", "发送成功", "短信已发送", "重新获取"]):
            return True
        return bool(
            target_page.evaluate(
                """() => Array.from(document.querySelectorAll('button, taro-button-core, taro-view-core, div, span'))
                  .some(el => {
                    const text = (el.innerText || el.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0 && (/\\d+\\s*s/.test(text) || text.includes('重新获取'));
                  })"""
            )
        )

    def click_text_on_page(self, target_page: Page, texts: list[str]) -> bool:
        for text in texts:
            clicked = target_page.evaluate(
                """text => {
                    const nodes = Array.from(document.querySelectorAll('*'));
                    const target = nodes
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text === text || item.text.split(/\\s+/).includes(text) || item.text.includes(text))
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                    if (!target) return false;
                    target.el.click();
                    return true;
                }""",
                text,
            )
            if clicked:
                target_page.wait_for_timeout(800)
                return True
        return False

    def page_input_values(self, target_page: Page) -> list[dict[str, str]]:
        return list(
            target_page.evaluate(
                """() => Array.from(document.querySelectorAll('input, textarea, taro-input-core'))
                  .map(el => {
                    const nativeInput = el.matches('input, textarea') ? el : el.querySelector('input, textarea');
                    const target = nativeInput || el;
                    const placeholder = target.getAttribute?.('placeholder') || el.getAttribute?.('placeholder') || '';
                    const value = target.value || '';
                    return { placeholder, value };
                  })"""
            )
        )

    def wait_for_invitation_inputs(self) -> None:
        for _ in range(10):
            if self.page.locator("input, textarea, taro-input-core").count() > 0:
                return
            text = self.page.locator("body").inner_text(timeout=1000)
            if "获取验证码" in text and "立即加入" in text:
                return
            self.page.wait_for_timeout(1000)
        frame_summaries = []
        for frame in self.page.frames:
            try:
                frame_summaries.append({"url": frame.url, "text": frame.locator("body").inner_text(timeout=1000)[:500]})
            except Exception:
                frame_summaries.append({"url": frame.url, "text": "<unreadable>"})
        raise AssertionError(f"邀请注册链接未加载出注册输入框；url={self.page.url}；frames={frame_summaries}")

    def confirm_member_modal(self, frame: Frame) -> None:
        clicked = bool(
            frame.evaluate(
                """() => {
                    const modals = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(modal => {
                        const r = modal.getBoundingClientRect();
                        const text = modal.innerText || modal.textContent || '';
                        return r.width > 0 && r.height > 0 && text.includes('邀请链接有效期');
                      });
                    const modal = modals[modals.length - 1];
                    if (!modal) return false;
                    const buttons = Array.from(modal.querySelectorAll('button'))
                      .map(button => ({
                        button,
                        text: (button.innerText || button.textContent || '').trim(),
                        r: button.getBoundingClientRect(),
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.button.disabled);
                    const target = buttons.find(item => ['确认创建', '生成邀请链接', '创建邀请链接', '创建链接'].includes(item.text))
                      || buttons.find(item => ['确 认', '确认', '保 存', '保存', '提 交', '提交'].includes(item.text));
                    if (!target) return false;
                    target.button.click();
                    return true;
                }"""
            )
        )
        self.page.wait_for_timeout(1000)
        assert clicked, f"未点到成员弹窗确认按钮；buttons={self.visible_buttons(frame)}"

    def verify_member_visible(self, frame: Frame, member: WanyoujiMemberData) -> None:
        self.search_if_possible(frame, member.phone)
        text = frame.locator("body").inner_text(timeout=5000)
        masked_phone = f"{member.phone[:3]}****{member.phone[-4:]}" if len(member.phone) >= 7 else member.phone
        assert member.phone in text or masked_phone in text, (
            f"新增成员后成员列表未展示手机号 {member.phone}；角色={member.role}；当前文本：{text[:1500]}"
        )
        assert member.role in text or member.name in text, (
            f"新增成员后未展示角色或姓名；phone={member.phone}；role={member.role}；name={member.name}；"
            f"当前文本：{text[:1500]}"
        )

    def confirm_member_invitation_if_needed(self, frame: Frame, member: WanyoujiMemberData) -> None:
        self.select_member_product_filter_if_possible(frame, member.product)
        self.search_if_possible(frame, member.phone)
        text = frame.locator("body").inner_text(timeout=5000)
        masked_phone = f"{member.phone[:3]}****{member.phone[-4:]}" if len(member.phone) >= 7 else member.phone
        assert member.phone in text or masked_phone in text or member.name in text, (
            f"子成员注册后主账号成员管理未找到该成员；phone={member.phone}；name={member.name}；text={text[:1500]}"
        )
        clicked = frame.evaluate(
            """({ phone, maskedPhone, name }) => {
                const rows = Array.from(document.querySelectorAll('.ant-table-tbody tr, tr, .ant-list-item, div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.text.includes(phone) || item.text.includes(maskedPhone) || item.text.includes(name));
                rows.sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const row = rows[0]?.el;
                if (!row) return false;
                const buttons = Array.from(row.querySelectorAll('button, a, span, div'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => ['确认', '确 认', '启用', '通过', '同意', '确认邀请'].some(text => item.text === text || item.text.includes(text)));
                const target = buttons.find(item => item.text === '确认' || item.text === '确 认')?.el || buttons[0]?.el;
                if (!target) return false;
                target.click();
                return true;
            }""",
            {"phone": member.phone, "maskedPhone": masked_phone, "name": member.name},
        )
        if clicked:
            self.page.wait_for_timeout(1000)
            self.confirm_visible_modal_if_present(frame)
            self.click_member_management_tab(frame, ["成员管理"])
            self.select_member_product_filter_if_possible(frame, member.product)
            self.search_if_possible(frame, member.phone)
            for _ in range(8):
                refreshed_text = frame.locator("body").inner_text(timeout=3000)
                if "待确认" not in refreshed_text:
                    break
                self.page.wait_for_timeout(1000)
                self.click_frame_text(frame, ["查 询", "查询"])
                self.page.wait_for_timeout(1000)
        self.verify_member_visible(frame, member)

    def select_member_product_filter_if_possible(self, frame: Frame, product: str) -> None:
        current_text = frame.locator("body").inner_text(timeout=5000)
        if f"产品\n{product}" in current_text or f"当前产品:\n{product}" in current_text:
            return
        try:
            product_item = frame.locator("[class*='searchItem']").filter(has_text="产品").first
            selector = product_item.locator(".ant-select-selector").first
            expect(selector).to_be_visible(timeout=1500)
            selector.click()
            self.page.wait_for_timeout(800)
            clicked_by_locator = self.click_dropdown_option_by_locator(frame, product)
            if clicked_by_locator:
                self.click_frame_text(frame, ["查 询", "查询"])
                self.page.wait_for_timeout(1200)
                text = frame.locator("body").inner_text(timeout=5000)
                assert product in text, f"成员管理产品筛选未切换到 {product}；text={text[:1200]}"
                return
        except Exception:
            pass

        opened = frame.evaluate(
            """product => {
                const searchContainers = Array.from(document.querySelectorAll("[class*='searchContainer'], .ant-form, div"))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 300 && item.r.height > 30)
                  .filter(item => item.text.includes('产品') && item.text.includes('部门') && item.text.includes('角色'))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const root = searchContainers[0]?.el || document;
                const selectors = Array.from(root.querySelectorAll('.ant-select-selector'))
                  .map(el => ({
                    el,
                    text: (el.innerText || el.textContent || '').trim(),
                    itemText: (el.closest("[class*='searchItem'], .ant-form-item, .ant-row, div")?.innerText || '').trim(),
                    r: el.getBoundingClientRect(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.itemText.includes('产品') || item.text.includes(product) || item.text.includes('企业管理'))
                  .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                const target = selectors.find(item => item.itemText.includes('产品'))?.el || selectors[0]?.el;
                if (!target) return false;
                target.click();
                return true;
            }""",
            product,
        )
        if not opened:
            return
        self.page.wait_for_timeout(800)
        clicked = self.click_dropdown_option_by_locator(frame, product)
        self.page.wait_for_timeout(1200)
        if not clicked:
            self.page.keyboard.press("Escape")
        else:
            self.click_frame_text(frame, ["查 询", "查询"])
            self.page.wait_for_timeout(1200)
            text = frame.locator("body").inner_text(timeout=5000)
            assert product in text, f"成员管理产品筛选未切换到 {product}；text={text[:1200]}"

    def click_member_management_tab(self, frame: Frame, texts: list[str]) -> bool:
        for text in texts:
            clicked = frame.evaluate(
                """text => {
                    const nodes = Array.from(document.querySelectorAll('.ant-tabs-tab, .ant-tabs-tab-btn, button, span, div'));
                    const target = nodes
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text === text || item.text.split(/\\s+/).includes(text))
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                    if (!target) return false;
                    target.el.click();
                    return true;
                }""",
                text,
            )
            if clicked:
                self.page.wait_for_timeout(1200)
                return True
        return False

    def confirm_visible_modal_if_present(self, frame: Frame) -> None:
        for _ in range(4):
            clicked = frame.evaluate(
                """() => {
                    const modals = Array.from(document.querySelectorAll('.ant-modal'))
                      .filter(modal => {
                        const r = modal.getBoundingClientRect();
                        return r.width > 0 && r.height > 0;
                      });
                    const modal = modals[modals.length - 1];
                    if (!modal) return false;
                    const buttons = Array.from(modal.querySelectorAll('button'))
                      .map(button => ({ button, text: (button.innerText || button.textContent || '').trim(), r: button.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0 && !item.button.disabled);
                    const target = buttons.find(item => ['确 认', '确认', '确 定', '确定', '通过', '同意'].includes(item.text));
                    if (!target) return false;
                    target.button.click();
                    return true;
                }"""
            )
            if not clicked:
                return
            self.page.wait_for_timeout(1000)

    def search_if_possible(self, frame: Frame, keyword: str) -> None:
        filled = self.fill_modal_or_page_input(frame, ["手机号", "手机号码", "成员姓名", "关键字"], keyword)
        if filled:
            self.click_frame_text(frame, ["查 询", "查询", "搜 索", "搜索"])
            self.page.wait_for_timeout(1500)

    def fill_modal_input_by_label_or_placeholder(self, frame: Frame, labels: list[str], value: str) -> None:
        filled = self.fill_modal_or_page_input(frame, labels, value, modal_only=True)
        assert filled, f"未找到成员表单输入项：{labels}；fields={self.visible_fields(frame)}"

    def fill_optional_modal_input(self, frame: Frame, labels: list[str], value: str) -> None:
        self.fill_modal_or_page_input(frame, labels, value, modal_only=True)

    def fill_page_input_by_label_or_placeholder(self, labels: list[str], value: str) -> None:
        filled = self.fill_page_input(labels, value)
        assert filled, f"未找到邀请注册页面输入项：{labels}；page_text={self.page.locator('body').inner_text(timeout=5000)[:1500]}"

    def fill_optional_page_input(self, labels: list[str], value: str) -> None:
        self.fill_page_input(labels, value)

    def fill_page_input(self, labels: list[str], value: str) -> bool:
        return bool(
            self.page.evaluate(
                """({ labels, value }) => {
                    const inputs = Array.from(document.querySelectorAll('input, textarea, taro-input-core'))
                      .map(el => ({
                        el,
                        placeholder: el.getAttribute('placeholder') || '',
                        r: el.getBoundingClientRect(),
                        itemText: (el.closest('.ant-form-item, label, div')?.innerText || '').trim(),
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => labels.some(label =>
                        item.placeholder.includes(label) || item.itemText.includes(label)
                      ));
                    const target = inputs[0]?.el;
                    if (!target) return false;
                    target.focus();
                    const nativeInput = target.matches('input, textarea') ? target : target.querySelector('input, textarea');
                    const valueTarget = nativeInput || target;
                    const proto = valueTarget instanceof HTMLTextAreaElement
                      ? window.HTMLTextAreaElement.prototype
                      : window.HTMLInputElement.prototype;
                    const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
                    if (setter) {
                      setter.call(valueTarget, value);
                    } else {
                      valueTarget.value = value;
                    }
                    valueTarget.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    valueTarget.dispatchEvent(new Event('change', { bubbles: true }));
                    valueTarget.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"labels": labels, "value": value},
            )
        )

    def fill_modal_or_page_input(
        self,
        frame: Frame,
        labels: list[str],
        value: str,
        modal_only: bool = False,
    ) -> bool:
        return bool(
            frame.evaluate(
                """({ labels, value, modalOnly }) => {
                    const root = modalOnly
                      ? Array.from(document.querySelectorAll('.ant-modal')).filter(el => {
                          const r = el.getBoundingClientRect();
                          return r.width > 0 && r.height > 0;
                        }).pop()
                      : document;
                    if (!root) return false;
                    const inputs = Array.from(root.querySelectorAll('input, textarea'))
                      .map(el => ({
                        el,
                        placeholder: el.getAttribute('placeholder') || '',
                        value: el.value || '',
                        r: el.getBoundingClientRect(),
                        itemText: (el.closest('.ant-form-item')?.innerText || '').trim(),
                      }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => labels.some(label =>
                        item.placeholder.includes(label) || item.itemText.includes(label)
                      ));
                    const target = inputs[0]?.el;
                    if (!target) return false;
                    target.focus();
                    const setter = Object.getOwnPropertyDescriptor(
                      target instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype,
                      'value'
                    ).set;
                    setter.call(target, value);
                    target.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
                    target.dispatchEvent(new Event('change', { bubbles: true }));
                    target.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
                    return true;
                }""",
                {"labels": labels, "value": value, "modalOnly": modal_only},
            )
        )

    def select_modal_option_by_label(
        self,
        frame: Frame,
        labels: list[str],
        option_text: str,
        fallback_index: int | None = None,
    ) -> str:
        selected = self.select_optional_modal_option_by_label(frame, labels, option_text, fallback_index=fallback_index)
        assert selected, (
            f"未选择到下拉项 {option_text}，字段={labels}；fields={self.visible_fields(frame)}；"
            f"dropdown_options={self.visible_dropdown_options(frame)}"
        )
        return selected

    def select_optional_modal_option_by_label(
        self,
        frame: Frame,
        labels: list[str],
        option_text: str,
        fallback_index: int | None = None,
    ) -> str:
        opened_by_locator = self.open_modal_select_by_locator(frame, labels)
        if opened_by_locator:
            self.page.wait_for_timeout(800)
            clicked_by_locator = self.click_dropdown_option_by_locator(frame, option_text)
            if clicked_by_locator:
                return clicked_by_locator

        opened = frame.evaluate(
            """({ labels, fallbackIndex }) => {
                const modal = Array.from(document.querySelectorAll('.ant-modal')).filter(el => {
                  const r = el.getBoundingClientRect();
                  return r.width > 0 && r.height > 0;
                }).pop();
                if (!modal) return false;
                const selectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                  .map(el => ({
                    el,
                    text: (el.innerText || el.textContent || '').trim(),
                    r: el.getBoundingClientRect(),
                    itemText: (el.closest('.ant-form-item')?.innerText || '').trim(),
                  }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => labels.some(label => item.text.includes(label) || item.itemText.includes(label)));
                let target = selectors[0]?.el;
                if (!target && Number.isInteger(fallbackIndex)) {
                  const allSelectors = Array.from(modal.querySelectorAll('.ant-select-selector'))
                    .map(el => ({ el, r: el.getBoundingClientRect() }))
                    .filter(item => item.r.width > 0 && item.r.height > 0)
                    .sort((a, b) => a.r.y - b.r.y || a.r.x - b.r.x);
                  target = allSelectors[fallbackIndex]?.el;
                }
                if (!target) return false;
                target.click();
                return true;
            }""",
            {"labels": labels, "fallbackIndex": fallback_index},
        )
        if not opened:
            return ""
        self.page.wait_for_timeout(800)
        clicked = frame.evaluate(
            """optionText => {
                const options = Array.from(document.querySelectorAll(
                  '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option'
                )).map(el => ({
                  el,
                  text: (el.innerText || el.textContent || '').trim(),
                  r: el.getBoundingClientRect(),
                })).filter(item => item.r.width > 0 && item.r.height > 0);
                const target = options.find(item => item.text.includes(optionText)) || options[0];
                if (!target) return '';
                target.el.click();
                return target.text;
            }""",
            option_text,
        )
        self.page.wait_for_timeout(800)
        return str(clicked or "")

    def open_modal_select_by_locator(self, frame: Frame, labels: list[str]) -> bool:
        field_id = ""
        if any("产品" in label for label in labels):
            field_id = "product"
        elif any("部门" in label for label in labels):
            field_id = "department"
        elif any("角色" in label for label in labels):
            field_id = "role"
        elif any("状态" in label for label in labels):
            field_id = "status"

        if field_id:
            selectors = [
                f".ant-modal input#{field_id}",
                f".ant-modal [id='{field_id}'] input",
                f".ant-modal [aria-controls*='{field_id}']",
            ]
            for css in selectors:
                raw_locator = frame.locator(css).first
                try:
                    expect(raw_locator).to_be_visible(timeout=1000)
                    raw_locator.click()
                    self.page.wait_for_timeout(500)
                    if self.visible_dropdown_options(frame):
                        return True
                except Exception:
                    pass

                selector = frame.locator(css).locator(
                    "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' ant-select-selector ')][1]"
                ).first
                try:
                    expect(selector).to_be_visible(timeout=1000)
                    selector.click()
                    self.page.wait_for_timeout(500)
                    if self.visible_dropdown_options(frame):
                        return True
                except Exception:
                    continue

            if field_id == "role":
                try:
                    overflow = frame.locator(
                        ".ant-modal input#role"
                    ).locator(
                        "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' ant-select ')][1]"
                    ).locator(".ant-select-selection-overflow").first
                    expect(overflow).to_be_visible(timeout=1000)
                    overflow.click()
                    self.page.wait_for_timeout(500)
                    if self.visible_dropdown_options(frame):
                        return True
                except Exception:
                    pass

        placeholder_candidates: list[str] = []
        if any("产品" in label for label in labels):
            placeholder_candidates = ["请选择产品", "请先选择产品", "产品"]
        elif any("角色" in label for label in labels):
            placeholder_candidates = ["请选择角色", "角色"]
        elif any("部门" in label for label in labels):
            placeholder_candidates = ["请选择部门", "部门"]
        elif any("状态" in label for label in labels):
            placeholder_candidates = ["状态"]

        for text in placeholder_candidates:
            selectors = frame.locator(".ant-modal .ant-select-selector").filter(has_text=text)
            for index in range(selectors.count() - 1, -1, -1):
                selector = selectors.nth(index)
                try:
                    expect(selector).to_be_visible(timeout=1000)
                    selector.click(force=True)
                    return True
                except Exception:
                    continue
        return False

    def click_dropdown_option_by_locator(self, frame: Frame, option_text: str) -> str:
        for root in [frame, self.page]:
            options = root.locator(
                ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option"
            )
            count = options.count()
            if count == 0:
                continue
            matched = options.filter(has_text=option_text)
            target = matched.first if matched.count() else options.first
            selected = target.inner_text(timeout=3000).strip()
            target.click(force=True)
            self.page.wait_for_timeout(800)
            return selected
        return ""

    def visible_dropdown_options(self, frame: Frame) -> list[str]:
        options: list[str] = []
        for root in [frame, self.page]:
            try:
                options.extend(
                    list(
                        root.evaluate(
                            """() => Array.from(document.querySelectorAll(
                              '.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option'
                            )).map(el => {
                              const r = el.getBoundingClientRect();
                              return { text: (el.innerText || el.textContent || '').trim(), visible: r.width > 0 && r.height > 0 };
                            }).filter(item => item.visible && item.text).map(item => item.text)"""
                        )
                    )
                )
            except Exception:
                continue
        return options

    def extract_invitation_url(self, frame: Frame) -> str:
        text = frame.locator("body").inner_text(timeout=5000)
        import re

        matches = re.findall(r"https?://[^\s'\"<>]+", text)
        if matches:
            return matches[-1]
        values = frame.evaluate(
            """() => Array.from(document.querySelectorAll('input, textarea, a'))
              .map(el => el.value || el.href || (el.innerText || el.textContent || '').trim())
              .filter(Boolean)"""
        )
        for value in values:
            match = re.search(r"https?://[^\s'\"<>]+", str(value))
            if match:
                return match.group(0)
        return ""

    def click_frame_text(self, frame: Frame, texts: list[str]) -> bool:
        for text in texts:
            clicked = frame.evaluate(
                """text => {
                const nodes = Array.from(document.querySelectorAll('*'));
                    const target = nodes
                      .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                      .filter(item => item.r.width > 0 && item.r.height > 0)
                      .filter(item => item.text === text || item.text.includes(text))
                      .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height))[0];
                    if (!target) return false;
                    target.el.click();
                    return true;
                }""",
                text,
            )
            if clicked:
                self.page.wait_for_timeout(800)
                return True
        return False

    def visible_buttons(self, frame: Frame) -> list[str]:
        return list(
            frame.evaluate(
                """() => Array.from(document.querySelectorAll('button'))
                  .map(button => (button.innerText || button.textContent || '').trim())
                  .filter(Boolean)"""
            )
        )

    def visible_fields(self, frame: Frame) -> list[str]:
        return list(
            frame.evaluate(
                """() => Array.from(document.querySelectorAll('.ant-modal input, .ant-modal textarea, .ant-modal .ant-select-selector'))
                  .map(el => {
                    const formItem = el.closest('.ant-form-item');
                    const text = (formItem?.innerText || el.innerText || el.textContent || '').trim();
                    const placeholder = el.getAttribute?.('placeholder') || '';
                    return `${placeholder}|${text}`;
                  })
                  .filter(Boolean)"""
            )
        )
