from __future__ import annotations

import datetime as dt
import json
import re
from html import unescape
from html.parser import HTMLParser
from typing import Any, Callable, Optional
from urllib.parse import urlparse
from urllib.request import Request, urlopen

ARTICLE_TYPES = {"news", "macro_analysis", "opinion", "education"}
BIAS_VALUES = {"LONG", "SHORT", "NEUTRAL"}
TIME_HORIZONS = {"scalp", "intraday", "swing", "template"}
_BLOCK_TAGS = {
    "article",
    "blockquote",
    "br",
    "div",
    "footer",
    "h1",
    "h2",
    "h3",
    "h4",
    "h5",
    "h6",
    "header",
    "li",
    "main",
    "ol",
    "p",
    "section",
    "table",
    "td",
    "tr",
    "ul",
}
_SKIP_TAGS = {"script", "style", "noscript", "svg"}
_POSITIVE_HINTS = (
    "gold rises",
    "gold gains",
    "gold advances",
    "bullish gold",
    "weaker dollar",
    "dollar weakens",
    "yield falls",
    "yields fall",
    "rate cut",
    "rate cuts",
    "safe haven demand",
    "safe-haven demand",
    "金价上涨",
    "黄金上涨",
    "黄金走强",
    "美元走弱",
    "收益率回落",
    "降息预期",
    "避险需求",
    "买盘支撑",
)
_NEGATIVE_HINTS = (
    "gold falls",
    "gold drops",
    "gold slips",
    "bearish gold",
    "stronger dollar",
    "dollar strengthens",
    "yield rises",
    "yields rise",
    "hawkish fed",
    "profit taking",
    "金价下跌",
    "黄金承压",
    "黄金走弱",
    "美元走强",
    "收益率上行",
    "鹰派",
    "抛售压力",
)


class _ArticleHTMLExtractor(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self._skip_depth = 0
        self._in_title = False
        self._text_parts: list[str] = []
        self._title_parts: list[str] = []
        self.meta: dict[str, str] = {}

    def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
        tag_name = tag.lower()
        if tag_name in _SKIP_TAGS:
            self._skip_depth += 1
            return
        if tag_name == "title":
            self._in_title = True
            return
        if self._skip_depth:
            return
        attrs_dict = {str(k or "").lower(): str(v or "") for k, v in attrs}
        if tag_name == "meta":
            key = (
                attrs_dict.get("property")
                or attrs_dict.get("name")
                or attrs_dict.get("http-equiv")
                or ""
            ).strip().lower()
            content = attrs_dict.get("content", "").strip()
            if key and content:
                self.meta[key] = content
            return
        if tag_name in _BLOCK_TAGS:
            self._text_parts.append("\n")

    def handle_endtag(self, tag: str) -> None:
        tag_name = tag.lower()
        if tag_name in _SKIP_TAGS and self._skip_depth:
            self._skip_depth -= 1
            return
        if tag_name == "title":
            self._in_title = False
            return
        if self._skip_depth:
            return
        if tag_name in _BLOCK_TAGS:
            self._text_parts.append("\n")

    def handle_data(self, data: str) -> None:
        if self._skip_depth:
            return
        text = str(data or "")
        if not text.strip():
            return
        if self._in_title:
            self._title_parts.append(text)
            return
        self._text_parts.append(text)

    @property
    def title(self) -> str:
        title = _normalize_text(" ".join(self._title_parts))
        return title

    @property
    def text(self) -> str:
        return _normalize_text("".join(self._text_parts))


def _normalize_text(text: str) -> str:
    value = unescape(str(text or ""))
    value = value.replace("\r", "\n")
    value = re.sub(r"[ \t\f\v]+", " ", value)
    value = re.sub(r"\n{3,}", "\n\n", value)
    return value.strip()


def _clean_text_lines(text: str) -> list[str]:
    lines: list[str] = []
    seen: set[str] = set()
    for raw in re.split(r"\n+", text):
        line = _normalize_text(raw)
        if len(line) < 20:
            continue
        if line.lower() in {"home", "markets", "news", "opinion", "analysis"}:
            continue
        compact = re.sub(r"\s+", " ", line).strip()
        if compact in seen:
            continue
        seen.add(compact)
        lines.append(compact)
    return lines


def _detect_charset(content_type: str, raw: bytes) -> str:
    match = re.search(r"charset=([a-zA-Z0-9_\-]+)", content_type or "", flags=re.I)
    if match:
        return match.group(1)
    head = raw[:2048].decode("ascii", errors="ignore")
    match = re.search(r'charset=["\']?([a-zA-Z0-9_\-]+)', head, flags=re.I)
    if match:
        return match.group(1)
    return "utf-8"


def _download_article_html(url: str, timeout_sec: int = 12) -> dict[str, Any]:
    request = Request(
        url,
        headers={
            "User-Agent": (
                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
            )
        },
    )
    with urlopen(request, timeout=timeout_sec) as response:
        raw = response.read(1_500_000)
        content_type = str(response.headers.get("Content-Type", ""))
        final_url = str(response.geturl() or url)
        status_code = int(getattr(response, "status", 200) or 200)
    charset = _detect_charset(content_type, raw)
    html = raw.decode(charset, errors="replace")
    return {
        "url": final_url,
        "status_code": status_code,
        "content_type": content_type,
        "html": html,
    }


def _extract_article_payload(url: str, html: str) -> dict[str, Any]:
    extractor = _ArticleHTMLExtractor()
    extractor.feed(html)
    raw_text = extractor.text
    lines = _clean_text_lines(raw_text)
    text = "\n\n".join(lines[:36])[:12000]
    meta = extractor.meta
    title = (
        meta.get("og:title")
        or meta.get("twitter:title")
        or meta.get("title")
        or extractor.title
    )
    description = (
        meta.get("description")
        or meta.get("og:description")
        or meta.get("twitter:description")
        or ""
    )
    published_at = (
        meta.get("article:published_time")
        or meta.get("published_time")
        or meta.get("og:published_time")
        or meta.get("pubdate")
        or ""
    )
    source = _source_name_from_url(url)
    summary = _summarize_article_text(text or description or title)
    if not text:
        raise ValueError("文章正文提取失败，未识别到可用内容")
    return {
        "url": url,
        "title": _normalize_text(title) or source,
        "description": _normalize_text(description),
        "published_at": str(published_at or "").strip(),
        "source": source,
        "text": text,
        "summary": summary,
    }


def _source_name_from_url(url: str) -> str:
    host = urlparse(url).netloc.lower().strip()
    host = host[4:] if host.startswith("www.") else host
    if not host:
        return "unknown"
    return host


def _summarize_article_text(text: str) -> str:
    sentences = _split_sentences(text)
    if not sentences:
        return _normalize_text(text)[:220]
    summary = " ".join(sentences[:2])
    return summary[:280]


def _split_sentences(text: str) -> list[str]:
    fragments = re.split(r"(?<=[。！？.!?])\s+", str(text or ""))
    sentences = []
    for fragment in fragments:
        line = _normalize_text(fragment)
        if 18 <= len(line) <= 240:
            sentences.append(line)
    return sentences


def _score_occurrences(text: str, hints: tuple[str, ...]) -> int:
    haystack = str(text or "").lower()
    return sum(haystack.count(hint.lower()) for hint in hints)


def _classify_article_type(article: dict[str, Any]) -> str:
    haystack = " ".join(
        [
            str(article.get("title", "")),
            str(article.get("description", "")),
            str(article.get("text", ""))[:3000],
        ]
    ).lower()
    if any(token in haystack for token in ("how to", "guide", "tutorial", "what is", "教程", "指南", "入门", "教学")):
        return "education"
    if any(token in haystack for token in ("opinion", "column", "editorial", "观点", "评论", "专栏")):
        return "opinion"
    if any(token in haystack for token in ("breaking", "live updates", "developing", "快讯", "公布", "最新", "突发")):
        return "news"
    if any(
        token in haystack
        for token in (
            "fed",
            "fomc",
            "cpi",
            "inflation",
            "yield",
            "dxy",
            "美元",
            "收益率",
            "通胀",
            "降息",
            "非农",
            "宏观",
        )
    ):
        return "macro_analysis"
    return "macro_analysis"


def _infer_bias(article: dict[str, Any]) -> str:
    haystack = " ".join(
        [
            str(article.get("title", "")),
            str(article.get("description", "")),
            str(article.get("summary", "")),
            str(article.get("text", ""))[:4000],
        ]
    )
    positive_score = _score_occurrences(haystack, _POSITIVE_HINTS)
    negative_score = _score_occurrences(haystack, _NEGATIVE_HINTS)
    if positive_score - negative_score >= 2:
        return "LONG"
    if negative_score - positive_score >= 2:
        return "SHORT"
    return "NEUTRAL"


def _infer_time_horizon(article_type: str) -> str:
    if article_type == "news":
        return "intraday"
    if article_type == "education":
        return "template"
    if article_type == "opinion":
        return "swing"
    return "intraday"


def _infer_strategy_style(article_type: str, bias: str) -> str:
    if article_type == "news":
        return "event_reaction"
    if article_type == "education":
        return "strategy_template"
    if article_type == "opinion":
        return "bias_filter"
    if bias == "NEUTRAL":
        return "watchlist_bias"
    return "pullback_continuation"


def _source_score(source: str) -> float:
    domain = str(source or "").lower()
    if any(token in domain for token in ("reuters", "bloomberg", "wsj", "ft.com")):
        return 0.92
    if any(token in domain for token in ("investing.com", "fxstreet", "kitco", "tradingview")):
        return 0.78
    if any(token in domain for token in ("substack", "medium", "blog")):
        return 0.58
    return 0.68


def _freshness_score(published_at: str) -> float:
    value = str(published_at or "").strip()
    if not value:
        return 0.45
    try:
        normalized = value.replace("Z", "+00:00")
        parsed = dt.datetime.fromisoformat(normalized)
        if parsed.tzinfo is None:
            parsed = parsed.replace(tzinfo=dt.timezone.utc)
        age_hours = max(
            0.0,
            (dt.datetime.now(dt.timezone.utc) - parsed.astimezone(dt.timezone.utc)).total_seconds()
            / 3600.0,
        )
    except Exception:
        return 0.45
    if age_hours <= 6:
        return 0.95
    if age_hours <= 24:
        return 0.85
    if age_hours <= 72:
        return 0.72
    if age_hours <= 24 * 7:
        return 0.55
    return 0.35


def _extract_evidence(article: dict[str, Any]) -> list[str]:
    sentences = _split_sentences(article.get("text", ""))
    ranked: list[str] = []
    for sentence in sentences:
        lowered = sentence.lower()
        score = 0
        if any(
            token in lowered
            for token in (
                "gold",
                "xauusd",
                "usd",
                "dollar",
                "fed",
                "yield",
                "inflation",
                "黄金",
                "美元",
                "通胀",
                "收益率",
                "避险",
            )
        ):
            score += 2
        score += min(3, len(sentence) // 60)
        if score >= 3:
            ranked.append(sentence)
        if len(ranked) >= 4:
            break
    if not ranked and article.get("summary"):
        ranked.append(str(article["summary"]))
    return ranked[:4]


def _recommended_name(article: dict[str, Any], article_type: str, bias: str) -> str:
    source = str(article.get("source", "article")).split(".")[0]
    bias_text = {"LONG": "看多", "SHORT": "看空", "NEUTRAL": "观察"}.get(bias, "观察")
    kind_text = {
        "news": "事件驱动",
        "macro_analysis": "宏观框架",
        "opinion": "观点过滤",
        "education": "模板提炼",
    }.get(article_type, "文章策略")
    return f"{source.upper()}_{bias_text}_{kind_text}"


def _entry_condition(article_type: str, bias: str) -> str:
    if bias == "NEUTRAL":
        return "仅观察，等待价格结构与动量重新同向后再进入候选池。"
    side = "多头" if bias == "LONG" else "空头"
    if article_type == "news":
        return f"等待 15m 首次冲击后回踩确认，且 {side} 动量没有快速衰减时，再考虑进入。"
    if article_type == "education":
        return f"把文章里的 {side} 思路沉淀为模板，需额外叠加结构与风险过滤后才允许交易。"
    return f"等待 15m 结构顺势回踩/反抽确认，且价差与流动性正常，再考虑 {side} 入场。"


def _invalid_condition(bias: str) -> str:
    if bias == "LONG":
        return "美元和收益率同步走强，且价格跌回关键支撑下方，则本次文章逻辑失效。"
    if bias == "SHORT":
        return "美元转弱且避险买盘重新抬升，价格重新站回关键阻力上方，则本次文章逻辑失效。"
    return "若新的宏观事件或结构信号与文章结论冲突，则维持观望并放弃执行。"


def _stop_loss_rule(article_type: str) -> str:
    if article_type == "news":
        return "止损放在事件脉冲结构低/高点之外，并至少保留 1.2 ATR 呼吸空间。"
    return "止损放在最近结构拐点之外，并至少保留 1.0-1.2 ATR 呼吸空间。"


def _take_profit_rule(article_type: str, bias: str) -> str:
    if bias == "NEUTRAL":
        return "不执行主动止盈计划，仅记录观察结果。"
    if article_type == "news":
        return "1R 减仓，2R 后转保护并跟踪事件后续延续性。"
    return "1R 分批减仓，2R 后启用保本和跟踪止盈。"


def _execution_mode(article_type: str, bias: str, confidence: float, freshness: float) -> str:
    if bias == "NEUTRAL":
        return "watch_only"
    if article_type == "education":
        return "watch_only"
    if confidence >= 0.74 and freshness >= 0.75:
        return "needs_manual_confirmation"
    if confidence >= 0.60:
        return "paper_trade"
    return "watch_only"


def _heuristic_candidate(article: dict[str, Any], symbol: str) -> dict[str, Any]:
    article_type = _classify_article_type(article)
    bias = _infer_bias(article)
    evidence = _extract_evidence(article)
    source_score = _source_score(str(article.get("source", "")))
    freshness = _freshness_score(str(article.get("published_at", "")))
    confidence = max(0.38, min(0.86, 0.42 + source_score * 0.22 + freshness * 0.18 + len(evidence) * 0.04))
    strategy_style = _infer_strategy_style(article_type, bias)
    time_horizon = _infer_time_horizon(article_type)
    return {
        "article_type": article_type,
        "symbol": symbol,
        "bias": bias,
        "strategy_name": _recommended_name(article, article_type, bias),
        "strategy_style": strategy_style,
        "time_horizon": time_horizon,
        "thesis": str(article.get("summary") or article.get("title") or "").strip(),
        "entry_condition": _entry_condition(article_type, bias),
        "invalid_condition": _invalid_condition(bias),
        "stop_loss_rule": _stop_loss_rule(article_type),
        "take_profit_rule": _take_profit_rule(article_type, bias),
        "risk_budget": {
            "max_risk_per_trade": 0.003 if article_type == "news" else 0.005,
            "max_attempts": 1 if article_type == "news" else 2,
        },
        "evidence": evidence,
        "confidence": round(confidence, 4),
        "freshness_score": round(freshness, 4),
        "source_score": round(source_score, 4),
        "execution_mode": _execution_mode(article_type, bias, confidence, freshness),
    }


def _extract_json_object(text: str) -> Optional[dict[str, Any]]:
    raw = str(text or "").strip()
    if not raw:
        return None
    if raw.startswith("```"):
        raw = re.sub(r"^```(?:json)?", "", raw).strip()
        raw = re.sub(r"```$", "", raw).strip()
    try:
        payload = json.loads(raw)
        return payload if isinstance(payload, dict) else None
    except Exception:
        match = re.search(r"\{[\s\S]*\}", raw)
        if not match:
            return None
        try:
            payload = json.loads(match.group(0))
            return payload if isinstance(payload, dict) else None
        except Exception:
            return None


def _default_llm_compiler(
    article: dict[str, Any],
    symbol: str,
    temp_model: str = "",
) -> tuple[Optional[dict[str, Any]], dict[str, Any]]:
    try:
        from src.llm import call_agent_llm
    except Exception as exc:
        return None, {
            "used": False,
            "degraded_reason": f"llm_import_failed:{type(exc).__name__}",
            "usage": {},
            "cost_usd": None,
            "retry_count": 0,
            "attempt_count": 1,
        }

    article_payload = {
        "url": article.get("url", ""),
        "source": article.get("source", ""),
        "title": article.get("title", ""),
        "published_at": article.get("published_at", ""),
        "summary": article.get("summary", ""),
        "content_excerpt": str(article.get("text", ""))[:6000],
        "symbol": symbol,
    }
    system_prompt = (
        "你是黄金/XAUUSD 策略编译器。"
        "你的职责是把文章内容编译成可治理的策略候选 JSON。"
        "不要输出 markdown，不要解释，只输出一个 JSON 对象。"
        "字段必须包含：article_type, bias, strategy_name, strategy_style, time_horizon, thesis, "
        "entry_condition, invalid_condition, stop_loss_rule, take_profit_rule, evidence, confidence, risk_budget。"
        "article_type 只能是 news/macro_analysis/opinion/education。"
        "bias 只能是 LONG/SHORT/NEUTRAL。"
        "time_horizon 只能是 scalp/intraday/swing/template。"
        "confidence 在 0 到 1 之间。"
        "risk_budget 只包含 max_risk_per_trade 和 max_attempts。"
        "如果文章不足以形成可交易机会，bias 返回 NEUTRAL，execution 倾向应体现在 thesis 和条件里。"
    )
    result = call_agent_llm(
        role="AI 策略开发师",
        system_prompt=system_prompt,
        user_prompt=json.dumps(article_payload, ensure_ascii=False, indent=2),
        temperature=0.2,
        max_tokens=900,
        timeout=90,
        response_format={"type": "json_object"},
        model_override=(str(temp_model or "").strip() or None),
    )
    if result.get("degraded"):
        return None, {
            "used": False,
            "degraded_reason": str(result.get("degraded_reason") or "llm_degraded"),
            "model": str(result.get("model", "")),
            "provider": str(result.get("provider", "")),
            "api_type": str(result.get("api_type", "")),
            "error_type": str(result.get("error_type", "")),
            "http_status": result.get("http_status"),
            "provider_error": str(result.get("provider_error", "")),
            "usage": result.get("usage", {}) if isinstance(result.get("usage", {}), dict) else {},
            "cost_usd": result.get("cost_usd"),
            "retry_count": int(result.get("retry_count", 0) or 0),
            "attempt_count": int(result.get("attempt_count", 1) or 1),
        }
    parsed = _extract_json_object(result.get("content", ""))
    if not parsed:
        return None, {
            "used": False,
            "degraded_reason": "llm_invalid_json",
            "model": str(result.get("model", "")),
            "provider": str(result.get("provider", "")),
            "api_type": str(result.get("api_type", "")),
            "error_type": "llm_invalid_json",
            "http_status": result.get("http_status"),
            "provider_error": "",
            "usage": result.get("usage", {}) if isinstance(result.get("usage", {}), dict) else {},
            "cost_usd": result.get("cost_usd"),
            "retry_count": int(result.get("retry_count", 0) or 0),
            "attempt_count": int(result.get("attempt_count", 1) or 1),
        }
    return parsed, {
        "used": True,
        "model": str(result.get("model", "")),
        "latency_ms": float(result.get("latency_ms", 0.0) or 0.0),
        "provider": str(result.get("provider", "")),
        "api_type": str(result.get("api_type", "")),
        "error_type": "",
        "http_status": result.get("http_status"),
        "provider_error": "",
        "usage": result.get("usage", {}) if isinstance(result.get("usage", {}), dict) else {},
        "cost_usd": result.get("cost_usd"),
        "retry_count": int(result.get("retry_count", 0) or 0),
        "attempt_count": int(result.get("attempt_count", 1) or 1),
    }


def _normalize_candidate(
    raw: dict[str, Any],
    fallback: dict[str, Any],
    article: dict[str, Any],
    symbol: str,
) -> dict[str, Any]:
    article_type = str(raw.get("article_type") or fallback["article_type"]).strip()
    if article_type not in ARTICLE_TYPES:
        article_type = fallback["article_type"]
    bias = str(raw.get("bias") or fallback["bias"]).strip().upper()
    if bias not in BIAS_VALUES:
        bias = fallback["bias"]
    time_horizon = str(raw.get("time_horizon") or fallback["time_horizon"]).strip().lower()
    if time_horizon not in TIME_HORIZONS:
        time_horizon = fallback["time_horizon"]
    evidence = raw.get("evidence", [])
    if not isinstance(evidence, list):
        evidence = fallback["evidence"]
    evidence = [str(item).strip() for item in evidence if str(item).strip()][:4] or fallback["evidence"]
    risk_budget = raw.get("risk_budget", {})
    if not isinstance(risk_budget, dict):
        risk_budget = {}
    max_risk = risk_budget.get("max_risk_per_trade", fallback["risk_budget"]["max_risk_per_trade"])
    max_attempts = risk_budget.get("max_attempts", fallback["risk_budget"]["max_attempts"])
    try:
        max_risk = float(max_risk)
    except Exception:
        max_risk = fallback["risk_budget"]["max_risk_per_trade"]
    try:
        max_attempts = int(max_attempts)
    except Exception:
        max_attempts = fallback["risk_budget"]["max_attempts"]
    confidence = raw.get("confidence", fallback["confidence"])
    try:
        confidence = float(confidence)
    except Exception:
        confidence = fallback["confidence"]
    confidence = max(0.0, min(1.0, confidence))
    freshness = fallback["freshness_score"]
    source_score = fallback["source_score"]
    candidate = {
        "url": str(article.get("url", "")),
        "source": str(article.get("source", "")),
        "article_title": str(article.get("title", "")),
        "published_at": str(article.get("published_at", "")),
        "article_summary": str(article.get("summary", "")),
        "article_type": article_type,
        "symbol": symbol,
        "bias": bias,
        "strategy_name": str(raw.get("strategy_name") or fallback["strategy_name"]).strip()
        or fallback["strategy_name"],
        "strategy_style": str(raw.get("strategy_style") or fallback["strategy_style"]).strip()
        or fallback["strategy_style"],
        "time_horizon": time_horizon,
        "thesis": str(raw.get("thesis") or fallback["thesis"]).strip() or fallback["thesis"],
        "entry_condition": str(raw.get("entry_condition") or fallback["entry_condition"]).strip()
        or fallback["entry_condition"],
        "invalid_condition": str(raw.get("invalid_condition") or fallback["invalid_condition"]).strip()
        or fallback["invalid_condition"],
        "stop_loss_rule": str(raw.get("stop_loss_rule") or fallback["stop_loss_rule"]).strip()
        or fallback["stop_loss_rule"],
        "take_profit_rule": str(raw.get("take_profit_rule") or fallback["take_profit_rule"]).strip()
        or fallback["take_profit_rule"],
        "risk_budget": {
            "max_risk_per_trade": round(max(0.001, min(0.02, max_risk)), 4),
            "max_attempts": max(1, min(3, max_attempts)),
        },
        "evidence": evidence,
        "confidence": round(confidence, 4),
        "freshness_score": round(float(freshness), 4),
        "source_score": round(float(source_score), 4),
    }
    candidate["execution_mode"] = _execution_mode(
        article_type,
        bias,
        candidate["confidence"],
        candidate["freshness_score"],
    )
    return candidate


def _timeframe_for_horizon(time_horizon: str) -> str:
    if time_horizon == "scalp":
        return "M1"
    if time_horizon == "swing":
        return "H1"
    if time_horizon == "template":
        return "M15"
    return "M5"


def _build_draft_document(name: str, candidate: dict[str, Any]) -> str:
    return "\n".join(
        [
            f"策略名称：{name}",
            f"来源文章：{candidate.get('article_title', '')}",
            f"文章来源：{candidate.get('source', '')}",
            f"文章链接：{candidate.get('url', '')}",
            f"发布时间：{candidate.get('published_at', '') or 'unknown'}",
            f"文章类型：{candidate.get('article_type', '')}",
            f"交易标的：{candidate.get('symbol', 'XAUUSD')}",
            f"方向偏置：{candidate.get('bias', 'NEUTRAL')}",
            f"交易风格：{candidate.get('strategy_style', '')}",
            f"时间尺度：{candidate.get('time_horizon', '')}",
            "",
            "市场假设：",
            str(candidate.get("thesis", "")),
            "",
            "入场规则：",
            str(candidate.get("entry_condition", "")),
            "",
            "失效条件：",
            str(candidate.get("invalid_condition", "")),
            "",
            "止损规则：",
            str(candidate.get("stop_loss_rule", "")),
            "",
            "止盈规则：",
            str(candidate.get("take_profit_rule", "")),
            "",
            "风险约束：",
            f"- 单笔最大风险：{candidate.get('risk_budget', {}).get('max_risk_per_trade', 0.0):.4f}",
            f"- 最大试错次数：{candidate.get('risk_budget', {}).get('max_attempts', 1)}",
            f"- 执行模式：{candidate.get('execution_mode', 'watch_only')}",
            "",
            "证据摘录：",
            *[f"- {item}" for item in candidate.get("evidence", [])],
        ]
    ).strip()


def _build_optimized_payload(name: str, candidate: dict[str, Any]) -> dict[str, Any]:
    confidence_pct = int(round(float(candidate.get("confidence", 0.0)) * 100))
    return {
        "strategy_name": name,
        "market_hypothesis": candidate.get("thesis", ""),
        "direction_bias": candidate.get("bias", "NEUTRAL"),
        "entry_rule": candidate.get("entry_condition", ""),
        "invalid_condition": candidate.get("invalid_condition", ""),
        "exit_rule": candidate.get("take_profit_rule", ""),
        "risk_constraints": {
            "max_position_size": candidate.get("risk_budget", {}).get("max_risk_per_trade", 0.005),
            "max_open_positions": 1,
            "min_confidence": confidence_pct,
            "execution_mode": candidate.get("execution_mode", "watch_only"),
        },
        "base_engine": "ema_macd",
        "params": {
            "timeframe": _timeframe_for_horizon(str(candidate.get("time_horizon", "intraday"))),
            "risk_multiplier": 0.8 if confidence_pct < 65 else 1.0,
            "sl_multiplier": 1.0,
            "tp_multiplier": 1.2 if candidate.get("bias") != "NEUTRAL" else 0.8,
        },
        "article_import": {
            "url": candidate.get("url", ""),
            "title": candidate.get("article_title", ""),
            "source": candidate.get("source", ""),
            "published_at": candidate.get("published_at", ""),
            "article_type": candidate.get("article_type", ""),
            "bias": candidate.get("bias", "NEUTRAL"),
            "strategy_style": candidate.get("strategy_style", ""),
            "time_horizon": candidate.get("time_horizon", ""),
            "summary": candidate.get("article_summary", ""),
            "confidence": candidate.get("confidence", 0.0),
            "freshness_score": candidate.get("freshness_score", 0.0),
            "source_score": candidate.get("source_score", 0.0),
            "evidence": candidate.get("evidence", []),
        },
    }


def import_article_as_strategy_draft(
    url: str,
    *,
    symbol: str = "XAUUSD",
    preferred_name: str = "",
    force_heuristic: bool = False,
    temp_model: str = "",
    fetcher: Optional[Callable[[str], dict[str, Any]]] = None,
    llm_compiler: Optional[
        Callable[[dict[str, Any], str, str], tuple[Optional[dict[str, Any]], dict[str, Any]]]
    ] = None,
) -> dict[str, Any]:
    clean_url = str(url or "").strip()
    if not clean_url:
        raise ValueError("缺少文章链接")
    if not re.match(r"^https?://", clean_url, flags=re.I):
        raise ValueError("仅支持 http/https 链接")

    fetched = (fetcher or _download_article_html)(clean_url)
    article = _extract_article_payload(str(fetched.get("url") or clean_url), str(fetched.get("html") or ""))
    fallback = _heuristic_candidate(article, symbol)

    llm_meta: dict[str, Any] = {"used": False}
    llm_candidate: Optional[dict[str, Any]] = None
    if not force_heuristic:
        compiler = llm_compiler or _default_llm_compiler
        llm_candidate, llm_meta = compiler(article, symbol, str(temp_model or "").strip())

    candidate = _normalize_candidate(llm_candidate or {}, fallback, article, symbol)
    name = str(preferred_name or candidate.get("strategy_name") or "").strip()
    if len(name) < 2:
        name = fallback["strategy_name"]
    document = _build_draft_document(name, candidate)
    optimized = _build_optimized_payload(name, candidate)
    return {
        "article": article,
        "candidate": candidate,
        "name": name,
        "document": document,
        "optimized": optimized,
        "llm": llm_meta,
    }


def _dedupe_urls(urls: list[str]) -> list[str]:
    result: list[str] = []
    seen: set[str] = set()
    for raw in urls:
        clean = str(raw or "").strip()
        if not clean or clean in seen:
            continue
        seen.add(clean)
        result.append(clean)
    return result


def _bias_numeric(bias: str) -> int:
    if bias == "LONG":
        return 1
    if bias == "SHORT":
        return -1
    return 0


def _consensus_from_score(weighted_score: float, article_count: int) -> tuple[str, str]:
    if article_count <= 0:
        return "NEUTRAL", "empty"
    normalized = weighted_score / max(1.0, float(article_count))
    if normalized >= 0.22:
        return "LONG", "aligned"
    if normalized <= -0.22:
        return "SHORT", "aligned"
    if normalized >= 0.09:
        return "LONG", "weak"
    if normalized <= -0.09:
        return "SHORT", "weak"
    if abs(normalized) <= 0.08:
        return "NEUTRAL", "conflicted"
    return "NEUTRAL", "mixed"


def _aggregate_article_pack(
    imports: list[dict[str, Any]],
    symbol: str,
    preferred_name: str,
) -> dict[str, Any]:
    if not imports:
        raise ValueError("没有可聚合的文章")

    weighted_score = 0.0
    weights_total = 0.0
    confidence_sum = 0.0
    freshness_sum = 0.0
    source_sum = 0.0
    evidence: list[str] = []
    evidence_seen: set[str] = set()
    bias_counts = {"LONG": 0, "SHORT": 0, "NEUTRAL": 0}
    type_counts: dict[str, int] = {}
    articles_meta: list[dict[str, Any]] = []

    for item in imports:
        candidate = item.get("candidate", {}) if isinstance(item.get("candidate", {}), dict) else {}
        article = item.get("article", {}) if isinstance(item.get("article", {}), dict) else {}
        bias = str(candidate.get("bias", "NEUTRAL")).upper()
        article_type = str(candidate.get("article_type", "macro_analysis"))
        bias_counts[bias if bias in bias_counts else "NEUTRAL"] += 1
        type_counts[article_type] = type_counts.get(article_type, 0) + 1
        confidence = float(candidate.get("confidence", 0.0) or 0.0)
        freshness = float(candidate.get("freshness_score", 0.0) or 0.0)
        source_score = float(candidate.get("source_score", 0.0) or 0.0)
        weight = max(0.1, confidence * 0.55 + freshness * 0.25 + source_score * 0.20)
        weighted_score += _bias_numeric(bias) * weight
        weights_total += weight
        confidence_sum += confidence
        freshness_sum += freshness
        source_sum += source_score
        for line in candidate.get("evidence", []):
            text = str(line).strip()
            if not text or text in evidence_seen:
                continue
            evidence_seen.add(text)
            evidence.append(text)
            if len(evidence) >= 6:
                break
        articles_meta.append(
            {
                "url": str(article.get("url", "")),
                "title": str(article.get("title", "")),
                "source": str(article.get("source", "")),
                "published_at": str(article.get("published_at", "")),
                "article_type": article_type,
                "bias": bias,
                "confidence": round(confidence, 4),
                "freshness_score": round(freshness, 4),
                "source_score": round(source_score, 4),
                "execution_mode": str(candidate.get("execution_mode", "watch_only")),
            }
        )

    article_count = len(imports)
    normalized_score = weighted_score / max(1.0, weights_total)
    consensus_bias, consensus_state = _consensus_from_score(normalized_score, article_count)
    avg_confidence = confidence_sum / article_count
    avg_freshness = freshness_sum / article_count
    avg_source_score = source_sum / article_count
    conflict_ratio = (
        max(bias_counts["LONG"], bias_counts["SHORT"]) / max(1, bias_counts["LONG"] + bias_counts["SHORT"])
        if (bias_counts["LONG"] + bias_counts["SHORT"]) > 0
        else 0.0
    )
    if bias_counts["LONG"] > 0 and bias_counts["SHORT"] > 0:
        conflict_degree = round(1.0 - conflict_ratio, 4)
    else:
        conflict_degree = 0.0

    dominant_type = max(type_counts.items(), key=lambda item: item[1])[0] if type_counts else "macro_analysis"
    style = "multi_source_macro_bundle"
    if dominant_type == "news":
        style = "event_consensus_bundle"
    elif dominant_type == "education":
        style = "template_bundle"

    thesis_parts: list[str] = []
    if consensus_bias == "LONG":
        thesis_parts.append("多篇文章加权后整体偏向黄金多头。")
    elif consensus_bias == "SHORT":
        thesis_parts.append("多篇文章加权后整体偏向黄金空头。")
    else:
        thesis_parts.append("多篇文章结论存在分歧，当前更适合作为观察型宏观包。")
    thesis_parts.append(
        f"样本共 {article_count} 篇，LONG={bias_counts['LONG']}、SHORT={bias_counts['SHORT']}、NEUTRAL={bias_counts['NEUTRAL']}。"
    )
    if conflict_degree > 0:
        thesis_parts.append(f"方向冲突度 {conflict_degree:.2f}，执行前需要更强技术确认。")

    consensus_confidence = max(
        0.35,
        min(
            0.9,
            avg_confidence * 0.55 + avg_freshness * 0.20 + avg_source_score * 0.15 + (1.0 - conflict_degree) * 0.10,
        ),
    )
    if consensus_bias == "NEUTRAL":
        consensus_confidence = min(consensus_confidence, 0.68)

    pack = {
        "symbol": symbol,
        "article_count": article_count,
        "bias_counts": bias_counts,
        "type_counts": type_counts,
        "consensus_bias": consensus_bias,
        "consensus_state": consensus_state,
        "conflict_degree": round(conflict_degree, 4),
        "weighted_direction_score": round(normalized_score, 4),
        "avg_confidence": round(avg_confidence, 4),
        "avg_freshness_score": round(avg_freshness, 4),
        "avg_source_score": round(avg_source_score, 4),
        "articles": articles_meta,
        "evidence": evidence[:6],
        "strategy_style": style,
        "time_horizon": "intraday" if dominant_type in {"news", "macro_analysis"} else "swing",
        "article_type": dominant_type,
        "execution_mode": _execution_mode(dominant_type, consensus_bias, consensus_confidence, avg_freshness),
        "thesis": " ".join(thesis_parts),
    }
    name = str(preferred_name or "").strip()
    if len(name) < 2:
        name = f"多源文章_{'看多' if consensus_bias == 'LONG' else '看空' if consensus_bias == 'SHORT' else '观察'}_宏观策略包"
    pack["strategy_name"] = name
    pack["confidence"] = round(consensus_confidence, 4)
    return pack


def _build_pack_document(pack: dict[str, Any]) -> str:
    articles = pack.get("articles", []) if isinstance(pack.get("articles", []), list) else []
    lines = [
        f"策略名称：{pack.get('strategy_name', '')}",
        f"交易标的：{pack.get('symbol', 'XAUUSD')}",
        f"聚合文章数：{pack.get('article_count', 0)}",
        f"共识方向：{pack.get('consensus_bias', 'NEUTRAL')}",
        f"共识状态：{pack.get('consensus_state', 'mixed')}",
        f"方向冲突度：{pack.get('conflict_degree', 0.0):.2f}",
        f"交易风格：{pack.get('strategy_style', '')}",
        f"时间尺度：{pack.get('time_horizon', '')}",
        "",
        "聚合结论：",
        str(pack.get("thesis", "")),
        "",
        "入场规则：",
        _entry_condition(str(pack.get("article_type", "macro_analysis")), str(pack.get("consensus_bias", "NEUTRAL"))),
        "",
        "失效条件：",
        _invalid_condition(str(pack.get("consensus_bias", "NEUTRAL"))),
        "",
        "止损规则：",
        _stop_loss_rule(str(pack.get("article_type", "macro_analysis"))),
        "",
        "止盈规则：",
        _take_profit_rule(str(pack.get("article_type", "macro_analysis")), str(pack.get("consensus_bias", "NEUTRAL"))),
        "",
        "风险约束：",
        f"- 单笔最大风险：{0.0035 if pack.get('conflict_degree', 0.0) > 0.25 else 0.0050:.4f}",
        f"- 最低执行置信度：{int(round(float(pack.get('confidence', 0.0)) * 100))}",
        f"- 执行模式：{pack.get('execution_mode', 'watch_only')}",
        "",
        "文章清单：",
    ]
    for item in articles:
        lines.append(
            f"- [{item.get('bias', 'NEUTRAL')}] {item.get('source', '')} | {item.get('title', '')} | conf={item.get('confidence', 0.0)}"
        )
    lines.extend(["", "核心证据："])
    for item in pack.get("evidence", []):
        lines.append(f"- {item}")
    return "\n".join(lines).strip()


def _build_pack_optimized_payload(pack: dict[str, Any]) -> dict[str, Any]:
    conflict_degree = float(pack.get("conflict_degree", 0.0) or 0.0)
    confidence_pct = int(round(float(pack.get("confidence", 0.0) or 0.0) * 100))
    return {
        "strategy_name": str(pack.get("strategy_name", "")),
        "market_hypothesis": str(pack.get("thesis", "")),
        "direction_bias": str(pack.get("consensus_bias", "NEUTRAL")),
        "entry_rule": _entry_condition(str(pack.get("article_type", "macro_analysis")), str(pack.get("consensus_bias", "NEUTRAL"))),
        "invalid_condition": _invalid_condition(str(pack.get("consensus_bias", "NEUTRAL"))),
        "exit_rule": _take_profit_rule(str(pack.get("article_type", "macro_analysis")), str(pack.get("consensus_bias", "NEUTRAL"))),
        "risk_constraints": {
            "max_position_size": 0.0035 if conflict_degree > 0.25 else 0.005,
            "max_open_positions": 1,
            "min_confidence": confidence_pct,
            "execution_mode": str(pack.get("execution_mode", "watch_only")),
            "conflict_degree": round(conflict_degree, 4),
        },
        "base_engine": "ema_macd",
        "params": {
            "timeframe": _timeframe_for_horizon(str(pack.get("time_horizon", "intraday"))),
            "risk_multiplier": 0.75 if conflict_degree > 0.25 else 1.0,
            "sl_multiplier": 1.1,
            "tp_multiplier": 1.2 if str(pack.get("consensus_bias", "NEUTRAL")) != "NEUTRAL" else 0.9,
        },
        "article_bundle": {
            "strategy_name": str(pack.get("strategy_name", "")),
            "symbol": str(pack.get("symbol", "XAUUSD")),
            "article_count": int(pack.get("article_count", 0) or 0),
            "consensus_bias": str(pack.get("consensus_bias", "NEUTRAL")),
            "consensus_state": str(pack.get("consensus_state", "mixed")),
            "strategy_style": str(pack.get("strategy_style", "")),
            "time_horizon": str(pack.get("time_horizon", "")),
            "confidence": round(float(pack.get("confidence", 0.0) or 0.0), 4),
            "conflict_degree": round(conflict_degree, 4),
            "weighted_direction_score": round(float(pack.get("weighted_direction_score", 0.0) or 0.0), 4),
            "avg_source_score": round(float(pack.get("avg_source_score", 0.0) or 0.0), 4),
            "avg_freshness_score": round(float(pack.get("avg_freshness_score", 0.0) or 0.0), 4),
            "avg_confidence": round(float(pack.get("avg_confidence", 0.0) or 0.0), 4),
            "bias_counts": pack.get("bias_counts", {}),
            "type_counts": pack.get("type_counts", {}),
            "articles": pack.get("articles", []),
            "evidence": pack.get("evidence", []),
        },
    }


def import_articles_as_strategy_pack(
    urls: list[str],
    *,
    symbol: str = "XAUUSD",
    preferred_name: str = "",
    force_heuristic: bool = False,
    temp_model: str = "",
    fetcher: Optional[Callable[[str], dict[str, Any]]] = None,
    llm_compiler: Optional[
        Callable[[dict[str, Any], str, str], tuple[Optional[dict[str, Any]], dict[str, Any]]]
    ] = None,
) -> dict[str, Any]:
    deduped_urls = _dedupe_urls(urls)
    if not deduped_urls:
        raise ValueError("请至少提供一个有效链接")
    imports: list[dict[str, Any]] = []
    llm_results: list[dict[str, Any]] = []
    for url in deduped_urls:
        imported = import_article_as_strategy_draft(
            url,
            symbol=symbol,
            force_heuristic=force_heuristic,
            temp_model=temp_model,
            fetcher=fetcher,
            llm_compiler=llm_compiler,
        )
        imports.append(imported)
        llm_meta = imported.get("llm", {}) if isinstance(imported.get("llm", {}), dict) else {}
        llm_results.append(
            {
                "url": url,
                "used": bool(llm_meta.get("used", False)),
                "model": str(llm_meta.get("model", "")),
                "degraded_reason": str(llm_meta.get("degraded_reason", "")),
            }
        )
    pack = _aggregate_article_pack(imports, symbol, preferred_name)
    document = _build_pack_document(pack)
    optimized = _build_pack_optimized_payload(pack)
    return {
        "pack": pack,
        "name": str(pack.get("strategy_name", "")),
        "document": document,
        "optimized": optimized,
        "imports": imports,
        "llm": {
            "used_count": sum(1 for item in llm_results if item.get("used")),
            "total_count": len(llm_results),
            "results": llm_results,
        },
    }
