from __future__ import annotations

import os
import re

from playwright.sync_api import Frame, Page, expect

from e2e.pages.base_page import BasePage
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.menu_path_mapping import is_production_url, menu_names_for_path


ENTERPRISE_CONSOLE_URL = "https://lianlianyun.shunfju.com/home"


class TenantAuthPage(BasePage):
    def __init__(self, page: Page, tenant_home_url: str, bug_reporter: BugReporter | None = None):
        super().__init__(page)
        self.tenant_home_url = tenant_home_url
        self.bug_reporter = bug_reporter

    def login_main_account(self, phone: str, password: str) -> None:
        if dual_browser_enabled():
            routed_page = dual_browser_manager().page_for_account(phone, seed_page=self.page)
            if routed_page is not self.page:
                self.page = routed_page
                if self.bug_reporter:
                    self.bug_reporter.bind_page(routed_page)
        setattr(self.page, "_e2e_current_player_account", "")
        if self.can_reuse_login(phone):
            if self.bug_reporter:
                self.bug_reporter.clear()
            return

        self.logout_current_account()
        if self.bug_reporter:
            self.bug_reporter.clear()
        self.goto_login_page()
        phone_input = self.page.locator("#password_login_phone")
        self.fill_and_verify(phone_input, phone)

        password_input = self.page.locator("#password_login_password")
        self.fill_and_verify(password_input, password)
        self.click_password_login_submit()
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(2000)
        self.select_main_account_if_present(phone)
        self.ensure_main_account_login_completed(phone, password)
        self.remember_current_login(phone)
        if dual_browser_enabled():
            dual_browser_manager().remember_login(phone)
            assert_account_page(self.page, phone)

    def ensure_main_account_login_completed(self, phone: str, password: str) -> None:
        for _ in range(2):
            try:
                self.goto_enterprise_console()
                self.wait_for_enterprise_console_shell()
                return
            except Exception:
                body_text = self.page.locator("body").inner_text(timeout=5000)
                if "#password_login_phone" not in body_text and "账号登录" not in body_text and "登 录" not in body_text:
                    break
                phone_input = self.page.locator("#password_login_phone")
                password_input = self.page.locator("#password_login_password")
                if phone_input.count() == 0 or password_input.count() == 0:
                    break
                self.fill_and_verify(phone_input, phone)
                self.fill_and_verify(password_input, password)
                self.click_password_login_submit()
                self.page.wait_for_load_state("domcontentloaded")
                self.page.wait_for_timeout(2000)
                self.select_main_account_if_present(phone)
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(f"登录账号 {phone} 后未进入企业控制台，当前页面文本：{body_text[:1000]}")

    def can_reuse_login(self, phone: str) -> bool:
        reuse_default = "1" if dual_browser_enabled() else ""
        if os.getenv("E2E_REUSE_TENANT_LOGIN", reuse_default).lower() not in {"1", "true", "yes"}:
            return False
        if getattr(self.page, "_e2e_current_main_account", "") != phone:
            return False
        try:
            self.goto_enterprise_console()
            self.wait_for_enterprise_console_shell()
            return True
        except Exception:
            return False

    def remember_current_login(self, phone: str) -> None:
        setattr(self.page, "_e2e_current_main_account", phone)

    def forget_current_login(self) -> None:
        setattr(self.page, "_e2e_current_main_account", "")

    def goto_login_page(self) -> None:
        login_url = self.tenant_home_url.replace("/home", "/login")
        last_error: Exception | None = None
        urls = [login_url, f"{login_url}?tab=password", login_url]
        for url in urls:
            try:
                self.goto(url)
                if self.login_context_ready():
                    return
                if self.wait_for_password_login_form(timeout=5000):
                    return
                self.click_account_login_tab_if_present()
                if self.wait_for_password_login_form(timeout=8000):
                    return
                self.page.wait_for_selector("#password_login_phone", state="visible", timeout=3000)
                self.page.wait_for_selector("#password_login_password", state="visible", timeout=3000)
                return
            except Exception as exc:
                last_error = exc
                self.page.wait_for_timeout(1000)
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(f"租户登录页未稳定打开：{login_url}，当前页面文本：{body_text[:1000]}") from last_error

    def wait_for_password_login_form(self, timeout: int = 10000) -> bool:
        try:
            self.page.wait_for_selector("#password_login_phone", state="visible", timeout=timeout)
            self.page.wait_for_selector("#password_login_password", state="visible", timeout=timeout)
            return True
        except Exception:
            return False

    def click_password_login_submit(self) -> None:
        clicked = self.page.evaluate(
            """() => {
                const inputs = Array.from(document.querySelectorAll('input, textarea'))
                  .map(el => ({ el, r: el.getBoundingClientRect(), id: el.id || '', placeholder: el.getAttribute('placeholder') || '' }))
                  .filter(item => item.r.width > 0 && item.r.height > 0)
                  .filter(item => item.id.includes('password_login') || item.placeholder.includes('账号') || 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, [role="button"]'))
                  .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.replace(/\\s/g, '') === '登录');
                const target = buttons
                  .filter(item => !formRect || (
                    item.r.left >= formRect.left - 120 &&
                    item.r.right <= formRect.right + 160 &&
                    item.r.top >= formRect.top &&
                    item.r.top <= formRect.bottom + 220
                  ))
                  .sort((a, b) => b.r.width * b.r.height - a.r.width * a.r.height)[0] || buttons[0];
                if (!target) return false;
                const clickable = target.el.click ? target.el : (target.el.querySelector?.('button, taro-button-core, [role="button"]') || target.el);
                const r = target.r;
                if (clickable.click) {
                  clickable.click();
                } else {
                  clickable.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                  clickable.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.x + r.width / 2, clientY: r.y + r.height / 2 }));
                }
                return true;
            }"""
        )
        if not clicked:
            self.page.locator("#password_login_password").press("Enter")

    def click_account_login_tab_if_present(self) -> None:
        self.page.evaluate(
            """() => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, button'));
                const target = nodes.find(el => {
                    const text = (el.innerText || el.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    return r.width > 0 && r.height > 0 && ['账号登录', '密码登录'].includes(text);
                });
                if (target) target.click();
            }"""
        )
        self.page.wait_for_timeout(800)

    def login_context_ready(self) -> bool:
        try:
            body_text = self.page.locator("body").inner_text(timeout=3000)
        except Exception:
            return False
        return "进入后台" in body_text and "主账号" in body_text or "选择账号" in body_text

    def clear_login_state(self) -> None:
        self.forget_current_login()
        self.page.context.clear_cookies()
        for url in [self.tenant_home_url, self.enterprise_console_url()]:
            try:
                self.goto(url)
                self.page.evaluate("() => { localStorage.clear(); sessionStorage.clear(); }")
            except Exception:
                continue

    def enterprise_console_url(self) -> str:
        return os.getenv("E2E_ENTERPRISE_CONSOLE_URL", "").strip() or self.tenant_home_url or ENTERPRISE_CONSOLE_URL

    def goto_enterprise_console(self) -> None:
        self.goto(self.enterprise_console_url())
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1200)
        self.click_enter_backend_if_present()

    def click_enter_backend_if_present(self) -> bool:
        click_script = """() => {
            const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, button, a'));
            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 === '进入后台' || item.text.includes('进入后台'))
              .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;
        }"""
        before_pages = list(self.page.context.pages)
        try:
            with self.page.context.expect_page(timeout=3000) as new_page_info:
                clicked = self.page.evaluate(click_script)
            if clicked:
                previous_page = self.page
                new_page = new_page_info.value
                new_page.wait_for_load_state("domcontentloaded")
                self.page = new_page
                self.inherit_login_marks(previous_page, new_page)
        except Exception:
            clicked = self.page.evaluate(click_script)
            after_pages = list(self.page.context.pages)
            if clicked and len(after_pages) > len(before_pages):
                previous_page = self.page
                self.page = after_pages[-1]
                self.inherit_login_marks(previous_page, self.page)
        if clicked:
            self.page.wait_for_load_state("domcontentloaded")
            self.page.wait_for_timeout(2000)
        return bool(clicked)

    def inherit_login_marks(self, previous_page: Page, new_page: Page) -> None:
        current_phone = str(getattr(previous_page, "_e2e_current_main_account", ""))
        for attr in [
            "_e2e_dual_browser_role",
            "_e2e_dual_browser_channel",
            "_e2e_current_main_account",
            "_e2e_current_player_account",
        ]:
            if hasattr(previous_page, attr):
                setattr(new_page, attr, getattr(previous_page, attr))
        if dual_browser_enabled() and current_phone:
            dual_browser_manager().register_page_for_account(current_phone, new_page)
        elif dual_browser_enabled():
            current_role = str(getattr(new_page, "_e2e_dual_browser_role", ""))
            if current_role:
                dual_browser_manager().register_page_for_role(current_role, new_page)

    def logout_current_account(self) -> None:
        self.forget_current_login()
        try:
            self.goto(self.tenant_home_url)
            self.page.wait_for_load_state("domcontentloaded")
            self.page.wait_for_timeout(1500)
            if self.click_logout_from_user_menu():
                return
        except Exception:
            pass
        self.clear_login_state()

    def click_logout_from_user_menu(self) -> bool:
        body_text = self.page.locator("body").inner_text(timeout=3000)
        if "退出登录" in body_text:
            if self.click_visible_text_by_js("退出登录"):
                self.confirm_logout_if_present()
                return True

        opened = self.page.evaluate(
            """() => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span'));
                const target = nodes
                  .map(el => ({ el, text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.text.includes('主账号') && item.r.y < 120 && item.r.width > 60 && item.r.height > 16)
                  .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;
            }"""
        )
        if not opened:
            return False
        self.page.wait_for_timeout(800)
        if not self.click_visible_text_by_js("退出登录"):
            return False
        self.confirm_logout_if_present()
        return True

    def click_visible_text_by_js(self, text: str) -> bool:
        clicked = self.page.evaluate(
            """text => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, button'));
                const target = nodes.find(el => {
                    const value = (el.innerText || el.textContent || '').trim();
                    const r = el.getBoundingClientRect();
                    return value === text && r.width > 0 && r.height > 0;
                });
                if (!target) return false;
                target.click();
                return true;
            }""",
            text,
        )
        self.page.wait_for_timeout(1000)
        return bool(clicked)

    def confirm_logout_if_present(self) -> None:
        for text in ["确 定", "确定", "确认", "退 出", "退出"]:
            try:
                locator = self.page.locator(".ant-modal button").filter(has_text=text).first
                if locator.count():
                    locator.click(timeout=1000)
                    self.page.wait_for_timeout(1000)
                    return
            except Exception:
                continue

    def open_account_switcher(self) -> bool:
        body_text = self.page.locator("body").inner_text(timeout=3000)
        if "/account/select" in self.page.url or "选择账号" in body_text or "閫夋嫨璐﹀彿" in body_text:
            return True
        opened = self.page.evaluate(
            """() => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, button'));
                const visible = nodes
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 0 && item.r.height > 0);
                const direct = visible.find(item => item.text === '切换账号' || item.text.includes('切换账号'));
                if (direct) {
                  direct.el.click();
                  return true;
                }
                const header = visible
                  .filter(item => item.r.y < 120 && item.r.x > window.innerWidth * 0.55)
                  .sort((a, b) => b.r.x - a.r.x)[0];
                if (!header) return false;
                header.el.click();
                return true;
            }"""
        )
        self.page.wait_for_timeout(800)
        if not opened:
            return False
        clicked = self.page.evaluate(
            """() => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span, button'));
                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)
                  .find(item => item.text === '切换账号' || item.text.includes('切换账号'));
                if (!target) return false;
                target.el.click();
                return true;
            }"""
        )
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1200)
        body_text = self.page.locator("body").inner_text(timeout=3000)
        return bool(clicked) or "/account/select" in self.page.url or "选择账号" in body_text or "閫夋嫨璐﹀彿" in body_text

    def switch_account_context(
        self,
        *,
        account_type: str = "main",
        role: str = "",
        enterprise_keyword: str = "",
    ) -> None:
        if not self.open_account_switcher():
            raise AssertionError("未找到切换账号入口")
        self.select_account_card(account_type=account_type, role=role, enterprise_keyword=enterprise_keyword)

    def select_account_card(
        self,
        *,
        account_type: str = "main",
        role: str = "",
        enterprise_keyword: str = "",
    ) -> None:
        assert account_type in {"main", "sub"}, f"Unsupported account_type: {account_type}"
        box = self.page.evaluate(
            """({ accountType, role, enterpriseKeyword }) => {
                const typeTexts = accountType === 'sub' ? ['子账号', '瀛愯处鍙?'] : ['主账号', '涓昏处鍙?'];
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div'));
                const candidates = nodes
                  .map(el => ({ text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.r.width > 240 && item.r.width < 900 && item.r.height > 36 && item.r.height < 190)
                  .filter(item => item.r.bottom > 0 && item.r.top < window.innerHeight)
                  .filter(item => typeTexts.some(typeText => item.text.includes(typeText)))
                  .filter(item => !role || item.text.includes(role))
                  .filter(item => !enterpriseKeyword || item.text.includes(enterpriseKeyword))
                  .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                const target = candidates[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 };
            }""",
            {"accountType": account_type, "role": role, "enterpriseKeyword": enterprise_keyword},
        )
        body_text = self.page.locator("body").inner_text(timeout=5000)
        assert box, (
            f"未找到账号卡片：account_type={account_type}, role={role}, "
            f"enterprise_keyword={enterprise_keyword}; text={body_text[:1000]}"
        )
        self.page.mouse.click(box["x"] + box["width"] - 30, box["y"] + box["height"] / 2)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(2000)

    def select_main_account_if_present(self, phone: str) -> 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
        for _ in range(20):
            if "加载中" not in body_text:
                break
            self.page.wait_for_timeout(500)
            body_text = self.page.locator("body").inner_text(timeout=5000)

        box = self.page.evaluate(
            """() => {
                const typedNodes = Array.from(document.querySelectorAll('[class*="accountType"]'))
                  .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.text.includes('主账号') && item.r.width > 0 && item.r.height > 0);
                for (const item of typedNodes) {
                  const card = item.el.closest('[class*="accountItem"], [class*="account"], taro-view-core, div');
                  const containers = [card, card?.parentElement, card?.parentElement?.parentElement].filter(Boolean);
                  const target = containers
                    .map(el => ({ text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                    .filter(candidate => candidate.r.width > 240 && candidate.r.width < 900 && candidate.r.height > 36 && candidate.r.height < 190)
                    .sort((a, b) => (b.r.width * b.r.height) - (a.r.width * a.r.height))[0];
                  if (target) return { x: target.r.x, y: target.r.y, width: target.r.width, height: target.r.height };
                }
                const candidates = Array.from(document.querySelectorAll('taro-view-core, div'))
                  .map(el => ({ text: el.innerText || el.textContent || '', r: el.getBoundingClientRect() }))
                  .filter(item => item.text.includes('主账号'))
                  .filter(item => item.r.width > 240 && item.r.width < 900 && item.r.height > 36 && item.r.height < 190)
                  .filter(item => item.r.bottom > 0 && item.r.top < window.innerHeight)
                  .sort((a, b) => {
                    const aArea = a.r.width * a.r.height;
                    const bArea = b.r.width * b.r.height;
                    if (aArea !== bArea) return aArea - bArea;
                    return a.r.y - b.r.y;
                  });
                const target = candidates[0];
                if (!target) return null;
                return { x: target.r.x, y: target.r.y, width: target.r.width, height: target.r.height };
            }"""
        )
        if not box:
            raise AssertionError(f"登录账号 {phone} 未找到主账号卡片，当前页面文本：{body_text[:800]}")
        self.page.mouse.click(box["x"] + box["width"] - 30, box["y"] + box["height"] / 2)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(2000)

        selected_text = self.page.locator("body").inner_text(timeout=5000)
        if "选择账号" in selected_text and "主账号" not in selected_text:
            raise AssertionError(f"登录账号 {phone} 未进入主账号上下文，当前页面文本：{selected_text[:800]}")

    def open_enterprise_auth_menu(self, leaf_menu: str) -> None:
        parent_menus = ["企业", "企业组织管理"]
        leaf_menus = {
            "授权密钥": ["授权密钥", "生成授权密钥"],
            "我的授权": ["我的授权", "激活授权秘钥", "激活授权密钥"],
        }.get(leaf_menu, [leaf_menu])
        expected_frame = "/secret" if leaf_menu == "授权密钥" else "/myAuth" if leaf_menu == "我的授权" else ""
        if is_production_url(self.tenant_home_url):
            expected_frame = ""
        path_by_leaf = {"授权密钥": "/secret", "我的授权": "/myAuth"}
        last_urls: list[str] = []
        last_error: Exception | None = None
        for _ in range(4):
            for parent_menu in parent_menus:
                for target_leaf in leaf_menus:
                    try:
                        self.goto_enterprise_console()
                        self.wait_for_enterprise_console_shell()
                        self.click_sidebar_text(parent_menu, right_edge=True)
                        self.click_sidebar_text("授权管理", right_edge=True)
                        if leaf_menu in path_by_leaf:
                            self.click_sidebar_path(
                                path_by_leaf[leaf_menu],
                                app_hint="saas企业管理",
                                fallback_texts=leaf_menus,
                            )
                        else:
                            self.click_sidebar_text(target_leaf)
                        self.page.wait_for_timeout(2500)
                        last_urls = [frame.url for frame in self.page.frames]
                        if not expected_frame or any(expected_frame in url for url in last_urls):
                            return
                    except Exception as exc:
                        last_error = exc
                        continue
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(
            f"点击授权菜单 {leaf_menu} 后未进入目标页面；已尝试父菜单={parent_menus}，"
            f"叶子菜单={leaf_menus}，expected_frame={expected_frame}，frames={last_urls}，"
            f"当前页面文本：{body_text[:1200]}"
        ) from last_error

    def wait_for_enterprise_console_shell(self) -> None:
        for _ in range(6):
            try:
                self.page.wait_for_load_state("domcontentloaded")
                body_text = self.page.locator("body").inner_text(timeout=3000)
                if "账号登录" in body_text and "登录/注册" in body_text:
                    raise AssertionError(f"仍停留在登录页，未进入企业控制台：{body_text[:500]}")
                if body_text.strip() and ("企业" in body_text or "商家管理" in body_text or "授权管理" in body_text):
                    return
            except Exception:
                pass
            self.page.wait_for_timeout(1000)
        self.page.reload(wait_until="domcontentloaded")
        self.page.wait_for_timeout(3000)
        body_text = self.page.locator("body").inner_text(timeout=5000)
        if "账号登录" in body_text and "登录/注册" in body_text:
            raise AssertionError(f"仍停留在登录页，未进入企业控制台：{body_text[:500]}")

    def click_sidebar_parent(self, text: str) -> None:
        box = self.sidebar_box(text, parent=True)
        self.page.mouse.click(box["x"] + box["width"] - 16, box["y"] + box["height"] / 2)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)

    def click_sidebar_leaf(self, text: str) -> None:
        box = self.sidebar_box(text, parent=False)
        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(1000)

    def click_sidebar_text(self, text: str, right_edge: bool = False) -> None:
        box = self.sidebar_box(text, parent=right_edge)
        x = box["x"] + box["width"] - 16 if right_edge else box["x"] + box["width"] / 2
        self.page.mouse.click(x, box["y"] + box["height"] / 2)
        self.page.wait_for_load_state("domcontentloaded")
        self.page.wait_for_timeout(1000)

    def click_sidebar_path(
        self,
        path: str,
        *,
        right_edge: bool = False,
        app_hint: str = "",
        component: str = "",
        fallback_texts: list[str] | None = None,
    ) -> str:
        candidates = menu_names_for_path(
            path,
            production=is_production_url(self.tenant_home_url or self.page.url),
            app_hint=app_hint,
            component=component,
        )
        for text in fallback_texts or []:
            if text and text not in candidates:
                candidates.append(text)
        last_error: Exception | None = None
        for text in candidates:
            try:
                self.click_sidebar_text(text, right_edge=right_edge)
                return text
            except Exception as exc:
                last_error = exc
                continue
        body_text = self.page.locator("body").inner_text(timeout=5000)
        raise AssertionError(
            f"未按菜单 path 找到左侧菜单：path={path}，app_hint={app_hint}，"
            f"候选菜单={candidates}，当前页面文本：{body_text[:1000]}"
        ) from last_error

    def sidebar_box(self, text: str, parent: bool) -> dict:
        box = self.page.evaluate(
            """({ text, parent }) => {
                const nodes = Array.from(document.querySelectorAll('taro-view-core, div, span'));
                const rows = nodes
                  .map(el => ({ text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
                  .filter(item => item.r.x >= 0 && item.r.x < 210 && item.r.y > 40)
                  .filter(item => item.r.width > 20 && item.r.width < 210 && item.r.height > 14)
                  .filter(item => item.text === text || item.text.split(/\\s+/).includes(text))
                  .filter(item => parent ? item.r.height >= 30 : true)
                  .sort((a, b) => {
                    if (a.text === text && b.text !== text) return -1;
                    if (b.text === text && a.text !== text) return 1;
                    return (a.r.width * a.r.height) - (b.r.width * b.r.height);
                  });
                const target = rows[0];
                if (!target) return null;
                return { x: target.r.x, y: target.r.y, width: target.r.width, height: target.r.height };
            }""",
            {"text": text, "parent": parent},
        )
        if not box:
            body_text = self.page.locator("body").inner_text(timeout=5000)
            raise AssertionError(f"未找到左侧菜单 {text}，当前页面文本：{body_text[:1000]}")
        return box

    def auth_key_frame(self) -> Frame:
        return self.frame_by_url_part("/secret")

    def my_auth_frame(self) -> Frame:
        return self.frame_by_url_part("/myAuth")

    def frame_by_url_part(self, url_part: str) -> Frame:
        for frame in self.page.frames:
            if url_part in frame.url:
                return frame
        urls = [frame.url for frame in self.page.frames]
        raise AssertionError(f"未找到授权 iframe {url_part}，当前 frames：{urls}")

    def generate_wanyouji_key(self) -> str:
        self.open_enterprise_auth_menu("授权密钥")
        frame = self.auth_key_frame()
        before_keys = set(self.extract_auth_keys(frame.locator("body").inner_text(timeout=5000)))

        self.click_frame_button(frame, "生成授权密钥")
        self.select_modal_option(frame, 0, "丸友集")
        selected_text = frame.locator(".ant-modal .ant-select-selector").first.inner_text(timeout=3000)
        assert "丸友集" in selected_text, f"生成授权密钥弹窗未成功选择丸友集，当前选择：{selected_text}"
        self.click_frame_button(frame, "生成密钥")
        self.page.wait_for_timeout(3000)

        text = frame.locator("body").inner_text(timeout=5000)
        after_keys = self.extract_auth_keys(text)
        new_keys = [key for key in after_keys if key not in before_keys]
        key = new_keys[0] if new_keys else self.extract_latest_table_key(frame)
        if not key:
            raise AssertionError(f"生成授权密钥后未在表格中找到新增密钥，当前页面文本：{text[:1200]}")
        return key

    def activate_key_for_wanzhanggui(self, auth_key: str) -> None:
        self.open_enterprise_auth_menu("我的授权")
        frame = self.my_auth_frame()
        if auth_key in frame.locator("body").inner_text(timeout=5000):
            return

        self.click_frame_button(frame, "激活密钥")
        self.select_modal_option(frame, 0, "丸掌柜")
        selected_text = frame.locator(".ant-modal .ant-select-selector").first.inner_text(timeout=3000)
        assert "丸掌柜" in selected_text, f"激活密钥弹窗未成功选择丸掌柜，当前选择：{selected_text}"
        frame.locator(".ant-modal input[placeholder='请输入授权密钥']").fill(auth_key)
        expect(frame.locator(".ant-modal input[placeholder='请输入授权密钥']")).to_have_value(auth_key, timeout=3000)
        self.click_frame_button(frame, "验证密钥")
        self.confirm_modal_until_closed_or_key_visible(frame, auth_key)
        self.click_optional_frame_buttons(frame, ["查 询", "查询"])
        self.page.wait_for_timeout(2000)

        text = frame.locator("body").inner_text(timeout=5000)
        duplicate_message = "该提供方的产品授权已绑定，请勿重复绑定"
        assert duplicate_message not in text, (
            f"丸掌柜未新增本次激活密钥 {auth_key}，页面提示：{duplicate_message}。"
            f"当前页面文本：{text[:1200]}"
        )
        assert auth_key in text, f"丸掌柜我的授权未新增本次激活密钥 {auth_key}，当前页面文本：{text[:1200]}"

    def confirm_modal_until_closed_or_key_visible(self, frame: Frame, auth_key: str) -> None:
        for _ in range(8):
            body_text = frame.locator("body").inner_text(timeout=5000)
            if auth_key in body_text and not frame.locator(".ant-modal").count():
                return

            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'))
                      .filter(button => {
                        const text = (button.innerText || button.textContent || '').trim();
                        const r = button.getBoundingClientRect();
                        const cls = String(button.className || '');
                        return r.width > 0 && r.height > 0 &&
                          !button.disabled &&
                          text !== '取 消' &&
                          text !== '取消' &&
                          (cls.includes('ant-btn-primary') || /确定|确 定|保存|保 存|提交|提 交|激活|验证密钥/.test(text));
                      });
                    const target = buttons[buttons.length - 1];
                    if (!target) return false;
                    target.click();
                    return true;
                }"""
            )
            self.page.wait_for_timeout(1500)
            if not clicked:
                break

    def verify_and_save_wanyouji_permissions(self, auth_key: str) -> None:
        self.open_enterprise_auth_menu("授权密钥")
        frame = self.auth_key_frame()
        body_text = frame.locator("body").inner_text(timeout=5000)
        assert auth_key in body_text, f"丸友集授权密钥页未找到已生成密钥 {auth_key}，当前页面文本：{body_text[:1200]}"

        self.click_row_action(frame, auth_key, "管理授权")
        self.page.wait_for_timeout(2500)
        text = frame.locator("body").inner_text(timeout=5000)
        assert "福通互通" in text, f"管理授权中未出现福通互通功能权限，当前页面文本：{text[:1500]}"
        self.check_all_visible_permissions(frame)
        required_permissions = ["福通互通"]
        if "shunfju.com" not in self.tenant_home_url:
            required_permissions.extend(["订单基本信息", "修改订单", "创建订单"])
        self.check_permissions_by_label(frame, required_permissions)
        unchecked = self.unchecked_permission_labels(frame)
        assert not unchecked, f"丸友集管理授权保存前仍有未勾选权限：{unchecked}"
        self.click_optional_frame_buttons(frame, ["保 存", "保存", "确 定", "确定"])

    def verify_wanzhanggui_authorized_permissions(self, auth_key: str, required_permissions: list[str]) -> None:
        self.open_enterprise_auth_menu("我的授权")
        frame = self.my_auth_frame()
        body_text = frame.locator("body").inner_text(timeout=5000)
        assert auth_key in body_text, f"丸掌柜我的授权页未找到已激活密钥 {auth_key}，当前页面文本：{body_text[:1200]}"

        self.click_row_action(frame, auth_key, "查看")
        self.page.wait_for_timeout(2500)
        detail_text = frame.locator("body").inner_text(timeout=5000)
        for permission in required_permissions:
            assert permission in detail_text, f"丸掌柜授权详情未展示权限「{permission}」，当前详情文本：{detail_text[:1500]}"
            assert self.permission_checked(frame, permission), f"丸掌柜授权详情中权限「{permission}」未勾选，当前详情文本：{detail_text[:1500]}"

    def click_frame_button(self, frame: Frame, text: str) -> None:
        buttons = frame.locator("button").filter(has_text=text)
        try:
            expect(buttons.first).to_be_visible(timeout=5000)
            buttons.first.click(timeout=5000)
        except Exception:
            body_text = frame.locator("body").inner_text(timeout=5000)
            raise AssertionError(f"未点击到按钮 {text}，当前 iframe 文本：{body_text[:1000]}")
        self.page.wait_for_timeout(1000)

    def click_optional_frame_buttons(self, frame: Frame, texts: list[str]) -> bool:
        for text in texts:
            try:
                self.click_frame_button(frame, text)
                return True
            except AssertionError:
                continue
        return False

    def select_modal_option(self, frame: Frame, select_index: int, option_text: str) -> None:
        frame.locator(".ant-modal .ant-select-selector").nth(select_index).click()
        self.page.wait_for_timeout(800)
        option = frame.locator(".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option-content").filter(
            has_text=option_text
        ).first
        expect(option).to_be_visible(timeout=5000)
        option.click()
        self.page.wait_for_timeout(800)

    def permission_checked(self, frame: Frame, permission: str) -> bool:
        return bool(
            frame.evaluate(
                """permission => {
                    const wrappers = Array.from(document.querySelectorAll('.ant-checkbox-wrapper, label'));
                    return wrappers.some(el => {
                        const text = (el.innerText || el.textContent || '').trim();
                        if (!text.includes(permission)) return false;
                        const input = el.querySelector('input[type="checkbox"]');
                        const checkbox = el.querySelector('.ant-checkbox') || el;
                        return Boolean(input?.checked) ||
                            String(checkbox.className).includes('ant-checkbox-checked') ||
                            String(el.className).includes('ant-checkbox-wrapper-checked');
                    });
                }""",
                permission,
            )
        )

    def click_row_action(self, frame: Frame, row_key: str, action_text: str) -> None:
        row = frame.locator(".ant-table-tbody tr").filter(has_text=row_key).first
        try:
            expect(row).to_be_visible(timeout=5000)
            row.get_by_text(action_text, exact=True).click(timeout=5000, force=True)
            self.page.wait_for_timeout(1000)
            return
        except Exception:
            text = frame.locator("body").inner_text(timeout=5000)
            raise AssertionError(f"未在密钥行点击 {action_text}，密钥：{row_key}，页面文本：{text[:1200]}")

    def check_all_visible_permissions(self, frame: Frame) -> None:
        for _ in range(3):
            frame.evaluate(
                """() => {
                    const expanders = Array.from(document.querySelectorAll(
                        '.ant-tree-switcher_close, .ant-tree-switcher:not(.ant-tree-switcher-noop)'
                    ));
                    expanders.forEach(el => {
                        if (String(el.className).includes('close')) el.click();
                    });
                }"""
            )
            self.page.wait_for_timeout(500)
            clicked = frame.evaluate(
                """() => {
                    let clicked = 0;
                    const wrappers = Array.from(document.querySelectorAll(
                        '.ant-checkbox-wrapper:not(.ant-checkbox-wrapper-disabled), .ant-tree-checkbox:not(.ant-tree-checkbox-disabled), .ant-checkbox:not(.ant-checkbox-disabled)'
                    ));
                    wrappers.forEach(wrapper => {
                        const text = (wrapper.innerText || wrapper.textContent || '').trim();
                        const input = wrapper.querySelector('input[type="checkbox"]');
                        const checked = Boolean(input?.checked) ||
                            String(wrapper.className).includes('ant-checkbox-wrapper-checked') ||
                            String(wrapper.className).includes('ant-checkbox-checked') ||
                            String(wrapper.className).includes('ant-tree-checkbox-checked');
                        const disabled = Boolean(input?.disabled) ||
                            String(wrapper.className).includes('disabled');
                        if (!checked && !disabled) {
                            const target = input || wrapper;
                            target.click();
                            clicked += 1;
                        }
                    });
                    return clicked;
                }"""
            )
            self.page.wait_for_timeout(800)
            if clicked == 0:
                break

    def check_permissions_by_label(self, frame: Frame, permissions: list[str]) -> None:
        for permission in permissions:
            for _ in range(4):
                checked = self.permission_checked(frame, permission)
                if checked:
                    break
                clicked = frame.evaluate(
                    """permission => {
                        const nodes = Array.from(document.querySelectorAll(
                            '.ant-checkbox-wrapper, .ant-tree-treenode, label, div, span'
                        ));
                        const candidates = 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.includes(permission))
                          .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
                        for (const item of candidates) {
                          const container = item.el.closest('.ant-tree-treenode, label, .ant-checkbox-wrapper, .ant-row, div') || item.el;
                          const checkbox = container.querySelector(
                            '.ant-checkbox:not(.ant-checkbox-disabled), .ant-tree-checkbox:not(.ant-tree-checkbox-disabled), input[type="checkbox"]:not(:disabled)'
                          );
                          if (!checkbox) continue;
                          const input = checkbox.matches('input') ? checkbox : checkbox.querySelector('input[type="checkbox"]');
                          const checked = Boolean(input?.checked) ||
                            String(checkbox.className).includes('ant-checkbox-checked') ||
                            String(checkbox.className).includes('ant-tree-checkbox-checked') ||
                            String(container.className).includes('ant-checkbox-wrapper-checked');
                          if (!checked) {
                            (input || checkbox).click();
                            return true;
                          }
                        }
                        return false;
                    }""",
                    permission,
                )
                self.page.wait_for_timeout(700)
                if not clicked:
                    break
            assert self.permission_checked(frame, permission), f"权限「{permission}」未能勾选成功"

    def unchecked_permission_labels(self, frame: Frame) -> list[str]:
        return list(
            frame.evaluate(
                """() => {
                    const labels = [];
                    const wrappers = Array.from(document.querySelectorAll(
                        '.ant-checkbox-wrapper:not(.ant-checkbox-wrapper-disabled), .ant-tree-checkbox:not(.ant-tree-checkbox-disabled), .ant-checkbox:not(.ant-checkbox-disabled)'
                    ));
                    wrappers.forEach(wrapper => {
                        const rect = wrapper.getBoundingClientRect();
                        if (!rect.width || !rect.height) return;
                        const input = wrapper.querySelector('input[type="checkbox"]');
                        const checked = Boolean(input?.checked) ||
                            String(wrapper.className).includes('ant-checkbox-wrapper-checked') ||
                            String(wrapper.className).includes('ant-checkbox-checked') ||
                            String(wrapper.className).includes('ant-tree-checkbox-checked');
                        const disabled = Boolean(input?.disabled) ||
                            String(wrapper.className).includes('disabled');
                        if (checked || disabled) return;
                        const text = (wrapper.innerText || wrapper.textContent || '').trim();
                        const row = wrapper.closest('label, .ant-tree-treenode, .ant-form-item, .ant-row, div');
                        const rowText = (row?.innerText || row?.textContent || '').trim();
                        labels.push(text || rowText || '<unknown>');
                    });
                    return Array.from(new Set(labels)).filter(Boolean);
                }"""
            )
        )
        self.page.wait_for_timeout(800)

    def extract_auth_key(self, text: str) -> str:
        matches = self.extract_auth_keys(text)
        for match in matches:
            if match not in {
                "6c829f6d43964a089078a2fabd6ae45c",
                "cdec2858dd07491d8d1c0326b9af7eef",
                "78d8fc0ce6e244b89089eb68083ecb27",
                "2fca606cc7ac45a39929ca54fa428426",
            }:
                return match
        return matches[0] if matches else ""

    def extract_auth_keys(self, text: str) -> list[str]:
        return re.findall(r"\b[a-f0-9]{32}\b", text)

    def extract_latest_table_key(self, frame: Frame) -> str:
        return str(
            frame.evaluate(
                """() => {
                    const rows = Array.from(document.querySelectorAll('.ant-table-tbody tr'));
                    for (const row of rows) {
                        const text = row.innerText || row.textContent || '';
                        const match = text.match(/\\b[a-f0-9]{32}\\b/);
                        if (match) return match[0];
                    }
                    return '';
                }"""
            )
        )
