#!/usr/bin/env python3
"""Visible-browser publisher for approved Xiaohongshu image notes.

The browser is intentionally human-visible and uses the same CDP/profile approach
as ``SKILL开发``.  Preparation never clicks the final publish control.  Real
submission requires both ``--publish`` and ``--confirm-publish``.
"""

from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import time
from typing import Any, Dict, Iterable, List, Optional
from urllib.request import urlopen

import db

try:
    from playwright.sync_api import Locator, Page, TimeoutError, sync_playwright
except ImportError as exc:  # pragma: no cover - exercised by the CLI environment
    raise SystemExit("缺少 Playwright，请执行 .review-venv/bin/pip install -r requirements-review.txt") from exc


ROOT = Path(__file__).resolve().parent
XHS_URL = "https://creator.xiaohongshu.com/publish/publish"
CDP_URL = "http://127.0.0.1:9223"
SCREENSHOTS = ROOT / "outputs" / "publish-screenshots"


class PauseRequired(RuntimeError):
    """The user must handle login, verification, or an ambiguous page state."""


def _profile_dir() -> Path:
    configured = os.environ.get("XHS_BROWSER_PROFILE")
    if configured:
        return Path(configured).expanduser()
    shared = ROOT.parent / "SKILL开发" / ".runtime" / "profiles" / "xiaohongshu"
    return shared if shared.exists() else ROOT / ".runtime" / "profiles" / "xiaohongshu"


def _port_ready() -> bool:
    try:
        with urlopen(f"{CDP_URL}/json/version", timeout=1) as response:
            return response.status == 200
    except Exception:
        return False


def ensure_browser() -> None:
    if _port_ready():
        return
    chrome = next(
        (
            Path(candidate)
            for candidate in (
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                "/Applications/Chromium.app/Contents/MacOS/Chromium",
                "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
            )
            if Path(candidate).exists()
        ),
        None,
    )
    if chrome is None:
        raise RuntimeError("未找到 Chrome/Chromium；请先启动带远程调试端口 9223 的浏览器。")
    profile = _profile_dir()
    profile.mkdir(parents=True, exist_ok=True)
    subprocess.Popen(
        [
            str(chrome),
            "--remote-debugging-port=9223",
            f"--user-data-dir={profile}",
            "--no-first-run",
            "--no-default-browser-check",
            "--new-window",
            "about:blank",
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    deadline = time.time() + 30
    while time.time() < deadline:
        if _port_ready():
            return
        time.sleep(0.5)
    raise RuntimeError("浏览器未在 9223 端口就绪。")


def local_asset(url: str) -> Path:
    if url.startswith("/"):
        return ROOT / url.lstrip("/")
    return Path(url).expanduser()


def image_paths(task: Dict[str, Any]) -> List[Path]:
    paths = [local_asset(str(image["url"])) for image in task.get("images", [])]
    if not paths:
        raise ValueError("任务没有可发布的图片")
    missing = [str(path) for path in paths if not path.is_file()]
    if missing:
        raise FileNotFoundError("图片文件不存在: " + ", ".join(missing))
    if len(paths) > 18:
        raise ValueError("小红书图文最多上传 18 张图片")
    return paths


class XiaohongshuImagePublisher:
    def __init__(self, database_url: str = "") -> None:
        self.database_url = database_url
        self.playwright = None
        self.browser = None
        self.context = None
        self.page: Optional[Page] = None

    def close(self) -> None:
        if self.playwright is not None:
            self.playwright.stop()
            self.playwright = None

    def connect(self) -> Page:
        ensure_browser()
        self.playwright = sync_playwright().start()
        self.browser = self.playwright.chromium.connect_over_cdp(CDP_URL)
        self.context = self.browser.contexts[0] if self.browser.contexts else self.browser.new_context()
        self.page = self.context.pages[0] if self.context.pages else self.context.new_page()
        self.page.set_default_timeout(15000)
        return self.page

    def prepare(self, task: Dict[str, Any], publish: bool = False, confirm_publish: bool = False) -> Dict[str, Any]:
        page = self.connect()
        page.goto(XHS_URL, wait_until="domcontentloaded", timeout=60000)
        page.wait_for_timeout(2500)
        self._dismiss_guide(page)
        self._assert_ready(page)
        self._open_image_editor(page)
        self._upload_images(page, image_paths(task))
        self._wait_editor(page)
        self._fill_fields(page, task)
        # Tiptap opens an autocomplete popover while hashtags are inserted.
        # Close it before the evidence screenshot and before any human review.
        page.keyboard.press("Escape")
        before = self._screenshot(task["task_id"], "before-publish")
        result: Dict[str, Any] = {"task_id": task["task_id"], "status": "已准备", "screenshot": str(before)}
        if not publish:
            result["message"] = "已填写图文编辑器，未点击发布。"
            return result
        if not confirm_publish:
            raise PauseRequired("真实发布必须同时传入 --publish 和 --confirm-publish。")
        self._click_publish(page)
        page.wait_for_timeout(2000)
        if self._verification_visible(page):
            raise PauseRequired("发布触发验证码或安全验证，请在浏览器中处理后再继续。")
        if not self._wait_publish_result(page):
            raise PauseRequired("已点击发布，但暂未识别到发布结果，请人工确认。")
        after = self._screenshot(task["task_id"], "after-publish")
        result.update(status="已发布", screenshot=str(after), message="已提交小红书图文笔记。")
        return result

    def _assert_ready(self, page: Page) -> None:
        if self._text_visible(page, ["登录小红书", "扫码登录", "手机号登录", "验证码登录"]):
            raise PauseRequired("请在打开的小红书浏览器中完成登录，再重新运行。")
        if self._verification_visible(page):
            raise PauseRequired("小红书要求安全验证，请在浏览器中处理后重新运行。")

    def _open_image_editor(self, page: Page) -> None:
        if page.locator("input[type=file][multiple]").count() and self._text_visible(page, ["上传图片"]):
            return
        links = page.get_by_text("上传图文", exact=True)
        for index in range(links.count()):
            link = links.nth(index)
            try:
                if link.is_visible():
                    link.click(force=True)
                    page.wait_for_timeout(1000)
                    return
            except Exception:
                continue
        raise RuntimeError("未找到小红书“上传图文”入口。")

    def _upload_images(self, page: Page, paths: Iterable[Path]) -> None:
        inputs = page.locator("input[type=file][multiple]")
        if not inputs.count():
            raise RuntimeError("未找到支持多图的上传控件。")
        inputs.first.set_input_files([str(path) for path in paths], timeout=30000)

    def _wait_editor(self, page: Page) -> None:
        deadline = time.time() + 180
        while time.time() < deadline:
            if self._verification_visible(page):
                raise PauseRequired("上传过程中出现验证码或安全验证。")
            if self._title_locator(page) or self._body_locator(page):
                return
            page.wait_for_timeout(1000)
        raise RuntimeError("等待小红书图文编辑器超时。")

    def _fill_fields(self, page: Page, task: Dict[str, Any]) -> None:
        title = str(task.get("title") or task.get("product_name") or "未命名商品")[:20]
        body = str(task.get("body") or "").strip()
        topics = self._topics(task.get("topics", ""))
        if topics and not any(topic in body for topic in topics):
            body = (body + "\n\n" + " ".join(f"#{topic}" for topic in topics)).strip()
        self._fill_input(self._title_locator(page), title)
        self._fill_input(self._body_locator(page), body or title)

    def _title_locator(self, page: Page) -> Optional[Locator]:
        for selector in ["input[placeholder*='填写标题']", "input[placeholder*='标题']"]:
            locator = page.locator(selector).first
            try:
                if locator.count() and locator.is_visible():
                    return locator
            except Exception:
                pass
        return None

    def _body_locator(self, page: Page) -> Optional[Locator]:
        locator = page.locator("[contenteditable='true']").first
        try:
            return locator if locator.count() and locator.is_visible() else None
        except Exception:
            return None

    def _fill_input(self, locator: Optional[Locator], value: str) -> None:
        if locator is None:
            raise RuntimeError("未找到小红书图文标题或正文输入框。")
        locator.click()
        try:
            locator.fill(value)
        except Exception:
            locator.press("Meta+A")
            locator.press("Backspace")
            if self.page is None:
                raise RuntimeError("浏览器页面已关闭")
            self.page.keyboard.insert_text(value)

    def _click_publish(self, page: Page) -> None:
        control = page.locator(".publish-video .btn-wrapper").last
        if not control.count():
            control = page.get_by_text("发布笔记", exact=True).last
        control.scroll_into_view_if_needed()
        control.click()

    def _wait_publish_result(self, page: Page) -> bool:
        deadline = time.time() + 180
        while time.time() < deadline:
            if self._verification_visible(page):
                raise PauseRequired("发布过程中出现验证码或安全验证。")
            if self._text_visible(page, ["发布成功", "审核中", "笔记管理", "提交成功", "发布完成"]):
                return True
            if self._text_visible(page, ["发布失败", "提交失败"]):
                raise RuntimeError("小红书页面提示发布失败。")
            page.wait_for_timeout(1500)
        return False

    def _screenshot(self, task_id: str, label: str) -> Path:
        assert self.page is not None
        target = SCREENSHOTS / task_id
        target.mkdir(parents=True, exist_ok=True)
        path = target / f"{label}-{int(time.time())}.png"
        timeout_ms = int(os.environ.get("XHS_SCREENSHOT_TIMEOUT_MS", "8000"))
        try:
            self.page.screenshot(path=str(path), full_page=True, timeout=timeout_ms, animations="disabled")
        except TimeoutError:
            # Very long editor pages can keep full-page capture waiting forever;
            # a viewport capture is still useful evidence and must not block the flow.
            try:
                self.page.screenshot(path=str(path), full_page=False, timeout=timeout_ms, animations="disabled")
            except TimeoutError as exc:
                marker = path.with_suffix(".screenshot-error.txt")
                marker.write_text(f"截图超时，DOM 流程仍已完成: {exc}\n", encoding="utf-8")
                return marker
        return path

    @staticmethod
    def _topics(value: Any) -> List[str]:
        return [topic for topic in re.findall(r"#?([\u4e00-\u9fffA-Za-z0-9_]+)", str(value or "")) if topic]

    @staticmethod
    def _dismiss_guide(page: Page) -> None:
        guide = page.get_by_text("我知道了", exact=True)
        try:
            if guide.count() and guide.last.is_visible():
                guide.last.click(timeout=3000)
        except Exception:
            pass

    @staticmethod
    def _text_visible(page: Page, texts: Iterable[str]) -> bool:
        for text in texts:
            locator = page.get_by_text(text, exact=False)
            for index in range(min(locator.count(), 20)):
                try:
                    if locator.nth(index).is_visible(timeout=500):
                        return True
                except Exception:
                    continue
        return False

    def _verification_visible(self, page: Page) -> bool:
        return self._text_visible(page, ["接收短信验证码", "请输入验证码", "安全验证", "身份验证", "滑块", "拖动"])


def publish_task(task_id: str, database_url: str, publish: bool, confirm_publish: bool) -> Dict[str, Any]:
    task = db.get_task(task_id, database_url)
    if not task:
        raise ValueError(f"task not found: {task_id}")
    if task.get("status") != "已确认":
        raise ValueError("只有审核状态为“已确认”的任务才能发布")
    publisher = XiaohongshuImagePublisher(database_url)
    try:
        return publisher.prepare(task, publish=publish, confirm_publish=confirm_publish)
    finally:
        publisher.close()


def main() -> None:
    parser = argparse.ArgumentParser(description="小红书图文可见浏览器发布器")
    parser.add_argument("task_id")
    parser.add_argument("--database-url", default="")
    parser.add_argument("--publish", action="store_true", help="点击最终发布控件")
    parser.add_argument("--confirm-publish", action="store_true", help="与 --publish 一起使用，确认允许真实提交")
    args = parser.parse_args()
    result = publish_task(args.task_id, args.database_url, args.publish, args.confirm_publish)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
