#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称：claude_service.py
模块描述：Claude CLI 语义增强服务，用于智能定位元素选择

作者：czj
创建日期：2026-08-06
最后修改：2026-08-06

Phase 5 更新：
- 单候选也调用 Claude 验证
- Prompt 上下文增强（页面 URL、前后步骤、完整候选信息）
- 返回 confirmed 字段
"""

import logging
import subprocess
import json
import re
import os
from typing import List, Dict, Any, Tuple, Optional

from app.config import settings

logger = logging.getLogger(__name__)


# ==================== Prompt 模板（Phase 5 增强）====================

ELEMENT_SELECTION_PROMPT = """你是一个UI自动化测试元素定位专家。根据以下信息选择最匹配的元素。

## 页面上下文
- 当前URL: {page_url}
- 当前路由: {current_route}

## 步骤上下文
- 当前步骤: {current_step}
- 前一步骤: {previous_step}
- 动作类型: {action}

## 候选元素
{candidates_json}

## 选择规则
1. 优先选择可见且可交互的元素
2. 优先选择在当前活动区域（弹窗/抽屉/Tab）内的元素
3. 如果步骤描述包含特定区域关键词，优先在对应区域查找
4. fill动作优先选择INPUT/TEXTAREA，click动作优先选择BUTTON/A
5. 避免选择装饰性元素（如图标、分隔线）
6. 文本精确匹配的元素优先级高于包含匹配

## 输出格式
返回JSON：
{
  "selected_index": 索引,
  "confirmed": true或false,
  "confidence": 0.0-1.0,
  "reason": "选择原因或拒绝原因"
}"""


class ClaudeService:
    """
    Claude CLI 语义增强服务

    核心功能：
    1. 调用 Claude CLI 对候选元素进行语义排序
    2. 返回最匹配的元素索引、置信度和理由
    3. 自动处理超时、异常和格式错误
    """

    def __init__(self):
        """初始化 Claude 服务"""
        self.enabled = settings.CLAUDE_ENABLED
        self.timeout = settings.CLAUDE_TIMEOUT
        self.model = settings.CLAUDE_MODEL
        self.max_candidates = settings.CLAUDE_MAX_CANDIDATES
        logger.info(f"Claude 服务初始化: enabled={self.enabled}, timeout={self.timeout}s, model={self.model}")

    def rank_candidates(
        self,
        step_name: str,
        action: str,
        params: dict,
        candidates: List[Dict[str, Any]],
        page_url: str = "",
        previous_steps: List[Dict[str, Any]] = None,
        min_confidence: float = 0.0
    ) -> Tuple[int, float, str, bool]:
        """
        对候选元素进行语义排序（Phase 5 增强）

        Args:
            step_name: 步骤名称，如 "点击新建会议按钮"
            action: 动作类型，如 click/fill/wait
            params: 步骤参数
            candidates: 候选元素列表，格式：
                [
                    {
                        "index": 0,
                        "tag": "BUTTON",
                        "text": "新建会议",
                        "selector": "button:has-text('新建会议')",
                        "attributes": {...},
                        "position": {"x": 100, "y": 200, "width": 80, "height": 32}
                    },
                    ...
                ]
            page_url: 当前页面 URL（Phase 5 新增）
            previous_steps: 前面的步骤列表（Phase 5 新增）
            min_confidence: 调用方预判的高置信度阈值；>0 且单候选时直接短路不调 Claude
                （语义解析器"规则优先，Claude 按需"：规则可判时跳过 Claude 仲裁）

        Returns:
            Tuple[int, float, str, bool]: (选中索引, 置信度, 选择理由, 是否确认)

        Raises:
            Exception: Claude 调用失败时抛出异常
        """
        if not self.enabled:
            # 未启用时，单候选直接返回，多候选返回第一个
            if len(candidates) == 1:
                return 0, 1.0, "唯一候选元素（Claude未启用）", True
            return 0, 0.6, "关键词匹配第一个候选（Claude未启用）", True

        if len(candidates) == 0:
            raise Exception("候选元素列表为空")

        # 规则优先：调用方判定唯一/高置信候选时跳过 Claude 仲裁（语义化执行机制）
        if min_confidence > 0 and len(candidates) == 1:
            logger.debug(f"Claude 语义排序短路: 唯一候选（min_confidence={min_confidence}）")
            return 0, 1.0, "唯一候选元素（min_confidence 短路）", True

        # Phase 5: 单候选也调用 Claude 验证
        logger.info(f"Claude 语义排序: step='{step_name}', candidates={len(candidates)}, url={page_url[:50] if page_url else 'N/A'}")

        try:
            # 1. 构建 Prompt（Phase 5 增强）
            prompt = self._build_prompt(step_name, action, params, candidates, page_url, previous_steps)
            logger.debug(f"Claude Prompt 长度: {len(prompt)} 字符")

            # 2. 调用 Claude CLI
            response = self._call_claude_cli(prompt)
            logger.debug(f"Claude 原始响应: {response}")

            # 3. 解析响应
            result = self._parse_response(response)

            selected_index = result.get('selected_index', 0)
            confidence = result.get('confidence', 0.8)
            reason = result.get('reason', '未提供理由')
            confirmed = result.get('confirmed', True)  # Phase 5 新增

            logger.info(f"Claude 选择结果: index={selected_index}, confidence={confidence:.2f}, confirmed={confirmed}, reason={reason}")

            return selected_index, confidence, reason, confirmed

        except Exception as e:
            logger.error(f"Claude 语义排序失败: {e}")
            # Phase 5: 失败时返回第一个候选，但标记为未确认
            return 0, 0.5, f"Claude调用失败，回退到第一个候选: {str(e)[:50]}", False

    def _build_prompt(
        self,
        step_name: str,
        action: str,
        params: dict,
        candidates: List[Dict[str, Any]],
        page_url: str = "",
        previous_steps: List[Dict[str, Any]] = None
    ) -> str:
        """
        构建 Claude Prompt（Phase 5 增强）

        Args:
            step_name: 步骤名称
            action: 动作类型
            params: 步骤参数
            candidates: 候选元素列表
            page_url: 当前页面 URL
            previous_steps: 前面的步骤列表

        Returns:
            str: 完整的 Prompt
        """
        # 限制候选数量
        limited_candidates = candidates[:self.max_candidates]

        # Phase 5: 保留完整候选信息（不过度简化）
        enhanced_candidates = []
        for c in limited_candidates:
            attrs = c.get('attributes', {})
            pos = c.get('position', {})

            enhanced_candidates.append({
                'index': c.get('index', 0),
                'tag': c.get('tag', ''),
                'text': (c.get('text', '') or '')[:100],  # 扩展文本长度
                'selector': c.get('selector', ''),
                'class': attrs.get('class', '')[:50] if attrs.get('class') else '',
                'type': attrs.get('type', ''),
                'placeholder': attrs.get('placeholder', ''),
                'aria-label': attrs.get('aria-label', ''),
                'name': attrs.get('name', ''),
                'data-testid': attrs.get('data-testid', ''),
                'position': f"({pos.get('x', 0)}, {pos.get('y', 0)})" if pos else '',
            })

        # 构建候选元素 JSON
        candidates_json = json.dumps(enhanced_candidates, ensure_ascii=False, indent=2)

        # Phase 5: 提取页面路由
        current_route = ""
        if page_url:
            try:
                # 提取 hash 路由或路径
                if '#' in page_url:
                    current_route = page_url.split('#')[1].split('?')[0]
                else:
                    current_route = page_url.split('//')[1].split('/', 1)[1] if '/' in page_url.split('//')[1] else '/'
            except Exception:
                current_route = page_url[:50]

        # Phase 5: 获取前一步骤描述
        previous_step = ""
        if previous_steps and len(previous_steps) > 0:
            last_step = previous_steps[-1]
            previous_step = last_step.get('name', '')[:50]

        # 填充模板
        prompt = ELEMENT_SELECTION_PROMPT.format(
            page_url=page_url[:100] if page_url else '未知',
            current_route=current_route[:50] if current_route else '未知',
            current_step=step_name,
            previous_step=previous_step or '无',
            action=action,
            candidates_json=candidates_json
        )

        return prompt

    def _call_claude_cli(self, prompt: str) -> str:
        """
        调用 Claude CLI

        支持三种调用方式（按优先级）：
        1. 本地 Claude CLI（Windows/Linux 本地环境）
        2. SSH 调用宿主机 Claude CLI（Docker 容器内环境）
        3. 容器内 Claude CLI（如果已安装）

        Args:
            prompt: Prompt 文本

        Returns:
            str: Claude 响应文本

        Raises:
            Exception: 调用失败时抛出异常
        """
        import platform
        import os

        # 设置环境变量确保 UTF-8 编码
        env = os.environ.copy()
        env['PYTHONIOENCODING'] = 'utf-8'
        env['LANG'] = 'en_US.UTF-8'

        is_windows = platform.system() == 'Windows'

        # ========== 方式1: 本地 Claude CLI ==========
        try:
            if is_windows:
                result = subprocess.run(
                    f'claude --print',
                    input=prompt,
                    capture_output=True,
                    text=True,
                    timeout=self.timeout,
                    shell=True,
                    env=env,
                    encoding='utf-8',
                    errors='replace'
                )
            else:
                result = subprocess.run(
                    ['claude', '--print'],
                    input=prompt,
                    capture_output=True,
                    text=True,
                    timeout=self.timeout,
                    env=env,
                    encoding='utf-8',
                    errors='replace'
                )

            if result.returncode == 0 and result.stdout and result.stdout.strip():
                logger.debug(f"Claude CLI 本地调用成功: {len(result.stdout)} 字符")
                return result.stdout.strip()
        except FileNotFoundError:
            logger.debug("本地 Claude CLI 未找到，尝试其他方式")
        except subprocess.TimeoutExpired:
            logger.warning("本地 Claude CLI 超时，尝试其他方式")
        except Exception as e:
            logger.debug(f"本地 Claude CLI 失败: {e}")

        # ========== 方式2: SSH 调用宿主机 Claude CLI（Docker 容器内使用） ==========
        try:
            response = self._call_claude_via_ssh(prompt)
            if response:
                logger.debug(f"Claude CLI SSH 调用成功: {len(response)} 字符")
                return response
        except Exception as e:
            logger.debug(f"SSH 调用 Claude CLI 失败: {e}")

        # ========== 方式3: 容器内 Claude CLI（如果已安装） ==========
        try:
            if not is_windows:
                result = subprocess.run(
                    ['claude', '--print'],
                    input=prompt,
                    capture_output=True,
                    text=True,
                    timeout=self.timeout,
                    env=env,
                    encoding='utf-8',
                    errors='replace'
                )
                if result.returncode == 0 and result.stdout and result.stdout.strip():
                    logger.debug(f"Claude CLI 容器内调用成功: {len(result.stdout)} 字符")
                    return result.stdout.strip()
        except Exception as e:
            logger.debug(f"容器内 Claude CLI 失败: {e}")

        # 所有方式都失败
        raise Exception("Claude CLI 不可用（本地/SSH/容器均无法调用）")

    def _call_claude_via_ssh(self, prompt: str) -> str:
        """
        通过 SSH 调用宿主机的 Claude CLI（适用于 Docker 容器内环境）

        容器内通过宿主机网关 IP 访问宿主机 SSH 服务

        Args:
            prompt: Prompt 文本

        Returns:
            str: Claude 响应文本

        Raises:
            Exception: SSH 调用失败时抛出异常
        """
        import tempfile

        # 获取宿主机网关 IP（Docker 容器内访问宿主机的方式）
        host_ip = os.environ.get('CLAUDE_SSH_HOST', '172.17.0.1')  # Docker 默认网关
        host_port = int(os.environ.get('CLAUDE_SSH_PORT', '22'))
        host_user = os.environ.get('CLAUDE_SSH_USER', 'ubains')
        host_pass = os.environ.get('CLAUDE_SSH_PASS', 'Ubains@123')

        logger.info(f"通过 SSH 调用宿主机 Claude CLI: {host_user}@{host_ip}:{host_port}")

        # 将 prompt 写入临时文件，避免 shell 转义问题
        with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
            f.write(prompt)
            prompt_file = f.name

        try:
            # 使用 SSH 执行 claude --print，通过 stdin 传递 prompt
            ssh_cmd = [
                'sshpass', '-p', host_pass,
                'ssh', '-o', 'StrictHostKeyChecking=no',
                '-o', 'ConnectTimeout=5',
                '-p', str(host_port),
                f'{host_user}@{host_ip}',
                'claude --print'
            ]

            result = subprocess.run(
                ssh_cmd,
                input=prompt,
                capture_output=True,
                text=True,
                timeout=self.timeout,
                encoding='utf-8',
                errors='replace'
            )

            if result.returncode == 0 and result.stdout and result.stdout.strip():
                return result.stdout.strip()

            # sshpass 不可用，尝试 paramiko
            if 'sshpass' in (result.stderr or ''):
                logger.debug("sshpass 不可用，尝试 paramiko")
                return self._call_claude_via_paramiko(prompt, host_ip, host_port, host_user, host_pass)

            error_msg = result.stderr.strip() if result.stderr else "未知错误"
            raise Exception(f"SSH Claude CLI 错误: {error_msg[:200]}")

        except subprocess.TimeoutExpired:
            raise Exception(f"SSH Claude CLI 超时（>{self.timeout}s）")
        except FileNotFoundError:
            # sshpass 不存在，回退到 paramiko
            logger.debug("sshpass 未安装，尝试 paramiko")
            return self._call_claude_via_paramiko(prompt, host_ip, host_port, host_user, host_pass)
        finally:
            # 清理临时文件
            try:
                os.unlink(prompt_file)
            except Exception:
                pass

    def _call_claude_via_paramiko(self, prompt: str, host: str, port: int, user: str, password: str) -> str:
        """
        通过 paramiko SSH 库调用宿主机 Claude CLI

        Args:
            prompt: Prompt 文本
            host: 宿主机 IP
            port: SSH 端口
            user: SSH 用户名
            password: SSH 密码

        Returns:
            str: Claude 响应文本
        """
        try:
            import paramiko
        except ImportError:
            raise Exception("paramiko 未安装，无法通过 SSH 调用 Claude CLI")

        ssh = paramiko.SSHClient()
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

        try:
            ssh.connect(host, port=port, username=user, password=password, timeout=10)

            # 通过 stdin 传递 prompt
            stdin, stdout, stderr = ssh.exec_command(
                'claude --print',
                timeout=self.timeout
            )
            stdin.write(prompt)
            stdin.flush()
            stdin.channel.shutdown_write()

            out = stdout.read().decode('utf-8', errors='replace').strip()
            err = stderr.read().decode('utf-8', errors='replace').strip()

            if out:
                return out

            raise Exception(f"Paramiko Claude CLI 无输出: {err[:200]}")

        except Exception as e:
            raise Exception(f"Paramiko SSH 调用失败: {e}")
        finally:
            ssh.close()

    def _parse_response(self, response: str) -> dict:
        """
        解析 Claude 响应（Phase 5 支持 confirmed 字段）

        Args:
            response: Claude 原始响应文本

        Returns:
            dict: 解析后的结果 {"selected_index": 0, "confirmed": true, "confidence": 0.95, "reason": "..."}

        Raises:
            Exception: 解析失败时抛出异常
        """
        try:
            # 清理响应文本
            cleaned = response.strip()

            # 移除可能的 markdown 代码块标记
            if cleaned.startswith("```"):
                lines = cleaned.split("\n")
                if lines[0].startswith("```"):
                    lines = lines[1:]
                if lines and lines[-1].strip() == "```":
                    lines = lines[:-1]
                cleaned = "\n".join(lines).strip()

            # 尝试提取 JSON
            # 方法1: 直接解析
            try:
                result = json.loads(cleaned)
                if isinstance(result, dict) and 'selected_index' in result:
                    # Phase 5: 确保 confirmed 字段存在
                    if 'confirmed' not in result:
                        result['confirmed'] = True
                    return result
            except json.JSONDecodeError:
                pass

            # 方法2: 使用正则提取 JSON 对象
            json_match = re.search(r'\{[^{}]*"selected_index"[^{}]*\}', cleaned, re.DOTALL)
            if json_match:
                try:
                    result = json.loads(json_match.group())
                    if isinstance(result, dict) and 'selected_index' in result:
                        if 'confirmed' not in result:
                            result['confirmed'] = True
                        return result
                except json.JSONDecodeError:
                    pass

            # 方法3: 尝试提取关键字段
            index_match = re.search(r'"selected_index"\s*:\s*(\d+)', cleaned)
            if index_match:
                selected_index = int(index_match.group(1))
                confidence_match = re.search(r'"confidence"\s*:\s*([\d.]+)', cleaned)
                confidence = float(confidence_match.group(1)) if confidence_match else 0.8
                reason_match = re.search(r'"reason"\s*:\s*"([^"]*)"', cleaned)
                reason = reason_match.group(1) if reason_match else "解析得到"
                confirmed_match = re.search(r'"confirmed"\s*:\s*(true|false)', cleaned)
                confirmed = confirmed_match.group(1) == 'true' if confirmed_match else True

                return {
                    'selected_index': selected_index,
                    'confirmed': confirmed,
                    'confidence': confidence,
                    'reason': reason
                }

            raise Exception(f"无法解析 Claude 响应: {cleaned[:200]}")

        except Exception as e:
            logger.error(f"Claude 响应解析失败: {e}")
            raise Exception(f"Claude 响应解析失败: {e}")


# ==================== 工厂函数 ====================

def get_claude_service() -> ClaudeService:
    """
    获取 Claude 服务实例

    Returns:
        ClaudeService: 服务实例
    """
    return ClaudeService()


# ==================== 测试代码 ====================

if __name__ == "__main__":
    """测试 Claude 服务"""
    import sys

    # 设置日志
    logging.basicConfig(
        level=logging.DEBUG,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )

    # 测试候选元素
    test_candidates = [
        {
            "index": 0,
            "tag": "DIV",
            "text": "会议预约",
            "selector": "div:has-text('会议预约')",
            "attributes": {"class": "menu-item"},
            "position": {"x": 50, "y": 150, "width": 200, "height": 40}
        },
        {
            "index": 1,
            "tag": "BUTTON",
            "text": "新建会议",
            "selector": "button:has-text('新建会议')",
            "attributes": {"class": "el-button el-button--primary", "type": "button"},
            "position": {"x": 100, "y": 200, "width": 80, "height": 32}
        },
        {
            "index": 2,
            "tag": "INPUT",
            "text": "",
            "selector": "input[placeholder='请输入会议名称']",
            "attributes": {"type": "text", "placeholder": "请输入会议名称"},
            "position": {"x": 200, "y": 300, "width": 200, "height": 32}
        }
    ]

    # 测试场景1: 点击按钮
    print("\n=== 测试场景1: 点击新建会议按钮 ===")
    service = ClaudeService()
    try:
        idx, conf, reason = service.rank_candidates(
            step_name="点击新建会议按钮",
            action="click",
            params={},
            candidates=test_candidates
        )
        print(f"结果: index={idx}, confidence={conf:.2f}, reason={reason}")
        print(f"选择器: {test_candidates[idx]['selector']}")
    except Exception as e:
        print(f"测试失败: {e}")

    # 测试场景2: 输入会议名称
    print("\n=== 测试场景2: 会议名称输入：自动化测试 ===")
    try:
        idx, conf, reason = service.rank_candidates(
            step_name="会议名称输入：自动化测试",
            action="fill",
            params={"value": "自动化测试"},
            candidates=test_candidates
        )
        print(f"结果: index={idx}, confidence={conf:.2f}, reason={reason}")
        print(f"选择器: {test_candidates[idx]['selector']}")
    except Exception as e:
        print(f"测试失败: {e}")

    # 测试场景3: 单候选
    print("\n=== 测试场景3: 单候选元素 ===")
    try:
        idx, conf, reason = service.rank_candidates(
            step_name="点击唯一按钮",
            action="click",
            params={},
            candidates=[test_candidates[1]]
        )
        print(f"结果: index={idx}, confidence={conf:.2f}, reason={reason}")
    except Exception as e:
        print(f"测试失败: {e}")
