from __future__ import annotations

from collections import deque
from dataclasses import dataclass
from datetime import datetime
import json
from pathlib import Path
import re
import shutil
from time import perf_counter
from typing import Any
from urllib.parse import urldefrag, urlsplit

from .browser import BrowserRuntime
from .config import SpiderConfig
from .llm import QwenVisionClient
from .schema import StepLog


@dataclass
class SpiderAgent:
    """负责视觉智能体的整体调度与闭环执行。"""

    model_provider: str = "openai-compatible"
    model_name: str = "qwen3.5-plus"
    api_key: str | None = None
    base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
    headless: bool = False
    viewport_width: int = 1440
    viewport_height: int = 900
    max_steps: int = 8
    step_wait_ms: int = 900
    verbose: bool = True

    def run(
        self,
        task: str,
        start_url: str,
        output_schema: dict[str, Any] | None = None,
        screenshot_path: str = "artifacts/last.png",
        crawl_site: bool = False,
        crawl_time_limit_seconds: int | None = None,
        crawl_explore_ratio: float = 0.7,
        max_pages: int | None = None,
        max_links_per_page: int | None = None,
        save_result_file: bool = True,
        result_output_path: str = "artifacts/result.json",
        save_page_files: bool = True,
        pages_output_dir: str = "artifacts/pages",
    ) -> dict[str, Any]:
        """运行视觉智能体任务，过程写日志，返回仅包含提取数据。"""
        run_started = perf_counter()
        output_file = Path(screenshot_path)
        output_file.parent.mkdir(parents=True, exist_ok=True)
        run_log_file = output_file.parent / "run.log"
        step_log_file = output_file.parent / "steps.jsonl"
        run_log_file.write_text("", encoding="utf-8")
        step_log_file.write_text("", encoding="utf-8")

        self._log(f"任务开始：{task}", run_log_file)
        self._log(f"起始地址：{start_url}", run_log_file)

        config = SpiderConfig(model_name=self.model_name, base_url=self.base_url, api_key=self.api_key)
        api_key = config.resolve_api_key()
        if not api_key:
            raise ValueError("缺少 API Key，请设置 DASHSCOPE_API_KEY 或通过 api_key 参数传入")

        llm = QwenVisionClient(model_name=config.model_name, base_url=config.base_url, api_key=api_key)
        step_logs: list[StepLog] = []
        history: list[dict[str, Any]] = []
        final_output: dict[str, Any] | None = None
        status = "max_steps_reached"
        last_screenshot_path: str | None = None
        usage_totals: dict[str, int] = {
            "calls": 0,
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "llm_elapsed_ms": 0,
        }

        with BrowserRuntime(
            headless=self.headless,
            viewport_width=self.viewport_width,
            viewport_height=self.viewport_height,
        ) as browser:
            browser.goto(start_url)
            if crawl_site:
                self._log("页面初始导航完成，进入全站遍历模式", run_log_file)
                final_output = self._crawl_site(
                    browser=browser,
                    llm=llm,
                    task=task,
                    start_url=start_url,
                    output_schema=output_schema,
                    crawl_time_limit_seconds=crawl_time_limit_seconds,
                    crawl_explore_ratio=crawl_explore_ratio,
                    max_pages=max_pages,
                    max_links_per_page=max_links_per_page,
                    run_log_file=run_log_file,
                    step_log_file=step_log_file,
                    usage_totals=usage_totals,
                )
                status = "ok"
            else:
                self._log("页面初始导航完成，进入观察-决策-执行循环", run_log_file)

                for step in range(1, self.max_steps + 1):
                    shot_path = browser.screenshot(f"artifacts/step_{step:02d}.png", full_page=True)
                    model_shot_path = browser.screenshot(
                        f"artifacts/step_{step:02d}_model.jpg",
                        full_page=False,
                        image_type="jpeg",
                        quality=55,
                    )
                    last_screenshot_path = model_shot_path
                    self._log(f"Step {step}: 开始请求模型决策", run_log_file)
                    decision, metrics = llm.decide_next_action(
                        task=task,
                        screenshot_path=model_shot_path,
                        history=history,
                        output_schema=output_schema,
                    )
                    self._accumulate_usage(usage_totals, metrics)
                    self._log(
                        (
                            "Step "
                            f"{step}: 模型响应 request_id={metrics['request_id']}, "
                            f"耗时={metrics['elapsed_ms']}ms, "
                            f"tokens(prompt={metrics['prompt_tokens']}, "
                            f"completion={metrics['completion_tokens']}, total={metrics['total_tokens']})"
                        ),
                        run_log_file,
                    )

                    observation = browser.execute(decision)
                    step_status = "executed"
                    if decision.action == "finish":
                        final_output = decision.output
                        status = "ok"
                        step_status = "finished"

                    log = StepLog(
                        step=step,
                        screenshot=shot_path,
                        action=decision.action,
                        args=decision.args,
                        reason=decision.reason,
                        status=step_status,
                        observation=observation,
                    )
                    step_logs.append(log)
                    self._append_step_jsonl(log, step_log_file)
                    self._log(
                        (
                            f"Step {step}: action={decision.action}, "
                            f"args={decision.args}, reason={decision.reason}, "
                            f"observation={observation}, screenshot={shot_path}"
                        ),
                        run_log_file,
                    )
                    history.append(
                        {
                            "step": step,
                            "action": decision.action,
                            "args": decision.args,
                            "status": step_status,
                        }
                    )

                    if decision.action == "finish":
                        self._log("模型返回 finish，任务结束", run_log_file)
                        break

                    browser.page.wait_for_timeout(self.step_wait_ms)

            browser.screenshot(str(output_file), full_page=True)
            if last_screenshot_path is None:
                last_screenshot_path = str(output_file)
            self._log(f"最终页面地址：{browser.page.url}", run_log_file)
            self._log(f"最终页面标题：{browser.page.title()}", run_log_file)

        if crawl_site:
            final_payload = final_output or {"pages": []}
            if save_result_file:
                self._write_result_json_file(final_payload, result_output_path)
                self._log(f"结果已写入: {result_output_path}", run_log_file)
            if save_page_files and isinstance(final_output, dict):
                self._write_page_json_files(final_output, pages_output_dir)
                self._log(f"页面结果目录: {pages_output_dir}", run_log_file)
            total_elapsed_ms = int((perf_counter() - run_started) * 1000)
            self._log(
                (
                    "任务总消耗: "
                    f"总耗时={total_elapsed_ms}ms, "
                    f"LLM调用={usage_totals['calls']}次, "
                    f"LLM耗时={usage_totals['llm_elapsed_ms']}ms, "
                    f"tokens(prompt={usage_totals['prompt_tokens']}, "
                    f"completion={usage_totals['completion_tokens']}, "
                    f"total={usage_totals['total_tokens']})"
                ),
                run_log_file,
            )
            self._log("任务结束：run() 仅返回提取数据，不携带任务元信息", run_log_file)
            return final_payload

        if step_logs:
            last_shot = Path(step_logs[-1].screenshot)
            if last_shot.exists() and last_shot.resolve() != output_file.resolve():
                shutil.copyfile(last_shot, output_file)

        if final_output is None:
            final_output = self._build_empty_output(output_schema)
            self._log(
                f"任务在最大步数内未 finish，返回兜底空结果。status={status}",
                run_log_file,
            )
        elif last_screenshot_path:
            before_count = self._count_records(final_output)
            refined_output, refine_metrics = llm.refine_output_with_same_image(
                task=task,
                screenshot_path=last_screenshot_path,
                current_output=final_output,
                output_schema=output_schema,
                max_rounds=4,
            )
            for item in refine_metrics:
                self._accumulate_usage(usage_totals, item)
                self._log(
                    (
                        "补全请求: "
                        f"stage={item['stage']}, request_id={item['request_id']}, "
                        f"耗时={item['elapsed_ms']}ms, "
                        f"tokens(prompt={item['prompt_tokens']}, "
                        f"completion={item['completion_tokens']}, total={item['total_tokens']})"
                    ),
                    run_log_file,
                )
            after_count = self._count_records(refined_output)
            final_output = refined_output
            self._log(
                f"同图补全完成：记录数 {before_count} -> {after_count}",
                run_log_file,
            )

        total_elapsed_ms = int((perf_counter() - run_started) * 1000)
        self._log(
            (
                "任务总消耗: "
                f"总耗时={total_elapsed_ms}ms, "
                f"LLM调用={usage_totals['calls']}次, "
                f"LLM耗时={usage_totals['llm_elapsed_ms']}ms, "
                f"tokens(prompt={usage_totals['prompt_tokens']}, "
                f"completion={usage_totals['completion_tokens']}, "
                f"total={usage_totals['total_tokens']})"
            ),
            run_log_file,
        )
        if save_result_file:
            self._write_result_json_file(final_output, result_output_path)
            self._log(f"结果已写入: {result_output_path}", run_log_file)
        self._log("任务结束：run() 仅返回提取数据，不携带任务元信息", run_log_file)
        return final_output

    def _build_empty_output(self, output_schema: dict[str, Any] | None) -> dict[str, Any]:
        """根据输出结构构建最小兜底结果。"""
        if not output_schema:
            return {"items": []}

        fallback: dict[str, Any] = {}
        for key, value in output_schema.items():
            if isinstance(value, list):
                fallback[key] = []
            elif isinstance(value, dict):
                fallback[key] = {}
            else:
                fallback[key] = None
        return fallback

    def _crawl_site(
        self,
        browser: BrowserRuntime,
        llm: QwenVisionClient,
        task: str,
        start_url: str,
        output_schema: dict[str, Any] | None,
        crawl_time_limit_seconds: int | None,
        crawl_explore_ratio: float,
        max_pages: int | None,
        max_links_per_page: int | None,
        run_log_file: Path,
        step_log_file: Path,
        usage_totals: dict[str, int],
    ) -> dict[str, Any]:
        """同域站点遍历：像人一样逐页打开新标签，提取页面价值信息。"""
        queue: deque[str] = deque([start_url])
        queued: set[str] = {self._normalize_url(start_url)}
        visited: set[str] = set()
        pages: list[dict[str, Any]] = []
        page_index = 0
        last_section = ""
        consecutive_same_section = 0
        root_host = (urlsplit(start_url).hostname or "").lower()
        crawl_started = perf_counter()
        page_limit = max_pages if max_pages is not None and max_pages > 0 else 500
        explore_ratio = min(0.95, max(0.05, float(crawl_explore_ratio)))
        rediscovery_round = 0
        deadline = (
            crawl_started + int(crawl_time_limit_seconds)
            if crawl_time_limit_seconds is not None and crawl_time_limit_seconds > 0
            else None
        )

        if deadline is not None:
            self._log(
                (
                    f"全站遍历时间预算：{int(crawl_time_limit_seconds)} 秒，"
                    f"阶段配比：探索 {int(explore_ratio * 100)}% / 深挖 {100 - int(explore_ratio * 100)}%"
                ),
                run_log_file,
            )

        while len(pages) < page_limit:
            if deadline is not None and perf_counter() >= deadline:
                self._log("达到采集时间预算，停止全站遍历", run_log_file)
                break

            if not queue:
                remaining_seconds = int(deadline - perf_counter()) if deadline is not None else None
                if remaining_seconds is not None and remaining_seconds <= 20:
                    self._log("剩余时间不足，结束遍历", run_log_file)
                    break

                if rediscovery_round >= 3:
                    self._log("候选链接队列耗尽且重发现次数已达上限，结束遍历", run_log_file)
                    break

                rediscovery_round += 1
                refilled = self._rediscover_candidates(
                    browser=browser,
                    start_url=start_url,
                    queue=queue,
                    queued=queued,
                    visited=visited,
                    round_index=rediscovery_round,
                )
                self._log(
                    f"候选队列重发现 round={rediscovery_round}, 新增候选={refilled}",
                    run_log_file,
                )
                if refilled == 0:
                    break
                continue

            target_url, current_section = self._pop_next_target(
                queue=queue,
                last_section=last_section,
                consecutive_same_section=consecutive_same_section,
            )
            if current_section == last_section:
                consecutive_same_section += 1
            else:
                last_section = current_section
                consecutive_same_section = 1

            normalized_target = self._normalize_url(target_url)
            if normalized_target in visited:
                continue

            if page_index == 0:
                if self._normalize_url(browser.page.url) != normalized_target:
                    browser.open_url_in_new_tab(target_url)
            else:
                browser.open_url_in_new_tab(target_url)

            current_url = browser.page.url
            normalized_current = self._normalize_url(current_url)
            if normalized_current in visited:
                continue
            visited.add(normalized_current)
            page_index += 1

            page_title = browser.page.title()
            shot_path = browser.screenshot(f"artifacts/site_{page_index:03d}.png", full_page=True)
            model_shot_path = browser.screenshot(
                f"artifacts/site_{page_index:03d}_model.jpg",
                full_page=False,
                image_type="jpeg",
                quality=52,
            )
            raw_page = browser.extract_page_content(max_chars=120000)
            elapsed_seconds = int(perf_counter() - crawl_started)
            phase = self._resolve_crawl_phase(
                elapsed_seconds=elapsed_seconds,
                crawl_time_limit_seconds=crawl_time_limit_seconds,
                crawl_explore_ratio=explore_ratio,
            )
            page_eval = self._evaluate_page_value(
                raw_page=raw_page,
                title=page_title,
                url=current_url,
            )
            should_extract_page = bool(page_eval.get("selected", False))
            page_reason = str(page_eval.get("reason", ""))
            page_task = (
                f"{task}\n"
                "你正在进行全站采集。"
                f"当前阶段：{phase}。"
                "当前仅处理这一页：提取文章/通知正文等完整信息并返回 finish+output，"
                "不要滚动，不要继续导航。"
            )
            page_output: dict[str, Any] = {}
            if should_extract_page:
                decision, metrics = llm.decide_next_action(
                    task=page_task,
                    screenshot_path=model_shot_path,
                    history=[],
                    output_schema=output_schema,
                )
                self._accumulate_usage(usage_totals, metrics)
                self._log(
                    (
                        f"Page {page_index}: 模型响应 request_id={metrics['request_id']}, "
                        f"耗时={metrics['elapsed_ms']}ms, "
                        f"tokens(prompt={metrics['prompt_tokens']}, "
                        f"completion={metrics['completion_tokens']}, total={metrics['total_tokens']})"
                    ),
                    run_log_file,
                )

                page_output = decision.output if decision.action == "finish" and decision.output else {}
                if not page_output or phase == "deep":
                    refine_rounds = 3 if phase == "deep" else 2
                    page_output, refine_metrics = llm.refine_output_with_same_image(
                        task=page_task,
                        screenshot_path=model_shot_path,
                        current_output=page_output or self._build_empty_output(output_schema),
                        output_schema=output_schema,
                        max_rounds=refine_rounds,
                    )
                    for item in refine_metrics:
                        self._accumulate_usage(usage_totals, item)
            else:
                self._log(
                    f"Page {page_index}: 跳过正文抽取（agent判定价值较低），reason={page_reason}",
                    run_log_file,
                )

            remaining_seconds = int(deadline - perf_counter()) if deadline is not None else None
            dynamic_link_limit = self._decide_link_budget(
                remaining_seconds=remaining_seconds,
                queued_count=len(queue),
                explicit_limit=max_links_per_page,
                phase=phase,
            )
            links = browser.collect_same_domain_links(base_url=start_url, limit=dynamic_link_limit)
            added_links = 0
            kept_links = 0
            section_caps = 2 if phase == "explore" else 1
            section_counts: dict[str, int] = {}
            accepted_candidates: list[dict[str, Any]] = []
            for link in links:
                link_url = str(link.get("url", "")).strip()
                if not link_url:
                    continue
                parsed = urlsplit(link_url)
                if parsed.scheme not in {"http", "https"}:
                    continue
                if root_host and (parsed.hostname or "").lower() != root_host:
                    continue

                normalized_link = self._normalize_url(link_url)
                if normalized_link in visited or normalized_link in queued:
                    continue

                link_eval = self._evaluate_link_value(link)
                if not bool(link_eval.get("selected", False)):
                    continue

                link_section = self._url_section(link_url)
                if section_counts.get(link_section, 0) >= section_caps:
                    continue

                section_counts[link_section] = section_counts.get(link_section, 0) + 1
                accepted_candidates.append(
                    {
                        "url": link_url,
                        "normalized": normalized_link,
                        "reason": str(link_eval.get("reason", "")),
                    }
                )

            for candidate in accepted_candidates:
                link_url = str(candidate.get("url", ""))
                normalized_link = str(candidate.get("normalized", ""))
                if not link_url or not normalized_link:
                    continue

                queue.append(link_url)
                queued.add(normalized_link)
                added_links += 1
                if "新近" in str(candidate.get("reason", "")) or "优先" in str(candidate.get("reason", "")):
                    kept_links += 1

            log = StepLog(
                step=page_index,
                screenshot=shot_path,
                action="crawl_page",
                args={"url": current_url, "title": page_title, "queued_links": added_links},
                reason="全站遍历采集",
                status="finished",
                observation=f"页面采集完成，新增待访问链接 {added_links} 条（最近内容优先 {kept_links} 条）",
            )
            self._append_step_jsonl(log, step_log_file)
            self._log(
                (
                    f"Page {page_index}: 采集完成 url={current_url}, title={page_title}, "
                    f"phase={phase}, selected={should_extract_page}, "
                    f"freshness_score={page_eval.get('freshness_score', 0)}, "
                    f"value_score={page_eval.get('value_score', 0)}, "
                    f"total_score={page_eval.get('total_score', 0)}, "
                    f"reason={page_reason}, 新增链接={added_links}, link_budget={dynamic_link_limit}, "
                    f"remaining={remaining_seconds if remaining_seconds is not None else '∞'}s"
                ),
                run_log_file,
            )

            should_close_tab = self._should_close_tab(
                start_url=start_url,
                current_url=current_url,
                title=page_title,
                added_links=added_links,
                raw_page=raw_page,
            )
            if should_close_tab:
                close_observation = browser.close_current_tab()
                self._log(
                    f"Page {page_index}: 关闭标签页决策=关闭, observation={close_observation}",
                    run_log_file,
                )
            else:
                self._log(
                    f"Page {page_index}: 关闭标签页决策=保留",
                    run_log_file,
                )

            if should_extract_page:
                pages.append(
                    {
                        "url": current_url,
                        "title": page_title,
                        "publish_time": raw_page.get("publish_time", ""),
                        "selection_reason": page_reason,
                        "freshness_score": page_eval.get("freshness_score", 0),
                        "value_score": page_eval.get("value_score", 0),
                        "total_score": page_eval.get("total_score", 0),
                        "output": page_output,
                        "raw_page": raw_page,
                        "screenshot": shot_path,
                    }
                )

        return {
            "site": start_url,
            "pages": pages,
            "stats": {
                "visited_pages": len(pages),
                "remaining_queue": len(queue),
                "elapsed_seconds": int(perf_counter() - crawl_started),
                "time_budget_seconds": int(crawl_time_limit_seconds) if crawl_time_limit_seconds else None,
            },
        }

    def _decide_link_budget(
        self,
        remaining_seconds: int | None,
        queued_count: int,
        explicit_limit: int | None,
        phase: str,
    ) -> int:
        """根据剩余时间和队列长度动态决定每页可扩展链接数。"""
        if explicit_limit is not None and explicit_limit > 0:
            return explicit_limit

        if phase == "deep":
            if remaining_seconds is not None and remaining_seconds < 120:
                return 3
            return 5

        if remaining_seconds is None:
            if queued_count < 8:
                return 24
            if queued_count < 20:
                return 16
            return 10

        if remaining_seconds <= 60:
            return 6
        if remaining_seconds <= 180:
            return 10
        if remaining_seconds <= 420:
            return 14
        if queued_count > 30:
            return 8
        return 20

    def _resolve_crawl_phase(
        self,
        elapsed_seconds: int,
        crawl_time_limit_seconds: int | None,
        crawl_explore_ratio: float,
    ) -> str:
        """根据时间预算与比例返回当前采集阶段。"""
        if crawl_time_limit_seconds is None or crawl_time_limit_seconds <= 0:
            return "explore"

        split_seconds = int(crawl_time_limit_seconds * crawl_explore_ratio)
        if elapsed_seconds < split_seconds:
            return "explore"
        return "deep"

    def _normalize_url(self, url: str) -> str:
        """将 URL 归一化后用于去重。"""
        stripped, _ = urldefrag(url.strip())
        parsed = urlsplit(stripped)
        scheme = (parsed.scheme or "https").lower()
        host = (parsed.netloc or "").lower()
        path = parsed.path or "/"
        query = f"?{parsed.query}" if parsed.query else ""
        return f"{scheme}://{host}{path}{query}"

    def _evaluate_link_value(self, link: dict[str, str]) -> dict[str, Any]:
        """判断链接是否值得打开：由 agent 进行价值/新近信号综合评分。"""
        url = str(link.get("url", "")).strip()
        title = str(link.get("title", "")).strip()
        text = f"{title} {url}".lower()

        negative_keywords = [
            "login",
            "logout",
            "register",
            "search",
            "javascript:",
            "mailto:",
            "tel:",
            "下载",
            "附件",
            "视频",
            "图片",
            "登录",
            "注册",
            "搜索",
        ]
        if any(word in text for word in negative_keywords):
            return {"selected": False, "score": -2, "reason": "低价值入口"}

        score = 0
        reason_parts: list[str] = []

        detail_keywords = [
            "通知",
            "公告",
            "新闻",
            "动态",
            "文章",
            "详情",
            "招生",
            "学院",
            "notice",
            "news",
            "article",
            "detail",
            "show",
        ]
        if any(word in text for word in detail_keywords):
            score += 2
            reason_parts.append("详情关键词")

        channel_keywords = ["index", "list", "栏目", "通知", "news", "zsw", "tzgg", "xwzx"]
        if any(word in text for word in channel_keywords):
            score += 1
            reason_parts.append("栏目入口")

        published_at = self._extract_datetime_from_text(f"{title} {url}")
        if published_at is not None:
            days_ago = (datetime.now() - published_at).days
            if days_ago <= 120:
                score += 2
                reason_parts.append("新近日期")
            elif days_ago <= 365:
                score += 1
                reason_parts.append("可接受日期")
            else:
                score -= 1
                reason_parts.append("历史日期")

        if len(urlsplit(url).path.strip("/").split("/")) >= 3:
            score += 1
            reason_parts.append("详情层级")

        if score >= 2:
            return {
                "selected": True,
                "score": score,
                "reason": "|".join(reason_parts) or "综合评分通过",
            }
        return {"selected": False, "score": score, "reason": "综合评分不足"}

    def _extract_datetime_from_text(self, text: str) -> datetime | None:
        """从链接文本/URL中提取日期时间。"""
        patterns = [
            r"(20\d{2})[-/年\.](\d{1,2})[-/月\.](\d{1,2})",
            r"(20\d{2})(\d{2})(\d{2})",
            r"(20\d{2})[-/年\.](\d{1,2})",
            r"\b(20\d{2})\b",
        ]
        for pattern in patterns:
            match = re.search(pattern, text)
            if not match:
                continue
            try:
                year = int(match.group(1))
                month = int(match.group(2)) if match.lastindex and match.lastindex >= 2 else 1
                day = int(match.group(3)) if match.lastindex and match.lastindex >= 3 else 1
                return datetime(year=year, month=month, day=day)
            except ValueError:
                continue
        return None

    def _evaluate_page_value(
        self,
        raw_page: dict[str, str],
        title: str,
        url: str,
    ) -> dict[str, Any]:
        """由 agent 判断页面是否值得进入正文抽取阶段。"""
        text = (
            f"{title} {url} "
            f"{raw_page.get('publish_time', '')} {raw_page.get('meta_description', '')}"
        ).lower()

        low_value = ["宣传片", "视频", "下载", "附件", "登录", "搜索", "校历"]
        if any(token in text for token in low_value):
            return {
                "selected": False,
                "freshness_score": 0,
                "value_score": -3,
                "total_score": -3,
                "reason": "内容类型低价值",
            }

        value_hints = ["通知", "公告", "新闻", "快讯", "文章", "政策", "学院", "招生", "信息"]
        value_score = 0
        if any(token in text for token in value_hints):
            value_score += 2

        full_text_len = len(str(raw_page.get("full_text", "")))
        if full_text_len >= 300:
            value_score += 1
        if full_text_len >= 1200:
            value_score += 1

        freshness_score = 0

        publish_at = self._extract_datetime_from_text(
            f"{raw_page.get('publish_time', '')} {title} {url}"
        )
        if publish_at is not None:
            days_ago = (datetime.now() - publish_at).days
            if days_ago <= 60:
                freshness_score += 3
            elif days_ago <= 180:
                freshness_score += 2
            elif days_ago <= 730:
                freshness_score += 1
            else:
                freshness_score -= 1

        total_score = value_score + freshness_score
        selected = total_score >= 2
        return {
            "selected": selected,
            "freshness_score": freshness_score,
            "value_score": value_score,
            "total_score": total_score,
            "reason": f"agent评分{'通过' if selected else '不足'}(total={total_score})",
        }

    def _rediscover_candidates(
        self,
        browser: BrowserRuntime,
        start_url: str,
        queue: deque[str],
        queued: set[str],
        visited: set[str],
        round_index: int,
    ) -> int:
        """队列耗尽时，从起始页做一次重新发现，尽量用满时间预算。"""
        browser.goto(start_url)
        base_limit = 60 + round_index * 30
        links = browser.collect_same_domain_links(base_url=start_url, limit=base_limit)
        added = 0

        for link in links:
            link_url = str(link.get("url", "")).strip()
            if not link_url:
                continue
            normalized = self._normalize_url(link_url)
            if normalized in visited or normalized in queued:
                continue

            link_eval = self._evaluate_link_value(link)
            keep = bool(link_eval.get("selected", False))
            if not keep:
                text = f"{str(link.get('title', ''))} {link_url}".lower()
                list_hints = ["index", "list", "栏目", "通知", "news", "zsw", "tzgg"]
                keep = any(hint in text for hint in list_hints)

            if not keep:
                continue

            queue.append(link_url)
            queued.add(normalized)
            added += 1
        return added

    def _url_section(self, url: str) -> str:
        """提取 URL 一级栏目路径，用于遍历多样性控制。"""
        path = urlsplit(url).path.strip("/")
        if not path:
            return "root"
        return path.split("/")[0].lower()

    def _pop_next_target(
        self,
        queue: deque[str],
        last_section: str,
        consecutive_same_section: int,
    ) -> tuple[str, str]:
        """从队列中挑选下一个目标，避免长期陷入同一栏目。"""
        if not queue:
            return "", ""

        if consecutive_same_section < 2:
            target = queue.popleft()
            return target, self._url_section(target)

        total = len(queue)
        rotated = 0
        while rotated < total:
            candidate = queue[0]
            candidate_section = self._url_section(candidate)
            if candidate_section != last_section:
                selected = queue.popleft()
                return selected, candidate_section
            queue.rotate(-1)
            rotated += 1

        selected = queue.popleft()
        return selected, self._url_section(selected)

    def _slugify(self, value: str) -> str:
        """文件名净化。"""
        cleaned = re.sub(r"\s+", "_", value.strip())
        cleaned = re.sub(r"[^\w\-\.]+", "_", cleaned)
        cleaned = cleaned.strip("._")
        return cleaned[:80] or "untitled"

    def _build_page_filename(self, index: int, page: dict[str, Any]) -> str:
        """根据标题或 URL 生成稳定文件名。"""
        title = str(page.get("title", "")).strip()
        url = str(page.get("url", "")).strip()
        if title:
            base = self._slugify(title)
        else:
            parsed = urlsplit(url)
            path_part = parsed.path.strip("/").replace("/", "_") or "home"
            base = self._slugify(f"{parsed.netloc}_{path_part}")
        return f"{index:03d}_{base}.json"

    def _write_page_json_files(self, result: dict[str, Any], pages_output_dir: str) -> None:
        """将 pages 结果自动写入多个 JSON 文件。"""
        pages = result.get("pages") if isinstance(result, dict) else None
        if not isinstance(pages, list):
            return

        out_dir = Path(pages_output_dir)
        out_dir.mkdir(parents=True, exist_ok=True)
        for index, page in enumerate(pages, start=1):
            if not isinstance(page, dict):
                continue
            file_path = out_dir / self._build_page_filename(index, page)
            file_path.write_text(json.dumps(page, ensure_ascii=False, indent=2), encoding="utf-8")

    def _write_result_json_file(self, result: dict[str, Any], result_output_path: str) -> None:
        """将本次任务结果写入单个 JSON 文件。"""
        output = Path(result_output_path)
        output.parent.mkdir(parents=True, exist_ok=True)
        output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")

    def _should_close_tab(
        self,
        start_url: str,
        current_url: str,
        title: str,
        added_links: int,
        raw_page: dict[str, str],
    ) -> bool:
        """由 agent 依据页面类型和收益决定是否关闭当前标签页。"""
        if self._normalize_url(start_url) == self._normalize_url(current_url):
            return False

        signals = f"{title} {current_url}".lower()
        detail_hints = ["/info/", "detail", "article", "notice", "news", "通知", "公告", "详情"]
        is_detail_page = any(hint in signals for hint in detail_hints)
        full_text_len = len(str(raw_page.get("full_text", "")))

        if is_detail_page and added_links == 0:
            return True
        if full_text_len > 800 and added_links <= 1:
            return True
        if added_links >= 3:
            return False
        return added_links == 0

    def _log(self, message: str, log_file: Path) -> None:
        """输出并持久化运行日志。"""
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        line = f"[{timestamp}] {message}"
        if self.verbose:
            print(line)
        with log_file.open("a", encoding="utf-8") as f:
            f.write(line + "\n")

    def _append_step_jsonl(self, step: StepLog, step_log_file: Path) -> None:
        """将步骤信息写入 JSONL，便于回放与排查。"""
        payload = {
            "step": step.step,
            "action": step.action,
            "args": step.args,
            "reason": step.reason,
            "status": step.status,
            "observation": step.observation,
            "screenshot": step.screenshot,
        }
        with step_log_file.open("a", encoding="utf-8") as f:
            f.write(json.dumps(payload, ensure_ascii=False) + "\n")

    def _count_records(self, payload: dict[str, Any]) -> int:
        """统计结果中列表记录数量，用于评估补全效果。"""
        count = 0
        for value in payload.values():
            if isinstance(value, list):
                count += len(value)
            elif isinstance(value, dict):
                count += self._count_records(value)
        return count

    def _accumulate_usage(self, totals: dict[str, int], metrics: dict[str, Any]) -> None:
        """累加单次模型请求的耗时和 token 指标。"""
        totals["calls"] += 1
        totals["prompt_tokens"] += int(metrics.get("prompt_tokens", 0) or 0)
        totals["completion_tokens"] += int(metrics.get("completion_tokens", 0) or 0)
        totals["total_tokens"] += int(metrics.get("total_tokens", 0) or 0)
        totals["llm_elapsed_ms"] += int(metrics.get("elapsed_ms", 0) or 0)
