# -*- coding: utf-8 -*-
"""
reCAPTCHA 图片验证码纯 HTTP 解决器 (protobuf + AI Vision)

100% 纯 HTTP，零浏览器依赖。
图片挑战不需要 bgdata，可以直接走 protobuf 接口。

用法:
    solver = RecaptchaImageSolver(vision_api_key="sk-...")
    result = solver.solve(sitekey, page_url)
"""
import os, re, time, struct, json, sys, logging, base64
from urllib.parse import urlencode, urlparse

log = logging.getLogger("recaptcha_image")

try:
    from curl_cffi import requests as curl_requests
    HAS_CURL = True
except ImportError:
    HAS_CURL = False
    import urllib.request

UA = (
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
)
IMPERSONATE = "chrome136"


# ==================== Protobuf 编码 ====================
def _encode_varint(value):
    parts = []
    while value > 0x7F:
        parts.append((value & 0x7F) | 0x80)
        value >>= 7
    parts.append(value & 0x7F)
    return bytes(parts)


def _encode_field(field_num, wire_type, data):
    tag = (field_num << 3) | wire_type
    return _encode_varint(tag) + data


def _encode_string(field_num, value):
    if value is None:
        return b""
    encoded = value.encode("utf-8") if isinstance(value, str) else value
    return _encode_field(field_num, 2, _encode_varint(len(encoded)) + encoded)


def encode_reload_protobuf(version, token, sitekey, reason="fi", timestamp=None):
    """编码 /reload 请求的 protobuf body (无 bgdata 版本，用于图片挑战)"""
    if timestamp is None:
        timestamp = str(int(-time.time()))

    body = b""
    body += _encode_string(1, version)
    body += _encode_string(2, token)
    body += _encode_string(5, timestamp)
    body += _encode_string(6, reason)
    body += _encode_string(14, sitekey)
    body += _encode_string(25, "W10")  # base64 of []
    return body


def encode_userverify_form(version, token, selected_indices):
    """编码 /userverify 请求 (URL-encoded form)

    Args:
        version: reCAPTCHA 版本
        token: 挑战 token (from /reload response)
        selected_indices: 选中的图块索引列表 (0-based), e.g. [1, 4, 7]
    """
    response_obj = {"response": sorted(selected_indices)}
    response_json = json.dumps(response_obj, separators=(",", ":"))
    response_b64 = base64.urlsafe_b64encode(response_json.encode()).decode().rstrip("=")

    return urlencode({
        "v": version,
        "c": token,
        "response": response_b64,
    })


# ==================== HTTP 请求 ====================
def http_get(url, headers=None, session=None):
    hdrs = {"user-agent": UA, "accept": "*/*"}
    if headers:
        hdrs.update(headers)

    if session and HAS_CURL:
        r = session.get(url, headers=hdrs, impersonate=IMPERSONATE, timeout=15)
        return r.text, r.status_code
    else:
        req = urllib.request.Request(url, headers=hdrs)
        resp = urllib.request.urlopen(req, timeout=15)
        return resp.read().decode("utf-8"), resp.status


def http_post_protobuf(url, body, headers=None, session=None):
    hdrs = {
        "user-agent": UA,
        "content-type": "application/x-protobuffer",
        "accept": "*/*",
    }
    if headers:
        hdrs.update(headers)

    if session and HAS_CURL:
        r = session.post(
            url, data=body, headers=hdrs, impersonate=IMPERSONATE, timeout=15
        )
        return r.content, r.status_code
    else:
        req = urllib.request.Request(url, data=body, headers=hdrs, method="POST")
        resp = urllib.request.urlopen(req, timeout=15)
        return resp.read(), resp.status


def http_get_binary(url, headers=None, session=None):
    hdrs = {"user-agent": UA}
    if headers:
        hdrs.update(headers)

    if session and HAS_CURL:
        r = session.get(url, headers=hdrs, impersonate=IMPERSONATE, timeout=30)
        return r.content
    else:
        req = urllib.request.Request(url, headers=hdrs)
        resp = urllib.request.urlopen(req, timeout=30)
        return resp.read()


# ==================== 响应解析 ====================
def parse_reload_response(raw_bytes):
    """解析 /reload 响应 (JSON array wrapped in )]}' prefix)"""
    text = raw_bytes.decode("utf-8", errors="replace")
    clean = re.sub(r"^\)\]\}'\n?", "", text)
    try:
        arr = json.loads(clean)
        return arr
    except json.JSONDecodeError:
        log.error("reload 响应解析失败: %s", text[:200])
        return None


KG_ID_MAP = {
    "/m/01bjv": "bus",
    "/m/015qff": "traffic light",
    "/m/014xcs": "bridge",
    "/m/0k4j": "car",
    "/m/07jdr": "train",
    "/m/015qbp": "parking meter",
    "/m/0199g": "bicycle",
    "/m/01pns0": "fire hydrant",
    "/m/0h2r6": "van",
    "/m/07r04": "truck",
    "/m/0pg52": "taxi",
    "/m/04_sv": "motorcycle",
    "/m/02dgv": "stairs",
    "/m/05_5t0l": "chimney",
    "/m/015kr": "bridge",
    "/m/01lynh": "stairs",
    "/m/01jk_4": "crosswalk",
    "/m/014y4n": "boat",
    "/m/0cdl1": "palm tree",
    "/m/09d_r": "mountain",
}


def extract_image_challenge(arr, sitekey, endpoint="api2"):
    """从 reload 响应中提取图片挑战信息"""
    if not arr or len(arr) < 2:
        return None

    new_token = arr[1]
    if not new_token:
        return None

    result = {
        "token": new_token,
        "type": arr[5] if len(arr) > 5 else None,
        "prompt": None,
        "image_url": None,
        "grid_size": 3,
        "kg_ids": [],
    }

    # 两种 pmeta 格式:
    # multicaptcha: ["pmeta", null, null, null, null, [[["/m/01bjv", null, 2, 4, 4], ...]]]
    # dynamic:      ["pmeta", ["/m/0199g", null, 3, 3, 3, null, "Bike"], null, [1, 3000]]
    if len(arr) > 4 and isinstance(arr[4], list):
        pmeta = arr[4]

        # 格式1: dynamic — pmeta[1] = [kg_id, null, type, rows, cols, null, "Label"]
        if len(pmeta) > 1 and isinstance(pmeta[1], list) and len(pmeta[1]) >= 1:
            item = pmeta[1]
            kg_id = item[0] if isinstance(item[0], str) else None
            if kg_id:
                result["kg_ids"].append(kg_id)
                # 文字标签在 item[6]
                if len(item) > 6 and isinstance(item[6], str):
                    result["prompt"] = item[6].lower()
                else:
                    result["prompt"] = KG_ID_MAP.get(kg_id, kg_id)
                # 网格大小在 item[3] 和 item[4]
                if len(item) > 4:
                    rows = item[3] if isinstance(item[3], int) else 3
                    cols = item[4] if isinstance(item[4], int) else 3
                    result["grid_size"] = max(rows, cols)

        # 格式2: multicaptcha — pmeta[5] = [[[kg_id, null, 2, 4, 4], ...]]
        if not result["prompt"] and len(pmeta) > 5 and isinstance(pmeta[5], list):
            for group in pmeta[5]:
                if isinstance(group, list):
                    for item in group:
                        if isinstance(item, list) and len(item) >= 5:
                            kg_id = item[0]
                            grid_rows = item[3] if len(item) > 3 else 3
                            grid_cols = item[4] if len(item) > 4 else 3
                            result["grid_size"] = max(grid_rows, grid_cols)
                            result["kg_ids"].append(kg_id)
                            if not result["prompt"]:
                                result["prompt"] = KG_ID_MAP.get(kg_id, kg_id)

    if not result["prompt"]:
        result["prompt"] = "the target object shown in the image"

    # arr[9] = payload token (图片下载，前缀 06AFcWeA)
    # arr[8] = 另一个 token (前缀 05AEc790, 400 错误)
    for idx in [9, 8]:
        if len(arr) > idx and isinstance(arr[idx], str) and len(arr[idx]) > 20:
            result["image_url"] = (
                f"https://www.google.com/recaptcha/{endpoint}/payload"
                f"?p={arr[idx]}&k={sitekey}"
            )
            result["payload_idx"] = idx
            break

    return result


# ==================== AI Vision ====================
def ask_vision_api(image_bytes, prompt_text, api_key, api_base=None,
                   grid_size=4, challenge_type="", model=None):
    """调用 AI Vision API 识别图片中的目标图块

    Args:
        image_bytes: JPEG 图片数据
        prompt_text: 目标对象名称 (如 "motorcycle")
        api_key: OpenAI API key
        api_base: API base URL
        grid_size: 网格大小 (3 or 4)
        challenge_type: 挑战类型 ("multicaptcha" 等)

    Returns: list of selected tile indices (0-based), e.g. [0, 3, 6]
    """
    if not api_base:
        api_base = "https://api.openai.com/v1"

    b64_img = base64.b64encode(image_bytes).decode("ascii")

    total_tiles = grid_size * grid_size
    system_prompt = (
        f"You are a visual recognition expert. The image shows a {grid_size}x{grid_size} grid "
        f"({total_tiles} tiles total), numbered 1-{total_tiles} left-to-right, top-to-bottom.\n"
        f"Task: identify which tiles contain a '{prompt_text}' (even partially visible).\n"
        "Rules:\n"
        "- A tile counts if ANY part of the target object appears in it\n"
        "- Typically 2-6 tiles match, rarely all or none\n"
        "- Reply with ONLY comma-separated tile numbers, nothing else\n"
        "- If truly no tile matches, reply: NONE\n"
        "Example: 1,4,7"
    )

    user_prompt = f"Which tiles in this {grid_size}x{grid_size} grid contain '{prompt_text}'?"

    payload = {
        "model": model or "gpt-4o",
        "messages": [
            {"role": "system", "content": system_prompt},
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": user_prompt},
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:image/jpeg;base64,{b64_img}",
                            "detail": "high",
                        },
                    },
                ],
            },
        ],
        "max_tokens": 100,
        "temperature": 0,
    }

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }

    data = json.dumps(payload).encode("utf-8")

    try:
        if HAS_CURL:
            r = curl_requests.post(
                f"{api_base}/chat/completions",
                data=data, headers=headers, timeout=30,
            )
            result = r.json()
        else:
            req = urllib.request.Request(
                f"{api_base}/chat/completions",
                data=data, headers=headers, method="POST",
            )
            resp = urllib.request.urlopen(req, timeout=30)
            result = json.loads(resp.read().decode("utf-8"))

        answer_text = result["choices"][0]["message"]["content"].strip()
        print(f"[AI Vision] 回答: {answer_text}")

        if answer_text.upper() in ("NO", "N", "NONE", "0"):
            return []

        # 解析网格答案: "1,4,7" → [0, 3, 6] (转为0-based)
        tiles = []
        for part in re.findall(r"\d+", answer_text):
            idx = int(part) - 1
            if 0 <= idx < total_tiles:
                tiles.append(idx)

        # 安全检查: 选了所有格子说明模型没理解，返回空让重试
        if len(tiles) >= total_tiles:
            print(f"[AI Vision] 警告: 选了全部 {total_tiles} 格，判定为误识别")
            return []

        return tiles
    except Exception as e:
        log.error("Vision API 错误: %s", e)
        print(f"[AI Vision] API 错误: {e}")
        return []


def tiles_to_answer(selected_tiles, grid_size=3):
    """将选中的图块索引转换为 reCAPTCHA answer 字符串"""
    # reCAPTCHA 图片答案格式: 每个选中的tile用一个字符表示
    # 索引映射到字符
    answer_parts = []
    for idx in sorted(selected_tiles):
        answer_parts.append(str(idx))
    return ",".join(answer_parts)


# ==================== 主解题器 ====================
class RecaptchaImageSolver:
    def __init__(self, vision_api_key=None, vision_api_base=None,
                 vision_model=None, max_retries=3):
        self.vision_api_key = vision_api_key or os.environ.get("OPENAI_API_KEY", "")
        self.vision_api_base = vision_api_base
        self.vision_model = vision_model
        self.max_retries = max_retries
        self.version = None
        self.session = curl_requests.Session() if HAS_CURL else None

    def _get_version(self):
        """获取 reCAPTCHA 版本号"""
        if self.version:
            return self.version

        text, _ = http_get(
            "https://www.google.com/recaptcha/api.js", session=self.session
        )
        m = re.search(r"releases/([^/]+)/", text)
        if m:
            self.version = m.group(1)
            log.info("版本: %s", self.version)
        else:
            self.version = "hsFBb1u5wWWWkWP4in1ua2cQ"
            log.warning("版本提取失败，使用默认: %s", self.version)
        return self.version

    def _get_anchor_token(self, sitekey, page_url, data_s=None):
        """获取 anchor token"""
        co = base64.b64encode(
            urlparse(page_url)._replace(path="", params="", query="", fragment="")
            .geturl()
            .encode()
        ).decode().rstrip("=")

        version = self._get_version()
        params = {
            "ar": "1",
            "k": sitekey,
            "co": co,
            "hl": "en",
            "v": version,
            "size": "normal",
            "cb": "bl" + str(int(time.time() * 1000)),
        }
        if data_s:
            params["s"] = data_s

        endpoint = "enterprise" if self._is_enterprise(sitekey) else "api2"
        url = f"https://www.google.com/recaptcha/{endpoint}/anchor?" + urlencode(params)

        text, status = http_get(
            url,
            headers={"referer": page_url},
            session=self.session,
        )

        m = re.search(r'id="recaptcha-token"\s+value="([^"]+)"', text)
        if not m:
            log.error("anchor 无 token (status=%d)", status)
            return None, url
        token = m.group(1)
        log.info("anchor token: %d chars", len(token))
        return token, url

    def _is_enterprise(self, sitekey):
        # Google sorry 页面使用 Enterprise: 6LdLLIMb...
        # Demo 页面使用 api2: 6Le-wvkS...
        return sitekey.startswith("6Ld")

    def _send_reload(self, sitekey, token, anchor_url, reason="fi"):
        """发送 /reload protobuf 请求获取图片挑战"""
        version = self._get_version()
        endpoint = "enterprise" if self._is_enterprise(sitekey) else "api2"
        url = f"https://www.google.com/recaptcha/{endpoint}/reload?k={sitekey}"

        body = encode_reload_protobuf(version, token, sitekey, reason=reason)

        raw, status = http_post_protobuf(
            url,
            body,
            headers={"referer": anchor_url},
            session=self.session,
        )

        log.info("/reload response: %d bytes, status=%d", len(raw), status)
        return parse_reload_response(raw)

    def _send_userverify(self, sitekey, token, selected_indices, anchor_url):
        """发送 /userverify (URL-encoded form) 提交答案"""
        version = self._get_version()
        endpoint = "enterprise" if self._is_enterprise(sitekey) else "api2"
        url = f"https://www.google.com/recaptcha/{endpoint}/userverify?k={sitekey}"

        form_body = encode_userverify_form(version, token, selected_indices)
        bframe_ref = f"https://www.google.com/recaptcha/{endpoint}/bframe?hl=en&v={version}&k={sitekey}"

        hdrs = {
            "user-agent": UA,
            "content-type": "application/x-www-form-urlencoded",
            "referer": bframe_ref,
        }

        if self.session and HAS_CURL:
            r = self.session.post(
                url, data=form_body, headers=hdrs,
                impersonate=IMPERSONATE, timeout=15,
            )
            raw, status = r.content, r.status_code
        else:
            req = urllib.request.Request(
                url, data=form_body.encode(), headers=hdrs, method="POST",
            )
            resp = urllib.request.urlopen(req, timeout=15)
            raw, status = resp.read(), resp.status

        log.info("/userverify: %d bytes, status=%d", len(raw), status)
        arr = parse_reload_response(raw)
        if arr:
            log.debug("userverify resp: %s", json.dumps(arr, ensure_ascii=False)[:300])
            uv_token = arr[1] if len(arr) > 1 else None
            if uv_token:
                log.info("userverify token: %d chars", len(uv_token))
                return uv_token
            # Check if passed (arr might indicate more challenges)
            log.info("userverify 需要更多挑战或答案不正确")
        return None

    def solve(self, sitekey, page_url, data_s=None):
        """
        解决图片验证码

        Returns:
            str: g-recaptcha-response token, 或 None
        """
        log.info("开始解题: sitekey=%s", sitekey[:20])

        # Step 1: 获取 anchor token
        token, anchor_url = self._get_anchor_token(sitekey, page_url, data_s)
        if not token:
            return None

        for attempt in range(self.max_retries):
            log.info("尝试 #%d", attempt + 1)

            # Step 2: 发送 /reload 获取图片挑战
            arr = self._send_reload(sitekey, token, anchor_url)
            if not arr:
                continue

            new_token = arr[1] if len(arr) > 1 else None
            if not new_token:
                log.warning("reload 返回 null token")
                continue

            # Step 3: 解析图片挑战
            ep = "enterprise" if self._is_enterprise(sitekey) else "api2"
            challenge = extract_image_challenge(arr, sitekey, ep)
            if not challenge:
                log.warning("无法解析挑战数据")
                log.debug("reload response: %s", json.dumps(arr, ensure_ascii=False)[:500])
                continue

            log.info(
                "挑战: prompt=%s, image=%s",
                challenge.get("prompt", "?"),
                challenge.get("image_url", "?")[:80],
            )

            # Step 4: 下载图片
            img_url = challenge.get("image_url")
            if not img_url:
                log.warning("无图片 URL, 完整响应: %s", json.dumps(arr, ensure_ascii=False)[:500])
                token = new_token
                continue

            version = self._get_version()
            bframe_ref = f"https://www.google.com/recaptcha/api2/bframe?hl=en&v={version}&k={sitekey}"
            img_data = http_get_binary(
                img_url,
                headers={"referer": bframe_ref, "accept": "image/*,*/*"},
                session=self.session,
            )

            if len(img_data) < 1000:
                log.warning("图片下载失败: %d bytes", len(img_data))
                token = new_token
                continue
            log.info("图片下载: %d bytes", len(img_data))

            # Step 5: AI Vision 识别
            prompt_text = challenge.get("prompt", "the target object")
            grid_size = challenge.get("grid_size", 4)
            challenge_type = challenge.get("type", "")

            if not self.vision_api_key:
                log.error("需要 vision_api_key (OPENAI_API_KEY 或构造函数传入)")
                with open("captcha_image_debug.jpg", "wb") as f:
                    f.write(img_data)
                log.info("图片已保存到 captcha_image_debug.jpg, prompt='%s'", prompt_text)
                return None

            selected = ask_vision_api(
                img_data, prompt_text, self.vision_api_key,
                self.vision_api_base, grid_size, challenge_type,
                self.vision_model,
            )
            if not selected:
                log.warning("AI Vision 未识别到目标")
                token = new_token
                continue

            log.info("选中图块: %s", selected)

            # Step 6: 提交答案
            result_token = self._send_userverify(
                sitekey, new_token, selected, anchor_url
            )

            if result_token:
                log.info("验证通过！token: %s...", result_token[:60])
                return result_token

            log.warning("答案不正确")
            token = new_token

        log.error("超过最大重试次数")
        return None


# ==================== 测试 ====================
if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(name)s] %(message)s",
    )

    SITEKEY = "6Le-wvkSAAAAAPBMRTvw0Q4Muexq9bi0DJwx_mJ-"
    PAGE_URL = "https://www.google.com/recaptcha/api2/demo"

    API_KEY = "sk-sDU8gSwyoqUsH9OVVTaNObQ176PZ134I34SCpJNmnBTsOcBx"
    API_BASE = "https://api.tu-zi.com/v1"
    MODEL = "gpt-4o-mini"

    solver = RecaptchaImageSolver(
        vision_api_key=API_KEY,
        vision_api_base=API_BASE,
        vision_model=MODEL,
    )

    print("=== 测试: 纯 HTTP + protobuf + AI Vision 图片验证码 ===")

    result = solver.solve(SITEKEY, PAGE_URL)
    if result:
        print(f"\n=== 验证通过 ===")
        print(f"Token: {result[:80]}...")
    else:
        print("\n=== 验证失败（multicaptcha 多轮挑战，需要完善答案提交格式）===")
