from __future__ import annotations

import os
import sys
from pathlib import Path
from tempfile import NamedTemporaryFile

from dotenv import load_dotenv
from playwright.sync_api import Page, sync_playwright

ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
    sys.path.insert(0, str(ROOT_DIR))

from e2e.utils.account_store import account_store
from e2e.utils.image_captcha import ImageCaptchaSolver, decode_data_image
from e2e.utils.verification_code import FeishuConfig, VerificationCodeProvider


TENANT_HOME_URL = "https://test-lianlianyun.lianlianwz.com/home"
WANYOUJI_CONSOLE_URL = "https://test-merchant-lianlianyun.lianlianwz.com/productConsole?productCode=P20250814095939369041"
OUT_DIR = Path("runtime/member_probe/customer_service_sms_login")


def main() -> None:
    load_dotenv()
    account = latest_customer_service_account()
    provider = VerificationCodeProvider(
        mode=os.getenv("E2E_VERIFICATION_CODE_MODE", "feishu"),
        static_code=os.getenv("E2E_VERIFICATION_CODE_STATIC", ""),
        manual_code_file=os.getenv("E2E_MANUAL_CODE_FILE", "runtime/sms_code.txt"),
        manual_timeout_seconds=int(os.getenv("E2E_MANUAL_CODE_TIMEOUT_SECONDS", "180")),
        manual_poll_interval_seconds=int(os.getenv("E2E_MANUAL_CODE_POLL_INTERVAL_SECONDS", "2")),
        feishu_config=FeishuConfig(
            app_id=os.getenv("FEISHU_APP_ID", ""),
            app_secret=os.getenv("FEISHU_APP_SECRET", ""),
            chat_id=os.getenv("FEISHU_CHAT_ID", ""),
            timeout_seconds=int(os.getenv("FEISHU_TIMEOUT_SECONDS", "60")),
            poll_interval_seconds=int(os.getenv("FEISHU_POLL_INTERVAL_SECONDS", "3")),
        ),
    )

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    with sync_playwright() as playwright:
        browser = playwright.chromium.launch(headless=False, slow_mo=150)
        context = browser.new_context(viewport={"width": 1920, "height": 1080})
        page = context.new_page()
        login_by_sms_code(page, account.phone, provider)
        dump(page, "01_验证码登录后")

        set_login_password_from_user_menu(page, account.phone, account.password, provider)
        dump(page, "02_修改密码后")

        login_by_password(page, account.phone, account.password)
        dump(page, "03_密码登录验证后")

        page.goto(WANYOUJI_CONSOLE_URL, wait_until="domcontentloaded")
        page.wait_for_timeout(5000)
        dump(page, "04_丸友集控制台")

        probe_clicks(page, ["收单应用", "客户自助填单", "打手订单"])
        dump(page, "05_入口探测后")

        page.wait_for_timeout(8000)
        context.close()
        browser.close()

    store = account_store()
    parent = store.find(phone=account.parent_phone) if account.parent_phone else None
    if parent is not None:
        store.mark_wanyouji_member_created(
            parent=parent,
            phone=account.phone,
            password=account.password,
            role=account.member_role,
            contact_name=account.contact_name,
            status="registered",
            invite_url=account.member_invite_url,
        )

    print(f"客服验证码登录探测完成：{account.phone}")
    print(f"探测结果目录：{OUT_DIR.resolve()}")


def latest_customer_service_account():
    store = account_store()
    account = next(
        (
            item
            for item in reversed(store.load())
            if item.account_type == "sub"
            and item.member_role == "客服"
            and item.member_status in {"invited", "registered", "joined"}
        ),
        None,
    )
    if account is None:
        raise SystemExit("台账中没有可用于验证码登录探测的客服子账号")
    return account


def login_by_sms_code(page: Page, phone: str, provider: VerificationCodeProvider) -> None:
    page.context.clear_cookies()
    page.goto(TENANT_HOME_URL.replace("/home", "/login"), wait_until="domcontentloaded")
    wait_for_login_page_ready(page)
    click_text(page, ["验证码登录", "短信登录"])
    wait_for_login_page_ready(page)
    fill_input(page, ["手机号", "手机号码", "账号"], phone, required=True)
    request_login_sms_code_with_graphic_retry(page)
    sms_code = provider.get_code(phone)
    fill_input(page, ["短信验证码", "验证码"], sms_code, required=True, exclude=["图形"])
    click_button_text(page, ["登 录", "登录"])
    page.wait_for_load_state("domcontentloaded")
    page.wait_for_timeout(3000)
    select_sub_account_if_present(page, phone)
    text = page.locator("body").inner_text(timeout=5000)
    assert "登录/注册" not in text or "选择账号" in text or "进入后台" in text or "主账号" in text or "子账号" in text, (
        f"客服验证码登录后仍停留在登录页；text={text[:1200]}"
    )


def login_by_password(page: Page, phone: str, password: str) -> None:
    page.context.clear_cookies()
    page.goto(TENANT_HOME_URL.replace("/home", "/login"), wait_until="domcontentloaded")
    wait_for_login_page_ready(page)
    click_text(page, ["账号登录", "密码登录"])
    page.wait_for_selector("#password_login_phone", state="visible", timeout=10000)
    page.locator("#password_login_phone").fill(phone)
    page.locator("#password_login_password").fill(password)
    click_button_text(page, ["登 录", "登录"])
    page.wait_for_load_state("domcontentloaded")
    page.wait_for_timeout(3000)
    select_sub_account_if_present(page, phone)
    text = page.locator("body").inner_text(timeout=5000)
    assert "用户名或密码错误" not in text, f"客服账号密码登录失败；phone={phone}；text={text[:1200]}"
    assert any(key in text for key in ["选择账号", "子账号", "进入后台", "商户认证", "开放平台"]), (
        f"客服账号密码登录后未看到已登录状态；phone={phone}；text={text[:1200]}"
    )


def set_login_password_from_user_menu(
    page: Page,
    phone: str,
    new_password: str,
    provider: VerificationCodeProvider,
) -> None:
    open_user_menu(page)
    assert click_text(page, ["修改密码"]), f"未找到右上角账号菜单中的修改密码；text={page.locator('body').inner_text(timeout=5000)[:1200]}"
    page.wait_for_load_state("domcontentloaded")
    page.wait_for_timeout(2000)
    text = page.locator("body").inner_text(timeout=5000)
    assert "修改密码" in text and "新密码" in text, f"未进入修改密码页；text={text[:1200]}"

    request_login_sms_code_with_graphic_retry(page)
    sms_code = provider.get_code(phone)
    fill_input(page, ["短信验证码", "验证码"], sms_code, required=True, exclude=["图形"])
    fill_input(page, ["新密码"], new_password, required=True)
    fill_input(page, ["确认密码", "再次输入新密码"], new_password, required=True)
    click_button_text(page, ["确 定", "确定", "提交"])
    page.wait_for_timeout(3000)
    result_text = page.locator("body").inner_text(timeout=5000)
    assert not any(key in result_text for key in ["验证码错误", "验证码失败", "图形验证码错误"]), (
        f"修改密码提交后验证码报错；text={result_text[:1200]}"
    )


def open_user_menu(page: Page) -> None:
    opened = page.evaluate(
        """() => {
            const candidates = Array.from(document.querySelectorAll('taro-view-core, div, span'))
              .map(el => ({ el, text: (el.innerText || el.textContent || '').trim(), r: el.getBoundingClientRect() }))
              .filter(item => item.r.y >= 0 && item.r.y < 90 && item.r.x > window.innerWidth * 0.55)
              .filter(item => item.r.width > 20 && item.r.height > 16)
              .filter(item => item.text && !['首页', '解决方案', '订阅购买', '商户认证', '开放平台'].includes(item.text))
              .sort((a, b) => (b.r.width * b.r.height) - (a.r.width * a.r.height));
            const target = candidates.find(item => item.text.includes('E2E') || item.text.length <= 20) || candidates[0];
            if (!target) return false;
            target.el.click();
            return true;
        }"""
    )
    page.wait_for_timeout(1000)
    if opened and "修改密码" in page.locator("body").inner_text(timeout=3000):
        return
    raise AssertionError(f"未打开右上角账号菜单；text={page.locator('body').inner_text(timeout=5000)[:1200]}")


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


def request_login_sms_code_with_graphic_retry(page: Page, max_attempts: int = 8) -> None:
    solver = ImageCaptchaSolver(mode=os.getenv("E2E_IMAGE_CAPTCHA_MODE", "ddddocr"))
    last_code = ""
    for _ in range(max_attempts):
        last_code = fill_graphic_captcha_if_present(page, solver)
        click_button_text(page, ["获取验证码"])
        page.wait_for_timeout(2500)
        if sms_request_succeeded(page):
            return
        if sms_request_too_frequent_visible(page):
            wait_seconds = int(os.getenv("E2E_SMS_FREQUENT_RETRY_SECONDS", "60"))
            print(f"[E2E member SMS] 验证码请求过于频繁，等待 {wait_seconds} 秒后重试。")
            page.wait_for_timeout(wait_seconds * 1000)
            refresh_graphic_captcha(page)
            continue
        if graphic_captcha_error_visible(page):
            refresh_graphic_captcha(page)
            continue
        refresh_graphic_captcha(page)
    raise AssertionError(f"验证码登录页图形验证码多次识别失败，未能成功获取短信验证码；最后识别：{last_code}")


def fill_graphic_captcha_if_present(page: Page, solver: ImageCaptchaSolver) -> str:
    wait_for_graphic_captcha_ready(page)
    if page.locator("input[placeholder*='图形验证码']").count() == 0:
        return ""
    src = find_graphic_captcha_image_src(page)
    image_bytes = decode_data_image(src) if src else screenshot_graphic_captcha_bytes(page)
    code = solver.solve(image_bytes)
    fill_input(page, ["图形验证码"], code, required=True)
    return code


def wait_for_graphic_captcha_ready(page: Page) -> None:
    for _ in range(20):
        text = page.locator("body").inner_text(timeout=1000)
        if "加载中" not in text and page.locator("input[placeholder*='图形验证码']").count() == 0:
            return
        src = page.evaluate(
            """() => {
                const input = Array.from(document.querySelectorAll('input'))
                  .find(el => (el.getAttribute('placeholder') || '').includes('图形验证码'));
                if (!input) return '';
                const inputBox = input.getBoundingClientRect();
                const images = Array.from(document.querySelectorAll('img'))
                  .map(img => ({ box: img.getBoundingClientRect(), src: img.getAttribute('src') || '' }))
                  .filter(item => item.src.startsWith('data:image'))
                  .filter(item => item.box.width > 40 && item.box.height > 20)
                  .filter(item => Math.abs(item.box.y - inputBox.y) < 45 && item.box.x > inputBox.x);
                return images[0]?.src || '';
            }"""
        )
        if src:
            return
        if graphic_captcha_image_box(page):
            return
        if graphic_captcha_input_box(page):
            return
        page.wait_for_timeout(500)


def find_graphic_captcha_image_src(page: Page) -> str:
    src = page.evaluate(
        """() => {
            const input = Array.from(document.querySelectorAll('input'))
              .find(el => (el.getAttribute('placeholder') || '').includes('图形验证码'));
            if (!input) return '';
            const inputBox = input.getBoundingClientRect();
            const images = Array.from(document.querySelectorAll('img'))
              .map(img => ({ img, box: img.getBoundingClientRect(), src: img.getAttribute('src') || '' }))
              .filter(item => item.src.startsWith('data:image'))
              .filter(item => item.box.width > 40 && item.box.height > 20)
              .filter(item => Math.abs(item.box.y - inputBox.y) < 35 && item.box.x > inputBox.x);
            images.sort((a, b) => a.box.x - b.box.x);
            return images[0]?.src || '';
        }"""
    )
    return str(src)


def graphic_captcha_input_box(page: Page) -> dict | None:
    return 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]?.el;
            if (!input) return null;
            const box = input.getBoundingClientRect();
            return { x: box.x, y: box.y, width: box.width, height: box.height };
        }"""
    )


def screenshot_graphic_captcha_bytes(page: Page) -> bytes:
    box = graphic_captcha_input_box(page)
    assert box, f"未找到图形验证码输入框；text={page.locator('body').inner_text(timeout=5000)[:1000]}"
    image_box = graphic_captcha_image_box(page)
    clip = image_box or {
        "x": box["x"] + box["width"] + 8,
        "y": max(0, box["y"] - 10),
        "width": 120,
        "height": max(44, box["height"] + 20),
    }
    with NamedTemporaryFile(suffix=".png", delete=False) as tmp:
        path = tmp.name
    page.screenshot(path=path, clip=clip)
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    page.screenshot(path=OUT_DIR / "debug_修改密码页全屏.png", full_page=True)
    data = Path(path).read_bytes()
    (OUT_DIR / "debug_图形验证码截图.png").write_bytes(data)
    try:
        Path(path).unlink(missing_ok=True)
    except Exception:
        pass
    return data


def graphic_captcha_image_box(page: Page) -> dict | None:
    return 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]?.el;
            if (!input) return null;
            const inputBox = input.getBoundingClientRect();
            const images = Array.from(document.querySelectorAll('img, canvas'))
              .map(el => ({ r: el.getBoundingClientRect(), tag: el.tagName }))
              .filter(item => item.r.width > 40 && item.r.height > 20)
              .filter(item => Math.abs((item.r.y + item.r.height / 2) - (inputBox.y + inputBox.height / 2)) < 28)
              .filter(item => item.r.x > inputBox.x + inputBox.width - 20 && item.r.x < inputBox.x + inputBox.width + 220)
              .sort((a, b) => a.r.x - b.r.x);
            const target = images[0];
            if (!target) return null;
            return {
              x: Math.max(0, target.r.x - 2),
              y: Math.max(0, target.r.y - 2),
              width: target.r.width + 4,
              height: target.r.height + 4,
            };
        }"""
    )


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


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


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


def sms_request_succeeded(page: Page) -> bool:
    text = page.locator("body").inner_text(timeout=3000)
    if any(key in text for key in ["验证码已发送", "发送成功", "短信已发送", "获取成功", "请查收"]):
        return True
    return bool(
        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 select_sub_account_if_present(page: Page, phone: str) -> None:
    body_text = page.locator("body").inner_text(timeout=5000)
    if "/account/select" not in page.url and "选择账号" not in body_text:
        return
    box = page.evaluate(
        """phone => {
            const cards = 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 < 700 && item.r.height > 45 && item.r.height < 180)
              .filter(item => item.text.includes('子账号') || item.text.includes('客服') || item.text.includes(phone))
              .sort((a, b) => (a.r.width * a.r.height) - (b.r.width * b.r.height));
            const target = cards[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,
    )
    if not box:
        raise AssertionError(f"验证码登录后未找到客服子账号卡片；text={body_text[:1200]}")
    page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    page.wait_for_load_state("domcontentloaded")
    page.wait_for_timeout(3000)


def fill_input(page: Page, labels: list[str], value: str, required: bool, exclude: list[str] | None = None) -> bool:
    filled = bool(
        page.evaluate(
            """({ labels, value, exclude }) => {
                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 itemText = (target.closest?.('label, div, taro-view-core')?.innerText || '').trim();
                    const r = target.getBoundingClientRect();
                    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)))
                  .filter(item => !(exclude || []).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, "exclude": exclude or []},
        )
    )
    if required and not filled:
        raise AssertionError(f"未找到输入项 {labels}；text={page.locator('body').inner_text(timeout=5000)[:1200]}")
    return filled


def click_text(page: Page, texts: list[str]) -> bool:
    for text in texts:
        clicked = page.evaluate(
            """text => {
                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 === 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:
            page.wait_for_timeout(1000)
            return True
    return False


def click_button_text(page: Page, texts: list[str]) -> bool:
    for text in texts:
        locator = page.locator("button").filter(has_text=text).first
        try:
            if locator.count():
                locator.click(timeout=3000)
                page.wait_for_timeout(1000)
                return True
        except Exception:
            pass
    return click_text(page, texts)


def probe_clicks(page: Page, texts: list[str]) -> None:
    for text in texts:
        clicked = click_text(page, [text])
        page.wait_for_timeout(1500)
        dump(page, f"点击_{text}_{'成功' if clicked else '未找到'}")


def dump(page: Page, name: str) -> None:
    payload = {
        "url": page.url,
        "text": safe_text(page)[:3000],
        "frames": [
            {
                "url": frame.url,
                "text": safe_frame_text(frame)[:2500],
                "nodes": safe_frame_nodes(frame),
            }
            for frame in page.frames
            if frame.url != "about:blank"
        ],
        "nodes": safe_nodes(page),
    }
    (OUT_DIR / f"{name}.json").write_text(__import__("json").dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    try:
        page.screenshot(path=OUT_DIR / f"{name}.png", full_page=True)
    except Exception:
        pass


def safe_text(page: Page) -> str:
    try:
        return page.locator("body").inner_text(timeout=5000)
    except Exception as exc:
        return f"<无法读取页面文本：{exc}>"


def safe_frame_text(frame) -> str:
    try:
        return frame.locator("body").inner_text(timeout=3000)
    except Exception as exc:
        return f"<无法读取 iframe 文本：{exc}>"


def safe_nodes(page: Page) -> list[dict]:
    try:
        return list(page.evaluate(VISIBLE_NODES_SCRIPT))
    except Exception:
        return []


def safe_frame_nodes(frame) -> list[dict]:
    try:
        return list(frame.evaluate(VISIBLE_NODES_SCRIPT))
    except Exception:
        return []


VISIBLE_NODES_SCRIPT = """() => Array.from(document.querySelectorAll('button, a, span, div, input, textarea, .ant-select-selector'))
  .map((el, index) => {
    const r = el.getBoundingClientRect();
    return {
      index,
      tag: el.tagName,
      text: (el.innerText || el.textContent || '').trim(),
      placeholder: el.getAttribute?.('placeholder') || '',
      className: String(el.className || ''),
      rect: { x: r.x, y: r.y, width: r.width, height: r.height },
    };
  })
  .filter(item => item.rect.width > 0 && item.rect.height > 0 && (item.text || item.placeholder))
  .slice(0, 500)"""


if __name__ == "__main__":
    main()
