import asyncio
import shutil
import urllib.parse
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
from urllib.parse import urlparse

import requests
from loguru import logger
from yt_dlp.utils import *

from flow_xhs.runtime.enums import XsecSource
from flow_xhs.runtime.errors import (
    InvalidPublishParameterError,
    PublishMediaPreparationError,
    PublishWorkflowNotReadyError,
    UnsupportedPublishTargetError,
)
from flow_xhs.runtime.errors import (
    PublishExecutionError,
    PublishNoteException,
)
from flow_xhs.platform.base import BaseExtractor, PlatformActionableError, RiskControlException
from flow_xhs.platform.issues import classify_issue_from_text
from flow_xhs.runtime.publishing.validator import parse_rfc3339
from flow_xhs.platform.publisher import RednotePublisher
from flow_xhs.platform.search_filters import build_search_filter_selections


def _is_url(path: str) -> bool:
    try:
        parsed = urlparse(path)
        return bool(parsed.scheme and parsed.netloc)
    except Exception:
        return False


def _validate_extension(file_identify: str, allowed_extensions) -> str:
    path_part = urlparse(file_identify).path
    suffix = Path(path_part).suffix.lower()
    if suffix not in allowed_extensions:
        supported = ", ".join(ext.lstrip(".") for ext in sorted(allowed_extensions))
        raise InvalidPublishParameterError(
            f"不支持的文件格式: {file_identify} (仅支持 {supported})"
        )
    return suffix


def _normalize_media_list(media_list: list, field_name: str) -> list[str]:
    if media_list is None:
        raise InvalidPublishParameterError(f"`{field_name}` 不能为空")
    if not isinstance(media_list, (list, tuple)):
        raise InvalidPublishParameterError(f"`{field_name}` 必须是 list 或 tuple")
    if len(media_list) == 0:
        raise InvalidPublishParameterError(f"`{field_name}` 至少包含 1 个文件")

    normalized = []
    for index, item in enumerate(media_list):
        text = "" if item is None else str(item).strip()
        if not text:
            raise InvalidPublishParameterError(f"第 {index + 1} 个素材项为空，请检查输入")
        normalized.append(text)
    return normalized


@contextmanager
def prepare_image_paths(image_list: list, allowed_extensions):
    """Validate/download image assets and cleanup temporary files automatically."""
    normalized_images = _normalize_media_list(image_list, field_name="image_list")
    temp_dir = tempfile.mkdtemp(prefix="publish_upload_")
    final_paths = []

    logger.info(f"开始校验发布素材, count: {len(normalized_images)}")

    try:
        for index, item in enumerate(normalized_images):
            if _is_url(item):
                suffix = _validate_extension(item, allowed_extensions=allowed_extensions)
                target_path = Path(temp_dir) / f"download_{index}{suffix}"
                try:
                    headers = {
                        "User-Agent": (
                            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                            "AppleWebKit/537.36 (KHTML, like Gecko) "
                            "Chrome/110.0.0.0 Safari/537.36"
                        )
                    }
                    response = requests.get(item, headers=headers, timeout=15, stream=True)
                    response.raise_for_status()
                    with open(target_path, "wb") as output:
                        for chunk in response.iter_content(chunk_size=8192):
                            output.write(chunk)
                except requests.exceptions.RequestException as e:
                    raise PublishMediaPreparationError(
                        f"图片 {index + 1} 下载失败: {item}。具体原因: {e}"
                    ) from e

                final_paths.append(str(target_path.absolute()))
                logger.info(f"图片 {index + 1} 下载成功, source: {item}, target_path: {str(target_path.absolute())}")
                continue

            local_path = Path(item)
            if not local_path.exists():
                raise InvalidPublishParameterError(f"本地图片不存在: {item}")
            if not local_path.is_file():
                raise InvalidPublishParameterError(f"路径不是一个有效文件: {item}")

            _validate_extension(item, allowed_extensions=allowed_extensions)
            final_paths.append(str(local_path.absolute()))
            logger.info(f"图片 {index + 1} 校验通过, path: {str(local_path.absolute())}")

        yield final_paths
    finally:
        if os.path.exists(temp_dir):
            shutil.rmtree(temp_dir)
            logger.info("发布素材临时目录已清理, temp_dir: {temp_dir}")


class RednoteExtractor(BaseExtractor):
    """Rednote-specific data extraction using yt_dlp InfoExtractor pattern."""
    # 采用拦截后端访问直接获取接口返回数据
    IE_NAME = 'rednote:all'

    def __init__(self, page):
        super().__init__(page)

    def _to_snake_case(self, name):
        """将 camelCase 转换为 snake_case"""
        import re
        s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
        return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()

    def _format_dict(self, data):
        """递归统一所有键名为小写下划线，不漏掉任何数据"""
        if not isinstance(data, (dict, list)):
            return data
        if isinstance(data, list):
            return [self._format_dict(i) for i in data]

        new_data = {}
        for k, v in data.items():
            new_key = self._to_snake_case(k)
            new_data[new_key] = self._format_dict(v)
        return new_data

    def _raise_classified_issue(self, text: str, *, source: str, context: str) -> None:
        issue = classify_issue_from_text(text, source=source)
        if issue is None:
            return

        message = f"{context}: {issue.failure_reason}，{issue.next_action}"
        if issue.cli_code == "RISK_CONTROL_TRIGGERED":
            raise RiskControlException(message, details=issue.to_details())
        raise PlatformActionableError(message, details=issue.to_details(), code=issue.cli_code)

    async def _apply_note_search_filters(
        self,
        *,
        sort_by: str | None = None,
        note_type: str | None = None,
        publish_time: str | None = None,
        search_scope: str | None = None,
        location: str | None = None,
    ) -> bool:
        selections = build_search_filter_selections(
            sort_by=sort_by,
            note_type=note_type,
            publish_time=publish_time,
            search_scope=search_scope,
            location=location,
        )
        if not selections:
            return False

        filter_button = self.page.locator("div.filter").first
        filter_panel = self.page.locator("div.filter-panel").first

        for item in selections:
            await filter_button.hover()
            await filter_panel.wait_for(state="visible", timeout=10_000)
            selector = f"div.filter-panel div.filters:nth-child({item.filters_index}) div.tags:nth-child({item.tags_index})"
            await self.page.locator(selector).first.click(timeout=10_000)
            logger.info(f"DEBUG: [search_notes] 应用筛选 {item.field}={item.value}({item.label})")
            await asyncio.sleep(random.uniform(0.5, 1.0))

        await self.page.wait_for_load_state("networkidle")
        await asyncio.sleep(random.uniform(1.0, 1.8))
        return True

    async def _extract_search_feeds_from_initial_state(self) -> list[dict]:
        feeds = await self.page.evaluate(
            """
            () => {
              const state = window.__INITIAL_STATE__;
              if (!state || !state.search || !state.search.feeds) return [];
              const feeds = state.search.feeds;
              const data = feeds.value !== undefined ? feeds.value : feeds._value;
              return Array.isArray(data) ? data : [];
            }
            """
        )
        if not isinstance(feeds, list):
            return []
        return [self._format_dict(item) for item in feeds if isinstance(item, dict)]

    async def get_self_info(self):
        """Extract personal information of the logged-in user with robust measures."""
        logger.info("DEBUG: [get_self_info] 开始执行")
        # 采用后端拦截方式直接获取接口数据
        result = {}
        state = {"risk_detected": False, "risk_msg": "", "self_info_obtained": False}
        event = asyncio.Event()

        async def handle_response(response):
            if "/api/sns/web/v2/user/me" in response.url or "/api/sns/web/v1/user/selfinfo" in response.url:
                if response.status != 200:
                    if not state["self_info_obtained"]:
                        state["risk_detected"] = True
                        state["risk_msg"] = f"Self info API status {response.status}"
                    logger.warning(f"Self info API returned non-200: {response.status}")
                    return
                else:
                    try:
                        data = await response.json()
                        if not data.get("success"):
                            msg = data.get("msg", "")
                            if ("薯队长遇到了点小麻烦" in msg or "风控" in msg) and not state["self_info_obtained"]:
                                state["risk_detected"] = True
                                state["risk_msg"] = msg
                            logger.error(f"Rednote API error: {data}")
                            return

                        r = data.get('data', {})
                        if "/api/sns/web/v2/user/me" in response.url:
                            result["red_id"] = r.get("red_id")
                            result["user_id"] = r.get("user_id")
                            result["nickname"] = r.get("nickname")
                            result["desc"] = r.get("desc")
                            result["gender"] = r.get("gender")
                            result["guest"] = r.get("guest")
                            result["images"] = r.get("images")
                            result["imageb"] = r.get("imageb")
                        else:
                            basic_info = r.get("basic_info", {})
                            result["red_id"] = basic_info.get("red_id")
                            result["nickname"] = basic_info.get("nickname")
                            result["desc"] = basic_info.get("desc")
                            result["gender"] = basic_info.get("gender")
                            result["images"] = basic_info.get("images")
                            result["imageb"] = basic_info.get("imageb")
                            result["ip_location"] = basic_info.get("ip_location")
                            result["interactions"] = r.get("interactions")
                            result["tags"] = r.get("tags")
                            result["tab_public"] = r.get("tab_public")

                        if result.get("red_id"):
                            state["self_info_obtained"] = True
                            state["risk_detected"] = False
                            state["risk_msg"] = ""
                            logger.info(f"DEBUG: [get_self_info] 成功拦截到接口数据: {result.get('nickname')}")
                            event.set()
                    except Exception as e:
                        if "No resource with given identifier found" in str(e):
                            return
                        logger.error(f"JSON parse error: {e}")

        from flow_xhs.runtime.browser.manager import browser_manager

        async with browser_manager.observe_responses(self.page, handle_response):
            try:
                await self.page.goto("https://www.xiaohongshu.com/explore", wait_until="domcontentloaded")
                await self.page.wait_for_selector(".main-container .user .link-wrapper .channel", timeout=30000)
                logger.info("DEBUG: [get_self_info] 正在触发用户信息请求...")
                await self.page.evaluate(
                    "() => fetch('/api/sns/web/v2/user/me', { credentials: 'include' }).catch(() => null)"
                )
                await self.page.wait_for_timeout(500)

                try:
                    # 等待接口返回，最多等待 10 秒
                    await asyncio.wait_for(event.wait(), timeout=10.0)
                except asyncio.TimeoutError:
                    logger.warning("DEBUG: [get_self_info] 等待接口返回超时")

                if result.get("red_id"):
                    return self._format_dict(result)

                page_content = await self.page.content()
                self._raise_classified_issue(page_content, source="page", context="读取当前账号信息失败")
                if state["risk_detected"]:
                    self._raise_classified_issue(
                        state["risk_msg"],
                        source="api",
                        context="读取当前账号信息失败",
                    )
                    raise RiskControlException(f"Risk control detected during self info extraction: {state['risk_msg']}")
                return self._format_dict(result)
            except Exception as e:
                logger.error(f"Extract self info failed: {e}")
                raise

        #     initial_state = self._search_json(
        #         r'window\s*\.\s*__INITIAL_STATE__\s*=',
        #         content, 'initial state', 'self',
        #         end_pattern=r'</script>', transform_source=js_to_json, default={}
        #     )
        #
        #     return traverse_obj(initial_state, {
        #         "user_id": ("user", "userInfo", ("user_id", "userId"), {str}, any),
        #         "red_id": ("user", "userInfo", ("red_id", "redId"), {str}, any),
        #         "nickname": ("user", "userInfo", "nickname", {str}),
        #         "desc": ("user", "userInfo", "desc", {str}),
        #         "gender": ("user", "userInfo", "gender", {int}),
        #         "images": ("user", "userInfo", "images", {str}),
        #         "imageb": ("user", "userInfo", "imageb", {str}),
        #         "guest": ("user", "userInfo", "guest", {bool}),
        #         "logged_in": ("user", "loggedIn", {bool}),
        #         "ip_location": ("user", "userPageData", "basicInfo", "ipLocation", {str}),
        #         "fans": ("user", "userPageData", "interactions", lambda _, v: v.get("type") == "fans", "count", {str_to_int}, any),
        #         "follows": ("user", "userPageData", "interactions", lambda _, v: v.get("type") == "follows", "count", {str_to_int}, any),
        #         "interaction": ("user", "userPageData", "interactions", lambda _, v: v.get("type") == "interaction", "count", {str_to_int}, any),
        #         "tags": (
        #             'user', 'userPageData', 'tags', ...,
        #             {
        #                 "name": ("name", {str}),
        #                 "icon": ("icon", {str}),
        #                 "type": ("tagType", {str}),
        #             }
        #         ),
        #     })

    async def get_user_info(self, user_id: str, xsec_token: str = None, xsec_source: XsecSource = XsecSource.PC_FEED):
        """Extract information about a specific user with robust anti-bot measures."""
        logger.info(f"DEBUG: [get_user_info] 目标 ID: {user_id}?xsec_source={xsec_source.value}")
        # 进入用户页面后不会触发 `/api/sns/web/v1/user/otherinfo` 和 `/api/sns/web/v1/user_posted` 接口, 因此采用解析 HTML 的方式
        try:
            url = f"https://www.xiaohongshu.com/user/profile/{user_id}"
            if xsec_token:
                url += f"&xsec_token={xsec_token}"

            await self.page.set_extra_http_headers({"Referer": "https://www.xiaohongshu.com/"})
            await self.page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded")
            await self.page.wait_for_selector(".side-bar .user a", timeout=30000)
            await asyncio.sleep(random.uniform(1.0, 2.0))

            logger.info(f"DEBUG: [get_user_info] 正在跳转: {url}")
            await self.page.goto(url, wait_until="load")
            await asyncio.sleep(random.uniform(2.0, 4.0))

            # 风控页面检测与自动刷新
            content = await self.page.content()
            self._raise_classified_issue(content, source="page", context=f"读取用户 {user_id} 信息失败")
            if "你访问的页面不见了" in content or "验证码" in content or "未连接到服务器" in content:
                logger.error(f"要访问该用户({user_id})信息, 必须传入 xsec_token 参数")

            initial_state = self._search_json(
                r'window\s*\.\s*__INITIAL_STATE__\s*=',
                content, 'initial state', user_id,
                end_pattern=r'</script>', transform_source=js_to_json, default={}
            )
            result = traverse_obj(initial_state, {
                "red_id": ("user", "userPageData", "basicInfo", "redId", {str}),
                "nickname": ("user", "userPageData", "basicInfo", "nickname", {str}),
                "desc": ("user", "userPageData", "basicInfo", "desc", {str}),
                "gender": ("user", "userPageData", "basicInfo", "gender", {int}),
                "images": ("user", "userPageData", "basicInfo", "images", {str}),
                "imageb": ("user", "userPageData", "basicInfo", "imageb", {str}),
                "ip_location": ("user", "userPageData", "basicInfo", "ipLocation", {str}),
                "interactions": ("user", "userPageData", "interactions", {list}),
                "tags": ("user", "userPageData", "tags", {list}),
                "tab_public": ("user", "userPageData", "tabPublic", {list}),
                "notes": ('user', 'notes', ..., ...),
            })

            result["user_id"] = user_id
            logger.info(f"DEBUG: [get_user_info] 提取完成，昵称: {result.get('nickname')}")
            return self._format_dict(result)
        except Exception as e:
            logger.error(f"Failed to extract user info for {user_id}: {e}")
            raise

    async def get_note_info(self, note_id: str, xsec_token: str = None, xsec_source: XsecSource = XsecSource.PC_FEED, comment_size: int = 10, sub_comment_size: int = 5):
        """Search for notes by keyword with robust anti-bot measures."""
        logger.info(f"DEBUG: [get_note_info] 目标笔记: {note_id}")
        # 进入笔记页面后不会触发 `/api/sns/web/v1/feed` 接口, 因此采用解析 HTML 的方式获取"笔记信息", 而采用拦截后端访问直接获取接口返回的评论数据
        comments = []
        state = {"has_more": True, "risk_detected": False, "risk_msg": ""}

        async def handle_response(response):
            if "/api/sns/web/v2/comment/sub/page" in response.url:
                if response.status != 200:
                    state["risk_detected"] = True
                    state["risk_msg"] = f"Sub-comment API status {response.status}"
                    logger.warning(f"Search blocked by risk control: {response.status}")
                    return

                if response.status == 200:
                    try:
                        parsed = urllib.parse.urlparse(str(response.url))
                        query_params = urllib.parse.parse_qs(parsed.query)
                        root_comment_id = query_params.get('root_comment_id', [None])[0]
                        data = await response.json()
                        if not data.get("success"):
                            msg = data.get("msg", "")
                            if "薯队长遇到了点小麻烦" in msg or "风控" in msg:
                                state["risk_detected"] = True
                                state["risk_msg"] = msg
                            logger.error(f"Rednote API error: {data}")
                            return
                        r = data.get('data', {})
                        cs = r.get("comments", [])
                        sub_comment_count = ""
                        now_sub_comment_count = -1
                        for c in comments:
                            if c.get("id") == root_comment_id:
                                sub_comment_count = c.get("sub_comment_count", "")
                                c.get("sub_comments", []).extend(cs)
                                now_sub_comment_count = len(c.get("sub_comments", []))
                                break

                        logger.info(f"DEBUG: [get_note_info] 拦截到 {root_comment_id} 二级评论数据: {now_sub_comment_count}/{sub_comment_count} 条")
                    except Exception as e:
                        logger.error(f"JSON parse error: {e}")
                        logger.error(f"response.text: {response.text}")
                        traceback.print_exc()

            elif "/api/sns/web/v2/comment/page" in response.url:
                if response.status != 200:
                    state["risk_detected"] = True
                    state["risk_msg"] = f"Comment API status {response.status}"
                    logger.warning(f"Search blocked by risk control: {response.status}")
                    return

                if response.status == 200:
                    try:
                        data = await response.json()
                        if not data.get("success"):
                            msg = data.get("msg", "")
                            if "薯队长遇到了点小麻烦" in msg or "风控" in msg:
                                state["risk_detected"] = True
                                state["risk_msg"] = msg
                            logger.error(f"Rednote API error: {data}")
                            return
                        r = data.get('data', {})
                        state["has_more"] = r.get('has_more', False)
                        comments.extend(r.get('comments', []))
                        logger.info(f"DEBUG: [get_note_info] 拦截到评论数据: {len(comments)} 条")
                    except Exception as e:
                        if "No resource with given identifier found" in str(e):
                            return
                        logger.error(f"JSON parse error: {e}")

        from flow_xhs.runtime.browser.manager import browser_manager

        async with browser_manager.observe_responses(self.page, handle_response):
            try:
                url = f"https://www.xiaohongshu.com/explore/{note_id}?xsec_source={xsec_source.value}"
                if xsec_token:
                    url += f"&xsec_token={xsec_token}"

                await self.page.set_extra_http_headers({"Referer": "https://www.xiaohongshu.com/"})
                await self.page.goto("https://www.xiaohongshu.com", wait_until="domcontentloaded")
                await self.page.wait_for_selector(".side-bar .user a", timeout=30000)
                await asyncio.sleep(random.uniform(1.0, 2.0))

                logger.info(f"DEBUG: [get_note_info] 正在跳转: {url}")
                await self.page.goto(url, wait_until="load")
                await asyncio.sleep(random.uniform(2.0, 4.0))

                content = await self.page.content()
                self._raise_classified_issue(content, source="page", context=f"读取笔记 {note_id} 失败")

                initial_state = self._search_json(
                    r'window\s*\.\s*__INITIAL_STATE__\s*=',
                    content, 'initial state', note_id,
                    end_pattern=r'</script>', transform_source=js_to_json, default={}
                )

                result = traverse_obj(initial_state, ("note", "noteDetailMap", note_id, "note"))
                logger.info(f"DEBUG: [get_note_info] 笔记解析完成: {result.get('title')}")

                comment_count = result.get("interactInfo", {}).get("commentCount", 0)
                logger.info(f"DEBUG: [get_note_info] 评论数量: {comment_count}")
                if comment_count != "0":
                    # 评论区翻页
                    for i in range(25):
                        if state.get("risk_detected"):
                            self._raise_classified_issue(
                                state.get("risk_msg") or "",
                                source="api",
                                context=f"读取笔记 {note_id} 失败",
                            )
                            raise RiskControlException(f"Risk control detected during scroll: {state.get('risk_msg')}")
                        if len(comments) >= comment_size or not state["has_more"]:
                            break
                        container = await self.page.evaluate_handle(r'''() => {
                            // 1. 寻找包含“条评论”的业务逻辑锚点
                            const divs = Array.from(document.querySelectorAll('div'));
                            const marker = divs.find(el => el.innerText && /共\s*\d+\s*条评论/.test(el.innerText));
                            if (!marker) return document.querySelector('.note-scroller'); // 兜底方案
    
                            // 2. 向上递归寻找最近的滚动容器
                            let parent = marker;
                            while (parent) {
                                const style = window.getComputedStyle(parent);
                                if (style.overflowY === 'scroll' || style.overflowY === 'auto') {
                                    return parent;
                                }
                                parent = parent.parentElement;
                            }
                            return document.querySelector('.note-scroller');
                        }''')
                        await self.human_scroll(container_locator=container.as_element())
                        logger.info(f"DEBUG: [search_notes] 第 {i + 1} 次滚动, 当前评论数: {len(comments)}/{comment_count}")
                        if len(comments) == 0:
                            logger.warning(
                                f"[get_note_info] 第 {i + 1} 次滚动仍未抓取到评论，页面可能尚未加载完成"
                            )
                    # 二级评论下钻
                    for comment_index, comment in enumerate(comments):
                        if comment_index >= comment_size:
                            break
                        comment_id = comment.get("id")

                        comment_item = self.page.locator(f"[id='comment-{comment_id}']")
                        if await comment_item.count() == 0:
                            break

                        container = self.page.locator("div").filter(has=comment_item).last

                        for i in range(25):
                            if state.get("risk_detected"):
                                self._raise_classified_issue(
                                    state.get("risk_msg") or "",
                                    source="api",
                                    context=f"读取笔记 {note_id} 二级评论失败",
                                )
                                raise RiskControlException(f"Risk control detected during sub-comment expansion: {state.get('risk_msg')}")
                            # 只定位“当前可见”的展开按钮
                            btns = container.get_by_text(re.compile(r"展开\s*\d+\s*条回复|展开更多回复")).filter(visible=True)
                            count = await btns.count()
                            if count == 0:
                                break

                            current_sub_comment_count = len(comments[comment_index].get("sub_comments", []))
                            if current_sub_comment_count >= sub_comment_size:
                                break

                            for i in range(count):
                                try:
                                    btn = btns.nth(i)
                                    # 滚动到中心，模拟真实视觉确认
                                    await btn.scroll_into_view_if_needed()
                                    await asyncio.sleep(random.uniform(0.5, 1.2))
                                    # 生产级点击：带上随机位置偏移
                                    await btn.click(timeout=3000, delay=random.randint(50, 150))
                                    # 关键：点击后必须给 API 留出加载时间
                                    await asyncio.sleep(random.uniform(1.2, 2.5))
                                except:
                                    continue

                            # 检查是否还有新出现的按钮
                            await asyncio.sleep(1.5)

                result = self._format_dict(result)
                result["comments"] = [self._format_dict(n) for n in comments][:comment_size]
                return result
            except Exception as e:
                logger.error(f"Search note failed: {e}")
                traceback.print_exc()
                raise

    async def search_notes(
        self,
        keyword: str,
        size: int = 20,
        sort_by: str | None = None,
        note_type: str | None = None,
        publish_time: str | None = None,
        search_scope: str | None = None,
        location: str | None = None,
    ):
        """Search for notes by keyword with robust anti-bot measures."""
        logger.info(f"DEBUG: [search_notes] 关键词: {keyword}")
        result = {}
        state = {"has_more": True, "risk_detected": False, "risk_msg": ""}

        async def handle_response(response):
            if "/api/sns/web/v1/search/notes" in response.url:
                if response.status != 200:
                    state["risk_detected"] = True
                    state["risk_msg"] = f"Search API status {response.status}"
                    logger.bind(sys={
                        "module": "SEARCH_NOTES",
                        "action": "HANDLE_RESPONSE",
                        "status": "WARN",
                        "raw_data": {"url": str(response.url), "status": response.status, "message": response.status_text},
                        "message": "拦截到 response 事件。response 响应失败 → 触发重试机制。"
                    })
                    return RiskControlException()
                else:
                    try:
                        try:
                            data = await response.json()
                        except Exception as e:
                            # response 解析 json 失败
                            logger.error(f"Search note failed: {e}")
                            return
                        if not data.get("success"):
                            # response 响应失败
                            msg = data.get("msg", "")
                            if "薯队长遇到了点小麻烦" in msg or "风控" in msg:
                                state["risk_detected"] = True
                                state["risk_msg"] = msg
                            logger.error(f"Rednote API error: {data}")
                            return

                        items = data.get('data', {}).get('items', [])
                        state["has_more"] = data.get('data', {}).get('has_more', False)
                        for item in items:
                            if item.get("model_type") == "note":
                                note_id = item.get('id')
                                if note_id:
                                    result[note_id] = item
                    except Exception as e:
                        if "No resource with given identifier found" in str(e):
                            return
                        logger.error(f"JSON parse error: {e}")

        from flow_xhs.runtime.browser.manager import browser_manager

        # Double encoding is required for Rednote search
        encoded_keyword = quote(keyword)
        search_url = f"https://www.xiaohongshu.com/search_result?keyword={encoded_keyword}&source=web_search_result_notes"

        async with browser_manager.observe_responses(self.page, handle_response):
            try:
                # Add Referer to look more like a real search from home page
                await self.page.set_extra_http_headers({"Referer": "https://www.xiaohongshu.com/"})

                logger.info(f"DEBUG: [search_notes] 正在打开搜索页: {search_url}")
                # Navigate and wait for a bit more than just domcontentloaded
                await self.page.goto(search_url, wait_until="load")
                await asyncio.sleep(random.uniform(2.0, 4.0))

                user_tab_selector = "#channel-container"
                await self.page.wait_for_selector(user_tab_selector, timeout=30000)
                await self.human_click(f"{user_tab_selector} >> text=全部")
                await asyncio.sleep(random.uniform(1.5, 3.0))

                filters_applied = await self._apply_note_search_filters(
                    sort_by=sort_by,
                    note_type=note_type,
                    publish_time=publish_time,
                    search_scope=search_scope,
                    location=location,
                )
                if filters_applied:
                    # Drop pre-filter responses captured during initial page load.
                    result.clear()
                    state["has_more"] = True
                    state["risk_detected"] = False
                    state["risk_msg"] = ""

                # Check if we hit the "Page not found" or "Risk control" page
                content = await self.page.content()
                self._raise_classified_issue(content, source="page", context=f"搜索笔记失败: {keyword}")

                for i in range(25):
                    if state.get("risk_detected"):
                        self._raise_classified_issue(
                            state.get("risk_msg") or "",
                            source="api",
                            context=f"搜索笔记失败: {keyword}",
                        )
                        raise RiskControlException(f"Risk control detected during search scroll: {state.get('risk_msg')}")
                    if len(result) >= size or not state["has_more"]:
                        break
                    logger.info(f"DEBUG: [search_notes] 第 {i + 1} 次滚动, 当前结果数: {len(result)}")
                    if len(result) == 0:
                        user_tab_selector = "#channel-container"
                        await self.page.wait_for_selector(user_tab_selector, timeout=30000)
                        await self.human_click(f"{user_tab_selector} >> text=全部")
                        await asyncio.sleep(random.uniform(1.5, 3.0))
                    await self.human_scroll()

                formatted = [self._format_dict(n) for n in result.values()][:size]
                if formatted:
                    return formatted

                fallback = await self._extract_search_feeds_from_initial_state()
                return fallback[:size]
            except Exception as e:
                logger.error(f"Search note failed: {e}")
                raise

    async def search_users(self, keyword: str, size: int = 20):
        """Search for users by keyword with robust anti-bot measures."""
        logger.info(f"DEBUG: [search_users] 关键词: {keyword}")
        result = {}
        state = {"has_more": True, "risk_detected": False, "risk_msg": ""}

        async def handle_response(response):
            if "/api/sns/web/v1/search/usersearch" in response.url:
                if response.status != 200:
                    state["risk_detected"] = True
                    state["risk_msg"] = f"User search API status {response.status}"
                    logger.warning(f"User search blocked by risk control: {response.status}")
                    return
                if response.status == 200:
                    try:
                        data = await response.json()
                        # 业务级错误码校验
                        if not data.get("success"):
                            msg = data.get("msg", "")
                            if "薯队长遇到了点小麻烦" in msg or "风控" in msg:
                                state["risk_detected"] = True
                                state["risk_msg"] = msg
                            logger.error(f"Rednote API error: {data}")
                            return

                        items = data.get('data', {}).get('users', [])
                        state["has_more"] = data.get('data', {}).get('has_more', False)
                        for item in items:
                            user_red_id = item.get('red_id')
                            if user_red_id:
                                result[user_red_id] = item
                    except Exception as e:
                        if "No resource with given identifier found" in str(e):
                            return
                        logger.error(f"JSON parse error: {e}")

        from flow_xhs.runtime.browser.manager import browser_manager
        encoded_keyword = quote(keyword)
        search_url = f"https://www.xiaohongshu.com/search_result?keyword={encoded_keyword}&source=web_search_result_notes"

        async with browser_manager.observe_responses(self.page, handle_response):
            try:
                # 添加 Referer 伪装
                await self.page.set_extra_http_headers({"Referer": "https://www.xiaohongshu.com/"})

                logger.info("DEBUG: [search_users] 正在进入搜索结果页...")
                await self.page.goto(search_url, wait_until="load")
                await asyncio.sleep(random.uniform(2.0, 4.0))

                # 风控页面检测与自动刷新
                content = await self.page.content()
                self._raise_classified_issue(content, source="page", context=f"搜索用户失败: {keyword}")

                user_tab_selector = "#channel-container"
                await self.page.wait_for_selector(user_tab_selector, timeout=30000)
                await self.human_click(f"{user_tab_selector} >> text=用户")
                await asyncio.sleep(random.uniform(1.5, 3.0))

                # 拟人化滚动循环
                for i in range(25):
                    if state.get("risk_detected"):
                        self._raise_classified_issue(
                            state.get("risk_msg") or "",
                            source="api",
                            context=f"搜索用户失败: {keyword}",
                        )
                        raise RiskControlException(f"Risk control detected during user search scroll: {state.get('risk_msg')}")
                    if len(result) >= size or not state["has_more"]:
                        break
                    logger.info(f"DEBUG: [search_users] 第 {i + 1} 次滚动, 当前用户数: {len(result)}")
                    if len(result) == 0:
                        user_tab_selector = "#channel-container"
                        await self.page.wait_for_selector(user_tab_selector, timeout=30000)
                        await self.human_click(f"{user_tab_selector} >> text=用户")
                        await asyncio.sleep(random.uniform(1.5, 3.0))
                    await self.human_scroll()

                return [self._format_dict(u) for u in result.values()][:size]
            except Exception as e:
                logger.error(f"Search user failed: {e}")
                raise

    async def publish_note(
            self,
            target: str,
            image_list: list = None,
            title: str = "",
            content: str = "",
            tags: list | None = None,
            schedule_at: datetime | None = None,
    ):
        """
        发布笔记，支持图文、视频、长文类型。

        :param target: 笔记类型：video、image、article
        :param image_list: 图片列表，当 target = image 时必填且非空，支持本地路径、图片URL或混合
        :return:
        """
        try:
            target_text = (target or "").strip().lower()
            if not target_text:
                raise InvalidPublishParameterError("`target` 必须是非空字符串")

            publisher = RednotePublisher(self.page)
            if target_text == "image":
                if not image_list:
                    raise InvalidPublishParameterError("`image_list` 在图文发布时不能为空")
                if isinstance(schedule_at, str) and schedule_at.strip():
                    schedule_at = parse_rfc3339(schedule_at)
                return await publisher.publish_image_note(
                    image_list=image_list,
                    title=title or "",
                    content=content or "",
                    tags=tags or [],
                    schedule_at=schedule_at,
                )
            if target_text == "video":
                if not image_list:
                    raise InvalidPublishParameterError("`image_list` 在视频发布时不能为空")
                if len(image_list) != 1:
                    raise InvalidPublishParameterError("`target=video` 时仅支持 1 个视频素材")
                if isinstance(schedule_at, str) and schedule_at.strip():
                    schedule_at = parse_rfc3339(schedule_at)
                return await publisher.publish_video_note(
                    video_path=image_list[0],
                    title=title or "",
                    content=content or "",
                    tags=tags or [],
                    schedule_at=schedule_at,
                )
            if target_text == "article":
                raise PublishWorkflowNotReadyError("article 发布流程尚未实现")

            raise UnsupportedPublishTargetError(f"不支持的 target: {target}，当前支持: video, image, article")

        except RiskControlException:
            logger.warning(f"发布过程中触发风控，交由重试机制处理")
            raise
        except PublishNoteException as e:
            logger.error(str(e))
            raise
        except Exception as e:
            logger.exception(f"[publish_note] Unexpected error: {e}")
            raise PublishExecutionError(f"发布笔记失败: {e}") from e
