# -*- coding:utf-8 -*-
import json
import os
import re
import subprocess
import sys
from urllib.parse import urlparse

import requests


CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(CURRENT_DIR, "config.json")
SKILL_PATH = os.path.abspath(os.path.join(CURRENT_DIR, "..", "..", "..", "skills", "webapp-testing", "SKILL.md"))
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..", "..", ".."))
LEGACY_SCREENSHOT_DIR = os.path.join(CURRENT_DIR, "screenshots")
GENERATED_CASES_DIR = os.path.join(CURRENT_DIR, "generated_cases")

DEFAULT_HEADERS = {
    "Accept": "application/json, text/plain, */*",
    "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-TW;q=0.6",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
    "Content-Type": "application/json;charset=UTF-8",
    "Pragma": "no-cache",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
}

MODEL_HEADERS = {
    "Content-Type": "application/json",
    "Accept": "text/event-stream"
}


def load_config(config_path=CONFIG_PATH):
    with open(config_path, "r", encoding="utf-8") as file:
        return json.load(file)


def load_skill_prompt(skill_path=SKILL_PATH):
    if not os.path.exists(skill_path):
        raise FileNotFoundError("Skill提示词文件不存在：{0}".format(skill_path))

    with open(skill_path, "r", encoding="utf-8") as file:
        return file.read()


def parse_prompt_context(prompt):
    context = {
        "urls": [],
        "pageUrl": None,
        "loginUrl": None,
        "username": None,
        "password": None,
        "passwordProvided": False,
        "testData": {},
        "selectors": {}
    }

    if not prompt:
        return context

    urls = re.findall(r"https?://[^\s，,。；;）)]+", prompt)
    context["urls"] = urls
    if urls:
        context["pageUrl"] = urls[0]

    field_patterns = [
        ("pageUrl", [r"(?:被测系统URL|被测页面URL|页面URL|访问地址|系统地址|pageUrl|url)[:：]\s*(https?://[^\s，,。；;）)]+)"]),
        ("loginUrl", [r"(?:登录URL|登录地址|loginUrl)[:：]\s*(https?://[^\s，,。；;）)]+)"]),
        ("username", [r"(?:登录账号|账号|用户名|username|user)[:：]\s*([^\s，,。；;]+)"]),
        ("password", [r"(?:登录密码|密码|password|pwd)[:：]\s*([^\s，,。；;]+)"])
    ]
    for field, patterns in field_patterns:
        for pattern in patterns:
            match = re.search(pattern, prompt, re.IGNORECASE)
            if match:
                context[field] = match.group(1).strip()
                if field == "password":
                    context["passwordProvided"] = True
                break

    keyword_match = re.search(r"(?:搜索关键字|关键字|keyword)[:：]\s*([^\n，,。；;]+)", prompt, re.IGNORECASE)
    if keyword_match:
        context["testData"]["keyword"] = keyword_match.group(1).strip()

    selector_patterns = [
        ("usernameInput", r"(?:用户名输入框|账号输入框|usernameInput)[:：]\s*([^\n]+)"),
        ("passwordInput", r"(?:密码输入框|passwordInput)[:：]\s*([^\n]+)"),
        ("loginButton", r"(?:登录按钮|loginButton)[:：]\s*([^\n]+)"),
        ("searchInput", r"(?:搜索输入框|搜索框|searchInput)[:：]\s*([^\n]+)"),
        ("searchButton", r"(?:搜索按钮|查询按钮|searchButton)[:：]\s*([^\n]+)"),
        ("resultTable", r"(?:结果表格|列表表格|resultTable)[:：]\s*([^\n]+)")
    ]
    for selector_name, pattern in selector_patterns:
        match = re.search(pattern, prompt, re.IGNORECASE)
        if match:
            context["selectors"][selector_name] = match.group(1).strip()

    if context.get("loginUrl") is None and len(urls) > 1:
        context["loginUrl"] = urls[1]

    return context


def _get_project_code(product_name=None, project_name=None, case_key=None):
    product_text = str(product_name or "")
    project_text = str(project_name or "")
    case_key_text = str(case_key or "")
    match_text = "{0} {1} {2}".format(product_text, project_text, case_key_text).lower()

    if "智慧运营" in product_text or "智慧运营" in project_text or "zhyy" in match_text or "zzyy" in match_text:
        return "zhyy"

    if "独立站" in product_text or "独立站" in project_text or "joyhub" in match_text or "dulizhan" in match_text:
        return "dulizhan"

    return None


def resolve_project_screenshot_dir(product_name=None, project_name=None, case_key=None):
    project_code = _get_project_code(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    if project_code:
        return os.path.join(PROJECT_ROOT, project_code, "screenshots")

    return os.path.join(
        GENERATED_CASES_DIR,
        _sanitize_path_part(project_name, default_value="unknown_project"),
        "screenshots"
    )


def sanitize_prompt(prompt, context=None):
    if not prompt:
        return prompt

    if context is None:
        context = parse_prompt_context(prompt)

    sanitized_prompt = prompt
    password = context.get("password")
    if password:
        sanitized_prompt = sanitized_prompt.replace(password, "******")

    sanitized_prompt = re.sub(
        r"((?:登录密码|密码|password|pwd)[:：]\s*)([^\s，,。；;]+)",
        r"\1******",
        sanitized_prompt,
        flags=re.IGNORECASE
    )
    return sanitized_prompt


def sanitize_reconnaissance_context(context):
    sanitized_context = dict(context or {})
    if sanitized_context.get("password"):
        sanitized_context["password"] = "******"
    return sanitized_context


def _safe_locator_count(page, selector):
    try:
        return page.locator(selector).count()
    except Exception:
        return 0


def _collect_locator_samples(page, selector, limit=20):
    samples = []
    try:
        locators = page.locator(selector)
        count = min(locators.count(), limit)
        for index in range(count):
            item = locators.nth(index)
            try:
                samples.append({
                    "tag": item.evaluate("el => el.tagName.toLowerCase()"),
                    "text": item.inner_text(timeout=1000).strip()[:120],
                    "id": item.get_attribute("id"),
                    "name": item.get_attribute("name"),
                    "type": item.get_attribute("type"),
                    "placeholder": item.get_attribute("placeholder"),
                    "role": item.get_attribute("role"),
                    "ariaLabel": item.get_attribute("aria-label"),
                    "dataTestId": item.get_attribute("data-testid") or item.get_attribute("data-test")
                })
            except Exception:
                continue
    except Exception:
        pass
    return samples


def _build_candidate_summary(page):
    return {
        "inputs": _collect_locator_samples(page, "input, textarea, [contenteditable='true']", limit=30),
        "buttons": _collect_locator_samples(page, "button, [role='button'], input[type='button'], input[type='submit']", limit=30),
        "links": _collect_locator_samples(page, "a", limit=20),
        "tables": _collect_locator_samples(page, "table, [role='table'], .el-table, .ant-table", limit=10),
        "counts": {
            "input": _safe_locator_count(page, "input"),
            "textarea": _safe_locator_count(page, "textarea"),
            "button": _safe_locator_count(page, "button"),
            "link": _safe_locator_count(page, "a"),
            "table": _safe_locator_count(page, "table")
        }
    }


def reconnaissance_web_page(prompt,
                            case_key=None,
                            product_name=None,
                            project_name=None,
                            timeout=30000,
                            headless=True,
                            required=True):
    context = parse_prompt_context(prompt)
    page_url = context.get("pageUrl")
    sanitized_context = sanitize_reconnaissance_context(context)

    if not page_url:
        message = "prompt中未解析到被测页面URL"
        if required:
            raise ValueError(message)
        return {
            "enabled": False,
            "reason": message,
            "parsedContext": sanitized_context
        }

    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        message = "当前环境未安装playwright，无法执行页面侦察，请执行：pip install playwright && python -m playwright install chromium"
        if required:
            raise RuntimeError(message)
        return {
            "enabled": False,
            "reason": message,
            "parsedContext": sanitized_context
        }

    result = {
        "enabled": True,
        "pageUrl": page_url,
        "parsedContext": sanitized_context,
        "headless": headless,
        "loginAttempted": False,
        "loginSucceededMaybe": False,
        "finalUrl": None,
        "title": None,
        "screenshotPath": None,
        "candidates": {},
        "errors": []
    }

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=headless)
        page = browser.new_page()
        try:
            target_url = context.get("loginUrl") or page_url
            page.goto(target_url, wait_until="networkidle", timeout=timeout)

            username = context.get("username")
            password = context.get("password")
            if username and password:
                result["loginAttempted"] = True
                before_login_url = page.url
                selector_map = context.get("selectors") or {}
                username_selector = selector_map.get("usernameInput")
                password_selector = selector_map.get("passwordInput")
                login_button_selector = selector_map.get("loginButton")

                if username_selector and password_selector:
                    page.locator(username_selector).fill(username, timeout=5000)
                    page.locator(password_selector).fill(password, timeout=5000)
                else:
                    text_inputs = page.locator("input[type='text'], input:not([type]), input[type='email'], input[type='tel']")
                    password_inputs = page.locator("input[type='password']")
                    if text_inputs.count() > 0:
                        text_inputs.first.fill(username, timeout=5000)
                    if password_inputs.count() > 0:
                        password_inputs.first.fill(password, timeout=5000)

                if login_button_selector:
                    page.locator(login_button_selector).click(timeout=5000)
                else:
                    login_buttons = page.locator("button:has-text('登录'), button:has-text('登陆'), input[type='submit'], [role='button']:has-text('登录')")
                    if login_buttons.count() > 0:
                        login_buttons.first.click(timeout=5000)

                try:
                    page.wait_for_load_state("networkidle", timeout=timeout)
                except Exception as error:
                    result["errors"].append("登录后等待networkidle失败：{0}".format(str(error)))
                result["loginSucceededMaybe"] = page.url != before_login_url or "login" not in page.url.lower()

            if page.url != page_url and page_url not in page.url:
                try:
                    page.goto(page_url, wait_until="networkidle", timeout=timeout)
                except Exception as error:
                    result["errors"].append("跳转被测页面失败：{0}".format(str(error)))

            result["finalUrl"] = page.url
            result["title"] = page.title()
            result["candidates"] = _build_candidate_summary(page)

            screenshot_dir = resolve_project_screenshot_dir(
                product_name=product_name,
                project_name=project_name,
                case_key=case_key
            )
            os.makedirs(screenshot_dir, exist_ok=True)
            safe_case_key = re.sub(r"[^0-9A-Za-z_.-]+", "_", str(case_key or "ui_reconnaissance"))
            screenshot_path = os.path.join(screenshot_dir, "{0}.png".format(safe_case_key))
            page.screenshot(path=screenshot_path, full_page=True)
            result["screenshotPath"] = screenshot_path
        except Exception as error:
            result["errors"].append(str(error))
            if required:
                raise RuntimeError("页面侦察失败：{0}".format(str(error)))
        finally:
            browser.close()

    return result


def build_ui_testcase_prompt(project_id,
                             case_id,
                             automation_type,
                             prompt,
                             case_key,
                             module_name,
                             product_name,
                             project_name,
                             steps,
                             expected_results,
                             case_name=None,
                             skill_prompt=None,
                             reconnaissance_result=None):
    if skill_prompt is None:
        skill_prompt = load_skill_prompt()

    screenshot_dir = resolve_project_screenshot_dir(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    case_info = {
        "projectId": project_id,
        "caseId": case_id,
        "automationType": automation_type,
        "caseKey": case_key,
        "moduleName": module_name,
        "productName": product_name,
        "projectName": project_name,
        "screenshotDir": screenshot_dir,
        "steps": steps,
        "expectedResults": expected_results,
        "caseName": case_name,
        "userPrompt": sanitize_prompt(prompt),
        "reconnaissanceResult": reconnaissance_result
    }

    return """你需要严格遵循以下 UI 自动化测试 Skill 规则生成用例：

{skill_prompt}

下面是测试平台传入的测试用例信息与页面侦察结果：

{case_info}

生成要求：
1. 根据测试步骤、预期结果、用户prompt和页面侦察结果生成 Python Playwright UI 自动化测试用例。
2. 优先使用页面侦察结果中的真实元素候选、URL、标题、输入框、按钮、表格信息生成 selector。
3. 优先遵循 Skill 中的 Reconnaissance-Then-Action、Page Object、pytest、Allure 和稳定定位原则。
4. 如果页面侦察失败或缺少关键 selector，不要编造，使用清晰的变量、占位符或 TODO 标识。
5. 如用例需要截图，必须使用 case_info.screenshotDir 作为截图目录，并在代码中 os.makedirs(screenshot_dir, exist_ok=True) 后保存到该目录。
6. 必须按分层结构输出代码：测试用例文件只包含 pytest 用例、fixture/测试数据和对页面对象的调用；BasePage、PageObject、业务操作方法、元素定位方法必须放到独立页面对象/公共方法文件中。
7. 测试用例文件禁止定义 class BasePage、PageObject 类、非测试业务方法；只能 import 并调用外部类或方法。
8. 测试用例必须使用 Allure 装饰器标记，至少包含 @allure.story(case_info.moduleName) 和 @allure.title(case_info.caseName)，必要时可补充 @allure.feature(case_info.projectName)；测试用例文件必须 import allure。
9. 请优先使用以下 fenced code block 标记分文件输出，方便平台落盘：
   ```python file=base_page.py
   # 公共页面基类
   ```
   ```python file=<页面对象文件名>.py
   # 页面对象和业务操作方法
   ```
   ```python file=test_case.py
   # pytest 测试用例，只包含用例
   ```
10. 不要输出无关解释。
""".format(
        skill_prompt=skill_prompt,
        case_info=json.dumps(case_info, ensure_ascii=False, indent=2)
    )


def build_generate_automation_payload(project_id,
                                      case_id,
                                      automation_type,
                                      prompt,
                                      case_key,
                                      module_name,
                                      product_name,
                                      project_name,
                                      steps,
                                      expected_results):
    return {
        "projectId": project_id,
        "caseId": case_id,
        "automationType": automation_type,
        "prompt": prompt,
        "caseKey": case_key,
        "moduleName": module_name,
        "productName": product_name,
        "projectName": project_name,
        "steps": steps,
        "expectedResults": expected_results
    }


def extract_streaming_response_text(stream_text):
    deltas = []
    done_text = ""

    for line in stream_text.splitlines():
        line = line.strip()
        if not line.startswith("data:"):
            continue

        payload_text = line.replace("data:", "", 1).strip()
        if not payload_text or payload_text == "[DONE]":
            continue

        try:
            payload = json.loads(payload_text)
        except ValueError:
            continue

        event_type = payload.get("type")
        if event_type == "response.output_text.delta":
            delta = payload.get("delta")
            if isinstance(delta, str):
                deltas.append(delta)
        elif event_type == "response.output_text.done":
            text = payload.get("text")
            if isinstance(text, str) and text.strip():
                done_text = text

    generated_text = "".join(deltas).strip()
    if generated_text:
        return generated_text

    return done_text.strip()


def call_model_api(instructions, user_content, config=None, timeout=300):
    if config is None:
        config = load_config()

    api_key = config.get("api_key")
    base_url = config.get("base_url")
    model = config.get("model")

    if not api_key:
        raise ValueError("api_key不能为空，请先在config.json中配置或通过config参数传入")
    if not base_url:
        raise ValueError("base_url不能为空，请先在config.json中配置或通过config参数传入")
    if not model:
        raise ValueError("model不能为空，请先在config.json中配置或通过config参数传入")

    headers = MODEL_HEADERS.copy()
    headers["Authorization"] = "Bearer {0}".format(api_key)

    payload = {
        "model": model,
        "instructions": instructions,
        "input": user_content,
        "max_output_tokens": 4096,
        "store": False,
        "stream": True
    }

    api_url = base_url.rstrip("/")
    if not api_url.endswith("/responses"):
        api_url = api_url + "/responses"

    response = requests.post(
        url=api_url,
        headers=headers,
        json=payload,
        timeout=timeout
    )

    if response.status_code >= 400:
        raise RuntimeError(
            "大模型Responses接口调用失败，url={0}，status_code={1}，response={2}".format(
                api_url,
                response.status_code,
                response.text[:2000]
            )
        )

    generated_text = extract_streaming_response_text(response.text)
    if generated_text:
        return generated_text

    try:
        return response.json()
    except ValueError:
        raise RuntimeError("大模型流式响应未解析到正文，响应预览：{0}".format(response.text[:2000]))


def _sanitize_path_part(value, default_value="unknown"):
    text = str(value or "").strip()
    if not text:
        text = default_value
    return re.sub(r'[\\/:*?"<>|\s]+', "_", text).strip("_") or default_value


def _sanitize_python_file_name(value, default_value="generated_ui_case"):
    text = _sanitize_path_part(value, default_value=default_value)
    text = re.sub(r"[^0-9A-Za-z_\u4e00-\u9fa5]+", "_", text).strip("_")
    if not text:
        text = default_value
    if not text.startswith("test_"):
        text = "test_{0}".format(text)
    return "{0}.py".format(text)


def _resolve_ui_testcase_dir(product_name, project_name, case_key, module_name):
    project_code = _get_project_code(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    if project_code:
        return os.path.join(PROJECT_ROOT, project_code, "test_case", "TestCase", "UI")

    return os.path.join(
        GENERATED_CASES_DIR,
        _sanitize_path_part(project_name, default_value="unknown_project")
    )


def extract_python_code(generated_content):
    if not isinstance(generated_content, str):
        return json.dumps(generated_content, ensure_ascii=False, indent=2)

    matches = re.findall(r"```(?:python|py)?\s*([\s\S]*?)```", generated_content, re.IGNORECASE)
    python_blocks = []
    for block in matches:
        block_text = block.strip()
        if "import " in block_text or "def test_" in block_text or "class Test" in block_text or "class " in block_text:
            python_blocks.append(block_text)

    if python_blocks:
        return "\n\n".join(python_blocks).strip() + "\n"

    return generated_content.strip() + "\n"


def extract_python_file_blocks(generated_content):
    if not isinstance(generated_content, str):
        return []

    pattern = r"```(?:python|py)\s+file=([^\s`]+)\s*\n([\s\S]*?)```"
    blocks = []
    for file_name, code in re.findall(pattern, generated_content, re.IGNORECASE):
        safe_file_name = _sanitize_python_module_file_name(file_name)
        blocks.append({
            "fileName": safe_file_name,
            "code": code.strip() + "\n"
        })

    if blocks:
        return blocks

    code = extract_python_code(generated_content)
    return extract_commented_python_file_blocks(code)


def _sanitize_python_module_file_name(file_name, default_value="generated.py"):
    normalized = str(file_name or "").strip().replace("\\", "/")
    base_name = os.path.basename(normalized)
    safe_file_name = _sanitize_path_part(base_name, default_value=default_value)
    if not safe_file_name.endswith(".py"):
        safe_file_name = "{0}.py".format(safe_file_name)
    return safe_file_name


def extract_commented_python_file_blocks(code):
    if not isinstance(code, str):
        return []

    marker_pattern = re.compile(r"^#\s*(?:[\w.-]+/)*([A-Za-z_][\w.-]*\.py)\s*$")
    blocks = []
    current_file_name = None
    current_lines = []

    for line in code.splitlines():
        marker_match = marker_pattern.match(line.strip())
        if marker_match:
            if current_file_name and current_lines:
                blocks.append({
                    "fileName": _sanitize_python_module_file_name(current_file_name),
                    "code": "\n".join(current_lines).strip() + "\n"
                })
            current_file_name = marker_match.group(1)
            current_lines = []
            continue

        if current_file_name:
            current_lines.append(line)

    if current_file_name and current_lines:
        blocks.append({
            "fileName": _sanitize_python_module_file_name(current_file_name),
            "code": "\n".join(current_lines).strip() + "\n"
        })

    if len(blocks) < 2:
        return []

    has_test_block = any("def test_" in block["code"] for block in blocks)
    has_resource_block = any("class " in block["code"] for block in blocks)
    if not has_test_block or not has_resource_block:
        return []

    return blocks


def _resolve_project_code_or_default(product_name, project_name, case_key):
    return _get_project_code(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    ) or _sanitize_path_part(project_name, default_value="generated_cases")


def _resolve_ui_resource_dir(product_name, project_name, case_key):
    project_code = _get_project_code(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    if project_code:
        return os.path.join(PROJECT_ROOT, project_code, "test_case", "Resource", "UI")

    return os.path.join(
        GENERATED_CASES_DIR,
        _sanitize_path_part(project_name, default_value="unknown_project"),
        "Resource",
        "UI"
    )


def _ensure_python_package_dirs(path, stop_dir):
    current = os.path.abspath(path)
    stop_dir = os.path.abspath(stop_dir)
    while current.startswith(stop_dir):
        init_path = os.path.join(current, "__init__.py")
        if not os.path.exists(init_path):
            with open(init_path, "w", encoding="utf-8"):
                pass
        if current == stop_dir:
            break
        parent = os.path.dirname(current)
        if parent == current:
            break
        current = parent


def _strip_class_blocks(code):
    lines = code.splitlines()
    kept_lines = []
    class_blocks = []
    index = 0

    while index < len(lines):
        line = lines[index]
        if re.match(r"^class\s+\w+", line):
            block = [line]
            index += 1
            while index < len(lines):
                next_line = lines[index]
                if next_line and not next_line.startswith((" ", "\t")):
                    break
                block.append(next_line)
                index += 1
            class_blocks.append("\n".join(block).rstrip())
            continue
        kept_lines.append(line)
        index += 1

    return "\n".join(kept_lines).strip() + "\n", class_blocks


def _extract_defined_class_names(code):
    return re.findall(r"^class\s+(\w+)", code, re.MULTILINE)


def _normalize_networkidle_waits(code):
    lines = code.splitlines()
    normalized_lines = []
    for line in lines:
        stripped = line.strip()
        previous_stripped = normalized_lines[-1].strip() if normalized_lines else ""
        if stripped == 'self.page.wait_for_load_state("networkidle")' and previous_stripped != "try:":
            indent = line[:len(line) - len(line.lstrip())]
            normalized_lines.append(indent + "try:")
            normalized_lines.append(indent + '    self.page.wait_for_load_state("networkidle", timeout=5000)')
            normalized_lines.append(indent + "except Exception:")
            normalized_lines.append(indent + "    pass")
        else:
            normalized_lines.append(line)
    return "\n".join(normalized_lines).strip() + "\n"


def _build_import_lines(project_code, page_file_name, class_names):
    module_name = os.path.splitext(page_file_name)[0]
    import_lines = []
    if "BasePage" in class_names:
        import_lines.append("from {0}.test_case.Resource.UI.base_page import BasePage".format(project_code))
    page_classes = [name for name in class_names if name != "BasePage"]
    if page_classes:
        import_lines.append("from {0}.test_case.Resource.UI.{1} import {2}".format(
            project_code,
            module_name,
            ", ".join(page_classes)
        ))
    return import_lines


def _normalize_generated_imports(code, project_code, resource_files, remove_local_resource_imports=False):
    resource_modules = [
        os.path.splitext(resource_file.get("fileName", ""))[0]
        for resource_file in resource_files
        if resource_file.get("fileName", "").endswith(".py")
    ]
    normalized_code = str(code or "")
    normalized_code = re.sub(r"^from\s+pages\.[^\n]+\n", "", normalized_code, flags=re.MULTILINE)

    for module_name in resource_modules:
        if not module_name:
            continue
        if remove_local_resource_imports:
            normalized_code = re.sub(
                r"^from\s+{0}\s+import\s+[^\n]+\n".format(re.escape(module_name)),
                "",
                normalized_code,
                flags=re.MULTILINE
            )
        else:
            normalized_code = re.sub(
                r"^from\s+{0}\s+import\s+([^\n]+)".format(re.escape(module_name)),
                r"from {0}.test_case.Resource.UI.{1} import \1".format(project_code, module_name),
                normalized_code,
                flags=re.MULTILINE
            )

    return normalized_code.strip() + "\n"


def _python_string_literal(value):
    return json.dumps(str(value or ""), ensure_ascii=False)


def _prepend_import_lines_preserving_future(code, import_lines):
    unique_import_lines = list(dict.fromkeys(line for line in import_lines if line))
    if not unique_import_lines:
        return str(code or "").strip() + "\n"

    lines = str(code or "").strip().splitlines()
    future_lines = []
    while lines and lines[0].startswith("from __future__ import "):
        future_lines.append(lines.pop(0))
        if lines and not lines[0].strip():
            lines.pop(0)

    result_lines = []
    result_lines.extend(future_lines)
    result_lines.extend(unique_import_lines)
    result_lines.append("")
    result_lines.extend(lines)
    return "\n".join(result_lines).strip() + "\n"


def _ensure_allure_markers(testcase_code, module_name=None, case_name=None, project_name=None):
    code = testcase_code.strip() + "\n"
    if "import allure" not in code:
        code = _prepend_import_lines_preserving_future(code, ["import allure"])

    decorators = []
    if project_name and "@allure.feature" not in code:
        decorators.append("@allure.feature({0})".format(_python_string_literal(project_name)))
    if module_name and "@allure.story" not in code:
        decorators.append("@allure.story({0})".format(_python_string_literal(module_name)))
    title_text = case_name or ""
    if title_text and "@allure.title" not in code:
        decorators.append("@allure.title({0})".format(_python_string_literal(title_text)))

    if decorators:
        code = re.sub(
            r"(\n)(def\s+test_\w+\s*\()",
            "\n" + "\n".join(decorators) + "\n" + r"\2",
            code,
            count=1
        )
    return code.strip() + "\n"


def _normalize_missing_secret_asserts(testcase_code):
    code = str(testcase_code or "")
    if "pytest.skip" in code:
        return code.strip() + "\n"
    code = re.sub(
        r"^(\s*)assert\s+([^\n,]+(?:password|PASSWORD|token|TOKEN|secret|SECRET)[^\n,]*),\s*([^\n]+)$",
        r"\1if not \2:\n\1    pytest.skip(\3)",
        code,
        flags=re.MULTILINE
    )
    return code.strip() + "\n"


def split_ui_generated_code(generated_content, product_name, project_name, case_key, module_name=None):
    explicit_blocks = extract_python_file_blocks(generated_content)
    project_code = _resolve_project_code_or_default(product_name, project_name, case_key)
    file_identity = "{0}_{1}".format(
        _sanitize_path_part(case_key, default_value="generated_ui"),
        _sanitize_path_part(module_name, default_value="ui")
    )
    page_file_name = "{0}_page.py".format(file_identity)

    if explicit_blocks:
        test_code_parts = []
        resource_files = []
        import_lines = []
        for block in explicit_blocks:
            file_name = block["fileName"]
            code = block["code"]
            if file_name.startswith("test_") or file_name == "test_case.py" or "def test_" in code:
                test_code_parts.append(code)
            else:
                resource_files.append({"fileName": file_name, "code": code})
                class_names = _extract_defined_class_names(code)
                module_name = os.path.splitext(file_name)[0]
                for class_name in class_names:
                    import_lines.append("from {0}.test_case.Resource.UI.{1} import {2}".format(
                        project_code,
                        module_name,
                        class_name
                    ))
        test_code = "\n\n".join(test_code_parts).strip() + "\n"
        test_code = _normalize_generated_imports(
            code=test_code,
            project_code=project_code,
            resource_files=resource_files,
            remove_local_resource_imports=True
        )
        if import_lines:
            test_code = _prepend_import_lines_preserving_future(test_code, import_lines)
        resource_files = [
            {
                "fileName": resource_file["fileName"],
                "code": _normalize_generated_imports(
                    code=resource_file["code"],
                    project_code=project_code,
                    resource_files=resource_files,
                    remove_local_resource_imports=False
                )
            }
            for resource_file in resource_files
        ]
        return test_code, resource_files

    full_code = extract_python_code(generated_content)
    test_code, class_blocks = _strip_class_blocks(full_code)
    resource_files = []

    base_blocks = [block for block in class_blocks if re.match(r"^class\s+BasePage", block)]
    page_blocks = [block for block in class_blocks if not re.match(r"^class\s+BasePage", block)]

    if base_blocks:
        base_code = "import os\nfrom playwright.sync_api import Page, expect\n\n\n" + "\n\n".join(base_blocks).strip() + "\n"
        resource_files.append({"fileName": "base_page.py", "code": base_code})

    if page_blocks:
        page_imports = "import os\nimport re\nfrom playwright.sync_api import Page, expect\n"
        if base_blocks:
            page_imports += "from {0}.test_case.Resource.UI.base_page import BasePage\n".format(project_code)
        page_code = page_imports + "\n\n" + "\n\n".join(page_blocks).strip() + "\n"
        resource_files.append({"fileName": page_file_name, "code": page_code})

    class_names = []
    for block in class_blocks:
        class_names.extend(_extract_defined_class_names(block))
    import_lines = _build_import_lines(project_code, page_file_name, class_names)
    if import_lines:
        test_code = _prepend_import_lines_preserving_future(test_code, import_lines)

    test_code = re.sub(r"^from\s+pages\.[^\n]+\n", "", test_code, flags=re.MULTILINE)
    return test_code.strip() + "\n", resource_files


def save_generated_ui_testcase(generated_content,
                               product_name,
                               project_name,
                               module_name,
                               case_key,
                               case_name=None):
    target_dir = _resolve_ui_testcase_dir(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key,
        module_name=module_name
    )
    resource_dir = _resolve_ui_resource_dir(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    os.makedirs(target_dir, exist_ok=True)
    os.makedirs(resource_dir, exist_ok=True)

    project_dir = os.path.dirname(os.path.dirname(os.path.dirname(resource_dir)))
    _ensure_python_package_dirs(resource_dir, project_dir)

    file_identity = "{0}_{1}".format(
        _sanitize_path_part(case_key, default_value="generated_ui_case"),
        _sanitize_path_part(module_name, default_value="ui")
    )
    file_name = _sanitize_python_file_name(file_identity, default_value="generated_ui_case")
    file_path = os.path.join(target_dir, file_name)
    testcase_code, resource_files = split_ui_generated_code(
        generated_content=generated_content,
        product_name=product_name,
        project_name=project_name,
        case_key=case_key,
        module_name=module_name
    )

    for resource_file in resource_files:
        resource_file_path = os.path.join(resource_dir, resource_file["fileName"])
        with open(resource_file_path, "w", encoding="utf-8") as file:
            file.write(_normalize_networkidle_waits(resource_file["code"]))

    testcase_code = _ensure_allure_markers(
        testcase_code,
        module_name=module_name,
        case_name=case_name or case_key,
        project_name=project_name
    )
    testcase_code = _normalize_missing_secret_asserts(testcase_code)

    with open(file_path, "w", encoding="utf-8") as file:
        file.write(testcase_code)

    return file_path


def _classify_pytest_result(return_code, output):
    output_text = str(output or "")
    if return_code == -1:
        return "timeout"
    if return_code != 0:
        return "failed"
    if re.search(r"\b\d+\s+skipped\b", output_text, re.IGNORECASE):
        return "skipped"
    return "passed"


def _build_verification_message(status, failure_reason=None):
    if status == "passed":
        return "用例已生成并执行通过"
    if status == "skipped":
        return "用例已生成，但 pytest 执行被跳过，请检查环境变量、测试数据或跳过条件"
    if status == "timeout":
        return "用例已生成，但 pytest 执行超时"
    return failure_reason or "用例已生成，但 pytest 执行失败"


def analyze_generated_case_quality(testcase_path, product_name, project_name, case_key):
    files = read_generated_case_files(
        testcase_path=testcase_path,
        product_name=product_name,
        project_name=project_name,
        case_key=case_key,
        only_imported_resources=True
    )
    todo_items = []
    for relative_path, content in files.items():
        for line_number, line in enumerate(str(content or "").splitlines(), 1):
            if "TODO" in line or "PLACEHOLDER" in line:
                todo_items.append({
                    "file": relative_path,
                    "line": line_number,
                    "text": line.strip()[:200]
                })

    warnings = []
    if todo_items:
        warnings.append("生成文件包含 TODO/PLACEHOLDER，需要人工补充页面元素定位或测试数据")

    return {
        "hasTodoSelectors": bool(todo_items),
        "todoCount": len(todo_items),
        "requiresManualCompletion": bool(todo_items),
        "warnings": warnings,
        "todoItems": todo_items[:20]
    }


def run_generated_ui_testcase(testcase_path, timeout=180):
    command = [
        sys.executable,
        "-m",
        "pytest",
        testcase_path,
        "-q",
        "--tb=short"
    ]
    try:
        process = subprocess.run(
            command,
            cwd=PROJECT_ROOT,
            capture_output=True,
            text=True,
            timeout=timeout
        )
        stdout = process.stdout or ""
        stderr = process.stderr or ""
        output = (stdout + "\n" + stderr).strip()
        verification_status = _classify_pytest_result(process.returncode, output)
        failure_reason = summarize_test_failure(output)
        return {
            "success": process.returncode == 0,
            "returnCode": process.returncode,
            "stdout": stdout,
            "stderr": stderr,
            "verificationStatus": verification_status,
            "verificationMessage": _build_verification_message(verification_status, failure_reason),
            "failureReason": failure_reason
        }
    except subprocess.TimeoutExpired as error:
        output = ((error.stdout or "") + "\n" + (error.stderr or "")).strip()
        failure_reason = "pytest执行超时，timeout={0}s".format(timeout)
        if output:
            failure_reason += "\n" + summarize_test_failure(output)
        return {
            "success": False,
            "returnCode": -1,
            "stdout": error.stdout or "",
            "stderr": error.stderr or "",
            "verificationStatus": "timeout",
            "verificationMessage": _build_verification_message("timeout", failure_reason),
            "failureReason": failure_reason
        }
    except Exception as error:
        failure_reason = "pytest执行异常：{0}".format(str(error))
        return {
            "success": False,
            "returnCode": -1,
            "stdout": "",
            "stderr": str(error),
            "verificationStatus": "failed",
            "verificationMessage": _build_verification_message("failed", failure_reason),
            "failureReason": failure_reason
        }


def summarize_test_failure(output, max_length=3000):
    text = str(output or "").strip()
    if not text:
        return "用例执行失败，但 pytest 未输出错误信息"

    failure_lines = []
    capture = False
    for line in text.splitlines():
        stripped = line.strip()
        if stripped.startswith(("E   ", "FAILED ", "ERROR ", "ImportError", "ModuleNotFoundError", "NameError", "AttributeError", "AssertionError", "playwright._impl")):
            failure_lines.append(line)
            capture = True
            continue
        if capture and stripped:
            failure_lines.append(line)
        if len("\n".join(failure_lines)) >= max_length:
            break

    summary = "\n".join(failure_lines).strip() or text[-max_length:]
    return summary[-max_length:]


def _extract_imported_resource_files(testcase_path, project_code):
    if not os.path.exists(testcase_path):
        return []
    with open(testcase_path, "r", encoding="utf-8") as file:
        content = file.read()
    modules = re.findall(
        r"^from\s+{0}\.test_case\.Resource\.UI\.([A-Za-z_][A-Za-z0-9_]*)\s+import\s+".format(re.escape(project_code)),
        content,
        flags=re.MULTILINE
    )
    return ["{0}.py".format(module_name) for module_name in modules]


def read_generated_case_files(testcase_path, product_name, project_name, case_key, only_imported_resources=False):
    files = {}
    candidate_paths = [testcase_path]
    project_code = _resolve_project_code_or_default(product_name, project_name, case_key)
    resource_dir = _resolve_ui_resource_dir(
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    if os.path.isdir(resource_dir):
        if only_imported_resources:
            resource_file_names = _extract_imported_resource_files(testcase_path, project_code)
        else:
            resource_file_names = [file_name for file_name in os.listdir(resource_dir) if file_name.endswith(".py")]
        for file_name in resource_file_names:
            candidate_paths.append(os.path.join(resource_dir, file_name))

    for path in candidate_paths:
        if os.path.exists(path):
            with open(path, "r", encoding="utf-8") as file:
                files[os.path.relpath(path, PROJECT_ROOT)] = file.read()
    return files


def build_ui_testcase_repair_prompt(project_id,
                                    case_id,
                                    automation_type,
                                    prompt,
                                    case_key,
                                    module_name,
                                    product_name,
                                    project_name,
                                    steps,
                                    expected_results,
                                    case_name,
                                    testcase_path,
                                    failure_reason,
                                    attempt_index):
    current_files = read_generated_case_files(
        testcase_path=testcase_path,
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    case_info = {
        "projectId": project_id,
        "caseId": case_id,
        "automationType": automation_type,
        "caseKey": case_key,
        "moduleName": module_name,
        "productName": product_name,
        "projectName": project_name,
        "steps": steps,
        "expectedResults": expected_results,
        "caseName": case_name,
        "userPrompt": sanitize_prompt(prompt),
        "attemptIndex": attempt_index,
        "failureReason": failure_reason,
        "currentFiles": current_files
    }
    return """当前 UI 自动化用例执行失败，请根据失败原因修复并重新输出完整分文件代码。

要求：
1. 只修复失败原因相关问题，保持原始测试目标不变。
2. 测试用例文件只包含 pytest 用例、fixture/测试数据和对页面对象的调用。
3. BasePage、PageObject、业务操作方法、元素定位方法必须放到独立页面对象/公共方法文件中。
4. 测试用例必须 import allure，并至少包含 @allure.story 与 @allure.title。
5. 继续使用 fenced code block 标记分文件输出：```python file=base_page.py```、```python file=<页面对象文件名>.py```、```python file=test_case.py```。
6. 不要输出解释，只输出可落盘代码。

失败上下文：
{case_info}
""".format(case_info=json.dumps(case_info, ensure_ascii=False, indent=2))


def repair_ui_automation_testcase(project_id,
                                  case_id,
                                  automation_type,
                                  prompt,
                                  case_key,
                                  module_name,
                                  product_name,
                                  project_name,
                                  steps,
                                  expected_results,
                                  case_name,
                                  testcase_path,
                                  failure_reason,
                                  attempt_index,
                                  config=None,
                                  timeout=120):
    repair_prompt = build_ui_testcase_repair_prompt(
        project_id=project_id,
        case_id=case_id,
        automation_type=automation_type,
        prompt=prompt,
        case_key=case_key,
        module_name=module_name,
        product_name=product_name,
        project_name=project_name,
        steps=steps,
        expected_results=expected_results,
        case_name=case_name,
        testcase_path=testcase_path,
        failure_reason=failure_reason,
        attempt_index=attempt_index
    )
    instructions = "你是资深UI自动化测试修复专家，负责根据pytest失败日志修复Python Playwright自动化测试用例。"
    return call_model_api(
        instructions=instructions,
        user_content=repair_prompt,
        config=config,
        timeout=timeout
    )


def generate_save_and_verify_ui_testcase(project_id,
                                         case_id,
                                         automation_type,
                                         prompt,
                                         case_key,
                                         module_name,
                                         product_name,
                                         project_name,
                                         steps,
                                         expected_results,
                                         case_name=None,
                                         config=None,
                                         skill_prompt=None,
                                         timeout=120,
                                         enable_reconnaissance=True,
                                         headless=True,
                                         max_attempts=3):
    generated_content = generate_ui_automation_testcase(
        project_id=project_id,
        case_id=case_id,
        automation_type=automation_type,
        prompt=prompt,
        case_key=case_key,
        module_name=module_name,
        product_name=product_name,
        project_name=project_name,
        steps=steps,
        expected_results=expected_results,
        case_name=case_name,
        config=config,
        skill_prompt=skill_prompt,
        timeout=timeout,
        enable_reconnaissance=enable_reconnaissance,
        headless=headless
    )
    testcase_path = save_generated_ui_testcase(
        generated_content=generated_content,
        product_name=product_name,
        project_name=project_name,
        module_name=module_name,
        case_key=case_key,
        case_name=case_name
    )

    attempts = []
    last_failure_reason = None
    for attempt_index in range(1, max_attempts + 1):
        run_result = run_generated_ui_testcase(testcase_path)
        quality = analyze_generated_case_quality(
            testcase_path=testcase_path,
            product_name=product_name,
            project_name=project_name,
            case_key=case_key
        )
        attempts.append({
            "attempt": attempt_index,
            "success": run_result["success"],
            "returnCode": run_result["returnCode"],
            "verificationStatus": run_result.get("verificationStatus"),
            "verificationMessage": run_result.get("verificationMessage"),
            "failureReason": run_result["failureReason"]
        })
        if run_result["success"]:
            return {
                "success": True,
                "testcasePath": testcase_path,
                "attempts": attempts,
                "quality": quality,
                "verificationStatus": run_result.get("verificationStatus"),
                "verificationMessage": run_result.get("verificationMessage"),
                "failureReason": None
            }

        last_failure_reason = run_result["failureReason"]
        if attempt_index >= max_attempts:
            break

        repaired_content = repair_ui_automation_testcase(
            project_id=project_id,
            case_id=case_id,
            automation_type=automation_type,
            prompt=prompt,
            case_key=case_key,
            module_name=module_name,
            product_name=product_name,
            project_name=project_name,
            steps=steps,
            expected_results=expected_results,
            case_name=case_name,
            testcase_path=testcase_path,
            failure_reason=last_failure_reason,
            attempt_index=attempt_index + 1,
            config=config,
            timeout=timeout
        )
        testcase_path = save_generated_ui_testcase(
            generated_content=repaired_content,
            product_name=product_name,
            project_name=project_name,
            module_name=module_name,
            case_key=case_key,
            case_name=case_name
        )

    quality = analyze_generated_case_quality(
        testcase_path=testcase_path,
        product_name=product_name,
        project_name=project_name,
        case_key=case_key
    )
    return {
        "success": False,
        "testcasePath": testcase_path,
        "attempts": attempts,
        "quality": quality,
        "verificationStatus": attempts[-1].get("verificationStatus") if attempts else "failed",
        "verificationMessage": attempts[-1].get("verificationMessage") if attempts else "用例生成后未执行",
        "failureReason": "用例生成后执行并自动修复 {0} 次仍失败：\n{1}".format(max_attempts, last_failure_reason)
    }


def generate_ui_automation_testcase(project_id,
                                    case_id,
                                    automation_type,
                                    prompt,
                                    case_key,
                                    module_name,
                                    product_name,
                                    project_name,
                                    steps,
                                    expected_results,
                                    case_name=None,
                                    config=None,
                                    skill_prompt=None,
                                    timeout=120,
                                    enable_reconnaissance=True,
                                    headless=True):
    if automation_type != "ui":
        raise ValueError("automation_type必须为ui")

    reconnaissance_result = None
    if enable_reconnaissance:
        reconnaissance_result = reconnaissance_web_page(
            prompt=prompt,
            case_key=case_key,
            product_name=product_name,
            project_name=project_name,
            headless=headless,
            required=True
        )

    final_prompt = build_ui_testcase_prompt(
        project_id=project_id,
        case_id=case_id,
        automation_type=automation_type,
        prompt=prompt,
        case_key=case_key,
        module_name=module_name,
        product_name=product_name,
        project_name=project_name,
        steps=steps,
        expected_results=expected_results,
        case_name=case_name,
        skill_prompt=skill_prompt,
        reconnaissance_result=reconnaissance_result
    )

    instructions = "你是资深UI自动化测试专家，负责生成稳定、可维护、可落地的Python Playwright自动化测试用例。"

    return call_model_api(
        instructions=instructions,
        user_content=final_prompt,
        config=config,
        timeout=timeout
    )


def generate_automation_case(url,
                             project_id,
                             case_id,
                             automation_type,
                             prompt,
                             case_key,
                             module_name,
                             product_name,
                             project_name,
                             steps,
                             expected_results,
                             access_token,
                             cookie=None,
                             timeout=60):
    if not url:
        raise ValueError("url不能为空，请由调用方传入生成自动化用例接口地址")

    headers = DEFAULT_HEADERS.copy()
    headers["accessToken"] = access_token

    if cookie:
        headers["Cookie"] = cookie

    payload = build_generate_automation_payload(
        project_id=project_id,
        case_id=case_id,
        automation_type=automation_type,
        prompt=prompt,
        case_key=case_key,
        module_name=module_name,
        product_name=product_name,
        project_name=project_name,
        steps=steps,
        expected_results=expected_results
    )

    response = requests.post(
        url=url,
        headers=headers,
        data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        timeout=timeout
    )
    response.raise_for_status()

    try:
        return response.json()
    except ValueError:
        return response.text
